Compare commits
2 Commits
73f5125d3d
...
09ffee8590
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ffee8590 | |||
| cb26217205 |
10
.env
10
.env
@@ -8,4 +8,12 @@ DB_SSLMODE=disable
|
|||||||
GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服务器的ip地址或者域名
|
GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服务器的ip地址或者域名
|
||||||
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
|
||||||
|
|||||||
10
.env.local
10
.env.local
@@ -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'
|
||||||
|
|||||||
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_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'
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
|
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
|
||||||
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 "📋 更新部署配置文件..."
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
|
||||||
|
|||||||
@@ -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 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.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)
|
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 服务..."
|
||||||
|
|||||||
@@ -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` 是 1~100 项数组,每项包含 `package_id`、`cost_price`,删除时传 `remove=true` |
|
| #43 系列套餐多选 | `POST /api/admin/shop-series-grants`;`PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1~100 项数组,每项包含 `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`、金额、1~5 个 `payment_voucher_key` 和备注,创建后等待企微审批 |
|
| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、1~5 个 `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
|
||||||
|
```
|
||||||
|
|
||||||
|
> 前端下载的静态模板可以只保留表头,上述数据行仅用于说明格式。资产标识必须按文本原样保存,不得转换为科学计数法、浮点数或截断前导零。
|
||||||
|
|
||||||
## 四、前端本期最容易漏掉的工作
|
## 四、前端本期最容易漏掉的工作
|
||||||
|
|
||||||
- 换货列表拆成新、旧资产两个搜索参数。
|
- 换货列表拆成新、旧资产两个搜索参数。
|
||||||
|
|||||||
@@ -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. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。
|
||||||
|
|||||||
@@ -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:启用)
|
||||||
|
|||||||
@@ -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,不需要额外的凭据加密环境变量。
|
||||||
|
|
||||||
### 审批旧入口切换
|
### 审批旧入口切换
|
||||||
|
|
||||||
|
|||||||
@@ -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` 保存不含凭据的模板、发起人和提交状态快照。
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:启用)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 指定企业微信应用表名。
|
||||||
|
|||||||
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 明文';
|
||||||
@@ -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. 数据、事务、常量与缓存
|
||||||
|
|
||||||
|
|||||||
@@ -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 环境验收不在本次执行范围,后续联调或发布流程另行承担。
|
||||||
|
|||||||
@@ -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** 已成功行不重复下单或扣款
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
- **THEN** 系统异步生成仅包含其可见退款数据的文件,并通过任务接口提供进度和下载结果
|
- **THEN** 系统异步生成仅包含其可见退款数据的文件,并通过任务接口提供进度和下载结果
|
||||||
|
|
||||||
### Requirement: 导出字段必须有稳定来源
|
### Requirement: 导出字段必须有稳定来源
|
||||||
每个 datasource MUST 明确列名、数据来源和格式;现有表或可批量关联数据无法提供的字段不得伪造,必须先补充经确认的最小数据字段后才能导出。
|
每个 datasource MUST 明确列名、数据来源和格式;只输出现有表或可批量关联数据能稳定提供的字段。原始需求清单中当前无法提供的字段 MUST 省略且不得伪造,本 Change 不为此新增业务字段或迁移,也不阻断其他稳定字段导出。
|
||||||
|
|
||||||
#### Scenario: 字段没有数据来源
|
#### Scenario: 字段没有数据来源
|
||||||
- **WHEN** 业务要求字段在现有数据中不存在且无法可靠推导
|
- **WHEN** 业务要求字段在现有数据中不存在且无法可靠推导
|
||||||
- **THEN** 该 datasource 不得用空含义或错误值冒充字段,任务保持未交付直到字段契约确认
|
- **THEN** 该 datasource 不得用空含义或错误值冒充字段,应省略该列并继续导出其他有稳定来源的字段
|
||||||
|
|
||||||
|
|||||||
@@ -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`,前端改为只读展示审批状态。
|
||||||
|
|
||||||
|
|||||||
@@ -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|边界:退款资产标识快照|不迁移:退款、订单或资产模块】
|
||||||
|
|||||||
@@ -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 轮询通用配置
|
||||||
|
|||||||
@@ -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_KEY(32 字节 Base64)
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user