# 企业微信应用连接 Adapter 功能总结

## 交付范围

本切片提供企业微信自建应用的最小连接能力，不迁移或改造通用审批核心：

- `POST /api/admin/wecom/applications`：按 `corp_id + agent_id` 创建或更新应用配置。
- `GET /api/admin/wecom/applications`：分页查询应用配置，默认每页 20、最大 100，向超级管理员返回可直接编辑的明文 Secret、回调 Token 和 EncodingAESKey。
- `POST /api/admin/wecom/applications/:id/test`：失效旧缓存并测试能否取得 access_token，仅返回测试是否成功，不返回 access_token。
- `PUT /api/admin/wecom/applications/:id/default-creator`：从应用当前可见成员中选择代理等非企微账号使用的默认审批发起人。
- `POST /api/admin/wecom/applications/:id/members/sync`：拉取应用可见范围内的成员并原子刷新本地选择快照。
- `GET /api/admin/wecom/applications/:id/members`：按姓名或 userid 搜索并分页选择最近同步的可见成员。
- `PUT /api/admin/accounts/:id/wecom-binding`：把系统账号绑定到管理员明确选择的 `(corp_id, userid)`，同时保存姓名快照。
- `PUT /api/admin/wecom/scenes/:business_type`：配置已知 `template_id` 和业务字段控件映射，保存前实时读取模板详情校验。
- `GET /api/admin/wecom/scenes`：分页查询退款、员工线下代充值两个稳定业务场景的当前模板映射。
- 应用凭据保存、查询和连接测试仅允许超级管理员；通讯录同步、选择和账号绑定允许超级管理员或平台账号。

## 凭据与配置

管理端按业务需要使用明文填写和读取连接参数，服务端在写入 PostgreSQL 前使用 AES-256-GCM 加密。数据库不保存明文，日志、配置审计和 Integration Log 也不记录明文、密文或 access_token。

启用前必须设置 `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY`，值为 32 字节随机密钥的 Base64 文本。未配置或格式错误时服务仍可启动，但企业微信相关业务接口失败关闭。

## Token 缓存与并发控制

- access_token 使用应用配置 ID 隔离缓存，TTL 按企微返回有效期减去 5 分钟刷新窗口计算。
- 缓存未命中时通过 Redis 单应用短锁限制并发回源；未取得锁的请求最多等待 2 秒读取回填结果。
- Redis 读取或加锁故障时记录中文告警并受控直连企微，确保缓存故障不直接中断连接测试。
- 配置更新后立即失效该应用 token 缓存。

## 通讯录与账号绑定

- 同步只读取 `userid`、姓名和部门 ID，不读取手机号或邮箱，也不做手机号、姓名自动匹配。
- `tb_wecom_member` 仅保存应用可见成员的选择快照；同步时把不再可见的成员标记为不可见，不建立本地部门或组织模型。
- 成员 userid 入库前统一转为小写，身份键按 `(corp_id, userid)` 管理；同一企微成员不能同时绑定多个未删除系统账号。
- 账号列表和详情返回 `wecom_corp_id`、`wecom_userid`、`wecom_name` 和 `wecom_bound`。
- 应用禁用或成员不再可见后，不能创建新的账号绑定；历史绑定快照保留，供审批提交前再次校验。

## 默认审批发起人

- 内部超级管理员或平台员工已绑定企微成员且仍在应用可见范围内时，审批优先以本人 userid 发起。
- 代理和企业账号不要求绑定企微，始终使用应用配置的默认审批发起人；内部员工未绑定时也可回退到默认发起人。
- 默认发起人只能从最近同步的应用可见成员中选择。未配置、应用禁用或成员不再可见时，新审批在业务事实写入前失败关闭。
- 默认企微发起人只承担 `applyevent.creator_userid` 身份；本地业务单和通用审批实例继续保存真实代理或员工提交人，企微表单应通过业务字段映射展示真实申请人信息。

## Integration Log

每次真实调用 `/cgi-bin/gettoken`、可见成员接口、`/cgi-bin/oa/gettemplatedetail`、临时素材上传或 `/cgi-bin/oa/applyevent` 前先创建公共 Integration Log，完成后记录 HTTP 状态、企微错误码、耗时和安全摘要。请求摘要只包含应用配置 ID、corp_id、agent_id、template_id、控件数量和附件大小等非密钥标识；响应摘要不保存 access_token、media_id、附件正文或外部响应正文。

## 审批场景与模板映射

- 稳定业务类型固定为 `refund_approval` 和 `offline_recharge_approval`，业务代码不直接写死企微模板 ID。
- 模板必须先在企业微信后台创建；本系统不创建模板，也不保存审批节点或审批人规则。
- 保存映射时调用模板详情，逐项校验控件 ID、控件类型和选择项 key，并要求模板所有必填控件都有映射；模板结构已变化时拒绝覆盖当前有效配置。
- 员工线下代充值只允许映射 `recharge_no`、`shop_id`、`shop_name`、`amount`、`amount_cent`、`payment_voucher_key`、`remark`、`submitter_id`、`submitter_name`。
- 退款只允许映射 `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`。
- `template_snapshot` 只保存控件 ID、类型、标题、必填标识和选择项 key 的最小快照。

## 数据库变更

- 迁移 `000184_create_wecom_application` 新增 `tb_wecom_application`，以 `(corp_id, agent_id)` 作为未删除记录的唯一身份。
- 迁移 `000185_add_wecom_member_binding` 新增 `tb_wecom_member`，并为 `tb_account` 增加企微绑定字段和唯一索引。
- 迁移 `000186_create_wecom_approval_scene` 新增当前业务场景、模板和控件映射配置表。
- 迁移 `000187_add_wecom_approval_submission` 增加应用默认发起人，并新增 `tb_wecom_approval_context` 保存不含凭据的模板、发起人和提交状态快照。
- 迁移 `000188_add_wecom_approval_detail_snapshot` 增加最近企微状态、权威详情快照和同步时间。
- 迁移 `000189_add_wecom_approval_recovery_timestamps` 增加实际提交尝试时间和最近恢复时间，周期恢复不会覆盖原始查询时间窗。
- 迁移 `000190_add_agent_recharge_approval_instance` 为员工线下代充值记录增加唯一通用审批实例引用，不建立外键。
- 迁移 `000191_add_refund_approval_instance` 为退款申请增加唯一通用审批实例引用，不建立外键。
- 所有迁移都不建立数据库外键。

## Approval Port 与异步提交

- 企微 Adapter 在业务事务前重新校验场景、应用、发起人可见性和模板指纹，通过后返回不含 Secret/access_token 的短期准备结果。
- 业务事务复用通用 Approval Port，原子保存业务申请、`tb_approval_instance`、企微渠道上下文和 `approval.submission.requested` Outbox。
- Worker 领取事件后先条件更新为“请求处理中”，再按业务字段映射组装控件；文件控件从对象存储下载并上传企微临时素材，单单最多 6 个、单文件不超过 20MB。
- `applyevent` 明确成功时同时保存 `sp_no` 并把通用实例置为审批中；企微明确拒绝时记提交失败；请求已发出但超时、断连或响应不可确认时记结果未知，禁止 Outbox 自动创建第二张审批单。
- 结果未知记录明确恢复策略，主动恢复优先读取成功提交 Integration Log 中已安全保存的 `sp_no`；没有成功日志时，按应用、模板、发起人和实际提交时刻前后 5 分钟调用批量单号接口。
- 批量查询单页最多 100 条并使用 `new_cursor/new_next_cursor` 分页；排除已关联本地实例的单号后，只有唯一候选才允许恢复关联，多候选或无候选继续保持结果未知，绝不自动重提 `applyevent`。

## 加密回调与标准决策

- 回调地址为 `GET/POST /api/callback/wecom/approval/:application_id`，企业微信后台为每个应用填写对应应用配置 ID。
- GET 校验 `msg_signature`，使用当前应用的 Token 和 EncodingAESKey 执行 AES-256-CBC/PKCS#7 解密，并原样返回 `echostr` 明文。
- POST 从 XML 读取 `Encrypt`，校验签名和 `receiveid=corp_id`，只接受 `sys_approval_change`；入站 Integration Log 只保存密文载荷哈希和稳定幂等键。
- 回调在成功写入幂等记录并提交结构化 Asynq 任务后立即返回纯文本 `success`，不在 HTTP 请求内更新退款或充值状态。
- Worker 使用 `SpNoStr` 调用 `oa/getapprovaldetail` 取得权威详情，保存最近状态和详情快照，再把 2/3/4/6/7 翻译为 approved/rejected/cancelled/revoked_after_approved/deleted 并调用现有 `SyncDecisionService`。
- 标准终态继续由通用审批核心在单事务内写状态、决策投递事实和终态 Outbox；企微 Adapter 不直接执行退款或钱包入账。

## 主动恢复、轮询与读取投影

- Asynq Scheduler 每 2 分钟提交 `wecom:approval:recovery`；提交中状态占用超过 5 分钟时保守转为结果未知，不恢复为待提交。
- 已关联 `sp_no` 且通用审批仍为审批中的记录，按最近成功同步时间批量扫描并提交结构化 `wecom:approval:sync` 任务，继续复用同一详情同步和标准决策链路。
- 结果未知记录单轮最多处理 10 条，避免最坏外部超时阻塞整个周期；每次 `getapprovalinfo` 和 `getapprovaldetail` 真实外呼均写 Integration Log。
- 未知企微回调会从权威详情取得模板、申请人和提交时间，仅在本地结果未知记录唯一匹配时恢复；真正无关的审批回调记为 ignored，不反复重试。
- `ApprovalProjectionResolver` 只从 `latest_detail_snapshot.sp_record` 读取审批节点 userid，按应用企业 ID 分组后用一次账号批量查询映射系统账号；映射不到时账号 ID/名称为空，不影响审批状态，也不实时调用企微。

## 员工线下代充值审批

- `POST /api/admin/agent-recharges` 继续作为创建入口；当 `payment_method=offline` 时，仅平台员工或超级管理员可提交，并要求 1～5 个对象存储凭证 Key。
- 创建前校验提交人账号、目标店铺、正常主钱包、企微应用、场景、模板和实际发起人；任一前置不完整时，不写业务单、审批实例或 Outbox。
- 充值单保存真实系统提交人；内部员工有效绑定优先本人发起企微审批，否则使用应用默认发起人。默认发起人只作为企微 creator，不覆盖本地真实提交人。
- 充值单、提交人/金额/凭证明文快照、通用审批实例、企微渠道上下文和 `approval.submission.requested` 在同一 GORM 事务内保存。
- 标准 `approved` 终态通过统一钱包 `PostingService` 入账，稳定幂等键为 `topup + recharge_record_id`；`rejected/cancelled/deleted` 只终结申请，不修改钱包；`revoked_after_approved` 不自动冲正。
- 列表和详情按一批审批实例查询 `provider/status/status_name`，不逐条访问企微。带审批实例的新申请禁止通过旧人工确认或驳回入口绕过企微；旧入口暂时仅服务存量记录，是否整体停用由后续切换任务控制。

## 退款企微审批

- `POST /api/admin/refunds` 在既有订单、金额、资产和权限校验后，先检查企微应用、退款场景、模板和实际发起人，再原子保存退款申请、真实提交人/订单/金额/凭证明文快照、通用审批实例、企微上下文和提交 Outbox。
- 代理账号始终使用应用默认企微发起人，平台员工有效绑定优先本人、否则回退默认发起人；本地退款申请和通用审批实例始终保存真实系统提交人。
- 标准 `approved` 终态条件更新退款单和订单，复用统一代理主钱包退款及资产钱包退款逻辑；代理主钱包按退款 ID、资产钱包按退款单号复核成功流水，重复终态不会重复回款。
- `rejected/cancelled/deleted` 将本地申请终结为未退款状态，并在 `reject_reason` 保留对应企微终态；`revoked_after_approved` 不自动反向改账，需后续人工前向处理。
- 佣金回扣按佣金记录锁定、唯一业务引用和状态失效实现可重入；套餐失效沿订单/换货迁移关系复用既有幂等路径。只有佣金与资产后处理都完成后，标准决策投递才标记成功，失败会释放租约等待重试。
- 退款列表和详情批量读取审批渠道、审批状态和中文状态名，不实时调用企微。带审批实例的新退款禁止旧人工通过、拒绝或退回入口绕过企微；存量旧 provider 的入口切换由后续发布任务控制。

## 旧审批入口切换

- `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` 和 `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` 默认均为 `true`，避免新版本部署时中断 `approval_instance_id IS NULL` 的存量旧 provider。
- 真实企微闭环验收和存量处理完成后，发布配置可分别关闭退款 `approve/reject` 与线下充值 `offline-pay`；关闭需要重启 API，Worker 标准终态消费者不依赖这些开关。
- 前端切换后隐藏旧操作按钮，只读展示 `approval_instance_id`、`approval_provider`、`approval_status` 和 `approval_status_name`。
- 存量核对 SQL、关闭门禁和回滚步骤见 [存量审批切换清单](存量审批切换清单.md)。存量记录不得伪造企微实例，回滚不得删除任何审批、资金或集成事实。

## 已知边界

- 当前已包含审批准备、提交、加密回调、详情终态同步、批量单号时间窗补偿、未终态轮询、结果未知主动恢复和审批人批量读取投影。
- 本次未运行迁移、测试、完整构建或 LSP；按用户后续明确要求已运行 OpenAPI 生成器并检查企微管理及回调契约，静态收口另执行格式化和一致性检查。
