让七月迭代具备可直接部署的配置基线
补齐三套环境配置、测试环境部署门禁与 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:
10
.env
10
.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
|
||||
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
|
||||
|
||||
10
.env.local
10
.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'
|
||||
|
||||
10
.env.prod
10
.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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 服务..."
|
||||
|
||||
@@ -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. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。
|
||||
|
||||
@@ -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:启用)
|
||||
|
||||
@@ -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,不需要额外的凭据加密环境变量。
|
||||
|
||||
### 审批旧入口切换
|
||||
|
||||
|
||||
@@ -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` 保存不含凭据的模板、发起人和提交状态快照。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:启用)"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 指定企业微信应用表名。
|
||||
|
||||
3
migrations/000195_seed_july_system_config.down.sql
Normal file
3
migrations/000195_seed_july_system_config.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 系统配置上线后可能已经被环境管理员修改,回滚代码时不得删除业务配置事实。
|
||||
-- 旧版本会把无法识别的配置按未注册只读项处理,因此这里安全保留数据。
|
||||
SELECT 1;
|
||||
81
migrations/000195_seed_july_system_config.up.sql
Normal file
81
migrations/000195_seed_july_system_config.up.sql
Normal 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;
|
||||
15
migrations/000196_store_wecom_credentials_plaintext.down.sql
Normal file
15
migrations/000196_store_wecom_credentials_plaintext.down.sql
Normal 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 密文';
|
||||
15
migrations/000196_store_wecom_credentials_plaintext.up.sql
Normal file
15
migrations/000196_store_wecom_credentials_plaintext.up.sql
Normal 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 明文';
|
||||
@@ -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 轮询通用配置
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user