让七月迭代具备可直接部署的配置基线

补齐三套环境配置、测试环境部署门禁与 system_config 初始化,并按当前无企微应用的约束将企微凭据改为明文存储。

Constraint: 当前测试环境尚无企微应用及历史企微密文数据

Rejected: 使用启动环境变量加密企微凭据 | 用户明确要求直接明文保存并移除加密密钥

Confidence: high

Scope-risk: moderate

Directive: 企微真实闭环完成前保持两个旧审批入口开关为 true

Tested: gofmt;git diff --check;bash -n;docker compose config;Gitea workflow YAML 解析;OpenAPI 重新生成

Not-tested: go test;常规 go build;LSP;实际数据库迁移;真实测试环境部署;企微真实联调
This commit is contained in:
2026-07-25 18:18:45 +08:00
parent 73f5125d3d
commit cb26217205
29 changed files with 277 additions and 232 deletions

8
.env
View File

@@ -9,3 +9,11 @@ GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服
GEMINI_API_KEY="sk-VoNbvr6aGpjvZX64rvhrwowrZrCgtGuX9oxykIy8F1DBg"
GOOGLE_GENAI_USE_GCA="true"
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

View File

@@ -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'

View File

@@ -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'

View File

@@ -67,6 +67,14 @@ jobs:
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
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 "📋 更新部署配置文件..."
cp -v docker-compose.prod.yml ${{ env.DEPLOY_DIR }}/
@@ -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

View File

@@ -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 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |

View File

@@ -257,7 +257,7 @@ default:
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
- **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md)
- **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 015 天临期卡/设备及分类数量,返回高亮和优先标识;每日 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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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 服务..."

View File

@@ -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. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。

View File

@@ -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:启用)

View File

@@ -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不需要额外的凭据加密环境变量
### 审批旧入口切换

View File

@@ -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` 保存不含凭据的模板、发起人和提交状态快照。

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -44,12 +44,12 @@ 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,
"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,
@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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:启用)"`
}

View File

@@ -6,15 +6,15 @@ 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:"-"`
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"`

View File

@@ -0,0 +1,3 @@
-- 系统配置上线后可能已经被环境管理员修改,回滚代码时不得删除业务配置事实。
-- 旧版本会把无法识别的配置按未注册只读项处理,因此这里安全保留数据。
SELECT 1;

View File

@@ -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;

View File

@@ -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 密文';

View File

@@ -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 明文';

View File

@@ -180,7 +180,6 @@ type ApprovalConfig struct {
type WeComConfig struct {
BaseURL string `mapstructure:"base_url"` // 企业微信 API 基础地址
Timeout time.Duration `mapstructure:"timeout"` // 外部请求超时时间
CredentialEncryptionKey string `mapstructure:"credential_encryption_key"` // 数据库凭证 AES-256-GCM 加密密钥Base64
}
// PollingConfig 轮询通用配置

View File

@@ -147,4 +147,3 @@ approval:
wecom:
base_url: "https://qyapi.weixin.qq.com"
timeout: "10s"
credential_encryption_key: "" # 启用企微前必填JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY32 字节 Base64

View File

@@ -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",