2 Commits

Author SHA1 Message Date
09ffee8590 完成
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 7m30s
2026-07-25 19:06:31 +08:00
cb26217205 让七月迭代具备可直接部署的配置基线
补齐三套环境配置、测试环境部署门禁与 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;实际数据库迁移;真实测试环境部署;企微真实联调
2026-07-25 18:18:45 +08:00
36 changed files with 435 additions and 252 deletions

8
.env
View File

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

View File

@@ -91,3 +91,13 @@ export JUNHONG_SMS_PASSWORD="wwR8E4qnL6F0"
export JUNHONG_SMS_SIGNATURE="【JHFTIOT】" export JUNHONG_SMS_SIGNATURE="【JHFTIOT】"
JUNHONG_QUEUE_CONCURRENCY=1000 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_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_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 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 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确保使用最新配置 # 强制更新 docker-compose.prod.yml确保使用最新配置
echo "📋 更新部署配置文件..." echo "📋 更新部署配置文件..."
cp -v docker-compose.prod.yml ${{ env.DEPLOY_DIR }}/ cp -v docker-compose.prod.yml ${{ env.DEPLOY_DIR }}/
@@ -77,10 +85,33 @@ jobs:
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
echo "🚀 重启服务..." echo "🚀 重启服务..."
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d --remove-orphans
echo "⏳ 等待服务启动..." echo "⏳ 等待 API 健康检查通过..."
sleep 10 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 "✅ 部署完成!" echo "✅ 部署完成!"
docker compose -f docker-compose.prod.yml ps 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无有效业务员时正常结束 | | 主钱包首次跌破 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 稳定防重,消费端按事件与接收人唯一键幂等 | | 创建物流换货单并提醒关联个人客户 | 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 | | 套餐临期列表、数量与每日 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同步和绑定均为同步事务不产生必须可靠投递的提交后副作用 | | 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(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`,记录业务类型、应用 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 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 | | 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `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/package-system-upgrade/)
- **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md)
- **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 015 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.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) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
- **对象存储**S3 兼容的对象存储服务集成(联通云 OSS支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.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 注册企业微信审批提交和标准终态业务消费者。 // registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) 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) integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider( tokenProvider := wecomInfra.NewTokenProvider(
applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, applicationRepository, runtime.redisClient, integrationRepository,
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
) )
consumer := wecomInfra.NewApprovalSubmissionConsumer( consumer := wecomInfra.NewApprovalSubmissionConsumer(
@@ -382,13 +378,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
// registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。 // registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。
func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) 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) integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider( tokenProvider := wecomInfra.NewTokenProvider(
applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, applicationRepository, runtime.redisClient, integrationRepository,
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
) )
decisionSync := approvalApp.NewSyncDecisionService( 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_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30 - 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_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001 - JUNHONG_SMS_USERNAME=JH0001
@@ -137,6 +140,12 @@ services:
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30 - JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_POLLING_VERBOSE_LOG=true - 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: volumes:
- ./logs:/app/logs - ./logs:/app/logs
networks: networks:

View File

@@ -30,7 +30,8 @@ echo "执行数据库迁移..."
if migrate -path /app/migrations -database "$DB_URL" up; then if migrate -path /app/migrations -database "$DB_URL" up; then
echo "数据库迁移完成" echo "数据库迁移完成"
else else
echo "警告: 数据库迁移失败或无新迁移" echo "错误: 数据库迁移失败,拒绝使用旧结构启动 API"
exit 1
fi fi
echo "启动 API 服务..." echo "启动 API 服务..."

View File

@@ -94,11 +94,25 @@
| #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations``PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override``null` 表示跟随套餐默认值 | | #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations``PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override``null` 表示跟随套餐默认值 |
| #43 系列套餐多选 | `POST /api/admin/shop-series-grants``PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1100 项数组,每项包含 `package_id``cost_price`,删除时传 `remove=true` | | #43 系列套餐多选 | `POST /api/admin/shop-series-grants``PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1100 项数组,每项包含 `package_id``cost_price`,删除时传 `remove=true` |
#### 3.2.1 #43 套餐价格展示和已授权/未授权区分
#43 不需要新增后端接口,前端按下面两个现有接口组合数据:
1. 调用 `GET /api/admin/packages?series_id={series_id}&page=1&page_size=100` 分页取得该系列全部套餐。列表项直接使用:
- `suggested_retail_price`:建议售价,单位分;未配置时为空。
- `cost_price`:公司成本价,单位分。
2. 编辑已有授权时,调用 `GET /api/admin/shop-series-grants/{grant_id}`,注意路径参数是授权记录 ID不是系列 ID。
3. 将授权详情的 `packages[].package_id` 组成已授权套餐 ID 集合。
4. 套餐列表项的 `id` 在该集合中显示“已授权”,否则显示“未授权”。授权详情 `packages[].cost_price` 是该次店铺授权成本价,不要拿它替代套餐列表的公司成本价。
5. 用户提交多选结果时,创建授权调用 `POST /api/admin/shop-series-grants`;编辑授权调用 `PUT /api/admin/shop-series-grants/{grant_id}/packages`。新增/调价项传 `package_id + cost_price`,删除项传 `package_id + remove=true`
套餐接口是分页接口;一个系列超过 100 个套餐时,前端必须继续请求后续页,再完成已授权集合标记。
### 3.3 审批、退款和员工线下代充值 ### 3.3 审批、退款和员工线下代充值
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | | 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
| --- | --- | --- | | --- | --- | --- |
| #182/#44 退款 | `GET /api/admin/refunds``GET /api/admin/refunds/:id` | 新增/补齐 `submitter_id``submitter_name``approval_provider``approval_status``approval_status_name` | | #181/#182/#44 退款 | `GET /api/admin/refunds``GET /api/admin/refunds/:id` | 新增/补齐 `asset_identifier``submitter_id``submitter_name``approval_provider``approval_status``approval_status_name`;资产标识是退款创建时固化的快照,卡为 ICCID设备按 VirtualNo 优先、IMEI 兜底;历史空快照不做兼容 |
| #182/#44 充值 | `GET /api/admin/agent-recharges``GET /api/admin/agent-recharges/:id` | 同上;线下代充值的审批状态只读展示 | | #182/#44 充值 | `GET /api/admin/agent-recharges``GET /api/admin/agent-recharges/:id` | 同上;线下代充值的审批状态只读展示 |
| #44 换货 | `GET /api/admin/exchanges``GET /api/admin/exchanges/:id` | 新增/补齐 `submitter_id``submitter_name` | | #44 换货 | `GET /api/admin/exchanges``GET /api/admin/exchanges/:id` | 新增/补齐 `submitter_id``submitter_name` |
| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、15 个 `payment_voucher_key` 和备注,创建后等待企微审批 | | #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、15 个 `payment_voucher_key` 和备注,创建后等待企微审批 |
@@ -110,6 +124,83 @@
| #37 场景模板 | `PUT /api/admin/wecom/scenes/:business_type``GET /api/admin/wecom/scenes` | `business_type``refund_approval``offline_recharge_approval`;传 `application_id``template_id``control_mapping[]` | | #37 场景模板 | `PUT /api/admin/wecom/scenes/:business_type``GET /api/admin/wecom/scenes` | `business_type``refund_approval``offline_recharge_approval`;传 `application_id``template_id``control_mapping[]` |
| #37 回调 | `GET/POST /api/callback/wecom/approval/:application_id` | 由企微服务器调用,前端无需调用 | | #37 回调 | `GET/POST /api/callback/wecom/approval/:application_id` | 由企微服务器调用,前端无需调用 |
#### 3.3.1 企业微信审批 8 个接口的前端调用流程
企业微信配置页使用 8 个 `/api/admin/wecom` 接口账号绑定属于账号模块因此完整闭环是“8 个企微配置接口 + 1 个账号绑定接口”。应用凭据和场景写操作应只向超级管理员开放;前端收到 403 时不要降级绕过。
| 顺序 | 接口 | 页面动作与调用说明 |
| --- | --- | --- |
| 1 | `POST /api/admin/wecom/applications` | 保存应用。传 `corp_id``agent_id``name``secret``callback_token`、43 位 `encoding_aes_key``status=1`。相同 `corp_id + agent_id` 再次提交表示更新;保存响应中的 `id` 是后续 `application_id` |
| 2 | `GET /api/admin/wecom/applications?page=1&page_size=20` | 进入配置页或保存后刷新应用列表,展示连接时间、启用状态、默认发起人和凭据是否完整 |
| 3 | `POST /api/admin/wecom/applications/{id}/test` | 用户点击“测试连接”时调用;`data.success=true` 只表示成功取得 access_token不表示通讯录、模板和回调均已配置完成 |
| 4 | `POST /api/admin/wecom/applications/{id}/members/sync` | 连接成功后点击“同步成员”;后端拉取该自建应用可见范围,返回 `synced_count``synced_at` |
| 5 | `GET /api/admin/wecom/applications/{id}/members?page=1&page_size=20&keyword=...` | 查询最近同步的本地成员快照,`keyword` 可按姓名或 userid 搜索;用于默认发起人和账号绑定的选择器,不要允许手输一个未同步 userid |
| 6 | `PUT /api/admin/wecom/applications/{id}/default-creator` | 从成员选择器取 `userid`,请求体为 `{"userid":"zhangsan"}`。代理等非企微账号提交业务时,企微审批由该成员代为发起,但本地业务提交人仍保持真实账号 |
| 7 | `PUT /api/admin/wecom/scenes/{business_type}` | 分别保存退款和线下代充值模板映射;后端会实时读取企微模板详情并校验控件 ID、类型、必填控件和选择项 key校验失败时页面应保留用户输入并展示后端错误 |
| 8 | `GET /api/admin/wecom/scenes?page=1&page_size=20` | 进入场景页或保存后刷新,展示 `business_type_name`、模板名称、状态、最近校验时间和控件映射 |
应用保存请求示例:
```json
{
"corp_id": "wwxxxxxxxxxxxxxxxx",
"agent_id": 1000002,
"name": "测试环境审批应用",
"secret": "企微应用Secret",
"callback_token": "企微后台配置的回调Token",
"encoding_aes_key": "43位EncodingAESKey",
"status": 1
}
```
场景路径只支持:
- `refund_approval`:退款审批。
- `offline_recharge_approval`:员工线下代充值审批。
场景保存请求示例:
```json
{
"application_id": 1,
"template_id": "企微模板ID",
"control_mapping": [
{
"business_field": "refund_no",
"control_id": "Text-xxxxxxxx",
"control_type": "Text",
"option_mapping": {}
}
],
"status": 1
}
```
`control_mapping``control_id``control_type` 和选择项 key 必须来自企微后台已经创建的模板。退款可映射业务字段为 `refund_no``order_id``order_no``asset_identifier``asset_type``actual_received_amount``requested_refund_amount``refund_voucher_key``refund_reason``package_usage_id``submitter_id``submitter_name`;线下代充值可映射 `recharge_no``shop_id``shop_name``amount``amount_cent``payment_voucher_key``remark``submitter_id``submitter_name`。企微模板中的必填控件必须全部映射。
完成成员同步后,账号管理页还要调用:
```http
PUT /api/admin/accounts/{account_id}/wecom-binding
```
```json
{
"application_id": 1,
"userid": "zhangsan"
}
```
前端完整配置顺序为:保存应用 → 测试连接 → 同步成员 → 查询成员 → 设置默认发起人 → 给需要本人发起审批的系统账号绑定成员 → 保存两个业务场景 → 查询场景确认均已启用。应用可见范围变化后,应重新同步成员并检查默认发起人和账号绑定。
#### 3.3.2 配置完成后的业务审批流程
1. 前端继续调用原业务接口创建退款或员工线下代充值,不直接调用企微发起审批接口。
2. 后端保存业务单、通用审批实例和提交事件,并异步向企微发起审批;前端根据业务列表/详情的 `approval_provider``approval_status``approval_status_name` 只读展示进度。
3. `approval_status` 可能为:`0` 提交中、`1` 审批中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 通过后撤销、`6` 已删除、`7` 提交失败、`8` 提交结果未知。页面名称优先直接使用 `approval_status_name`
4. 企微回调和 Worker 轮询共同同步最终状态;`GET/POST /api/callback/wecom/approval/{application_id}` 只由企微服务器调用,前端禁止调用。
5. `approval_provider=wecom` 或存在 `approval_instance_id` 时,前端不得显示退款通过/驳回、线下充值确认等旧人工按钮。企微通过后,退款终结或钱包入账由后端自动幂等执行。
### 3.4 通知、批量任务、导出和 Gateway ### 3.4 通知、批量任务、导出和 Gateway
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | | 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
@@ -127,6 +218,47 @@
| #49 创建设备分配任务 | `POST /api/admin/devices/import/allocations` | 传 `file_key + operation_type + target_id``operation_type=assign_shop|assign_series` | | #49 创建设备分配任务 | `POST /api/admin/devices/import/allocations` | 传 `file_key + operation_type + target_id``operation_type=assign_shop|assign_series` |
| #49 查询任务 | `GET /api/admin/devices/import/tasks``GET /api/admin/devices/import/tasks/:id` | 复用原设备导入任务页面,新增展示 `operation_type``operation_name``target_id``status_name` | | #49 查询任务 | `GET /api/admin/devices/import/tasks``GET /api/admin/devices/import/tasks/:id` | 复用原设备导入任务页面,新增展示 `operation_type``operation_name``target_id``status_name` |
### 3.5 前端静态 CSV 模板
本期需要前端提供两份静态文件模板,**都是 CSV不接受 Excel`.xls`/`.xlsx`**。文件使用 UTF-8 编码,允许 UTF-8 BOM最大 10MB最多 1000 行数据(不含表头)。每个文件只允许一列,不要添加空行、说明行或示例外的其他列。
#### 模板一:批量订购套餐
- 建议文件名:`批量订购套餐模板.csv`
- 上传用途:`purpose=batch_purchase`
- 套餐、支付方式和线下凭证由页面另外选择,不放在 CSV 中。
| 列序号 | 固定表头 | 必填 | 填写内容 |
| --- | --- | --- | --- |
| 1 | `资产标识` | 是 | 每行一个资产标识。卡支持 ICCID、VirtualNo 或 MSISDN设备支持 VirtualNo、IMEI 或 SN |
```csv
资产标识
89860012345678901234
CARD-VIRTUAL-0001
DEVICE-VIRTUAL-0001
860123456789012
```
#### 模板二:设备批量分配
- 建议文件名:`设备批量分配模板.csv`
- 上传用途:`purpose=device_batch_allocation`
- 目标代理或套餐系列由页面另外选择,不放在 CSV 中。“分配代理”和“设置套餐系列”可以共用这一份模板。
| 列序号 | 固定表头 | 必填 | 填写内容 |
| --- | --- | --- | --- |
| 1 | `设备标识` | 是 | 每行一个设备标识,支持 VirtualNo、IMEI 或 SN |
```csv
设备标识
DEVICE-VIRTUAL-0001
860123456789012
SN202607250001
```
> 前端下载的静态模板可以只保留表头,上述数据行仅用于说明格式。资产标识必须按文本原样保存,不得转换为科学计数法、浮点数或截断前导零。
## 四、前端本期最容易漏掉的工作 ## 四、前端本期最容易漏掉的工作
- 换货列表拆成新、旧资产两个搜索参数。 - 换货列表拆成新、旧资产两个搜索参数。

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` 为准。 接口统一使用 `{code,msg,data,timestamp}` 响应。完整请求和响应结构以 `docs/admin-openapi.yaml` 为准。
@@ -14,8 +14,7 @@
| --- | --- | --- | | --- | --- | --- |
| `JUNHONG_WECOM_BASE_URL` | 环境变量 | 默认 `https://qyapi.weixin.qq.com` | | `JUNHONG_WECOM_BASE_URL` | 环境变量 | 默认 `https://qyapi.weixin.qq.com` |
| `JUNHONG_WECOM_TIMEOUT` | 环境变量 | 默认 `10s` | | `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 位 | | 回调 Token、EncodingAESKey | 同上 | Token 按明文提交EncodingAESKey 固定 43 位 |
| 默认发起人 | `PUT /api/admin/wecom/applications/:id/default-creator` | 必须先同步可见成员,再从当前可见成员中选择 | | 默认发起人 | `PUT /api/admin/wecom/applications/:id/default-creator` | 必须先同步可见成员,再从当前可见成员中选择 |
| `template_id` 和控件映射 | `PUT /api/admin/wecom/scenes/:business_type` | 模板必须先在企微后台创建,本系统只校验和绑定 | | `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。 2. 创建应用配置并调用 `POST /api/admin/wecom/applications/:id/test` 验证取 token。
3. 调用 `POST /api/admin/wecom/applications/:id/members/sync`,再分页查询成员。 3. 调用 `POST /api/admin/wecom/applications/:id/members/sync`,再分页查询成员。
4. 为内部系统账号调用 `PUT /api/admin/accounts/:id/wecom-binding` 绑定 `(corp_id,userid)` 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` 联调时确认 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 ## 对象存储和 CSV
先调用 `POST /api/admin/storage/upload-url` 获取预签名地址,再把文件直传对象存储,业务接口只接收返回的 `file_key` 先调用 `POST /api/admin/storage/upload-url` 获取预签名地址,再把文件直传对象存储,业务接口只接收返回的 `file_key`
@@ -115,11 +127,11 @@ API 与 Worker 必须连接同一 Redis/Asynq。新增或复用的任务如下
- 卡限速不保存本地当前档位,结果未知必须人工向 Gateway 核对。 - 卡限速不保存本地当前档位,结果未知必须人工向 Gateway 核对。
- CSV 任务逐行部分成功,不保证整批业务原子性;任务详情是失败和跳过原因的权威展示。 - CSV 任务逐行部分成功,不保证整批业务原子性;任务详情是失败和跳过原因的权威展示。
- 店铺登录限制不主动吊销既有 C 端 Token。 - 店铺登录限制不主动吊销既有 C 端 Token。
- 测试库已完成 `000182``000194` 结构落库和注释核对;其他环境尚未执行迁移,外部系统闭环和前端浏览器验收未完成。 - 测试库此前已完成 `000182``000194` 结构落库和注释核对;`000195``000196` 将在下一次测试环境部署时执行,外部系统闭环和前端浏览器验收未完成。
## 发布和回滚 ## 发布和回滚
1. 测试库已执行至 `000194`;其他环境发布时仍须按迁移顺序执行并核对最终版本和表结构 1. 发布目标数据库必须执行至 `000196`,并核对 `tb_system_config` 已出现 6 个七月迭代默认配置、`tb_wecom_application` 已使用明文凭据字段;测试环境由 API 容器启动脚本自动迁移
2. 先部署数据库兼容代码、API 和 Worker保持旧审批入口开启再配置企微应用、模板、回调和默认发起人。 2. 先部署数据库兼容代码、API 和 Worker保持旧审批入口开启再配置企微应用、模板、回调和默认发起人。
3. 真实审批闭环通过后,先隐藏前端旧按钮,再关闭旧入口配置并重启 API。 3. 真实审批闭环通过后,先隐藏前端旧按钮,再关闭旧入口配置并重启 API。
4. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。 4. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。

View File

@@ -8661,19 +8661,19 @@ components:
description: 企业微信自建应用 AgentID description: 企业微信自建应用 AgentID
type: integer type: integer
callback_token: callback_token:
description: 回调 Token管理端使用明文传入,服务端加密保存 description: 回调 Token服务端明文保存
type: string type: string
corp_id: corp_id:
description: 企业微信企业 ID description: 企业微信企业 ID
type: string type: string
encoding_aes_key: encoding_aes_key:
description: 回调 EncodingAESKey管理端使用明文传入,服务端加密保存 description: 回调 EncodingAESKey服务端明文保存
type: string type: string
name: name:
description: 应用展示名称 description: 应用展示名称
type: string type: string
secret: secret:
description: 应用 Secret管理端使用明文传入,服务端加密保存 description: 应用 Secret服务端明文保存
type: string type: string
status: status:
description: 状态 (0:禁用, 1:启用) 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_BASE_URL` | `https://qyapi.weixin.qq.com` | 企业微信 API 基础地址 |
| `JUNHONG_WECOM_TIMEOUT` | `10s` | 企业微信外部请求超时时间 | | `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。 管理端按业务需要使用明文填写和读取连接参数PostgreSQL 直接保存应用 Secret、回调 Token 和 EncodingAESKey 明文日志、配置审计和 Integration Log 仍不得记录这些连接凭据或 access_token。
启用前必须设置 `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY`,值为 32 字节随机密钥的 Base64 文本。未配置或格式错误时服务仍可启动,但企业微信相关业务接口失败关闭。
## 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` 增加企微绑定字段和唯一索引。 - 迁移 `000185_add_wecom_member_binding` 新增 `tb_wecom_member`,并为 `tb_account` 增加企微绑定字段和唯一索引。
- 迁移 `000186_create_wecom_approval_scene` 新增当前业务场景、模板和控件映射配置表。 - 迁移 `000186_create_wecom_approval_scene` 新增当前业务场景、模板和控件映射配置表。
- 迁移 `000187_add_wecom_approval_submission` 增加应用默认发起人,并新增 `tb_wecom_approval_context` 保存不含凭据的模板、发起人和提交状态快照。 - 迁移 `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) 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 的边界。 // AccessTokenProvider 定义按应用取得及失效 access_token 的边界。
type AccessTokenProvider interface { type AccessTokenProvider interface {
GetAccessToken(ctx context.Context, applicationID uint) (string, error) GetAccessToken(ctx context.Context, applicationID uint) (string, error)
Invalidate(ctx context.Context, applicationID uint) Invalidate(ctx context.Context, applicationID uint)
} }
// ConnectionService 保存加密配置并测试企业微信连接。 // ConnectionService 保存应用配置并测试企业微信连接。
type ConnectionService struct { type ConnectionService struct {
db *gorm.DB db *gorm.DB
repo ApplicationRepository repo ApplicationRepository
cipher CredentialCipher
tokens AccessTokenProvider tokens AccessTokenProvider
audit systemconfigapp.AuditWriter audit systemconfigapp.AuditWriter
members DefaultCreatorMemberFinder members DefaultCreatorMemberFinder
@@ -61,14 +54,14 @@ func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorM
} }
// NewConnectionService 创建企业微信连接用例。 // NewConnectionService 创建企业微信连接用例。
func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService { func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService {
return &ConnectionService{db: db, repo: repo, cipher: cipher, tokens: tokens, audit: audit, now: time.Now} 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) { 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 { if s == nil || s.db == nil || s.repo == nil {
return nil, errors.New(errors.CodeWeComCredentialInvalid) return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
} }
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden) return nil, errors.New(errors.CodeForbidden)
@@ -77,21 +70,9 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
if operatorID == 0 { if operatorID == 0 {
return nil, errors.New(errors.CodeInvalidParam) 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() now := s.now().UTC()
var saved *model.WeComApplication 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 { if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil {
return err return err
} }
@@ -111,9 +92,9 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
before = applicationAuditSnapshot(existing) before = applicationAuditSnapshot(existing)
} }
existing.Name = request.Name existing.Name = request.Name
existing.SecretCiphertext = secret existing.Secret = request.Secret
existing.CallbackTokenCiphertext = callbackToken existing.CallbackToken = request.CallbackToken
existing.EncodingAESKeyCiphertext = encodingAESKey existing.EncodingAESKey = request.EncodingAESKey
existing.Status = request.Status existing.Status = request.Status
existing.UpdatedBy = operatorID existing.UpdatedBy = operatorID
existing.UpdatedAt = now existing.UpdatedAt = now
@@ -150,14 +131,14 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
if s.tokens != nil { if s.tokens != nil {
s.tokens.Invalidate(ctx, saved.ID) s.tokens.Invalidate(ctx, saved.ID)
} }
response := toApplicationResponse(*saved, request.Secret, request.CallbackToken, request.EncodingAESKey) response := toApplicationResponse(*saved)
return &response, nil return &response, nil
} }
// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。 // List 返回企业微信应用列表,并向超级管理员返回可直接编辑的凭据。
func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) { func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) {
if s == nil || s.repo == nil || s.cipher == nil { if s == nil || s.repo == nil {
return nil, errors.New(errors.CodeWeComCredentialInvalid) return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
} }
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden) 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)) result := make([]dto.WeComApplicationResponse, 0, len(applications))
for _, application := range applications { for _, application := range applications {
secret, err := s.cipher.Decrypt(application.SecretCiphertext) result = append(result, toApplicationResponse(application))
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))
} }
return &dto.WeComApplicationListResponse{ return &dto.WeComApplicationListResponse{
Items: result, Total: total, Page: request.Page, PageSize: request.PageSize, 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 从应用当前可见成员中保存代理等账号使用的默认审批发起人。 // SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) { 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, "企业微信默认审批发起人服务未配置") return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
} }
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { 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, "保存企业微信默认审批发起人失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败")
} }
secret, err := s.cipher.Decrypt(application.SecretCiphertext) response := toApplicationResponse(*application)
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)
return &response, nil 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 := "禁用" statusName := "禁用"
if application.Status == constants.StatusEnabled { if application.Status == constants.StatusEnabled {
statusName = "启用" statusName = "启用"
} }
return dto.WeComApplicationResponse{ return dto.WeComApplicationResponse{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name, 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, DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName,
Status: application.Status, StatusName: statusName, 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, 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/config"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"go.uber.org/zap"
) )
func initHandlers(svc *services, deps *Dependencies) *Handlers { 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, deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
) )
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB) wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
var credentialCipher *wecomInfra.CredentialCipher
wecomBaseURL := "" wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout wecomTimeout := constants.WeComDefaultHTTPTimeout
if cfg := config.Get(); cfg != nil { if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout 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( wecomTokens := wecomInfra.NewTokenProvider(
wecomRepository, credentialCipher, deps.Redis, integrationlog.NewRepository(deps.DB), wecomRepository, deps.Redis, integrationlog.NewRepository(deps.DB),
wecomBaseURL, wecomTimeout, deps.Logger, wecomBaseURL, wecomTimeout, deps.Logger,
) )
wecomConnections := wecomApp.NewConnectionService( wecomConnections := wecomApp.NewConnectionService(
deps.DB, wecomRepository, credentialCipher, wecomTokens, deps.SystemConfigAudit, deps.DB, wecomRepository, wecomTokens, deps.SystemConfigAudit,
) )
wecomMembers := wecomInfra.NewMemberRepository(deps.DB) wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers) wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
@@ -146,7 +136,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit, wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit,
) )
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService( wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
wecomRepository, credentialCipher, integrationlog.NewRepository(deps.DB), deps.QueueClient, wecomRepository, integrationlog.NewRepository(deps.DB), deps.QueueClient,
)) ))
svc.Account.SetWeComMemberFinder(wecomMembers) svc.Account.SetWeComMemberFinder(wecomMembers)

View File

@@ -291,15 +291,13 @@ func initServices(s *stores, deps *Dependencies) *services {
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB) wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)
wecomBaseURL := "" wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout wecomTimeout := constants.WeComDefaultHTTPTimeout
var wecomCredentialCipher *wecomInfra.CredentialCipher
if cfg := config.Get(); cfg != nil { if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout wecomTimeout = cfg.WeCom.Timeout
wecomCredentialCipher, _ = wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
} }
wecomIntegrationRepository := integrationlog.NewRepository(deps.DB) wecomIntegrationRepository := integrationlog.NewRepository(deps.DB)
wecomTokenProvider := wecomInfra.NewTokenProvider( wecomTokenProvider := wecomInfra.NewTokenProvider(
wecomApplicationRepository, wecomCredentialCipher, deps.Redis, wecomIntegrationRepository, wecomApplicationRepository, deps.Redis, wecomIntegrationRepository,
wecomBaseURL, wecomTimeout, deps.Logger, wecomBaseURL, wecomTimeout, deps.Logger,
) )
approvalCreationService := approvalApp.NewCreationService( approvalCreationService := approvalApp.NewCreationService(

View File

@@ -44,15 +44,15 @@ func (r *ApplicationRepository) Create(ctx context.Context, tx *gorm.DB, applica
return nil return nil
} }
// Update 更新企业微信应用配置及密文凭据。 // Update 更新企业微信应用配置及连接凭据。
func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error { func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error {
updates := map[string]any{ updates := map[string]any{
"name": application.Name, "secret_ciphertext": application.SecretCiphertext, "name": application.Name, "secret": application.Secret,
"callback_token_ciphertext": application.CallbackTokenCiphertext, "callback_token": application.CallbackToken,
"encoding_aes_key_ciphertext": application.EncodingAESKeyCiphertext, "encoding_aes_key": application.EncodingAESKey,
"default_creator_userid": application.DefaultCreatorUserID, "default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName, "default_creator_name": application.DefaultCreatorName,
"status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt, "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 { if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", application.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败") return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败")
@@ -73,7 +73,7 @@ func (r *ApplicationRepository) UpdateDefaultCreator(ctx context.Context, tx *go
return nil return nil
} }
// List 分页查询企业微信应用配置及密文凭据。 // List 分页查询企业微信应用配置及连接凭据。
func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) { func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComApplication{}) query := r.db.WithContext(ctx).Model(&model.WeComApplication{})
var total int64 var total int64
@@ -87,7 +87,7 @@ func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([
return applications, total, nil return applications, total, nil
} }
// GetEnabled 查询启用的企业微信应用及密文凭据。 // GetEnabled 查询启用的企业微信应用及连接凭据。
func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) { func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) {
var application model.WeComApplication var application model.WeComApplication
if err := r.db.WithContext(ctx).Where("id = ? AND status = ?", applicationID, constants.StatusEnabled).First(&application).Error; err != nil { 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 校验解密企微回调、记录入站幂等事实并异步拉取详情。 // CallbackService 校验解密企微回调、记录入站幂等事实并异步拉取详情。
type CallbackService struct { type CallbackService struct {
applications *ApplicationRepository applications *ApplicationRepository
cipher *CredentialCipher
integration callbackIntegrationLog integration callbackIntegrationLog
queue *queue.Client queue *queue.Client
} }
// NewCallbackService 创建企业微信审批回调服务。 // NewCallbackService 创建企业微信审批回调服务。
func NewCallbackService(applications *ApplicationRepository, cipher *CredentialCipher, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService { func NewCallbackService(applications *ApplicationRepository, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService {
return &CallbackService{applications: applications, cipher: cipher, integration: integration, queue: queueClient} return &CallbackService{applications: applications, integration: integration, queue: queueClient}
} }
// VerifyURL 校验企微回调 URL 并返回 echostr 明文。 // 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) { 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, "企业微信审批回调服务未配置") return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置")
} }
application, err := s.applications.GetEnabled(ctx, applicationID) application, err := s.applications.GetEnabled(ctx, applicationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
token, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) return NewCallbackCrypto(application.CallbackToken, application.EncodingAESKey, application.CorpID)
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)
} }
func applicationCallbackIdempotencyKey(applicationID uint, signature string) string { 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 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 边界。 // TokenIntegrationLog 定义企微外呼的 Integration Log 边界。
type TokenIntegrationLog interface { type TokenIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error) Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
@@ -48,7 +43,6 @@ type TokenIntegrationLog interface {
// TokenProvider 按应用缓存企业微信 access_token并用 Redis 短锁抑制并发回源。 // TokenProvider 按应用缓存企业微信 access_token并用 Redis 短锁抑制并发回源。
type TokenProvider struct { type TokenProvider struct {
repo TokenApplicationRepository repo TokenApplicationRepository
cipher TokenCredentialCipher
redis *redis.Client redis *redis.Client
integration TokenIntegrationLog integration TokenIntegrationLog
httpClient *http.Client httpClient *http.Client
@@ -60,7 +54,6 @@ type TokenProvider struct {
// NewTokenProvider 创建企业微信 access_token Provider。 // NewTokenProvider 创建企业微信 access_token Provider。
func NewTokenProvider( func NewTokenProvider(
repo TokenApplicationRepository, repo TokenApplicationRepository,
cipher TokenCredentialCipher,
redisClient *redis.Client, redisClient *redis.Client,
integration TokenIntegrationLog, integration TokenIntegrationLog,
baseURL string, baseURL string,
@@ -71,7 +64,7 @@ func NewTokenProvider(
timeout = constants.WeComDefaultHTTPTimeout timeout = constants.WeComDefaultHTTPTimeout
} }
return &TokenProvider{ 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, "/"), httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"),
logger: logger, now: time.Now, logger: logger, now: time.Now,
} }
@@ -79,7 +72,7 @@ func NewTokenProvider(
// GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。 // GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。
func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) { 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) return "", errors.New(errors.CodeWeComCredentialInvalid)
} }
cacheKey := constants.RedisWeComAccessTokenKey(applicationID) cacheKey := constants.RedisWeComAccessTokenKey(applicationID)
@@ -162,11 +155,7 @@ func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, c
if err != nil { if err != nil {
return "", err return "", err
} }
secret, err := p.cipher.Decrypt(application.SecretCiphertext) request, err := p.newTokenRequest(ctx, application.CorpID, application.Secret)
if err != nil {
return "", err
}
request, err := p.newTokenRequest(ctx, application.CorpID, secret)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -7,9 +7,9 @@ type SaveWeComApplicationRequest struct {
CorpID string `json:"corp_id" validate:"required,min=1,max=64" description:"企业微信企业 ID"` CorpID string `json:"corp_id" validate:"required,min=1,max=64" description:"企业微信企业 ID"`
AgentID int64 `json:"agent_id" validate:"required,gt=0" description:"企业微信自建应用 AgentID"` AgentID int64 `json:"agent_id" validate:"required,gt=0" description:"企业微信自建应用 AgentID"`
Name string `json:"name" validate:"required,min=1,max=100" description:"应用展示名称"` Name string `json:"name" validate:"required,min=1,max=100" description:"应用展示名称"`
Secret string `json:"secret" validate:"required,min=1,max=512" description:"应用 Secret管理端使用明文传入,服务端加密保存"` 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管理端使用明文传入,服务端加密保存"` 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管理端使用明文传入,服务端加密保存"` 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:启用)"` Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"`
} }

View File

@@ -6,21 +6,21 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// WeComApplication 企业微信自建应用及加密连接凭据。 // WeComApplication 企业微信自建应用及连接凭据。
type WeComApplication struct { type WeComApplication struct {
gorm.Model 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"` 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"` 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"` Name string `gorm:"column:name;type:varchar(100);not null" json:"name"`
SecretCiphertext []byte `gorm:"column:secret_ciphertext;type:bytea;not null" json:"-"` Secret string `gorm:"column:secret;type:varchar(512);not null" json:"-"`
CallbackTokenCiphertext []byte `gorm:"column:callback_token_ciphertext;type:bytea;not null" json:"-"` CallbackToken string `gorm:"column:callback_token;type:varchar(512);not null" json:"-"`
EncodingAESKeyCiphertext []byte `gorm:"column:encoding_aes_key_ciphertext;type:bytea;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"` 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"` 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"` Status int `gorm:"column:status;type:int;not null;default:1" json:"status"`
CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"` CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"`
UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_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"` LastConnectedAt *time.Time `gorm:"column:last_connected_at;type:timestamptz" json:"last_connected_at,omitempty"`
} }
// TableName 指定企业微信应用表名。 // TableName 指定企业微信应用表名。

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

@@ -37,7 +37,7 @@
| 需求切片 | 主通道 | 完整业务边界 | 明确不迁移范围 | | 需求切片 | 主通道 | 完整业务边界 | 明确不迁移范围 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| #189#181#57 | 旧 `Handler → Service → Store → Model` | 对现有换货、退款、订单路径做局部修复和拦截 | 不迁移换货、退款或订单模块 | | #189#181#57 | 旧 `Handler → Service → Store → Model` | 对现有换货、退款、订单路径做局部修复和拦截;新建设备退款固化订单资产标识快照 | 不迁移换货、退款或订单模块,不兼容历史空快照 |
| #182#44#53 | 既有 Query 或旧 Store 查询 | 列表/详情字段、实名筛选、批量账号名称解析 | 不建聚合、历史投影或事件消费者 | | #182#44#53 | 既有 Query 或旧 Store 查询 | 列表/详情字段、实名筛选、批量账号名称解析 | 不建聚合、历史投影或事件消费者 |
| #41#62#48 | 简单写 + 既有读取 | 店铺开关、实名策略、系统配置的更新和查询 | 不建领域模型,不重构认证/支付模块 | | #41#62#48 | 简单写 + 既有读取 | 店铺开关、实名策略、系统配置的更新和查询 | 不建领域模型,不重构认证/支付模块 |
| #188#97#33 | Application/既有通知 Adapter | 产生明确业务事件后向既有站内通知接线 | 不建营销平台或新通知中心 | | #188#97#33 | Application/既有通知 Adapter | 产生明确业务事件后向既有站内通知接线 | 不建营销平台或新通知中心 |
@@ -63,8 +63,9 @@
| 企微场景配置 | 管理员配置 `business_type → template_id` 及业务字段到控件 ID/类型/选项 key 的映射;模板必须先在企微后台创建 | | 企微场景配置 | 管理员配置 `business_type → template_id` 及业务字段到控件 ID/类型/选项 key 的映射;模板必须先在企微后台创建 |
| 实名顺序 | 保持已约定的单资产 PATCH、卡批量 POST、设备批量 POST枚举仅为 `none/before_order/after_order`,批量上限 500、全成全败 | | 实名顺序 | 保持已约定的单资产 PATCH、卡批量 POST、设备批量 POST枚举仅为 `none/before_order/after_order`,批量上限 500、全成全败 |
| 列表人员字段 | 退款、充值、换货返回提交人 ID/名称;审批人仅从已同步企微详情中的 userid 批量映射,映射不到返回空,不实时调用企微 | | 列表人员字段 | 退款、充值、换货返回提交人 ID/名称;审批人仅从已同步企微详情中的 userid 批量映射,映射不到返回空,不实时调用企微 |
| 设备退款资产标识 | 创建退款时从订单固化 `asset_identifier`;列表和详情只返回退款快照,不按 `device_id` 查询当前设备,不兼容历史空快照;单卡继续使用 ICCID 快照 |
| 批量订购 | CSV 只有资产标识列;请求额外选择一个套餐和一个支付方式,不选择代理;整批使用相同参数 | | 批量订购 | CSV 只有资产标识列;请求额外选择一个套餐和一个支付方式,不选择代理;整批使用相同参数 |
| 导出 | 复用现有导出任务入口,通过六个 datasource code 区分;每类只输出本业务已有且已确认的字段 | | 导出 | 复用现有导出任务入口,通过六个 datasource code 区分;每类只输出系统现有且有稳定来源的字段,原始字段清单中当前无法提供的字段不伪造、不新增模型,也不阻断其他字段导出 |
| 支付方式 | `system_config` 分别保存卡和设备允许的支付方式;两类资产默认均启用 `wallet/wechat/alipay`三项可独立取消但至少保留一项C 端返回当前购包场景有效集合,创建订单时后端再次校验 | | 支付方式 | `system_config` 分别保存卡和设备允许的支付方式;两类资产默认均启用 `wallet/wechat/alipay`三项可独立取消但至少保留一项C 端返回当前购包场景有效集合,创建订单时后端再次校验 |
| 设备批量分配 | 复用现有导入任务 API 和状态模型CSV 单列设备标识;业务参数选择“代理”或“套餐系列”及目标 ID | | 设备批量分配 | 复用现有导入任务 API 和状态模型CSV 单列设备标识;业务参数选择“代理”或“套餐系列”及目标 ID |
@@ -76,7 +77,7 @@
### 5. 企微只做 Adapter审批流程仍由企微模板拥有 ### 5. 企微只做 Adapter审批流程仍由企微模板拥有
系统按应用保存加密 Secret、回调 Token、EncodingAESKey、`corp_id/agent_id` 和一个从当前可见成员中选择的默认审批发起人;`access_token` 按应用缓存并预留提前刷新。管理员从应用可见通讯录选择成员,账号绑定键为 `(corp_id, userid)`,姓名和部门仅为展示快照。内部员工已绑定且仍可见时优先以本人发起,代理等非企微账号始终以应用默认成员发起;本地申请仍保留真实业务提交人,不用默认成员冒充业务操作者。 系统按应用明文保存 Secret、回调 Token、EncodingAESKey、`corp_id/agent_id` 和一个从当前可见成员中选择的默认审批发起人;不要求额外启动加密密钥。`access_token` 按应用缓存并预留提前刷新。管理员从应用可见通讯录选择成员,账号绑定键为 `(corp_id, userid)`,姓名和部门仅为展示快照。内部员工已绑定且仍可见时优先以本人发起,代理等非企微账号始终以应用默认成员发起;本地申请仍保留真实业务提交人,不用默认成员冒充业务操作者。
场景配置保存已知 `template_id`。保存或发布时调用 `oa/gettemplatedetail` 校验控件 ID、类型、必填项和选择项 key。系统不创建模板、不保存审批节点、不计算部门领导或财务人员`oa/applyevent` 使用 `use_template_approver=1`,审批人由企微后台模板决定。 场景配置保存已知 `template_id`。保存或发布时调用 `oa/gettemplatedetail` 校验控件 ID、类型、必填项和选择项 key。系统不创建模板、不保存审批节点、不计算部门领导或财务人员`oa/applyevent` 使用 `use_template_approver=1`,审批人由企微后台模板决定。
@@ -104,7 +105,7 @@
批量订购和设备分配只新增业务解析器/执行器继续使用现有对象存储、任务五态、Asynq 重试、失败明细和下载能力。文件级校验失败不写业务数据;涉及扣款/订购时按现有订单幂等键和钱包流水保证不重复扣款。 批量订购和设备分配只新增业务解析器/执行器继续使用现有对象存储、任务五态、Asynq 重试、失败明细和下载能力。文件级校验失败不写业务数据;涉及扣款/订购时按现有订单幂等键和钱包流水保证不重复扣款。
六类导出分别实现 datasource查询直接投影 DTO不串联多个业务迁移。不存在的字段不得临时创造含义;先从既有数据组合,确认确实缺失后再增加最小字段迁移。 六类导出分别实现 datasource查询直接投影 DTO不串联多个业务迁移。不存在或无稳定来源的字段不得临时创造含义,本 Change 不为此新增字段迁移;该字段从导出表头中省略并在对接说明中记录,不阻断其他稳定字段的导出交付
### 8. 数据、事务、常量与缓存 ### 8. 数据、事务、常量与缓存

View File

@@ -6,15 +6,15 @@
- 新 Change 完整替代 `complete-july-iteration-test-release` 的后续规划;旧 Change 在本 Change 校验通过前保留完成证据,之后归档为被替代,不继续执行其未完成任务。 - 新 Change 完整替代 `complete-july-iteration-test-release` 的后续规划;旧 Change 在本 Change 校验通过前保留完成证据,之后归档为被替代,不继续执行其未完成任务。
- 实施遵循“存量能力优先”优先修改或复用现有接口、字段、Service、Query、任务和基础设施只有现有能力确实无法承载已确认需求时才允许做边界最小、可说明必要性的新增。 - 实施遵循“存量能力优先”优先修改或复用现有接口、字段、Service、Query、任务和基础设施只有现有能力确实无法承载已确认需求时才允许做边界最小、可说明必要性的新增。
- 本 Change 当前交付不编写或补齐测试代码,不运行单元、集成、验收或业务流程测试,也不执行 `go test``go build`、LSP 诊断、迁移和 OpenAPI 生成;只完成生产代码、必要迁移文件和契约文档至可联调状态,可使用 `gofmt`、只读检查与 `git diff --check` 做静态收口。 - 本 Change 当前交付不编写或补齐测试代码,不运行单元、集成、验收或业务流程测试,也不执行 `go test`常规 `go build`、LSP 诊断和实际迁移;只完成生产代码、必要迁移文件和契约文档至可联调状态,修改 API 契约后必须重新生成 OpenAPI 核对,可使用 `gofmt`、只读检查与 `git diff --check` 做静态收口。
-#189#181#57 等缺陷和 #53#44#182 等字段/筛选需求收敛为旧 Service、Store 和现有 Query 上的局部修改,不进行 DDD 迁移。 -#189#181#57 等缺陷和 #53#44#182 等字段/筛选需求收敛为旧 Service、Store 和现有 Query 上的局部修改,不进行 DDD 迁移#181 的设备退款在创建时固化资产标识快照,列表和详情直接返回快照,不兼容历史空快照
- 增加店铺级 C 端登录限制开关;仅阻止该店铺资产发起新登录,不建立代理 API 权限体系,也不强制吊销已登录 Token。 - 增加店铺级 C 端登录限制开关;仅阻止该店铺资产发起新登录,不建立代理 API 权限体系,也不强制吊销已登录 Token。
- 补齐三种实名顺序及卡/设备批量配置,保持既有 `realname_policy` 模型和前后端接口约定。 - 补齐三种实名顺序及卡/设备批量配置,保持既有 `realname_policy` 模型和前后端接口约定。
- 复用已有站内通知,完成换货单弹窗、固定 100 元钱包余额提醒、套餐 15/7/3 天临期列表和 C 端提醒,不建设通用营销平台。 - 复用已有站内通知,完成换货单弹窗、固定 100 元钱包余额提醒、套餐 15/7/3 天临期列表和 C 端提醒,不建设通用营销平台。
- 复用已完成的渠道无关审批核心,交付最小企业微信 Adapter管理员从通讯录为系统账号绑定 `(corp_id, userid)`,后台创建模板,本系统配置业务场景/模板/控件映射,完成发起、回调、详情查询和轮询补偿;不做扫码绑定、本地流程设计器或未来渠道抽象扩建。 - 复用已完成的渠道无关审批核心,交付最小企业微信 Adapter管理员从通讯录为系统账号绑定 `(corp_id, userid)`,后台创建模板,本系统配置业务场景/模板/控件映射,完成发起、回调、详情查询和轮询补偿;不做扫码绑定、本地流程设计器或未来渠道抽象扩建。
- **BREAKING**:退款和员工线下代充值的人工审批结果改由企业微信终态驱动;新链路可用后停用原系统内人工通过/驳回入口。代理在线扫码充值后置。 - **BREAKING**:退款和员工线下代充值的人工审批结果改由企业微信终态驱动;新链路可用后停用原系统内人工通过/驳回入口。代理在线扫码充值后置。
- 交付单列 CSV 批量订购、六类业务导出、固定档位限速、按资产类型配置支付方式、CSV 批量分配设备等已确认功能均复用现有导入、导出、Gateway、`system_config` 和任务基础设施。 - 交付单列 CSV 批量订购、六类业务导出、固定档位限速、按资产类型配置支付方式、CSV 批量分配设备等已确认功能均复用现有导入、导出、Gateway、`system_config` 和任务基础设施;批量订购沿用现有后台认证和入口可见性,不新增“内部员工”账号类型限制;导出仅输出系统现有且有稳定来源的字段,不为原始清单中的缺失字段新增模型或迁移
- 冻结已经完成的 #45#46#55#60#86#38#94#96#98#43,仅做接口联调或代码证据核验;冻结行业卡现有复机行为,不按旧提案改写。 - 冻结已经完成的 #45#46#55#60#86#38#94#96#98#43,仅做接口联调或代码证据核验;#43 前端可复用套餐列表的建议售价/公司成本价与授权详情的已授权套餐 ID 完成展示和区分,无需新增后端接口;冻结行业卡现有复机行为,不按旧提案改写。
- 明确排除原路退款、聚水潭、跨品类换货、分销佣金提现、代理在线扫码充值、通用营销/ERP、自动限速、本地审批流引擎、全局 Audit Event 专项,以及已关闭且不处理的需求。 - 明确排除原路退款、聚水潭、跨品类换货、分销佣金提现、代理在线扫码充值、通用营销/ERP、自动限速、本地审批流引擎、全局 Audit Event 专项,以及已关闭且不处理的需求。
## Capabilities ## Capabilities
@@ -33,7 +33,7 @@
- `card-replacement`: 换货前拦截活跃退款,并修复换货套餐在原订单退款后未失效的问题。 - `card-replacement`: 换货前拦截活跃退款,并修复换货套餐在原订单退款后未失效的问题。
- `exchange-client-notification`: 创建物流换货单后复用站内通知在 C 端弹窗。 - `exchange-client-notification`: 创建物流换货单后复用站内通知在 C 端弹窗。
- `order-management`: 修复 C 端订单渠道和订单资产标识返回,并支持从历史订单使用稳定资产/套餐引用发起新的下架套餐续费订单。 - `order-management`: 修复 C 端订单渠道和订单资产标识返回,并支持从历史订单使用稳定资产/套餐引用发起新的下架套餐续费订单。
- `refund-api`: 返回正确设备资产标识、提交人,并将退款审批结果切换为企微驱动。 - `refund-api`: 新建退款固化并返回正确设备资产标识快照和提交人,不兼容历史空快照,并将退款审批结果切换为企微驱动。
- `agent-recharge`: 返回提交人,仅保留员工线下代充值并接入企微审批,代理在线扫码充值后置。 - `agent-recharge`: 返回提交人,仅保留员工线下代充值并接入企微审批,代理在线扫码充值后置。
- `exchange-admin-management`: 换货列表和详情返回提交人。 - `exchange-admin-management`: 换货列表和详情返回提交人。
- `asset-realname-policy`: 支持三种实名顺序、单资产修改、卡/设备批量修改和 C 端生效策略字段。 - `asset-realname-policy`: 支持三种实名顺序、单资产修改、卡/设备批量修改和 C 端生效策略字段。
@@ -50,4 +50,4 @@
- **数据与基础设施**:使用 PostgreSQL、Redis/Asynq、现有 Outbox、Integration Log、对象存储、站内通知和 `system_config`;不新增外键或 GORM 关联标签,不引入新依赖。 - **数据与基础设施**:使用 PostgreSQL、Redis/Asynq、现有 Outbox、Integration Log、对象存储、站内通知和 `system_config`;不新增外键或 GORM 关联标签,不引入新依赖。
- **外部系统**:企业微信自建应用与 Gateway。企微上线需应用 Secret、审批权限、通讯录可见范围、可信 IP、回调 Token/EncodingAESKey 和模板 ID。 - **外部系统**:企业微信自建应用与 Gateway。企微上线需应用 Secret、审批权限、通讯录可见范围、可信 IP、回调 Token/EncodingAESKey 和模板 ID。
- **性能**:列表保持分页并批量解析提交人/审批人,禁止 N+1批量实名上限 500 且事务全成全败CSV/导出沿用异步任务,外部接口设置超时、幂等和补偿。 - **性能**:列表保持分页并批量解析提交人/审批人,禁止 N+1批量实名上限 500 且事务全成全败CSV/导出沿用异步任务,外部接口设置超时、幂等和补偿。
- **交付边界**:本 Change 以生产代码、迁移文件、接口契约、联调配置和实施证据齐备为完成标准自动化测试、构建、LSP、迁移执行、OpenAPI 生成及真实企微/Gateway 环境验收不在本次执行范围,后续联调或发布流程另行承担。 - **交付边界**:本 Change 以生产代码、迁移文件、接口契约、联调配置和实施证据齐备为完成标准;API 契约变更需重新生成 OpenAPI 核对。自动化测试、常规构建、LSP、实际迁移执行及真实企微/Gateway 环境验收不在本次执行范围,后续联调或发布流程另行承担。

View File

@@ -1,10 +1,10 @@
## ADDED Requirements ## ADDED Requirements
### Requirement: 单列 CSV 创建批量订购任务 ### Requirement: 单列 CSV 创建批量订购任务
内部员工 SHALL 上传仅包含资产标识的 CSV并在请求中为整批选择一个套餐和一个支付方式系统不得要求 CSV 包含代理、套餐系列或支付账户,也不得按行选择不同套餐。 通过现有后台认证且当前页面入口可见的用户 SHALL 上传仅包含资产标识的 CSV并在请求中为整批选择一个套餐和一个支付方式系统不新增“内部员工”账号类型限制,不得要求 CSV 包含代理、套餐系列或支付账户,也不得按行选择不同套餐。
#### Scenario: 创建合法批量订购任务 #### Scenario: 创建合法批量订购任务
- **WHEN** 员工上传单列资产 CSV 并选择有效套餐与支付方式 - **WHEN** 已通过现有后台认证的用户上传单列资产 CSV 并选择有效套餐与支付方式
- **THEN** 系统创建异步任务并通过统一响应返回任务 ID 和初始状态 - **THEN** 系统创建异步任务并通过统一响应返回任务 ID 和初始状态
### Requirement: 批量订购复用现有订单和扣款规则 ### Requirement: 批量订购复用现有订单和扣款规则
@@ -17,4 +17,3 @@
#### Scenario: 任务被重复投递 #### Scenario: 任务被重复投递
- **WHEN** Asynq 重复执行同一批量订购任务 - **WHEN** Asynq 重复执行同一批量订购任务
- **THEN** 已成功行不重复下单或扣款 - **THEN** 已成功行不重复下单或扣款

View File

@@ -8,9 +8,8 @@
- **THEN** 系统异步生成仅包含其可见退款数据的文件,并通过任务接口提供进度和下载结果 - **THEN** 系统异步生成仅包含其可见退款数据的文件,并通过任务接口提供进度和下载结果
### Requirement: 导出字段必须有稳定来源 ### Requirement: 导出字段必须有稳定来源
每个 datasource MUST 明确列名、数据来源和格式;现有表或可批量关联数据无法提供的字段不得伪造,必须先补充经确认的最小数据字段后才能导出。 每个 datasource MUST 明确列名、数据来源和格式;只输出现有表或可批量关联数据能稳定提供的字段。原始需求清单中当前无法提供的字段 MUST 省略且不得伪造,本 Change 不为此新增业务字段或迁移,也不阻断其他稳定字段导出。
#### Scenario: 字段没有数据来源 #### Scenario: 字段没有数据来源
- **WHEN** 业务要求字段在现有数据中不存在且无法可靠推导 - **WHEN** 业务要求字段在现有数据中不存在且无法可靠推导
- **THEN** 该 datasource 不得用空含义或错误值冒充字段,任务保持未交付直到字段契约确认 - **THEN** 该 datasource 不得用空含义或错误值冒充字段,应省略该列并继续导出其他有稳定来源的字段

View File

@@ -7,6 +7,10 @@
- **WHEN** 有权限用户查询设备订单产生的退款 - **WHEN** 有权限用户查询设备订单产生的退款
- **THEN** 响应返回设备资产标识、提交人 ID 和提交人名称 - **THEN** 响应返回设备资产标识、提交人 ID 和提交人名称
#### Scenario: 新建设备退款固化资产标识
- **WHEN** 用户为设备订单创建退款申请
- **THEN** 退款记录从订单固化 `asset_identifier`,列表和详情直接返回该快照,不查询设备当前标识;历史空快照保持为空
### Requirement: 退款终态由企微审批驱动 ### Requirement: 退款终态由企微审批驱动
退款申请 SHALL 关联唯一通用审批实例;企微标准决策为 approved 时执行现有退款终结rejected/cancelled/deleted 时按对应终态结束,不得由列表可见权限替代审批权限。 退款申请 SHALL 关联唯一通用审批实例;企微标准决策为 approved 时执行现有退款终结rejected/cancelled/deleted 时按对应终态结束,不得由列表可见权限替代审批权限。
@@ -29,4 +33,3 @@
**Reason**: 审批拒绝改由企业微信回调或轮询同步的标准决策驱动。 **Reason**: 审批拒绝改由企业微信回调或轮询同步的标准决策驱动。
**Migration**: 企微测试闭环可用后停用 `POST /api/admin/refunds/:id/reject`,前端改为只读展示审批状态。 **Migration**: 企微测试闭环可用后停用 `POST /api/admin/refunds/:id/reject`,前端改为只读展示审批状态。

View File

@@ -50,8 +50,12 @@
## 7. 文档与联调交付 ## 7. 文档与联调交付
- [x] 7.1 补齐所有新增/修改 API 的 DTO description、枚举名称字段、中文错误码、路由注释和统一响应示例新增 Handler 同步 `cmd/api/docs.go``cmd/gendocs/main.go`但本次不执行 OpenAPI 生成。【主API 契约】 - [x] 7.1 补齐所有新增/修改 API 的 DTO description、枚举名称字段、中文错误码、路由注释和统一响应示例新增 Handler 同步 `cmd/api/docs.go``cmd/gendocs/main.go`并按后续明确要求生成 OpenAPI 核对契约。【主API 契约】
- [x] 7.2 增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`,逐项登记 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由。【主:治理门禁】 - [x] 7.2 增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`,逐项登记 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由。【主:治理门禁】
- [x] 7.3 对全部变更生产代码执行 `gofmt`、只读一致性检查和 `git diff --check`记录未执行测试、构建、LSP、迁移和 OpenAPI 生成的验证缺口。【主:静态收口】 - [x] 7.3 对全部变更生产代码执行 `gofmt`、只读一致性检查和 `git diff --check`,记录未执行测试、常规构建、LSP 和实际迁移的验证缺口OpenAPI 已按后续明确要求生成核对。【主:静态收口】
- [x] 7.4 准备企微、Gateway、Redis/Asynq、对象存储和前端联调所需配置、接口说明、已知限制及回滚步骤本次不执行真实环境闭环。【主联调交付】 - [x] 7.4 准备企微、Gateway、Redis/Asynq、对象存储和前端联调所需配置、接口说明、已知限制及回滚步骤本次不执行真实环境闭环。【主联调交付】
- [x] 7.5 更新实施证据和任务状态,保留 `complete-july-iteration-test-release` 的历史完成证据严格校验、真实联调验收和归档由后续流程处理。【主OpenSpec 收口】 - [x] 7.5 更新实施证据和任务状态,保留 `complete-july-iteration-test-release` 的历史完成证据严格校验、真实联调验收和归档由后续流程处理。【主OpenSpec 收口】
## 8. 补充核对发现的缺口
- [x] 8.1 核对 #181 设备退款资产标识快照链路:新建退款从订单复制按 VirtualNo 优先、IMEI 兜底生成的 `asset_identifier`,列表和详情只返回退款快照,不按 `device_id` 解析当前设备且不兼容历史空快照;单卡 ICCID 口径保持不变。同步更新对接说明并重新生成 OpenAPI 核对 `asset_identifier`。Audit Event、Domain Ledger、Integration Log、Outbox 均 N/A只核对快照写入与读取不修改业务状态。【主旧 Service边界退款资产标识快照不迁移退款、订单或资产模块】

View File

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

View File

@@ -147,4 +147,3 @@ approval:
wecom: wecom:
base_url: "https://qyapi.weixin.qq.com" base_url: "https://qyapi.weixin.qq.com"
timeout: "10s" 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", "approval.legacy_offline_recharge_pay_enabled",
"wecom.base_url", "wecom.base_url",
"wecom.timeout", "wecom.timeout",
"wecom.credential_encryption_key",
"wechat.official_account.app_id", "wechat.official_account.app_id",
"wechat.official_account.app_secret", "wechat.official_account.app_secret",
"wechat.official_account.token", "wechat.official_account.token",