diff --git a/.env b/.env index 47f882a..31b4342 100644 --- a/.env +++ b/.env @@ -8,4 +8,12 @@ DB_SSLMODE=disable GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服务器的ip地址或者域名 GEMINI_API_KEY="sk-VoNbvr6aGpjvZX64rvhrwowrZrCgtGuX9oxykIy8F1DBg" GOOGLE_GENAI_USE_GCA="true" -GEMINI_MODEL="gemini-3-pro-preview" # 如果你有gemini3权限可以填: gemini-3-pro-preview \ No newline at end of file +GEMINI_MODEL="gemini-3-pro-preview" # 如果你有gemini3权限可以填: gemini-3-pro-preview + +# 七月迭代:Worker、企微 Adapter 与旧审批入口切换 +JUNHONG_WORKER_ROLE=all +JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1 +JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com +JUNHONG_WECOM_TIMEOUT=10s +JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true +JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true diff --git a/.env.local b/.env.local index e16e5de..ba4406d 100644 --- a/.env.local +++ b/.env.local @@ -91,3 +91,13 @@ export JUNHONG_SMS_PASSWORD="wwR8E4qnL6F0" export JUNHONG_SMS_SIGNATURE="【JHFTIOT】" JUNHONG_QUEUE_CONCURRENCY=1000 + +# ---------------------------------------------------------------------------- +# 七月迭代:Worker、企微 Adapter 与旧审批入口切换 +# ---------------------------------------------------------------------------- +export JUNHONG_WORKER_ROLE='all' +export JUNHONG_WORKER_INSTANCE_NAME='local-worker-all-1' +export JUNHONG_WECOM_BASE_URL='https://qyapi.weixin.qq.com' +export JUNHONG_WECOM_TIMEOUT='10s' +export JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED='true' +export JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED='true' diff --git a/.env.prod b/.env.prod index 3db1e25..6c7436a 100644 --- a/.env.prod +++ b/.env.prod @@ -234,3 +234,13 @@ export JUNHONG_DEFAULT_ADMIN_PASSWORD='Admin@123456' export JUNHONG_MIDDLEWARE_CORS_ENABLED=true export JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.xm-iot.cn,https://cmp-agent.xm-iot.cn,https://cmp-c.xm-iot.cn export JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true + +# ---------------------------------------------------------------------------- +# 七月迭代:Worker、企微 Adapter 与旧审批入口切换 +# ---------------------------------------------------------------------------- +export JUNHONG_WORKER_ROLE='all' +export JUNHONG_WORKER_INSTANCE_NAME='worker-all-1' +export JUNHONG_WECOM_BASE_URL='https://qyapi.weixin.qq.com' +export JUNHONG_WECOM_TIMEOUT='10s' +export JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED='true' +export JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED='true' diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 8bee370..bc63dbc 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -66,6 +66,14 @@ jobs: run: | # 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件) mkdir -p ${{ env.DEPLOY_DIR }}/logs + + umask 077 + { + printf 'JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true\n' + printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n' + printf 'JUNHONG_WORKER_ROLE=all\n' + printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n' + } > ${{ env.DEPLOY_DIR }}/.env # 强制更新 docker-compose.prod.yml(确保使用最新配置) echo "📋 更新部署配置文件..." @@ -77,10 +85,33 @@ jobs: docker compose -f docker-compose.prod.yml pull echo "🚀 重启服务..." - docker compose -f docker-compose.prod.yml up -d + docker compose -f docker-compose.prod.yml up -d --remove-orphans - echo "⏳ 等待服务启动..." - sleep 10 + echo "⏳ 等待 API 健康检查通过..." + for i in $(seq 1 30); do + API_HEALTH=$(docker inspect junhong-cmp-api --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true) + if [ "$API_HEALTH" = "healthy" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ API 在等待窗口内未恢复健康" + docker compose -f docker-compose.prod.yml logs api --tail=200 + exit 1 + fi + sleep 2 + done + + echo "🔎 校验 Worker 与数据库迁移版本..." + if [ "$(docker inspect junhong-cmp-worker --format='{{.State.Running}}' 2>/dev/null || true)" != "true" ]; then + echo "❌ Worker 未运行" + docker compose -f docker-compose.prod.yml logs worker --tail=200 + exit 1 + fi + docker exec junhong-cmp-api sh -c ' + DB_URL="postgresql://${JUNHONG_DATABASE_USER}:${JUNHONG_DATABASE_PASSWORD}@${JUNHONG_DATABASE_HOST}:${JUNHONG_DATABASE_PORT}/${JUNHONG_DATABASE_DBNAME}?sslmode=${JUNHONG_DATABASE_SSLMODE}" + migrate -path /app/migrations -database "$DB_URL" version + ' + docker compose -f docker-compose.prod.yml logs worker --tail=80 echo "✅ 部署完成!" docker compose -f docker-compose.prod.yml ps diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index a775bdb..51b1bc9 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -59,7 +59,7 @@ | 主钱包首次跌破 100 元通知店铺业务员 | N/A(由已审计资金事实派生的内部提醒,不新增人工操作或敏感读取) | `tb_agent_wallet_transaction` 与 `wallet.agent_main.debited` 是余额前后值的权威事实,`tb_notification` 保存最终通知与已读状态 | N/A(不调用外部系统) | 扣款事实消费者仅在 `balance_before >= 10000 && balance_after < 10000` 时同事务幂等写入明确后台账号通知 Outbox;无有效业务员时正常结束 | | 创建物流换货单并提醒关联个人客户 | N/A(通知记录本身是投递事实;当前 Change 不新增全局 Audit Event Writer,后台创建操作继续进入 Access Log) | `tb_exchange_order` 是物流换货申请及状态的权威事实,`tb_notification` 是接收人通知与已读状态的权威事实 | N/A(不调用外部系统) | 换货单与每个启用关联客户的 `notification.personal_customer.direct.requested` 在同一 GORM 事务写入;事件 ID 使用换货单和客户 ID 稳定防重,消费端按事件与接收人唯一键幂等 | | 套餐临期列表、数量与每日 15/7/3 天节点提醒 | N/A(列表和数量是普通受权读取;通知记录本身是投递事实,不新增人工状态变更) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存个人客户通知与已读状态 | N/A(不调用企业微信、短信、邮件或其他外部系统) | 每日任务按资产、到期日、节点和个人客户生成稳定事件 ID,在单个 GORM 事务中幂等写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox | -| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录明文或密文;明文读取仅允许超级管理员并进入 Access Log,统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及 AES-256-GCM 密文凭据的权威事实;管理响应按用户确认向超级管理员返回解密明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A(连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) | +| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录连接凭据;明文读取仅允许超级管理员并进入 Access Log,统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A(连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) | | 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A(同步和绑定均为同步事务,不产生必须可靠投递的提交后副作用) | | 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`,记录业务类型、应用 ID、模板 ID、状态和最近校验时间;不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时,不保存 access_token 或完整外部响应 | N/A(配置保存为同步事务,不产生必须可靠投递的提交后副作用) | | 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`,记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应;提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`;Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 | diff --git a/README.md b/README.md index 02eae70..2e54383 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ default: - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md) -- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口直接返回可编辑明文,数据库使用 AES-256-GCM 密文保存。Adapter 支持成员显式绑定、安全异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) +- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口和数据库均使用明文连接凭据。Adapter 支持成员显式绑定、异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index e27381f..b95d4cb 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -317,13 +317,9 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L // registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。 func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) - credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) - if err != nil { - appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批提交将失败关闭", zap.Error(err)) - } integrationRepository := integrationlog.NewRepository(runtime.db) tokenProvider := wecomInfra.NewTokenProvider( - applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, + applicationRepository, runtime.redisClient, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, ) consumer := wecomInfra.NewApprovalSubmissionConsumer( @@ -382,13 +378,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con // registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。 func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) - credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) - if err != nil { - appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批详情同步将失败关闭", zap.Error(err)) - } integrationRepository := integrationlog.NewRepository(runtime.db) tokenProvider := wecomInfra.NewTokenProvider( - applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, + applicationRepository, runtime.redisClient, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, ) decisionSync := approvalApp.NewSyncDecisionService( diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8c45162..6015bfe 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - # 君鸿卡管系统生产环境部署配置 # # 配置方式:纯环境变量配置(配置已嵌入二进制文件) @@ -74,6 +72,11 @@ services: - JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0 - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_TIMEOUT=30 + # 七月迭代:企业微信 Adapter 与旧审批入口切换 + - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com + - JUNHONG_WECOM_TIMEOUT=10s + - JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true} + - JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true} # 短信服务配置 - JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443 - JUNHONG_SMS_USERNAME=JH0001 @@ -137,6 +140,12 @@ services: - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_TIMEOUT=30 - JUNHONG_POLLING_VERBOSE_LOG=true + # 七月迭代:单 Worker 测试环境同时承担调度和消费 + - JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all} + - JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1} + # Worker 执行企微审批提交、同步和恢复任务 + - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com + - JUNHONG_WECOM_TIMEOUT=10s volumes: - ./logs:/app/logs networks: diff --git a/docker/entrypoint-api.sh b/docker/entrypoint-api.sh index e730584..0a2d865 100644 --- a/docker/entrypoint-api.sh +++ b/docker/entrypoint-api.sh @@ -30,7 +30,8 @@ echo "执行数据库迁移..." if migrate -path /app/migrations -database "$DB_URL" up; then echo "数据库迁移完成" else - echo "警告: 数据库迁移失败或无新迁移" + echo "错误: 数据库迁移失败,拒绝使用旧结构启动 API" + exit 1 fi echo "启动 API 服务..." diff --git a/docs/7月迭代/七月迭代联调交付说明.md b/docs/7月迭代/七月迭代联调交付说明.md index 71dec77..108efcd 100644 --- a/docs/7月迭代/七月迭代联调交付说明.md +++ b/docs/7月迭代/七月迭代联调交付说明.md @@ -2,7 +2,7 @@ ## 交付状态 -本说明覆盖 `deliver-july-iteration-confirmed-scope` 的企业微信、Gateway、Redis/Asynq、对象存储和前端联调输入。生产代码、增量迁移和 OpenAPI 已生成至可联调状态;测试库已执行至 `000194` 并完成结构核对,尚未执行自动化测试、完整构建、LSP 或真实外部环境闭环。 +本说明覆盖 `deliver-july-iteration-confirmed-scope` 的企业微信、Gateway、Redis/Asynq、对象存储和前端联调输入。生产代码、增量迁移和 OpenAPI 已生成至可联调状态;仓库迁移已新增至 `000196`,测试环境下次部署时由 API 启动脚本自动执行,尚未执行自动化测试、完整构建、LSP 或真实外部环境闭环。 接口统一使用 `{code,msg,data,timestamp}` 响应。完整请求和响应结构以 `docs/admin-openapi.yaml` 为准。 @@ -14,8 +14,7 @@ | --- | --- | --- | | `JUNHONG_WECOM_BASE_URL` | 环境变量 | 默认 `https://qyapi.weixin.qq.com` | | `JUNHONG_WECOM_TIMEOUT` | 环境变量 | 默认 `10s` | -| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | 环境变量 | 32 字节随机密钥的 Base64 文本;更换前必须先完成历史凭据重加密 | -| `corp_id`、`agent_id`、Secret | `POST /api/admin/wecom/applications` | 管理端按明文提交,服务端加密保存;超级管理员读取时返回明文,业务日志不记录 | +| `corp_id`、`agent_id`、Secret | `POST /api/admin/wecom/applications` | 管理端按明文提交并保存;超级管理员读取时返回明文,业务日志不记录 | | 回调 Token、EncodingAESKey | 同上 | Token 按明文提交;EncodingAESKey 固定 43 位 | | 默认发起人 | `PUT /api/admin/wecom/applications/:id/default-creator` | 必须先同步可见成员,再从当前可见成员中选择 | | `template_id` 和控件映射 | `PUT /api/admin/wecom/scenes/:business_type` | 模板必须先在企微后台创建,本系统只校验和绑定 | @@ -28,7 +27,7 @@ GET/POST {API_PUBLIC_BASE_URL}/api/callback/wecom/approval/{application_id} ### 配置和联调顺序 -1. 配置加密 Key,部署 API 和 Worker。 +1. 部署 API 和 Worker,并确认数据库迁移已执行至最新版本。 2. 创建应用配置并调用 `POST /api/admin/wecom/applications/:id/test` 验证取 token。 3. 调用 `POST /api/admin/wecom/applications/:id/members/sync`,再分页查询成员。 4. 为内部系统账号调用 `PUT /api/admin/accounts/:id/wecom-binding` 绑定 `(corp_id,userid)`。 @@ -80,6 +79,19 @@ API 与 Worker 必须连接同一 Redis/Asynq。新增或复用的任务如下 联调时确认 Worker 启动日志已注册上述 Handler,且 `JUNHONG_WORKER_ROLE` 的部署拓扑为单实例 `all`,或严格一个 `leader` 加多个 `consumer`。 +## System Config 初始化 + +迁移 `000195_seed_july_system_config` 初始化以下 6 个代码已注册配置;使用 `ON CONFLICT DO NOTHING`,不会覆盖环境管理员已经保存的值: + +- `carrier_callback.ctcc_realname.enabled=false` +- `carrier_callback.cmcc_realname.enabled=false` +- `carrier_callback.cucc_realname.enabled=false` +- `carrier_callback.cucc_realname_removal.enabled=false` +- `c2b.payment.card_allowed_methods=["wallet","wechat","alipay"]` +- `c2b.payment.device_allowed_methods=["wallet","wechat","alipay"]` + +部署后通过 `GET /api/admin/system-configs` 核对 6 项均为 `registered=true`。运营商回调默认关闭,完成对应运营商联调后再逐项开启;支付方式默认全选,可由超级管理员按卡和设备分别调整。 + ## 对象存储和 CSV 先调用 `POST /api/admin/storage/upload-url` 获取预签名地址,再把文件直传对象存储,业务接口只接收返回的 `file_key`。 @@ -115,11 +127,11 @@ API 与 Worker 必须连接同一 Redis/Asynq。新增或复用的任务如下 - 卡限速不保存本地当前档位,结果未知必须人工向 Gateway 核对。 - CSV 任务逐行部分成功,不保证整批业务原子性;任务详情是失败和跳过原因的权威展示。 - 店铺登录限制不主动吊销既有 C 端 Token。 -- 测试库已完成 `000182`~`000194` 结构落库和注释核对;其他环境尚未执行迁移,外部系统闭环和前端浏览器验收也未完成。 +- 测试库此前已完成 `000182`~`000194` 结构落库和注释核对;`000195`~`000196` 将在下一次测试环境部署时执行,外部系统闭环和前端浏览器验收尚未完成。 ## 发布和回滚 -1. 测试库已执行至 `000194`;其他环境发布时仍须按迁移顺序执行并核对最终版本和表结构。 +1. 发布目标数据库必须执行至 `000196`,并核对 `tb_system_config` 已出现 6 个七月迭代默认配置、`tb_wecom_application` 已使用明文凭据字段;测试环境由 API 容器启动脚本自动迁移。 2. 先部署数据库兼容代码、API 和 Worker,保持旧审批入口开启;再配置企微应用、模板、回调和默认发起人。 3. 真实审批闭环通过后,先隐藏前端旧按钮,再关闭旧入口配置并重启 API。 4. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 071c170..d58fa09 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -8661,19 +8661,19 @@ components: description: 企业微信自建应用 AgentID type: integer callback_token: - description: 回调 Token,管理端使用明文传入,服务端加密保存 + description: 回调 Token,服务端明文保存 type: string corp_id: description: 企业微信企业 ID type: string encoding_aes_key: - description: 回调 EncodingAESKey,管理端使用明文传入,服务端加密保存 + description: 回调 EncodingAESKey,服务端明文保存 type: string name: description: 应用展示名称 type: string secret: - description: 应用 Secret,管理端使用明文传入,服务端加密保存 + description: 应用 Secret,服务端明文保存 type: string status: description: 状态 (0:禁用, 1:启用) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index a4c1db7..6a879a0 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -182,9 +182,8 @@ JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1 |---------|--------|------| | `JUNHONG_WECOM_BASE_URL` | `https://qyapi.weixin.qq.com` | 企业微信 API 基础地址 | | `JUNHONG_WECOM_TIMEOUT` | `10s` | 企业微信外部请求超时时间 | -| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | `""` | 启用企业微信功能前必填,32 字节随机密钥的 Base64 文本 | -可使用 `openssl rand -base64 32` 生成加密密钥。密钥缺失或格式错误不会阻止不使用企微的环境启动,但企业微信配置保存、查询和连接测试会按失败关闭处理。更换密钥前必须先制定已有凭据的重新加密方案,否则历史密文将无法读取。 +企业微信应用 Secret、回调 Token 和 EncodingAESKey 通过管理接口维护并明文保存到 PostgreSQL,不需要额外的凭据加密环境变量。 ### 审批旧入口切换 diff --git a/docs/wecom-application-connection/功能总结.md b/docs/wecom-application-connection/功能总结.md index fbca57a..02640cd 100644 --- a/docs/wecom-application-connection/功能总结.md +++ b/docs/wecom-application-connection/功能总结.md @@ -17,9 +17,7 @@ ## 凭据与配置 -管理端按业务需要使用明文填写和读取连接参数,服务端在写入 PostgreSQL 前使用 AES-256-GCM 加密。数据库不保存明文,日志、配置审计和 Integration Log 也不记录明文、密文或 access_token。 - -启用前必须设置 `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY`,值为 32 字节随机密钥的 Base64 文本。未配置或格式错误时服务仍可启动,但企业微信相关业务接口失败关闭。 +管理端按业务需要使用明文填写和读取连接参数,PostgreSQL 直接保存应用 Secret、回调 Token 和 EncodingAESKey 明文。日志、配置审计和 Integration Log 仍不得记录这些连接凭据或 access_token。 ## Token 缓存与并发控制 @@ -58,7 +56,7 @@ ## 数据库变更 -- 迁移 `000184_create_wecom_application` 新增 `tb_wecom_application`,以 `(corp_id, agent_id)` 作为未删除记录的唯一身份。 +- 迁移 `000184_create_wecom_application` 新增 `tb_wecom_application`,以 `(corp_id, agent_id)` 作为未删除记录的唯一身份;`000196_store_wecom_credentials_plaintext` 按当前部署约定改为明文连接凭据字段。 - 迁移 `000185_add_wecom_member_binding` 新增 `tb_wecom_member`,并为 `tb_account` 增加企微绑定字段和唯一索引。 - 迁移 `000186_create_wecom_approval_scene` 新增当前业务场景、模板和控件映射配置表。 - 迁移 `000187_add_wecom_approval_submission` 增加应用默认发起人,并新增 `tb_wecom_approval_context` 保存不含凭据的模板、发起人和提交状态快照。 diff --git a/internal/application/wecom/connection.go b/internal/application/wecom/connection.go index 7a72b46..fb7a055 100644 --- a/internal/application/wecom/connection.go +++ b/internal/application/wecom/connection.go @@ -32,23 +32,16 @@ type DefaultCreatorMemberFinder interface { GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) } -// CredentialCipher 定义企业微信敏感凭据加密边界。 -type CredentialCipher interface { - Encrypt(plaintext string) ([]byte, error) - Decrypt(ciphertext []byte) (string, error) -} - // AccessTokenProvider 定义按应用取得及失效 access_token 的边界。 type AccessTokenProvider interface { GetAccessToken(ctx context.Context, applicationID uint) (string, error) Invalidate(ctx context.Context, applicationID uint) } -// ConnectionService 保存加密配置并测试企业微信连接。 +// ConnectionService 保存应用配置并测试企业微信连接。 type ConnectionService struct { db *gorm.DB repo ApplicationRepository - cipher CredentialCipher tokens AccessTokenProvider audit systemconfigapp.AuditWriter members DefaultCreatorMemberFinder @@ -61,14 +54,14 @@ func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorM } // NewConnectionService 创建企业微信连接用例。 -func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService { - return &ConnectionService{db: db, repo: repo, cipher: cipher, tokens: tokens, audit: audit, now: time.Now} +func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService { + return &ConnectionService{db: db, repo: repo, tokens: tokens, audit: audit, now: time.Now} } -// Save 创建或更新企业微信应用,并保证数据库仅保存密文凭据。 +// Save 创建或更新企业微信应用配置。 func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) { - if s == nil || s.db == nil || s.repo == nil || s.cipher == nil { - return nil, errors.New(errors.CodeWeComCredentialInvalid) + if s == nil || s.db == nil || s.repo == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden) @@ -77,21 +70,9 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli if operatorID == 0 { return nil, errors.New(errors.CodeInvalidParam) } - secret, err := s.cipher.Encrypt(request.Secret) - if err != nil { - return nil, err - } - callbackToken, err := s.cipher.Encrypt(request.CallbackToken) - if err != nil { - return nil, err - } - encodingAESKey, err := s.cipher.Encrypt(request.EncodingAESKey) - if err != nil { - return nil, err - } now := s.now().UTC() var saved *model.WeComApplication - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil { return err } @@ -111,9 +92,9 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli before = applicationAuditSnapshot(existing) } existing.Name = request.Name - existing.SecretCiphertext = secret - existing.CallbackTokenCiphertext = callbackToken - existing.EncodingAESKeyCiphertext = encodingAESKey + existing.Secret = request.Secret + existing.CallbackToken = request.CallbackToken + existing.EncodingAESKey = request.EncodingAESKey existing.Status = request.Status existing.UpdatedBy = operatorID existing.UpdatedAt = now @@ -150,14 +131,14 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli if s.tokens != nil { s.tokens.Invalidate(ctx, saved.ID) } - response := toApplicationResponse(*saved, request.Secret, request.CallbackToken, request.EncodingAESKey) + response := toApplicationResponse(*saved) return &response, nil } -// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。 +// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的凭据。 func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) { - if s == nil || s.repo == nil || s.cipher == nil { - return nil, errors.New(errors.CodeWeComCredentialInvalid) + if s == nil || s.repo == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden) @@ -177,19 +158,7 @@ func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicati } result := make([]dto.WeComApplicationResponse, 0, len(applications)) for _, application := range applications { - secret, err := s.cipher.Decrypt(application.SecretCiphertext) - if err != nil { - return nil, err - } - callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) - if err != nil { - return nil, err - } - encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) - if err != nil { - return nil, err - } - result = append(result, toApplicationResponse(application, secret, callbackToken, encodingAESKey)) + result = append(result, toApplicationResponse(application)) } return &dto.WeComApplicationListResponse{ Items: result, Total: total, Page: request.Page, PageSize: request.PageSize, @@ -214,7 +183,7 @@ func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error // SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。 func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) { - if s == nil || s.db == nil || s.repo == nil || s.cipher == nil || s.members == nil { + if s == nil || s.db == nil || s.repo == nil || s.members == nil { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { @@ -262,19 +231,7 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI } return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败") } - secret, err := s.cipher.Decrypt(application.SecretCiphertext) - if err != nil { - return nil, err - } - callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) - if err != nil { - return nil, err - } - encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) - if err != nil { - return nil, err - } - response := toApplicationResponse(*application, secret, callbackToken, encodingAESKey) + response := toApplicationResponse(*application) return &response, nil } @@ -287,17 +244,17 @@ func applicationAuditSnapshot(application *model.WeComApplication) map[string]an } } -func toApplicationResponse(application model.WeComApplication, secret, callbackToken, encodingAESKey string) dto.WeComApplicationResponse { +func toApplicationResponse(application model.WeComApplication) dto.WeComApplicationResponse { statusName := "禁用" if application.Status == constants.StatusEnabled { statusName = "启用" } return dto.WeComApplicationResponse{ ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name, - Secret: secret, CallbackToken: callbackToken, EncodingAESKey: encodingAESKey, + Secret: application.Secret, CallbackToken: application.CallbackToken, EncodingAESKey: application.EncodingAESKey, DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName, Status: application.Status, StatusName: statusName, - CredentialsSet: len(application.SecretCiphertext) > 0 && len(application.CallbackTokenCiphertext) > 0 && len(application.EncodingAESKeyCiphertext) > 0, + CredentialsSet: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "", LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt, } } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f9401ae..542d208 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -31,7 +31,6 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/go-playground/validator/v10" - "go.uber.org/zap" ) func initHandlers(svc *services, deps *Dependencies) *Handlers { @@ -111,27 +110,18 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil, ) wecomRepository := wecomInfra.NewApplicationRepository(deps.DB) - var credentialCipher *wecomInfra.CredentialCipher wecomBaseURL := "" wecomTimeout := constants.WeComDefaultHTTPTimeout if cfg := config.Get(); cfg != nil { wecomBaseURL = cfg.WeCom.BaseURL wecomTimeout = cfg.WeCom.Timeout - cipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) - if err != nil { - if deps.Logger != nil { - deps.Logger.Warn("企业微信凭据加密密钥未配置或无效,相关接口将拒绝业务调用", zap.Error(err)) - } - } else { - credentialCipher = cipher - } } wecomTokens := wecomInfra.NewTokenProvider( - wecomRepository, credentialCipher, deps.Redis, integrationlog.NewRepository(deps.DB), + wecomRepository, deps.Redis, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout, deps.Logger, ) wecomConnections := wecomApp.NewConnectionService( - deps.DB, wecomRepository, credentialCipher, wecomTokens, deps.SystemConfigAudit, + deps.DB, wecomRepository, wecomTokens, deps.SystemConfigAudit, ) wecomMembers := wecomInfra.NewMemberRepository(deps.DB) wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers) @@ -146,7 +136,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit, ) wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService( - wecomRepository, credentialCipher, integrationlog.NewRepository(deps.DB), deps.QueueClient, + wecomRepository, integrationlog.NewRepository(deps.DB), deps.QueueClient, )) svc.Account.SetWeComMemberFinder(wecomMembers) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 62a4808..996a626 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -291,15 +291,13 @@ func initServices(s *stores, deps *Dependencies) *services { wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB) wecomBaseURL := "" wecomTimeout := constants.WeComDefaultHTTPTimeout - var wecomCredentialCipher *wecomInfra.CredentialCipher if cfg := config.Get(); cfg != nil { wecomBaseURL = cfg.WeCom.BaseURL wecomTimeout = cfg.WeCom.Timeout - wecomCredentialCipher, _ = wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) } wecomIntegrationRepository := integrationlog.NewRepository(deps.DB) wecomTokenProvider := wecomInfra.NewTokenProvider( - wecomApplicationRepository, wecomCredentialCipher, deps.Redis, wecomIntegrationRepository, + wecomApplicationRepository, deps.Redis, wecomIntegrationRepository, wecomBaseURL, wecomTimeout, deps.Logger, ) approvalCreationService := approvalApp.NewCreationService( diff --git a/internal/infrastructure/wecom/application_repository.go b/internal/infrastructure/wecom/application_repository.go index 3e81b75..ac4403d 100644 --- a/internal/infrastructure/wecom/application_repository.go +++ b/internal/infrastructure/wecom/application_repository.go @@ -44,15 +44,15 @@ func (r *ApplicationRepository) Create(ctx context.Context, tx *gorm.DB, applica return nil } -// Update 更新企业微信应用配置及密文凭据。 +// Update 更新企业微信应用配置及连接凭据。 func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error { updates := map[string]any{ - "name": application.Name, "secret_ciphertext": application.SecretCiphertext, - "callback_token_ciphertext": application.CallbackTokenCiphertext, - "encoding_aes_key_ciphertext": application.EncodingAESKeyCiphertext, - "default_creator_userid": application.DefaultCreatorUserID, - "default_creator_name": application.DefaultCreatorName, - "status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt, + "name": application.Name, "secret": application.Secret, + "callback_token": application.CallbackToken, + "encoding_aes_key": application.EncodingAESKey, + "default_creator_userid": application.DefaultCreatorUserID, + "default_creator_name": application.DefaultCreatorName, + "status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt, } if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", application.ID).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败") @@ -73,7 +73,7 @@ func (r *ApplicationRepository) UpdateDefaultCreator(ctx context.Context, tx *go return nil } -// List 分页查询企业微信应用配置及密文凭据。 +// List 分页查询企业微信应用配置及连接凭据。 func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) { query := r.db.WithContext(ctx).Model(&model.WeComApplication{}) var total int64 @@ -87,7 +87,7 @@ func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([ return applications, total, nil } -// GetEnabled 查询启用的企业微信应用及密文凭据。 +// GetEnabled 查询启用的企业微信应用及连接凭据。 func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) { var application model.WeComApplication if err := r.db.WithContext(ctx).Where("id = ? AND status = ?", applicationID, constants.StatusEnabled).First(&application).Error; err != nil { diff --git a/internal/infrastructure/wecom/callback_service.go b/internal/infrastructure/wecom/callback_service.go index 501819f..ad776e3 100644 --- a/internal/infrastructure/wecom/callback_service.go +++ b/internal/infrastructure/wecom/callback_service.go @@ -40,14 +40,13 @@ type ApprovalDetailSyncTask struct { // CallbackService 校验解密企微回调、记录入站幂等事实并异步拉取详情。 type CallbackService struct { applications *ApplicationRepository - cipher *CredentialCipher integration callbackIntegrationLog queue *queue.Client } // NewCallbackService 创建企业微信审批回调服务。 -func NewCallbackService(applications *ApplicationRepository, cipher *CredentialCipher, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService { - return &CallbackService{applications: applications, cipher: cipher, integration: integration, queue: queueClient} +func NewCallbackService(applications *ApplicationRepository, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService { + return &CallbackService{applications: applications, integration: integration, queue: queueClient} } // VerifyURL 校验企微回调 URL 并返回 echostr 明文。 @@ -99,24 +98,16 @@ func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signa }) } -// callbackCrypto 每次从数据库密文解出当前回调凭据,使凭据轮换无需重启进程。 +// callbackCrypto 每次读取数据库当前回调凭据,使凭据更新无需重启进程。 func (s *CallbackService) callbackCrypto(ctx context.Context, applicationID uint) (*CallbackCrypto, error) { - if s == nil || s.applications == nil || s.cipher == nil || applicationID == 0 { + if s == nil || s.applications == nil || applicationID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置") } application, err := s.applications.GetEnabled(ctx, applicationID) if err != nil { return nil, err } - token, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) - if err != nil { - return nil, err - } - aesKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) - if err != nil { - return nil, err - } - return NewCallbackCrypto(token, aesKey, application.CorpID) + return NewCallbackCrypto(application.CallbackToken, application.EncodingAESKey, application.CorpID) } func applicationCallbackIdempotencyKey(applicationID uint, signature string) string { diff --git a/internal/infrastructure/wecom/credential_cipher.go b/internal/infrastructure/wecom/credential_cipher.go deleted file mode 100644 index 031cb43..0000000 --- a/internal/infrastructure/wecom/credential_cipher.go +++ /dev/null @@ -1,61 +0,0 @@ -// Package wecom 提供企业微信外部系统 Adapter。 -package wecom - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "io" - - "github.com/break/junhong_cmp_fiber/pkg/errors" -) - -var credentialAAD = []byte("junhong-wecom-credential-v1") - -// CredentialCipher 使用 AES-256-GCM 加解密企业微信敏感凭据。 -type CredentialCipher struct { - aead cipher.AEAD -} - -// NewCredentialCipher 从 32 字节 Base64 密钥创建凭据加密器。 -func NewCredentialCipher(encodedKey string) (*CredentialCipher, error) { - key, err := base64.StdEncoding.DecodeString(encodedKey) - if err != nil || len(key) != 32 { - return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信凭据加密密钥必须是 32 字节 Base64") - } - block, err := aes.NewCipher(key) - if err != nil { - return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密器失败") - } - aead, err := cipher.NewGCM(block) - if err != nil { - return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密模式失败") - } - return &CredentialCipher{aead: aead}, nil -} - -// Encrypt 加密单个敏感凭据,返回 nonce 与密文组合。 -func (c *CredentialCipher) Encrypt(plaintext string) ([]byte, error) { - if c == nil || c.aead == nil || plaintext == "" { - return nil, errors.New(errors.CodeWeComCredentialInvalid) - } - nonce := make([]byte, c.aead.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "生成企业微信凭据随机数失败") - } - return c.aead.Seal(nonce, nonce, []byte(plaintext), credentialAAD), nil -} - -// Decrypt 解密单个企业微信敏感凭据。 -func (c *CredentialCipher) Decrypt(ciphertext []byte) (string, error) { - if c == nil || c.aead == nil || len(ciphertext) <= c.aead.NonceSize() { - return "", errors.New(errors.CodeWeComCredentialInvalid) - } - nonce := ciphertext[:c.aead.NonceSize()] - plaintext, err := c.aead.Open(nil, nonce, ciphertext[c.aead.NonceSize():], credentialAAD) - if err != nil { - return "", errors.Wrap(errors.CodeWeComCredentialInvalid, err, "解密企业微信凭据失败") - } - return string(plaintext), nil -} diff --git a/internal/infrastructure/wecom/token_provider.go b/internal/infrastructure/wecom/token_provider.go index b2a38a7..398fa75 100644 --- a/internal/infrastructure/wecom/token_provider.go +++ b/internal/infrastructure/wecom/token_provider.go @@ -34,11 +34,6 @@ type TokenApplicationRepository interface { MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error } -// TokenCredentialCipher 定义 access_token Provider 所需的凭据解密边界。 -type TokenCredentialCipher interface { - Decrypt(ciphertext []byte) (string, error) -} - // TokenIntegrationLog 定义企微外呼的 Integration Log 边界。 type TokenIntegrationLog interface { Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error) @@ -48,7 +43,6 @@ type TokenIntegrationLog interface { // TokenProvider 按应用缓存企业微信 access_token,并用 Redis 短锁抑制并发回源。 type TokenProvider struct { repo TokenApplicationRepository - cipher TokenCredentialCipher redis *redis.Client integration TokenIntegrationLog httpClient *http.Client @@ -60,7 +54,6 @@ type TokenProvider struct { // NewTokenProvider 创建企业微信 access_token Provider。 func NewTokenProvider( repo TokenApplicationRepository, - cipher TokenCredentialCipher, redisClient *redis.Client, integration TokenIntegrationLog, baseURL string, @@ -71,7 +64,7 @@ func NewTokenProvider( timeout = constants.WeComDefaultHTTPTimeout } return &TokenProvider{ - repo: repo, cipher: cipher, redis: redisClient, integration: integration, + repo: repo, redis: redisClient, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), logger: logger, now: time.Now, } @@ -79,7 +72,7 @@ func NewTokenProvider( // GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。 func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) { - if p == nil || p.repo == nil || p.cipher == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 { + if p == nil || p.repo == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 { return "", errors.New(errors.CodeWeComCredentialInvalid) } cacheKey := constants.RedisWeComAccessTokenKey(applicationID) @@ -162,11 +155,7 @@ func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, c if err != nil { return "", err } - secret, err := p.cipher.Decrypt(application.SecretCiphertext) - if err != nil { - return "", err - } - request, err := p.newTokenRequest(ctx, application.CorpID, secret) + request, err := p.newTokenRequest(ctx, application.CorpID, application.Secret) if err != nil { return "", err } diff --git a/internal/model/dto/wecom_dto.go b/internal/model/dto/wecom_dto.go index e422eac..2b95a48 100644 --- a/internal/model/dto/wecom_dto.go +++ b/internal/model/dto/wecom_dto.go @@ -7,9 +7,9 @@ type SaveWeComApplicationRequest struct { CorpID string `json:"corp_id" validate:"required,min=1,max=64" description:"企业微信企业 ID"` AgentID int64 `json:"agent_id" validate:"required,gt=0" description:"企业微信自建应用 AgentID"` Name string `json:"name" validate:"required,min=1,max=100" description:"应用展示名称"` - Secret string `json:"secret" validate:"required,min=1,max=512" description:"应用 Secret,管理端使用明文传入,服务端加密保存"` - CallbackToken string `json:"callback_token" validate:"required,min=1,max=512" description:"回调 Token,管理端使用明文传入,服务端加密保存"` - EncodingAESKey string `json:"encoding_aes_key" validate:"required,len=43" description:"回调 EncodingAESKey,管理端使用明文传入,服务端加密保存"` + Secret string `json:"secret" validate:"required,min=1,max=512" description:"应用 Secret,服务端明文保存"` + CallbackToken string `json:"callback_token" validate:"required,min=1,max=512" description:"回调 Token,服务端明文保存"` + EncodingAESKey string `json:"encoding_aes_key" validate:"required,len=43" description:"回调 EncodingAESKey,服务端明文保存"` Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"` } diff --git a/internal/model/wecom_application.go b/internal/model/wecom_application.go index 7a43b12..bca08b0 100644 --- a/internal/model/wecom_application.go +++ b/internal/model/wecom_application.go @@ -6,21 +6,21 @@ import ( "gorm.io/gorm" ) -// WeComApplication 企业微信自建应用及加密连接凭据。 +// WeComApplication 企业微信自建应用及连接凭据。 type WeComApplication struct { gorm.Model - CorpID string `gorm:"column:corp_id;type:varchar(64);not null;uniqueIndex:uq_wecom_application_identity,priority:1,where:deleted_at IS NULL" json:"corp_id"` - AgentID int64 `gorm:"column:agent_id;type:bigint;not null;uniqueIndex:uq_wecom_application_identity,priority:2,where:deleted_at IS NULL" json:"agent_id"` - Name string `gorm:"column:name;type:varchar(100);not null" json:"name"` - SecretCiphertext []byte `gorm:"column:secret_ciphertext;type:bytea;not null" json:"-"` - CallbackTokenCiphertext []byte `gorm:"column:callback_token_ciphertext;type:bytea;not null" json:"-"` - EncodingAESKeyCiphertext []byte `gorm:"column:encoding_aes_key_ciphertext;type:bytea;not null" json:"-"` - DefaultCreatorUserID string `gorm:"column:default_creator_userid;type:varchar(64);not null;default:''" json:"default_creator_userid"` - DefaultCreatorName string `gorm:"column:default_creator_name;type:varchar(100);not null;default:''" json:"default_creator_name"` - Status int `gorm:"column:status;type:int;not null;default:1" json:"status"` - CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"` - UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"` - LastConnectedAt *time.Time `gorm:"column:last_connected_at;type:timestamptz" json:"last_connected_at,omitempty"` + CorpID string `gorm:"column:corp_id;type:varchar(64);not null;uniqueIndex:uq_wecom_application_identity,priority:1,where:deleted_at IS NULL" json:"corp_id"` + AgentID int64 `gorm:"column:agent_id;type:bigint;not null;uniqueIndex:uq_wecom_application_identity,priority:2,where:deleted_at IS NULL" json:"agent_id"` + Name string `gorm:"column:name;type:varchar(100);not null" json:"name"` + Secret string `gorm:"column:secret;type:varchar(512);not null" json:"-"` + CallbackToken string `gorm:"column:callback_token;type:varchar(512);not null" json:"-"` + EncodingAESKey string `gorm:"column:encoding_aes_key;type:varchar(43);not null" json:"-"` + DefaultCreatorUserID string `gorm:"column:default_creator_userid;type:varchar(64);not null;default:''" json:"default_creator_userid"` + DefaultCreatorName string `gorm:"column:default_creator_name;type:varchar(100);not null;default:''" json:"default_creator_name"` + Status int `gorm:"column:status;type:int;not null;default:1" json:"status"` + CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"` + UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"` + LastConnectedAt *time.Time `gorm:"column:last_connected_at;type:timestamptz" json:"last_connected_at,omitempty"` } // TableName 指定企业微信应用表名。 diff --git a/migrations/000195_seed_july_system_config.down.sql b/migrations/000195_seed_july_system_config.down.sql new file mode 100644 index 0000000..d234ed5 --- /dev/null +++ b/migrations/000195_seed_july_system_config.down.sql @@ -0,0 +1,3 @@ +-- 系统配置上线后可能已经被环境管理员修改,回滚代码时不得删除业务配置事实。 +-- 旧版本会把无法识别的配置按未注册只读项处理,因此这里安全保留数据。 +SELECT 1; diff --git a/migrations/000195_seed_july_system_config.up.sql b/migrations/000195_seed_july_system_config.up.sql new file mode 100644 index 0000000..a0d1cd9 --- /dev/null +++ b/migrations/000195_seed_july_system_config.up.sql @@ -0,0 +1,81 @@ +-- 初始化七月迭代已注册的受控系统配置。 +-- 已存在的配置由环境管理员维护,迁移不得覆盖人工设置。 +INSERT INTO tb_system_config ( + config_key, + config_value, + value_type, + module, + description, + is_readonly, + is_sensitive, + creator, + updater +) +VALUES + ( + 'carrier_callback.ctcc_realname.enabled', + 'false', + 'bool', + 'carrier_callback', + '是否处理中国电信实名回调', + false, + false, + 0, + 0 + ), + ( + 'carrier_callback.cmcc_realname.enabled', + 'false', + 'bool', + 'carrier_callback', + '是否处理中国移动实名回调', + false, + false, + 0, + 0 + ), + ( + 'carrier_callback.cucc_realname.enabled', + 'false', + 'bool', + 'carrier_callback', + '是否处理中国联通实名成功回调', + false, + false, + 0, + 0 + ), + ( + 'carrier_callback.cucc_realname_removal.enabled', + 'false', + 'bool', + 'carrier_callback', + '是否处理中国联通解除实名回调', + false, + false, + 0, + 0 + ), + ( + 'c2b.payment.card_allowed_methods', + '["wallet","wechat","alipay"]', + 'json', + 'c2b.payment', + '卡资产允许的C端支付方式', + false, + false, + 0, + 0 + ), + ( + 'c2b.payment.device_allowed_methods', + '["wallet","wechat","alipay"]', + 'json', + 'c2b.payment', + '设备资产允许的C端支付方式', + false, + false, + 0, + 0 + ) +ON CONFLICT (config_key) DO NOTHING; diff --git a/migrations/000196_store_wecom_credentials_plaintext.down.sql b/migrations/000196_store_wecom_credentials_plaintext.down.sql new file mode 100644 index 0000000..d784d2d --- /dev/null +++ b/migrations/000196_store_wecom_credentials_plaintext.down.sql @@ -0,0 +1,15 @@ +-- 回滚到密文字段结构;当前无企微应用数据,不执行凭据转换。 +ALTER TABLE tb_wecom_application + ADD COLUMN secret_ciphertext bytea NOT NULL DEFAULT ''::bytea, + ADD COLUMN callback_token_ciphertext bytea NOT NULL DEFAULT ''::bytea, + ADD COLUMN encoding_aes_key_ciphertext bytea NOT NULL DEFAULT ''::bytea; + +ALTER TABLE tb_wecom_application + DROP COLUMN secret, + DROP COLUMN callback_token, + DROP COLUMN encoding_aes_key; + +COMMENT ON TABLE tb_wecom_application IS '企业微信自建应用安全连接配置'; +COMMENT ON COLUMN tb_wecom_application.secret_ciphertext IS '应用 Secret 的 AES-256-GCM 密文'; +COMMENT ON COLUMN tb_wecom_application.callback_token_ciphertext IS '回调 Token 的 AES-256-GCM 密文'; +COMMENT ON COLUMN tb_wecom_application.encoding_aes_key_ciphertext IS '回调 EncodingAESKey 的 AES-256-GCM 密文'; diff --git a/migrations/000196_store_wecom_credentials_plaintext.up.sql b/migrations/000196_store_wecom_credentials_plaintext.up.sql new file mode 100644 index 0000000..b053418 --- /dev/null +++ b/migrations/000196_store_wecom_credentials_plaintext.up.sql @@ -0,0 +1,15 @@ +-- 当前环境尚未创建企微应用,直接将企微连接凭据改为明文存储。 +ALTER TABLE tb_wecom_application + ADD COLUMN secret varchar(512) NOT NULL DEFAULT '', + ADD COLUMN callback_token varchar(512) NOT NULL DEFAULT '', + ADD COLUMN encoding_aes_key varchar(43) NOT NULL DEFAULT ''; + +ALTER TABLE tb_wecom_application + DROP COLUMN secret_ciphertext, + DROP COLUMN callback_token_ciphertext, + DROP COLUMN encoding_aes_key_ciphertext; + +COMMENT ON TABLE tb_wecom_application IS '企业微信自建应用连接配置'; +COMMENT ON COLUMN tb_wecom_application.secret IS '企业微信自建应用 Secret 明文'; +COMMENT ON COLUMN tb_wecom_application.callback_token IS '企业微信回调 Token 明文'; +COMMENT ON COLUMN tb_wecom_application.encoding_aes_key IS '企业微信回调 EncodingAESKey 明文'; diff --git a/pkg/config/config.go b/pkg/config/config.go index c852387..853245b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -178,9 +178,8 @@ type ApprovalConfig struct { // WeComConfig 企业微信 Adapter 运行配置。 type WeComConfig struct { - BaseURL string `mapstructure:"base_url"` // 企业微信 API 基础地址 - Timeout time.Duration `mapstructure:"timeout"` // 外部请求超时时间 - CredentialEncryptionKey string `mapstructure:"credential_encryption_key"` // 数据库凭证 AES-256-GCM 加密密钥(Base64) + BaseURL string `mapstructure:"base_url"` // 企业微信 API 基础地址 + Timeout time.Duration `mapstructure:"timeout"` // 外部请求超时时间 } // PollingConfig 轮询通用配置 diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 969d491..dbb1a64 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -147,4 +147,3 @@ approval: wecom: base_url: "https://qyapi.weixin.qq.com" timeout: "10s" - credential_encryption_key: "" # 启用企微前必填:JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY(32 字节 Base64) diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 5684c30..40027b6 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -134,7 +134,6 @@ func bindEnvVariables(v *viper.Viper) { "approval.legacy_offline_recharge_pay_enabled", "wecom.base_url", "wecom.timeout", - "wecom.credential_encryption_key", "wechat.official_account.app_id", "wechat.official_account.app_secret", "wechat.official_account.token",