From d71a23fa57084fed98fa4f723ba87e9d2b0adcd7 Mon Sep 17 00:00:00 2001 From: Mrxs <18278715334@163.com> Date: Fri, 19 Jun 2026 10:49:13 +0800 Subject: [PATCH] f --- config.yaml | 16 +- docs/查询白名单公开添加接口对接文档.md | 261 ++++++++++++++++++ .../api/dto/query_whitelist_dto.go | 15 + internal/application/api/errors.go | 8 + .../query_whitelist_application_service.go | 38 +-- .../api/query_whitelist_public_service.go | 213 ++++++++++++++ .../query_whitelist_public_service_test.go | 34 +++ internal/config/config.go | 12 + internal/container/container.go | 14 +- .../api/entities/query_whitelist_entry.go | 1 + .../public_query_whitelist_handler.go | 85 ++++++ .../routes/public_query_whitelist_routes.go | 35 +++ .../migrate_query_whitelist_operation_ip.sql | 3 + 13 files changed, 696 insertions(+), 39 deletions(-) create mode 100644 docs/查询白名单公开添加接口对接文档.md create mode 100644 internal/application/api/query_whitelist_public_service.go create mode 100644 internal/application/api/query_whitelist_public_service_test.go create mode 100644 internal/infrastructure/http/handlers/public_query_whitelist_handler.go create mode 100644 internal/infrastructure/http/routes/public_query_whitelist_routes.go create mode 100644 scripts/migrate_query_whitelist_operation_ip.sql diff --git a/config.yaml b/config.yaml index 423336c..a819546 100644 --- a/config.yaml +++ b/config.yaml @@ -8,6 +8,13 @@ app: # 子账号入口与主站可同域;邀请链接 {sub_portal_base_url}/sub/auth/register?invite=... sub_portal_base_url: "http://localhost:5173/" +# 查询白名单公开添加接口(面向下游调用方,与上游数据源配置无关) +query_whitelist: + public_api: + enabled: true + # 平台独立管理密钥,请求头 Whitelist-Mgmt-Key(48 位,见对接文档) + management_key: "2R12TmEc1e8P3p69RdIoN5Ykjk%H@4orPy7DZv7MXpGByoEL" + server: host: "0.0.0.0" port: "8080" @@ -330,7 +337,6 @@ westdex: max_backups: 5 max_age: 30 compress: true - # =========================================== # 🌍 羽山配置 # =========================================== @@ -364,7 +370,6 @@ yushan: max_backups: 5 max_age: 30 compress: true - # =========================================== # 💰 支付宝支付配置 # =========================================== @@ -447,7 +452,6 @@ zhicha: max_backups: 5 max_age: 30 compress: true - # =========================================== # 🌐 木子数据配置 # =========================================== @@ -509,7 +513,6 @@ xingwei: max_backups: 5 max_age: 30 compress: true - # =========================================== # ✨ 极光配置 # =========================================== @@ -545,7 +548,6 @@ jiguang: max_backups: 5 max_age: 30 compress: true - # =========================================== # 📄 PDF生成服务配置 # =========================================== @@ -608,7 +610,6 @@ shumai: max_backups: 5 max_age: 30 compress: true - # =========================================== # ✨ 数据宝配置走实时接口 # =========================================== @@ -641,7 +642,6 @@ shujubao: max_backups: 5 max_age: 30 compress: true - # =========================================== # ✨ 汇博(BHSC)配置 # =========================================== @@ -681,7 +681,6 @@ huibo: compress: true - # =========================================== # 🌐 诺尔智汇配置 # =========================================== @@ -713,7 +712,6 @@ nuoer: max_backups: 5 max_age: 30 compress: true - # =========================================== # 🌐 海宇API(上游数据源)配置 # =========================================== diff --git a/docs/查询白名单公开添加接口对接文档.md b/docs/查询白名单公开添加接口对接文档.md new file mode 100644 index 0000000..c3c9bd4 --- /dev/null +++ b/docs/查询白名单公开添加接口对接文档.md @@ -0,0 +1,261 @@ +# 查询白名单公开添加接口 — 对接文档 + +> 面向**下游调用方**(API 用户 / 合作方),与上游数据源配置无关。 +> 平台侧仅在 `config.yaml` 顶部保留**一份** `query_whitelist.public_api` 配置。 + +--- + +## 1. 接口概述 + +| 项 | 说明 | +|----|------| +| 地址 | `POST /api/v1/query-whitelist/entries` | +| 用途 | 为**当前 access_id 对应用户**添加查询白名单规则 | +| 生效范围 | **仅对该用户生效**(`user_id` 自动绑定,不可创建全局规则) | +| 计费 | 否 | + +命中白名单后,对应 API 调用将返回 `1000 查询为空`,不调用上游、不扣费。 + +--- + +## 2. 鉴权(双重 + IP) + +### 2.1 请求头 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `Access-Id` | string | 是 | 目标 API 用户的 Access-Id | +| `Whitelist-Mgmt-Key` | string | 是 | 平台下发的**独立管理密钥**(与 Access Key 不同) | +| `Content-Type` | string | 是 | 固定为 `application/json` | + +### 2.2 请求体(加密) + +与业务 API 相同,外层仅传 `data` 字段: + +```json +{ + "data": "" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `data` | string | 是 | 业务参数的 AES-128-CBC 密文(Base64 编码) | + +使用目标用户的 **Access Key**(16 进制 Secret Key)加密。Access Key **仅用于本地加密,不要写入明文 JSON**。 + +### 2.3 明文业务参数(加密前 JSON) + +**无需在明文里传 `key` 或 `access_id`**。身份校验方式与业务 API 相同:请求头携带 `Access-Id`,服务端用该用户 Access Key 解密 `data`,解密成功即证明调用方持有正确密钥。 + +```json +{ + "name": "*", + "id_card": "350681198611130611", + "api_codes": ["FLXG0V4B", "JRZQ8A2D"], + "remark": "可选备注" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 姓名;填 `*` 表示只匹配身份证,不校验姓名 | +| `id_card` | string | 是 | 18 位身份证号 | +| `api_codes` | string[] | 是 | 生效的产品编码列表;**必须为 JSON 数组**,至少 1 个元素;元素类型为 string;**禁止**传字符串/对象等非数组类型;**禁止**包含通配符 `*`(不可写 `["*"]` 或与其他编码混用) | + +> **`api_codes` 约束(公开接口专属)** +> +> | 传参方式 | 是否允许 | 示例 | +> |----------|----------|------| +> | 字符串数组 | ✅ | `["FLXG0V4B", "JRZQ8A2D"]` | +> | 单个字符串 | ❌ | `"FLXG0V4B"` | +> | 通配全部 `*` | ❌ | `["*"]` | +> | 空数组 | ❌ | `[]` | +> | 非字符串元素 | ❌ | `[123]`、`[{}]` | +| `remark` | string | 否 | 备注,最长 500 字符 | + +### 2.4 IP 白名单 + +生产环境下,调用方 IP 须在该用户控制台配置的 IP 白名单内(与业务 API 一致)。开发环境(`app.env=development`)跳过。 + +--- + +## 3. 加密算法 + +与业务 API 完全一致(AES-128-CBC + PKCS7 + 随机 IV): + +1. Access Key 为 16 进制字符串,解码为 16 字节密钥 +2. 随机生成 16 字节 IV,拼在密文前 +3. 整体 Base64 编码后放入 `data` + +参考示例:`tyapi-frontend/public/examples/nodejs/demo.js` + +--- + +## 4. 响应格式 + +与业务 API 一致: + +```json +{ + "code": 0, + "message": "业务成功", + "transaction_id": "uuid", + "data": "" +} +``` + +`data` 解密后为: + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "is_global": false, + "name": "*", + "id_card_masked": "350681********0611", + "api_codes": ["FLXG0V4B", "JRZQ8A2D"], + "status": "enabled", + "remark": "", + "operation_ip": "203.0.113.10", + "created_at": "2026-06-18T10:00:00+08:00", + "updated_at": "2026-06-18T10:00:00+08:00" +} +``` + +`operation_ip` 为本次添加时的客户端 IP,便于审计。 + +--- + +## 5. 错误码 + +| code | message | 说明 | +|------|---------|------| +| 0 | 业务成功 | 创建成功 | +| 1002 | 解密失败 | `data` 解密失败 | +| 1003 | 请求参数结构不正确 | 明文参数缺失或格式错误 | +| 1004 | 未经授权的IP | IP 不在白名单 | +| 1005 | 缺少Access-Id | 请求头缺失 | +| 1006 | 未经授权的AccessId | Access-Id 无效、解密失败或账户冻结 | +| 1010 | 缺少管理密钥 | 未传 `Whitelist-Mgmt-Key` | +| 1011 | 管理密钥无效 | 管理密钥错误 | +| 1012 | 公开接口未启用 | 服务端 `public_api.enabled=false` | +| 1013 | 规则已存在 | 同用户下身份证+姓名重复 | +| 1001 | 接口异常 | 系统错误 | + +--- + +## 6. Node.js 调用示例 + +```javascript +const crypto = require('crypto'); + +const BASE_URL = 'https://api.tianyuanapi.com'; +const ACCESS_ID = process.env.ACCESS_ID; +const ACCESS_KEY = process.env.ACCESS_KEY; // 用户 Access Key +const MGMT_KEY = '2R12TmEc1e8P3p69RdIoN5Ykjk%H@4orPy7DZv7MXpGByoEL'; // 平台管理密钥,见文档 §7 + +function encrypt(data, keyHex) { + const key = Buffer.from(keyHex, 'hex'); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); + cipher.setAutoPadding(true); + let enc = cipher.update(data, 'utf8'); + enc = Buffer.concat([iv, enc, cipher.final()]); + return enc.toString('base64'); +} + +function decrypt(data, keyHex) { + const key = Buffer.from(keyHex, 'hex'); + const buf = Buffer.from(data, 'base64'); + const iv = buf.slice(0, 16); + const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); + decipher.setAutoPadding(true); + let dec = decipher.update(buf.slice(16)); + dec = Buffer.concat([dec, decipher.final()]); + return dec.toString('utf8'); +} + +async function addWhitelistEntry() { + const payload = { + name: '*', + id_card: '350681198611130611', + api_codes: ['FLXG0V4B', 'FLXG2E8F', 'JRZQ8A2D'], + remark: '外部系统添加', + }; + + const res = await fetch(`${BASE_URL}/api/v1/query-whitelist/entries`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Access-Id': ACCESS_ID, + 'Whitelist-Mgmt-Key': MGMT_KEY, + }, + body: JSON.stringify({ data: encrypt(JSON.stringify(payload), ACCESS_KEY) }), + }); + + const json = await res.json(); + console.log('响应:', json); + if (json.code === 0 && json.data) { + console.log('规则详情:', JSON.parse(decrypt(json.data, ACCESS_KEY))); + } +} + +addWhitelistEntry(); +``` + +--- + +## 7. 平台配置(仅一份) + +`config.yaml` 顶部(`app` 段之后),**不要**写在上游数据源配置块内: + +```yaml +query_whitelist: + public_api: + enabled: true + management_key: "2R12TmEc1e8P3p69RdIoN5Ykjk%H@4orPy7DZv7MXpGByoEL" +``` + +| 配置项 | 类型 | 说明 | +|--------|------|------| +| `enabled` | bool | 是否启用公开接口 | +| `management_key` | string | 平台独立管理密钥(当前 **48 位**),调用时放入请求头 `Whitelist-Mgmt-Key` | + +**当前环境管理密钥(`Whitelist-Mgmt-Key`):** + +``` +2R12TmEc1e8P3p69RdIoN5Ykjk%H@4orPy7DZv7MXpGByoEL +``` + +> 该密钥与下游用户的 Access Key 无关,仅用于授权调用本公开接口。生产环境轮换密钥后请同步更新本文档与 `config.yaml`。 + +--- + +## 8. 字段说明补充 + +### `name = *` + +通配姓名:只要请求中的**身份证号**与本规则一致,无论传入什么姓名都会命中并返回「查询为空」。 + +### `name = 具体姓名` + +须身份证 + 姓名**同时一致**才命中。 + +### 用户隔离 + +使用谁的 `Access-Id` 调用(请求头),规则就只对该用户(`user_id`)下的 API 调用生效,不会影响其他用户,也不能创建 `user_id=*` 的全局规则。 + +--- + +## 9. 数据库变更 + +新增字段 `operation_ip`(记录公开接口添加时的客户端 IP): + +```sql +ALTER TABLE query_whitelist_entries + ADD COLUMN IF NOT EXISTS operation_ip VARCHAR(64) DEFAULT ''; +``` + +脚本:`scripts/migrate_query_whitelist_operation_ip.sql` +若 `auto_migrate: true`,GORM 也会自动加列。 diff --git a/internal/application/api/dto/query_whitelist_dto.go b/internal/application/api/dto/query_whitelist_dto.go index 46b794e..e3b47be 100644 --- a/internal/application/api/dto/query_whitelist_dto.go +++ b/internal/application/api/dto/query_whitelist_dto.go @@ -34,6 +34,7 @@ type QueryWhitelistEntryResponse struct { APICodes []string `json:"api_codes"` Status string `json:"status"` Remark string `json:"remark"` + OperationIP string `json:"operation_ip,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -51,6 +52,19 @@ type QueryWhitelistImportLegacyResponse struct { Total int `json:"total"` } +// QueryWhitelistPublicEncryptedRequest 公开接口外层请求(data 为 AES 密文) +type QueryWhitelistPublicEncryptedRequest struct { + Data string `json:"data" binding:"required"` +} + +// QueryWhitelistPublicPayload 公开接口解密后的业务参数(不含 key/access_id,身份由请求头 + 解密成功证明) +type QueryWhitelistPublicPayload struct { + Name string `json:"name"` + IDCard string `json:"id_card"` + APICodes []string `json:"api_codes"` + Remark string `json:"remark"` +} + func NewQueryWhitelistEntryResponse(entry *entities.QueryWhitelistEntry) QueryWhitelistEntryResponse { apiCodes := []string(entry.APICodes) if apiCodes == nil { @@ -65,6 +79,7 @@ func NewQueryWhitelistEntryResponse(entry *entities.QueryWhitelistEntry) QueryWh APICodes: apiCodes, Status: entry.Status, Remark: entry.Remark, + OperationIP: entry.OperationIP, CreatedAt: entry.CreatedAt, UpdatedAt: entry.UpdatedAt, } diff --git a/internal/application/api/errors.go b/internal/application/api/errors.go index 68ca8b4..ccaceea 100644 --- a/internal/application/api/errors.go +++ b/internal/application/api/errors.go @@ -25,6 +25,10 @@ var ( ErrBusiness = errors.New("业务失败") ErrSubordinateLinkNotFound = errors.New("非子账号,无法使用master_accessid") ErrSubordinateParentMismatch = errors.New("master_accessid与主账号不匹配") + ErrMissingMgmtKey = errors.New("缺少管理密钥") + ErrInvalidMgmtKey = errors.New("管理密钥无效") + ErrPublicAPIDisabled = errors.New("公开接口未启用") + ErrWhitelistExists = errors.New("规则已存在") ) // 错误码映射 - 严格按照用户要求 @@ -50,6 +54,10 @@ var ErrorCodeMap = map[error]int{ ErrBusiness: 2001, ErrSubordinateLinkNotFound: 1301, ErrSubordinateParentMismatch: 1302, + ErrMissingMgmtKey: 1010, + ErrInvalidMgmtKey: 1011, + ErrPublicAPIDisabled: 1012, + ErrWhitelistExists: 1013, } // GetErrorCode 获取错误对应的错误码 diff --git a/internal/application/api/query_whitelist_application_service.go b/internal/application/api/query_whitelist_application_service.go index 9b07f33..69ba03e 100644 --- a/internal/application/api/query_whitelist_application_service.go +++ b/internal/application/api/query_whitelist_application_service.go @@ -7,6 +7,7 @@ import ( "strings" "tyapi-server/internal/application/api/dto" + "tyapi-server/internal/config" "tyapi-server/internal/domains/api/entities" "tyapi-server/internal/domains/api/repositories" api_services "tyapi-server/internal/domains/api/services" @@ -18,6 +19,7 @@ import ( type QueryWhitelistApplicationService interface { CreateEntry(ctx context.Context, adminUserID string, req *dto.QueryWhitelistEntryRequest) (*dto.QueryWhitelistEntryResponse, error) + CreateEntryPublic(ctx context.Context, headerAccessID, managementKey, clientIP, encryptedData string) (*dto.QueryWhitelistEntryResponse, string, error) UpdateEntry(ctx context.Context, adminUserID, id string, req *dto.QueryWhitelistEntryUpdateRequest) (*dto.QueryWhitelistEntryResponse, error) UpdateEntryStatus(ctx context.Context, adminUserID, id, status string) (*dto.QueryWhitelistEntryResponse, error) DeleteEntry(ctx context.Context, id string) error @@ -29,17 +31,23 @@ type QueryWhitelistApplicationService interface { type QueryWhitelistApplicationServiceImpl struct { repo repositories.QueryWhitelistRepository queryWhitelistSvc api_services.QueryWhitelistService + apiUserService api_services.ApiUserAggregateService + config *config.Config logger *zap.Logger } func NewQueryWhitelistApplicationService( repo repositories.QueryWhitelistRepository, queryWhitelistSvc api_services.QueryWhitelistService, + apiUserService api_services.ApiUserAggregateService, + config *config.Config, logger *zap.Logger, ) QueryWhitelistApplicationService { return &QueryWhitelistApplicationServiceImpl{ repo: repo, queryWhitelistSvc: queryWhitelistSvc, + apiUserService: apiUserService, + config: config, logger: logger, } } @@ -49,35 +57,7 @@ func (s *QueryWhitelistApplicationServiceImpl) CreateEntry( adminUserID string, req *dto.QueryWhitelistEntryRequest, ) (*dto.QueryWhitelistEntryResponse, error) { - if err := validateQueryWhitelistRequest(req.UserID, req.Name, req.IDCard, req.APICodes); err != nil { - return nil, err - } - - idCardHash := api_services.HashIDCard(req.IDCard) - exists, err := s.repo.ExistsByUserIDCardHashAndName(ctx, req.UserID, idCardHash, strings.TrimSpace(req.Name), "") - if err != nil { - return nil, err - } - if exists { - return nil, fmt.Errorf("该用户下已存在相同的身份证与姓名规则") - } - - entry := &entities.QueryWhitelistEntry{ - UserID: strings.TrimSpace(req.UserID), - Name: normalizeWhitelistName(req.Name), - IDCardHash: idCardHash, - IDCardMasked: api_services.MaskIDCard(req.IDCard), - APICodes: entities.APICodeList(req.APICodes), - Status: entities.QueryWhitelistStatusEnabled, - Remark: strings.TrimSpace(req.Remark), - CreatedBy: &adminUserID, - } - if err := s.repo.Create(ctx, entry); err != nil { - return nil, err - } - s.queryWhitelistSvc.InvalidateCache(entry.UserID, idCardHash) - resp := dto.NewQueryWhitelistEntryResponse(entry) - return &resp, nil + return s.createEntry(ctx, adminUserID, req, "") } func (s *QueryWhitelistApplicationServiceImpl) UpdateEntry( diff --git a/internal/application/api/query_whitelist_public_service.go b/internal/application/api/query_whitelist_public_service.go new file mode 100644 index 0000000..2756656 --- /dev/null +++ b/internal/application/api/query_whitelist_public_service.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "strings" + + "tyapi-server/internal/application/api/dto" + "tyapi-server/internal/config" + "tyapi-server/internal/domains/api/entities" + api_services "tyapi-server/internal/domains/api/services" + "tyapi-server/internal/shared/crypto" + "fmt" + + "go.uber.org/zap" +) + +const queryWhitelistMgmtKeyHeader = "Whitelist-Mgmt-Key" + +// QueryWhitelistMgmtKeyHeader 公开接口管理密钥请求头名 +func QueryWhitelistMgmtKeyHeader() string { + return queryWhitelistMgmtKeyHeader +} + +// CreateEntryPublic 公开接口:解密业务参数后为当前 access_id 对应用户添加规则(仅对该用户生效) +func (s *QueryWhitelistApplicationServiceImpl) CreateEntryPublic( + ctx context.Context, + headerAccessID, managementKey, clientIP, encryptedData string, +) (*dto.QueryWhitelistEntryResponse, string, error) { + if !s.config.QueryWhitelist.PublicAPI.Enabled { + return nil, "", ErrPublicAPIDisabled + } + if strings.TrimSpace(managementKey) == "" { + return nil, "", ErrMissingMgmtKey + } + if !constantTimeEqual(managementKey, s.config.QueryWhitelist.PublicAPI.ManagementKey) { + return nil, "", ErrInvalidMgmtKey + } + headerAccessID = strings.TrimSpace(headerAccessID) + if headerAccessID == "" { + return nil, "", ErrMissingAccessId + } + + apiUser, err := s.apiUserService.LoadApiUserByAccessId(ctx, headerAccessID) + if err != nil { + s.logger.Warn("公开白名单接口 AccessId 无效", zap.String("access_id", headerAccessID), zap.Error(err)) + return nil, "", ErrInvalidAccessId + } + if apiUser.IsFrozen() { + return nil, "", ErrFrozenAccount + } + + decrypted, err := crypto.AesDecrypt(encryptedData, apiUser.SecretKey) + if err != nil { + s.logger.Warn("公开白名单接口解密失败", zap.String("access_id", headerAccessID), zap.Error(err)) + return nil, "", ErrDecryptFail + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(decrypted, &raw); err != nil { + return nil, "", ErrRequestParam + } + if err := validatePublicAPICodesField(raw["api_codes"]); err != nil { + return nil, "", MapWhitelistAppError(err) + } + + var payload dto.QueryWhitelistPublicPayload + if err := json.Unmarshal(decrypted, &payload); err != nil { + return nil, "", ErrRequestParam + } + + if !s.config.App.IsDevelopment() && !apiUser.IsWhiteListed(clientIP) { + s.logger.Warn("公开白名单接口 IP 未授权", + zap.String("access_id", headerAccessID), + zap.String("client_ip", clientIP)) + return nil, "", ErrInvalidIP + } + + createdBy := "public_api:" + headerAccessID + resp, err := s.createEntry(ctx, createdBy, &dto.QueryWhitelistEntryRequest{ + UserID: apiUser.UserId, + Name: payload.Name, + IDCard: payload.IDCard, + APICodes: payload.APICodes, + Remark: payload.Remark, + }, clientIP) + return resp, apiUser.SecretKey, MapWhitelistAppError(err) +} + +func (s *QueryWhitelistApplicationServiceImpl) createEntry( + ctx context.Context, + createdBy string, + req *dto.QueryWhitelistEntryRequest, + operationIP string, +) (*dto.QueryWhitelistEntryResponse, error) { + if err := validateQueryWhitelistRequest(req.UserID, req.Name, req.IDCard, req.APICodes); err != nil { + return nil, err + } + + idCardHash := api_services.HashIDCard(req.IDCard) + exists, err := s.repo.ExistsByUserIDCardHashAndName(ctx, req.UserID, idCardHash, strings.TrimSpace(req.Name), "") + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("该用户下已存在相同的身份证与姓名规则") + } + + entry := &entities.QueryWhitelistEntry{ + UserID: strings.TrimSpace(req.UserID), + Name: normalizeWhitelistName(req.Name), + IDCardHash: idCardHash, + IDCardMasked: api_services.MaskIDCard(req.IDCard), + APICodes: entities.APICodeList(req.APICodes), + Status: entities.QueryWhitelistStatusEnabled, + Remark: strings.TrimSpace(req.Remark), + OperationIP: strings.TrimSpace(operationIP), + CreatedBy: &createdBy, + } + if err := s.repo.Create(ctx, entry); err != nil { + return nil, err + } + s.queryWhitelistSvc.InvalidateCache(entry.UserID, idCardHash) + resp := dto.NewQueryWhitelistEntryResponse(entry) + return &resp, nil +} + +func constantTimeEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// validatePublicAPICodesField 公开接口:api_codes 必须为 string 数组,且禁止通配符 * +func validatePublicAPICodesField(raw json.RawMessage) error { + if len(raw) == 0 { + return fmt.Errorf("api_codes 必填且必须为 string 数组") + } + var probe interface{} + if err := json.Unmarshal(raw, &probe); err != nil { + return fmt.Errorf("api_codes 必须为 string 数组") + } + arr, ok := probe.([]interface{}) + if !ok { + return fmt.Errorf("api_codes 必须为 string 数组,不可传字符串或其他类型") + } + if len(arr) == 0 { + return fmt.Errorf("api_codes 不能为空") + } + codes := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + return fmt.Errorf("api_codes 数组元素必须为 string") + } + code := strings.TrimSpace(s) + if code == "" { + return fmt.Errorf("api_codes 不能包含空值") + } + if code == "*" { + return fmt.Errorf("公开接口不允许 api_codes 使用通配符 *") + } + codes = append(codes, code) + } + return validateAPICodes(codes) +} + +// ValidatePublicAPIManagementKey 校验平台管理密钥 +func ValidatePublicAPIManagementKey(cfg *config.Config, key string) error { + if !cfg.QueryWhitelist.PublicAPI.Enabled { + return ErrPublicAPIDisabled + } + if strings.TrimSpace(key) == "" { + return ErrMissingMgmtKey + } + if !constantTimeEqual(key, cfg.QueryWhitelist.PublicAPI.ManagementKey) { + return ErrInvalidMgmtKey + } + return nil +} + +// MapWhitelistAppError 将校验错误映射为公开 API 错误码 +func MapWhitelistAppError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case strings.Contains(msg, "身份证号格式不正确"), + strings.Contains(msg, "api_codes"), + strings.Contains(msg, "name 不能为空"), + strings.Contains(msg, "user_id 不能为空"): + return ErrRequestParam + case strings.Contains(msg, "已存在"): + return ErrWhitelistExists + default: + return err + } +} + +// PublicAPIErrorMessage 公开接口错误文案 +func PublicAPIErrorMessage(err error) string { + if err == nil { + return "" + } + switch err { + case ErrWhitelistExists: + return "规则已存在" + case ErrRequestParam: + return "请求参数结构不正确" + default: + return GetErrorMessage(err) + } +} diff --git a/internal/application/api/query_whitelist_public_service_test.go b/internal/application/api/query_whitelist_public_service_test.go new file mode 100644 index 0000000..84bbc40 --- /dev/null +++ b/internal/application/api/query_whitelist_public_service_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestValidatePublicAPICodesField(t *testing.T) { + tests := []struct { + name string + raw string + wantErr bool + }{ + {name: "valid array", raw: `["FLXG0V4B","JRZQ8A2D"]`}, + {name: "string not array", raw: `"FLXG0V4B"`, wantErr: true}, + {name: "wildcard star", raw: `["*"]`, wantErr: true}, + {name: "star mixed", raw: `["FLXG0V4B","*"]`, wantErr: true}, + {name: "empty array", raw: `[]`, wantErr: true}, + {name: "missing field", raw: "", wantErr: true}, + {name: "number element", raw: `[123]`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var raw json.RawMessage + if tt.raw != "" { + raw = json.RawMessage(tt.raw) + } + err := validatePublicAPICodesField(raw) + if (err != nil) != tt.wantErr { + t.Fatalf("validatePublicAPICodesField() err=%v wantErr=%v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f8fc1d0..78198e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,18 @@ type Config struct { Huibo HuiboConfig `mapstructure:"huibo"` Nuoer NuoerConfig `mapstructure:"nuoer"` Haiyuapi HaiyuapiConfig `mapstructure:"haiyuapi"` + QueryWhitelist QueryWhitelistConfig `mapstructure:"query_whitelist"` +} + +// QueryWhitelistConfig 查询白名单配置 +type QueryWhitelistConfig struct { + PublicAPI QueryWhitelistPublicAPIConfig `mapstructure:"public_api"` +} + +// QueryWhitelistPublicAPIConfig 查询白名单公开添加接口配置 +type QueryWhitelistPublicAPIConfig struct { + Enabled bool `mapstructure:"enabled"` + ManagementKey string `mapstructure:"management_key"` } // ServerConfig HTTP服务器配置 diff --git a/internal/container/container.go b/internal/container/container.go index 2548366..8cb61a9 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -813,7 +813,15 @@ func NewContainer() *Container { // API域应用服务 fx.Provide( fx.Annotate( - api_app.NewQueryWhitelistApplicationService, + func( + repo domain_api_repo.QueryWhitelistRepository, + queryWhitelistSvc api_services.QueryWhitelistService, + apiUserService api_services.ApiUserAggregateService, + cfg *config.Config, + logger *zap.Logger, + ) api_app.QueryWhitelistApplicationService { + return api_app.NewQueryWhitelistApplicationService(repo, queryWhitelistSvc, apiUserService, cfg, logger) + }, fx.As(new(api_app.QueryWhitelistApplicationService)), ), // API应用服务 - 绑定到接口 @@ -1321,6 +1329,7 @@ func NewContainer() *Container { // 管理员安全HTTP处理器 handlers.NewAdminSecurityHandler, handlers.NewAdminQueryWhitelistHandler, + handlers.NewPublicQueryWhitelistHandler, // 文章HTTP处理器 func( appService article.ArticleApplicationService, @@ -1416,6 +1425,7 @@ func NewContainer() *Container { // 管理员安全路由 routes.NewAdminSecurityRoutes, routes.NewAdminQueryWhitelistRoutes, + routes.NewPublicQueryWhitelistRoutes, // PDFG路由 routes.NewPDFGRoutes, // 企业报告页面路由 @@ -1537,6 +1547,7 @@ func RegisterRoutes( statisticsRoutes *routes.StatisticsRoutes, adminSecurityRoutes *routes.AdminSecurityRoutes, adminQueryWhitelistRoutes *routes.AdminQueryWhitelistRoutes, + publicQueryWhitelistRoutes *routes.PublicQueryWhitelistRoutes, pdfgRoutes *routes.PDFGRoutes, qyglReportRoutes *routes.QYGLReportRoutes, jwtAuth *middleware.JWTAuthMiddleware, @@ -1566,6 +1577,7 @@ func RegisterRoutes( statisticsRoutes.Register(router) adminSecurityRoutes.Register(router) adminQueryWhitelistRoutes.Register(router) + publicQueryWhitelistRoutes.Register(router) pdfgRoutes.Register(router) qyglReportRoutes.Register(router) diff --git a/internal/domains/api/entities/query_whitelist_entry.go b/internal/domains/api/entities/query_whitelist_entry.go index ad7bc4a..d8bfdf6 100644 --- a/internal/domains/api/entities/query_whitelist_entry.go +++ b/internal/domains/api/entities/query_whitelist_entry.go @@ -63,6 +63,7 @@ type QueryWhitelistEntry struct { APICodes APICodeList `gorm:"type:json;not null" json:"api_codes"` Status string `gorm:"type:varchar(20);not null;default:'enabled'" json:"status"` Remark string `gorm:"type:varchar(500)" json:"remark"` + OperationIP string `gorm:"type:varchar(64)" json:"operation_ip"` CreatedBy *string `gorm:"type:varchar(36)" json:"created_by,omitempty"` UpdatedBy *string `gorm:"type:varchar(36)" json:"updated_by,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/infrastructure/http/handlers/public_query_whitelist_handler.go b/internal/infrastructure/http/handlers/public_query_whitelist_handler.go new file mode 100644 index 0000000..5e3464c --- /dev/null +++ b/internal/infrastructure/http/handlers/public_query_whitelist_handler.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "encoding/json" + + api_app "tyapi-server/internal/application/api" + "tyapi-server/internal/application/api/dto" + "tyapi-server/internal/shared/crypto" + "tyapi-server/internal/shared/interfaces" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// PublicQueryWhitelistHandler 查询白名单公开添加接口(面向下游调用方) +type PublicQueryWhitelistHandler struct { + appService api_app.QueryWhitelistApplicationService + validator interfaces.RequestValidator + logger *zap.Logger +} + +func NewPublicQueryWhitelistHandler( + appService api_app.QueryWhitelistApplicationService, + validator interfaces.RequestValidator, + logger *zap.Logger, +) *PublicQueryWhitelistHandler { + return &PublicQueryWhitelistHandler{ + appService: appService, + validator: validator, + logger: logger, + } +} + +// CreateEntry 公开添加查询白名单规则 +func (h *PublicQueryWhitelistHandler) CreateEntry(c *gin.Context) { + accessID := c.GetHeader("Access-Id") + if accessID == "" { + c.JSON(200, dto.NewErrorResponse(1005, "缺少Access-Id", "")) + return + } + + mgmtKey := c.GetHeader(api_app.QueryWhitelistMgmtKeyHeader()) + if mgmtKey == "" { + c.JSON(200, dto.NewErrorResponse(api_app.GetErrorCode(api_app.ErrMissingMgmtKey), "缺少管理密钥", "")) + return + } + + var req dto.QueryWhitelistPublicEncryptedRequest + if err := h.validator.BindAndValidate(c, &req); err != nil { + c.JSON(200, dto.NewErrorResponse(1003, "请求参数结构不正确", "")) + return + } + + transactionID := uuid.New().String() + result, secretKey, err := h.appService.CreateEntryPublic( + c.Request.Context(), + accessID, + mgmtKey, + c.ClientIP(), + req.Data, + ) + if err != nil { + code := api_app.GetErrorCode(err) + message := api_app.PublicAPIErrorMessage(err) + c.JSON(200, dto.NewErrorResponse(code, message, transactionID)) + return + } + + respBytes, err := json.Marshal(result) + if err != nil { + h.logger.Error("序列化公开白名单响应失败", zap.Error(err)) + c.JSON(200, dto.NewErrorResponse(1001, "接口异常", transactionID)) + return + } + + encrypted, err := crypto.AesEncrypt(respBytes, secretKey) + if err != nil { + h.logger.Error("加密公开白名单响应失败", zap.Error(err)) + c.JSON(200, dto.NewErrorResponse(1001, "接口异常", transactionID)) + return + } + + c.JSON(200, dto.NewSuccessResponse(transactionID, encrypted)) +} diff --git a/internal/infrastructure/http/routes/public_query_whitelist_routes.go b/internal/infrastructure/http/routes/public_query_whitelist_routes.go new file mode 100644 index 0000000..16b67eb --- /dev/null +++ b/internal/infrastructure/http/routes/public_query_whitelist_routes.go @@ -0,0 +1,35 @@ +package routes + +import ( + "tyapi-server/internal/infrastructure/http/handlers" + sharedhttp "tyapi-server/internal/shared/http" + "tyapi-server/internal/shared/middleware" + + "go.uber.org/zap" +) + +// PublicQueryWhitelistRoutes 查询白名单公开接口路由(面向下游调用方) +type PublicQueryWhitelistRoutes struct { + handler *handlers.PublicQueryWhitelistHandler + domainAuthMiddleware *middleware.DomainAuthMiddleware + logger *zap.Logger +} + +func NewPublicQueryWhitelistRoutes( + handler *handlers.PublicQueryWhitelistHandler, + domainAuthMiddleware *middleware.DomainAuthMiddleware, + logger *zap.Logger, +) *PublicQueryWhitelistRoutes { + return &PublicQueryWhitelistRoutes{ + handler: handler, + domainAuthMiddleware: domainAuthMiddleware, + logger: logger, + } +} + +func (r *PublicQueryWhitelistRoutes) Register(router *sharedhttp.GinRouter) { + group := router.GetEngine().Group("/api/v1/query-whitelist") + group.Use(r.domainAuthMiddleware.Handle("")) + group.POST("/entries", r.handler.CreateEntry) + r.logger.Info("查询白名单公开接口路由注册完成") +} diff --git a/scripts/migrate_query_whitelist_operation_ip.sql b/scripts/migrate_query_whitelist_operation_ip.sql new file mode 100644 index 0000000..94fa3c2 --- /dev/null +++ b/scripts/migrate_query_whitelist_operation_ip.sql @@ -0,0 +1,3 @@ +-- 为 query_whitelist_entries 增加操作 IP 字段(公开接口添加时记录调用方 IP) +ALTER TABLE query_whitelist_entries + ADD COLUMN IF NOT EXISTS operation_ip VARCHAR(64) DEFAULT '';