七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s

This commit is contained in:
2026-07-25 17:06:58 +08:00
parent ad9f613dd6
commit 73f5125d3d
249 changed files with 17137 additions and 877 deletions

View File

@@ -44,6 +44,47 @@
| 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 | | 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 | | 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 |
## 七月确认范围增量登记
| 入口 | Audit Event | Domain Ledger | Integration Log | Outbox |
|---|---|---|---|---|
| 换货迁移套餐在原订单退款后失效 | N/A退款终态的自动后处理不新增人工决定人工申请与审批审计沿用退款入口 | 原订单、换货新旧资产关系和 `tb_package_usage` 是套餐权益来源及失效状态的权威事实;仅失效该订单迁移后的对应权益 | N/A本地数据库后处理不调用企微、支付或 Gateway | 企微退款沿用 `approval.terminal_decision.recorded` 驱动;存量旧退款沿用原同步链路,本修复不新增 Outbox |
| 订单渠道、资产标识、退款/充值/换货提交人和卡/设备实名筛选 | N/A均为字段来源修正或受权只读投影不改变订单、资产、实名或审批事实 | 只读既有订单 `purchase_role`、卡 ICCID、设备 VirtualNo/IMEI、创建人账号及有效卡绑定实名事实不另建领域账本 | N/A查询使用本地批量账号查询和 EXISTS不实时调用外部系统 | N/A纯查询和创建时既有字段赋值不产生新增可靠副作用 |
| 换货创建前未终结退款拦截 | N/A拒绝发生在换货事实写入前拒绝原因进入 Access Log不伪造成功业务审计 | `tb_refund_request` 的未终结状态是拦截依据;拒绝后不创建换货单、不修改资产 | N/A本地前置校验不调用企微或其他外部系统 | N/A校验拒绝不产生业务事实或可靠副作用 |
| 店铺 C 端新登录限制配置与登录拦截 | 店铺更新入口记录操作者 `updater`、字段前后值进入 Access Log本 Change 不新建店铺专用 Audit Writer统一店铺配置 Audit Event 接缝登记为后续治理项 | `tb_shop.client_login_disabled` 是是否允许新登录的权威事实;已有 Token 不修改,拦截时不创建新 Token | N/A配置更新与资产登录判断均为本地数据库操作 | N/A同步配置与登录前拦截不产生必须可靠投递的提交后副作用 |
| 卡/设备实名策略单个及批量更新 | 复用资产操作审计 `card_realname_policy` / `asset_realname_policy`,记录操作者、资产集合、策略和 success/failed 结果;批量业务更新先在单事务中全量校验并写入 | `tb_iot_card.realname_policy``tb_device.realname_policy` 是策略权威事实C 端仅实时计算 `effective_realname_policy` | N/A策略更新和读取均不调用运营商或其他外部系统 | N/A策略同步更新不产生可靠异步副作用 |
| 下架套餐当前使用者续费与普通列表过滤 | N/A普通受权查询与既有订单创建规则订单创建继续沿用原订单/资金审计接缝) | 当前套餐使用记录决定续费资格,新订单、订单明细、套餐当前配置和支付记录是新购买权威事实;不修改历史订单 | 第三方支付仍沿用既有支付 Integration Log本资格判断不新增外部调用 | 新订单支付、钱包扣款、自动购包及佣金继续沿用既有任务/Outbox本切片不新增事件类型 |
| 卡/设备 C 端支付方式配置更新 | 复用 `systemconfig.UpdateService` 的事务内 `AuditWriter`,记录操作者、请求标识及配置前后值;审计 Writer 已装配时写入失败会回滚配置更新 | `tb_system_config` 是卡、设备允许支付方式集合及更新人的 PostgreSQL 权威事实Redis 仅为可失效缓存 | N/A配置更新不调用外部系统 | N/A提交后仅失效可重建缓存不产生必须可靠投递的业务副作用 |
| C 端读取支付方式与后端订单/充值校验 | N/A普通受权读取和业务规则校验不产生独立敏感事实拒绝原因进入 Access Log | 只读 `tb_system_config`,订单创建后由 `tb_order.payment_method` 固化所选方式,充值与支付事实沿用既有订单、充值单和支付记录 | 第三方支付请求继续沿用既有支付集成日志接缝,本配置策略本身不新增外部调用 | 强充支付成功后的自动购包继续沿用既有 Asynq/业务幂等链路,本配置读取不新增 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 稳定防重,消费端按事件与接收人唯一键幂等 |
| 套餐临期列表、数量与每日 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 缓存,不产生必须可靠投递的业务副作用) |
| 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A同步和绑定均为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`,记录业务类型、应用 ID、模板 ID、状态和最近校验时间不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时不保存 access_token 或完整外部响应 | N/A配置保存为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
| 企业微信审批加密回调与详情终态同步 | 回调为外部自动事实,不伪造人工 Audit Event验签、AES 解密、receiveid 校验失败只记录安全错误,不进入业务终态 | `tb_integration_log` 按应用和签名幂等保存入站密文哈希,`tb_wecom_approval_context.latest_detail_snapshot` 保存权威详情,通用审批实例及决策投递表保存标准终态 | 入站回调先写 Integration Log pending详情任务完成后置 completed每次 `getapprovaldetail` 写独立出站 Integration Log不保存 access_token但保存 sp_no、sp_status 和耗时摘要 | 回调只入队结构化 `wecom:approval:sync` 任务;权威终态通过现有 `SyncDecisionService` 原子写 `approval.terminal_decision.recorded`,不直接修改退款或钱包 |
| 企业微信审批主动恢复、未终态轮询与审批人读取投影 | N/A定时恢复和只读投影均为系统自动动作不伪造人工操作者映射不到账号不改变业务事实 | `tb_wecom_approval_context.submission_attempted_at/last_recovery_at/sp_no/latest_detail_snapshot` 与通用审批实例是恢复和展示的权威本地事实;只有唯一候选可从结果未知转为审批中 | 每次 `getapprovalinfo` 分页和 `getapprovaldetail` 均写独立出站 Integration Log只保存应用、时间窗、模板、发起人、单号数量、sp_no、状态和耗时等安全摘要不保存 Secret、access_token、media_id、附件正文或完整响应 | Scheduler 仅提交 `wecom:approval:recovery`;恢复和轮询仅提交结构化 `wecom:approval:sync`,不写审批提交 Outbox、不调用 `applyevent`,标准终态仍沿用既有终态 Outbox |
| 员工线下代充值申请与企微终态入账 | 申请保存以真实提交人及明文业务快照留痕;资金成功 Audit Event 延期至既有统一钱包治理任务,企微自动终态不伪造人工审批人 | `tb_agent_recharge_record``tb_approval_instance``tb_wecom_approval_context` 同事务保存申请事实approved 通过 `topup + recharge_record_id` 唯一成功钱包流水幂等入账,其他终态不修改钱包,通过后撤销不自动冲正 | 申请创建本身不外呼后续附件上传、applyevent、详情与恢复沿用企微 Integration Log业务参数按用户确认保存明文日志仍不记录 Secret、access_token、media_id 或附件正文 | 创建事务写 `approval.submission.requested`;标准终态写 `approval.terminal_decision.recorded`approved 入账事务再写 `wallet.agent_main.credited`;新审批单禁止旧人工确认或驳回入口绕过 |
| 退款申请与企微终态处理 | 申请以真实提交人和订单/金额/凭证明文快照留痕;企微自动终态不伪造本地人工审批人,资金与佣金 Audit Event 继续沿用既有治理延期口径 | `tb_refund_request`、通用审批实例和企微上下文同事务保存approved 条件更新订单与退款单,代理主钱包按 refund ID、资产钱包按退款单号复核成功回款佣金按记录锁定并失效套餐按订单及换货迁移关系幂等失效 | 申请创建不外呼附件、applyevent、详情、回调和恢复沿用企微 Integration Log业务参数明文保存在业务/审批快照中但不复制到 Integration LogSecret、access_token、media_id 和附件正文仍禁止记录 | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 |
| 退款与线下代充值旧审批入口发布切换 | N/A部署环境开关控制旧入口是否可用不新增业务操作实际旧入口操作继续沿用各自既有审计口径 | `approval_instance_id IS NULL` 是存量旧 provider 的兼容边界,非空记录只接受企微标准终态;关闭开关不修改或删除任何业务事实 | N/A开关判断不调用外部系统也不得写 Integration Log 冒充发布审计) | N/A开关判断不产生可靠副作用企微 Worker 继续消费既有标准终态 Outbox |
| 单列 CSV 资产套餐批量订购 | 任务创建和逐行订单继续进入 Access Log、任务操作者快照与既有订单/钱包审计接缝;本 Change 不新建批量任务专用 Audit Writer | `tb_asset_package_batch_order_task` 保存输入参数和逐行结果,成功行以 `tb_order`、订单明细、套餐使用、支付记录及代理钱包成功流水为权威业务事实 | 对象存储上传和下载沿用现有存储日志,不把文件正文写入 Integration Log本切片不新增外部支付或 Gateway 调用 | 创建接口提交结构化 `asset:package:batch_order` Asynq 任务;逐行钱包订单继续沿用既有钱包扣款 Outbox 和佣金任务,重复任务由状态条件与订单幂等规则阻断 |
| IoT 卡与套餐业务导出 | N/A只读投影不改变资产、套餐或权限状态任务创建操作者与权限快照沿用现有导出任务记录 | N/A导出只读取现有卡、套餐使用、套餐和分配事实不写入领域账本 | N/A不调用外部业务系统对象存储文件生成和下载沿用现有导出基础设施日志 | 沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| 钱包流水与代理充值业务导出 | N/A只读投影不改变钱包、充值或审批状态任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A只读取主钱包流水、充值记录和本地通用审批实例金额及余额使用既有权威事实不写入领域账本 | N/A不实时调用支付渠道或企业微信明文业务凭证 Key 只进入导出结果,不复制到 Integration Log且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| 退款与换货业务导出 | N/A只读投影不改变退款、换货、资产或审批状态任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A只读取退款、订单、套餐使用、换货资产快照和本地审批事实金额及处理标记沿用既有权威事实不写入领域账本 | N/A不实时调用企业微信、支付或 Gateway明文业务凭证和收货资料只进入有权导出结果不复制到 Integration Log且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| IoT 卡固定档位限速 | 复用资产操作审计 `card_speed_tier`,记录后台操作者、卡 ICCID、固定档位、Integration Log ID 和 success/failed 结果;设备无入口且不通过绑定卡间接限速 | N/A不在本地保存或修改卡当前限速状态Gateway 是外部执行方 | 每次实际 Gateway 调用前写 pending按 success/failed/unknown 终结;超时 unknown 保存按 ICCID 人工核对策略,摘要不含 Secret、access_token 或完整响应正文 | N/A单次外部命令无后续可靠副作用结果未知禁止盲目重发不创建自动补偿 Outbox |
| 设备 CSV 批量分配代理或套餐系列 | 任务记录冻结操作者和目标;逐批复用 `device_allocate``device_series_binding` 资产操作审计,记录设备前后值、目标、成功/失败数和失败原因 | N/A设备归属、绑定卡归属、分配记录及 `series_id` 是权威业务事实,不另建领域账本 | N/ACSV 解析和两类分配均为本地数据库操作,不调用 Gateway、支付或企微对象存储沿用现有存储日志 | 复用 `device:import` Asynq 任务;状态条件阻止完成任务重复执行,处理中断恢复时已达到目标关系的设备按成功处理,不新增业务 Outbox |
### `deliver-july-iteration-confirmed-scope` 任务覆盖映射
- 1.1 对应“换货迁移套餐在原订单退款后失效”1.21.4 对应“订单渠道、资产标识、提交人与实名筛选”1.5 对应“换货创建前未终结退款拦截”。
- 2.12.4 分别对应店铺登录限制、实名策略、下架套餐续费、支付方式配置及后端校验。
- 3.13.3 分别对应物流换货提醒、主钱包低余额提醒、套餐临期提醒。
- 4.14.6 分别由企业微信应用、成员绑定、场景模板、默认发起人与提交、加密回调、主动恢复六行覆盖。
- 5.15.3 分别由员工线下代充值、退款企微终态、旧审批入口发布切换三行覆盖。
- 6.16.6 分别由批量订购、三组业务导出、IoT 卡限速、设备批量分配六行覆盖。
- 0.1、1.6、7.1、7.37.5 只产生证据、API 契约、静态检查或联调文档,不运行生产入口,因此 Audit Event、Domain Ledger、Integration Log 与 Outbox 均为 N/A7.2 即本基线维护动作。
## 旧 Writer 与旧表写入口清单 ## 旧 Writer 与旧表写入口清单
### 旧账号审计 ### 旧账号审计

View File

@@ -232,7 +232,7 @@ default:
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护 - **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力 - **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务 - **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md) - **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持设备、IoT 卡、订单、套餐、代理主钱包流水、代理充值、退款和换货等业务场景,支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md)、[业务数据导出说明](docs/business-data-export/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md)
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql) - **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md) - **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md) - **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
@@ -256,6 +256,8 @@ default:
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
- **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md)
- **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 015 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md)
- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口直接返回可编辑明文,数据库使用 AES-256-GCM 密文保存。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)
@@ -941,9 +943,17 @@ rdb.Set(ctx, key, status, time.Hour)
- **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性 - **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性
- **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读) - **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读)
- **[七月迭代 AI 实施与验收操作手册](docs/7月迭代/七月迭代-AI实施与验收操作手册.md)**PRD 拆票、Issues 实现、测试、双轴评审与验收流程 - **[七月迭代 AI 实施与验收操作手册](docs/7月迭代/七月迭代-AI实施与验收操作手册.md)**PRD 拆票、Issues 实现、测试、双轴评审与验收流程
- **[七月迭代实现与接口对接说明](docs/7月迭代/七月迭代实现与接口对接说明.md)**:按需求说明实现关键节点、口径检查、接口变化和前端应补齐的调用
- **[七月迭代联调交付说明](docs/7月迭代/七月迭代联调交付说明.md)**企业微信、Gateway、Redis/Asynq、对象存储、前端契约、限制和回滚步骤
### 功能指南 ### 功能指南
- **[企业微信审批 Adapter](docs/wecom-application-connection/功能总结.md)**:应用连接、默认发起人、账号绑定、模板映射、安全异步提交及退款/线下代充值闭环说明
- **[企业微信审批存量切换清单](docs/wecom-application-connection/存量审批切换清单.md)**:旧人工入口默认保留、存量核对、前端只读切换、停用门禁与回滚步骤
- **[资产套餐批量订购](docs/asset-package-batch-order/功能总结.md)**:单列 CSV、整批统一套餐与支付方式、五态异步任务及逐行订单结果说明
- **[业务数据导出](docs/business-data-export/功能总结.md)**IoT 卡套餐流量列、套餐列表及后续业务 datasource 的字段、筛选和权限口径
- **[IoT 卡固定档位限速](docs/iot-card-speed-tier/功能总结.md)**:仅按卡 ICCID 调用 Gateway 的 `-1..8` 固定档位、权限、Integration Log 与结果未知处理;设备不提供限速入口
- **[设备 CSV 批量分配](docs/device-batch-allocation/功能总结.md)**:复用设备导入任务外壳,以单列 VirtualNo/IMEI/SN 为整批分配目标代理或套餐系列,并沿用现有权限、事务、审计和幂等规则
- **[快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)**:详细设置和测试说明 - **[快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)**:详细设置和测试说明
- **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用 - **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用
- **[错误处理使用指南](docs/003-error-handling/使用指南.md)**错误码参考、Handler 使用、客户端处理、最佳实践 - **[错误处理使用指南](docs/003-error-handling/使用指南.md)**错误码参考、Handler 使用、客户端处理、最佳实践

View File

@@ -5,6 +5,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -24,10 +25,14 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构) // 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。 // 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers() handlers := openapi.BuildDocHandlers()
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil) handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)
handlers.WeComApprovalCallback = callback.NewWeComApprovalHandler(nil)
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)

View File

@@ -7,6 +7,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
@@ -33,10 +34,14 @@ func generateAdminDocs(outputPath string) error {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构) // 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。 // 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers() handlers := openapi.BuildDocHandlers()
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil) handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)
handlers.WeComApprovalCallback = callback.NewWeComApprovalHandler(nil)
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)

View File

@@ -14,19 +14,25 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task" "github.com/break/junhong_cmp_fiber/internal/task"
pkgBootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap" pkgBootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
@@ -135,6 +141,7 @@ func runWorker(cfg *config.Config) {
taskHandler := createTaskHandler(runtime, appLogger) taskHandler := createTaskHandler(runtime, appLogger)
taskHandler.RegisterHandlers() taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers) outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
@@ -231,12 +238,14 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
Password: cfg.Redis.Password, Password: cfg.Redis.Password,
DB: cfg.Redis.DB, DB: cfg.Redis.DB,
}) })
workerQueueClient := queue.NewClient(redisClient, appLogger)
workerDeps := &bootstrap.WorkerDependencies{ workerDeps := &bootstrap.WorkerDependencies{
DB: db, DB: db,
Redis: redisClient, Redis: redisClient,
Logger: appLogger, Logger: appLogger,
AsynqClient: asynqClient, AsynqClient: asynqClient,
QueueClient: workerQueueClient,
StorageService: storageSvc, StorageService: storageSvc,
GatewayClient: gatewayClient, GatewayClient: gatewayClient,
} }
@@ -247,7 +256,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
} }
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger) workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
outboxQueueClient := queue.NewClient(redisClient, appLogger) outboxQueueClient := workerQueueClient
pollingConfigStore := postgres.NewPollingConfigStore(db) pollingConfigStore := postgres.NewPollingConfigStore(db)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
@@ -301,9 +310,108 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
registerNotificationOutboxConsumer(runtime, appLogger) registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger) registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger) registerCardObservationOutboxConsumer(runtime, appLogger)
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
return runtime return runtime
} }
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db)
credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
if err != nil {
appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批提交将失败关闭", zap.Error(err))
}
integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider(
applicationRepository, credentialCipher, runtime.redisClient, integrationRepository,
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
)
consumer := wecomInfra.NewApprovalSubmissionConsumer(
wecomInfra.NewApprovalContextRepository(runtime.db),
wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalSubmissionRequested, consumer); err != nil {
appLogger.Fatal("注册企业微信审批提交 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeApprovalSubmissionRequested), zap.Error(err))
}
owner := cfg.Worker.InstanceName
if owner == "" {
owner = fmt.Sprintf("worker-%d", os.Getpid())
}
walletPosting := walletApp.NewPostingService(
walletInfra.NewCreditEventWriter(outbox.NewRepository()),
nil,
)
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
refundService := refundSvc.New(
runtime.db,
postgres.NewRefundStore(runtime.db),
runtime.workerResult.Stores.Order,
runtime.workerResult.Stores.CommissionRecord,
runtime.workerResult.Stores.AgentWallet,
runtime.workerResult.Stores.AgentWalletTransaction,
stopResumeService,
nil,
runtime.workerResult.Services.ActivationService,
runtime.workerResult.Stores.IotCard,
runtime.workerResult.Stores.Device,
runtime.workerResult.Stores.AssetWallet,
appLogger,
)
refundService.SetAgentWalletRefundService(
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
decisionDispatcher := approvalApp.NewDecisionDispatcher(
approvalInfra.NewDecisionDeliveryStore(runtime.db),
map[string]approvalApp.BusinessDecisionHandler{
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting),
constants.ApprovalBusinessTypeRefund: refundService,
},
owner,
appLogger,
nil,
)
decisionConsumer := approvalInfra.NewTerminalDecisionConsumer(decisionDispatcher)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalTerminalDecision, decisionConsumer); err != nil {
appLogger.Fatal("注册审批标准终态 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeApprovalTerminalDecision), zap.Error(err))
}
}
// registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。
func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db)
credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
if err != nil {
appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批详情同步将失败关闭", zap.Error(err))
}
integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider(
applicationRepository, credentialCipher, runtime.redisClient, integrationRepository,
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
)
decisionSync := approvalApp.NewSyncDecisionService(
runtime.db, approvalInfra.NewRepositoryProvider(),
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()),
approvalInfra.NewDecisionDeliveryStore(runtime.db), nil,
)
contexts := wecomInfra.NewApprovalContextRepository(runtime.db)
detailHandler := wecomInfra.NewApprovalDetailTaskHandler(
wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
contexts, decisionSync, integrationRepository,
)
recoveryHandler := wecomInfra.NewApprovalRecoveryTaskHandler(
contexts,
wecomInfra.NewApprovalInfoClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
runtime.outboxQueueClient,
)
mux.HandleFunc(constants.TaskTypeWeComApprovalSync, detailHandler.Handle)
mux.HandleFunc(constants.TaskTypeWeComApprovalRecovery, recoveryHandler.Handle)
appLogger.Info("注册企业微信审批详情同步任务处理器", zap.String("task_type", constants.TaskTypeWeComApprovalSync))
appLogger.Info("注册企业微信审批主动恢复任务处理器", zap.String("task_type", constants.TaskTypeWeComApprovalRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 // registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -543,7 +651,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
} }
}() }()
appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *, 通知清理: 15 2 * * *, 每日流量落盘: 0 2 * * *") appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 2 分钟,套餐临期提醒: 上海时区每日 03:00")
return asynqScheduler return asynqScheduler
} }
@@ -563,6 +671,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil { )); err != nil {
return fmt.Errorf("注册告警检查定时任务失败: %w", err) return fmt.Errorf("注册告警检查定时任务失败: %w", err)
} }
if _, err := asynqScheduler.Register("@every 2m", asynq.NewTask(
constants.TaskTypeWeComApprovalRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeWeComApprovalRecovery)),
)); err != nil {
return fmt.Errorf("注册企业微信审批主动恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask( if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(
constants.TaskTypeDataCleanup, constants.TaskTypeDataCleanup,
nil, nil,
@@ -578,6 +695,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil { )); err != nil {
return fmt.Errorf("注册站内通知保留清理定时任务失败: %w", err) return fmt.Errorf("注册站内通知保留清理定时任务失败: %w", err)
} }
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 3 * * *", asynq.NewTask(
constants.TaskTypePackageExpiryReminder,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageExpiryReminder)),
)); err != nil {
return fmt.Errorf("注册套餐临期提醒定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register( if _, err := asynqScheduler.Register(
"0 2 * * *", "0 2 * * *",
asynq.NewTask( asynq.NewTask(

View File

@@ -20,7 +20,7 @@
|需求07实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|12小时|23小时|设备多当前卡、绑定历史异常会增加 23 小时| |需求07实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|12小时|23小时|设备多当前卡、绑定历史异常会增加 23 小时|
|需求08设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq新增两个独立批量命令和失败明细|23小时|34小时|前端无现成上传任务组件或生产文件格式不统一会增加 24 小时| |需求08设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq新增两个独立批量命令和失败明细|23小时|34小时|前端无现成上传任务组件或生产文件格式不统一会增加 24 小时|
|需求09C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.52.5小时|23小时|多端支付入口未复用同一接口会增加逐端排查时间| |需求09C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.52.5小时|23小时|多端支付入口未复用同一接口会增加逐端排查时间|
|需求10Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|12小时|23小时|Gateway 取消参数不明确或联调不稳定会增加 24 小时| |需求10Gateway 限速|仅 IoT 卡详情增加固定档位设置和恢复不限速入口|卡专用 `speed-tier` 接口按 ICCID 调用 Gateway,设备无入口|12小时|23小时|Gateway 档位或联调不稳定会增加 24 小时|
|需求11当前套餐到期高亮|并入需求06/22不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时| |需求11当前套餐到期高亮|并入需求06/22不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时|
|需求12换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|12小时|12小时|历史数据不回填导致验收口径混淆会增加沟通时间| |需求12换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|12小时|12小时|历史数据不回填导致验收口径混淆会增加沟通时间|
|需求13列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|12小时|23小时|旧记录缺提交人或企微实例会增加兼容展示时间| |需求13列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|12小时|23小时|旧记录缺提交人或企微实例会增加兼容展示时间|

View File

@@ -782,13 +782,8 @@ sequenceDiagram
```mermaid ```mermaid
flowchart TD flowchart TD
Trigger[后台手动设置或取消] --> Asset{资产类型} Trigger[卡详情手动选择固定档位] --> ICCID[校验 IoT 卡 ICCID 与数据权限]
Asset -->|卡| ICCID[读取卡 ICCID] ICCID --> Gateway[SetCardSpeedTier cardNo, code]
Asset -->|设备| Binding[查询 is_current=true 的当前卡]
Binding --> Exists{当前卡有效?}
Exists -->|否| Reject[拒绝操作并记录原因]
Exists -->|是| ICCID
ICCID --> Gateway[SetSpeedLimit cardNo, speedLevel]
Gateway --> Audit[记录操作审计和 Gateway 结果] Gateway --> Audit[记录操作审计和 Gateway 结果]
``` ```
@@ -796,15 +791,15 @@ flowchart TD
数据和契约: 数据和契约:
- 应用层只接收语义化 `speed_level`,前端只能从固定档位中选择,禁止输入任意速率,也不得接触 Gateway 渠道 `code` - 后台接口只接收固定整数档位 `code=-1..8`,前端只能从固定档位中选择,禁止输入任意速率
- 统一接口:`POST /api/admin/assets/{identifier}/speed-limit`。资产为设备时解析当前绑定卡,不存在当前卡则拒绝,绝不把设备 IMEI 传给 Gateway - 卡专用接口:`PUT /api/admin/iot-cards/{iccid}/speed-tier`。设备没有限速入口,也不得通过设备当前绑定卡间接限速
- Gateway 端口只有 `SetSpeedLimit(cardNo, speedLevel)`Infrastructure Adapter 根据 Gateway 账户和运营商把业务等级映射为上游字符串 `code`,映射不得进入 Handler、前端或领域模型 - Gateway 端口只有 `SetCardSpeedTier(cardNo, code)``cardNo` 必须是经过本地权限校验的卡 ICCID不得传设备 ID 或 IMEI
- “恢复不限速”和“限到 0kbps”是两个不同业务等级必须分别映射为上游 `code=-1``code=0`,禁止继续用数值 `0` 表示取消限速。 - “恢复不限速”和“限到 0kbps”是两个不同业务等级必须分别映射为上游 `code=-1``code=0`,禁止继续用数值 `0` 表示取消限速。
- 上游说明只有广电和电信直接支持限速接口,联通通过通信计划调整,移动不支持限速。调用前必须按卡的运营商能力校验;不支持或缺少账户档位映射时返回明确业务错误,不得猜测 `code` 或盲目调用 - 上游是否支持具体运营商与账户由 Gateway 返回结果确认;本系统只校验固定档位并原样发送,不自行推算运营商策略
- 不新增 `tb_package.speed_limit_kbps``SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速,除非上游另提供查询接口。 - 不新增 `tb_package.speed_limit_kbps``SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速,除非上游另提供查询接口。
- 卡详情和设备详情都提供固定档位选择及恢复不限速入口;设备入口明确显示“当前使用卡 ICCID”。每次操作记录资产、最终 `cardNo`、业务 `speed_level`、实际发送的渠道 `code`Gateway 返回的 `appliedSpeed/channelRawValue`、操作人、请求结果和错误摘要。 - 卡详情提供固定档位选择及恢复不限速入口。每次操作记录卡 ICCID、实际发送的 `code`操作人、Integration Log、请求结果和错误摘要。
CMP 固定业务等级如下。枚举名称属于本系统稳定契约;展示文案和上游默认 `code` 仅用于表达当前已知映射,实际调用仍须按 Gateway 账户和运营商查找映射 CMP 固定档位如下,接口直接提交表中的整数 `code`
| `speed_level` | 展示文案 | 当前上游默认 `code` | | `speed_level` | 展示文案 | 当前上游默认 `code` |
|---------------|----------|----------------------| |---------------|----------|----------------------|

View File

@@ -103,7 +103,7 @@ FE/BE 研发需求开发完成
| #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。<br>**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。<br>**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1``GET /api/admin/devices?real_name_status=0\|1`。<br>**返回:**`real_name_status/real_name_status_name`。 | | #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。<br>**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。<br>**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1``GET /api/admin/devices?real_name_status=0\|1`。<br>**返回:**`real_name_status/real_name_status_name`。 |
| #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。<br>**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。<br>**接口:**`POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series``GET /api/admin/devices/batch-allocation/{task_id}`。<br>**入参:**文件和目标ID。<br>**返回:**任务状态及失败明细。 | | #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。<br>**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。<br>**接口:**`POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series``GET /api/admin/devices/batch-allocation/{task_id}`。<br>**入参:**文件和目标ID。<br>**返回:**任务状态及失败明细。 |
| #48 支付方式限制 | **标题:**C端按资产展示支付方式。<br>**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。<br>**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods``POST /api/c/v1/orders/create``POST /api/c/v1/orders/{id}/pay` 再次校验。<br>**后台:**`PUT /api/admin/system/config/{config_key}`。 | | #48 支付方式限制 | **标题:**C端按资产展示支付方式。<br>**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。<br>**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods``POST /api/c/v1/orders/create``POST /api/c/v1/orders/{id}/pay` 再次校验。<br>**后台:**`PUT /api/admin/system/config/{config_key}`。 |
| #47 Gateway限速 | **标题:**卡和设备手动限速。<br>**页面:**详情提供设置、取消入口设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。<br>**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。<br>**入参:**`speed_kbps`0表示取消。<br>**返回:**资产标识、最终 `card_no`、目标值和执行结果。 | | #47 Gateway限速 | **标题:**IoT 卡手动固定档位限速。<br>**页面:**仅卡详情提供设置、恢复不限速入口;设备不提供限速操作。 | **标题:**Gateway 卡 ICCID 固定档位限速。<br>**接口:**`PUT /api/admin/iot-cards/{iccid}/speed-tier`。<br>**入参:**`code=-1..8``-1` 表示恢复不限速。<br>**返回:**卡ID、ICCID、档位名称和 Integration Log ID。 |
| #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。<br>**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}`。<br>**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 | | #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。<br>**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}`。<br>**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 |
| #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。<br>**页面:**列表分别搜索旧资产、新资产展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。<br>**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。<br>**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 | | #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。<br>**页面:**列表分别搜索旧资产、新资产展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。<br>**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。<br>**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 |
| #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。<br>**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。<br>**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。<br>**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 | | #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。<br>**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。<br>**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。<br>**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 |
@@ -200,7 +200,7 @@ FE/BE 研发需求开发完成
| INT-04 | CSV批量任务与导出联调 | #49#36#42 | FE 11.5h / BE 11.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 | | INT-04 | CSV批量任务与导出联调 | #49#36#42 | FE 11.5h / BE 11.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48#38#34#36#96#97 | FE 11.5h / BE 11.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 | | INT-05 | 支付、代理钱包、信用和余额预警联调 | #48#38#34#36#96#97 | FE 11.5h / BE 11.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
| INT-06 | 企业微信审批、退款和线下充值联调 | #37#35#34#44 | FE 1.52h / BE 1.52h已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 | | INT-06 | 企业微信审批、退款和线下充值联调 | #37#35#34#44 | FE 1.52h / BE 1.52h已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
| INT-07 | Gateway卡限速联调 | #47 | FE 0.51h / BE 0.51h已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 | | INT-07 | Gateway卡限速联调 | #47 | FE 0.51h / BE 0.51h已含 | Gateway联调配置和卡专用入口完成 | 单卡各固定档位、恢复不限速、越权卡、Gateway 失败/结果未知与审计 |
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求及三项公共技术需求 | FE 34h / BE 45h额外 | INT-0107、公共基础12、全局审计19、公共通知08及各业务发布门禁完成 | 权限、通知、全系统审计覆盖、正式Adapter、事件消费者、历史数据、旧入口关闭、Worker恢复、生产阈值和发布检查 | | INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求及三项公共技术需求 | FE 34h / BE 45h额外 | INT-0107、公共基础12、全局审计19、公共通知08及各业务发布门禁完成 | 权限、通知、全系统审计覆盖、正式Adapter、事件消费者、历史数据、旧入口关闭、Worker恢复、生产阈值和发布检查 |
## 七、联调任务责任方式 ## 七、联调任务责任方式

View File

@@ -784,29 +784,28 @@
**标题** **标题**
```text ```text
[FE][UR#47] 卡设备手动设置与取消限速 [FE][UR#47] IoT 卡手动设置固定限速档位
``` ```
**描述** **描述**
```markdown ```markdown
目标:在卡和设备详情提供统一的手动限速入口,不展示自动限速规则。 目标:仅在 IoT 卡详情提供手动固定档位限速入口,不展示自动限速规则。
预计工时前端12小时。 预计工时前端12小时。
页面入口:卡详情设备详情。 页面入口:IoT 卡详情设备详情不提供限速入口
页面结构: 页面结构:
1. “手动限速”弹框只展示后端返回的固定语义档位,不允许输入任意速率或上游code 1. “手动限速”弹框只展示 `code=-1..8` 固定档位,不允许输入任意速率。
2. “恢复不限速”提交speed_level=unlimited“限到0kbps”提交speed_level=zero_kbps,两者严格区分。 2. “恢复不限速”提交 `code=-1`“限到0kbps”提交 `code=0`,两者严格区分。
3. 设备详情必须显示本次实际作用的当前卡ICCID无当前卡时禁用操作并展示原因 3. 不展示“Gateway当前实际限速”除非接口未来明确返回查询结果
4. 不展示“Gateway当前实际限速”除非接口未来明确返回查询结果。
接口约定:POST /api/admin/assets/{identifier}/speed-limitbody={speed_level:string}返回asset_type、asset_identifier、card_no、speed_level、channel_code、applied_speed、channel_raw_value、result、message 接口约定:`PUT /api/admin/iot-cards/{iccid}/speed-tier``body={code:int}`;返回 `iot_card_id/iccid/code/speed_tier_name/integration_id`
交互规则:提交中禁用按钮;失败保留所选档位;中国电信/广电按后端能力展示,联通/移动或映射缺失时禁用并展示原因;结果未知不能显示成功。 交互规则:提交中禁用按钮;失败保留所选档位;结果未知不能显示成功,并提示按 ICCID 核对 Gateway 实际档位
完成标准:卡限速、设备解析当前卡、取消限速和无当前卡四类场景完整 完成标准:卡固定档位、恢复不限速、越权卡、明确失败和结果未知场景完整;设备无入口
``` ```
### 后端研发需求 ### 后端研发需求
@@ -814,23 +813,23 @@
**标题** **标题**
```text ```text
[BE][UR#47] Gateway按cardNo统一限速接口 [BE][UR#47] Gateway按卡ICCID设置固定限速档位
``` ```
**描述** **描述**
```markdown ```markdown
目标:只开放一个手动限速接口,最终始终按卡ICCID调用Gateway。 目标:只开放 IoT 卡手动限速接口,按卡 ICCID 调用 Gateway。
预计工时后端23小时。 预计工时后端23小时。
接口:POST /api/admin/assets/{identifier}/speed-limit入参为固定语义枚举speed_level恢复不限速与限到0kbps是不同值前端和业务层不得接触渠道code 接口:`PUT /api/admin/iot-cards/{iccid}/speed-tier`,入参为固定整数档位 `code=-1..8`恢复不限速与限到0kbps分别使用 `-1``0`
规则:资产为卡时读取ICCID资产为设备时解析is_current=true的唯一有效当前卡不存在或多条当前卡都拒绝绝不把设备号传给Gateway。仅中国电信和中国广电直连POST /flow-card/speedLimitAdapter按Gateway账号+运营商+speed_level映射code缺失映射不猜测。外部副作用关闭自动重试超时等结果不明记录unknown。 规则:按 ICCID 查询 IoT 卡并沿用现有数据权限;只向 `POST /flow-card/speedLimit` 发送 `cardNo + code`。设备没有限速入口,也不得解析设备当前绑定卡间接调用。外部副作用关闭盲目自动重试,超时等结果不明记录 `unknown`
非目标不建立套餐固定限速不因激活、到期、停机或切卡自动限速不增加自动补偿Worker。 非目标不建立套餐固定限速不因激活、到期、停机或切卡自动限速不增加自动补偿Worker。
完成标准:返回最终card_no、语义档位和规范化调用结果电信、广电、联通、移动、映射缺失、明确失败和结果未知均有正确行为每次请求记录操作审计及Integration Log。 完成标准:返回卡 ID、ICCID、固定档位和 Integration Log ID明确失败和结果未知均有正确行为;每次请求记录操作审计及 Integration Log。
``` ```
## UR#46 资产信息详情字段新增 ## UR#46 资产信息详情字段新增
@@ -1914,7 +1913,7 @@
进入条件统一限速接口、卡和设备详情入口、Gateway联调配置完成。 进入条件统一限速接口、卡和设备详情入口、Gateway联调配置完成。
联调范围:单卡固定speed_level、设备解析当前卡、恢复不限速与限到0kbps区分、设备无当前卡、电信/广电档位映射、联通/移动不支持、Gateway失败/结果未知及审计记录。 联调范围:单卡固定 `code`、恢复不限速与限到0kbps区分、越权卡、Gateway 明确失败/结果未知及审计记录;确认设备端没有限速入口
完成标准Gateway收到的cardNo始终为卡ICCID设备号不会被发送上游结果在页面和审计中可追踪。 完成标准Gateway收到的cardNo始终为卡ICCID设备号不会被发送上游结果在页面和审计中可追踪。
``` ```

View File

@@ -0,0 +1,152 @@
# 七月迭代实现与接口对接说明
> 面向:产品、前端、测试和联调人员
> 范围:`deliver-july-iteration-confirmed-scope` 及本期确认“后端已完成,只需前端联调”的需求
> 接口细节:以 [`docs/admin-openapi.yaml`](../admin-openapi.yaml) 为准,本文只说明关键调用和字段变化。
## 一、先看这几个关键结论
1. **设备没有限速接口**:限速只允许对 IoT 卡 ICCID 操作,固定档位 `-18`,不通过设备绑定卡间接限速。
2. **企微模板在企微后台创建**:系统只配置应用、默认发起人、账号 userid 绑定、`template_id` 和控件映射,不在本系统设计审批节点。
3. **代理发起审批走默认发起人**:企微单据使用配置的默认成员发起,但退款、充值等本地业务单仍记录真实代理提交人。
4. **下架套餐续费不新增专用接口**:历史订单和资产信息返回稳定套餐 ID前端仍调用现有 C 端创建订单接口生成一张新订单。
5. **实名流程由后端返回值决定**:前端使用 `effective_realname_policy`,不能按卡或设备自行推断;设备有任意一张有效绑定卡已实名即视为已实名。
6. **支付按钮由后端返回值决定**:前端使用 `allowed_payment_methods`,不要自行写死卡/设备的微信、支付宝或钱包规则。
7. **设备批量分配只复用导入任务外壳**:复用任务表、队列、进度和结果页;不会进入原 Excel 创建设备逻辑。
8. **系列套餐批量授权后端原本就支持多选**:前端把多选套餐组装为 `packages[]` 调现有接口即可,不需要新后端接口。
## 二、本期需求怎么实现
### 2.1 本期新增或修改后端的需求
| 需求 | 实现方式 | 关键口径检查 |
| --- | --- | --- |
| #189 换货后退款套餐未失效 | 退款处理不再只按旧资产查套餐,而是按原订单和换货迁移关系定位新资产上的对应套餐权益 | 只失效该退款订单产生的权益,不影响其他订单套餐;无前端改动 |
| #188 换货 C 端提醒 | 创建物流换货单时写可靠通知事件,继续走现有 C 端站内通知和未读弹窗 | 不做营销投放、ERP、自动创建其他单据 |
| #182/#44 提交人和审批展示 | 退款、代理充值、换货列表/详情批量解析提交人 ID 和名称;企微详情已具备将审批节点 userid 批量映射系统账号的内部能力 | 当前退款/充值业务 DTO 只稳定返回提交人和审批状态,尚未直接返回审批节点人员列表;如页面必须展示具体审批人,仍需把已有投影接入业务查询 |
| #181 订单渠道和资产标识 | C 端新订单保存正确 `purchase_role`;卡返回 ICCID设备优先 VirtualNo、为空时返回 IMEI | 不用 SN 冒充订单设备标识;历史空数据不伪造 |
| #41 店铺 C 端登录限制 | 店铺增加 `client_login_disabled`C 端验证资产后、签发短期令牌前检查所属店铺 | 只阻止新登录,不吊销已有 Token平台库存保持原行为 |
| #53 卡/设备实名筛选 | 卡按自身实名状态过滤;设备通过有效绑定卡 `EXISTS` 实时判断 | 设备任意一张有效绑定卡已实名即为已实名;未建投影或 Worker |
| #57 退款中禁止换货 | 在现有换货创建入口前检查资产未终结退款 | 拒绝文案为“该资产存在退款申请”;不依赖企微实时接口 |
| #62 三种实名顺序 | 保留 `none/before_order/after_order`,补齐卡和设备最多 500 条批量修改C 端返回生效策略 | 批量全成全败;设备和下卡冲突时以设备策略为准 |
| #97 主钱包低余额预警 | 消费主钱包扣款事实,余额首次从不少于 100 元跌破 100 元时通知店铺业务员 | 阈值以下不重复;恢复后再次跌破可再次提醒;无业务员不猜接收人 |
| #33 套餐临期提醒 | 每日计算 15/7/3 天节点,提供后台/代理临期列表及数量,并向个人客户发送站内通知 | 高亮为 815、47、03 天03 天优先;不发企微业务员提醒 |
| #34 员工线下代充值 | 原充值创建入口在 `offline` 场景创建通用审批实例和企微提交事件;通过后幂等入主钱包 | 在线扫码充值后置;真实提交人、金额和凭证明文业务快照保留 |
| #35 退款企微审批 | 原退款申请关联唯一通用审批实例,企微标准终态驱动现有退款处理 | 不做原路退款;重复回调/轮询不会重复退款或失效套餐 |
| #37 企业微信审批 | 完成应用连接、通讯录同步、账号绑定、默认发起人、模板映射、提交、回调、详情查询和轮询恢复 | 模板/审批人规则由企微维护;超时结果未知不盲目重提 |
| #36 批量订购套餐 | 单列 CSV整批选择一个套餐和支付方式走独立异步任务逐行复用现有订单和钱包规则 | 不选择代理,不在 CSV 中逐行指定套餐;部分失败不影响其他行 |
| #40 下架套餐老客户续费 | 普通可购列表排除下架套餐;当前使用者可用历史订单或当前资产返回的套餐 ID 调现有下单接口创建新订单 | 无专用续费接口;新客户和代理代购仍拒绝;历史订单不修改 |
| #42 六类导出 | 在现有导出任务框架新增 IoT 卡、套餐、钱包流水、代理充值、退款、换货 datasource | 按现有数据权限和字段来源导出,不建设新字段权限平台 |
| #47 卡固定档位限速 | 后台选择固定档位,按 ICCID 调用 Gateway并记录操作审计和 Integration Log | 仅 IoT 卡;设备无接口;超时返回结果未知并人工核对 |
| #48 按资产类型配置支付方式 | `system_config` 分别保存卡、设备允许的 `wallet/wechat/alipay` 集合C 端返回业务场景交集,订单端再次校验 | 至少保留一种;强充和普通充值剔除钱包;前端不可绕过 |
| #49 设备批量分配 | 扩展现有设备导入任务的 `operation_type`,新增分配代理和设置套餐系列两个 CSV 分支 | CSV 单列 VirtualNo/IMEI/SN复用现有权限、分配、系列绑定和幂等规则 |
### 2.2 后端此前已经完成,主要由前端正确调用或展示
| 需求 | 后端现状 | 前端要做什么 |
| --- | --- | --- |
| #45 换货新旧资产展示/搜索 | 换货列表已分别返回新旧资产,并支持两个独立搜索参数 | 分别提供“旧资产”和“新资产”搜索框,不要继续共用一个字段 |
| #46 预计套餐到期 | 资产详情、C 端资产信息和相关列表已返回预计最终到期字段 | 展示 `estimated_final_expires_at`;按 `is_expiring` 或剩余天数高亮,不要只显示当前套餐到期时间 |
| #55 套餐分配生效条件 | 套餐和分配接口已支持默认值、覆盖值和最终生效值 | 创建/编辑套餐传 `expiry_base`;分配时传 `expiry_base_override`,展示 `effective_expiry_base` |
| #60 店铺联系电话搜索 | 店铺列表已支持 11 位联系电话精确查询 | 将输入值作为 `contact_phone` 查询参数传给店铺列表接口 |
| #86 资产换货标识和跳转 | 资产解析接口已返回 `exchange_trace.previous_asset/next_asset``can_view` | 仅 `can_view=true` 且存在资产 ID 时允许跳转;该需求前端已对接可保持现状 |
| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;更新时携带钱包 `version`;余额为负数时正常展示 |
| #94 状态同步和运营商回调 | 后端回调、定时触发和原轮询链路已装配 | 通常无前端新调用;状态页面继续读取现有资产状态字段 |
| #96 店铺业务员 | 店铺创建/更新、候选人、列表/详情和筛选都已支持业务员 | 创建/编辑店铺选择 `business_owner_account_id`;列表可按该 ID 筛选并展示名称 |
| #98 换货新资产继承旧店铺 | 换货完成时后端自动继承旧资产店铺归属 | 前端继续调用原换货完成接口,不新增分配步骤 |
| #43 系列套餐批量授权 | 创建授权和管理套餐接口均支持 `packages[]`,单次 1100 项 | 页面实现套餐多选,一次提交整个数组;删除项使用 `remove=true` |
> 注意:#44 的“提交人”已经可以直接对接;“审批人”目前不是退款、充值列表的稳定返回字段。后端已经能从企微详情快照解析并映射 userid但当前前端不能把 `processor_id` 当作完整企微审批人列表。
### 2.3 本期明确不由后端处理
- #84 H5 首页隐藏设备下 ICCID纯前端显示调整但本期范围确认标记为“不做”。
- #63 授权列表滚动条和字段顺序:纯前端页面调整,需求已关闭。
- #73 行业卡未实名复机:后端保持原有行业卡放行逻辑,不改接口、不改前端。
- #99 原路退款、#52 聚水潭、#51 跨品类换货、#39 分销码/佣金提现:本期不做。
- #168#90#75#64:已关闭,本期不重新修改。
## 三、需求与接口对接表
### 3.1 资产、店铺、订单和换货
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
| --- | --- | --- |
| #41 登录限制 | `PUT /api/admin/shops/:id``GET /api/admin/shops``GET /api/admin/shops/:id` | 更新请求增加可选 `client_login_disabled`;列表/详情返回同名布尔值。C 端仍调 `POST /api/c/v1/auth/verify-asset`,受限时直接展示后端错误,不会返回资产令牌 |
| #53 实名筛选 | `GET /api/admin/iot-cards/standalone``GET /api/admin/devices` | 查询参数增加 `real_name_status=0|1`;响应已有 `real_name_status``real_name_status_name` |
| #62 实名顺序 | `PATCH /api/admin/assets/:identifier/realname-mode``POST /api/admin/iot-cards/batch-update-realname-policy``POST /api/admin/devices/batch-update-realname-policy` | 单条传 `realname_policy`;批量传 `asset_ids[] + realname_policy`,最多 500 条 |
| #62/#48 C 端初始化 | `GET /api/c/v1/asset/info?identifier=...` | 使用 `effective_realname_policy``realname_required``real_name_status``allowed_payment_methods`;前端不要自行覆盖 |
| #45 换货搜索 | `GET /api/admin/exchanges` | 使用 `old_asset_keyword``new_asset_keyword` 两个独立参数,可同时传并按 AND 组合 |
| #57/#98 换货 | `POST /api/admin/exchanges``POST /api/admin/exchanges/:id/complete` | 请求结构不变;存在退款时创建接口返回业务错误;完成后店铺归属由后端继承 |
| #188 换货提醒 | `GET /api/c/v1/notifications/unread-count``GET /api/c/v1/notifications``PUT /api/c/v1/notifications/:id/read` | 无新增通知接口;前端继续使用现有未读数、列表和已读接口弹窗展示 |
| #181 订单字段 | `GET /api/admin/orders``GET /api/admin/orders/:id``GET /api/c/v1/orders``GET /api/c/v1/orders/:id` | 返回正确 `purchase_role``asset_identifier`;设备标识为 VirtualNo 优先、IMEI 兜底 |
| #40 下架套餐续费 | `GET /api/c/v1/asset/info``GET /api/c/v1/orders``GET /api/c/v1/orders/:id``POST /api/c/v1/orders/create` | 资产信息返回 `current_package_id`,历史订单返回 `package_ids`;前端把选中的套餐 ID、资产 `identifier` 和当前允许的 `payment_method` 传给原创建订单接口 |
| #46/#86 资产展示 | `GET /api/admin/assets/resolve/:identifier``GET /api/c/v1/asset/info` | 返回预计最终到期字段;后台解析额外返回 `exchange_trace`,跳转前检查 `can_view` |
### 3.2 店铺业务员、信用和套餐授权
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
| --- | --- | --- |
| #60 联系电话搜索 | `GET /api/admin/shops?contact_phone=11位号码` | 精确查询;可与店铺名称、编号等条件组合 |
| #96 店铺业务员 | `GET /api/admin/shops/business-owner-candidates``POST /api/admin/shops``PUT /api/admin/shops/:id``GET /api/admin/shops` | 创建/更新传 `business_owner_account_id`;列表可用同名参数筛选,响应展示账号 ID、名称和可用状态 |
| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit``PUT /api/admin/shops/:id/credit-limit``GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口传 `credit_enabled + credit_limit + version`;金额单位均为分 |
| #55 套餐默认生效条件 | `POST /api/admin/packages``PUT /api/admin/packages/:id``GET /api/admin/packages/:id` | 请求使用 `expiry_base=from_activation|from_purchase`;响应展示默认生效条件名称 |
| #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations``PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override``null` 表示跟随套餐默认值 |
| #43 系列套餐多选 | `POST /api/admin/shop-series-grants``PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1100 项数组,每项包含 `package_id``cost_price`,删除时传 `remove=true` |
### 3.3 审批、退款和员工线下代充值
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
| --- | --- | --- |
| #182/#44 退款 | `GET /api/admin/refunds``GET /api/admin/refunds/:id` | 新增/补齐 `submitter_id``submitter_name``approval_provider``approval_status``approval_status_name` |
| #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` |
| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、15 个 `payment_voucher_key` 和备注,创建后等待企微审批 |
| #35 退款申请 | `POST /api/admin/refunds` | 仍用原入口;请求结构保持订单、实收金额、申请金额、凭证、原因等业务字段,创建后等待企微审批 |
| #34/#35 旧按钮 | `POST /api/admin/agent-recharges/:id/offline-pay``POST /api/admin/refunds/:id/approve|reject` | 仅兼容存量旧审批记录;企微记录前端不得显示这些操作按钮,最终按发布配置停用 |
| #37 企微应用 | `POST/GET /api/admin/wecom/applications``POST /api/admin/wecom/applications/:id/test` | 配置 corp_id、agent_id、Secret、回调 Token、EncodingAESKey管理端按明文填写 |
| #37 默认发起人和成员 | `POST /api/admin/wecom/applications/:id/members/sync``GET /api/admin/wecom/applications/:id/members``PUT /api/admin/wecom/applications/:id/default-creator` | 先同步成员,再从可见成员中选择默认 `userid` |
| #37 账号绑定 | `PUT /api/admin/accounts/:id/wecom-binding` | 管理员选择应用可见成员,绑定 `(corp_id,userid)`;不做扫码绑定 |
| #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` | 由企微服务器调用,前端无需调用 |
### 3.4 通知、批量任务、导出和 Gateway
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
| --- | --- | --- |
| #97 后台低余额通知 | `GET /api/admin/notifications/unread-count``GET /api/admin/notifications``PUT /api/admin/notifications/:id/read` | 复用现有后台通知接口;业务员账号正常展示低余额通知 |
| #33 临期列表 | `GET /api/admin/expiring-assets` | 支持资产类型、关键词、店铺、套餐、剩余天数和日期范围;响应含 `summary``expiry_level``is_priority` |
| #33 C 端提醒 | `GET /api/c/v1/notifications/unread-count``GET /api/c/v1/notifications` | 复用现有站内通知,前端在 15/7/3 天节点按未读通知弹窗 |
| #36 批量订购上传 | `POST /api/admin/storage/upload-url` | `purpose=batch_purchase`,上传 UTF-8 单列 CSV 后取得 `file_key` |
| #36 批量订购任务 | `POST /api/admin/asset-package-batch-orders``GET /api/admin/asset-package-batch-orders``GET /api/admin/asset-package-batch-orders/:id` | 创建传 `file_key + package_id + payment_method`,线下支付另传 `voucher_keys[]`;详情返回逐行结果 |
| #42 业务导出 | `POST /api/admin/export-tasks``GET /api/admin/export-tasks``GET /api/admin/export-tasks/:id` | `scene` 使用 `iot_card/package/agent_wallet_transaction/agent_recharge/refund/exchange`,筛选条件放 `query` |
| #47 卡限速 | `PUT /api/admin/iot-cards/:iccid/speed-tier` | 请求只传 `code=-1..8`;设备页面不要展示限速入口 |
| #48 后台支付配置 | `GET /api/admin/system-configs``PUT /api/admin/system-configs/:key` | Key 为 `c2b.payment.card_allowed_methods``c2b.payment.device_allowed_methods`;更新请求的 `value` 是 JSON 数组字符串 |
| #48 C 端支付 | `GET /api/c/v1/asset/info``GET /api/c/v1/wallet/recharge-check``POST /api/c/v1/orders/create``POST /api/c/v1/wallet/recharge` | 展示和提交都使用后端返回的 `allowed_payment_methods`;创建订单时 `payment_method` 必传,微信场景按接口要求传 `app_type` |
| #49 分配 CSV 上传 | `POST /api/admin/storage/upload-url` | `purpose=device_batch_allocation`,上传单列 CSV 后取得 `file_key` |
| #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` |
## 四、前端本期最容易漏掉的工作
- 换货列表拆成新、旧资产两个搜索参数。
- 资产预计到期展示使用“预计最终到期”,并按临期字段高亮。
- 套餐分配页面传递默认/覆盖生效条件。
- 店铺页面接入联系电话查询、业务员选择和 C 端登录限制开关。
- 系列套餐授权页面真正使用现有 `packages[]` 做多选提交。
- C 端实名流程只读取 `effective_realname_policy`
- C 端支付按钮只读取 `allowed_payment_methods`
- 历史订单续费继续调用原创建订单接口,不等待新续费接口。
- 企微退款和线下代充值只读展示审批状态,隐藏本地人工审批按钮。
- 卡页面提供固定档位限速;设备页面不得出现限速入口。
- 设备批量分配继续复用原设备导入任务列表/详情页面,但根据 `operation_type` 改标题和结果说明。
## 五、联调和验收边界
- 当前有两个需要在联调时明确的接口边界:
- #44:退款/充值列表已经返回提交人和审批状态,但没有直接返回企微审批节点人员列表;现有 userid 映射能力尚未接入这两个业务 DTO。
- #49:设备批量分配创建服务允许平台和代理账号,但复用的设备导入任务列表/详情 Handler 目前仍沿用“仅平台用户可查看”的旧限制。若本期只允许平台操作则前端应隐藏代理入口;若要求代理自行查看任务,需要再统一后端权限。
- OpenAPI 已生成,新增路径和 DTO 已检查;设备限速旧契约不存在。
- 本次没有执行数据库迁移、完整构建、自动化测试、LSP 或真实企微/Gateway 闭环。
- 企微、Gateway、Redis/Asynq、对象存储配置和回滚步骤见 [`七月迭代联调交付说明.md`](七月迭代联调交付说明.md)。
- 涉及金额的字段默认单位为分;前端展示时统一转换,提交时不要传浮点元金额。

View File

@@ -0,0 +1,126 @@
# 七月迭代联调交付说明
## 交付状态
本说明覆盖 `deliver-july-iteration-confirmed-scope` 的企业微信、Gateway、Redis/Asynq、对象存储和前端联调输入。生产代码、增量迁移和 OpenAPI 已生成至可联调状态;测试库已执行至 `000194` 并完成结构核对尚未执行自动化测试、完整构建、LSP 或真实外部环境闭环。
接口统一使用 `{code,msg,data,timestamp}` 响应。完整请求和响应结构以 `docs/admin-openapi.yaml` 为准。
## 企业微信联调
### 环境和管理配置
| 配置 | 来源 | 说明 |
| --- | --- | --- |
| `JUNHONG_WECOM_BASE_URL` | 环境变量 | 默认 `https://qyapi.weixin.qq.com` |
| `JUNHONG_WECOM_TIMEOUT` | 环境变量 | 默认 `10s` |
| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | 环境变量 | 32 字节随机密钥的 Base64 文本;更换前必须先完成历史凭据重加密 |
| `corp_id``agent_id`、Secret | `POST /api/admin/wecom/applications` | 管理端按明文提交,服务端加密保存;超级管理员读取时返回明文,业务日志不记录 |
| 回调 Token、EncodingAESKey | 同上 | Token 按明文提交EncodingAESKey 固定 43 位 |
| 默认发起人 | `PUT /api/admin/wecom/applications/:id/default-creator` | 必须先同步可见成员,再从当前可见成员中选择 |
| `template_id` 和控件映射 | `PUT /api/admin/wecom/scenes/:business_type` | 模板必须先在企微后台创建,本系统只校验和绑定 |
部署侧还必须在企微管理后台配置:自建应用审批权限、应用可见通讯录范围、调用接口的可信 IP以及公开可访问的回调地址
```text
GET/POST {API_PUBLIC_BASE_URL}/api/callback/wecom/approval/{application_id}
```
### 配置和联调顺序
1. 配置加密 Key部署 API 和 Worker。
2. 创建应用配置并调用 `POST /api/admin/wecom/applications/:id/test` 验证取 token。
3. 调用 `POST /api/admin/wecom/applications/:id/members/sync`,再分页查询成员。
4. 为内部系统账号调用 `PUT /api/admin/accounts/:id/wecom-binding` 绑定 `(corp_id,userid)`
5. 选择一个当前可见成员作为默认发起人。代理退款、代理相关业务以及未绑定内部员工均使用该成员发起,但本地业务单仍保存真实提交人。
6. 分别配置 `refund_approval``offline_recharge_approval``template_id` 和控件映射。选择控件必须填写企微模板真实 option key。
7. 在企微后台配置回调 URL、Token、EncodingAESKey完成 GET 校验。
8. 分别提交一笔退款和员工线下代充值,验证 `applyevent``sp_no`、回调、详情同步、标准终态和业务终态。
Integration Log 不记录 Secret、access_token、media_id、附件正文或完整企微响应普通业务字段和业务快照按明文保存便于业务处理。
### 结果未知和旧入口
- `applyevent` 超时或断连后标记为结果未知不自动创建第二张审批单Worker 通过时间窗单号和详情查询恢复。
- `wecom:approval:recovery` 每 2 分钟触发,详情同步使用 `wecom:approval:sync`
- 在真实退款和线下代充值闭环、存量 provider 清单均验证前,保持 `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true``JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true`
- 切换后前端只读展示 `approval_provider``approval_status``approval_status_name`,不得再提供企微记录的本地人工通过、驳回或线下确认按钮。
## Gateway 卡限速联调
| 环境变量 | 说明 |
| --- | --- |
| `JUNHONG_GATEWAY_BASE_URL` | Gateway API 基础地址 |
| `JUNHONG_GATEWAY_APP_ID` | Gateway 应用 ID |
| `JUNHONG_GATEWAY_APP_SECRET` | Gateway 应用密钥 |
| `JUNHONG_GATEWAY_TIMEOUT` | 超时秒数,允许 5300默认配置为 60 |
接口固定为:
```text
PUT /api/admin/iot-cards/:iccid/speed-tier
```
请求只允许 `code=-1..8`Gateway 业务参数只有卡 ICCID 对应的 `cardNo` 和档位 `code`。设备没有限速接口,也不得通过设备绑定关系间接限速。
联调需覆盖全部档位、越权卡、明确失败和超时。超时返回结果未知时,按 Integration Log 中的 ICCID 到 Gateway 运维侧核对当前档位,再决定是否人工重试;系统不会盲目自动重发。
## Redis、Asynq 和 Worker
API 与 Worker 必须连接同一 Redis/Asynq。新增或复用的任务如下
| 任务类型 | 队列 | 用途 |
| --- | --- | --- |
| `wecom:approval:sync` | `wecom:approval` | 拉取企微详情并同步标准决策 |
| `wecom:approval:recovery` | `wecom:approval` | 结果未知恢复和未终态轮询 |
| `asset:package:batch_order` | 同名独立队列 | 单列 CSV 批量订购 |
| `device:import` | 同名独立队列 | 原设备导入和设备 CSV 批量分配共用任务外壳,通过 `operation_type` 分支 |
| `package:expiry:reminder` | `data:cleanup` | 每日 15/7/3 天套餐临期提醒 |
| `export:dispatch/shard/finalize` | 各自既有队列 | 六类新增 datasource 继续复用导出流水线 |
联调时确认 Worker 启动日志已注册上述 Handler`JUNHONG_WORKER_ROLE` 的部署拓扑为单实例 `all`,或严格一个 `leader` 加多个 `consumer`
## 对象存储和 CSV
先调用 `POST /api/admin/storage/upload-url` 获取预签名地址,再把文件直传对象存储,业务接口只接收返回的 `file_key`
| purpose | 业务 | 文件要求 |
| --- | --- | --- |
| `batch_purchase` | 资产套餐批量订购 | UTF-8 单列 CSV最多 1000 行、10MB |
| `device_batch_allocation` | 设备分配代理或设置套餐系列 | UTF-8 单列 CSV支持 VirtualNo、IMEI、SN最多 1000 行、10MB |
对象存储需配置 `JUNHONG_STORAGE_PROVIDER`、S3 endpoint/region/bucket/access key/secret key、SSL/path style 和预签名有效期。Worker 必须具备下载相同私有对象的权限。
## 前端接口和字段
| 场景 | 前端接入要点 |
| --- | --- |
| 店铺登录限制 | `PUT /api/admin/shops/:id``client_login_disabled`;列表和详情同字段回显,只阻止新登录,不踢出已有 Token |
| 实名策略 | 卡、设备分别调用 `POST /api/admin/iot-cards/batch-update-realname-policy``POST /api/admin/devices/batch-update-realname-policy`;最多 500 条、全成全败 |
| C 端资产初始化 | 使用 `effective_realname_policy``realname_required``realname_status``allowed_payment_methods`,不要在前端重新推断策略 |
| 支付方式配置 | `GET/PUT /api/admin/system-configs` 管理 `c2b.payment.card_allowed_methods``c2b.payment.device_allowed_methods`;至少保留一种 |
| 临期资产 | `GET /api/admin/expiring-assets`,使用数量、高亮和优先标识 |
| 企微配置 | 使用应用、成员同步/选择、默认发起人、账号绑定和场景接口;模板节点和审批人规则仍在企微后台维护 |
| 退款/充值/换货 | 展示 `submitter_id/submitter_name`;退款和充值只读展示企微审批渠道及状态 |
| 订单/退款资产 | 卡展示 ICCID设备优先 VirtualNo、缺失时展示 IMEI订单同时展示 `purchase_role` |
| 批量订购 | 上传 `purpose=batch_purchase`,再调用 `POST /api/admin/asset-package-batch-orders`;任务列表和详情使用统一状态名称 |
| 设备批量分配 | 上传 `purpose=device_batch_allocation`,再调用 `POST /api/admin/devices/import/allocations`;任务查询复用设备导入任务接口并展示 `operation_type/operation_name/target_id/status_name` |
| 业务导出 | `POST /api/admin/export-tasks`;新增场景为 `iot_card``package``agent_wallet_transaction``agent_recharge``refund``exchange` |
| 卡固定限速 | 只在卡页面展示固定档位选择;设备页面不得出现限速入口 |
## 已知限制
- 本系统不创建企微模板、不配置审批节点或审批人规则,也不提供扫码绑定。
- 企微应用只能看到其可见范围内的成员;可见范围或可信 IP 错误会导致连接、绑定或提交失败。
- 卡限速不保存本地当前档位,结果未知必须人工向 Gateway 核对。
- CSV 任务逐行部分成功,不保证整批业务原子性;任务详情是失败和跳过原因的权威展示。
- 店铺登录限制不主动吊销既有 C 端 Token。
- 测试库已完成 `000182``000194` 结构落库和注释核对;其他环境尚未执行迁移,外部系统闭环和前端浏览器验收也未完成。
## 发布和回滚
1. 测试库已执行至 `000194`;其他环境发布时仍须按迁移顺序执行并核对最终版本和表结构。
2. 先部署数据库兼容代码、API 和 Worker保持旧审批入口开启再配置企微应用、模板、回调和默认发起人。
3. 真实审批闭环通过后,先隐藏前端旧按钮,再关闭旧入口配置并重启 API。
4. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。
5. 已产生的审批、退款、钱包流水、订单、通知、Outbox、Integration Log、设备分配和限速审计均为业务事实不得清表回滚使用前向修复。

View File

@@ -0,0 +1,654 @@
# 七月迭代需求范围确认表
> 用途:由需求负责人逐条确认本期是否实施、按什么口径实施,以及哪些内容明确后置或取消。
>
> 来源:`docs/7月迭代/物联网卡管系统-需求.csv` 当前 41 条记录。其中 36 条挂在七月迭代计划5 条已关闭。
>
> 填写完成后,请通知后端重新审查。本表确认前,不据此修改 `complete-july-iteration-test-release` 或实施业务代码。
## 填写说明
每条需求只勾选一个“本期决定”,然后在“说明”中补充你的处理意见即可。
本期决定:
- [ ] 本期做:本次迭代必须上线。
- [ ] 已完成,只联调/验收:不再重复开发后端。
- [ ] 后置:需求保留,但不阻塞本次上线。
- [ ] 不做:取消或移出当前产品范围。
- [ ] 待确认:产品口径仍不清楚,暂不实施。
填写示例:
```markdown
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:只在现有店铺列表增加联系电话精确筛选。
```
---
## 一、新增、漏项或未被当前提案准确追踪的需求
### #189 换货迁移套餐退款后未正常失效
- 原始需求:换货后的新卡继承旧卡套餐后,对原订单退款,退款通过但套餐没有正常失效。
- 当前判断:提案漏项;属于退款与换货交叉缺陷。
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:跟前端没关系;这个问题是后端查找路径有问题,按 Bug 修复即可。
### #188 广电管控卡弹窗提醒
- 原始需求:广电风险停机卡在 H5 弹窗提醒换卡并采集收货地址;描述中还包含后台主动投放、自定义营销内容、自动换货单和 ERP 对接设想。
- 当前判断:提案漏项;“风险卡提醒”与“通用营销投放/ERP”应分别确认。
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:他描述的有远,我们只在已经完成的站内通知的基础上,保证创建物流换货单的时候会在C端弹窗通知用户
### #182 退款和代理充值列表增加提交人
- 原始需求:退款管理和订单/代理充值后台列表增加提交人字段。
- 当前判断:与 #44 部分重复;可先作为简单字段独立交付,不应等待完整企微审批体系。
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明现在数据库里面存了提交人的ID,在列表跟详情中返回id跟名称就好了
### #181 订单和退款列表缺少资产标识、订单渠道
- 原始需求:设备类订单、退款记录未正确展示资产标识符和订单渠道。
- 当前判断:提案漏项;属于 DTO/查询/展示字段修复。
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明这算是一个bug,我记得只有C端购买订单的时候会缺少订单渠道,然后退款列表跟退款详情是设备的标识没有正确放入
### #99 退款支持原路退回
- 原始需求:支持原路退款和客户收款凭证退款;原路退款前校验原收款商户退款能力,失败后允许改为凭证退款。
- 当前判断:提案漏项,并与现提案“部分支付方式系统外退款”口径冲突;属于资金业务,需明确真实范围。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:
### #84 H5 首页隐藏设备下 ICCID
- 原始需求:新卡管 H5 首页不再显示设备号下方的 ICCID。
- 当前判断:提案漏项;基本属于纯前端调整。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:跟我们后端没关系
### #52 聚水潭对接
- 原始需求:打通聚水潭订单、发货等数据。
- 当前判断:提案漏项;当前没有接口清单、数据方向和验收标准,不能直接实施。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:现在不做
### #51 不同品类资产换货及补差价
- 原始需求:卡与设备跨品类换货、补差价、旧套餐失效、新资产使用正确品类套餐。
- 当前判断:当前为草稿,旧规划排除;属于独立复杂业务,不适合顺带实现。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:
### #41 代理 API 查询限制
- 原始需求名称存在误导,最终业务口径不是“代理跨级 API 查询限制”。
- 最终口径:为每个店铺提供一个 C 端登录限制开关;开关开启后,该店铺名下的卡和设备不能再发起新的系统 C 端登录。
- 建议最小范围店铺增加配置字段并提供管理员配置入口C 端资产校验成功后、签发短期资产令牌前按资产所属店铺校验。平台库存或未归属店铺的资产保持原行为。
- 明确不做:不重构代理权限体系,不做全局 Token 吊销,不强制已登录用户立即下线,不迁移现有认证模块。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:提供接口针对某个店铺开关;打开后,属于该店铺的资产不能登录系统 C 端。
### #39 代理分销码与佣金提现
- 原始需求:分销归属、分销码、佣金提现及合同/证照/发票材料。
- 当前判断:现提案明确移出本期;其中“员工作为发展人”已由 #96 单独承接。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:
---
## 二、已有后端成果,优先确认前端联调和验收
### #45 换货列表新旧资产展示和独立搜索
- 原始需求:修正新旧资产标识混乱;新旧资产分别支持 ICCID、接入号、虚拟号搜索。
- 当前判断:后端已完成且有测试,主要剩前端和人工验收。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:现在跟我们后端没关系了
### #46 资产预计套餐到期时间
- 原始需求:显示当前生效套餐过期时间,剩余 15 天时高亮。
- 当前实现:后端已实现“当前套餐加排队套餐后的预计最终到期时间”,且有测试。
- 需要确认:接受当前“预计最终到期”口径,还是改回只显示当前生效套餐到期时间。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:现在这个口径是对的,已经跟我们后端没有关系了,接口已经提供给前端
### #55 套餐分配生效条件
- 原始需求:套餐支持购买即生效或实名即生效,分配时可覆盖条件。
- 当前判断:后端已完成且有测试。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:已经提供给前端对接了,跟我们没关系了
### #60 店铺联系电话搜索
- 原始需求:店铺列表增加联系电话检索。
- 当前判断:后端已沿用旧 Store 完成精确查询并有测试,符合简单需求处理方式。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:已经提供给前端接口,跟后端没有关系了
### #73 行业卡允许未实名复机
- 原始需求:行业卡允许未实名复机。
- 当前实现:旧代码已经按 `card_category=industry` 放行未实名复机。
- 提案偏差:现提案准备改成按运营商 `realname_link_type` 判断,可能反向改变原需求。
本期决定(单选):
- [ ] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [X] 不做
- [ ] 待确认
- 说明:先不管这个,只要行业卡能正确的不管有没有实名都能复机即可
### #86 资产详情换货标识与跳转
- 原始需求:详情显示新资产/旧资产换货标识,旧资产可链接新资产。
- 当前判断:后端已完成且有测试,主要剩前端和历史数据人工抽样。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:前端已经对接,现在与后端无关
### #38 代理信用额度
- 原始需求:代理可授权额度、不同额度下限、金额业务使用额度、允许显示负余额。
- 当前判断:后端代码基本完成,但资金链自动化和真实验证延期。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:接口已经提供给前端,具体测试是测试环节的事情,现在与后端无关吧,最好确认一下代码是否完成
### #94 状态同步优化和运营商回调
- 原始需求:因资产迁移优化状态同步和回调,原描述没有具体验收标准。
- 当前判断:后端已完成大范围公共观测和四类运营商回调,但真实运营商/Gateway 验证延期。
本期决定(单选):
- [ ] 本期做
- [x] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明: 现在就算优化完了,有运营商回调,有时间触发,轮询还是保持老样子
### #96 店铺业务员/发展人
- 原始需求:新建店铺可选业务员,列表/详情展示,可按业务员筛选。
- 当前判断:后端代码完成,主要剩前端和验证。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:接口已经提供给前端了,与后端无关
### #98 换货新资产继承旧店铺
- 原始需求:换货时新资产默认继承旧资产归属。
- 当前判断:旧换货 Service 已实现主要归属继承逻辑;提案任务状态滞后,不应再建新的 Domain/分配记录。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这算做完了,就不要动了,到时候我会统一说创建一个新的提案,把原来的提案废掉
### #43 代理系列套餐批量授权
- 原始需求:支持一次多选,展示建议售价和公司成本价,区分已分配/未分配套餐。
- 当前判断:核心批量授权、价格和移除兼容能力基本已有;应轻量收口,不从头重构。
本期决定(单选):
- [ ] 本期做
- [X] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这他妈就是错误的描述,之前我们的接口本身就支持批量多选,但是前端没做,这个需求跟后端没关系
---
## 三、适合沿用现有代码做局部修改的需求
### #53 卡和设备实名状态筛选
- 原始需求:卡管理、设备管理增加已实名/未实名筛选。
- 提案问题:增加设备实名投影、历史初始化、事件消费者和 Worker明显超出简单筛选需求。
- 建议最小范围:优先在现有列表 DTO、Store 查询和返回字段上补筛选;只有现有数据无法准确查询时再讨论快照。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:设备有一个情况是,只要有任意一张卡实名了,就算实名
### #57 存在退款申请时禁止换货
- 原始需求:资产存在未完成退款申请时禁止换货,并提示“该资产存在退款申请”。
- 提案问题:被绑定到尚未完成的全新退款模型和企微审批链。
- 建议最小范围:在现有换货创建入口查询现有退款状态并拦截。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:没有什么特别注意的,越简单达成需求越好
### #62 H5 先充值/先实名流程配置
- 原始需求:进入 H5 先绑定手机号,并可按资产批次选择先充值后实名或先实名后充值。
- 当前判断:已有 `realname_policy`、单资产修改接口和 C 端购买/充值门禁,不应按从零 DDD 建设。
本期决定(单选):
- [x] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明: 这个有点奇怪,之前我跟前端的约定是这样的"目标:支持无需实名、先实名后购买、先购买后实名三种流程,并提供后台批量配置。
预计工时前端23小时。
页面入口C端资产初始化流程后台卡列表和设备列表。
页面结构:
1. C端按 effective_realname_policy 决定直接购买、先实名或购买后提示实名。
2. 后台卡和设备列表分别增加“批量修改实名顺序”入口。
3. 弹框提供无需实名、先实名后购买、先购买后实名三选一,并展示已选数量。
4. 修改设备下卡策略时提示“实际H5流程由设备策略决定”。
接口约定:
- PATCH /api/admin/assets/{identifier}/realname-mode入参 realname_policy:none|before_order|after_order。
- POST /api/admin/iot-cards/batch-update-realname-policy、POST /api/admin/devices/batch-update-realname-policy入参 asset_ids:int64[]、realname_policy:string。
- C端初始化返回 effective_realname_policy:string、realname_required:bool、realname_status:int。
交互规则单次最多500条批量接口全成全败前端不根据资产类型自行覆盖策略。
完成标准三种C端流程和卡/设备批量配置均可运行,冲突数据能展示明确错误。"
### #44 退款、充值、换货列表字段
- 原始需求:退款列表增加提交人/审批人;代理充值列表增加提交人/审批人;换货列表增加提交人。
- 提案问题:将简单字段绑定到通用审批、企微节点、历史回填和新状态模型。
- 建议最小范围:先交付已有数据能够提供的提交人/审批人字段;企微详情和历史快照另行确认。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个很关键,提交人我们现在就按照现在的有,只是存了id没有存名称,相关接口也没有返回,至于审批人,我们没办法拿到,这是企微模版中已经做好的,所以不用加审批人,或者说如果我们能通过模版拿到审批人企微的id,同时又跟我们系统的账号绑定了的话,就能正常展示审批人,这个看怎么搞,反正不能搞复杂了,我觉得就是一个很简单的,现有的逻辑都有了,只是把审批通过从我们系统中变成到企微中
### #97 代理钱包余额预警
- 原始需求:不同代理可配置不同预警额度,达到阈值时提醒代理及其发展人。
- 提案偏差:被改成固定 100 元且不提供阈值配置。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个主要是发站内通知,站内通知的功能好像已经做完了,这个就是低于100元的时候提醒发展人(业务员,创建店铺的时候选择的)
---
## 四、确有复杂业务,但需要重新确认原始口径
### #33 套餐临期提醒
- 原始需求每日计算15/7/3 天节点;企业客户企微推送业务员;代理端数量/高亮/置顶C 端续费提醒。
- 提案偏差:企业微信提醒被排除,只保留站内通知。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个得做,我们只做站内提醒,还有代理端数量/高亮/置顶(我记得现在好像是单独开一个临期列表吧,后台管理跟代理端都要),然后C端那边的话就是这几个时间点站内通知弹窗提醒
### #34 代理在线充值与员工线下代充值
- 原始需求:代理提交充值后扫码支付;员工线下代充值走部门领导、财务审批并通知结果。
- 当前判断:业务未完成;在线支付、资金入账和线下审批应分别确认。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:在线充值可以后置,员工线下代充这个其实就是走企微审批,现在我记得是做了我们系统的审批,不知道怎么做的,好像是做成灵活的,未来可以接任何第三方的,现在不要考虑那么多了,先赶紧按照企微的审批做完再说别的
### #35 退款审核
- 原始需求:员工申请,部门领导和财务顺序审批,逐环节企微提醒,通过/驳回通知申请人。
- 当前判断:业务未完成;与 #37 共用审批能力,不应建立第二套审批系统。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个其实也是企微审批,退款本身我们已经做了,我记得退款好像本身也有审批吧,只是说我们让前端做成权限的了,谁能看见谁就能通过来着,现在就是把企微审批接进来
### #36 批量订购套餐
- 原始需求:内部员工上传 Excel文件含资产类型、资产标识、套餐系列、套餐、代理、支付账户校验后扣款订购并展示失败明细。
- 提案偏差:改成单列 CSV不选择代理整批只选一个套餐和支付方式。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个"改成单列 CSV不选择代理整批只选一个套餐和支付方式。"是对的
### #37 企业微信审核流转
- 原始需求:充值和退款多级审批、待办提醒、逐环节通知、结果通知,并接入企业微信。
- 当前判断:只完成渠道无关审批核心,企业微信 Adapter、绑定、模板、回调和轮询均未完成。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:是的剩下的东西都要完成。企微审批模板由管理员在企微后台创建,系统只配置“业务场景与企微模板”的绑定并按模板发起审批,不在本系统新建审批模板或设计审批节点。账号身份不采用用户扫码自助绑定;通过企微通讯录接口获取成员,在系统账号列表中由管理员维护系统账号对应的企微 userid。退款、员工线下代充等业务发起审批后通过企微回调接收结果并保留查询审批详情/轮询作为回调丢失时的补偿。是否能取得审批人 userid 并映射为系统账号名称,以企微官方接口实际返回能力为准,不能为列表展示另建复杂审批人模型。
### #40 下架套餐老客户续费
- 原始需求:正在使用下架套餐的客户仍可自行续费;新客户不可购买;代理不可代购。
- 当前判断:未完成;有购买资格校验,但不需要迁移整个套餐模块。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个要做,主要是提供一个续费接口,只有通过这个接口才能去购买下架后的套餐(我记得是这样设计的),然后确保C端的可购套餐列表中没有下架的套餐,其次就是可以在历史订单里面续费,应该也是用同一个接口
### #42 导出功能
- 原始需求:分别支持 lot 卡、钱包流水、套餐、退款、换货、代理充值六类导出及指定字段。
- 提案问题:将六个独立导出需求扩大成统一字段权限平台,并依赖多个未完成业务。
- 建议:按业务场景逐个交付,优先使用现有导出框架,不阻塞在“统一平台全部完成”。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:这个没啥好说的,确保按照我们有的字段,以及业务要求的字段去导出即可,可能需要探索一下,如果有字段我们没有,需要决定一下是否要加
### #47 限速规则
- 原始需求:根据不同运营商规则,基于套餐流量设置卡/设备限速规则。
- 提案偏差:改成后台手动选择固定档位并调用 Gateway明确排除自动限速。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明:改成后台在 IoT 卡详情手动选择固定档位并调用 GatewayGateway 只支持按卡 ICCID 限速,设备没有限速接口,也不通过设备绑定卡间接限速
### #48 按资产类型限制支付方式
- 原始需求:卡只允许支付宝/钱包,拒绝微信;设备只允许微信/钱包,拒绝支付宝。
- 当前判断:未完成;前端展示和后端订单/支付强校验都需要实施。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明这个其实就是在system_config里面做一个参数修改而已,通过这个参数可以决定卡/设备的支付方式限制,这个我们的迭代方案中好像说明是对的,其他的就是在C端不知道有没有东西能让前端判断展示哪些支付方式了
### #49 设备批量分配代理和套餐系列
- 原始需求:上传 Excel以设备号批量分配代理或套餐系列。
- 提案偏差:改成 CSV、异步五态任务、对象存储和失败明细平台。
本期决定(单选):
- [X] 本期做
- [ ] 已完成,只联调/验收
- [ ] 后置
- [ ] 不做
- [ ] 待确认
- 说明是改成csv了,其他跟现在导入没有什么区别,就是业务不一样
---
## 五、已关闭需求回归确认
以下 5 条在 CSV 中未挂七月计划且已关闭。默认不重新开发;如本次需要回归,请勾选。
### #168 停机阈值显示
- 原始需求:虚流量停机阈值显示为真流量总量。
本期决定(单选):
- [ ] 仅回归
- [ ] 重新修改
- [X] 不处理
- 说明:已经做过了
### #90 资产详情敏感字段展示调整
- 原始需求:代理/企业只显示运营商名称,不显示运营商账户;代理不显示设备实名策略和制造商。
本期决定(单选):
- [ ] 仅回归
- [ ] 重新修改
- [X] 不处理
- 说明:
### #75 支付配置命名调整
- 原始需求:“微信配置”改名为“支付配置”,同步权限编码和路由命名。
本期决定(单选):
- [ ] 仅回归
- [ ] 重新修改
- [X] 不处理
- 说明:
### #64 H5 设备支持支付宝
- 原始需求H5 设备支付增加支付宝。
本期决定(单选):
- [ ] 仅回归
- [ ] 重新修改
- [x] 不处理
- 说明:
### #63 授权列表滚动条和字段顺序
- 原始需求:代理系列授权套餐列表增加滚动条,调整后台资产字段展示顺序。
本期决定(单选):
- [ ] 仅回归
- [ ] 重新修改
- [x] 不处理
- 说明:
---
## 六、填写完成后
填写完成后通知后端复核即可。后端将根据选择结果重新整理 OpenSpec 提案和实施顺序。

View File

@@ -0,0 +1,202 @@
# 企业微信审批官方接口调研
> 调研范围:企业微信开发者中心官方文档
> 访问日期2026-07-24
> 结论口径:文中“官方明确”均来自企业微信官方页面;“建议”是基于官方约束形成的系统接入方案,不代表企业微信额外承诺。
## 一、结论摘要
1. 管理员手工绑定系统账号与企业微信成员是可行的。系统应保存企业标识与 `userid` 的绑定关系,并用姓名、部门辅助管理员辨认。`userid` 是企业内唯一、对应管理端账号的成员标识;手机号和邮箱属于敏感字段,不能假定一定能取得,也不应作为唯一绑定依据。[读取成员][1]
2. 审批模板通常可以在企业微信管理后台维护,模板 ID 可从模板编辑页 URL、审批回调或审批详情取得系统再调用“获取审批模板详情”读取控件 ID、类型、必填属性和选项 key。自建应用和代开发应用也可以通过接口创建模板因此“模板只能由管理员在后台创建”的设想不成立但第三方应用不支持创建模板接口。[获取审批模板详情][6] [创建审批模板][7]
3. 系统发起审批时,必须使用已绑定且位于应用可见范围内的申请人 `userid`,提交模板 ID、审批流程选择、控件值和摘要。附件须先上传为临时素材再将返回的 `media_id` 作为附件控件的 `file_id` 提交。[提交审批申请][8] [上传临时素材][9]
4. 审批结果同步不能只依赖回调。官方明确说明回调不能保证 100% 成功,应采用“回调实时处理 + 批量获取审批单号 + 获取审批详情”的主动补偿机制。[回调配置][12] [批量获取审批单号][11] [获取审批申请详情][10]
5. 上线前必须具备正确应用 Secret、按应用缓存的 `access_token`、审批接口权限、应用可见范围、可信 IP以及可完成签名校验和 AES 解密的回调服务。[开发前必读][15] [获取 access_token][14] [审批状态变化回调][13]
## 二、通讯录能力与手工绑定方案
### 2.1 官方接口能取得什么
| 信息 | 官方能力 | 主要限制 |
| --- | --- | --- |
| `userid` | “读取成员”返回成员标识;“获取部门成员/详情”可按部门返回成员;“获取成员 ID 列表”可分页返回 `userid` 与部门 ID 关系 | 只能读取应用可见范围;“获取成员 ID 列表”只支持“通讯录同步 secret”调用。[读取成员][1] [获取部门成员][2] [获取成员 ID 列表][4] |
| 姓名 | 自建应用可在权限范围内通过成员接口取得;代开发自建应用需要管理员授权 | 第三方应用通常不能直接取得真实姓名,接口可能以 `userid` 代替,需要使用通讯录展示组件。[读取成员][1] |
| 部门 | 成员接口返回部门 ID部门成员接口也返回成员所属部门列表 | 只返回应用有查看权限的部门;要取得部门名称还需结合部门列表接口。成员授权模式下,部门信息还可能固定为根部门。[读取成员][1] [获取部门列表][5] |
| 手机号、邮箱 | 接口字段存在;自建应用与代开发应用可在满足授权条件后获取 | 2022-06-20 之后新建的自建应用和代开发应用,敏感字段需管理员在应用详情选择,并由成员通过 OAuth2 授权;第三方应用不可通过“获取访问用户敏感信息”取得手机、邮箱。[读取成员][1] [获取访问用户敏感信息][3] |
`userid` 对应企业微信管理端账号,在企业内必须唯一、不区分大小写,长度为 164 字节。对于企业内部自建应用,建议将 `(corp_id, userid)` 作为企业微信身份键;不要仅保存姓名,也不要用手机号或邮箱充当稳定主键。[读取成员][1]
### 2.2 推荐的管理员手工绑定流程
1. 为企业微信自建应用配置足够但最小化的通讯录可见范围。
2. 系统按部门拉取可见成员,向管理员展示 `userid`、姓名和部门。若使用通讯录同步能力,可用“获取成员 ID 列表”分页取得完整 `userid`/部门关系;该接口不能用普通应用 Secret 替代通讯录同步 Secret。[获取部门成员详情][2] [获取成员 ID 列表][4]
3. 管理员在系统账号页面选择一个企业微信成员,系统保存系统账号 ID、企业 ID、`userid`,并保存姓名/部门快照用于展示和审计。
4. 发起审批前再次确认绑定仍有效,且该成员仍在审批调用应用的可见范围内;否则“提交审批申请”会因无权限或提单者不可见失败。[提交审批申请][8]
5. 手机号、邮箱只能作为获得成员明确授权后的辅助核对信息。没有敏感字段时,绑定流程仍应可完成。[获取访问用户敏感信息][3]
此方案是“管理员明确选择并绑定”,不是依据姓名、手机号自动猜测成员。重名、敏感字段缺失和通讯录权限变化都使自动匹配存在误绑风险。
## 三、审批模板与控件配置
### 3.1 模板从哪里来
企业微信提供两条路径:
- 管理后台创建和维护:模板 ID 可从模板编辑页面的浏览器 URL 取得,也会出现在审批状态回调和审批详情中。[获取审批模板详情][6]
- 接口创建:自建应用需被配置到“审批 - 可调用接口的应用”,代开发应用需具有“审批”权限;第三方应用暂不支持。创建成功后,管理后台和审批应用内会生成对应模板,并生效默认流程和规则配置。[创建审批模板][7]
因此,本项目若由客户企业管理员掌控流程,优先采用“后台建模板 + 系统配置模板 ID”若模板由平台标准化交付且应用权限满足要求可以评估调用创建模板接口。系统不应假定存在“列出企业全部模板”的接口官方“获取审批模板详情”要求调用方已知 `template_id`。[获取审批模板详情][6]
### 3.2 系统应如何配置控件
配置模板 ID 后,系统调用:
```text
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail?access_token=ACCESS_TOKEN
{"template_id":"TEMPLATE_ID"}
```
返回的 `template_content.controls` 包含:
- `property.control`:控件类型,如 `Text``Money``Date``Selector``Contact``File``Table` 等;
- `property.id`:提交审批时必须使用的控件唯一 ID
- `property.title``placeholder`:控件名称和填写说明;
- `property.require`:是否必填;
- `config`:日期精度、单/多选、成员/部门模式、明细子控件等配置;
- 选择控件的 `options[].key`:提交选择值时使用的稳定选项键。[获取审批模板详情][6]
建议系统保存“业务字段 → 控件 ID/类型/选项 key”的显式映射并在保存配置及发起审批前校验模板结构。不能仅按控件中文标题赋值因为提交接口以控件 ID 和选项 key 为准,管理员修改模板后原映射可能失效。
## 四、发起审批申请
### 4.1 请求与关键字段
接口为:
```text
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=ACCESS_TOKEN
```
核心字段如下:[提交审批申请][8]
| 字段 | 含义与约束 |
| --- | --- |
| `creator_userid` | 申请人 `userid`;审批将以该员工身份提交,且申请人必须在应用可见范围内 |
| `template_id` | 审批模板 ID |
| `use_template_approver` | `0` 表示接口通过 `process` 指定审批人/抄送人;`1` 表示使用后台模板流程 |
| `choose_department` | 提单部门 ID不填时默认主部门 |
| `process.node_list` | 接口指定流程时必填;节点类型可为审批人、抄送人、办理人,并配置人员及多人处理方式 |
| `apply_data.contents` | 控件赋值数组,每项必须携带 `control``id``value`;模板必填控件必须有值 |
| `summary_list` | 审批通知卡片和审批列表摘要,最多 3 行,每行文字不超过 20 个字符 |
`use_template_approver=1`,后台审批流程中不能存在“申请人自选”节点。若为 `0``process` 必填。接口当前不支持提交“打卡补卡”“调班”模板审批单。[提交审批申请][8]
不同控件的 `value` 结构不同,例如文本使用 `text`,金额使用 `new_money`,日期使用 `date`,选择控件使用 `selector.options[].key`,成员控件使用 `members[].userid`,部门控件使用 `departments[].openapi_id`。具体结构必须同时服从模板详情和提交接口附录,不能用一个通用字符串值替代。[获取审批模板详情][6] [提交审批申请][8]
提交成功返回审批单号 `sp_no`,系统应立即将其与本地业务单据关联,作为后续回调去重、详情查询和补偿对账的关键标识。[提交审批申请][8]
### 4.2 附件
附件需先调用临时素材上传接口:
```text
POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=file
Content-Type: multipart/form-data
```
上传结果中的 `media_id` 仅 3 天有效;提交附件控件时将其写入 `value.files[].file_id`。审批提单后,企业微信会将其转换为长期文件。单个审批申请全局最多支持 6 个附件;普通文件上传上限为 20 MB且所有文件必须大于 5 字节。[上传临时素材][9] [提交审批申请][8]
## 五、审批结果回传与主动补偿
### 5.1 状态变化回调
审批回调事件名为 `sys_approval_change`。回调包含 `SpNoStr``SpStatus``TemplateId`、申请人、流程节点、备注及 `StatuChangeEvent` 等信息。官方推荐使用字符串字段 `SpNoStr` 代替原有数值字段 `SpNo`。[审批状态变化回调][13]
申请单状态包括审批中、已通过、已驳回、已撤销、通过后撤销、已删除、已支付;状态变化类型还覆盖提单、同意、驳回、转审、催办、撤销、添加备注、退回、加签、办理和转交等。[审批状态变化回调][13]
回调处理建议:
1. 校验签名并解密消息;验证解密后的 `receiveid` 与企业 `corpid` 一致。[加解密方案][16]
2. 持久化必要的接收记录并快速应答,把详情拉取和业务状态更新交给异步任务。
3.`SpNoStr` 查询“获取审批申请详情”,以详情中的最终状态、流程和表单值更新本地数据,而不是仅依赖一次回调载荷。[获取审批申请详情][10]
4. 业务处理必须幂等。企业微信在网络失败或超时时会重试,且状态变化可能多次发生;本地应允许同一审批单重复对齐到最新详情。
企业微信服务器在 5 秒内未收到响应会断开连接并重新发起请求,总共重试 3 次,但仅针对网络连接失败或超时。官方同时明确说明无法保证 100% 回调成功,并建议不要强依赖回调、增加额外机制对齐业务数据。[回调配置][12]
### 5.2 主动查询与轮询补偿
补偿链路使用两个接口:
1. “批量获取审批单号”按审批单提交时间范围拉取 `sp_no_list`,可按模板 ID、申请人、部门、审批状态筛选单次最多 100 条,通过 `new_cursor`/`new_next_cursor` 分页。时间跨度不能超过 31 天,频率上限 600 次/分钟。[批量获取审批单号][11]
2. 对每个审批单号调用“获取审批申请详情”,取得 `sp_status`、模板、申请人、流程、表单控件值、备注和附件等完整信息;频率上限同样为 600 次/分钟。[获取审批申请详情][10]
建议定时任务按模板 ID 拉取最近时间窗,并保留重叠回看区间,以覆盖任务延迟、分页中断和回调丢失;本地以 `sp_no` 幂等更新。时间窗应以“提交时间”理解,因为批量接口的 `starttime`/`endtime` 定义为审批单提交时间,而不是最后状态更新时间。因此,对仍处于审批中的历史单据,还应单独维护待终态集合并周期性查询详情,不能只扫描最近新提交的单据。[批量获取审批单号][11]
## 六、接入前置条件
### 6.1 凭证与权限
- `access_token` 必须由后端使用企业 ID 和正确应用 Secret 获取,禁止返回给前端。每个应用的 Secret 和 Token 相互独立,必须按应用分别缓存。[获取 access_token][14]
- Token 正常有效期为 7200 秒,但可能提前失效;系统需按 `expires_in` 缓存,并在失效时重新获取,不能高频调用获取接口。[获取 access_token][14]
- 自建应用必须配置到“审批 - 可调用接口的应用”;代开发应用和第三方应用必须具有“审批”权限。提交人还必须在应用可见范围内。[获取审批模板详情][6] [提交审批申请][8]
- 自 2023-12-01 起,审批接口不再支持通过系统应用 Secret 调用,存量企业暂不受影响;新接入不能依赖系统应用 Secret。[获取审批模板详情][6]
- 2022-06-20 之后新开启的通讯录同步助手及新创建的自建应用,必须在管理端配置可信 IP只有配置的 IP 才能调用接口。[开发前必读][15]
### 6.2 回调服务
自建应用需在应用详情的“设置 API 接收”中配置 `URL``Token``EncodingAESKey`,并打开审批状态通知事件;其中 Token 用于签名43 位 `EncodingAESKey` 用于消息体加密。[审批状态变化回调][13] [回调配置][12]
回调端点必须同时支持:
- `GET`:校验 `msg_signature`,解密 `echostr`,在 1 秒内原样返回明文且不能带引号、BOM 或换行;
- `POST`:校验签名,解密 XML 中的 `Encrypt`,处理明文事件并正确响应;
- AES-256-CBC、PKCS#7 填充、签名计算及 `receiveid` 校验。官方提供 Go 等语言的加解密库,宜优先使用官方库。[回调配置][12] [加解密方案][16]
如防火墙按来源 IP 放行,可调用官方回调 IP 接口获取网段。官方提示 IP 段可能变化,建议每天定时拉取并更新防火墙。[回调配置][12]
## 七、不成立或存在明确限制的设想
| 设想 | 官方结论 |
| --- | --- |
| 只要能调审批接口,就一定能读取全企业通讯录 | 不成立。通讯录接口受应用可见范围、通讯录权限和 Secret 类型限制;审批权限不等于全通讯录权限。[读取成员][1] [获取成员 ID 列表][4] |
| 总能取得员工手机号或邮箱,用它自动匹配系统账号 | 不成立。新自建/代开发应用需管理员选择敏感字段并获得成员 OAuth2 授权;第三方应用还存在更严格限制。[获取访问用户敏感信息][3] |
| `userid` 可以跨企业直接当全局用户 ID | 不成立。官方定义的是企业内唯一;本地应与企业 ID 组合存储。第三方应用的成员接口还可能返回 `open_userid`。[读取成员][1] |
| 审批模板只能在企业微信后台人工创建 | 不成立。自建和代开发应用可调用创建模板接口;但第三方应用暂不支持。[创建审批模板][7] |
| 系统可以不保存模板 ID启动时自动枚举全部模板 | 官方“获取模板详情”接口要求已知 `template_id`,官方给出的来源是编辑页 URL、回调、审批详情或创建接口返回值不能据此假定存在全量模板枚举能力。[获取审批模板详情][6] [创建审批模板][7] |
| 修改模板控件标题不会影响系统提交 | 有限制。提交依赖控件 `id`、类型及选择项 `key`,不是只按标题匹配;模板变化后应重新校验映射。[获取审批模板详情][6] [提交审批申请][8] |
| 使用后台模板流程时,任何流程配置都能直接发起 | 不成立。`use_template_approver=1` 时,后台流程不能含“申请人自选”节点。[提交审批申请][8] |
| 所有审批模板都能通过接口发起 | 不成立。官方明确暂不支持“打卡补卡”“调班”模板。[提交审批申请][8] |
| 回调成功配置后可保证每次审批变化都送达一次 | 不成立。官方明确无法保证 100% 回调成功,超时重试也仅有限次数;必须主动补偿并实现幂等。[回调配置][12] |
| 回调就是完整审批详情,不需要再查询 | 有限制。状态回调提供状态和流程信息;完整表单控件值以“获取审批申请详情”为准。[审批状态变化回调][13] [获取审批申请详情][10] |
| 上传得到的附件 ID 可长期暂存,审批单附件数量不限 | 不成立。临时素材 `media_id` 仅 3 天有效,单个审批申请全局最多 6 个附件。[上传临时素材][9] [提交审批申请][8] |
| 可继续使用系统应用 Secret 接入新企业 | 不成立。审批接口自 2023-12-01 起不再支持系统应用 Secret只有存量企业暂不受影响。[获取审批模板详情][6] |
## 八、建议的最小接入闭环
1. 企业管理员创建并启用自建应用,配置应用可见范围、审批可调用应用、可信 IP、回调 URL/Token/EncodingAESKey。
2. 系统按应用维度安全保存 Secret并在后端缓存 `access_token`
3. 管理员通过可见成员列表,把本地账号绑定到 `(corp_id, userid)`
4. 管理员填写模板 ID系统拉取模板详情保存并校验业务字段与控件 ID/选项 key 的映射。
5. 发起审批时使用已绑定 `userid`;附件即时上传后随审批提交;保存返回的 `sp_no`
6. 回调服务验签解密、快速应答并异步按 `sp_no` 拉取详情,幂等更新本地业务状态。
7. 定时通过“批量获取审批单号 + 获取审批申请详情”补偿回调遗漏,同时周期性刷新未终态审批单。
## 九、官方资料
以下页面均为企业微信开发者中心官方文档,访问日期均为 **2026-07-24**
[1]: https://developer.work.weixin.qq.com/document/path/90196 "读取成员"
[2]: https://developer.work.weixin.qq.com/document/path/90201 "获取部门成员详情"
[3]: https://developer.work.weixin.qq.com/document/path/95833 "获取访问用户敏感信息"
[4]: https://developer.work.weixin.qq.com/document/path/96067 "获取成员ID列表"
[5]: https://developer.work.weixin.qq.com/document/path/90208 "获取部门列表"
[6]: https://developer.work.weixin.qq.com/document/path/91982 "获取审批模板详情"
[7]: https://developer.work.weixin.qq.com/document/path/97437 "创建审批模板"
[8]: https://developer.work.weixin.qq.com/document/path/91853 "提交审批申请"
[9]: https://developer.work.weixin.qq.com/document/path/90253 "上传临时素材"
[10]: https://developer.work.weixin.qq.com/document/path/91983 "获取审批申请详情"
[11]: https://developer.work.weixin.qq.com/document/path/91816 "批量获取审批单号"
[12]: https://developer.work.weixin.qq.com/document/path/90930 "回调配置"
[13]: https://developer.work.weixin.qq.com/document/path/91815 "审批申请状态变化回调通知"
[14]: https://developer.work.weixin.qq.com/document/path/91039 "获取access_token"
[15]: https://developer.work.weixin.qq.com/document/path/90664 "开发前必读"
[16]: https://developer.work.weixin.qq.com/document/path/90968 "加解密方案说明"

View File

@@ -1,72 +1,135 @@
"编号","所属产品","所属模块","所属计划","来源","来源备注","用户需求名称","描述","验收标准","需求层级","父需求","关键词","优先级","预计工时","当前状态","所处阶段","类别","T","B","C","由谁创建","创建日期","指派给","指派日期","抄送给","已评审人","评审时间","由谁关闭","关闭日期","关闭原因","最后修改","最后修改日期","反馈者","重复需求","附件" "编号","所属产品","所属模块","所属计划","来源","来源备注","用户需求名称","描述","验收标准","需求层级","父需求","关键词","优先级","预计工时","当前状态","所处阶段","类别","T","B","C","由谁创建","创建日期","指派给","指派日期","抄送给","已评审人","评审时间","由谁关闭","关闭日期","关闭原因","最后修改","最后修改日期","反馈者","重复需求","附件"
"189 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过货的卡迁移的旧套餐退款不会正常失效","目前换过货的新卡接受了旧卡的套餐,对这笔订单申请退款,退款通过后,这个套餐订单在卡上不会正常失效。
<img src=&quot;{157.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=157&quot; />
<img src=&quot;{158.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=158&quot; />
<img src=&quot;{159.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=159&quot; />
","","1 ","0 ","","3(#3)","","评审中(#reviewing)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-24 11:05:51","","","","","","","","","邹进","2026-07-24 20:18:13","","","",""
"188 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","广电管控卡弹窗提醒","广电风险停机的卡可以在H5进行弹窗提醒。
方式一系统自动弹窗提示客户需要换卡引导客户填写收货地址并同步至后台最好可以直接生成物流换货的换货单同时对接ERP系统
方式二:支持我们主动发出弹窗。提供发出弹窗功能,可以自定义弹窗内容,可以选择适用客户或适用设备类型或卡类型等。我们同时可以使用弹窗功能进行新的套餐政策推广,新品到货通知,从而引导客户消费等。(方式二是否需要新建一个需求,具体分析?)
","","1 ","0 ","","3(#3)","","评审中(#reviewing)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-23 17:18:34","","","","","","","","","邹进","2026-07-24 20:18:13","","","",""
"182 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款和订单充值提交人记录","退款管理和订单充值在后台的列表中都需要添加一个提交人字段
","","1 ","0 ","","3(#3)","","评审中(#reviewing)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-23 10:47:43","","","","","","","","","邹进","2026-07-24 20:18:13","","","",""
"181 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","订单列表和退款管理资产标识符、订单渠道缺失","目前订单列表和退款管理页面存在资产标识符、订单渠道缺失的情况,主要为设备:
退款单号RF20260723085931918896
<img src=&quot;{155.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=155&quot; />
订单号ORD20260721121735544971
<img src=&quot;{156.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=156&quot; />
","","1 ","0 ","","3(#3)","","评审中(#reviewing)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-23 09:50:32","","","","","","","","","邹进","2026-07-24 20:18:13","","","",""
"168 ","物联网卡管系统(#2)","/(#0)","","","","停机阈值显示","虚流量的停机阈值应为真流量的总流量
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-21 14:09:25","罗洋平","2026-07-21 14:09:25","","
黄燚麒(#huang)","2026-07-21 14:11:00","","","","罗洋平","2026-07-22 14:19:48","","","",""
"99 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款管理支持原路退回","系统需支持两种退款方式:
1. 原路退款
2. 客户提供收款凭证退款
同时,若选择原路退款,系统需先校验该订单对应的原收款商户当前是否具备退款能力,避免进入不可执行的退款流程。
2. 需求目标
1. 退款申请时支持选择退款方式。
2. 原路退款前自动校验原收款商户当前是否支持退款。
3. 当原收款商户不支持退款时,改为“客户提供收款凭证退款”。
4. 保留退款方式、校验结果、退款凭证、处理记录,便于财务复核与追溯。
3. 流程说明
1. 员工发起退款申请。
2. 选择退款方式。
3. 若选择原路退款,系统先执行原收款商户退款能力校验。
4. 校验通过,进入退款审核及执行流程。
5. 校验失败,提示原因,并允许改为客户提供收款凭证退款。
6. 若选择客户提供收款凭证退款,上传收款凭证后进入审核及财务处理流程。
7. 财务完成退款后,系统记录退款结果并通知申请人。
","","1 ","0 ","","2(#2)","6.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-16 11:50:41","黄燚麒","2026-07-16 12:06:36","","
黄燚麒(#huang)","2026-07-16 12:06:00","","","","邹进","2026-07-24 20:18:13","","","",""
"98 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过管理新资产归属","默认换货把旧资产的所属权一起划过去 "98 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过管理新资产归属","默认换货把旧资产的所属权一起划过去
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 14:08:57","黄燚麒","2026-07-16 11:51:00",""," ","","1 ","0 ","","3(#3)","2.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-15 14:08:57","黄燚麒","2026-07-16 11:51:00","","
黄燚麒(#huang)","2026-07-16 11:50:00","","","","黄燚麒","2026-07-16 11:51:00","","","","" 黄燚麒(#huang)","2026-07-16 11:50:00","","","","黄燚麒","2026-07-17 15:42:34","","","",""
"97 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理钱包阈值提醒","代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。 "97 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理钱包阈值提醒","代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:23:35","黄燚麒","2026-07-16 15:40:38",""," ","","1 ","0 ","","3(#3)","2.10 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-15 11:23:35","黄燚麒","2026-07-16 15:40:38","","
黄燚麒(#huang)","2026-07-16 15:40:00","","","","黄燚麒","2026-07-16 15:40:38","","","","" 黄燚麒(#huang)","2026-07-16 15:40:00","","","","黄燚麒","2026-07-17 15:52:54","","","",""
"96 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","员工作为发展人进行标识","新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。 "96 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","员工作为发展人进行标识","新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。
","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:22:00","黄燚麒","2026-07-15 11:23:15",""," ","","1 ","0 ","","2(#2)","2.50 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-15 11:22:00","黄燚麒","2026-07-15 11:23:15","","
黄燚麒(#huang)","2026-07-15 11:23:00","","","","李昕娉","2026-07-15 11:23:42","","","","" 黄燚麒(#huang)","2026-07-15 11:23:00","","","","黄燚麒","2026-07-17 15:57:47","","","",""
"94 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","系统状态同步优化以及回调处理","因资产迁移需要涉及回调和状态同步,故进行相关优化 "94 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","系统状态同步优化以及回调处理","因资产迁移需要涉及回调和状态同步,故进行相关优化
","","1 ","0 ","","1(#1)","40.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-14 10:53:29","黄燚麒","2026-07-14 11:00:14",""," ","","1 ","0 ","","1(#1)","12.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-14 10:53:29","黄燚麒","2026-07-14 11:00:14","","
黄燚麒(#huang)","2026-07-14 10:59:00","","","","李昕娉","2026-07-14 11:01:12","","","","" 黄燚麒(#huang)","2026-07-14 10:59:00","","","","黄燚麒","2026-07-17 15:59:07","","","",""
"90 ","物联网卡管系统(#2)","/(#0)","","","","资产详情列表的字段修改","因运营商账户字段不想直接展示给代理、企业客户等查看。现需要将运营商账户字段在代理、企业客户处直接显示为:中国移动、中国电信、中国联通、中国广电。
<img src=&quot;{134.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=134&quot; />
且设备的实名认证策略和制造商均不展示给代理显示。
<img src=&quot;{135.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=135&quot; />
","","1 ","0 ","","3(#3)","1.40 ","激活(#active)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-13 15:11:48","罗洋平","2026-07-13 15:11:48","","
黄燚麒(#huang)","2026-07-13 15:13:00","","","","罗洋平","2026-07-13 16:22:48","","","",""
"86 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情中换货标识","在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src=&quot;{128.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=128&quot; /><img src=&quot;{129.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=129&quot; /> "86 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情中换货标识","在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src=&quot;{128.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=128&quot; /><img src=&quot;{129.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=129&quot; />
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-13 11:50:17","黄燚麒","2026-07-13 11:54:36",""," ","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-13 11:50:17","黄燚麒","2026-07-13 11:54:36","","
黄燚麒(#huang)","2026-07-13 11:54:00","","","","黄燚麒","2026-07-13 11:54:36","","","","" 黄燚麒(#huang)","2026-07-13 11:54:00","","","","黄燚麒","2026-07-17 16:03:36","","","",""
"84 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5首页设备号下的ICCID去掉显示","新卡管H5首页设备号下的ICCID去掉显示
<img src=&quot;{133.jpg}&quot; alt=&quot;index.php?m=file&f=read&t=jpg&fileID=133&quot; />
","","1 ","0 ","","3(#3)","0.20 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-11 17:17:47","罗洋平","2026-07-13 15:12:41","","
黄燚麒(#huang)","2026-07-13 15:12:00","","","","邹进","2026-07-24 20:18:13","","","",""
"75 ","物联网卡管系统(#2)","/(#0)","","","","支付配置","将微信配置修改为支付配置,并修改权限编码以及路由和其他参数的变量名
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-11 14:21:59","罗洋平","2026-07-11 14:21:59","","
黄燚麒(#huang)","2026-07-11 14:25:00","","","","罗洋平","2026-07-11 14:44:28","","","",""
"73 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","行业卡操作停复机","行业卡允许未实名复机 "73 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","行业卡操作停复机","行业卡允许未实名复机
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-11 10:02:33","黄燚麒","2026-07-11 14:22:26",""," ","","1 ","0 ","","3(#3)","1.20 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-11 10:02:33","黄燚麒","2026-07-11 14:22:26","","
黄燚麒(#huang)","2026-07-11 14:22:00","","","","黄燚麒","2026-07-11 14:22:26","","","","" 黄燚麒(#huang)","2026-07-11 14:22:00","","","","黄燚麒","2026-07-17 16:05:54","","","",""
"64 ","物联网卡管系统(#2)","/(#0)","","","","新卡管支付方式变更","新卡管H5设备新增支付宝支付
","","1 ","0 ","","3(#3)","0.20 ","激活(#active)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-10 09:37:24","罗洋平","2026-07-10 09:37:24","","
黄燚麒(#huang)","2026-07-10 09:41:00","","","","罗洋平","2026-07-10 10:01:22","","","",""
"63 ","物联网卡管系统(#2)","/(#0)","","","","代理系列授权套餐列表增加滚动条","代理系列授权套餐列表添加滚动条和卡管后台资产信息字段显示顺序。
","","1 ","0 ","","3(#3)","0.20 ","激活(#active)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-10 09:36:52","罗洋平","2026-07-10 09:36:52","","
黄燚麒(#huang)","2026-07-10 09:41:00","","","","罗洋平","2026-07-10 09:58:35","","","",""
"62 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5需要设置先充值后实名","用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置例如这一批设备需要强制先充值后实名这一批资产可以先实名后充值 "62 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5需要设置先充值后实名","用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置例如这一批设备需要强制先充值后实名这一批资产可以先实名后充值
","","1 ","0 ","","3(#3)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 17:04:27","李昕娉","2026-07-09 18:13:07",""," ","","1 ","0 ","","3(#3)","3.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-09 17:04:27","李昕娉","2026-07-09 18:13:07","","
黄燚麒(#huang)","2026-07-11 14:23:00","","","","黄燚麒","2026-07-11 14:23:36","","","","" 黄燚麒(#huang)","2026-07-11 14:23:00","","","","黄燚麒","2026-07-17 16:07:55","","","",""
"60 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","店铺管理新增检索条件","店铺列表搜索栏新增一项:联系电话,便于搜索 "60 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","店铺管理新增检索条件","店铺列表搜索栏新增一项:联系电话,便于搜索
","","1 ","0 ","","3(#3)","0.10 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 16:00:56","黄燚麒","2026-07-09 17:37:57",""," ","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-09 16:00:56","黄燚麒","2026-07-09 17:37:57","","
黄燚麒(#huang)","2026-07-09 17:37:00","","","","黄燚麒","2026-07-09 17:37:57","","","","" 黄燚麒(#huang)","2026-07-09 17:37:00","","","","黄燚麒","2026-07-17 16:08:34","","","",""
"57 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","操作拦截","若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请 "57 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","操作拦截","若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 15:07:17","黄燚麒","2026-07-09 17:32:56",""," ","","1 ","0 ","","3(#3)","1.50 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-09 15:07:17","黄燚麒","2026-07-09 17:32:56","","
黄燚麒(#huang)","2026-07-09 17:32:00","","","","李昕娉","2026-07-09 17:33:21","","","","" 黄燚麒(#huang)","2026-07-09 17:32:00","","","","黄燚麒","2026-07-17 16:09:19","","","",""
"55 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐生效条件","套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本 "55 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐生效条件","套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本
","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 11:57:51","黄燚麒","2026-07-09 16:54:53",""," ","","1 ","0 ","","1(#1)","5.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-09 11:57:51","黄燚麒","2026-07-09 16:54:53","","
黄燚麒(#huang)","2026-07-09 16:54:00","","","","黄燚麒","2026-07-09 16:54:53","","","","" 黄燚麒(#huang)","2026-07-09 16:54:00","","","","黄燚麒","2026-07-17 16:11:34","","","",""
"54 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情页面新增套餐到期时间显示","增加所有待生效套餐加上生效套餐加起来的最后到期时间
","","1 ","0 ","","2(#2)","","已关闭(#closed)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-09 11:56:50","closed","2026-07-09 17:26:38","","
黄燚麒(#huang)","2026-07-09 17:26:00","黄燚麒","2026-07-09 17:26:38","重复(#duplicate)","黄燚麒","2026-07-09 17:26:38","","#46 资产信息详情字段新增","",""
"53 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产管理查询字段新增","lot卡管理和设备管理新增已实名/未实名的筛选查询条件 "53 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产管理查询字段新增","lot卡管理和设备管理新增已实名/未实名的筛选查询条件
","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 09:39:53","黄燚麒","2026-07-09 16:53:29",""," ","","1 ","0 ","","1(#1)","2.50 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-09 09:39:53","黄燚麒","2026-07-09 16:53:29","","
黄燚麒(#huang)","2026-07-09 16:53:00","","","","黄燚麒","2026-07-09 16:53:29","","","","" 黄燚麒(#huang)","2026-07-09 16:53:00","","","","黄燚麒","2026-07-17 16:12:35","","","",""
"52 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","对接聚水潭","因很多流程需要聚水潭的配合,故想将聚水潭打通。例如订单接口、发货数据等
","","1 ","0 ","","3(#3)","200.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 08:46:31","黄燚麒","2026-07-09 08:46:31","","
黄燚麒(#huang)","2026-07-09 17:38:00","","","","邹进","2026-07-24 20:18:13","","","",""
"51 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同品类资产的换货","因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。 "51 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同品类资产的换货","因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:54:32","李昕娉","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","黄燚麒","2026-07-09 16:53:05","","","","" ","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:54:32","李昕娉","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","黄燚麒","2026-07-09 16:53:05","","","",""
"49 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","设备批量分配代理和套餐系列","因设备号不是连号故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为设备号 "49 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","设备批量分配代理和套餐系列","因设备号不是连号故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为设备号
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:43:58","黄燚麒","2026-07-08 17:43:58",""," ","","1 ","0 ","","1(#1)","5.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:43:58","黄燚麒","2026-07-08 17:43:58","","
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:46:08","","","","" 黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-17 16:13:53","","","",""
"48 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","根据不同的资产使用不同的支付方式","C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:56:52","黄燚麒","2026-07-09 16:45:21",""," "48 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","根据不同的资产使用不同的支付方式","C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:56:52","黄燚麒","2026-07-09 16:45:21","","
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:45:21","","","","" 黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-17 16:15:40","","","",""
"47 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","限速规则","根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则","","1 ","0 ","","3(#3)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:54:39","黄燚麒","2026-07-09 17:30:42",""," "47 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","限速规则","根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则","","1 ","0 ","","3(#3)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:54:39","黄燚麒","2026-07-09 17:30:42","","
黄燚麒(#huang)","2026-07-09 17:27:00","","","","黄燚麒","2026-07-09 17:30:42","","","","" 黄燚麒(#huang)","2026-07-09 17:27:00","","","","黄燚麒","2026-07-17 16:16:36","","","",""
"46 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产信息详情字段新增","资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:52:36","黄燚麒","2026-07-09 17:25:53",""," "46 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产信息详情字段新增","资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。","","1 ","0 ","","2(#2)","4.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:52:36","黄燚麒","2026-07-09 17:25:53","","
黄燚麒(#huang)","2026-07-09 17:25:00","","","","黄燚麒","2026-07-09 17:25:53","","","","" 黄燚麒(#huang)","2026-07-09 17:25:00","","","","黄燚麒","2026-07-17 16:17:29","","","",""
"45 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换货管理","| 编号 | 需求 | "45 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换货管理","| 编号 | 需求 |
| ------- | ---------------------------------------------------- | | ------- | ---------------------------------------------------- |
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 | | EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 | | EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 | | EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |","","1 ","0 ","","1(#1)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:46",""," | EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |","","1 ","0 ","","1(#1)","2.50 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:46","","
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:46","","","","" 黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-17 16:18:30","","","",""
"44 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","列表字段新增","| 编号 | 模块 | 新增字段 | "44 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","列表字段新增","| 编号 | 模块 | 新增字段 |
| ------- | ------------ | -------------- | | ------- | ------------ | -------------- |
| COL-001 | 退款管理列表 | 提交人、审批人 | | COL-001 | 退款管理列表 | 提交人、审批人 |
| COL-002 | 代理充值列表 | 提交人、审批人 | | COL-002 | 代理充值列表 | 提交人、审批人 |
| COL-003 | 换号管理列表 | 提交人 |","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:20",""," | COL-003 | 换号管理列表 | 提交人 |","","1 ","0 ","","1(#1)","3.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:20","","
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:20","","","","" 黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-17 16:19:27","","","",""
"43 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理系列授权","| 编号 | 需求 | "43 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理系列授权","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ | | ------- | ------------------------------------------------------------ |
| AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 | | AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 |
| AUT-002 | 授权套餐时展示建议售价。 | | AUT-002 | 授权套餐时展示建议售价。 |
| AUT-003 | 授权套餐时展示公司成本价。 | | AUT-003 | 授权套餐时展示公司成本价。 |
| AUT-004 | 已分配套餐和未分配套餐需要明显区分。 | | AUT-004 | 已分配套餐和未分配套餐需要明显区分。 |
| AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 |","","1 ","0 ","","2(#2)","5.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 17:25:42",""," | AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 |","","1 ","0 ","","2(#2)","1.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 17:25:42","","
黄燚麒(#huang)","2026-07-09 17:12:00","","","","黄燚麒","2026-07-09 17:25:42","","","","" 黄燚麒(#huang)","2026-07-09 17:12:00","","","","黄燚麒","2026-07-17 16:20:11","","","",""
"42 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","导出功能","#### 6.8.1 lot 卡导出 "42 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","导出功能","#### 6.8.1 lot 卡导出
##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 ##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
@@ -208,8 +271,8 @@
| 部门领导审批人 | | 部门领导审批人 |
| 财务审批人 | | 财务审批人 |
| 支付凭证 | | 支付凭证 |
| 备注 |","","1 ","0 ","","1(#1)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:42:07",""," | 备注 |","","1 ","0 ","","1(#1)","8.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:42:07","","
黄燚麒(#huang)","2026-07-09 16:40:00","","","","黄燚麒","2026-07-09 16:42:07","","","","" 黄燚麒(#huang)","2026-07-09 16:40:00","","","","黄燚麒","2026-07-17 16:20:58","","","",""
"41 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理查询限制(类似酷蛙这种客户)","下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。 "41 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理查询限制(类似酷蛙这种客户)","下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。
| 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 | | 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 |
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","李昕娉","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","黄燚麒","2026-07-11 14:25:30","","","","" ","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","李昕娉","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","黄燚麒","2026-07-11 14:25:30","","","",""
@@ -218,15 +281,27 @@
| PKG-001 | 套餐需标准化管理。 | | PKG-001 | 套餐需标准化管理。 |
| PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 | | PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 |
| PKG-003 | 下架套餐续费仅支持客户自己购买。 | | PKG-003 | 下架套餐续费仅支持客户自己购买。 |
| PKG-004 | 下架套餐不可被新购买。 |","","1 ","0 ","","2(#2)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:11:34",""," | PKG-004 | 下架套餐不可被新购买。 |","","1 ","0 ","","2(#2)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:11:34","","
黄燚麒(#huang)","2026-07-09 17:10:00","","","","黄燚麒","2026-07-09 17:11:34","","","","" 黄燚麒(#huang)","2026-07-09 17:10:00","","","","黄燚麒","2026-07-17 16:21:43","","","",""
"39 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理分销码与佣金提现","##### 员工可作为代理发展人进行标识。(在系统中如何体现?)
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| DST-001 | 新建代理时自动建立分销归属关系。 |
| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) |
| DST-003 | 佣金提现前,代理必须签署合同。(必填) |
| DST-004 | 佣金提现需上传营业执照。(可选) |
| DST-005 | 佣金提现需上传法人身份证。(必填) |
| DST-006 | 佣金提现可选上传门头照。(可选) |
| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) |","","1 ","0 ","","3(#3)","40.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:43:17","","
黄燚麒(#huang)","2026-07-09 17:43:00","","","","邹进","2026-07-24 20:18:13","","","",""
"38 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同渠道额度处理","| 编号 | 需求 | "38 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同渠道额度处理","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ | | ------- | ------------------------------------------------------------ |
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 | | BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 | | BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | | BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:58:16",""," | BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |","","1 ","0 ","","2(#2)","7.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:58:16","","
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:58:16","","","","" 黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-17 16:22:35","","","",""
"37 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","审核流转","| 编号 | 需求 | "37 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","审核流转","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ | | ------- | ------------------------------------------------------------ |
| APR-001 | 代理可在系统提交充值申请。 | | APR-001 | 代理可在系统提交充值申请。 |
@@ -237,8 +312,8 @@
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 | | APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
| APR-007 | 审核通过后通知申请人。 | | APR-007 | 审核通过后通知申请人。 |
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 | | APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
| APR-009 | 审核流程需对接企业微信审批流程。 |","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:57:35",""," | APR-009 | 审核流程需对接企业微信审批流程。 |","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:57:35","","
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:57:35","","","","" 黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-17 16:23:22","","","",""
"36 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","批量订购套餐","1. 内部员工进入批量订购页面。 "36 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","批量订购套餐","1. 内部员工进入批量订购页面。
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号 2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
3. Excel表头跳转至6.3会显示。 3. Excel表头跳转至6.3会显示。
@@ -266,15 +341,15 @@ excel
| 套餐系列名称 | | 套餐系列名称 |
| 套餐名称 | | 套餐名称 |
| 代理名称 | | 代理名称 |
| 支付方式:代理商账户/员工账户 |","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:31:53",""," | 支付方式:代理商账户/员工账户 |","","1 ","0 ","","1(#1)","8.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:31:53","","
黄燚麒(#huang)","2026-07-09 16:31:00","","","","黄燚麒","2026-07-09 16:31:53","","","","" 黄燚麒(#huang)","2026-07-09 16:31:00","","","","黄燚麒","2026-07-17 16:24:11","","","",""
"35 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款审核","(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) "35 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款审核","(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 员工提交退款申请。 1. 员工提交退款申请。
2. 退款单进入多级审核流程。 2. 退款单进入多级审核流程。
3. 按部门领导、财务顺序审批。 3. 按部门领导、财务顺序审批。
4. 当前环节审批完成后,下一环节审批人收到消息提示。 4. 当前环节审批完成后,下一环节审批人收到消息提示。
5. 审批通过或驳回后通知申请人。","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:56:31",""," 5. 审批通过或驳回后通知申请人。","","1 ","0 ","","2(#2)","7.00 ","激活(#active)","研发中(#developing)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:56:31","","
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:56:31","","","","" 黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-17 16:25:07","","","",""
"34 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","充值审核流程","代理自己充值: "34 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","充值审核流程","代理自己充值:
1. 代理在系统提交充值申请。 1. 代理在系统提交充值申请。
2. 系统展示收款二维码。 2. 系统展示收款二维码。
@@ -285,8 +360,8 @@ excel
2. 提交人部门领导先审批。 2. 提交人部门领导先审批。
3. 财务在上一审批人通过后收到待办提醒并审批。 3. 财务在上一审批人通过后收到待办提醒并审批。
4. 审批通过后通知申请人。 4. 审批通过后通知申请人。
5. 审批驳回后通知申请人,并展示驳回原因。","","1 ","0 ","","2(#2)","32.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:55:42",""," 5. 审批驳回后通知申请人,并展示驳回原因。","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:55:42","","
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:55:42","","","","" 黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-17 16:26:05","","","",""
"33 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐临期提醒","1. 系统每日计算卡/设备套餐剩余有效期。 "33 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐临期提醒","1. 系统每日计算卡/设备套餐剩余有效期。
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。 2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。 3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
@@ -325,5 +400,5 @@ excel
卡号/设备号xxx 卡号/设备号xxx
剩余有效期xx 天 剩余有效期xx 天
为避免到期后影响正常使用,请您提前完成续费。","","1 ","0 ","","1(#1)","","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:30:06",""," 为避免到期后影响正常使用,请您提前完成续费。","","1 ","0 ","","1(#1)","8.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:30:06","","
黄燚麒(#huang)","2026-07-09 16:29:00","","","","黄燚麒","2026-07-09 16:30:06","","","","" 黄燚麒(#huang)","2026-07-09 16:29:00","","","","黄燚麒","2026-07-17 16:26:41","","","",""
1 编号 所属产品 所属模块 所属计划 来源 来源备注 用户需求名称 描述 验收标准 需求层级 父需求 关键词 优先级 预计工时 当前状态 所处阶段 类别 T B C 由谁创建 创建日期 指派给 指派日期 抄送给 已评审人 评审时间 由谁关闭 关闭日期 关闭原因 最后修改 最后修改日期 反馈者 重复需求 附件
2 189 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 换过货的卡迁移的旧套餐退款不会正常失效 目前换过货的新卡接受了旧卡的套餐,对这笔订单申请退款,退款通过后,这个套餐订单在卡上不会正常失效。 <img src=&quot;{157.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=157&quot; /> <img src=&quot;{158.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=158&quot; /> <img src=&quot;{159.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=159&quot; /> 1 0 3(#3) 评审中(#reviewing) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-24 11:05:51 邹进 2026-07-24 20:18:13
3 188 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 广电管控卡弹窗提醒 广电风险停机的卡可以在H5进行弹窗提醒。 方式一:系统自动弹窗,提示客户需要换卡,引导客户填写收货地址并同步至后台;(最好可以直接生成物流换货的换货单,同时对接ERP系统) 方式二:支持我们主动发出弹窗。提供发出弹窗功能,可以自定义弹窗内容,可以选择适用客户或适用设备类型或卡类型等。我们同时可以使用弹窗功能进行新的套餐政策推广,新品到货通知,从而引导客户消费等。(方式二是否需要新建一个需求,具体分析?) 1 0 3(#3) 评审中(#reviewing) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-23 17:18:34 邹进 2026-07-24 20:18:13
4 182 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 退款和订单充值提交人记录 退款管理和订单充值在后台的列表中都需要添加一个提交人字段 1 0 3(#3) 评审中(#reviewing) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-23 10:47:43 邹进 2026-07-24 20:18:13
5 181 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 订单列表和退款管理资产标识符、订单渠道缺失 目前订单列表和退款管理页面存在资产标识符、订单渠道缺失的情况,主要为设备: 退款单号:RF20260723085931918896 <img src=&quot;{155.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=155&quot; /> 订单号:ORD20260721121735544971 <img src=&quot;{156.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=156&quot; /> 1 0 3(#3) 评审中(#reviewing) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-23 09:50:32 邹进 2026-07-24 20:18:13
6 168 物联网卡管系统(#2) /(#0) 停机阈值显示 虚流量的停机阈值应为真流量的总流量 1 0 3(#3) 0.50 激活(#active) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-21 14:09:25 罗洋平 2026-07-21 14:09:25 黄燚麒(#huang) 2026-07-21 14:11:00 罗洋平 2026-07-22 14:19:48
7 99 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 退款管理支持原路退回 系统需支持两种退款方式: 1. 原路退款 2. 客户提供收款凭证退款 同时,若选择原路退款,系统需先校验该订单对应的原收款商户当前是否具备退款能力,避免进入不可执行的退款流程。 2. 需求目标 1. 退款申请时支持选择退款方式。 2. 原路退款前自动校验原收款商户当前是否支持退款。 3. 当原收款商户不支持退款时,改为“客户提供收款凭证退款”。 4. 保留退款方式、校验结果、退款凭证、处理记录,便于财务复核与追溯。 3. 流程说明 1. 员工发起退款申请。 2. 选择退款方式。 3. 若选择原路退款,系统先执行原收款商户退款能力校验。 4. 校验通过,进入退款审核及执行流程。 5. 校验失败,提示原因,并允许改为客户提供收款凭证退款。 6. 若选择客户提供收款凭证退款,上传收款凭证后进入审核及财务处理流程。 7. 财务完成退款后,系统记录退款结果并通知申请人。 1 0 2(#2) 6.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-16 11:50:41 黄燚麒 2026-07-16 12:06:36 黄燚麒(#huang) 2026-07-16 12:06:00 邹进 2026-07-24 20:18:13
8 98 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 换过管理新资产归属 默认换货把旧资产的所属权一起划过去 1 0 3(#3) 2.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-15 14:08:57 黄燚麒 2026-07-16 11:51:00 黄燚麒(#huang) 2026-07-16 11:50:00 黄燚麒 2026-07-17 15:42:34
9 97 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理钱包阈值提醒 代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。 1 0 3(#3) 2.10 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-15 11:23:35 黄燚麒 2026-07-16 15:40:38 黄燚麒(#huang) 2026-07-16 15:40:00 黄燚麒 2026-07-17 15:52:54
10 96 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 员工作为发展人进行标识 新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。 1 0 2(#2) 2.50 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-15 11:22:00 黄燚麒 2026-07-15 11:23:15 黄燚麒(#huang) 2026-07-15 11:23:00 黄燚麒 2026-07-17 15:57:47
11 94 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 系统状态同步优化以及回调处理 因资产迁移需要涉及回调和状态同步,故进行相关优化 1 0 1(#1) 12.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-14 10:53:29 黄燚麒 2026-07-14 11:00:14 黄燚麒(#huang) 2026-07-14 10:59:00 黄燚麒 2026-07-17 15:59:07
12 90 物联网卡管系统(#2) /(#0) 资产详情列表的字段修改 因运营商账户字段不想直接展示给代理、企业客户等查看。现需要将运营商账户字段在代理、企业客户处直接显示为:中国移动、中国电信、中国联通、中国广电。 <img src=&quot;{134.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=134&quot; /> 且设备的实名认证策略和制造商均不展示给代理显示。 <img src=&quot;{135.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=135&quot; /> 1 0 3(#3) 1.40 激活(#active) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-13 15:11:48 罗洋平 2026-07-13 15:11:48 黄燚麒(#huang) 2026-07-13 15:13:00 罗洋平 2026-07-13 16:22:48
13 86 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产详情中换货标识 在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src=&quot;{128.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=128&quot; /><img src=&quot;{129.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=129&quot; /> 1 0 1(#1) 1.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-13 11:50:17 黄燚麒 2026-07-13 11:54:36 黄燚麒(#huang) 2026-07-13 11:54:00 黄燚麒 2026-07-17 16:03:36
14 84 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 新卡管H5首页设备号下的ICCID去掉显示 新卡管H5首页设备号下的ICCID去掉显示 <img src=&quot;{133.jpg}&quot; alt=&quot;index.php?m=file&f=read&t=jpg&fileID=133&quot; /> 1 0 3(#3) 0.20 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-11 17:17:47 罗洋平 2026-07-13 15:12:41 黄燚麒(#huang) 2026-07-13 15:12:00 邹进 2026-07-24 20:18:13
15 75 物联网卡管系统(#2) /(#0) 支付配置 将微信配置修改为支付配置,并修改权限编码以及路由和其他参数的变量名 1 0 3(#3) 0.50 激活(#active) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-11 14:21:59 罗洋平 2026-07-11 14:21:59 黄燚麒(#huang) 2026-07-11 14:25:00 罗洋平 2026-07-11 14:44:28
16 73 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 行业卡操作停复机 行业卡允许未实名复机 1 0 3(#3) 1.20 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-11 10:02:33 黄燚麒 2026-07-11 14:22:26 黄燚麒(#huang) 2026-07-11 14:22:00 黄燚麒 2026-07-17 16:05:54
17 64 物联网卡管系统(#2) /(#0) 新卡管支付方式变更 新卡管H5设备新增支付宝支付 1 0 3(#3) 0.20 激活(#active) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-10 09:37:24 罗洋平 2026-07-10 09:37:24 黄燚麒(#huang) 2026-07-10 09:41:00 罗洋平 2026-07-10 10:01:22
18 63 物联网卡管系统(#2) /(#0) 代理系列授权套餐列表增加滚动条 代理系列授权套餐列表添加滚动条和卡管后台资产信息字段显示顺序。 1 0 3(#3) 0.20 激活(#active) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-10 09:36:52 罗洋平 2026-07-10 09:36:52 黄燚麒(#huang) 2026-07-10 09:41:00 罗洋平 2026-07-10 09:58:35
19 62 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 新卡管H5需要设置先充值后实名 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值? 1 0 3(#3) 3.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-09 17:04:27 李昕娉 2026-07-09 18:13:07 黄燚麒(#huang) 2026-07-11 14:23:00 黄燚麒 2026-07-17 16:07:55
20 60 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 店铺管理新增检索条件 店铺列表搜索栏新增一项:联系电话,便于搜索 1 0 3(#3) 1.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-09 16:00:56 黄燚麒 2026-07-09 17:37:57 黄燚麒(#huang) 2026-07-09 17:37:00 黄燚麒 2026-07-17 16:08:34
21 57 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 操作拦截 若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请 1 0 3(#3) 1.50 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-09 15:07:17 黄燚麒 2026-07-09 17:32:56 黄燚麒(#huang) 2026-07-09 17:32:00 黄燚麒 2026-07-17 16:09:19
22 55 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐生效条件 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本 1 0 1(#1) 5.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-09 11:57:51 黄燚麒 2026-07-09 16:54:53 黄燚麒(#huang) 2026-07-09 16:54:00 黄燚麒 2026-07-17 16:11:34
23 53 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产管理查询字段新增 lot卡管理和设备管理新增已实名/未实名的筛选查询条件 1 0 1(#1) 2.50 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-09 09:39:53 黄燚麒 2026-07-09 16:53:29 黄燚麒(#huang) 2026-07-09 16:53:00 黄燚麒 2026-07-17 16:12:35
24 52 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 对接聚水潭 因很多流程需要聚水潭的配合,故想将聚水潭打通。例如订单接口、发货数据等 1 0 3(#3) 200.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 08:46:31 黄燚麒 2026-07-09 08:46:31 黄燚麒(#huang) 2026-07-09 17:38:00 邹进 2026-07-24 20:18:13
25 51 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 不同品类资产的换货 因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。 1 0 1(#1) 草稿(#draft) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 17:54:32 李昕娉 2026-07-09 16:53:05 2026-07-09 16:52:00 黄燚麒 2026-07-09 16:53:05
26 49 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 设备批量分配代理和套餐系列 因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号 1 0 1(#1) 5.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 17:43:58 黄燚麒 2026-07-08 17:43:58 黄燚麒(#huang) 2026-07-09 16:45:00 黄燚麒 2026-07-17 16:13:53
27 48 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 根据不同的资产使用不同的支付方式 C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝 1 0 1(#1) 4.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:56:52 黄燚麒 2026-07-09 16:45:21 黄燚麒(#huang) 2026-07-09 16:45:00 黄燚麒 2026-07-17 16:15:40
28 47 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 限速规则 根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则 1 0 3(#3) 3.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:54:39 黄燚麒 2026-07-09 17:30:42 黄燚麒(#huang) 2026-07-09 17:27:00 黄燚麒 2026-07-17 16:16:36
29 46 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产信息详情字段新增 资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。 1 0 2(#2) 4.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:52:36 黄燚麒 2026-07-09 17:25:53 黄燚麒(#huang) 2026-07-09 17:25:00 黄燚麒 2026-07-17 16:17:29
30 45 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 换货管理 | 编号 | 需求 | | ------- | ---------------------------------------------------- | | EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 | | EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 | | EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 | | EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 | 1 0 1(#1) 2.50 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 16:44:46 黄燚麒(#huang) 2026-07-09 16:44:00 黄燚麒 2026-07-17 16:18:30
31 44 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 列表字段新增 | 编号 | 模块 | 新增字段 | | ------- | ------------ | -------------- | | COL-001 | 退款管理列表 | 提交人、审批人 | | COL-002 | 代理充值列表 | 提交人、审批人 | | COL-003 | 换号管理列表 | 提交人 | 1 0 1(#1) 3.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 16:44:20 黄燚麒(#huang) 2026-07-09 16:44:00 黄燚麒 2026-07-17 16:19:27
32 43 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理系列授权 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 | | AUT-002 | 授权套餐时展示建议售价。 | | AUT-003 | 授权套餐时展示公司成本价。 | | AUT-004 | 已分配套餐和未分配套餐需要明显区分。 | | AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 | 1 0 2(#2) 1.50 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 17:25:42 黄燚麒(#huang) 2026-07-09 17:12:00 黄燚麒 2026-07-17 16:20:11
33 42 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 导出功能 #### 6.8.1 lot 卡导出 ##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 | 编号 | 需求 | | -------- | ---------------------------- | | EXPD-001 | lot 卡导出字段新增套餐名称。 | | EXPD-002 | lot 卡导出字段新增使用流量。 | | EXPD-003 | lot 卡导出字段新增剩余流量。 | #### 6.8.2 代理资金概况-预充值钱包流水导出 导出字段: | 字段 | 说明 | | ------------------- | ------------------------------------------------------------ | | 店铺名称 | 代理店铺名称 | | 交易类型 | 充值、扣款、退款等 | | 交易金额 | 以元为单位 | | 状态 | 交易状态 | | 资产类型 | 卡/设备等 | | 资产标识 | ICCID/设备号等 | | 交易时间 | 流水生成时间 | | 交易前金额 | 交易前余额 | | 交易后金额 | 交易后余额 | | 购买套餐名称 | 资产此条扣款记录对应的套餐名称 | | 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) | | 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 | | 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 | | 交易渠道 / 支付方式 | 明确交易来源:余额支付等 | #### 6.8.3 套餐列表导出 导出字段: | 字段 | | -------------- | | 套餐编码 | | 套餐名称 | | 套餐系列名称 | | 套餐类型 | | 套餐时长(月) | | 套餐时长说明 | | 套餐周期类型 | | 套餐天数 | | 真流量额度(MB) | | 虚流量额度(MB) | | 是否启用虚流量 | | 虚流量比例 | | 流量重置周期 | | 到期时间基准 | | 成本价(元) | | 建议售价(元) | | 价格配置状态 | | 状态 | | 上架状态 | | 是否赠送套餐 | | 创建人ID | | 更新人ID | | 创建时间 | | 更新时间 | | 删除时间 | #### 6.8.4 退款管理退款列表导出 导出字段: | 字段 | | ---------------- | | 退款单号 | | 代理店铺名称 | | 关联的支付订单号 | | 资产类型 | | 资产标识 | | 套餐名称 | | 原订单金额 | | 实收金额 | | 可退金额 | | 申请退款金额 | | 实际退款金额 | | 退款到账方式 | | 状态 | | 退款原因 | | 备注 | | 审批备注 | | 退款申请时间 | | 退款完成时间 | | 提交人 | | 部门领导审批人 | | 财务审批人 | | 退款凭证 | #### 6.8.5 换货管理导出 ##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 导出字段: | 字段 | | ------------ | | 换货单号 | | 换货类型 | | 换货原因 | | 问题描述 | | 旧资产类型 | | 旧资产标识符 | | 新资产标识符 | | 收货人姓名 | | 收货人电话 | | 收货地址 | | 快递公司 | | 快递单号 | | 状态 | | 创建人 | | 创建时间 | #### 6.8.6 代理充值导出 导出字段: | 字段 | | -------------- | | 充值单号 | | 店铺名称 | | 充值类型 | | 充值金额 | | 实付金额 | | 充值前余额 | | 充值后余额 | | 状态 | | 支付方式 | | 支付通道 | | 运营备注 | | 驳回原因 | | 创建时间 | | 支付时间 | | 完成时间 | | 提交人 | | 部门领导审批人 | | 财务审批人 | | 支付凭证 | | 备注 | 1 0 1(#1) 8.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:42:07 黄燚麒(#huang) 2026-07-09 16:40:00 黄燚麒 2026-07-17 16:20:58
34 41 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理查询限制(类似酷蛙这种客户) 下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。 | 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 | 1 0 1(#1) 草稿(#draft) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 李昕娉 2026-07-09 16:42:55 2026-07-11 14:24:00 黄燚麒 2026-07-11 14:25:30
35 40 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐设计 | 编号 | 需求 | | ------- | ------------------------------------------ | | PKG-001 | 套餐需标准化管理。 | | PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 | | PKG-003 | 下架套餐续费仅支持客户自己购买。 | | PKG-004 | 下架套餐不可被新购买。 | 1 0 2(#2) 4.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 17:11:34 黄燚麒(#huang) 2026-07-09 17:10:00 黄燚麒 2026-07-17 16:21:43
36 39 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理分销码与佣金提现 ##### 员工可作为代理发展人进行标识。(在系统中如何体现?) | 编号 | 需求 | | ------- | ---------------------------------------------------- | | DST-001 | 新建代理时自动建立分销归属关系。 | | DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) | | DST-003 | 佣金提现前,代理必须签署合同。(必填) | | DST-004 | 佣金提现需上传营业执照。(可选) | | DST-005 | 佣金提现需上传法人身份证。(必填) | | DST-006 | 佣金提现可选上传门头照。(可选) | | DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) | 1 0 3(#3) 40.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 17:43:17 黄燚麒(#huang) 2026-07-09 17:43:00 邹进 2026-07-24 20:18:13
37 38 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 不同渠道额度处理 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 | | BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 | | BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | | BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 | 1 0 2(#2) 7.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:58:16 黄燚麒(#huang) 2026-07-09 16:57:00 黄燚麒 2026-07-17 16:22:35
38 37 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 审核流转 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | APR-001 | 代理可在系统提交充值申请。 | | APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 | | APR-003 | 充值和退款均支持多级审核。 | | APR-004 | 审核环节包括提交人部门领导审核和财务审核。 | | APR-005 | 待审核订单需有消息提示。 | | APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 | | APR-007 | 审核通过后通知申请人。 | | APR-008 | 审核驳回后通知申请人,并附带驳回原因。 | | APR-009 | 审核流程需对接企业微信审批流程。 | 1 0 2(#2) 16.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:57:35 黄燚麒(#huang) 2026-07-09 16:57:00 黄燚麒 2026-07-17 16:23:22
39 36 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 批量订购套餐 1. 内部员工进入批量订购页面。 2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号 3. Excel表头:跳转至6.3会显示。 4. 系统校验导入文件和资产状态。 5. 校验通过的资产从代理余额扣款并完成订购。 6. 校验失败的明细在页面展示失败原因。 7. 系统记录操作员、导入明细、导入数量和导入时间。 | 编号 | 需求 | | ------- | ---------------------------------------------------- | | BPO-001 | 批量订购由内部员工操作。 | | BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 | | BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 | | BPO-004 | 批量订购无需审核。 | | BPO-005 | 资产标识支持 ICCID/设备号。 | | BPO-006 | 支持 Excel 模板导入。 | | BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 | | BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 | excel表导入字段: | 字段 | | ----------------------------- | | 资产类型 | | 资产标识 | | 套餐系列名称 | | 套餐名称 | | 代理名称 | | 支付方式:代理商账户/员工账户 | 1 0 1(#1) 8.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:31:53 黄燚麒(#huang) 2026-07-09 16:31:00 黄燚麒 2026-07-17 16:24:11
40 35 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 退款审核 (审批均可通过企微进行提醒和显示并将最新状态同步至卡管) 1. 员工提交退款申请。 2. 退款单进入多级审核流程。 3. 按部门领导、财务顺序审批。 4. 当前环节审批完成后,下一环节审批人收到消息提示。 5. 审批通过或驳回后通知申请人。 1 0 2(#2) 7.00 激活(#active) 研发中(#developing) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:56:31 黄燚麒(#huang) 2026-07-09 16:55:00 黄燚麒 2026-07-17 16:25:07
41 34 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 充值审核流程 代理自己充值: 1. 代理在系统提交充值申请。 2. 系统展示收款二维码。 3. 代理扫码支付。 员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) 1. 充值单进入多级审核流程。 2. 提交人部门领导先审批。 3. 财务在上一审批人通过后收到待办提醒并审批。 4. 审批通过后通知申请人。 5. 审批驳回后通知申请人,并展示驳回原因。 1 0 2(#2) 16.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:55:42 黄燚麒(#huang) 2026-07-09 16:55:00 黄燚麒 2026-07-17 16:26:05
42 33 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐临期提醒 1. 系统每日计算卡/设备套餐剩余有效期。 2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。 3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。 4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。 5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。 6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。 #### 6.1.1 企业客户临期提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-001 | 后台每日生成企业客户临期列表。 | | EXP-002 | 临期节点包括 15 天、7 天、3 天。 | | EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 | | EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 | #### 6.1.2 代理端临期提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 | | EXP-006 | 代理端资产列表对临期资产进行颜色标记。 | | EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 | | EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 | #### 6.1.3 C 端公众号首页提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 | | EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 | | EXP-011 | 按钮文案为“立即续费”。 | | EXP-012 | 剩余天数需要按日期每天自动更新。 | | EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 | 推荐展示文案: 您的套餐即将到期 卡号/设备号:xxx 剩余有效期:xx 天 为避免到期后影响正常使用,请您提前完成续费。 1 0 1(#1) 8.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:30:06 黄燚麒(#huang) 2026-07-09 16:29:00 黄燚麒 2026-07-17 16:26:41
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
271
272
273
274
275
276
277
278
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
312
313
314
315
316
317
318
319
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
360
361
362
363
364
365
366
367
400
401
402
403
404

View File

@@ -4,15 +4,17 @@
## 1. success 示例 ## 1. success 示例
### 1.1 设备限速(`device_speed_limit` ### 1.1 IoT 卡固定档位限速(`card_speed_tier`
```bash ```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<identifier>/speed-limit" \ curl -X PUT "http://127.0.0.1:8080/api/admin/iot-cards/<iccid>/speed-tier" \
-H "Authorization: Bearer <token>" \ -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"speed_limit":1024}' -d '{"code":3}'
``` ```
该入口只接受 IoT 卡 ICCID设备没有限速接口也不通过设备当前绑定卡间接限速。
### 1.2 资产轮询开关(`asset_polling_status` ### 1.2 资产轮询开关(`asset_polling_status`
```bash ```bash

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# 资产套餐批量订购功能总结
## 交付范围
本功能复用现有对象存储、Asynq、统一异步任务五态和后台单笔订单 Service为一批卡或设备统一购买一个套餐
- `POST /api/admin/storage/upload-url` 使用 `purpose=batch_purchase` 获取只允许 `.csv` 的上传地址和稳定 `file_key`
- `POST /api/admin/asset-package-batch-orders` 接收 CSV `file_key`、一个 `package_id`、整批统一的 `wallet|offline` 支付方式及可选线下凭证;请求不选择代理。
- `GET /api/admin/asset-package-batch-orders` 分页查询任务,默认 20、最大 100可按统一五态筛选。
- `GET /api/admin/asset-package-batch-orders/:id` 返回任务汇总和逐行成功/失败明细。
- 平台可查看全部任务;代理只能查看自己及下级店铺创建的任务,权限上下文缺失时失败关闭。
## CSV 契约
- 文件必须为 UTF-8 编码的 `.csv`,允许 UTF-8 BOM最大 10MB、最多 1000 个业务行。
- 文件只能有一列,每行填写一个资产标识。首行可使用 `资产标识``identifier``iccid``iccid/虚拟号` 表头,也可直接从第一行填写数据。
- 空资产标识作为行级失败;多列、非法 CSV、非 UTF-8、空文件和超限作为任务级失败在任何订单写入前终止。
- 同一输入标识重复时仅第一行进入订单规则;不同标识解析为同一资产时复用订单幂等结果,后续行记录重复原因,不重复下单或扣款。
## 支付和订单规则
- 整批只选择一个套餐和一种支付方式,不选择代理。每行由现有订单 Service 根据资产当前归属解析结算关系、套餐授权、价格和钱包。
- `wallet` 不允许凭证,支持超级管理员、平台和代理账号;逐行复用现有代理主钱包扣款、信用边界、成功流水、套餐激活、佣金任务和订单幂等规则。
- `offline` 仅允许超级管理员或平台账号,必须提交 15 个凭证 Key每个成功订单保存同一批凭证快照并按现有线下订单规则立即激活不扣钱包。
- 每行独立处理,业务失败不回滚其他成功行。全部行业务失败仍表示任务完成,通过 `success_count=0``fail_count=total_count` 表达。
## 任务与幂等
- 任务状态固定为 `1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`;当前版本没有取消入口,仅保留统一状态契约。
- Asynq 载荷只包含结构化 `task_id`,不传 CSV 字节、临时路径或预序列化 `[]byte`
- Worker 通过任务状态和超时时间条件领取任务;重复消息发现任务正在有效执行或已终结时直接跳过。
- 超过任务最长执行时间仍停留在“处理中”的记录可被重新领取,用于 Worker 崩溃后恢复;未超时的处理中任务不会并发重入。
- Worker 使用任务快照恢复创建人权限;代理创建的任务仅能处理自己及下级店铺资产,权限范围查询失败时收紧为仅自己店铺,不会按平台无限制权限执行。
- 单行继续复用订单 Service 的资产级锁、Redis 业务键和钱包订单数据库幂等指纹,重复消费不得重复创建订单或扣款。
## 数据与发布
- 迁移 `000192_create_asset_package_batch_order_task` 新增 `tb_asset_package_batch_order_task`,保存套餐/操作者/支付/文件快照、五态、汇总及最多 1000 行 JSONB 结果;不建立外键。
- Worker 必须配置 Redis/Asynq 和对象存储,并监听 `asset:package:batch_order` 独立队列。
- 回滚时先隐藏创建入口并停止新任务,等待已领取任务完成。已经创建的订单、钱包流水、套餐使用和任务结果均为业务事实,不做反向删除。
## 当前验证边界
本次未运行测试、完整构建、LSP 或迁移;按用户后续明确要求已运行 OpenAPI 生成器,并执行 `gofmt`、只读一致性检查和 `git diff --check`。真实对象存储上传、Worker 消费、钱包扣款和线下订单需在后续联调流程验证。

View File

@@ -0,0 +1,59 @@
# 业务数据导出功能总结
## 公共交付方式
业务导出复用现有 `POST /api/admin/export-tasks` 异步任务入口、DataSource 注册、Asynq 分片、对象存储和 CSV/XLSX 汇总流程。前端只提交 `scene``format` 和当前列表的 `query.filters`,不另外选择导出列。
Worker 使用任务创建时固化的用户类型、店铺和下级店铺范围,不从运行时上下文猜测权限。`Count``Fetch` 使用同一套筛选和权限条件,分片按稳定 ID 顺序读取。
## IoT 卡导出
- 场景编码:`iot_card`
- 继续沿用现有卡列表筛选和店铺层级权限。
- 在原有 ICCID、MSISDN、设备、运营商、店铺、实名和网络状态列后追加“套餐名称”、“使用流量(MB)”和“剩余流量(MB)”。
- 套餐字段取当前生效主套餐,名称优先使用购买时快照;剩余流量由套餐真流量总量减已用量得出,超额使用时收敛为 `0`
## 套餐列表导出
- 场景编码:`package`
- 支持现有套餐列表的 `package_name``series_id``status``shelf_status``package_type` 筛选。
- 平台和超级管理员导出套餐本体数据;代理只导出已分配给自己店铺且分配状态启用的非赠送套餐,成本价和上下架状态取自当前店铺分配记录。
- 固定输出已确认的 25 列:编码、名称、系列、类型、时长、周期、真/虚流量、虚流量配置、重置周期、到期基准、价格、状态、赠送标识、创建/更新人及时间。当前列表不返回软删除套餐,因此“删除时间”按契约保留为空列。
- 金额从数据库的“分”转为两位小数的“元”;未配置建议售价时输出空值,不用 `0.00` 冒充已配置价格。
## 代理主钱包流水导出
- 场景编码:`agent_wallet_transaction`
- 仅导出主钱包流水,代理按任务创建时固化的自己及下级店铺范围读取;平台和超级管理员可读取全部有权数据。
- 支持现有流水列表的 `shop_id``transaction_type``start_date``end_date``asset_identifier` 筛选,纯日期截止时间包含当天。
- 固定输出 14 列:店铺、交易类型、交易金额、状态、资产类型与标识、交易时间、交易前后金额、购买套餐名称、操作人、交易 ID、关联业务订单号、支付方式。
- 金额沿用钱包流水中的分值事实并格式化为两位小数的元;套餐名称优先读取订单项快照,历史缺失时回退流水元数据。历史操作账号即使已软删除仍保留名称展示,但不参与权限判断。
## 代理充值导出
- 场景编码:`agent_recharge`
- 支持现有充值列表的 `shop_id``status``start_date``end_date` 筛选,代理权限使用任务创建时固化的店铺范围。
- 固定输出 18 列:充值单号、店铺、充值类型、充值/实付金额、充值前后余额、状态、支付方式、运营备注、驳回原因、三个业务时间、提交人、审批来源、审批状态和支付凭证。
- 充值金额取充值单事实;成功入账后的实付金额和余额前后值取首条匹配的成功主钱包充值流水,缺失时留空,不用零值冒充。
- 审批字段只读取本地通用审批实例,不逐行实时调用企业微信。支付凭证按业务需要输出明文对象存储 Key并按原顺序以逗号连接Integration Log 仍不得记录 Secret、access_token、media_id 或附件正文。
## 退款导出
- 场景编码:`refund`
- 支持退款列表的 `status``order_id``shop_id``asset_identifier` 筛选,代理按任务创建时固化的自己及下级店铺范围导出。
- 固定输出 21 列:退款单号、店铺、支付订单号、资产、套餐、五项金额、退款状态与原因、审批备注、审批来源与状态、退款处理状态、申请/审批时间、提交人和退款凭证。
- 订单号和资产标识使用退款申请快照;套餐名称优先使用指定套餐使用记录快照,否则聚合同一订单的订单项快照。原订单金额、可退金额、申请金额和实际退款金额均读取既有订单或退款事实,来源缺失时留空,不用零值冒充。
- 退款处理状态仅从退款状态、佣金回扣和资产处理完成标记稳定推导。当前模型没有可靠的当前审批节点和逐节点审批记录结构,因此不输出无意义空列;审批来源与状态只读取本地通用审批实例,不实时调用企业微信。
- 退款凭证按业务需要输出明文对象存储 Key。历史软删除账号仍可用于提交人名称展示但不参与权限判断。
## 换货导出
- 场景编码:`exchange`
- 支持换货列表的 `status``flow_type``old_asset_keyword``new_asset_keyword``created_at_start``created_at_end` 筛选;新旧资产关键词继续按 AND 组合,并按卡/设备当前权威标识命中资产 ID。
- 固定输出 15 列:换货单号、流程类型、原因、问题描述/备注、新旧资产标识、收货信息、物流信息、状态、创建人和创建时间。
- 新旧资产列直接使用换货单已固化的权威快照,卡为完整 ICCID设备按虚拟号、IMEI、SN 的既定优先级保存;不会在导出时重新拼接或覆盖历史快照。
- 收货人电话、地址等业务字段按用户确认保持明文输出。代理权限使用任务创建时固化的店铺层级范围,历史软删除创建人只用于名称展示。
## 当前验证边界
本次不运行测试、完整构建、LSP 或迁移;按用户后续明确要求已运行 OpenAPI 生成器。静态收口使用 `gofmt`、只读一致性检查和 `git diff --check`真实数据行数、筛选、分片、CSV/XLSX 文件和下载链接由后续联调验证。

View File

@@ -0,0 +1,51 @@
# 设备 CSV 批量分配功能总结
## 功能边界
本功能复用现有设备导入任务的对象存储、任务状态、Asynq 队列、进度统计和失败明细能力。复用的是异步任务外壳,原多列 Excel 创建设备与绑卡逻辑保持不变。
新增任务创建接口:
```text
POST /api/admin/devices/import/allocations
```
请求参数:
```json
{
"file_key": "device-batch-allocations/2026/07/25/example.csv",
"operation_type": "assign_shop",
"target_id": 1001
}
```
- `assign_shop``target_id` 为目标代理店铺 ID。
- `assign_series``target_id` 为目标套餐系列 ID。
- 每个任务只能选择一种操作和一个目标,不能逐行指定不同目标。
## CSV 格式
CSV 只能有一列,每行填写一个设备标识,支持 VirtualNo、IMEI 或 SN。首行可选使用 `device_identifier``设备标识` 表头。文件必须使用上传用途 `device_batch_allocation` 获取对象存储 Key最多 1000 个设备且不超过 10MB。
## 规则复用与幂等
Worker 批量解析设备后调用现有业务方法:
- 分配代理调用 `DeviceService.AllocateDevices`,沿用直属下级、平台库存、绑定卡归属同步、分配记录和资产审计规则。
- 设置套餐系列调用 `DeviceService.BatchSetSeriesBinding`,沿用套餐系列有效性、代理授权、设备归属和资产审计规则。
任务创建时冻结操作者 ID、类型和店铺 IDWorker 重建权限上下文不能因异步执行丢失数据范围。重复消费时已经属于目标店铺或已经绑定目标系列的设备直接按成功处理任务完成或失败后不重复执行。CSV 内重复设备记录为跳过,不产生重复副作用。
## 任务查询与回滚
继续使用现有接口查询任务:
```text
GET /api/admin/devices/import/tasks
GET /api/admin/devices/import/tasks/:id
```
响应新增 `operation_type``operation_name``target_id`,逐行失败原因继续通过原详情结构返回。
回滚应用版本前应先停止新任务入口并等待处理中的任务结束。迁移新增列保持兼容,不删除既有任务;已经完成的设备归属、套餐系列关系、分配记录和资产审计不得通过清表回滚,应使用现有回收或系列绑定接口前向修正。

View File

@@ -165,6 +165,36 @@ JUNHONG_WORKER_ROLE=consumer
JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1 JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1
``` ```
### Gateway 服务
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_GATEWAY_BASE_URL` | 默认配置文件值 | Gateway API 基础地址,配置后必须为 HTTP/HTTPS URL |
| `JUNHONG_GATEWAY_APP_ID` | 默认配置文件值 | Gateway 应用 ID |
| `JUNHONG_GATEWAY_APP_SECRET` | 默认配置文件值 | Gateway 应用密钥 |
| `JUNHONG_GATEWAY_TIMEOUT` | `60` | Gateway 请求超时秒数,允许 5300 |
生产环境必须通过环境变量覆盖应用身份和密钥。IoT 卡固定档位限速只向 Gateway 发送卡 ICCID 和档位编码,不支持设备限速。
### 企业微信 Adapter
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_WECOM_BASE_URL` | `https://qyapi.weixin.qq.com` | 企业微信 API 基础地址 |
| `JUNHONG_WECOM_TIMEOUT` | `10s` | 企业微信外部请求超时时间 |
| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | `""` | 启用企业微信功能前必填32 字节随机密钥的 Base64 文本 |
可使用 `openssl rand -base64 32` 生成加密密钥。密钥缺失或格式错误不会阻止不使用企微的环境启动,但企业微信配置保存、查询和连接测试会按失败关闭处理。更换密钥前必须先制定已有凭据的重新加密方案,否则历史密文将无法读取。
### 审批旧入口切换
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` | `true` | 是否保留退款人工通过/拒绝入口;仅在企微真实闭环和存量清单完成后改为 `false` |
| `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` | `true` | 是否保留线下充值人工确认入口;仅在企微真实闭环和存量清单完成后改为 `false` |
两个开关默认开启,避免部署新版本时中断存量旧 provider。关闭后接口仍保留路由但返回“入口已停用”前端只展示本地批量投影的审批渠道和状态。配置变更需要重启 API 实例Worker 不依赖这两个旧入口开关。
### 限流中间件 ### 限流中间件
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |

View File

@@ -5,8 +5,8 @@
本文档提供 Gateway 客户端所有 API 接口的完整参考,包括请求参数、响应格式和使用示例。 本文档提供 Gateway 客户端所有 API 接口的完整参考,包括请求参数、响应格式和使用示例。
**API 分类** **API 分类**
- 流量卡管理(7 个接口) - 流量卡管理(8 个接口)
- 设备管理(7 个接口) - 设备管理(6 个接口)
**基础信息** **基础信息**
- 协议HTTPS - 协议HTTPS
@@ -270,7 +270,43 @@ fmt.Printf("实名链接: %s\n", resp.Link)
--- ---
### 7. 批量查询(预留) ### 7. 设置流量卡固定限速档位
按 ICCID 设置或恢复流量卡固定限速档位。Gateway 不提供设备限速能力,禁止传入设备 ID、IMEI 或通过设备绑定关系间接调用。
**方法**: `SetCardSpeedTier`
**请求参数**:
```go
type CardSpeedTierReq struct {
CardNo string `json:"cardNo" validate:"required"`
Code string `json:"code" validate:"required"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| CardNo | string | ✅ | 流量卡 ICCID |
| Code | string | ✅ | 固定档位编码:`-1` 恢复不限速,`0` 0kbps`1` 128Kbps`2` 512Kbps`3` 1Mbps`4` 2Mbps`5` 10Mbps`6` 20Mbps`7` 50Mbps`8` 100Mbps |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{
CardNo: "898608070422D0010269",
Code: "3",
})
if err != nil {
return err
}
fmt.Println("流量卡固定限速档位设置成功")
```
---
### 8. 批量查询(预留)
批量查询流量卡信息(暂未实现)。 批量查询流量卡信息(暂未实现)。
@@ -427,48 +463,7 @@ for _, slot := range resp.Slots {
--- ---
### 3. 设置设备限速 ### 3. 设置设备 WiFi
设置设备的上行和下行速率限制。
**方法**: `SetSpeedLimit`
**请求参数**:
```go
type SpeedLimitReq struct {
DeviceID string `json:"deviceId" validate:"required"`
UploadSpeed int `json:"uploadSpeed" validate:"required,min=1"`
DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1"`
Extend string `json:"extend,omitempty"`
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| DeviceID | string | ✅ | 设备 ID/IMEI |
| UploadSpeed | int | ✅ | 上行速率KB/s最小1 |
| DownloadSpeed | int | ✅ | 下行速率KB/s最小1 |
| Extend | string | ❌ | 扩展字段 |
**响应参数**: 无(成功返回 nil失败返回 error
**使用示例**:
```go
err := client.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
DeviceID: "123456789012345",
UploadSpeed: 100,
DownloadSpeed: 500,
})
if err != nil {
return err
}
fmt.Println("限速设置成功")
```
---
### 4. 设置设备 WiFi
设置设备的 WiFi 信息。 设置设备的 WiFi 信息。
@@ -516,7 +511,7 @@ fmt.Println("WiFi设置成功")
--- ---
### 5. 设备切换卡 ### 4. 设备切换卡
切换设备当前使用的卡到指定的目标卡。 切换设备当前使用的卡到指定的目标卡。
@@ -554,7 +549,7 @@ fmt.Println("切换卡成功")
--- ---
### 6. 设备恢复出厂设置 ### 5. 设备恢复出厂设置
将设备恢复到出厂设置状态。 将设备恢复到出厂设置状态。
@@ -589,7 +584,7 @@ fmt.Println("恢复出厂设置成功")
--- ---
### 7. 设备重启 ### 6. 设备重启
远程重启设备。 远程重启设备。

View File

@@ -212,28 +212,28 @@ func (s *Service) GetDeviceSlots(ctx context.Context, imei string) ([]gateway.Sl
} }
``` ```
#### 设置设备限速 #### 设置流量卡固定限速档位
```go ```go
func (s *Service) SetDeviceSpeed(ctx context.Context, imei string, uploadKBps, downloadKBps int) error { func (s *Service) SetCardSpeedTier(ctx context.Context, iccid string, code int) error {
err := s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ err := s.gatewayClient.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{
DeviceID: imei, CardNo: iccid,
UploadSpeed: uploadKBps, Code: strconv.Itoa(code),
DownloadSpeed: downloadKBps,
}) })
if err != nil { if err != nil {
return errors.Wrap(errors.CodeGatewayError, err, "设置限速失败") return errors.Wrap(errors.CodeGatewayError, err, "设置流量卡固定限速档位失败")
} }
s.logger.Info("设置限速成功", s.logger.Info("设置流量卡固定限速档位成功",
zap.String("imei", imei), zap.String("iccid", iccid),
zap.Int("upload", uploadKBps), zap.Int("code", code),
zap.Int("download", downloadKBps),
) )
return nil return nil
} }
``` ```
Gateway 限速对象只有流量卡 ICCID。设备没有限速接口业务代码不得传入设备 ID、IMEI也不得先解析设备当前绑定卡再间接限速。
#### 设置设备 WiFi #### 设置设备 WiFi
```go ```go

View File

@@ -0,0 +1,55 @@
# IoT 卡固定档位限速功能总结
## 功能边界
本功能只允许后台账号为有权限的 IoT 卡设置 Gateway 固定限速档位。限速对象始终是请求路径中的卡 ICCID设备没有限速接口系统也不会查询设备当前绑定卡后间接限速。
接口:
```text
PUT /api/admin/iot-cards/:iccid/speed-tier
```
请求体:
```json
{
"code": 3
}
```
## 固定档位
| code | 含义 |
| ---: | --- |
| -1 | 恢复不限速 |
| 0 | 0kbps |
| 1 | 128Kbps |
| 2 | 512Kbps |
| 3 | 1Mbps |
| 4 | 2Mbps |
| 5 | 10Mbps |
| 6 | 20Mbps |
| 7 | 50Mbps |
| 8 | 100Mbps |
前端只能提交上述固定档位,不允许输入任意速率。`-1``0` 是不同操作,不能混用。
## 权限与调用链
- 仅超级管理员、平台账号和代理后台账号可调用。
- 通过现有 `IotCardStore.GetByICCID` 查询卡并应用当前数据权限;不存在和无权限统一返回“无权限操作该资源或资源不存在”。
- 外呼 Gateway 使用 `/flow-card/speedLimit`,请求业务参数只有 `cardNo``code`,其中 `cardNo` 必须是已校验的卡 ICCID。
- 不根据套餐流量、运营商或设备绑定关系自动计算或转换档位。
## 审计与结果未知
每次实际外呼前先创建 `pending` Integration Log记录卡、档位、操作者和请求关联标识外呼完成后终结为 `success``failed``unknown`。资产操作审计使用 `card_speed_tier` 动作编码记录操作者、卡、档位及结果。
Gateway 请求超时时外部是否已生效无法确定。接口返回可识别的结果未知错误Integration Log 保存人工恢复策略:通过 Gateway 运维侧按 ICCID 核对当前档位后,再决定是否重试。系统不会盲目声称成功,也不会自动重复提交。
## 联调与回滚
联调需覆盖全部固定档位、越权卡、Gateway 明确失败和请求超时,并确认设备详情和设备 API 中不存在限速入口。
回滚时可隐藏前端入口并回退应用版本;已经产生的资产操作审计和 Integration Log 不得删除或清空。由于本功能不保存本地“当前限速档位”,不涉及数据库状态回滚。

View File

@@ -0,0 +1,29 @@
# UR#33 套餐临期提醒功能总结
## 交付范围
- 新增 `GET /api/admin/expiring-assets`,平台与代理共用当前后台认证和店铺层级权限。
- 仅返回预计最终到期状态为 `exact`、剩余 0 至 15 个上海自然日的卡和设备。
- 返回卡、设备及合计数量,提供 `pink/purple/red` 高亮等级和 0 至 3 天优先标识。
- 每日上海时间 03:00 扫描 15、7、3 天节点,向资产关联个人客户写入站内通知 Outbox。
- C 端继续复用既有通知列表、未读数和弹窗机制,不新增营销、短信或企业微信提醒。
## 数据与幂等
- 临期时间复用 `internal/query/packageexpiry` 的最终到期算法,不建立临期状态快照。
- 列表候选使用当前主套餐到期时间预筛,再批量计算当前与排队主套餐的最终到期时间。
- 通知事件 ID 固化资产类型、资产 ID、到期日、节点和个人客户 ID定时任务、Outbox Relay 和通知消费者重复执行均不会重复通知。
- `000183_add_package_expiry_scan_indexes` 仅增加卡、设备主套餐到期扫描索引,不新增外键。
## 权限和异常边界
- 平台与超级管理员读取全局范围;代理只读取当前认证上下文的下级店铺范围。
- 代理权限范围为空时查询固定返回空集,不能因空切片退化为全局读取。
- `shop_id` 只缩小既有权限范围;企业账号禁止调用。
- 无关联个人客户的资产正常跳过通知,不影响临期列表事实。
## 联调与回滚
- 前端按 `expiry_level` 渲染颜色,按 `is_priority` 置顶,不自行重算阈值。
- 回滚应用版本后可删除迁移新增的两个扫描索引;已产生的通知和 Outbox 事实不得清表。
- 本 Change 未运行测试、完整构建、LSP 和迁移;按用户后续明确要求已运行 OpenAPI 生成器,另执行 `gofmt`、只读一致性检查和 `git diff --check`

View File

@@ -0,0 +1,125 @@
# 企业微信应用连接 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` 时,仅平台员工或超级管理员可提交,并要求 15 个对象存储凭证 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`;关闭需要重启 APIWorker 标准终态消费者不依赖这些开关。
- 前端切换后隐藏旧操作按钮,只读展示 `approval_instance_id``approval_provider``approval_status``approval_status_name`
- 存量核对 SQL、关闭门禁和回滚步骤见 [存量审批切换清单](存量审批切换清单.md)。存量记录不得伪造企微实例,回滚不得删除任何审批、资金或集成事实。
## 已知边界
- 当前已包含审批准备、提交、加密回调、详情终态同步、批量单号时间窗补偿、未终态轮询、结果未知主动恢复和审批人批量读取投影。
- 本次未运行迁移、测试、完整构建或 LSP按用户后续明确要求已运行 OpenAPI 生成器并检查企微管理及回调契约,静态收口另执行格式化和一致性检查。

View File

@@ -0,0 +1,62 @@
# 企业微信审批存量切换清单
## 目标与边界
本清单用于把退款人工 `approve/reject` 和线下充值人工 `offline-pay` 从存量兼容模式切换为企业微信只读审批状态模式。切换只关闭旧 HTTP 入口不删除路由、历史退款、充值、审批实例、钱包流水、Outbox 或 Integration Log也不为存量记录伪造企业微信审批实例。
## 发布开关
两个开关默认均为 `true`,部署新版本后继续允许 `approval_instance_id IS NULL` 的存量旧 provider 完成处理:
| 环境变量 | 默认值 | 关闭后的入口 |
|---|---|---|
| `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` | `true` | 退款 `approve/reject` 返回“入口已停用” |
| `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` | `true` | 线下充值 `offline-pay` 返回“入口已停用” |
配置变更后必须重启 API 实例。Worker 的企业微信标准终态消费者不读取这两个开关,不受切换影响。
## 切换前只读核对
以下 SQL 仅用于发布人员核对,不在本 Change 中执行。待审批状态值按当前模型均为 `1`
```sql
-- 仍需通过旧人工入口完成的存量待审批退款
SELECT id, refund_no, order_id, shop_id, creator, status, created_at
FROM tb_refund_request
WHERE deleted_at IS NULL
AND status = 1
AND approval_instance_id IS NULL
ORDER BY id;
-- 仍需通过旧人工入口完成的存量待确认线下充值
SELECT id, recharge_no, shop_id, user_id, amount, status, created_at
FROM tb_agent_recharge_record
WHERE deleted_at IS NULL
AND payment_method = 'offline'
AND status = 1
AND approval_instance_id IS NULL
ORDER BY id;
```
核对和处理规则:
- `approval_instance_id IS NULL` 表示存量旧 provider必须在关闭开关前通过原入口处理完成不得补写或伪造企微实例。
- `approval_instance_id IS NOT NULL` 表示新企微审批记录,只能由企微回调或轮询产生的标准终态处理;旧人工入口已在代码中逐单阻断。
- 存量清单应保存退款/充值业务 ID、业务单号、当前状态、责任人和处理结果不复制 Secret、access_token、media_id 或附件正文。
## 关闭门禁
只有同时满足以下条件才关闭旧入口:
1. 退款和员工线下代充值已在真实企微环境完成发起、审批、回调或轮询补偿、标准终态以及资金副作用联调。
2. 上述两份存量清单已清零,或剩余记录已有明确的前向处理方案和责任人。
3. 前端已隐藏退款 `approve/reject` 与线下充值 `offline-pay` 操作,只读展示 `approval_instance_id``approval_provider``approval_status``approval_status_name`
4. API 发布配置把对应开关设为 `false`,并完成全部 API 实例滚动重启。
关闭后应抽查新企微记录的列表和详情审批字段一致旧入口返回明确停用错误Worker 仍能消费 `approved/rejected/cancelled/deleted` 标准终态。
## 回滚
如关闭后发现前端或存量处理遗漏,将对应开关重新设为 `true` 并重启 API 实例。回滚只恢复旧入口可用性,不改变企微审批记录的逐单防绕过规则。
不得删除、清空或回退已经产生的审批实例、退款、充值、钱包流水、Outbox、Integration Log 或企微外部审批;不得把新企微记录改为旧 provider。问题应通过前向修复处理。

View File

@@ -0,0 +1,24 @@
ICCID(必填)联通必须是20位,MSISDN(必填),虚拟号(非必填)
89860624660030801094,861400566073546,
89860624590015958355,861400498975835,
89860624660030813982,861400566074835,
89860624590015955237,861400498975523,
89860624660030811838,861400566074620,
89860624660030811747,861400566074611,
89860624590015958967,861400498975896,
89860624660030816431,861400566075080,
89860624590015955880,861400498975588,
89860624660030816456,861400566075082,
89860624660030816431,861400566075080,
89860624660030807117,861400566074148,
89860624660030801904,861400566073627,
89860624590015958058,861400498975805,
89860624660030801615,861400566073598,
89860624590015958108,861400498975810,
89860624660030813669,861400566074803,
89860624660030812513,861400566074688,
89860624660030813503,861400566074787,
89860624660030807521,861400566074189,
89860624660030813107,861400566074747,
89860624660030807919,861400566074228,
89860624660030812687,861400566074705,
1 ICCID(必填)联通必须是20位 MSISDN(必填) 虚拟号(非必填)
2 89860624660030801094 861400566073546
3 89860624590015958355 861400498975835
4 89860624660030813982 861400566074835
5 89860624590015955237 861400498975523
6 89860624660030811838 861400566074620
7 89860624660030811747 861400566074611
8 89860624590015958967 861400498975896
9 89860624660030816431 861400566075080
10 89860624590015955880 861400498975588
11 89860624660030816456 861400566075082
12 89860624660030816431 861400566075080
13 89860624660030807117 861400566074148
14 89860624660030801904 861400566073627
15 89860624590015958058 861400498975805
16 89860624660030801615 861400566073598
17 89860624590015958108 861400498975810
18 89860624660030813669 861400566074803
19 89860624660030812513 861400566074688
20 89860624660030813503 861400566074787
21 89860624660030807521 861400566074189
22 89860624660030813107 861400566074747
23 89860624660030807919 861400566074228
24 89860624660030812687 861400566074705

View File

@@ -0,0 +1,24 @@
虚拟号(必填),SN非必填,设备名称(非必填),设备型号(非必填),设备类型(非必填),IMEI(非必填),制造商(非必填),最大SIM槽数 (非必填),卡1 ICCID (非必填),卡2 ICCID (非必填),卡3 ICCID (非必填),卡4 ICCID (非必填)
60003080109,,Dongle005,,Dongle005,869555083143381,,1,89860624660030801094,,,
90001595835,,Dongle005,,Dongle005,869555083142417,,1,89860624590015958355,,,
60003081398,,Dongle005,,Dongle005,869555083105034,,1,89860624660030813982,,,
90001595523,,Dongle005,,Dongle005,869555083142359,,1,89860624590015955237,,,
60003081183,,Dongle005,,Dongle005,869555083109259,,1,89860624660030811838,,,
60003081174,,Dongle005,,Dongle005,869555083106321,,1,89860624660030811747,,,
90001595896,,Dongle005,,Dongle005,869555083120306,,1,89860624590015958967,,,
60003081643,,Dongle005,,Dongle005,869555083104151,,1,89860624660030816431,,,
90001595588,,Dongle005,,Dongle005,869555083145048,,1,89860624590015955880,,,
60003081645,,Dongle005,,Dongle005,869555083089592,,1,89860624660030816456,,,
60003081643,,Dongle005,,Dongle005,869555083089568,,1,89860624660030816431,,,
60003080711,,Dongle005,,Dongle005,869555083144835,,1,89860624660030807117,,,
60003080190,,Dongle005,,Dongle005,869555083100118,,1,89860624660030801904,,,
90001595805,,Dongle005,,Dongle005,869555083127673,,1,89860624590015958058,,,
60003080161,,Dongle005,,Dongle005,869555083101702,,1,89860624660030801615,,,
90001595810,,Dongle005,,Dongle005,869555083127665,,1,89860624590015958108,,,
60003081366,,Dongle005,,Dongle005,869555083108509,,1,89860624660030813669,,,
60003081251,,Dongle005,,Dongle005,869555083100134,,1,89860624660030812513,,,
60003081350,,Dongle005,,Dongle005,869555083099575,,1,89860624660030813503,,,
60003080752,,Dongle005,,Dongle005,869555083145261,,1,89860624660030807521,,,
60003081310,,Dongle005,,Dongle005,869555083100308,,1,89860624660030813107,,,
60003080791,,Dongle005,,Dongle005,869555083144884,,1,89860624660030807919,,,
60003081268,,Dongle005,,Dongle005,869555083088545,,1,89860624660030812687,,,
1 虚拟号(必填) SN(非必填) 设备名称(非必填) 设备型号(非必填) 设备类型(非必填) IMEI(非必填) 制造商(非必填) 最大SIM槽数 (非必填) 卡1 ICCID (非必填) 卡2 ICCID (非必填) 卡3 ICCID (非必填) 卡4 ICCID (非必填)
2 60003080109 Dongle005 Dongle005 869555083143381 1 89860624660030801094
3 90001595835 Dongle005 Dongle005 869555083142417 1 89860624590015958355
4 60003081398 Dongle005 Dongle005 869555083105034 1 89860624660030813982
5 90001595523 Dongle005 Dongle005 869555083142359 1 89860624590015955237
6 60003081183 Dongle005 Dongle005 869555083109259 1 89860624660030811838
7 60003081174 Dongle005 Dongle005 869555083106321 1 89860624660030811747
8 90001595896 Dongle005 Dongle005 869555083120306 1 89860624590015958967
9 60003081643 Dongle005 Dongle005 869555083104151 1 89860624660030816431
10 90001595588 Dongle005 Dongle005 869555083145048 1 89860624590015955880
11 60003081645 Dongle005 Dongle005 869555083089592 1 89860624660030816456
12 60003081643 Dongle005 Dongle005 869555083089568 1 89860624660030816431
13 60003080711 Dongle005 Dongle005 869555083144835 1 89860624660030807117
14 60003080190 Dongle005 Dongle005 869555083100118 1 89860624660030801904
15 90001595805 Dongle005 Dongle005 869555083127673 1 89860624590015958058
16 60003080161 Dongle005 Dongle005 869555083101702 1 89860624660030801615
17 90001595810 Dongle005 Dongle005 869555083127665 1 89860624590015958108
18 60003081366 Dongle005 Dongle005 869555083108509 1 89860624660030813669
19 60003081251 Dongle005 Dongle005 869555083100134 1 89860624660030812513
20 60003081350 Dongle005 Dongle005 869555083099575 1 89860624660030813503
21 60003080752 Dongle005 Dongle005 869555083145261 1 89860624660030807521
22 60003081310 Dongle005 Dongle005 869555083100308 1 89860624660030813107
23 60003080791 Dongle005 Dongle005 869555083144884 1 89860624660030807919
24 60003081268 Dongle005 Dongle005 869555083088545 1 89860624660030812687

View File

@@ -0,0 +1,119 @@
package agentrecharge
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.posting == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND payment_method = ?", event.BusinessID, constants.RechargeMethodOffline).
First(&record).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定员工线下代充值申请失败")
}
if record.ApprovalInstanceID == nil || *record.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "线下代充值申请关联的审批实例不一致")
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &record, event)
case constants.ApprovalDecisionRejected:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝")
case constants.ApprovalDecisionCancelled:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
default:
return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态")
}
})
}
func (h *ApprovalDecisionHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
record *model.AgentRechargeRecord,
event approvalapp.TerminalDecisionEvent,
) error {
if record.Status != constants.RechargeStatusPending && record.Status != constants.RechargeStatusCompleted {
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许审批入账")
}
if record.Status == constants.RechargeStatusPending {
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{
"status": constants.RechargeStatusCompleted, "paid_at": event.OccurredAt,
"completed_at": event.OccurredAt, "updated_at": event.OccurredAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成线下代充值审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
}
_, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID,
Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID,
})
return err
}
func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error {
if record.Status == status {
return nil
}
if record.Status != constants.RechargeStatusPending {
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许结束审批")
}
reason = strings.TrimSpace(reason)
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{"status": status, "rejection_reason": reason})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束线下代充值审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
return nil
}
var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil)

View File

@@ -0,0 +1,188 @@
// Package agentrecharge 收口员工线下代充值申请和审批终态业务用例。
package agentrecharge
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CreateOfflineCommand 描述员工创建线下代充值审批申请的稳定输入。
type CreateOfflineCommand struct {
SubmitterAccountID uint
SubmitterUserType int
ShopID uint
RechargeNo string
Amount int64
PaymentVoucherKeys []string
Remark string
}
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
type CreateOfflineResult struct {
Record *model.AgentRechargeRecord
ShopName string
SubmitterName string
ApprovalStatus int
}
// OfflineCreationService 创建员工线下代充值申请及唯一通用审批实例。
type OfflineCreationService struct {
db *gorm.DB
approval approvalapp.Port
}
// NewOfflineCreationService 创建员工线下代充值申请用例。
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval}
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
if err := validateCreateOfflineCommand(command); err != nil {
return nil, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: command.SubmitterAccountID,
CorrelationID: strings.TrimSpace(command.RechargeNo),
})
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
record := &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
BusinessID: record.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: record.RechargeNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND approval_instance_id IS NULL", record.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联员工线下代充值审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工线下代充值审批实例关联已变化")
}
record.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
})
if err != nil {
return nil, err
}
return &CreateOfflineResult{
Record: record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
}, nil
}
func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.SubmitterUserType != constants.UserTypePlatform && command.SubmitterUserType != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
if command.SubmitterAccountID == 0 || command.ShopID == 0 || strings.TrimSpace(command.RechargeNo) == "" {
return errors.New(errors.CodeInvalidParam)
}
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
}
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
}
}
return nil
}
func (s *OfflineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOfflineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.SubmitterAccountID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeForbidden, "提交人账号不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值提交人失败")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", command.ShopID).First(&shop).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND wallet_type = ? AND status = ?", command.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "目标店铺主钱包不存在或不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺主钱包失败")
}
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
constants.ApprovalFieldShopID: command.ShopID,
constants.ApprovalFieldShopName: shopName,
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
constants.ApprovalFieldAmountCent: command.Amount,
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}

View File

@@ -0,0 +1,49 @@
// Package exchange 提供换货用例的可靠通知编排。
package exchange
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ShippingCreatedEvent 是物流换货单创建后通知个人客户的稳定业务事件。
type ShippingCreatedEvent struct {
ExchangeID uint
ExchangeNo string
CustomerID uint
AssetType string
AssetID uint
AssetIdentifier string
RequestID string
CorrelationID string
}
// ShippingCreatedEventWriter 将物流换货创建通知写入可靠事件基础设施。
type ShippingCreatedEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error
}
// ShippingCreatedNotifier 在换货业务事务内编排个人客户通知事件。
type ShippingCreatedNotifier struct {
writer ShippingCreatedEventWriter
}
// NewShippingCreatedNotifier 创建物流换货通知用例。
func NewShippingCreatedNotifier(writer ShippingCreatedEventWriter) *ShippingCreatedNotifier {
return &ShippingCreatedNotifier{writer: writer}
}
// Notify 在换货单事务内追加指定个人客户的可靠通知事件。
func (n *ShippingCreatedNotifier) Notify(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error {
if n == nil || n.writer == nil {
return errors.New(errors.CodeInternalError, "物流换货通知事件 Writer 未配置")
}
if tx == nil || event.ExchangeID == 0 || event.ExchangeNo == "" || event.CustomerID == 0 ||
event.AssetType == "" || event.AssetID == 0 || event.AssetIdentifier == "" {
return errors.New(errors.CodeInvalidParam, "物流换货通知事件参数不完整")
}
return n.writer.Append(ctx, tx, event)
}

View File

@@ -103,8 +103,8 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`, AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer, constants.NotificationRecipientKindPersonalCustomer,
customerID, customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry}, []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
[]string{constants.NotificationTypePackageExpiring}, []string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
now, now,
) )
} }

View File

@@ -0,0 +1,45 @@
// Package packageexpiry 编排套餐临期节点提醒用例。
package packageexpiry
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReminderScanner 查询当天命中临期节点的资产。
type ReminderScanner interface {
ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error)
}
// ReminderPublisher 批量发布个人客户临期通知。
type ReminderPublisher interface {
Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error
}
// ReminderService 扫描并发布每日套餐临期节点提醒。
type ReminderService struct {
scanner ReminderScanner
publisher ReminderPublisher
}
// NewReminderService 创建套餐临期提醒用例。
func NewReminderService(scanner ReminderScanner, publisher ReminderPublisher) *ReminderService {
return &ReminderService{scanner: scanner, publisher: publisher}
}
// Run 执行当天 15、7、3 天节点扫描并可靠发布通知。
func (s *ReminderService) Run(ctx context.Context) error {
if s == nil || s.scanner == nil || s.publisher == nil {
return errors.New(errors.CodeInternalError, "套餐临期提醒用例未配置")
}
candidates, err := s.scanner.ReminderCandidates(ctx)
if err != nil {
return err
}
if len(candidates) == 0 {
return nil
}
return s.publisher.Publish(ctx, candidates)
}

View File

@@ -0,0 +1,151 @@
// Package refundapproval 收口退款申请与渠道无关审批的事务边界。
package refundapproval
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
type CreateCommand struct {
Refund *model.RefundRequest
SubmitterAccountID uint
}
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
}
// NewCreationService 创建退款审批申请用例。
func NewCreationService(db *gorm.DB, approval approvalapp.Port) *CreationService {
return &CreationService{db: db, approval: approval}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Refund.OrderID == 0 || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
account, err := s.loadSubmitter(ctx, command.SubmitterAccountID)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
}
var activeCount int64
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
}
if activeCount > 0 {
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
}
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
}
return &account, nil
}
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldRefundNo: refund.RefundNo,
constants.ApprovalFieldOrderID: refund.OrderID,
constants.ApprovalFieldOrderNo: refund.OrderNo,
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
constants.ApprovalFieldAssetType: refund.OrderType,
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
constants.ApprovalFieldRefundReason: refund.RefundReason,
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
constants.ApprovalFieldSubmitterID: account.ID,
constants.ApprovalFieldSubmitterName: account.Username,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}
func formatCentAmount(amount int64) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}

View File

@@ -219,6 +219,7 @@ func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName, ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City, ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
District: shop.District, Address: shop.Address, Status: shop.Status, District: shop.District, Address: shop.Address, Status: shop.Status,
ClientLoginDisabled: shop.ClientLoginDisabled,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
} }

View File

@@ -38,6 +38,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet { if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员") return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
} }
if userType == constants.UserTypeAgent && request.ClientLoginDisabled != nil && middleware.GetShopIDFromContext(ctx) != shopID {
return nil, errors.New(errors.CodeForbidden, "无权限修改其他店铺的C端登录限制")
}
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent { if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
} }
@@ -59,6 +62,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
} }
shop.BusinessOwnerAccountID = ownerID shop.BusinessOwnerAccountID = ownerID
} }
if request.ClientLoginDisabled != nil {
shop.ClientLoginDisabled = *request.ClientLoginDisabled
}
shop.ShopName = request.ShopName shop.ShopName = request.ShopName
shop.ContactName = request.ContactName shop.ContactName = request.ContactName
shop.ContactPhone = request.ContactPhone shop.ContactPhone = request.ContactPhone

View File

@@ -0,0 +1,303 @@
// Package wecom 提供企业微信配置简单写与连接测试用例。
package wecom
import (
"context"
stdErrors "errors"
"fmt"
"time"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ApplicationRepository 定义企业微信应用配置持久化边界。
type ApplicationRepository interface {
FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error)
Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error)
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error
}
// DefaultCreatorMemberFinder 定义默认审批发起人的可见成员查询边界。
type DefaultCreatorMemberFinder interface {
GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error)
}
// CredentialCipher 定义企业微信敏感凭据加密边界。
type CredentialCipher interface {
Encrypt(plaintext string) ([]byte, error)
Decrypt(ciphertext []byte) (string, error)
}
// AccessTokenProvider 定义按应用取得及失效 access_token 的边界。
type AccessTokenProvider interface {
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
Invalidate(ctx context.Context, applicationID uint)
}
// ConnectionService 保存加密配置并测试企业微信连接。
type ConnectionService struct {
db *gorm.DB
repo ApplicationRepository
cipher CredentialCipher
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
}
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) {
s.members = finder
}
// NewConnectionService 创建企业微信连接用例。
func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService {
return &ConnectionService{db: db, repo: repo, cipher: cipher, tokens: tokens, audit: audit, now: time.Now}
}
// Save 创建或更新企业微信应用,并保证数据库仅保存密文凭据。
func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.cipher == nil {
return nil, errors.New(errors.CodeWeComCredentialInvalid)
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
secret, err := s.cipher.Encrypt(request.Secret)
if err != nil {
return nil, err
}
callbackToken, err := s.cipher.Encrypt(request.CallbackToken)
if err != nil {
return nil, err
}
encodingAESKey, err := s.cipher.Encrypt(request.EncodingAESKey)
if err != nil {
return nil, err
}
now := s.now().UTC()
var saved *model.WeComApplication
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil {
return err
}
existing, findErr := s.repo.FindByIdentityForUpdate(ctx, tx, request.CorpID, request.AgentID)
if findErr != nil {
return findErr
}
before := map[string]any{"configured": false}
if existing == nil {
existing = &model.WeComApplication{
Model: gorm.Model{CreatedAt: now},
CorpID: request.CorpID,
AgentID: request.AgentID,
CreatedBy: operatorID,
}
} else {
before = applicationAuditSnapshot(existing)
}
existing.Name = request.Name
existing.SecretCiphertext = secret
existing.CallbackTokenCiphertext = callbackToken
existing.EncodingAESKeyCiphertext = encodingAESKey
existing.Status = request.Status
existing.UpdatedBy = operatorID
existing.UpdatedAt = now
if existing.ID == 0 {
if err := s.repo.Create(ctx, tx, existing); err != nil {
return err
}
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置",
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
}
saved = existing
return nil
})
if err != nil {
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信应用配置失败")
}
if s.tokens != nil {
s.tokens.Invalidate(ctx, saved.ID)
}
response := toApplicationResponse(*saved, request.Secret, request.CallbackToken, request.EncodingAESKey)
return &response, nil
}
// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。
func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) {
if s == nil || s.repo == nil || s.cipher == nil {
return nil, errors.New(errors.CodeWeComCredentialInvalid)
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
applications, total, err := s.repo.List(ctx, request.Page, request.PageSize)
if err != nil {
return nil, err
}
result := make([]dto.WeComApplicationResponse, 0, len(applications))
for _, application := range applications {
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
if err != nil {
return nil, err
}
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
if err != nil {
return nil, err
}
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
if err != nil {
return nil, err
}
result = append(result, toApplicationResponse(application, secret, callbackToken, encodingAESKey))
}
return &dto.WeComApplicationListResponse{
Items: result, Total: total, Page: request.Page, PageSize: request.PageSize,
}, nil
}
// Test 强制失效旧缓存后取得一次 access_token但绝不向调用方返回 token。
func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error {
if s == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden)
}
if s.tokens == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
s.tokens.Invalidate(ctx, applicationID)
_, err := s.tokens.GetAccessToken(ctx, applicationID)
return err
}
// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.cipher == nil || s.members == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if applicationID == 0 || operatorID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
application, err := s.repo.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
member, err := s.members.GetVisible(ctx, applicationID, request.UserID)
if err != nil {
return nil, err
}
now := s.now().UTC()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := applicationAuditSnapshot(application)
if err := s.repo.UpdateDefaultCreator(ctx, tx, applicationID, member.UserID, member.Name, operatorID, now); err != nil {
return err
}
application.DefaultCreatorUserID = member.UserID
application.DefaultCreatorName = member.Name
application.UpdatedBy = operatorID
application.UpdatedAt = now
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人",
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
})
}
return nil
})
if err != nil {
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败")
}
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
if err != nil {
return nil, err
}
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
if err != nil {
return nil, err
}
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
if err != nil {
return nil, err
}
response := toApplicationResponse(*application, secret, callbackToken, encodingAESKey)
return &response, nil
}
func applicationAuditSnapshot(application *model.WeComApplication) map[string]any {
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status, "credentials_configured": true,
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
}
}
func toApplicationResponse(application model.WeComApplication, secret, callbackToken, encodingAESKey string) dto.WeComApplicationResponse {
statusName := "禁用"
if application.Status == constants.StatusEnabled {
statusName = "启用"
}
return dto.WeComApplicationResponse{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name,
Secret: secret, CallbackToken: callbackToken, EncodingAESKey: encodingAESKey,
DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName,
Status: application.Status, StatusName: statusName,
CredentialsSet: len(application.SecretCiphertext) > 0 && len(application.CallbackTokenCiphertext) > 0 && len(application.EncodingAESKeyCiphertext) > 0,
LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
}
}

View File

@@ -0,0 +1,129 @@
package wecom
import (
"context"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// DirectoryMember 是企微 Adapter 交给 Application 的最小成员快照。
type DirectoryMember struct {
UserID string
Name string
DepartmentIDs []int64
}
// DirectoryProvider 定义拉取应用可见成员的外部端口。
type DirectoryProvider interface {
ListVisibleMembers(ctx context.Context, applicationID uint) ([]DirectoryMember, error)
}
// MemberRepository 定义可见成员快照同步和分页查询边界。
type MemberRepository interface {
ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error)
}
// DirectoryService 同步并分页查询企业微信应用可见成员。
type DirectoryService struct {
applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}
provider DirectoryProvider
members MemberRepository
now func() time.Time
}
// NewDirectoryService 创建企业微信通讯录同步用例。
func NewDirectoryService(applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}, provider DirectoryProvider, members MemberRepository) *DirectoryService {
return &DirectoryService{applications: applications, provider: provider, members: members, now: time.Now}
}
// Sync 拉取并替换指定应用当前可见成员快照。
func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) {
if s == nil || s.applications == nil || s.provider == nil || s.members == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
return nil, errors.New(errors.CodeForbidden)
}
application, err := s.applications.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID)
if err != nil {
return nil, err
}
syncedAt := s.now().UTC()
members := make([]model.WeComMember, 0, len(remoteMembers))
for _, member := range remoteMembers {
departments, err := sonic.Marshal(member.DepartmentIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化企业微信成员部门失败")
}
members = append(members, model.WeComMember{
ApplicationID: applicationID, CorpID: application.CorpID, UserID: member.UserID,
Name: member.Name, DepartmentIDs: departments, Visible: true, SyncedAt: syncedAt,
CreatedAt: syncedAt, UpdatedAt: syncedAt,
})
}
if err := s.members.ReplaceVisible(ctx, applicationID, members, syncedAt); err != nil {
return nil, err
}
return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil
}
// List 分页返回本地最近一次同步的应用可见成员。
func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) {
if s == nil || s.applications == nil || s.members == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, err := s.applications.GetEnabled(ctx, applicationID); err != nil {
return nil, err
}
members, total, err := s.members.ListVisible(ctx, applicationID, request.Page, request.PageSize, request.Keyword)
if err != nil {
return nil, err
}
items := make([]dto.WeComMemberResponse, 0, len(members))
for _, member := range members {
var departmentIDs []int64
if len(member.DepartmentIDs) > 0 {
if err := sonic.Unmarshal(member.DepartmentIDs, &departmentIDs); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信成员部门失败")
}
}
items = append(items, dto.WeComMemberResponse{
ApplicationID: member.ApplicationID, CorpID: member.CorpID, UserID: member.UserID,
Name: member.Name, DepartmentIDs: departmentIDs, SyncedAt: member.SyncedAt,
})
}
return &dto.WeComMemberListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func canManageWeComDirectory(ctx context.Context) bool {
userType := middleware.GetUserTypeFromContext(ctx)
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
}

View File

@@ -0,0 +1,322 @@
package wecom
import (
"context"
"crypto/sha256"
"encoding/hex"
stdErrors "errors"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TemplateControl 是模板详情中可供业务映射的最小控件结构。
type TemplateControl struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Required bool `json:"required"`
OptionKeys []string `json:"option_keys"`
}
// TemplateDefinition 是企微 Adapter 返回的模板最小结构。
type TemplateDefinition struct {
TemplateID string `json:"template_id"`
Name string `json:"name"`
Controls []TemplateControl `json:"controls"`
}
// TemplateProvider 定义审批模板详情外部端口。
type TemplateProvider interface {
GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error)
}
// SceneRepository 定义审批场景当前配置持久化边界。
type SceneRepository interface {
FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error)
Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error)
}
// SceneService 保存经企微模板详情校验的业务场景当前映射。
type SceneService struct {
db *gorm.DB
provider TemplateProvider
repo SceneRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewSceneService 创建企业微信审批场景配置用例。
func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService {
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
}
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
businessType = strings.TrimSpace(businessType)
if !validApprovalBusinessType(businessType) {
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
}
if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 ||
(request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
if err != nil {
return nil, err
}
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
return nil, err
}
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
mappingJSON, err := sonic.Marshal(request.ControlMapping)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败")
}
snapshotJSON, err := sonic.Marshal(definition)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
}
fingerprintBytes := sha256.Sum256(snapshotJSON)
fingerprint := hex.EncodeToString(fingerprintBytes[:])
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
now := s.now().UTC()
var saved *model.WeComApprovalScene
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil {
return err
}
existing, err := s.repo.FindForUpdate(ctx, tx, businessType)
if err != nil {
return err
}
before := map[string]any{"configured": false, "business_type": businessType}
if existing == nil {
existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now}
} else {
before = sceneAuditSnapshot(existing)
}
existing.ApplicationID = request.ApplicationID
existing.TemplateID = definition.TemplateID
existing.TemplateName = definition.Name
existing.ControlMapping = mappingJSON
existing.TemplateSnapshot = snapshotJSON
existing.TemplateFingerprint = fingerprint
existing.Status = request.Status
existing.LastVerifiedAt = now
existing.UpdatedBy = operatorID
existing.UpdatedAt = now
if existing.ID == 0 {
if err := s.repo.Create(ctx, tx, existing); err != nil {
return err
}
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
}
saved = existing
return nil
})
if err != nil {
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败")
}
return sceneResponse(*saved)
}
// List 分页查询企业微信审批场景当前配置。
func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) {
if s == nil || s.repo == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
if request.Page <= 0 {
request.Page = constants.DefaultPage
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
if request.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize)
if err != nil {
return nil, err
}
items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes))
for _, scene := range scenes {
item, err := sceneResponse(scene)
if err != nil {
return nil, err
}
items = append(items, *item)
}
return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
controlByID := make(map[string]TemplateControl, len(controls))
for _, control := range controls {
controlByID[control.ID] = control
}
mappedControls := make(map[string]struct{}, len(mapping))
businessFields := make(map[string]struct{}, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
if item.BusinessField == "" || item.ControlID == "" {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空")
}
if !allowedSceneBusinessField(businessType, item.BusinessField) {
return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段")
}
if _, exists := businessFields[item.BusinessField]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复")
}
if _, exists := mappedControls[item.ControlID]; exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射")
}
control, exists := controlByID[item.ControlID]
if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) {
return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效")
}
validOptions := make(map[string]struct{}, len(control.OptionKeys))
for _, key := range control.OptionKeys {
validOptions[key] = struct{}{}
}
for _, key := range item.OptionMapping {
if _, exists := validOptions[key]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效")
}
}
businessFields[item.BusinessField] = struct{}{}
mappedControls[item.ControlID] = struct{}{}
}
for _, control := range controls {
if control.Required {
if _, exists := mappedControls[control.ID]; !exists {
return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title)
}
}
}
return nil
}
func allowedSceneBusinessField(businessType, businessField string) bool {
allowed := map[string]struct{}{}
switch businessType {
case constants.ApprovalBusinessTypeOfflineRecharge:
allowed = map[string]struct{}{
constants.ApprovalFieldRechargeNo: {},
constants.ApprovalFieldShopID: {},
constants.ApprovalFieldShopName: {},
constants.ApprovalFieldAmount: {},
constants.ApprovalFieldAmountCent: {},
constants.ApprovalFieldPaymentVoucherKey: {},
constants.ApprovalFieldRemark: {},
constants.ApprovalFieldSubmitterID: {},
constants.ApprovalFieldSubmitterName: {},
}
case constants.ApprovalBusinessTypeRefund:
allowed = map[string]struct{}{
constants.ApprovalFieldRefundNo: {},
constants.ApprovalFieldOrderID: {},
constants.ApprovalFieldOrderNo: {},
constants.ApprovalFieldAssetIdentifier: {},
constants.ApprovalFieldAssetType: {},
constants.ApprovalFieldActualReceivedAmount: {},
constants.ApprovalFieldRequestedRefundAmount: {},
constants.ApprovalFieldRefundVoucherKey: {},
constants.ApprovalFieldRefundReason: {},
constants.ApprovalFieldPackageUsageID: {},
constants.ApprovalFieldSubmitterID: {},
constants.ApprovalFieldSubmitterName: {},
}
default:
return false
}
_, exists := allowed[businessField]
return exists
}
func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem {
result := make([]dto.WeComControlMappingItem, 0, len(mapping))
for _, item := range mapping {
item.BusinessField = strings.TrimSpace(item.BusinessField)
item.ControlID = strings.TrimSpace(item.ControlID)
item.ControlType = strings.TrimSpace(item.ControlType)
if item.OptionMapping == nil {
item.OptionMapping = map[string]string{}
}
result = append(result, item)
}
return result
}
func validApprovalBusinessType(businessType string) bool {
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
return map[string]any{
"business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
"template_fingerprint": scene.TemplateFingerprint,
}
}
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
var mapping []dto.WeComControlMappingItem
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败")
}
name := "员工线下代充值审批"
if scene.BusinessType == constants.ApprovalBusinessTypeRefund {
name = "退款审批"
}
return &dto.WeComApprovalSceneResponse{
ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: name,
ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName,
TemplateFingerprint: scene.TemplateFingerprint,
ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status),
LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt,
}, nil
}

View File

@@ -6,6 +6,7 @@ import (
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
wecomApp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
@@ -14,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback" "github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
@@ -22,11 +24,14 @@ import (
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order" rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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 {
@@ -92,16 +97,58 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
systemConfigRegistry = systemConfigInfra.NewRegistry() systemConfigRegistry = systemConfigInfra.NewRegistry()
} }
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger) registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil { if deps.Redis != nil {
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis) systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
} }
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts) systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader) systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigUpdate := systemConfigApp.NewUpdateService( systemConfigUpdate := systemConfigApp.NewUpdateService(
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil, deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
) )
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
var credentialCipher *wecomInfra.CredentialCipher
wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout
if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout
cipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
if err != nil {
if deps.Logger != nil {
deps.Logger.Warn("企业微信凭据加密密钥未配置或无效,相关接口将拒绝业务调用", zap.Error(err))
}
} else {
credentialCipher = cipher
}
}
wecomTokens := wecomInfra.NewTokenProvider(
wecomRepository, credentialCipher, deps.Redis, integrationlog.NewRepository(deps.DB),
wecomBaseURL, wecomTimeout, deps.Logger,
)
wecomConnections := wecomApp.NewConnectionService(
deps.DB, wecomRepository, credentialCipher, wecomTokens, deps.SystemConfigAudit,
)
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
wecomDirectory := wecomApp.NewDirectoryService(
wecomRepository,
wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomMembers,
)
wecomScenes := wecomApp.NewSceneService(
deps.DB,
wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit,
)
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
wecomRepository, credentialCipher, integrationlog.NewRepository(deps.DB), deps.QueueClient,
))
svc.Account.SetWeComMemberFinder(wecomMembers)
return &Handlers{ return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate), Auth: authHandler.NewHandler(svc.Auth, validate),
@@ -117,9 +164,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientAsset: func() *app.ClientAssetHandler { ClientAsset: func() *app.ClientAssetHandler {
handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger) handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries) handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetForceRechargeChecker(svc.Recharge)
return handler
}(),
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
return handler return handler
}(), }(),
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange), ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: func() *app.ClientRealnameHandler { ClientRealname: func() *app.ClientRealnameHandler {
@@ -189,6 +242,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB), carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger, integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger,
), ),
WeComApprovalCallback: wecomApprovalCallback,
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
@@ -208,6 +262,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger)) h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
h.SetLifecycleService(svc.AssetLifecycle) h.SetLifecycleService(svc.AssetLifecycle)
h.SetObservationSeriesDispatcher(svc.ObservationSeries) h.SetObservationSeriesDispatcher(svc.ObservationSeries)
h.SetPackageExpiryQuery(packageExpiry)
return h return h
}(), }(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
@@ -220,9 +275,16 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
Refund: admin.NewRefundHandler(svc.Refund), Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword), SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate), SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
WeCom: func() *admin.WeComHandler {
handler := admin.NewWeComHandler(wecomConnections, validate)
handler.SetDirectoryService(wecomDirectory)
handler.SetSceneService(wecomScenes)
return handler
}(),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate), AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
} }
} }

View File

@@ -0,0 +1,36 @@
package bootstrap
import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {
if existing.ValueType != definition.ValueType || existing.Module != definition.Module {
logPaymentMethodConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册")
}
continue
}
if err := registry.Register(definition); err != nil {
logPaymentMethodConfigRegistrationError(logger, definition.Key, err.Error())
}
}
}
func logPaymentMethodConfigRegistrationError(logger *zap.Logger, key, reason string) {
if logger != nil {
logger.Error("注册支付方式系统配置失败C端支付将失败关闭", zap.String("config_key", key), zap.String("reason", reason))
}
}

View File

@@ -5,12 +5,19 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit" accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
@@ -26,11 +33,13 @@ import (
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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/break/junhong_cmp_fiber/pkg/payment" "github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/queue"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet" assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import" deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
@@ -65,6 +74,7 @@ import (
) )
type services struct { type services struct {
Approval *approvalApp.CreationService
Account *accountSvc.Service Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service AccountAudit *accountAuditSvc.Service
AssetAudit *assetAuditSvc.Service AssetAudit *assetAuditSvc.Service
@@ -120,6 +130,7 @@ type services struct {
AgentOpenAPI *agentOpenAPISvc.Service AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service OrderPackageInvalidate *orderPackageInvalidateSvc.Service
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService CardObservationSeries *cardObservationApp.SeriesAttemptService
@@ -155,6 +166,7 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger), cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
) )
iotCard.SetCardObservationService(cardObservationService) iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
seriesIntegration := integrationlog.NewRepository(deps.DB) seriesIntegration := integrationlog.NewRepository(deps.DB)
seriesTrigger := cardObservationApp.NewSeriesTrigger( seriesTrigger := cardObservationApp.NewSeriesTrigger(
@@ -251,7 +263,8 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis, deps.Redis,
deps.Logger, deps.Logger,
) )
agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)) agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
refundService := refundSvc.New( refundService := refundSvc.New(
deps.DB, deps.DB,
s.RefundRequest, s.RefundRequest,
@@ -268,11 +281,45 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger, deps.Logger,
) )
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries) agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)
wecomBaseURL := ""
wecomTimeout := constants.WeComDefaultHTTPTimeout
var wecomCredentialCipher *wecomInfra.CredentialCipher
if cfg := config.Get(); cfg != nil {
wecomBaseURL = cfg.WeCom.BaseURL
wecomTimeout = cfg.WeCom.Timeout
wecomCredentialCipher, _ = wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
}
wecomIntegrationRepository := integrationlog.NewRepository(deps.DB)
wecomTokenProvider := wecomInfra.NewTokenProvider(
wecomApplicationRepository, wecomCredentialCipher, deps.Redis, wecomIntegrationRepository,
wecomBaseURL, wecomTimeout, deps.Logger,
)
approvalCreationService := approvalApp.NewCreationService(
wecomInfra.NewApprovalProvider(
deps.DB, wecomSceneRepository, wecomApplicationRepository, wecomMemberRepository,
wecomInfra.NewTemplateClient(wecomTokenProvider, wecomIntegrationRepository, wecomBaseURL, wecomTimeout),
),
approvalInfra.NewRepositoryProvider(),
approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()),
nil,
)
agentRechargeService.SetOfflineCreationService(
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService),
)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
)
return &services{ return &services{
Approval: approvalCreationService,
Account: account, Account: account,
AccountAudit: accountAudit, AccountAudit: accountAudit,
AssetAudit: assetAudit, AssetAudit: assetAudit,
@@ -337,7 +384,7 @@ func initServices(s *stores, deps *Dependencies) *services {
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation, PurchaseValidation: purchaseValidation,
Order: orderService, Order: orderService,
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger), Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
@@ -358,6 +405,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Refund: refundService, Refund: refundService,
CustomerBinding: customerBinding, CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
ObservationSeries: observationSeries, ObservationSeries: observationSeries,
CardObservation: cardObservationService, CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries, CardObservationSeries: cardObservationSeries,

View File

@@ -68,6 +68,8 @@ type stores struct {
RefundRequest *postgres.RefundStore RefundRequest *postgres.RefundStore
// 订单套餐失效任务 // 订单套餐失效任务
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 资产套餐批量订购任务
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
// 流量系统 // 流量系统
CardDailyUsage *postgres.CardDailyUsageStore CardDailyUsage *postgres.CardDailyUsageStore
// 资产标识符注册表 // 资产标识符注册表
@@ -137,5 +139,6 @@ func initStores(deps *Dependencies) *stores {
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
} }
} }

View File

@@ -57,6 +57,7 @@ type Handlers struct {
CMCCRealnameCallback *callback.CMCCRealnameHandler CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler PollingMonitoring *admin.PollingMonitoringHandler
@@ -70,9 +71,11 @@ type Handlers struct {
AgentRecharge *admin.AgentRechargeHandler AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
ClientWechat *app.ClientWechatHandler ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler SystemConfig *admin.SystemConfigHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler AgentOpenAPI *openapiHandler.Handler
} }

View File

@@ -17,6 +17,7 @@ type WorkerDependencies struct {
Redis *redis.Client Redis *redis.Client
Logger *zap.Logger Logger *zap.Logger
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务 AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选) StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选) GatewayClient *gateway.Client // Gateway 客户端(可选)
} }

View File

@@ -10,10 +10,12 @@ import (
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order" orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/queue"
) )
@@ -102,7 +104,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration), cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
) )
// 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端) // 初始化订单服务,供超时取消和批量订购共同复用现有订单规则。
purchaseValidation := purchaseValidationSvc.New(deps.DB, stores.IotCard, stores.Device, stores.Package, stores.ShopPackageAllocation)
orderService := orderSvc.New( orderService := orderSvc.New(
deps.DB, deps.DB,
deps.Redis, deps.Redis,
@@ -111,7 +114,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.AgentWallet, stores.AgentWallet,
stores.AssetWallet, stores.AssetWallet,
nil, // paymentStore: 超时取消不需要 nil, // paymentStore: 超时取消不需要
nil, // purchaseValidationService: 超时取消不需要 purchaseValidation,
stores.ShopPackageAllocation, stores.ShopPackageAllocation,
stores.ShopSeriesAllocation, stores.ShopSeriesAllocation,
stores.IotCard, stores.IotCard,
@@ -122,7 +125,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
nil, // wechatConfigService: 超时取消不需要 nil, // wechatConfigService: 超时取消不需要
nil, // wechatPayment: 超时取消不需要 nil, // wechatPayment: 超时取消不需要
nil, // paymentLoader: 超时取消不需要 nil, // paymentLoader: 超时取消不需要
nil, // queueClient: 超时取消不触发分佣 deps.QueueClient,
deps.Logger, deps.Logger,
stores.AssetIdentifier, stores.AssetIdentifier,
stores.PersonalCustomer, stores.PersonalCustomer,
@@ -131,6 +134,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
walletOutbox := outbox.NewRepository() walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil)) orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService( stopResumeService := iotCardSvc.NewStopResumeService(
@@ -148,6 +152,11 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
activationService.SetResumeCallback(stopResumeService) activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService)
resetService.SetResumeCallback(stopResumeService) resetService.SetResumeCallback(stopResumeService)
deviceBatchAllocator := deviceSvc.New(
deps.DB, deps.Redis, stores.Device, stores.DeviceSimBinding, stores.IotCard, stores.Shop,
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, assetAudit, nil, nil,
)
return &queue.WorkerServices{ return &queue.WorkerServices{
CardObservation: cardObservationService, CardObservation: cardObservationService,
@@ -162,5 +171,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
CleanupService: cleanupService, CleanupService: cleanupService,
StopResumeService: stopResumeService, StopResumeService: stopResumeService,
OrderExpirer: orderService, OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,
} }
} }

View File

@@ -7,6 +7,7 @@ import (
type workerStores struct { type workerStores struct {
AssetOperationLog *postgres.AssetOperationLogStore AssetOperationLog *postgres.AssetOperationLogStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore
IotCardImportTask *postgres.IotCardImportTaskStore IotCardImportTask *postgres.IotCardImportTaskStore
IotCard *postgres.IotCardStore IotCard *postgres.IotCardStore
DeviceImportTask *postgres.DeviceImportTaskStore DeviceImportTask *postgres.DeviceImportTaskStore
@@ -36,11 +37,13 @@ type workerStores struct {
PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
} }
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
stores := &workerStores{ stores := &workerStores{
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB), AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
@@ -70,10 +73,12 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
} }
return &queue.WorkerStores{ return &queue.WorkerStores{
AssetOperationLog: stores.AssetOperationLog, AssetOperationLog: stores.AssetOperationLog,
AssetAllocationRecord: stores.AssetAllocationRecord,
IotCardImportTask: stores.IotCardImportTask, IotCardImportTask: stores.IotCardImportTask,
IotCard: stores.IotCard, IotCard: stores.IotCard,
DeviceImportTask: stores.DeviceImportTask, DeviceImportTask: stores.DeviceImportTask,
@@ -103,5 +108,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
PersonalCustomer: stores.PersonalCustomer, PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone, PersonalCustomerPhone: stores.PersonalCustomerPhone,
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
} }
} }

View File

@@ -0,0 +1,190 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AgentRechargeDataSource 代理充值导出数据源。
type AgentRechargeDataSource struct {
db *gorm.DB
}
// NewAgentRechargeDataSource 创建代理充值导出数据源。
func NewAgentRechargeDataSource(db *gorm.DB) *AgentRechargeDataSource {
return &AgentRechargeDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *AgentRechargeDataSource) Scene() string {
return constants.ExportTaskSceneAgentRecharge
}
// Count 统计代理充值导出行数。
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回代理充值导出表头。
func (s *AgentRechargeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"充值单号", "店铺名称", "充值类型", "充值金额(元)", "实付金额(元)", "充值前余额(元)", "充值后余额(元)",
"状态", "支付方式", "运营备注", "驳回原因", "创建时间", "支付时间", "完成时间", "提交人", "审批来源", "审批状态", "支付凭证",
}, nil
}
// Fetch 按 offset/limit 查询代理充值导出数据。
func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []agentRechargeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
r.recharge_no,
r.amount,
r.payment_method,
r.status,
r.remark,
COALESCE(r.rejection_reason, '') AS rejection_reason,
r.created_at,
r.paid_at,
r.completed_at,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
wt.amount AS actual_amount,
wt.balance_before,
wt.balance_after,
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
FROM jsonb_array_elements_text(COALESCE(r.payment_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.user_id").
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
Joins(`LEFT JOIN LATERAL (
SELECT t.amount, t.balance_before, t.balance_after
FROM tb_agent_wallet_transaction AS t
WHERE t.reference_type = ?
AND t.reference_id = r.id
AND t.transaction_type = ?
AND t.status = ?
AND t.deleted_at IS NULL
ORDER BY t.id ASC
LIMIT 1
) AS wt ON TRUE`, constants.ReferenceTypeTopup, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
Order("r.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.RechargeNo,
item.ShopName,
formatRechargeType(item.PaymentMethod),
formatMoneyYuan(item.Amount),
formatOptionalMoneyYuan(item.ActualAmount),
formatOptionalMoneyYuan(item.BalanceBefore),
formatOptionalMoneyYuan(item.BalanceAfter),
constants.GetRechargeStatusName(item.Status),
constants.GetBusinessPaymentMethodName(item.PaymentMethod),
item.Remark,
item.RejectionReason,
item.CreatedAt.Format(exportTimeLayout),
formatOptionalTime(item.PaidAt),
formatOptionalTime(item.CompletedAt),
item.SubmitterName,
formatApprovalProvider(item.ApprovalProvider),
formatOptionalApprovalStatus(item.ApprovalStatus),
item.VoucherKeys,
})
}
return rows, nil
}
func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
}
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "r.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("r.created_at >= ?", start)
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("r.created_at <= ?", end)
}
return query
}
type agentRechargeExportRow struct {
RechargeNo string `gorm:"column:recharge_no"`
ShopName string `gorm:"column:shop_name"`
Amount int64 `gorm:"column:amount"`
ActualAmount *int64 `gorm:"column:actual_amount"`
BalanceBefore *int64 `gorm:"column:balance_before"`
BalanceAfter *int64 `gorm:"column:balance_after"`
PaymentMethod string `gorm:"column:payment_method"`
Status int `gorm:"column:status"`
Remark string `gorm:"column:remark"`
RejectionReason string `gorm:"column:rejection_reason"`
CreatedAt time.Time `gorm:"column:created_at"`
PaidAt *time.Time `gorm:"column:paid_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
SubmitterName string `gorm:"column:submitter_name"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRechargeType(paymentMethod string) string {
if paymentMethod == constants.RechargeMethodOffline {
return "员工线下代充值"
}
return "在线充值"
}
func formatOptionalMoneyYuan(value *int64) string {
if value == nil {
return ""
}
return formatMoneyYuan(*value)
}
func formatApprovalProvider(provider *string) string {
if provider == nil || *provider == "" {
return ""
}
if *provider == constants.IntegrationProviderWeCom {
return "企业微信"
}
return *provider
}
func formatOptionalApprovalStatus(status *int) string {
if status == nil {
return ""
}
return constants.GetApprovalStatusName(*status)
}

View File

@@ -0,0 +1,168 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AgentWalletTransactionDataSource 代理主钱包流水导出数据源。
type AgentWalletTransactionDataSource struct {
db *gorm.DB
}
// NewAgentWalletTransactionDataSource 创建代理主钱包流水导出数据源。
func NewAgentWalletTransactionDataSource(db *gorm.DB) *AgentWalletTransactionDataSource {
return &AgentWalletTransactionDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *AgentWalletTransactionDataSource) Scene() string {
return constants.ExportTaskSceneAgentWalletTransaction
}
// Count 统计代理主钱包流水导出行数。
func (s *AgentWalletTransactionDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回代理主钱包流水导出表头。
func (s *AgentWalletTransactionDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"店铺名称", "交易类型", "交易金额(元)", "状态", "资产类型", "资产标识", "交易时间",
"交易前金额(元)", "交易后金额(元)", "购买套餐名称", "操作人", "交易ID", "关联业务订单号", "交易渠道/支付方式",
}, nil
}
// Fetch 按 offset/limit 查询代理主钱包流水导出数据。
func (s *AgentWalletTransactionDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []agentWalletTransactionExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
t.id,
t.transaction_type,
t.amount,
t.status,
t.asset_type,
t.asset_identifier,
t.created_at,
t.balance_before,
t.balance_after,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(items.package_names, t.metadata ->> 'package_name', '') AS package_name,
COALESCE(ac.username, '') AS operator_name,
COALESCE(
CASE t.reference_type
WHEN ? THEN o.order_no
WHEN ? THEN ar.recharge_no
WHEN ? THEN rr.refund_no
WHEN ? THEN wr.withdrawal_no
WHEN ? THEN ex.exchange_no
END,
''
) AS business_order_no,
COALESCE(t.metadata ->> 'payment_method', '') AS payment_method
`, constants.ReferenceTypeOrder, constants.ReferenceTypeTopup, constants.ReferenceTypeRefund, constants.ReferenceTypeWithdrawal, constants.ReferenceTypeExchange).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = t.shop_id").
Joins("LEFT JOIN tb_account AS ac ON ac.id = COALESCE(NULLIF(t.creator, 0), t.user_id)").
Joins("LEFT JOIN tb_order AS o ON t.reference_type = ? AND o.id = t.reference_id AND o.deleted_at IS NULL", constants.ReferenceTypeOrder).
Joins("LEFT JOIN tb_agent_recharge_record AS ar ON t.reference_type = ? AND ar.id = t.reference_id AND ar.deleted_at IS NULL", constants.ReferenceTypeTopup).
Joins("LEFT JOIN tb_refund_request AS rr ON t.reference_type = ? AND rr.id = t.reference_id AND rr.deleted_at IS NULL", constants.ReferenceTypeRefund).
Joins("LEFT JOIN tb_commission_withdrawal_request AS wr ON t.reference_type = ? AND wr.id = t.reference_id AND wr.deleted_at IS NULL", constants.ReferenceTypeWithdrawal).
Joins("LEFT JOIN tb_exchange_order AS ex ON t.reference_type = ? AND ex.id = t.reference_id AND ex.deleted_at IS NULL", constants.ReferenceTypeExchange).
Joins(`LEFT JOIN LATERAL (
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
FROM tb_order_item AS oi
WHERE t.reference_type = ?
AND oi.order_id = t.reference_id
AND oi.deleted_at IS NULL
) AS items ON TRUE`, constants.ReferenceTypeOrder).
Order("t.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
paymentMethod := item.PaymentMethod
if paymentMethod == "" && item.TransactionType == constants.AgentTransactionTypeDeduct {
paymentMethod = constants.PaymentMethodWallet
}
rows = append(rows, []string{
item.ShopName,
constants.GetAgentTransactionTypeName(item.TransactionType),
formatMoneyYuan(item.Amount),
constants.GetTransactionStatusName(item.Status),
constants.GetWalletAssetTypeName(item.AssetType),
item.AssetIdentifier,
item.CreatedAt.Format(exportTimeLayout),
formatMoneyYuan(item.BalanceBefore),
formatMoneyYuan(item.BalanceAfter),
item.PackageName,
item.OperatorName,
strconv.FormatUint(uint64(item.ID), 10),
item.BusinessOrderNo,
constants.GetBusinessPaymentMethodName(paymentMethod),
})
}
return rows, nil
}
func (s *AgentWalletTransactionDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).
Table("tb_agent_wallet_transaction AS t").
Joins("INNER JOIN tb_agent_wallet AS w ON w.id = t.agent_wallet_id AND w.wallet_type = ? AND w.deleted_at IS NULL", constants.AgentWalletTypeMain).
Where("t.deleted_at IS NULL")
}
func (s *AgentWalletTransactionDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "t.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("t.shop_id = ?", shopID)
}
if transactionType, ok := filterString(params.Filters, "transaction_type"); ok {
query = query.Where("t.transaction_type = ?", transactionType)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("t.created_at >= ?", start)
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("t.created_at <= ?", end)
}
if assetIdentifier, ok := filterString(params.Filters, "asset_identifier"); ok {
query = query.Where("t.asset_identifier = ?", assetIdentifier)
}
return query
}
type agentWalletTransactionExportRow struct {
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
TransactionType string `gorm:"column:transaction_type"`
Amount int64 `gorm:"column:amount"`
Status int `gorm:"column:status"`
AssetType string `gorm:"column:asset_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
CreatedAt time.Time `gorm:"column:created_at"`
BalanceBefore int64 `gorm:"column:balance_before"`
BalanceAfter int64 `gorm:"column:balance_after"`
PackageName string `gorm:"column:package_name"`
OperatorName string `gorm:"column:operator_name"`
BusinessOrderNo string `gorm:"column:business_order_no"`
PaymentMethod string `gorm:"column:payment_method"`
}

View File

@@ -0,0 +1,176 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ExchangeDataSource 换货记录导出数据源。
type ExchangeDataSource struct {
db *gorm.DB
}
// NewExchangeDataSource 创建换货记录导出数据源。
func NewExchangeDataSource(db *gorm.DB) *ExchangeDataSource {
return &ExchangeDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *ExchangeDataSource) Scene() string {
return constants.ExportTaskSceneExchange
}
// Count 统计换货记录导出行数。
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回换货记录导出表头。
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
}, nil
}
// Fetch 按 offset/limit 查询换货记录导出数据。
func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []exchangeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
e.exchange_no,
e.flow_type,
e.exchange_reason,
COALESCE(e.remark, '') AS remark,
e.old_asset_type,
e.old_asset_identifier,
e.new_asset_identifier,
e.recipient_name,
e.recipient_phone,
e.recipient_address,
e.express_company,
e.express_no,
e.status,
e.created_at,
COALESCE(ac.username, '') AS creator_name
`).
Joins("LEFT JOIN tb_account AS ac ON ac.id = e.creator").
Order("e.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.ExchangeNo,
constants.GetExchangeFlowTypeName(item.FlowType),
item.ExchangeReason,
item.Remark,
formatExchangeAssetType(item.OldAssetType),
item.OldAssetIdentifier,
item.NewAssetIdentifier,
item.RecipientName,
item.RecipientPhone,
item.RecipientAddress,
item.ExpressCompany,
item.ExpressNo,
constants.GetExchangeStatusName(item.Status),
item.CreatorName,
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
}
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "e.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("e.status = ?", status)
}
if flowType, ok := filterString(params.Filters, "flow_type"); ok {
query = query.Where("COALESCE(NULLIF(e.flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType)
}
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("e.created_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("e.created_at <= ?", end)
}
return query
}
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {
if keyword == "" {
return query
}
like := "%" + keyword + "%"
cardIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_iot_card").Select("id").
Where("deleted_at IS NULL").
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_device").Select("id").
Where("deleted_at IS NULL").
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
prefix := "e." + side
return query.Where(
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
)
}
func filterValue(filters map[string]any, key string) string {
value, _ := filterString(filters, key)
return value
}
func formatExchangeAssetType(assetType string) string {
switch assetType {
case constants.ExchangeAssetTypeIotCard:
return "物联网卡"
case constants.ExchangeAssetTypeDevice:
return "设备"
case "":
return ""
default:
return "未知"
}
}
type exchangeExportRow struct {
ExchangeNo string `gorm:"column:exchange_no"`
FlowType string `gorm:"column:flow_type"`
ExchangeReason string `gorm:"column:exchange_reason"`
Remark string `gorm:"column:remark"`
OldAssetType string `gorm:"column:old_asset_type"`
OldAssetIdentifier string `gorm:"column:old_asset_identifier"`
NewAssetIdentifier string `gorm:"column:new_asset_identifier"`
RecipientName string `gorm:"column:recipient_name"`
RecipientPhone string `gorm:"column:recipient_phone"`
RecipientAddress string `gorm:"column:recipient_address"`
ExpressCompany string `gorm:"column:express_company"`
ExpressNo string `gorm:"column:express_no"`
Status int `gorm:"column:status"`
CreatorName string `gorm:"column:creator_name"`
CreatedAt time.Time `gorm:"column:created_at"`
}

View File

@@ -255,6 +255,18 @@ func filterTime(filters map[string]any, key string) (time.Time, bool) {
return time.Time{}, false return time.Time{}, false
} }
// filterEndDate 解析截止时间;纯日期输入覆盖到当天结束,带时分秒输入保持原值。
func filterEndDate(filters map[string]any, key string) (time.Time, bool) {
text, ok := filterString(filters, key)
if !ok {
return time.Time{}, false
}
if parsed, err := time.ParseInLocation("2006-01-02", text, time.Local); err == nil {
return parsed.Add(24*time.Hour - time.Nanosecond), true
}
return filterTime(filters, key)
}
func formatOptionalUint(value *uint) string { func formatOptionalUint(value *uint) string {
if value == nil { if value == nil {
return "" return ""

View File

@@ -2,6 +2,7 @@ package exporter
import ( import (
"context" "context"
"strconv"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -36,7 +37,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
// Headers 返回 IoT 卡导出表头。 // Headers 返回 IoT 卡导出表头。
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态"}, nil return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil
} }
// Fetch 按 offset/limit 查询 IoT 卡导出数据。 // Fetch 按 offset/limit 查询 IoT 卡导出数据。
@@ -56,7 +57,10 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
COALESCE(d.device_name, '') AS device_name, COALESCE(d.device_name, '') AS device_name,
c.real_name_status, c.real_name_status,
c.first_realname_at, c.first_realname_at,
c.network_status c.network_status,
COALESCE(pkg.package_name, '') AS package_name,
COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb,
COALESCE(pkg.data_limit_mb, 0) AS data_limit_mb
`). `).
Order("c.id ASC"). Order("c.id ASC").
Limit(limit). Limit(limit).
@@ -77,6 +81,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
formatRealNameVerified(item.RealNameStatus), formatRealNameVerified(item.RealNameStatus),
formatOptionalTime(item.FirstRealnameAt), formatOptionalTime(item.FirstRealnameAt),
constants.GetNetworkStatusName(item.NetworkStatus), constants.GetNetworkStatusName(item.NetworkStatus),
item.PackageName,
strconv.FormatInt(item.DataUsageMB, 10),
strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10),
}) })
} }
return rows, nil return rows, nil
@@ -100,6 +107,22 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB {
LIMIT 1 LIMIT 1
) AS d ON TRUE ) AS d ON TRUE
`, constants.BindStatusBound). `, constants.BindStatusBound).
Joins(`
LEFT JOIN LATERAL (
SELECT
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
pu.data_usage_mb,
pu.data_limit_mb
FROM tb_package_usage AS pu
LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL
WHERE pu.iot_card_id = c.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL
ORDER BY pu.priority ASC, pu.activated_at ASC, pu.id ASC
LIMIT 1
) AS pkg ON TRUE
`, constants.PackageUsageStatusActive).
Where("c.deleted_at IS NULL") Where("c.deleted_at IS NULL")
} }
@@ -206,6 +229,17 @@ type iotCardExportRow struct {
RealNameStatus int `gorm:"column:real_name_status"` RealNameStatus int `gorm:"column:real_name_status"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
NetworkStatus int `gorm:"column:network_status"` NetworkStatus int `gorm:"column:network_status"`
PackageName string `gorm:"column:package_name"`
DataUsageMB int64 `gorm:"column:data_usage_mb"`
DataLimitMB int64 `gorm:"column:data_limit_mb"`
}
func remainingPackageDataMB(limit, used int64) int64 {
remaining := limit - used
if remaining < 0 {
return 0
}
return remaining
} }
func formatRealNameVerified(status int) string { func formatRealNameVerified(status int) string {

View File

@@ -0,0 +1,228 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PackageDataSource 套餐导出数据源。
type PackageDataSource struct {
db *gorm.DB
}
// NewPackageDataSource 创建套餐导出数据源。
func NewPackageDataSource(db *gorm.DB) *PackageDataSource {
return &PackageDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *PackageDataSource) Scene() string {
return constants.ExportTaskScenePackage
}
// Count 统计套餐导出行数。
func (s *PackageDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx, params), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回套餐导出固定表头。
func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"套餐编码", "套餐名称", "套餐系列名称", "套餐类型", "套餐时长(月)",
"套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)",
"是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)",
"建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐",
"创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间",
}, nil
}
// Fetch 按 offset/limit 查询套餐导出数据。
func (s *PackageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []packageExportRow
query := s.applyFilters(s.baseQuery(ctx, params), params).
Joins("LEFT JOIN tb_package_series AS ps ON ps.id = p.series_id AND ps.deleted_at IS NULL").
Select(s.selectColumns(params)).
Order("p.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, buildPackageExportRow(item))
}
return rows, nil
}
func (s *PackageDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_package AS p").Where("p.deleted_at IS NULL")
switch params.UserType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if params.CreatorShopID == nil || *params.CreatorShopID == 0 {
return query.Where("1 = 0")
}
return query.
Joins(`INNER JOIN tb_shop_package_allocation AS a
ON a.package_id = p.id
AND a.shop_id = ?
AND a.status = ?
AND a.deleted_at IS NULL`, *params.CreatorShopID, constants.StatusEnabled).
Where("p.is_gift = ?", false)
default:
return query.Where("1 = 0")
}
}
func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
if packageName, ok := filterString(params.Filters, "package_name"); ok {
query = query.Where("p.package_name LIKE ?", "%"+packageName+"%")
}
if seriesID, ok := filterUint(params.Filters, "series_id"); ok {
query = query.Where("p.series_id = ?", seriesID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("p.status = ?", status)
}
if shelfStatus, ok := filterInt(params.Filters, "shelf_status"); ok {
if params.UserType == constants.UserTypeAgent {
query = query.Where("a.shelf_status = ?", shelfStatus)
} else {
query = query.Where("p.shelf_status = ?", shelfStatus)
}
}
if packageType, ok := filterString(params.Filters, "package_type"); ok {
query = query.Where("p.package_type = ?", packageType)
}
return query
}
func (s *PackageDataSource) selectColumns(params ExportParams) string {
costPriceColumn := "p.cost_price"
shelfStatusColumn := "p.shelf_status"
if params.UserType == constants.UserTypeAgent {
costPriceColumn = "a.cost_price"
shelfStatusColumn = "a.shelf_status"
}
return `
p.package_code,
p.package_name,
COALESCE(ps.series_name, '') AS series_name,
p.package_type,
p.duration_months,
p.calendar_type,
p.duration_days,
p.real_data_mb,
p.virtual_data_mb,
p.enable_virtual_data,
p.virtual_ratio,
p.data_reset_cycle,
p.expiry_base,
` + costPriceColumn + ` AS cost_price,
p.suggested_retail_price,
p.price_config_status,
p.status,
` + shelfStatusColumn + ` AS shelf_status,
p.is_gift,
p.creator,
p.updater,
p.created_at,
p.updated_at`
}
type packageExportRow struct {
PackageCode string `gorm:"column:package_code"`
PackageName string `gorm:"column:package_name"`
SeriesName string `gorm:"column:series_name"`
PackageType string `gorm:"column:package_type"`
DurationMonths int `gorm:"column:duration_months"`
CalendarType string `gorm:"column:calendar_type"`
DurationDays int `gorm:"column:duration_days"`
RealDataMB int64 `gorm:"column:real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb"`
EnableVirtualData bool `gorm:"column:enable_virtual_data"`
VirtualRatio float64 `gorm:"column:virtual_ratio"`
DataResetCycle string `gorm:"column:data_reset_cycle"`
ExpiryBase string `gorm:"column:expiry_base"`
CostPrice int64 `gorm:"column:cost_price"`
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price"`
PriceConfigStatus int `gorm:"column:price_config_status"`
Status int `gorm:"column:status"`
ShelfStatus int `gorm:"column:shelf_status"`
IsGift bool `gorm:"column:is_gift"`
Creator uint `gorm:"column:creator"`
Updater uint `gorm:"column:updater"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func buildPackageExportRow(item packageExportRow) []string {
return []string{
item.PackageCode,
item.PackageName,
item.SeriesName,
constants.GetPackageTypeName(item.PackageType),
strconv.Itoa(item.DurationMonths),
formatPackageDurationDescription(item),
constants.GetPackageCalendarTypeName(item.CalendarType),
strconv.Itoa(item.DurationDays),
strconv.FormatInt(item.RealDataMB, 10),
strconv.FormatInt(item.VirtualDataMB, 10),
formatYesNo(item.EnableVirtualData),
strconv.FormatFloat(item.VirtualRatio, 'f', 6, 64),
constants.GetPackageDataResetCycleName(item.DataResetCycle),
constants.GetPackageExpiryBaseName(item.ExpiryBase),
formatMoneyYuan(item.CostPrice),
formatPackageSuggestedRetailPrice(item),
constants.GetPackagePriceConfigStatusName(item.PriceConfigStatus),
constants.GetStatusName(item.Status),
constants.GetShelfStatusName(item.ShelfStatus),
formatYesNo(item.IsGift),
strconv.FormatUint(uint64(item.Creator), 10),
strconv.FormatUint(uint64(item.Updater), 10),
item.CreatedAt.Format(exportTimeLayout),
item.UpdatedAt.Format(exportTimeLayout),
"",
}
}
func formatPackageDurationDescription(item packageExportRow) string {
if item.CalendarType == constants.PackageCalendarTypeByDay && item.DurationDays > 0 {
return strconv.Itoa(item.DurationDays) + "天"
}
if item.DurationMonths > 0 {
return strconv.Itoa(item.DurationMonths) + "个月"
}
return ""
}
func formatPackageSuggestedRetailPrice(item packageExportRow) string {
if item.PriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
return ""
}
return formatMoneyYuan(item.SuggestedRetailPrice)
}
func formatYesNo(value bool) string {
if value {
return "是"
}
return "否"
}

View File

@@ -0,0 +1,206 @@
package exporter
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// RefundDataSource 退款记录导出数据源。
type RefundDataSource struct {
db *gorm.DB
}
// NewRefundDataSource 创建退款记录导出数据源。
func NewRefundDataSource(db *gorm.DB) *RefundDataSource {
return &RefundDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *RefundDataSource) Scene() string {
return constants.ExportTaskSceneRefund
}
// Count 统计退款记录导出行数。
func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回退款记录导出表头。
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
}, nil
}
// Fetch 按 offset/limit 查询退款记录导出数据。
func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []refundExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Select(`
r.refund_no,
r.order_no,
r.order_type,
r.asset_identifier,
r.actual_received_amount,
r.requested_refund_amount,
r.approved_refund_amount,
r.status,
r.refund_reason,
r.remark,
r.commission_deducted,
r.asset_reset,
r.created_at,
r.processed_at,
COALESCE(sh.shop_name, '') AS shop_name,
o.total_amount AS original_amount,
o.actual_paid_amount AS refundable_amount,
COALESCE(pu.package_name, items.package_names, '') AS package_name,
COALESCE(ac.username, '') AS submitter_name,
ai.provider AS approval_provider,
ai.status AS approval_status,
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
FROM jsonb_array_elements_text(COALESCE(r.refund_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
Joins("LEFT JOIN tb_order AS o ON o.id = r.order_id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_package_usage AS pu ON pu.id = r.package_usage_id AND pu.deleted_at IS NULL").
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.creator").
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
Joins(`LEFT JOIN LATERAL (
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
FROM tb_order_item AS oi
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
) AS items ON TRUE`).
Order("r.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.RefundNo,
item.ShopName,
item.OrderNo,
formatRefundAssetType(item.OrderType),
item.AssetIdentifier,
item.PackageName,
formatOptionalMoneyYuan(item.OriginalAmount),
formatMoneyYuan(item.ActualReceivedAmount),
formatOptionalMoneyYuan(item.RefundableAmount),
formatMoneyYuan(item.RequestedRefundAmount),
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
constants.GetRefundStatusName(item.Status),
item.RefundReason,
item.Remark,
formatRefundApprovalSource(item.ApprovalProvider),
formatOptionalApprovalStatus(item.ApprovalStatus),
formatRefundProcessingStatus(item.Status, item.CommissionDeducted, item.AssetReset),
item.CreatedAt.Format(exportTimeLayout),
formatOptionalTime(item.ProcessedAt),
item.SubmitterName,
item.VoucherKeys,
})
}
return rows, nil
}
func (s *RefundDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_refund_request AS r").Where("r.deleted_at IS NULL")
}
func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "r.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if orderID, ok := filterUint(params.Filters, "order_id"); ok {
query = query.Where("r.order_id = ?", orderID)
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
}
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
query = query.Where("r.asset_identifier = ?", identifier)
}
return query
}
type refundExportRow struct {
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
Status int `gorm:"column:status"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRefundAssetType(orderType string) string {
switch orderType {
case model.OrderTypeSingleCard:
return "物联网卡"
case model.OrderTypeDevice:
return "设备"
case "":
return ""
default:
return "未知"
}
}
func formatRefundApprovalSource(provider *string) string {
if provider == nil || *provider == "" {
return "历史审批"
}
return formatApprovalProvider(provider)
}
func formatRefundProcessingStatus(status int, commissionDeducted, assetReset bool) string {
switch status {
case model.RefundStatusPending, model.RefundStatusReturned:
return "待审批"
case model.RefundStatusRejected:
return "无需处理"
case model.RefundStatusApproved:
if commissionDeducted && assetReset {
return "已完成"
}
return "处理中"
default:
return "未知"
}
}

View File

@@ -31,6 +31,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewDeviceDataSource(db), NewDeviceDataSource(db),
NewIotCardDataSource(db), NewIotCardDataSource(db),
NewOrderDataSource(db), NewOrderDataSource(db),
NewPackageDataSource(db),
NewAgentWalletTransactionDataSource(db),
NewAgentRechargeDataSource(db),
NewRefundDataSource(db),
NewExchangeDataSource(db),
) )
} }
@@ -59,7 +64,14 @@ func (r *Registry) Scenes() []string {
// IsSupportedScene 判断是否为受支持的场景。 // IsSupportedScene 判断是否为受支持的场景。
func IsSupportedScene(scene string) bool { func IsSupportedScene(scene string) bool {
switch scene { switch scene {
case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneOrder: case constants.ExportTaskSceneDevice,
constants.ExportTaskSceneIotCard,
constants.ExportTaskSceneOrder,
constants.ExportTaskScenePackage,
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange:
return true return true
default: default:
return false return false

View File

@@ -27,14 +27,6 @@ func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfo
return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req) return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req)
} }
// SetSpeedLimit 设置设备限速
// 设置设备的统一限速值(单位 KB/s
// POST /device/speed-limit
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
_, err := c.doRequest(ctx, "/device/speed-limit", req)
return err
}
// SetWiFi 设置设备 WiFi // SetWiFi 设置设备 WiFi
// Gateway 实际要求 cardNo 传设备 IMEI并搭配内层 params 下发 WiFi 名称和密码 // Gateway 实际要求 cardNo 传设备 IMEI并搭配内层 params 下发 WiFi 名称和密码
// POST /device/wifi-config // POST /device/wifi-config

View File

@@ -45,6 +45,19 @@ func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*Real
return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req) return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req)
} }
// SetCardSpeedTier 设置或恢复流量卡固定限速档位。
// POST /flow-card/speedLimit
func (c *Client) SetCardSpeedTier(ctx context.Context, req *CardSpeedTierReq) error {
if req == nil || req.CardNo == "" || req.Code == "" {
return errors.New(errors.CodeInvalidParam, "流量卡号和限速档位不能为空")
}
// 限速是外部状态写入,网络错误或超时后实际结果不可确定,禁止沿用查询接口的自动重试。
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/speedLimit", req)
return err
}
// BatchQuery 批量查询(预留接口,暂未实现) // BatchQuery 批量查询(预留接口,暂未实现)
func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) { func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) {
return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现") return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现")

View File

@@ -187,12 +187,10 @@ type DeviceInfoResp struct {
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"` Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
} }
// SpeedLimitReq 是设置设备限速的请求 // CardSpeedTierReq 是流量卡固定限速档位请求
type SpeedLimitReq struct { type CardSpeedTierReq struct {
CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"` CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡 ICCID"`
DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI与 CardNo 二选一)"` Code string `json:"code" validate:"required" required:"true" description:"限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"`
SpeedLimit int `json:"speedLimit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值KB/s"`
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
} }
// WiFiParams 是设置设备 WiFi 的内层参数 // WiFiParams 是设置设备 WiFi 的内层参数

View File

@@ -77,6 +77,24 @@ func (h *AccountHandler) Update(c *fiber.Ctx) error {
return response.Success(c, account) return response.Success(c, account)
} }
// BindWeCom 绑定账号与企业微信应用可见成员。
// PUT /api/admin/accounts/:id/wecom-binding
func (h *AccountHandler) BindWeCom(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var request dto.BindAccountWeComRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BindWeCom(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 删除账号 // Delete 删除账号
// DELETE /api/admin/accounts/:id // DELETE /api/admin/accounts/:id
func (h *AccountHandler) Delete(c *fiber.Ctx) error { func (h *AccountHandler) Delete(c *fiber.Ctx) error {

View File

@@ -10,6 +10,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto" dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device" deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
@@ -17,8 +18,10 @@ import (
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/pkg/response"
"go.uber.org/zap"
) )
// AssetHandler 资产管理处理器 // AssetHandler 资产管理处理器
@@ -33,6 +36,7 @@ type AssetHandler struct {
assetLifecycleService AssetLifecycleService assetLifecycleService AssetLifecycleService
exchangeTraceQuery AssetExchangeTraceResolver exchangeTraceQuery AssetExchangeTraceResolver
observationSeries cardObservationApp.BestEffortSeriesDispatcher observationSeries cardObservationApp.BestEffortSeriesDispatcher
packageExpiryQuery *packageExpiryQuery.Query
} }
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。 // SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
@@ -40,6 +44,11 @@ func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservation
h.observationSeries = dispatcher h.observationSeries = dispatcher
} }
// SetPackageExpiryQuery 注入临期资产分页查询。
func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
h.packageExpiryQuery = query
}
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 // AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
type AssetExchangeTraceResolver interface { type AssetExchangeTraceResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
@@ -105,6 +114,27 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
// ListExpiring 查询当前权限范围内的临期资产列表和数量汇总。
// GET /api/admin/expiring-assets
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
var request dto.ExpiringAssetListRequest
if err := c.QueryParser(&request); err != nil {
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
if h.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, dto.ExpiringAssetListResponse{
Items: result.Items, Total: result.Total, Page: result.Page, Size: result.Size, Summary: result.Summary,
})
}
// RealtimeStatus 获取资产实时状态 // RealtimeStatus 获取资产实时状态
// GET /api/admin/assets/:identifier/realtime-status // GET /api/admin/assets/:identifier/realtime-status
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
@@ -463,6 +493,10 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
// UpdateRealnamePolicy 更新资产实名认证策略 // UpdateRealnamePolicy 更新资产实名认证策略
// PATCH /api/admin/assets/:identifier/realname-mode // PATCH /api/admin/assets/:identifier/realname-mode
func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error { func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权修改资产实名认证策略")
}
identifier := c.Params("identifier") identifier := c.Params("identifier")
if identifier == "" { if identifier == "" {
return errors.New(errors.CodeInvalidParam) return errors.New(errors.CodeInvalidParam)

View File

@@ -0,0 +1,92 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
batchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetPackageBatchOrderHandler 资产套餐批量订购 Handler。
type AssetPackageBatchOrderHandler struct {
service *batchOrderSvc.Service
validator *validator.Validate
}
// NewAssetPackageBatchOrderHandler 创建资产套餐批量订购 Handler。
func NewAssetPackageBatchOrderHandler(service *batchOrderSvc.Service, validator *validator.Validate) *AssetPackageBatchOrderHandler {
return &AssetPackageBatchOrderHandler{service: service, validator: validator}
}
// Create 创建资产套餐批量订购任务。
// POST /api/admin/asset-package-batch-orders
func (h *AssetPackageBatchOrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAssetPackageBatchOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userType := middleware.GetUserTypeFromContext(c.UserContext())
if req.PaymentMethod == model.PaymentMethodOffline && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
}
if req.PaymentMethod == model.PaymentMethodWallet && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "无权创建批量订购任务")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询资产套餐批量订购任务列表。
// GET /api/admin/asset-package-batch-orders
func (h *AssetPackageBatchOrderHandler) List(c *fiber.Ctx) error {
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
}
var req dto.ListAssetPackageBatchOrderRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询资产套餐批量订购任务详情。
// GET /api/admin/asset-package-batch-orders/:id
func (h *AssetPackageBatchOrderHandler) Get(c *fiber.Ctx) error {
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "任务ID无效")
}
result, err := h.service.GetByID(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
func canAccessAssetPackageBatchOrders(userType int) bool {
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform || userType == constants.UserTypeAgent
}

View File

@@ -35,6 +35,20 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
} }
// BatchUpdateRealnamePolicy 批量更新设备实名认证策略。
// POST /api/admin/devices/batch-update-realname-policy
func (h *DeviceHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
var req dto.BatchUpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Delete(c *fiber.Ctx) error { func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext()) userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
@@ -226,26 +240,6 @@ func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
return response.Success(c, resp) return response.Success(c, resp)
} }
// SetSpeedLimit 设置设备限速
// PUT /api/admin/devices/by-identifier/:identifier/speed-limit
func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
}
var req dto.SetSpeedLimitRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.GatewaySetSpeedLimit(c.UserContext(), identifier, &req); err != nil {
return err
}
return response.Success(c, nil)
}
// SetWiFi 设置设备 WiFi // SetWiFi 设置设备 WiFi
// PUT /api/admin/devices/by-identifier/:identifier/wifi // PUT /api/admin/devices/by-identifier/:identifier/wifi
func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error { func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {

View File

@@ -46,6 +46,20 @@ func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
// CreateAllocation 创建单列 CSV 设备批量分配任务。
// POST /api/admin/devices/import/allocations
func (h *DeviceImportHandler) CreateAllocation(c *fiber.Ctx) error {
var req dto.CreateDeviceBatchAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.CreateBatchAllocationTask(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceImportHandler) List(c *fiber.Ctx) error { func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext()) userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {

View File

@@ -35,6 +35,38 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
} }
// BatchUpdateRealnamePolicy 批量更新卡实名认证策略。
// POST /api/admin/iot-cards/batch-update-realname-policy
func (h *IotCardHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
var req dto.BatchUpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// SetSpeedTier 设置或恢复 IoT 卡固定限速档位。
// PUT /api/admin/iot-cards/:iccid/speed-tier
func (h *IotCardHandler) SetSpeedTier(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID 不能为空")
}
var req dto.SetIotCardSpeedTierRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.SetSpeedTier(c.UserContext(), iccid, req.Code)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error { func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
var req dto.AllocateStandaloneCardsRequest var req dto.AllocateStandaloneCardsRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {

View File

@@ -0,0 +1,197 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// WeComHandler 提供企业微信自建应用连接配置接口。
type WeComHandler struct {
service *wecomapp.ConnectionService
directory *wecomapp.DirectoryService
scenes *wecomapp.SceneService
validator *validator.Validate
}
// SetSceneService 注入企业微信审批场景配置用例。
func (h *WeComHandler) SetSceneService(service *wecomapp.SceneService) {
h.scenes = service
}
// NewWeComHandler 创建企业微信连接配置 Handler。
func NewWeComHandler(service *wecomapp.ConnectionService, validate *validator.Validate) *WeComHandler {
return &WeComHandler{service: service, validator: validate}
}
// SetDirectoryService 注入企业微信通讯录同步用例。
func (h *WeComHandler) SetDirectoryService(service *wecomapp.DirectoryService) {
h.directory = service
}
// Save 创建或更新企业微信应用连接配置。
// POST /api/admin/wecom/applications
func (h *WeComHandler) Save(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
var request dto.SaveWeComApplicationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Save(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询企业微信应用连接配置。
// GET /api/admin/wecom/applications
func (h *WeComHandler) List(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
var request dto.WeComApplicationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// Test 测试指定企业微信应用能否成功取得 access_token。
// POST /api/admin/wecom/applications/:id/test
func (h *WeComHandler) Test(c *fiber.Ctx) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
if err := h.service.Test(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, dto.WeComConnectionTestResponse{Success: true})
}
// SaveDefaultCreator 保存应用默认审批发起人。
// PUT /api/admin/wecom/applications/:id/default-creator
func (h *WeComHandler) SaveDefaultCreator(c *fiber.Ctx) error {
if h == nil || h.service == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.SaveWeComDefaultCreatorRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.SaveDefaultCreator(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SyncMembers 同步指定应用当前可见成员。
// POST /api/admin/wecom/applications/:id/members/sync
func (h *WeComHandler) SyncMembers(c *fiber.Ctx) error {
if h == nil || h.directory == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
result, err := h.directory.Sync(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListMembers 分页查询指定应用最近同步的可见成员。
// GET /api/admin/wecom/applications/:id/members
func (h *WeComHandler) ListMembers(c *fiber.Ctx) error {
if h == nil || h.directory == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.WeComMemberListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.directory.List(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SaveScene 保存并校验稳定业务类型的企微模板控件映射。
// PUT /api/admin/wecom/scenes/:business_type
func (h *WeComHandler) SaveScene(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
var request dto.SaveWeComApprovalSceneRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.Save(c.UserContext(), c.Params("business_type"), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListScenes 分页查询企业微信审批场景当前配置。
// GET /api/admin/wecom/scenes
func (h *WeComHandler) ListScenes(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
var request dto.WeComApprovalSceneListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.List(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -15,6 +15,7 @@ import (
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice" "github.com/break/junhong_cmp_fiber/internal/service/packageprice"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -39,6 +40,21 @@ type ClientAssetHandler struct {
db *gorm.DB db *gorm.DB
logger *zap.Logger logger *zap.Logger
observationSeries cardObservationApp.BestEffortSeriesDispatcher observationSeries cardObservationApp.BestEffortSeriesDispatcher
paymentMethodPolicy ClientPaymentMethodPolicy
forceRechargeChecker ClientForceRechargeChecker
}
// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。
type ClientPaymentMethodPolicy interface {
AllowedMethods(ctx context.Context, assetType string) ([]string, error)
AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error)
EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error
EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error
}
// ClientForceRechargeChecker 提供 C 端资产当前强充资格的统一判定。
type ClientForceRechargeChecker interface {
GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*rechargeSvc.ForceRechargeRequirement, error)
} }
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。 // SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
@@ -46,6 +62,16 @@ func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObser
h.observationSeries = dispatcher h.observationSeries = dispatcher
} }
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (h *ClientAssetHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
h.paymentMethodPolicy = policy
}
// SetForceRechargeChecker 注入复用现有一次性佣金状态语义的强充判定服务。
func (h *ClientAssetHandler) SetForceRechargeChecker(checker ClientForceRechargeChecker) {
h.forceRechargeChecker = checker
}
// NewClientAssetHandler 创建 C 端资产信息处理器 // NewClientAssetHandler 创建 C 端资产信息处理器
func NewClientAssetHandler( func NewClientAssetHandler(
assetService *asset.Service, assetService *asset.Service,
@@ -158,6 +184,34 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
currentPackageID, err := h.getCurrentPackageID(resolved.SkipPermissionCtx, resolved.Asset.CurrentPackageUsageID)
if err != nil {
return err
}
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
if h.forceRechargeChecker == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
resourceType := resolved.Asset.AssetType
if resourceType == "card" {
resourceType = constants.ResourceTypeIotCard
}
forceRecharge, err := h.forceRechargeChecker.GetRechargeCheck(resolved.SkipPermissionCtx, resourceType, resolved.Asset.AssetID)
if err != nil {
return err
}
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
if forceRecharge.NeedForceRecharge {
allowedPaymentMethods, err = h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
}
phone, _ := middleware.GetCustomerPhone(c) phone, _ := middleware.GetCustomerPhone(c)
@@ -172,11 +226,16 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
StatusName: resolved.Asset.StatusName, StatusName: resolved.Asset.StatusName,
RealNameStatus: resolved.Asset.RealNameStatus, RealNameStatus: resolved.Asset.RealNameStatus,
RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus), RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus),
RealnamePolicy: resolved.Asset.RealnamePolicy,
EffectiveRealnamePolicy: resolved.Asset.RealnamePolicy,
RealnameRequired: resolved.Asset.RealnamePolicy != constants.RealnamePolicyNone,
CarrierName: resolved.Asset.CarrierName, CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation), Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance, WalletBalance: resolved.WalletBalance,
AllowedPaymentMethods: allowedPaymentMethods,
ActivatedAt: resolved.Asset.ActivatedAt, ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage, CurrentPackage: resolved.Asset.CurrentPackage,
CurrentPackageID: currentPackageID,
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID, CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt, CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt,
CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt, CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt,
@@ -226,6 +285,20 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
return response.Success(c, resp) return response.Success(c, resp)
} }
func (h *ClientAssetHandler) getCurrentPackageID(ctx context.Context, usageID *uint) (uint, error) {
if usageID == nil || *usageID == 0 {
return 0, nil
}
var usage model.PackageUsage
if err := h.db.WithContext(ctx).Select("package_id").First(&usage, *usageID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐引用失败")
}
return usage.PackageID, nil
}
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) { func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
if h.observationSeries == nil || asset == nil { if h.observationSeries == nil || asset == nil {
return return
@@ -303,6 +376,7 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
}, map[string]any{ }, map[string]any{
"series_id": *resolved.Asset.SeriesID, "series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled, "status": constants.StatusEnabled,
"shelf_status": constants.ShelfStatusOn,
}) })
if err != nil { if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败") return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")

View File

@@ -45,6 +45,12 @@ type ClientWalletHandler struct {
db *gorm.DB db *gorm.DB
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
paymentMethodPolicy ClientPaymentMethodPolicy
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
h.paymentMethodPolicy = policy
} }
// NewClientWalletHandler 创建 C 端钱包处理器 // NewClientWalletHandler 创建 C 端钱包处理器
@@ -243,6 +249,13 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
resp := &dto.ClientRechargeCheckResponse{ resp := &dto.ClientRechargeCheckResponse{
NeedForceRecharge: check.NeedForceRecharge, NeedForceRecharge: check.NeedForceRecharge,
@@ -251,6 +264,7 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
MinAmount: check.MinAmount, MinAmount: check.MinAmount,
MaxAmount: check.MaxAmount, MaxAmount: check.MaxAmount,
Message: check.Message, Message: check.Message,
AllowedPaymentMethods: allowedPaymentMethods,
} }
return response.Success(c, resp) return response.Success(c, resp)
@@ -273,6 +287,12 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 { if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname) return errors.New(errors.CodeNeedRealname)
} }
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
if err := h.paymentMethodPolicy.EnsureRechargeAllowed(resolved.SkipPermissionCtx, resolved.Asset.AssetType, req.PaymentMethod); err != nil {
return err
}
wallet, err := h.getOrCreateWallet(resolved) wallet, err := h.getOrCreateWallet(resolved)
if err != nil { if err != nil {
@@ -302,9 +322,10 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
switch req.PaymentMethod { switch req.PaymentMethod {
case constants.RechargeMethodAlipay: case constants.RechargeMethodAlipay:
return h.createAlipayRecharge(c, resolved, config, wallet, req) return h.createAlipayRecharge(c, resolved, config, wallet, req)
default: case constants.RechargeMethodWechat:
// 微信充值默认app_type 必填
return h.createWechatRecharge(c, resolved, config, wallet, req) return h.createWechatRecharge(c, resolved, config, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
} }
} }
@@ -585,7 +606,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
}, nil }, nil
} }
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType { switch assetType {
case "card": case "card":

View File

@@ -0,0 +1,61 @@
package callback
import (
"strconv"
"github.com/gofiber/fiber/v2"
wecominfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WeComApprovalHandler 提供企业微信审批加密回调入口。
type WeComApprovalHandler struct {
service *wecominfra.CallbackService
}
// NewWeComApprovalHandler 创建企业微信审批回调 Handler。
func NewWeComApprovalHandler(service *wecominfra.CallbackService) *WeComApprovalHandler {
return &WeComApprovalHandler{service: service}
}
// Verify 验证企业微信审批回调 URL。
// GET /api/callback/wecom/approval/:application_id
func (h *WeComApprovalHandler) Verify(c *fiber.Ctx) error {
applicationID, err := parseWeComApplicationID(c.Params("application_id"))
if err != nil {
return err
}
plaintext, err := h.service.VerifyURL(
c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Query("echostr"),
)
if err != nil {
return err
}
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
return c.Send(plaintext)
}
// Receive 接收企业微信审批状态变化事件并快速返回 success。
// POST /api/callback/wecom/approval/:application_id
func (h *WeComApprovalHandler) Receive(c *fiber.Ctx) error {
applicationID, err := parseWeComApplicationID(c.Params("application_id"))
if err != nil {
return err
}
if err := h.service.Receive(
c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Body(),
); err != nil {
return err
}
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
return c.SendString("success")
}
func parseWeComApplicationID(value string) (uint, error) {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
return uint(id), nil
}

View File

@@ -0,0 +1,49 @@
// Package exchange 提供换货业务对公共基础设施的 Adapter。
package exchange
import (
"context"
"fmt"
"strconv"
"gorm.io/gorm"
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ShippingNotificationWriter 将物流换货创建事实转换为个人客户通知 Outbox。
type ShippingNotificationWriter struct {
outbox *outbox.Repository
}
// NewShippingNotificationWriter 创建物流换货通知 Outbox Adapter。
func NewShippingNotificationWriter(repository *outbox.Repository) *ShippingNotificationWriter {
return &ShippingNotificationWriter{outbox: repository}
}
// Append 在换货业务事务中追加稳定且可重复提交的个人客户通知事件。
func (w *ShippingNotificationWriter) Append(ctx context.Context, tx *gorm.DB, event exchangeapp.ShippingCreatedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "物流换货通知 Outbox Writer 未配置")
}
exchangeID := strconv.FormatUint(uint64(event.ExchangeID), 10)
assetID := strconv.FormatUint(uint64(event.AssetID), 10)
eventID := fmt.Sprintf("exchange-created:%d:%d", event.ExchangeID, event.CustomerID)
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "exchange", AggregateID: exchangeID,
ResourceType: event.AssetType, ResourceID: assetID,
BusinessKey: eventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: notificationapp.PersonalCustomerDirectPayload{
RecipientID: event.CustomerID, NotificationType: constants.NotificationTypeExchangeShippingCreated,
TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset,
RefID: assetID, RefKey: event.AssetIdentifier,
},
})
return err
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/queue"
) )
// DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。 // DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。
@@ -63,11 +62,16 @@ func Permanent(err error) error {
// QueuePublisher 使用项目统一队列客户端发布结构化信封。 // QueuePublisher 使用项目统一队列客户端发布结构化信封。
type QueuePublisher struct { type QueuePublisher struct {
client *queue.Client client TaskEnqueuer
}
// TaskEnqueuer 定义 Outbox Relay 所需的最小队列提交能力,避免基础设施包反向依赖队列处理器装配。
type TaskEnqueuer interface {
EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error
} }
// NewQueuePublisher 创建项目统一队列客户端 Adapter。 // NewQueuePublisher 创建项目统一队列客户端 Adapter。
func NewQueuePublisher(client *queue.Client) *QueuePublisher { func NewQueuePublisher(client TaskEnqueuer) *QueuePublisher {
return &QueuePublisher{client: client} return &QueuePublisher{client: client}
} }

View File

@@ -70,6 +70,28 @@ func NewRegistry() *Registry {
constants.NotificationRefTypeAsset: {}, constants.NotificationRefTypeAsset: {},
}, },
}, },
constants.NotificationTypeExchangeShippingCreated: {
Type: constants.NotificationTypeExchangeShippingCreated, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "换货申请待处理",
BodyTemplate: "您有一条物流换货申请待处理,请及时填写收货信息。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
constants.NotificationTypeAgentMainWalletLowBalance: {
Type: constants.NotificationTypeAgentMainWalletLowBalance, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "店铺主钱包余额不足",
BodyTemplate: "店铺主钱包余额低于 100 元,请及时关注。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeShopFund: {},
},
},
}} }}
} }

View File

@@ -0,0 +1,141 @@
// Package packageexpiry 提供套餐临期提醒的 PostgreSQL 与 Outbox Adapter。
package packageexpiry
import (
"context"
"fmt"
"strconv"
"time"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。
type ReminderPublisher struct {
db *gorm.DB
outbox *outbox.Repository
}
// NewReminderPublisher 创建套餐临期通知 Outbox Adapter。
func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderPublisher {
return &ReminderPublisher{db: db, outbox: repository}
}
type recipientRow struct {
AssetID uint
CustomerID uint
}
// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。
func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error {
if p == nil || p.db == nil || p.outbox == nil {
return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置")
}
recipients, err := p.loadRecipients(ctx, candidates)
if err != nil {
return err
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, candidate := range candidates {
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil {
continue
}
key := recipientKey(candidate.AssetType, candidate.AssetID)
for customerID := range recipients[key] {
if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil {
return err
}
}
}
return nil
})
}
func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) {
cardIDs := make([]uint, 0)
deviceIDs := make([]uint, 0)
for _, candidate := range candidates {
if candidate.AssetType == constants.AssetTypeIotCard {
cardIDs = append(cardIDs, candidate.AssetID)
} else if candidate.AssetType == constants.AssetTypeDevice {
deviceIDs = append(deviceIDs, candidate.AssetID)
}
}
result := make(map[string]map[uint]struct{})
queries := []struct {
assetType string
ids []uint
sql string
}{
{constants.AssetTypeIotCard, cardIDs, `
SELECT c.id AS asset_id, b.customer_id
FROM tb_iot_card c
JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
{constants.AssetTypeIotCard, cardIDs, `
SELECT c.id AS asset_id, b.customer_id
FROM tb_iot_card c
JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19))
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
{constants.AssetTypeDevice, deviceIDs, `
SELECT d.id AS asset_id, b.customer_id
FROM tb_device d
JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END
WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
}
for _, query := range queries {
if len(query.ids) == 0 {
continue
}
var rows []recipientRow
if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败")
}
for _, row := range rows {
key := recipientKey(query.assetType, row.AssetID)
if result[key] == nil {
result[key] = make(map[uint]struct{})
}
result[key][row.CustomerID] = struct{}{}
}
}
return result, nil
}
func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID uint) error {
node := *candidate.DaysUntilFinalExpiry
expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102")
assetCode := "c"
if candidate.AssetType == constants.AssetTypeDevice {
assetCode = "d"
}
eventID := fmt.Sprintf("pex:%s:%d:%s:%d:%d", assetCode, candidate.AssetID, expiryDate, node, customerID)
assetID := strconv.FormatUint(uint64(candidate.AssetID), 10)
_, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10),
ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID,
Payload: notificationapp.PersonalCustomerDirectPayload{
RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring,
TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset,
RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败")
}
return nil
}
func recipientKey(assetType string, assetID uint) string {
return fmt.Sprintf("%s:%d", assetType, assetID)
}

View File

@@ -96,6 +96,44 @@ func (r *Reader) Get(ctx context.Context, key string) (string, error) {
return value, nil return value, nil
} }
// GetStrict 严格读取单个已注册配置;已落库的非法值或数据库读取失败不会回退默认值。
// 该入口用于支付方式等必须失败关闭的关键配置,未落库时仍返回代码注册的首次初始化默认值。
func (r *Reader) GetStrict(ctx context.Context, key string) (string, error) {
definition, registered := r.registry.Get(key)
if !registered {
return "", stderrors.New("系统配置 Key 未注册")
}
cacheKey := constants.RedisSystemConfigKey(key)
if r.cache != nil {
if value, err := r.cache.Get(ctx, cacheKey); err == nil {
if ValidateValue(definition, value) == nil {
return value, nil
}
r.warn(ctx, "SYSTEM_CONFIG_CACHE_INVALID", key, "关键系统配置缓存值非法,已回源 PostgreSQL")
} else if err != redis.Nil {
r.warn(ctx, "SYSTEM_CONFIG_CACHE_READ_FAILED", key, "关键系统配置缓存读取失败,已回源 PostgreSQL")
}
}
var record model.SystemConfig
err := r.db.WithContext(ctx).Where("config_key = ?", key).First(&record).Error
if err == gorm.ErrRecordNotFound {
return definition.DefaultValue, nil
}
if err != nil {
return "", err
}
if ValidateValue(definition, record.ConfigValue) != nil {
r.warn(ctx, "SYSTEM_CONFIG_DATABASE_VALUE_INVALID", key, "关键系统配置数据库值非法,已失败关闭")
return "", stderrors.New("系统配置数据库值非法")
}
if r.cache != nil {
if err := r.cache.Set(ctx, cacheKey, record.ConfigValue, constants.SystemConfigCacheTTL); err != nil {
r.warn(ctx, "SYSTEM_CONFIG_CACHE_WRITE_FAILED", key, "关键系统配置缓存回填失败")
}
}
return record.ConfigValue, nil
}
// ListItem 是查询层组装 DTO 所需的稳定投影。 // ListItem 是查询层组装 DTO 所需的稳定投影。
type ListItem struct { type ListItem struct {
Definition Definition Definition Definition

View File

@@ -28,6 +28,7 @@ type Definition struct {
EnumValues []string EnumValues []string
Min *int64 Min *int64
Max *int64 Max *int64
Validator func(string) error
} }
// Registry 保存可写配置的代码权威定义。 // Registry 保存可写配置的代码权威定义。
@@ -127,13 +128,20 @@ func ValidateValue(definition Definition, value string) error {
return stderrors.New("系统配置类型不受支持") return stderrors.New("系统配置类型不受支持")
} }
if len(definition.EnumValues) > 0 { if len(definition.EnumValues) > 0 {
matched := false
for _, allowed := range definition.EnumValues { for _, allowed := range definition.EnumValues {
if value == allowed { if value == allowed {
return nil matched = true
break
} }
} }
if !matched {
return stderrors.New("系统配置值不在允许枚举中") return stderrors.New("系统配置值不在允许枚举中")
} }
}
if definition.Validator != nil {
return definition.Validator(value)
}
return nil return nil
} }

View File

@@ -2,10 +2,12 @@ package wallet
import ( import (
"context" "context"
"strconv"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"gorm.io/gorm" "gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -17,16 +19,17 @@ import (
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。 // 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
type DebitEventConsumer struct { type DebitEventConsumer struct {
db *gorm.DB db *gorm.DB
outbox *outbox.Repository
} }
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。 // NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer { func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
return &DebitEventConsumer{db: db} return &DebitEventConsumer{db: db, outbox: outbox.NewRepository()}
} }
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。 // Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil { if c == nil || c.db == nil || c.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置") return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
} }
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited || if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
@@ -37,14 +40,21 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil { if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析") return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析")
} }
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 || if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 || event.ReferenceID == 0 ||
event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct || event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct ||
event.Amount <= 0 { event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整") return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整")
} }
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return c.consumeInTx(ctx, tx, event)
})
}
// consumeInTx 复核权威扣款流水,并在余额首次跨过固定阈值时追加业务员通知事件。
func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
var transaction model.AgentWalletTransaction var transaction model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped(). err := tx.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess). event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&transaction).Error First(&transaction).Error
@@ -58,5 +68,57 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive
transaction.BalanceAfter != event.BalanceAfter { transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致") return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
} }
if event.BalanceBefore < constants.AgentMainWalletLowBalanceThreshold ||
event.BalanceAfter >= constants.AgentMainWalletLowBalanceThreshold {
return nil return nil
} }
recipientID, err := resolveBusinessOwnerAccountID(ctx, tx, event.ShopID)
if err != nil {
return err
}
if recipientID == 0 {
return nil
}
shopID := strconv.FormatUint(uint64(event.ShopID), 10)
notificationEventID := "wallet-low:order:" + strconv.FormatUint(uint64(event.ReferenceID), 10)
_, err = c.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDirectNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: constants.NotificationRefTypeShopFund, ResourceID: shopID,
BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: notificationapp.AdminDirectPayload{
RecipientID: recipientID, NotificationType: constants.NotificationTypeAgentMainWalletLowBalance,
RefType: constants.NotificationRefTypeShopFund, RefID: shopID,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入主钱包低余额通知事件失败")
}
return nil
}
// resolveBusinessOwnerAccountID 返回店铺当前启用的平台业务员账号;无有效归属时返回零值。
func resolveBusinessOwnerAccountID(ctx context.Context, tx *gorm.DB, shopID uint) (uint, error) {
var shop model.Shop
err := tx.WithContext(ctx).Select("business_owner_account_id").Where("id = ?", shopID).Take(&shop).Error
if err == gorm.ErrRecordNotFound || shop.BusinessOwnerAccountID == nil {
return 0, nil
}
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员归属失败")
}
var account model.Account
err = tx.WithContext(ctx).Select("id").
Where("id = ? AND status = ? AND user_type = ?", *shop.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform).
Take(&account).Error
if err == gorm.ErrRecordNotFound {
return 0, nil
}
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺业务员账号失败")
}
return account.ID, nil
}

View File

@@ -0,0 +1,109 @@
package wecom
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApplicationRepository 持久化企业微信应用配置。
type ApplicationRepository struct {
db *gorm.DB
}
// NewApplicationRepository 创建企业微信应用 Repository。
func NewApplicationRepository(db *gorm.DB) *ApplicationRepository {
return &ApplicationRepository{db: db}
}
// FindByIdentityForUpdate 在事务中锁定企业与应用唯一配置。
func (r *ApplicationRepository) FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error) {
var application model.WeComApplication
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("corp_id = ? AND agent_id = ?", corpID, agentID).First(&application).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败")
}
return &application, nil
}
// Create 创建企业微信应用配置。
func (r *ApplicationRepository) Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error {
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信应用配置失败")
}
return nil
}
// Update 更新企业微信应用配置及密文凭据。
func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error {
updates := map[string]any{
"name": application.Name, "secret_ciphertext": application.SecretCiphertext,
"callback_token_ciphertext": application.CallbackTokenCiphertext,
"encoding_aes_key_ciphertext": application.EncodingAESKeyCiphertext,
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
"status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt,
}
if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", application.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败")
}
return nil
}
// UpdateDefaultCreator 更新应用默认审批发起人快照。
func (r *ApplicationRepository) UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error {
if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ? AND deleted_at IS NULL", applicationID).Updates(map[string]any{
"default_creator_userid": userID,
"default_creator_name": name,
"updated_by": operatorID,
"updated_at": updatedAt,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信默认审批发起人失败")
}
return nil
}
// List 分页查询企业微信应用配置及密文凭据。
func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComApplication{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信应用配置失败")
}
var applications []model.WeComApplication
if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&applications).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用列表失败")
}
return applications, total, nil
}
// GetEnabled 查询启用的企业微信应用及密文凭据。
func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) {
var application model.WeComApplication
if err := r.db.WithContext(ctx).Where("id = ? AND status = ?", applicationID, constants.StatusEnabled).First(&application).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWeComApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败")
}
return &application, nil
}
// MarkConnected 记录最近一次成功取得 access_token 的时间。
func (r *ApplicationRepository) MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error {
if err := r.db.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", applicationID).
Update("last_connected_at", connectedAt.UTC()).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信最近连接时间失败")
}
return nil
}

View File

@@ -0,0 +1,166 @@
package wecom
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// ApprovalFileReference 是业务快照中可上传到企微的对象存储引用。
type ApprovalFileReference struct {
StorageKey string `json:"storage_key"`
FileName string `json:"file_name"`
}
// ApprovalAttachmentUploader 将对象存储文件上传为企微临时素材。
type ApprovalAttachmentUploader struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
storage *storage.Service
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalAttachmentUploader 创建企业微信审批附件上传客户端。
func NewApprovalAttachmentUploader(tokens DirectoryTokenProvider, integration TokenIntegrationLog, storageService *storage.Service, baseURL string, timeout time.Duration) *ApprovalAttachmentUploader {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalAttachmentUploader{
tokens: tokens, integration: integration, storage: storageService,
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// Upload 下载对象存储文件并上传为企微临时素材Integration Log 不记录文件正文或 media_id。
func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) {
if u == nil || u.tokens == nil || u.integration == nil || u.storage == nil || u.httpClient == nil {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置")
}
reference.StorageKey = strings.TrimSpace(reference.StorageKey)
if reference.StorageKey == "" {
return "", errors.New(errors.CodeInvalidParam, "审批附件对象存储 Key 不能为空")
}
localPath, cleanup, err := u.storage.DownloadToTemp(ctx, reference.StorageKey)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "下载审批附件失败")
}
defer cleanup()
file, err := os.Open(localPath)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "打开审批附件失败")
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() <= 5 || info.Size() > constants.WeComApprovalMaxAttachmentBytes {
return "", errors.New(errors.CodeInvalidParam, "审批附件大小必须大于 5 字节且不超过 20MB")
}
fileName := filepath.Base(strings.TrimSpace(reference.FileName))
if fileName == "." || fileName == "" {
fileName = filepath.Base(reference.StorageKey)
}
token, err := u.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return "", err
}
endpoint, err := url.Parse(u.baseURL + "/cgi-bin/media/upload")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return "", errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
query.Set("type", "file")
endpoint.RawQuery = query.Encode()
request, err := newAttachmentUploadRequest(ctx, endpoint.String(), fileName, file)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(instanceID), 10)
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
},
})
if err != nil {
return "", err
}
startedAt := u.now()
response, requestErr := u.httpClient.Do(request)
if requestErr != nil {
message := "企业微信审批附件上传失败"
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: "request_failed", ProviderMessage: message,
ResponseSummary: map[string]any{"success": false}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.Wrap(errors.CodeServiceUnavailable, requestErr, message)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes || sonic.Unmarshal(body, &result) != nil ||
response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.MediaID) == "" {
message := strings.TrimSpace(result.ErrMsg)
if message == "" {
message = "企业微信审批附件上传响应无效"
}
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: message,
ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.New(errors.CodeServiceUnavailable, "上传企业微信审批附件失败")
}
_, err = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"success": true, "media_id_set": true}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return "", err
}
return strings.TrimSpace(result.MediaID), nil
}
// newAttachmentUploadRequest 在内存上限受文件大小校验约束的前提下组装 multipart 请求,避免流式写入 goroutine 泄漏。
func newAttachmentUploadRequest(ctx context.Context, endpoint, fileName string, file *os.File) (*http.Request, error) {
var body bytes.Buffer
multipartWriter := multipart.NewWriter(&body)
part, err := multipartWriter.CreateFormFile("media", fileName)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件表单失败")
}
if _, err := io.Copy(part, file); err != nil {
return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "读取审批附件失败")
}
if err := multipartWriter.Close(); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "完成企业微信附件表单失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body.Bytes()))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件上传请求失败")
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
return request, nil
}

View File

@@ -0,0 +1,395 @@
package wecom
import (
"context"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalSubmissionRecord 聚合 Worker 提交企微审批所需的本地事实。
type ApprovalSubmissionRecord struct {
Instance model.ApprovalInstance
Context model.WeComApprovalContext
}
// ApprovalRecoveryRecord 是主动恢复扫描需要的本地最小事实。
type ApprovalRecoveryRecord struct {
InstanceID uint
ApplicationID uint
TemplateID string
CreatorUserID string
SPNo string
SubmissionStatus int
SubmissionAttemptedAt time.Time
}
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct {
db *gorm.DB
now func() time.Time
}
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository {
return &ApprovalContextRepository{db: db, now: time.Now}
}
// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) {
if r == nil || r.db == nil || instanceID == 0 {
return nil, false, errors.New(errors.CodeInvalidParam, "企业微信审批提交参数无效")
}
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusReady).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusSending, "submission_attempted_at": now,
"last_error": "", "updated_at": now,
})
if result.Error != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信审批提交任务失败")
}
record, err := r.Get(ctx, instanceID)
if err != nil {
return nil, false, err
}
return record, result.RowsAffected == 1, nil
}
// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。
func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error {
if r == nil || r.db == nil || cutoff.IsZero() {
return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stale []model.WeComApprovalContext
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("submission_status = ? AND updated_at <= ?", constants.WeComSubmissionStatusSending, cutoff.UTC()).
Order("id ASC").Limit(constants.WeComApprovalRecoveryBatchSize).Find(&stale).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询超时的企业微信审批提交失败")
}
if len(stale) == 0 {
return nil
}
instanceIDs := make([]uint, 0, len(stale))
for _, channelContext := range stale {
instanceIDs = append(instanceIDs, channelContext.ApprovalInstanceID)
}
result := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id IN ? AND submission_status = ?", instanceIDs, constants.WeComSubmissionStatusSending).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusUnknown,
"submission_attempted_at": gorm.Expr("COALESCE(submission_attempted_at, updated_at)"),
"last_error": "企业微信审批提交处理中断,已进入结果未知恢复",
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败")
}
return nil
})
}
// ListUnknownRecovery 查询到期且尚未关联审批单号的结果未知记录。
func (r *ApprovalContextRepository) ListUnknownRecovery(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) {
if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize {
return nil, errors.New(errors.CodeInvalidParam, "企业微信审批恢复批量大小无效")
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Where("wc.submission_status = ? AND wc.sp_no = '' AND (wc.last_recovery_at IS NULL OR wc.last_recovery_at <= ?)",
constants.WeComSubmissionStatusUnknown, cutoff.UTC()).
Order("wc.id ASC").Limit(limit).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信结果未知审批失败")
}
return records, nil
}
// ClaimUnknownRecovery 领取一次结果未知恢复尝试,阻止多个 Worker 同时查询同一提交。
func (r *ApprovalContextRepository) ClaimUnknownRecovery(ctx context.Context, instanceID uint, cutoff time.Time) (bool, error) {
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = '' AND (last_recovery_at IS NULL OR last_recovery_at <= ?)",
instanceID, constants.WeComSubmissionStatusUnknown, cutoff.UTC()).
UpdateColumn("last_recovery_at", now)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信结果未知恢复任务失败")
}
return result.RowsAffected == 1, nil
}
// ListPendingSync 查询已提交且仍未终态、需要再次拉取权威详情的审批。
func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) {
if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize {
return nil, errors.New(errors.CodeInvalidParam, "企业微信审批轮询批量大小无效")
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Joins("JOIN tb_approval_instance AS ai ON ai.id = wc.approval_instance_id").
Where("wc.submission_status = ? AND wc.sp_no <> '' AND ai.status = ? AND (wc.last_synced_at IS NULL OR wc.last_synced_at <= ?)",
constants.WeComSubmissionStatusSubmitted, constants.ApprovalStatusPending, cutoff.UTC()).
Order("COALESCE(wc.last_synced_at, wc.created_at) ASC, wc.id ASC").Limit(limit).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待轮询企业微信审批失败")
}
return records, nil
}
// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) {
resourceID := strconv.FormatUint(uint64(instanceID), 10)
var log model.IntegrationLog
err := r.db.WithContext(ctx).
Where("provider = ? AND operation = ? AND direction = ? AND resource_id = ? AND result = ?",
constants.IntegrationProviderWeCom, constants.IntegrationOperationWeComApprovalSubmit,
constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess).
Order("id DESC").First(&log).Error
if err == gorm.ErrRecordNotFound {
return "", nil
}
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
}
var summary struct {
SPNo string `json:"sp_no"`
}
if sonic.Unmarshal(log.ResponseSummary, &summary) != nil {
return "", nil
}
return strings.TrimSpace(summary.SPNo), nil
}
// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。
func (r *ApprovalContextRepository) ExistingSPNos(ctx context.Context, applicationID uint, spNos []string) (map[string]struct{}, error) {
result := make(map[string]struct{})
if len(spNos) == 0 {
return result, nil
}
var values []string
if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("application_id = ? AND sp_no IN ?", applicationID, spNos).Pluck("sp_no", &values).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询已关联企业微信审批单号失败")
}
for _, value := range values {
result[value] = struct{}{}
}
return result, nil
}
// FindUniqueUnknownByFingerprint 按应用、模板、发起人和提交时间唯一定位结果未知实例。
func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint(
ctx context.Context,
applicationID uint,
templateID string,
creatorUserID string,
submittedAt time.Time,
) (*ApprovalRecoveryRecord, error) {
if applicationID == 0 || strings.TrimSpace(templateID) == "" || strings.TrimSpace(creatorUserID) == "" || submittedAt.IsZero() {
return nil, nil
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Where(`wc.application_id = ? AND wc.template_id = ? AND wc.creator_userid = ?
AND wc.submission_status = ? AND wc.sp_no = ''
AND COALESCE(wc.submission_attempted_at, wc.updated_at) BETWEEN ? AND ?`,
applicationID, strings.TrimSpace(templateID), strings.TrimSpace(creatorUserID), constants.WeComSubmissionStatusUnknown,
submittedAt.Add(-constants.WeComApprovalRecoveryWindow).UTC(), submittedAt.Add(constants.WeComApprovalRecoveryWindow).UTC()).
Order("wc.id ASC").Limit(2).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配企业微信结果未知审批失败")
}
if len(records) != 1 {
return nil, nil
}
return &records[0], nil
}
// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。
func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) {
spNo = strings.TrimSpace(spNo)
if instanceID == 0 || spNo == "" {
return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效")
}
now := r.now().UTC()
recovered := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo,
"last_error": "", "updated_at": now,
})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "恢复企业微信审批单号失败")
}
if contextResult.RowsAffected == 0 {
return nil
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmissionUnknown).
Updates(map[string]any{
"external_ref": spNo, "status": constants.ApprovalStatusPending,
"status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now,
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步恢复后的通用审批状态失败")
}
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批恢复状态已变化")
}
recovered = true
return nil
})
return recovered, err
}
// Get 读取通用审批实例及对应企微渠道上下文。
func (r *ApprovalContextRepository) Get(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, error) {
var instance model.ApprovalInstance
if err := r.db.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败")
}
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "企业微信审批渠道上下文不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return &ApprovalSubmissionRecord{Instance: instance, Context: channelContext}, nil
}
// GetBySPNo 按企微审批单号读取本地通用实例和渠道上下文。
func (r *ApprovalContextRepository) GetBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) {
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return r.Get(ctx, channelContext.ApprovalInstanceID)
}
// FindBySPNo 按企微审批单号查找本地记录;无关联时返回空,供回调区分无关审批。
func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) {
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return r.Get(ctx, channelContext.ApprovalInstanceID)
}
// SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。
func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error {
now := r.now().UTC()
if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).Where("approval_instance_id = ?", instanceID).Updates(map[string]any{
"latest_sp_status": spStatus, "latest_detail_snapshot": datatypes.JSON(snapshot), "last_synced_at": now, "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批详情快照失败")
}
return nil
}
// ReleaseForRetry 将尚未调用 applyevent 的安全失败恢复为待提交。
func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanceID uint, message string) error {
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusReady, "last_error": message, "updated_at": r.now().UTC()})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "恢复企业微信审批待提交状态失败")
}
return nil
}
// MarkSubmitted 原子保存企微 sp_no并把通用审批实例置为审批中。
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error {
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, "last_error": "", "updated_at": now})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "保存企业微信审批单号失败")
}
if contextResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "企业微信审批提交状态已变化")
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"external_ref": spNo, "status": constants.ApprovalStatusPending,
"status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now,
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交状态失败")
}
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交状态已变化")
}
return nil
})
}
// MarkFailed 将企微明确拒绝的提交记录为提交失败。
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message)
}
// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message)
}
// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error {
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": approvalStatus, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败")
}
return nil
})
}

View File

@@ -0,0 +1,130 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。
type ApprovalDetail struct {
SPNo string
SPStatus int
Snapshot []byte
}
// ApprovalDetailClient 获取企业微信审批申请详情。
type ApprovalDetailClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalDetailClient 创建企业微信审批详情客户端。
func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalDetailClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalDetailClient{tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now}
}
// Get 获取审批详情并记录一次真实外呼。
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) {
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return ApprovalDetail{}, err
}
request, err := c.newRequest(ctx, token, spNo)
if err != nil {
return ApprovalDetail{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
})
if err != nil {
return ApprovalDetail{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信审批详情请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt)
}
var payload map[string]any
if err := sonic.Unmarshal(body, &payload); err != nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt)
}
errCode := numberToInt64(payload["errcode"])
errMsg, _ := payload["errmsg"].(string)
info, _ := payload["info"].(map[string]any)
status := int(numberToInt64(info["sp_status"]))
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 || info == nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(errCode, 10), errMsg, startedAt)
}
snapshot, err := sonic.Marshal(info)
if err != nil {
return ApprovalDetail{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情快照失败")
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err
}
// newRequest 组装按字符串审批单号查询权威详情的企微请求。
func (c *ApprovalDetailClient) newRequest(ctx context.Context, token, spNo string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovaldetail")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
body, err := sonic.Marshal(map[string]string{"sp_no": spNo})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情请求失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批详情请求失败")
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
// completeFailure 先终结 Integration Log再向 Asynq 返回可重试的统一错误。
func (c *ApprovalDetailClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error {
if strings.TrimSpace(message) == "" {
message = "企业微信审批详情接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批详情失败")
}

View File

@@ -0,0 +1,159 @@
package wecom
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDetailTaskHandler 拉取企微审批详情并翻译为现有标准决策。
type ApprovalDetailTaskHandler struct {
details *ApprovalDetailClient
contexts *ApprovalContextRepository
decisions *approvalapp.SyncDecisionService
integration TokenIntegrationLog
}
// NewApprovalDetailTaskHandler 创建企业微信审批详情同步任务 Handler。
func NewApprovalDetailTaskHandler(details *ApprovalDetailClient, contexts *ApprovalContextRepository, decisions *approvalapp.SyncDecisionService, integration TokenIntegrationLog) *ApprovalDetailTaskHandler {
return &ApprovalDetailTaskHandler{details: details, contexts: contexts, decisions: decisions, integration: integration}
}
// Handle 处理回调或后续轮询提交的详情同步任务。
func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.details == nil || h.contexts == nil || h.decisions == nil || h.integration == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批详情同步任务未配置")
}
var payload ApprovalDetailSyncTask
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil || payload.ApplicationID == 0 || strings.TrimSpace(payload.SPNo) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步任务载荷无效")
}
if !validApprovalSyncSource(payload.Source) {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效")
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
if record == nil {
record, err = h.recoverUnknownCallback(ctx, payload, detail)
if err != nil {
return err
}
if record == nil {
return h.completeIgnoredCallback(ctx, payload)
}
}
if err := h.contexts.SaveLatestDetail(ctx, record.Instance.ID, detail.SPStatus, detail.Snapshot); err != nil {
return err
}
decisions := weComDecisions(detail.SPStatus)
for _, decision := range decisions {
if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{
InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source,
}); err != nil {
return err
}
}
if strings.TrimSpace(payload.IntegrationID) != "" {
_, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultCompleted, ProviderCode: "processed",
ProviderMessage: "企业微信审批回调已完成权威详情同步",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions},
DurationMS: 0, StateChanged: len(decisions) > 0,
})
}
return err
}
func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context, payload ApprovalDetailSyncTask, detail ApprovalDetail) (*ApprovalSubmissionRecord, error) {
if payload.Source != constants.ApprovalSyncSourceCallback {
return nil, nil
}
fingerprint := approvalDetailFingerprint(detail.Snapshot)
candidate, err := h.contexts.FindUniqueUnknownByFingerprint(
ctx, payload.ApplicationID, fingerprint.TemplateID, fingerprint.CreatorUserID, fingerprint.SubmittedAt,
)
if err != nil || candidate == nil {
return nil, err
}
recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo)
if err != nil || !recovered {
return nil, err
}
return h.contexts.Get(ctx, candidate.InstanceID)
}
func (h *ApprovalDetailTaskHandler) completeIgnoredCallback(ctx context.Context, payload ApprovalDetailSyncTask) error {
if strings.TrimSpace(payload.IntegrationID) == "" {
return nil
}
_, err := h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored, ProviderCode: "unrelated_approval",
ProviderMessage: "企业微信审批单未关联本系统业务,已忽略",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "ignored": true},
})
return err
}
type approvalFingerprint struct {
TemplateID string
CreatorUserID string
SubmittedAt time.Time
}
func approvalDetailFingerprint(snapshot []byte) approvalFingerprint {
var value struct {
TemplateID string `json:"template_id"`
ApplyTime int64 `json:"apply_time"`
Applyer struct {
UserID string `json:"userid"`
} `json:"applyer"`
}
if sonic.Unmarshal(snapshot, &value) != nil || value.ApplyTime <= 0 {
return approvalFingerprint{}
}
return approvalFingerprint{
TemplateID: strings.TrimSpace(value.TemplateID), CreatorUserID: strings.TrimSpace(value.Applyer.UserID),
SubmittedAt: time.Unix(value.ApplyTime, 0).UTC(),
}
}
func validApprovalSyncSource(source string) bool {
switch source {
case constants.ApprovalSyncSourceCallback, constants.ApprovalSyncSourcePolling, constants.ApprovalSyncSourceManual:
return true
default:
return false
}
}
// weComDecisions 只翻译现有审批核心支持的企微标准终态;通过后撤销按领域允许顺序补齐两个事实。
func weComDecisions(status int) []string {
switch status {
case 2:
return []string{constants.ApprovalDecisionApproved}
case 3:
return []string{constants.ApprovalDecisionRejected}
case 4:
return []string{constants.ApprovalDecisionCancelled}
case 6:
return []string{constants.ApprovalDecisionApproved, constants.ApprovalDecisionRevokedAfterApproved}
case 7:
return []string{constants.ApprovalDecisionDeleted}
default:
return nil
}
}

View File

@@ -0,0 +1,176 @@
package wecom
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type approvalAttachmentPort interface {
Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error)
}
type approvalFormBuildResult struct {
Contents []map[string]any
}
type attachmentPreparationError struct {
err error
}
func (e *attachmentPreparationError) Error() string {
return e.err.Error()
}
func (e *attachmentPreparationError) Unwrap() error {
return e.err
}
// buildApprovalForm 按后台已校验映射将渠道无关业务快照组装为企微控件值。
func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) {
var mappings []dto.WeComControlMappingItem
if err := sonic.Unmarshal(mappingJSON, &mappings); err != nil || len(mappings) == 0 {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效")
}
var snapshot map[string]any
if err := sonic.Unmarshal(snapshotJSON, &snapshot); err != nil || snapshot == nil {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照无效")
}
var template wecomapp.TemplateDefinition
if err := sonic.Unmarshal(templateJSON, &template); err != nil {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板快照无效")
}
requiredControls := make(map[string]bool, len(template.Controls))
for _, control := range template.Controls {
requiredControls[control.ID] = control.Required
}
contents := make([]map[string]any, 0, len(mappings))
attachmentCount := 0
for _, mapping := range mappings {
raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField)
if !exists || raw == nil {
if requiredControls[mapping.ControlID] {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
}
continue
}
value, count, err := buildControlValue(ctx, applicationID, instanceID, mapping, raw, attachments)
if err != nil {
return approvalFormBuildResult{}, err
}
attachmentCount += count
if attachmentCount > constants.WeComApprovalMaxAttachmentCount {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidParam, "单张企业微信审批单最多支持 6 个附件")
}
contents = append(contents, map[string]any{
"control": mapping.ControlType, "id": mapping.ControlID, "value": value,
})
}
return approvalFormBuildResult{Contents: contents}, nil
}
// lookupSnapshotValue 支持用点分路径读取嵌套业务快照,避免模板映射绑定具体 DTO。
func lookupSnapshotValue(snapshot map[string]any, path string) (any, bool) {
segments := strings.Split(strings.TrimSpace(path), ".")
var current any = snapshot
for _, segment := range segments {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[segment]
if !ok {
return nil, false
}
}
return current, true
}
// buildControlValue 按企微控件类型生成官方要求的 value 结构,复杂结构允许业务快照直接传入对象。
func buildControlValue(ctx context.Context, applicationID, instanceID uint, mapping dto.WeComControlMappingItem, raw any, attachments approvalAttachmentPort) (map[string]any, int, error) {
if object, ok := raw.(map[string]any); ok && !strings.EqualFold(mapping.ControlType, "File") {
return object, 0, nil
}
switch strings.ToLower(strings.TrimSpace(mapping.ControlType)) {
case "text", "textarea":
return map[string]any{"text": fmt.Sprint(raw)}, 0, nil
case "number":
return map[string]any{"new_number": fmt.Sprint(raw)}, 0, nil
case "money":
return map[string]any{"new_money": fmt.Sprint(raw)}, 0, nil
case "date":
return map[string]any{"date": map[string]any{"type": "day", "s_timestamp": fmt.Sprint(raw)}}, 0, nil
case "selector":
businessValue := fmt.Sprint(raw)
key := mapping.OptionMapping[businessValue]
if key == "" {
return nil, 0, errors.New(errors.CodeInvalidStatus, "审批业务枚举值没有企微选项映射: "+mapping.BusinessField)
}
return map[string]any{"selector": map[string]any{"type": "single", "options": []map[string]string{{"key": key}}}}, 0, nil
case "contact":
return map[string]any{"members": []map[string]string{{"userid": fmt.Sprint(raw)}}}, 0, nil
case "department":
return map[string]any{"departments": []map[string]string{{"openapi_id": fmt.Sprint(raw)}}}, 0, nil
case "file":
if attachments == nil {
return nil, 0, errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置")
}
references, err := parseApprovalFileReferences(raw)
if err != nil {
return nil, 0, err
}
files := make([]map[string]string, 0, len(references))
for _, reference := range references {
mediaID, err := attachments.Upload(ctx, applicationID, instanceID, reference)
if err != nil {
return nil, 0, &attachmentPreparationError{err: err}
}
files = append(files, map[string]string{"file_id": mediaID})
}
return map[string]any{"files": files}, len(files), nil
default:
return nil, 0, errors.New(errors.CodeInvalidStatus, "暂不支持的企业微信审批控件类型: "+mapping.ControlType)
}
}
// parseApprovalFileReferences 兼容现有退款和线下充值的字符串 Key 列表,以及带文件名的结构化引用。
func parseApprovalFileReferences(raw any) ([]ApprovalFileReference, error) {
if storageKey, ok := raw.(string); ok && strings.TrimSpace(storageKey) != "" {
return []ApprovalFileReference{{StorageKey: strings.TrimSpace(storageKey)}}, nil
}
bytes, err := sonic.Marshal(raw)
if err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "编码审批附件引用失败")
}
var references []ApprovalFileReference
if err := sonic.Unmarshal(bytes, &references); err != nil {
var storageKeys []string
if keyErr := sonic.Unmarshal(bytes, &storageKeys); keyErr == nil && len(storageKeys) > 0 {
references = make([]ApprovalFileReference, 0, len(storageKeys))
for _, storageKey := range storageKeys {
if strings.TrimSpace(storageKey) != "" {
references = append(references, ApprovalFileReference{StorageKey: strings.TrimSpace(storageKey)})
}
}
if len(references) > 0 {
return references, nil
}
}
var single ApprovalFileReference
if singleErr := sonic.Unmarshal(bytes, &single); singleErr != nil {
return nil, errors.New(errors.CodeInvalidParam, "审批附件必须提供对象存储引用")
}
references = []ApprovalFileReference{single}
}
if len(references) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "审批附件不能为空")
}
return references, nil
}

View File

@@ -0,0 +1,195 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalInfoQuery 描述按审批单提交时间查询审批单号的过滤条件。
type ApprovalInfoQuery struct {
ApplicationID uint
StartTime time.Time
EndTime time.Time
TemplateID string
CreatorUserID string
Cursor string
Size int
}
// ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。
type ApprovalInfoPage struct {
SPNos []string
NextCursor string
}
// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。
type ApprovalInfoClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalInfoClient 创建企业微信批量审批单号客户端。
func NewApprovalInfoClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalInfoClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalInfoClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// List 获取一页审批单号,并为每次真实外呼写入 Integration Log。
func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery) (ApprovalInfoPage, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return ApprovalInfoPage{}, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号客户端未配置")
}
if err := validateApprovalInfoQuery(input); err != nil {
return ApprovalInfoPage{}, err
}
token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID)
if err != nil {
return ApprovalInfoPage{}, err
}
request, err := c.newRequest(ctx, token, input)
if err != nil {
return ApprovalInfoPage{}, err
}
resourceID := strconv.FormatUint(uint64(input.ApplicationID), 10)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
"template_id": input.TemplateID, "creator_userid": input.CreatorUserID, "size": input.Size,
"cursor_present": strings.TrimSpace(input.Cursor) != "",
},
})
if err != nil {
return ApprovalInfoPage{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信批量审批单号请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt)
}
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
SPNoList []string `json:"sp_no_list"`
NewNextCursor string `json:"new_next_cursor"`
}
if err := sonic.Unmarshal(body, &result); err != nil {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(result.ErrCode, 10), result.ErrMsg, startedAt)
}
spNos := normalizeSPNos(result.SPNoList)
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{
"sp_no_count": len(spNos), "next_cursor_present": strings.TrimSpace(result.NewNextCursor) != "",
},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err
}
func validateApprovalInfoQuery(input ApprovalInfoQuery) error {
if input.ApplicationID == 0 || input.StartTime.IsZero() || input.EndTime.IsZero() || !input.StartTime.Before(input.EndTime) {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询参数无效")
}
if input.EndTime.Sub(input.StartTime) > constants.WeComApprovalInfoMaxWindow {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询时间跨度不能超过 31 天")
}
if input.Size <= 0 || input.Size > constants.WeComApprovalInfoMaxPageSize {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询每页数量无效")
}
return nil
}
// newRequest 组装企业微信批量审批单号请求,筛选值仅来自本地稳定提交快照。
func (c *ApprovalInfoClient) newRequest(ctx context.Context, token string, input ApprovalInfoQuery) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovalinfo")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
filters := make([]map[string]string, 0, 2)
if value := strings.TrimSpace(input.TemplateID); value != "" {
filters = append(filters, map[string]string{"key": "template_id", "value": value})
}
if value := strings.TrimSpace(input.CreatorUserID); value != "" {
filters = append(filters, map[string]string{"key": "creator", "value": value})
}
payload := map[string]any{
"starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
"new_cursor": strings.TrimSpace(input.Cursor), "size": input.Size, "filters": filters,
}
body, err := sonic.Marshal(payload)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信批量审批单号请求失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信批量审批单号请求失败")
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
func (c *ApprovalInfoClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error {
if strings.TrimSpace(message) == "" {
message = "企业微信批量审批单号接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批单号失败")
}
func normalizeSPNos(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@@ -0,0 +1,200 @@
package wecom
import (
"context"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
approvalquery "github.com/break/junhong_cmp_fiber/internal/query/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalApproverProjection 是企微审批节点成员到系统账号的可选映射。
type ApprovalApproverProjection struct {
WeComUserID string `json:"wecom_userid"`
AccountID *uint `json:"account_id,omitempty"`
AccountName string `json:"account_name"`
}
// ApprovalChannelProjection 是通用审批 Query 返回的企微本地只读扩展。
type ApprovalChannelProjection struct {
SPNo string `json:"sp_no"`
SPStatus int `json:"sp_status"`
Approvers []ApprovalApproverProjection `json:"approvers"`
}
// ApprovalProjectionResolver 从已同步详情快照批量投影审批人,不调用企业微信接口。
type ApprovalProjectionResolver struct {
db *gorm.DB
}
// NewApprovalProjectionResolver 创建企业微信审批读取投影 Resolver。
func NewApprovalProjectionResolver(db *gorm.DB) *ApprovalProjectionResolver {
return &ApprovalProjectionResolver{db: db}
}
// Resolve 按当前页审批实例批量读取快照,并批量映射同企业下的系统账号。
func (r *ApprovalProjectionResolver) Resolve(
ctx context.Context,
_ approvalquery.Viewer,
references []approvalquery.ExtensionReference,
) (map[uint]any, error) {
result := make(map[uint]any)
if len(references) == 0 {
return result, nil
}
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "企业微信审批读取投影未配置")
}
instanceIDs := weComExtensionInstanceIDs(references)
if len(instanceIDs) == 0 {
return result, nil
}
var contexts []model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("approval_instance_id IN ?", instanceIDs).Find(&contexts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信审批详情快照失败")
}
applicationIDs := make([]uint, 0, len(contexts))
for _, channelContext := range contexts {
applicationIDs = append(applicationIDs, channelContext.ApplicationID)
}
corpByApplication, err := r.loadApplicationCorps(ctx, applicationIDs)
if err != nil {
return nil, err
}
userIDsByCorp := make(map[string][]string)
approversByInstance := make(map[uint][]string, len(contexts))
for _, channelContext := range contexts {
userIDs := approvalNodeUserIDs(channelContext.LatestDetailSnapshot)
approversByInstance[channelContext.ApprovalInstanceID] = userIDs
corpID := corpByApplication[channelContext.ApplicationID]
userIDsByCorp[corpID] = append(userIDsByCorp[corpID], userIDs...)
}
accounts, err := r.loadBoundAccounts(ctx, userIDsByCorp)
if err != nil {
return nil, err
}
for _, channelContext := range contexts {
corpID := corpByApplication[channelContext.ApplicationID]
approvers := make([]ApprovalApproverProjection, 0, len(approversByInstance[channelContext.ApprovalInstanceID]))
for _, userID := range approversByInstance[channelContext.ApprovalInstanceID] {
item := ApprovalApproverProjection{WeComUserID: userID}
if account, exists := accounts[weComAccountKey(corpID, userID)]; exists {
accountID := account.ID
item.AccountID = &accountID
item.AccountName = account.Username
}
approvers = append(approvers, item)
}
result[channelContext.ApprovalInstanceID] = ApprovalChannelProjection{
SPNo: channelContext.SPNo, SPStatus: channelContext.LatestSPStatus, Approvers: approvers,
}
}
return result, nil
}
func weComExtensionInstanceIDs(references []approvalquery.ExtensionReference) []uint {
seen := make(map[uint]struct{}, len(references))
ids := make([]uint, 0, len(references))
for _, reference := range references {
if reference.InstanceID == 0 || reference.Provider != constants.IntegrationProviderWeCom {
continue
}
if _, exists := seen[reference.InstanceID]; exists {
continue
}
seen[reference.InstanceID] = struct{}{}
ids = append(ids, reference.InstanceID)
}
return ids
}
func (r *ApprovalProjectionResolver) loadApplicationCorps(ctx context.Context, applicationIDs []uint) (map[uint]string, error) {
result := make(map[uint]string)
if len(applicationIDs) == 0 {
return result, nil
}
var applications []model.WeComApplication
if err := r.db.WithContext(ctx).Select("id", "corp_id").Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信应用企业标识失败")
}
for _, application := range applications {
result[application.ID] = application.CorpID
}
return result, nil
}
func (r *ApprovalProjectionResolver) loadBoundAccounts(ctx context.Context, userIDsByCorp map[string][]string) (map[string]model.Account, error) {
result := make(map[string]model.Account)
query := r.db.WithContext(ctx).Model(&model.Account{}).Where("1 = 0")
hasCondition := false
for corpID, userIDs := range userIDsByCorp {
corpID = strings.TrimSpace(corpID)
userIDs = uniqueStrings(userIDs)
if corpID == "" || len(userIDs) == 0 {
continue
}
query = query.Or("wecom_corp_id = ? AND wecom_userid IN ? AND status = ?", corpID, userIDs, constants.StatusEnabled)
hasCondition = true
}
if !hasCondition {
return result, nil
}
var accounts []model.Account
if err := query.Select("id", "username", "wecom_corp_id", "wecom_userid").Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量映射企业微信审批人账号失败")
}
for _, account := range accounts {
result[weComAccountKey(account.WeComCorpID, account.WeComUserID)] = account
}
return result, nil
}
func approvalNodeUserIDs(snapshot []byte) []string {
var detail struct {
SPRecords []struct {
Details []struct {
Approver struct {
UserID string `json:"userid"`
} `json:"approver"`
} `json:"details"`
} `json:"sp_record"`
}
if sonic.Unmarshal(snapshot, &detail) != nil {
return []string{}
}
values := make([]string, 0)
for _, record := range detail.SPRecords {
for _, node := range record.Details {
values = append(values, node.Approver.UserID)
}
}
return uniqueStrings(values)
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func weComAccountKey(corpID, userID string) string {
return strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
}
var _ approvalquery.ChannelExtensionResolver = (*ApprovalProjectionResolver)(nil)

View File

@@ -0,0 +1,152 @@
package wecom
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalProvider 实现通用 Approval Port 的企微准备与渠道上下文写入。
type ApprovalProvider struct {
db *gorm.DB
scenes *SceneRepository
applications *ApplicationRepository
members *MemberRepository
templates wecomapp.TemplateProvider
}
type approvalPreparationContext struct {
ApplicationID uint `json:"application_id"`
BusinessType string `json:"business_type"`
TemplateID string `json:"template_id"`
CreatorUserID string `json:"creator_userid"`
CreatorName string `json:"creator_name"`
CreatorSource string `json:"creator_source"`
ControlMapping []map[string]any `json:"control_mapping"`
TemplateSnapshot wecomapp.TemplateDefinition `json:"template_snapshot"`
TemplateFingerprint string `json:"template_fingerprint"`
}
// NewApprovalProvider 创建企业微信通用审批渠道 Adapter。
func NewApprovalProvider(db *gorm.DB, scenes *SceneRepository, applications *ApplicationRepository, members *MemberRepository, templates wecomapp.TemplateProvider) *ApprovalProvider {
return &ApprovalProvider{db: db, scenes: scenes, applications: applications, members: members, templates: templates}
}
// Prepare 在业务事务前校验场景、模板和可用的企微发起人。
func (p *ApprovalProvider) Prepare(ctx context.Context, request approvalapp.PrepareRequest) (approvalapp.ProviderPreparation, error) {
if p == nil || p.db == nil || p.scenes == nil || p.applications == nil || p.members == nil || p.templates == nil {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "企业微信审批 Adapter 未配置")
}
scene, err := p.scenes.GetEnabled(ctx, strings.TrimSpace(request.BusinessType))
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
application, err := p.applications.GetEnabled(ctx, scene.ApplicationID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
creator, source, err := p.resolveCreator(ctx, application, request.SubmitterAccountID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
definition, err := p.templates.GetTemplateDetail(ctx, scene.ApplicationID, scene.TemplateID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
fingerprint, err := templateFingerprint(definition)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
if fingerprint != scene.TemplateFingerprint {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板已变化,请管理员重新校验场景配置")
}
var mapping []map[string]any
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil || len(mapping) == 0 {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效")
}
channelContext, err := sonic.Marshal(approvalPreparationContext{
ApplicationID: scene.ApplicationID, BusinessType: scene.BusinessType, TemplateID: scene.TemplateID,
CreatorUserID: creator.UserID, CreatorName: creator.Name, CreatorSource: source,
ControlMapping: mapping, TemplateSnapshot: definition, TemplateFingerprint: fingerprint,
})
if err != nil {
return approvalapp.ProviderPreparation{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批准备结果失败")
}
return approvalapp.ProviderPreparation{Provider: constants.IntegrationProviderWeCom, ChannelContext: channelContext}, nil
}
// CreateContextInTx 在调用方事务中保存不含凭据的企微提交快照。
func (p *ApprovalProvider) CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation approvalapp.ProviderPreparation, instanceID uint) error {
if p == nil || tx == nil || preparation.Provider != constants.IntegrationProviderWeCom || instanceID == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信审批渠道上下文参数无效")
}
var prepared approvalPreparationContext
if err := sonic.Unmarshal(preparation.ChannelContext, &prepared); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "解析企业微信审批准备结果失败")
}
mappingJSON, err := sonic.Marshal(prepared.ControlMapping)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批控件映射失败")
}
templateJSON, err := sonic.Marshal(prepared.TemplateSnapshot)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批模板快照失败")
}
record := model.WeComApprovalContext{
ApprovalInstanceID: instanceID, ApplicationID: prepared.ApplicationID, BusinessType: prepared.BusinessType,
TemplateID: prepared.TemplateID, CreatorUserID: prepared.CreatorUserID, CreatorName: prepared.CreatorName,
CreatorSource: prepared.CreatorSource, ControlMapping: mappingJSON, TemplateSnapshot: templateJSON,
TemplateFingerprint: prepared.TemplateFingerprint, SubmissionStatus: constants.WeComSubmissionStatusReady,
}
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批渠道上下文失败")
}
return nil
}
// resolveCreator 保留真实业务提交人,同时按账号类型选择企微本人或应用默认成员作为接口发起人。
func (p *ApprovalProvider) resolveCreator(ctx context.Context, application *model.WeComApplication, accountID uint) (*model.WeComMember, string, error) {
var account model.Account
if err := p.db.WithContext(ctx).Select("id", "user_type", "wecom_corp_id", "wecom_userid").Where("id = ?", accountID).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询审批真实提交人失败")
}
useBoundCreator := account.UserType == constants.UserTypeSuperAdmin || account.UserType == constants.UserTypePlatform
if useBoundCreator && strings.EqualFold(account.WeComCorpID, application.CorpID) && strings.TrimSpace(account.WeComUserID) != "" {
member, err := p.members.GetVisible(ctx, application.ID, account.WeComUserID)
if err == nil {
return member, constants.WeComCreatorSourceBound, nil
}
}
if strings.TrimSpace(application.DefaultCreatorUserID) == "" {
return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信应用尚未配置默认审批发起人")
}
member, err := p.members.GetVisible(ctx, application.ID, application.DefaultCreatorUserID)
if err != nil {
return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信默认审批发起人已不可用,请管理员重新配置")
}
return member, constants.WeComCreatorSourceDefault, nil
}
func templateFingerprint(definition wecomapp.TemplateDefinition) (string, error) {
snapshot, err := sonic.Marshal(definition)
if err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
}
hash := sha256.Sum256(snapshot)
return hex.EncodeToString(hash[:]), nil
}
var _ approvalapp.ProviderPort = (*ApprovalProvider)(nil)

View File

@@ -0,0 +1,155 @@
package wecom
import (
"context"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// ApprovalRecoveryTaskHandler 周期恢复结果未知审批并轮询未终态详情。
type ApprovalRecoveryTaskHandler struct {
contexts *ApprovalContextRepository
infos *ApprovalInfoClient
queue *queue.Client
now func() time.Time
}
// NewApprovalRecoveryTaskHandler 创建企业微信审批主动恢复任务 Handler。
func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos *ApprovalInfoClient, queueClient *queue.Client) *ApprovalRecoveryTaskHandler {
return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now}
}
// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置")
}
now := h.now().UTC()
if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil {
return err
}
if err := h.enqueuePendingSync(ctx, now); err != nil {
return err
}
return h.recoverUnknown(ctx, now)
}
func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, now time.Time) error {
records, err := h.contexts.ListPendingSync(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalRecoveryBatchSize)
if err != nil {
return err
}
for _, record := range records {
if err := h.enqueueDetailSync(ctx, record.ApplicationID, record.SPNo); err != nil {
return err
}
}
return nil
}
func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error {
cutoff := now.Add(-constants.WeComApprovalPollingInterval)
records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize)
if err != nil {
return err
}
for _, record := range records {
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff)
if err != nil {
return err
}
if !claimed {
continue
}
spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
if err != nil {
return err
}
if spNo == "" {
spNo, err = h.findUniqueSPNo(ctx, record, now)
if err != nil {
return err
}
}
if spNo == "" {
continue
}
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo)
if err != nil {
return err
}
if recovered {
if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil {
return err
}
}
}
return nil
}
// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) {
startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow)
endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow)
if endTime.After(now) {
endTime = now
}
if !startTime.Before(endTime) {
return "", nil
}
cursor := ""
seenCursors := make(map[string]struct{})
seenCandidates := make(map[string]struct{})
unbound := make([]string, 0, 2)
for {
page, err := h.infos.List(ctx, ApprovalInfoQuery{
ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime,
TemplateID: record.TemplateID, CreatorUserID: record.CreatorUserID,
Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize,
})
if err != nil {
return "", err
}
existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos)
if err != nil {
return "", err
}
for _, candidate := range page.SPNos {
if _, seen := seenCandidates[candidate]; seen {
continue
}
seenCandidates[candidate] = struct{}{}
if _, exists := existing[candidate]; !exists {
unbound = append(unbound, candidate)
if len(unbound) > 1 {
return "", nil
}
}
}
next := strings.TrimSpace(page.NextCursor)
if next == "" {
break
}
if _, exists := seenCursors[next]; exists {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
}
seenCursors[next] = struct{}{}
cursor = next
}
if len(unbound) != 1 {
return "", nil
}
return unbound[0], nil
}
func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error {
return h.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{
ApplicationID: applicationID, SPNo: spNo, Source: constants.ApprovalSyncSourcePolling,
})
}

View File

@@ -0,0 +1,189 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
submissionOutcomeSuccess = "success"
submissionOutcomeFailed = "failed"
submissionOutcomeUnknown = "unknown"
)
// ApprovalSubmitRequest 是调用企微 applyevent 的安全请求。
type ApprovalSubmitRequest struct {
InstanceID uint
ApplicationID uint
TemplateID string
CreatorUserID string
CorrelationID string
Contents []map[string]any
}
// ApprovalSubmitResult 描述企微是否明确创建审批单。
type ApprovalSubmitResult struct {
Outcome string
SPNo string
Message string
SafeToRetry bool
}
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
type ApprovalSubmissionClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalSubmissionClient 创建企业微信审批提交客户端。
func NewApprovalSubmissionClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalSubmissionClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalSubmissionClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// Submit 使用企微后台模板流程提交审批申请,绝不在结果未知时自动重发。
func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSubmitRequest) (ApprovalSubmitResult, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "企业微信审批提交客户端未配置", SafeToRetry: true}, nil
}
token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID)
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "取得企业微信 access_token 失败", SafeToRetry: true}, err
}
request, err := c.newSubmitRequest(ctx, token, input)
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "创建企业微信审批提交请求失败", SafeToRetry: true}, err
}
resourceID := strconv.FormatUint(uint64(input.InstanceID), 10)
correlationID := strings.TrimSpace(input.CorrelationID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "template_id": input.TemplateID,
"creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents),
}, CorrelationID: optionalIntegrationString(correlationID),
})
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
message := "企业微信审批提交请求结果未知"
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: message,
ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if readErr != nil || int64(len(responseBody)) > constants.WeComMaxResponseBodyBytes {
message := "企业微信审批提交响应无法确认"
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, HTTPStatus: response.StatusCode, ProviderCode: "invalid_response",
ProviderMessage: message, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
}
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
SPNo string `json:"sp_no"`
}
if err := sonic.Unmarshal(responseBody, &result); err != nil {
result.ErrMsg = "企业微信审批提交响应格式无效"
result.ErrCode = int64(response.StatusCode)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.SPNo) == "" {
providerCode := strconv.FormatInt(result.ErrCode, 10)
if result.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
message := strings.TrimSpace(result.ErrMsg)
if message == "" {
message = "企业微信明确拒绝审批提交"
}
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
})
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
}
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。
func (c *ApprovalSubmissionClient) newSubmitRequest(ctx context.Context, token string, input ApprovalSubmitRequest) (*http.Request, error) {
payload := map[string]any{
"creator_userid": input.CreatorUserID,
"template_id": input.TemplateID,
"use_template_approver": 1,
"apply_data": map[string]any{"contents": input.Contents},
"summary_list": []map[string]any{{"summary_info": []map[string]string{{"text": "业务审批申请", "lang": "zh_CN"}}}},
}
body, err := sonic.Marshal(payload)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批提交请求失败")
}
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/applyevent")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批提交请求失败")
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
func optionalIntegrationString(value string) *string {
if value == "" {
return nil
}
return &value
}

View File

@@ -0,0 +1,100 @@
package wecom
import (
"context"
stdErrors "errors"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalSubmissionConsumer 消费通用审批提交 Outbox并保证结果未知时不盲目重提。
type ApprovalSubmissionConsumer struct {
repository *ApprovalContextRepository
client *ApprovalSubmissionClient
attachments approvalAttachmentPort
}
// NewApprovalSubmissionConsumer 创建企业微信审批提交消费者。
func NewApprovalSubmissionConsumer(repository *ApprovalContextRepository, client *ApprovalSubmissionClient, attachments approvalAttachmentPort) *ApprovalSubmissionConsumer {
return &ApprovalSubmissionConsumer{repository: repository, client: client, attachments: attachments}
}
// Consume 校验通用事件并提交一次企业微信审批申请。
func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.repository == nil || c.client == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批提交消费者未配置")
}
if envelope.PayloadVersion != constants.ApprovalSubmissionPayloadVersionV1 {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "不支持的审批提交事件版本"))
}
var event approvalapp.SubmissionRequestedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件载荷无效"))
}
if event.InstanceID == 0 || event.Provider != constants.IntegrationProviderWeCom {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件渠道或实例无效"))
}
record, claimed, err := c.repository.ClaimSubmission(ctx, event.InstanceID)
if err != nil {
return err
}
if !claimed {
return nil
}
form, err := buildApprovalForm(
ctx, record.Context.ApplicationID, event.InstanceID,
record.Context.ControlMapping, record.Context.TemplateSnapshot, record.Instance.RequestSnapshot, c.attachments,
)
if err != nil {
var attachmentErr *attachmentPreparationError
if stdErrors.As(err, &attachmentErr) {
if releaseErr := c.repository.ReleaseForRetry(ctx, event.InstanceID, "审批附件准备失败,等待重试"); releaseErr != nil {
return releaseErr
}
return err
}
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil {
return markErr
}
return nil
}
result, submitErr := c.client.Submit(ctx, ApprovalSubmitRequest{
InstanceID: event.InstanceID, ApplicationID: record.Context.ApplicationID,
TemplateID: record.Context.TemplateID, CreatorUserID: record.Context.CreatorUserID,
CorrelationID: event.CorrelationID, Contents: form.Contents,
})
switch result.Outcome {
case submissionOutcomeSuccess:
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil {
return err
}
case submissionOutcomeUnknown:
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil {
return err
}
case submissionOutcomeFailed:
if result.SafeToRetry {
if err := c.repository.ReleaseForRetry(ctx, event.InstanceID, result.Message); err != nil {
return err
}
if submitErr != nil {
return submitErr
}
return errors.New(errors.CodeServiceUnavailable, result.Message)
}
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil {
return err
}
default:
return errors.New(errors.CodeInternalError, "企业微信审批提交结果无效")
}
return submitErr
}
var _ outbox.EventConsumer = (*ApprovalSubmissionConsumer)(nil)

View File

@@ -0,0 +1,85 @@
package wecom
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"sort"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CallbackCrypto 实现企微回调要求的 SHA-1 签名校验和 AES-256-CBC 解密。
type CallbackCrypto struct {
token string
aesKey []byte
receiveID string
}
// NewCallbackCrypto 创建企业微信回调加密器。
func NewCallbackCrypto(token, encodingAESKey, receiveID string) (*CallbackCrypto, error) {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodingAESKey) + "=")
if err != nil || len(key) != 32 || strings.TrimSpace(token) == "" || strings.TrimSpace(receiveID) == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信回调 Token、EncodingAESKey 或 receiveid 无效")
}
return &CallbackCrypto{token: token, aesKey: key, receiveID: receiveID}, nil
}
// VerifyAndDecrypt 校验签名并解密企业微信回调密文。
func (c *CallbackCrypto) VerifyAndDecrypt(signature, timestamp, nonce, encrypted string) ([]byte, error) {
if c == nil || !c.validSignature(signature, timestamp, nonce, encrypted) {
return nil, errors.New(errors.CodeUnauthorized, "企业微信回调签名无效")
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调密文无效")
}
block, err := aes.NewCipher(c.aesKey)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "初始化企业微信回调解密器失败")
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, c.aesKey[:aes.BlockSize]).CryptBlocks(plaintext, ciphertext)
plaintext, err = removePKCS7Padding(plaintext)
if err != nil || len(plaintext) < 20 {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调填充无效")
}
messageLength := int(binary.BigEndian.Uint32(plaintext[16:20]))
if messageLength < 0 || 20+messageLength > len(plaintext) {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调消息长度无效")
}
message := plaintext[20 : 20+messageLength]
receiveID := plaintext[20+messageLength:]
if subtle.ConstantTimeCompare(receiveID, []byte(c.receiveID)) != 1 {
return nil, errors.New(errors.CodeUnauthorized, "企业微信回调 receiveid 不匹配")
}
return append([]byte(nil), message...), nil
}
func (c *CallbackCrypto) validSignature(signature, timestamp, nonce, encrypted string) bool {
parts := []string{c.token, timestamp, nonce, encrypted}
sort.Strings(parts)
hash := sha1.Sum([]byte(strings.Join(parts, "")))
expected := hex.EncodeToString(hash[:])
return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1
}
func removePKCS7Padding(value []byte) ([]byte, error) {
if len(value) == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
padding := int(value[len(value)-1])
if padding <= 0 || padding > 32 || padding > len(value) {
return nil, errors.New(errors.CodeInvalidParam)
}
if !bytes.Equal(value[len(value)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) {
return nil, errors.New(errors.CodeInvalidParam)
}
return value[:len(value)-padding], nil
}

View File

@@ -0,0 +1,124 @@
package wecom
import (
"context"
"encoding/xml"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
type callbackIntegrationLog interface {
RecordInbound(ctx context.Context, input integrationlog.InboundAttempt) (*model.IntegrationLog, bool, error)
}
type encryptedCallbackEnvelope struct {
Encrypt string `xml:"Encrypt"`
}
type approvalChangeEvent struct {
Event string `xml:"Event"`
SPNo string `xml:"ApprovalInfo>SpNoStr"`
SPStatus int `xml:"ApprovalInfo>SpStatus"`
TemplateID string `xml:"ApprovalInfo>TemplateId"`
StatusChangeType int `xml:"ApprovalInfo>StatuChangeEvent"`
}
// ApprovalDetailSyncTask 是回调快速响应后提交给 Worker 的结构化任务载荷。
type ApprovalDetailSyncTask struct {
ApplicationID uint `json:"application_id"`
SPNo string `json:"sp_no"`
Source string `json:"source"`
IntegrationID string `json:"integration_id"`
}
// CallbackService 校验解密企微回调、记录入站幂等事实并异步拉取详情。
type CallbackService struct {
applications *ApplicationRepository
cipher *CredentialCipher
integration callbackIntegrationLog
queue *queue.Client
}
// NewCallbackService 创建企业微信审批回调服务。
func NewCallbackService(applications *ApplicationRepository, cipher *CredentialCipher, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService {
return &CallbackService{applications: applications, cipher: cipher, integration: integration, queue: queueClient}
}
// VerifyURL 校验企微回调 URL 并返回 echostr 明文。
func (s *CallbackService) VerifyURL(ctx context.Context, applicationID uint, signature, timestamp, nonce, echo string) ([]byte, error) {
crypto, err := s.callbackCrypto(ctx, applicationID)
if err != nil {
return nil, err
}
return crypto.VerifyAndDecrypt(signature, timestamp, nonce, echo)
}
// Receive 校验并解密审批事件,持久化入站事实后用 struct 载荷提交详情同步任务。
func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signature, timestamp, nonce string, body []byte) error {
if s == nil || s.integration == nil || s.queue == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置")
}
var envelope encryptedCallbackEnvelope
if err := xml.Unmarshal(body, &envelope); err != nil || strings.TrimSpace(envelope.Encrypt) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信回调 XML 无效")
}
crypto, err := s.callbackCrypto(ctx, applicationID)
if err != nil {
return err
}
plaintext, err := crypto.VerifyAndDecrypt(signature, timestamp, nonce, envelope.Encrypt)
if err != nil {
return err
}
var event approvalChangeEvent
if err := xml.Unmarshal(plaintext, &event); err != nil || event.Event != "sys_approval_change" || strings.TrimSpace(event.SPNo) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信审批回调事件无效")
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
log, created, err := s.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: applicationCallbackIdempotencyKey(applicationID, signature),
Provider: constants.IntegrationProviderWeCom, Operation: constants.IntegrationOperationWeComApprovalCallback,
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID,
RawPayload: body, ContentType: "application/xml",
})
if err != nil {
return err
}
if !created && log.Result != constants.IntegrationResultPending {
return nil
}
return s.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{
ApplicationID: applicationID, SPNo: event.SPNo, Source: constants.ApprovalSyncSourceCallback,
IntegrationID: log.IntegrationID,
})
}
// callbackCrypto 每次从数据库密文解出当前回调凭据,使凭据轮换无需重启进程。
func (s *CallbackService) callbackCrypto(ctx context.Context, applicationID uint) (*CallbackCrypto, error) {
if s == nil || s.applications == nil || s.cipher == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置")
}
application, err := s.applications.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
token, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
if err != nil {
return nil, err
}
aesKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
if err != nil {
return nil, err
}
return NewCallbackCrypto(token, aesKey, application.CorpID)
}
func applicationCallbackIdempotencyKey(applicationID uint, signature string) string {
return "wecom-approval:" + strconv.FormatUint(uint64(applicationID), 10) + ":" + strings.ToLower(strings.TrimSpace(signature))
}

View File

@@ -0,0 +1,61 @@
// Package wecom 提供企业微信外部系统 Adapter。
package wecom
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
var credentialAAD = []byte("junhong-wecom-credential-v1")
// CredentialCipher 使用 AES-256-GCM 加解密企业微信敏感凭据。
type CredentialCipher struct {
aead cipher.AEAD
}
// NewCredentialCipher 从 32 字节 Base64 密钥创建凭据加密器。
func NewCredentialCipher(encodedKey string) (*CredentialCipher, error) {
key, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil || len(key) != 32 {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信凭据加密密钥必须是 32 字节 Base64")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密器失败")
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密模式失败")
}
return &CredentialCipher{aead: aead}, nil
}
// Encrypt 加密单个敏感凭据,返回 nonce 与密文组合。
func (c *CredentialCipher) Encrypt(plaintext string) ([]byte, error) {
if c == nil || c.aead == nil || plaintext == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid)
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "生成企业微信凭据随机数失败")
}
return c.aead.Seal(nonce, nonce, []byte(plaintext), credentialAAD), nil
}
// Decrypt 解密单个企业微信敏感凭据。
func (c *CredentialCipher) Decrypt(ciphertext []byte) (string, error) {
if c == nil || c.aead == nil || len(ciphertext) <= c.aead.NonceSize() {
return "", errors.New(errors.CodeWeComCredentialInvalid)
}
nonce := ciphertext[:c.aead.NonceSize()]
plaintext, err := c.aead.Open(nil, nonce, ciphertext[c.aead.NonceSize():], credentialAAD)
if err != nil {
return "", errors.Wrap(errors.CodeWeComCredentialInvalid, err, "解密企业微信凭据失败")
}
return string(plaintext), nil
}

View File

@@ -0,0 +1,185 @@
package wecom
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// DirectoryTokenProvider 定义通讯录客户端取得应用 access_token 的边界。
type DirectoryTokenProvider interface {
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
}
// DirectoryClient 拉取企业微信应用可见成员,不读取手机号或邮箱。
type DirectoryClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewDirectoryClient 创建企业微信通讯录客户端。
func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *DirectoryClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &DirectoryClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// ListVisibleMembers 拉取根部门及其子部门中当前应用可见的成员。
func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID uint) ([]wecomapp.DirectoryMember, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return nil, err
}
request, err := c.newListRequest(ctx, token)
if err != nil {
return nil, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "department_id": constants.WeComRootDepartmentID, "fetch_child": true,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return nil, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt)
}
defer response.Body.Close()
result, err := c.readResponse(response)
if err != nil {
return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 {
providerCode := strconv.FormatInt(result.ErrCode, 10)
if result.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt)
}
members := normalizeRemoteMembers(result.UserList)
if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result, len(members), startedAt); err != nil {
return nil, err
}
return members, nil
}
func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/user/simplelist")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
query.Set("department_id", strconv.FormatInt(constants.WeComRootDepartmentID, 10))
query.Set("fetch_child", "1")
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信通讯录请求失败")
}
return request, nil
}
type directoryResponse struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
UserList []directoryMember `json:"userlist"`
}
type directoryMember struct {
UserID string `json:"userid"`
Name string `json:"name"`
Department []int64 `json:"department"`
}
func (c *DirectoryClient) readResponse(response *http.Response) (directoryResponse, error) {
var result directoryResponse
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败")
}
if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大")
}
if err := sonic.Unmarshal(body, &result); err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败")
}
return result, nil
}
func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember {
seen := make(map[string]struct{}, len(source))
result := make([]wecomapp.DirectoryMember, 0, len(source))
for _, member := range source {
userID := strings.ToLower(strings.TrimSpace(member.UserID))
if userID == "" {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
name := strings.TrimSpace(member.Name)
if name == "" {
name = userID
}
result = append(result, wecomapp.DirectoryMember{UserID: userID, Name: name, DepartmentIDs: member.Department})
}
return result
}
func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, result directoryResponse, memberCount int, startedAt time.Time) error {
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: status,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return err
}
func (c *DirectoryClient) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信通讯录接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录同步失败,请检查应用可见范围")
}
var _ TokenIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -0,0 +1,89 @@
package wecom
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// MemberRepository 持久化企业微信应用可见成员快照。
type MemberRepository struct {
db *gorm.DB
}
// NewMemberRepository 创建企业微信成员快照 Repository。
func NewMemberRepository(db *gorm.DB) *MemberRepository {
return &MemberRepository{db: db}
}
// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。
func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || r.db == nil {
return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
return err
}
if len(members) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
DoUpdates: clause.Assignments(map[string]any{
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
}),
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
return nil
}
// ListVisible 分页查询指定应用当前可见成员。
func (r *MemberRepository) ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComMember{}).
Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled).
Where("tb_wecom_member.application_id = ? AND tb_wecom_member.visible = ?", applicationID, true)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
query = query.Where("tb_wecom_member.name ILIKE ? OR tb_wecom_member.userid ILIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信可见成员失败")
}
var members []model.WeComMember
if err := query.Select("tb_wecom_member.*").Order("tb_wecom_member.name ASC, tb_wecom_member.userid ASC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败")
}
return members, total, nil
}
// GetVisible 查询可用于账号绑定的应用可见成员。
func (r *MemberRepository) GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) {
var member model.WeComMember
if err := r.db.WithContext(ctx).Model(&model.WeComMember{}).
Select("tb_wecom_member.*").
Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled).
Where("tb_wecom_member.application_id = ? AND tb_wecom_member.userid = ? AND tb_wecom_member.visible = ?", applicationID, strings.ToLower(strings.TrimSpace(userID)), true).
First(&member).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "所选成员不在企业微信应用可见范围内")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败")
}
return &member, nil
}

View File

@@ -0,0 +1,83 @@
package wecom
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SceneRepository 持久化企业微信审批业务场景当前配置。
type SceneRepository struct {
db *gorm.DB
}
// NewSceneRepository 创建企业微信审批场景 Repository。
func NewSceneRepository(db *gorm.DB) *SceneRepository {
return &SceneRepository{db: db}
}
// FindForUpdate 在事务内锁定指定业务场景。
func (r *SceneRepository) FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error) {
var scene model.WeComApprovalScene
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_type = ?", businessType).First(&scene).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return &scene, nil
}
// Create 创建企业微信审批场景配置。
func (r *SceneRepository) Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error {
if err := tx.WithContext(ctx).Create(scene).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批场景失败")
}
return nil
}
// Update 更新企业微信审批场景当前配置。
func (r *SceneRepository) Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error {
if err := tx.WithContext(ctx).Model(&model.WeComApprovalScene{}).Where("id = ?", scene.ID).Updates(map[string]any{
"application_id": scene.ApplicationID, "template_id": scene.TemplateID, "template_name": scene.TemplateName,
"control_mapping": scene.ControlMapping, "template_snapshot": scene.TemplateSnapshot,
"template_fingerprint": scene.TemplateFingerprint,
"status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
"updated_by": scene.UpdatedBy, "updated_at": scene.UpdatedAt,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批场景失败")
}
return nil
}
// List 分页查询企业微信审批场景配置。
func (r *SceneRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComApprovalScene{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信审批场景失败")
}
var scenes []model.WeComApprovalScene
if err := query.Order("business_type ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&scenes).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return scenes, total, nil
}
// GetEnabled 查询指定业务类型当前启用的企微审批场景。
func (r *SceneRepository) GetEnabled(ctx context.Context, businessType string) (*model.WeComApprovalScene, error) {
var scene model.WeComApprovalScene
if err := r.db.WithContext(ctx).Where("business_type = ? AND status = ?", businessType, constants.StatusEnabled).First(&scene).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景未配置或已禁用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return &scene, nil
}

View File

@@ -0,0 +1,240 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TemplateClient 读取企微后台已有审批模板的控件结构。
type TemplateClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewTemplateClient 创建企业微信审批模板详情客户端。
func NewTemplateClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *TemplateClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &TemplateClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// GetTemplateDetail 获取模板详情并只投影控件结构,不保存审批节点和审批人规则。
func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (wecomapp.TemplateDefinition, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return wecomapp.TemplateDefinition{}, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
request, err := c.newRequest(ctx, token, templateID)
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "template_id": templateID,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信模板详情请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信模板详情响应无效", startedAt)
}
definition, errCode, errMsg, err := parseTemplateDefinition(templateID, body)
if err != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 {
providerCode := strconv.FormatInt(errCode, 10)
if errCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, providerCode, errMsg, startedAt)
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"errcode": errCode, "control_count": len(definition.Controls)},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
return definition, nil
}
func (c *TemplateClient) newRequest(ctx context.Context, token, templateID string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/gettemplatedetail")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
body, err := sonic.Marshal(map[string]string{"template_id": templateID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板详情请求失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信模板详情请求失败")
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
func parseTemplateDefinition(templateID string, body []byte) (wecomapp.TemplateDefinition, int64, string, error) {
var payload map[string]any
if err := sonic.Unmarshal(body, &payload); err != nil {
return wecomapp.TemplateDefinition{}, 0, "", err
}
errCode := numberToInt64(payload["errcode"])
errMsg, _ := payload["errmsg"].(string)
if errCode != 0 {
return wecomapp.TemplateDefinition{}, errCode, errMsg, nil
}
content, ok := payload["template_content"].(map[string]any)
if !ok {
return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeServiceUnavailable, "企业微信模板详情缺少模板内容")
}
controls := make([]wecomapp.TemplateControl, 0)
seen := make(map[string]struct{})
collectTemplateControls(content["controls"], &controls, seen)
if len(controls) == 0 {
return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeInvalidParam, "企业微信模板没有可映射控件")
}
name := localizedText(content["template_name"])
if name == "" {
name = localizedText(content["template_names"])
}
return wecomapp.TemplateDefinition{
TemplateID: templateID, Name: name, Controls: controls,
}, errCode, errMsg, nil
}
func collectTemplateControls(value any, result *[]wecomapp.TemplateControl, seen map[string]struct{}) {
switch typed := value.(type) {
case []any:
for _, item := range typed {
collectTemplateControls(item, result, seen)
}
case map[string]any:
if property, ok := typed["property"].(map[string]any); ok {
id, _ := property["id"].(string)
controlType, _ := property["control"].(string)
if id != "" && controlType != "" {
if _, exists := seen[id]; !exists {
seen[id] = struct{}{}
*result = append(*result, wecomapp.TemplateControl{
ID: id, Type: controlType, Title: localizedText(property["title"]),
Required: numberToInt64(property["require"]) == 1, OptionKeys: collectOptionKeys(typed),
})
}
}
}
for _, item := range typed {
collectTemplateControls(item, result, seen)
}
}
}
func collectOptionKeys(value any) []string {
keys := make([]string, 0)
seen := make(map[string]struct{})
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case []any:
for _, item := range typed {
walk(item)
}
case map[string]any:
if key, ok := typed["key"].(string); ok && key != "" {
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
keys = append(keys, key)
}
}
for _, item := range typed {
walk(item)
}
}
}
walk(value)
return keys
}
func localizedText(value any) string {
if text, ok := value.(string); ok {
return text
}
if items, ok := value.([]any); ok {
for _, item := range items {
if translated, ok := item.(map[string]any); ok {
if text, ok := translated["text"].(string); ok && text != "" {
return text
}
}
}
}
return ""
}
func numberToInt64(value any) int64 {
switch number := value.(type) {
case float64:
return int64(number)
case int64:
return number
case int:
return int64(number)
default:
return 0
}
}
func (c *TemplateClient) completeTemplateFailure(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信模板详情接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeInvalidParam, "企业微信审批模板无效或不可访问")
}

View File

@@ -0,0 +1,296 @@
package wecom
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
const releaseTokenLockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`
// TokenApplicationRepository 定义 access_token Provider 所需的应用配置查询边界。
type TokenApplicationRepository interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, 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 边界。
type TokenIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
}
// TokenProvider 按应用缓存企业微信 access_token并用 Redis 短锁抑制并发回源。
type TokenProvider struct {
repo TokenApplicationRepository
cipher TokenCredentialCipher
redis *redis.Client
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
logger *zap.Logger
now func() time.Time
}
// NewTokenProvider 创建企业微信 access_token Provider。
func NewTokenProvider(
repo TokenApplicationRepository,
cipher TokenCredentialCipher,
redisClient *redis.Client,
integration TokenIntegrationLog,
baseURL string,
timeout time.Duration,
logger *zap.Logger,
) *TokenProvider {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &TokenProvider{
repo: repo, cipher: cipher, redis: redisClient, integration: integration,
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"),
logger: logger, now: time.Now,
}
}
// GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。
func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) {
if p == nil || p.repo == nil || p.cipher == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 {
return "", errors.New(errors.CodeWeComCredentialInvalid)
}
cacheKey := constants.RedisWeComAccessTokenKey(applicationID)
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
return token, nil
} else if err != nil {
p.warnRedis("读取企业微信 access_token 缓存失败,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if p.redis == nil {
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
lockKey := constants.RedisWeComAccessTokenLockKey(applicationID)
lockValue := uuid.NewString()
acquired, err := p.redis.SetNX(ctx, lockKey, lockValue, constants.WeComTokenLockTTL).Result()
if err != nil {
p.warnRedis("获取企业微信 access_token 回源锁失败,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if !acquired {
return p.waitForToken(ctx, applicationID, cacheKey)
}
defer p.releaseLock(ctx, lockKey, lockValue, applicationID)
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
return token, nil
}
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
// Invalidate 删除指定应用的 access_token 缓存。
func (p *TokenProvider) Invalidate(ctx context.Context, applicationID uint) {
if p == nil || p.redis == nil || applicationID == 0 {
return
}
if err := p.redis.Del(ctx, constants.RedisWeComAccessTokenKey(applicationID)).Err(); err != nil {
p.warnRedis("删除企业微信 access_token 缓存失败", err, applicationID)
}
}
func (p *TokenProvider) getCachedToken(ctx context.Context, cacheKey string) (string, bool, error) {
if p.redis == nil {
return "", false, nil
}
token, err := p.redis.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, err
}
return token, token != "", nil
}
func (p *TokenProvider) waitForToken(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
timer := time.NewTimer(constants.WeComTokenWaitTimeout)
defer timer.Stop()
ticker := time.NewTicker(constants.WeComTokenWaitPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", errors.Wrap(errors.CodeServiceUnavailable, ctx.Err(), "等待企业微信 access_token 已取消")
case <-timer.C:
return "", errors.New(errors.CodeServiceUnavailable, "企业微信 access_token 正在刷新,请稍后重试")
case <-ticker.C:
token, found, err := p.getCachedToken(ctx, cacheKey)
if err != nil {
p.warnRedis("等待企业微信 access_token 时 Redis 不可用,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if found {
return token, nil
}
}
}
}
func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
application, err := p.repo.GetEnabled(ctx, applicationID)
if err != nil {
return "", err
}
secret, err := p.cipher.Decrypt(application.SecretCiphertext)
if err != nil {
return "", err
}
request, err := p.newTokenRequest(ctx, application.CorpID, secret)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return "", err
}
startedAt := p.now()
response, err := p.httpClient.Do(request)
if err != nil {
return "", p.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信 Token 请求失败", startedAt)
}
defer response.Body.Close()
tokenResponse, err := p.readTokenResponse(response)
if err != nil {
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信 Token 响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || tokenResponse.ErrCode != 0 || tokenResponse.AccessToken == "" {
providerCode := strconv.FormatInt(tokenResponse.ErrCode, 10)
if tokenResponse.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, tokenResponse.ErrMsg, startedAt)
}
ttl := time.Duration(tokenResponse.ExpiresIn)*time.Second - constants.WeComTokenRefreshAdvance
if ttl <= 0 {
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_expires_in", "企业微信 access_token 有效期无效", startedAt)
}
if err := p.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, tokenResponse, startedAt); err != nil {
return "", err
}
if p.redis != nil {
if err := p.redis.Set(ctx, cacheKey, tokenResponse.AccessToken, ttl).Err(); err != nil {
p.warnRedis("缓存企业微信 access_token 失败,本次继续使用直连结果", err, applicationID)
}
}
if err := p.repo.MarkConnected(ctx, applicationID, p.now()); err != nil && p.logger != nil {
p.logger.Error("记录企业微信最近连接时间失败,本次 token 仍可使用", zap.Uint("application_id", applicationID), zap.Error(err))
}
return tokenResponse.AccessToken, nil
}
func (p *TokenProvider) newTokenRequest(ctx context.Context, corpID, secret string) (*http.Request, error) {
endpoint, err := url.Parse(p.baseURL + "/cgi-bin/gettoken")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("corpid", corpID)
query.Set("corpsecret", secret)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信 Token 请求失败")
}
return request, nil
}
type tokenResponse struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
func (p *TokenProvider) readTokenResponse(response *http.Response) (tokenResponse, error) {
var result tokenResponse
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信 Token 响应失败")
}
if int64(len(body)) > constants.WeComMaxResponseBodyBytes {
return result, errors.New(errors.CodeServiceUnavailable, "企业微信 Token 响应过大")
}
if err := sonic.Unmarshal(body, &result); err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信 Token 响应失败")
}
return result, nil
}
func (p *TokenProvider) completeSuccess(ctx context.Context, integrationID string, status int, result tokenResponse, startedAt time.Time) error {
_, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: status,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"errcode": result.ErrCode, "expires_in": result.ExpiresIn, "token_present": result.AccessToken != ""},
DurationMS: p.now().Sub(startedAt).Milliseconds(),
})
return err
}
func (p *TokenProvider) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信 Token 接口返回失败"
}
_, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: p.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "企业微信连接失败,请检查应用配置和可信 IP")
}
func (p *TokenProvider) releaseLock(ctx context.Context, lockKey, lockValue string, applicationID uint) {
if p.redis == nil {
return
}
if err := p.redis.Eval(ctx, releaseTokenLockScript, []string{lockKey}, lockValue).Err(); err != nil {
p.warnRedis("释放企业微信 access_token 回源锁失败", err, applicationID)
}
}
func (p *TokenProvider) warnRedis(message string, err error, applicationID uint) {
if p.logger != nil {
p.logger.Warn(message, zap.Uint("application_id", applicationID), zap.Error(err))
}
}

View File

@@ -14,6 +14,9 @@ type Account struct {
UserType int `gorm:"column:user_type;type:int;not null;index;comment:用户类型 1=超级管理员 2=平台用户 3=代理账号 4=企业账号" json:"user_type"` UserType int `gorm:"column:user_type;type:int;not null;index;comment:用户类型 1=超级管理员 2=平台用户 3=代理账号 4=企业账号" json:"user_type"`
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID代理账号必填" json:"shop_id,omitempty"` ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID代理账号必填" json:"shop_id,omitempty"`
EnterpriseID *uint `gorm:"column:enterprise_id;index;comment:企业ID企业账号必填" json:"enterprise_id,omitempty"` EnterpriseID *uint `gorm:"column:enterprise_id;index;comment:企业ID企业账号必填" json:"enterprise_id,omitempty"`
WeComCorpID string `gorm:"column:wecom_corp_id;type:varchar(64);comment:绑定的企业微信企业 ID" json:"wecom_corp_id"`
WeComUserID string `gorm:"column:wecom_userid;type:varchar(64);comment:绑定的企业微信成员 userid" json:"wecom_userid"`
WeComName string `gorm:"column:wecom_name;type:varchar(100);comment:企业微信成员姓名快照" json:"wecom_name"`
IsPrimary bool `gorm:"column:is_primary;type:boolean;default:false;comment:是否为店铺主账号(默认 false" json:"is_primary"` IsPrimary bool `gorm:"column:is_primary;type:boolean;default:false;comment:是否为店铺主账号(默认 false" json:"is_primary"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
} }

View File

@@ -84,10 +84,11 @@ type AgentRechargeRecord struct {
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"`
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表线下支付时必填最多5个微信支付时为空" json:"payment_voucher_key"` PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表线下支付时必填最多5个微信支付时为空" json:"payment_voucher_key"`
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`

View File

@@ -0,0 +1,78 @@
package model
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
// AssetPackageBatchOrderTask 资产套餐批量订购任务。
type AssetPackageBatchOrderTask struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
TaskNo string `gorm:"column:task_no;type:varchar(50);not null;uniqueIndex" json:"task_no"`
PackageID uint `gorm:"column:package_id;not null;index" json:"package_id"`
PackageCode string `gorm:"column:package_code;type:varchar(100);not null;default:''" json:"package_code"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:''" json:"package_name"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:''" json:"file_name"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null" json:"storage_key"`
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]'" json:"voucher_keys"`
Status int `gorm:"column:status;type:int;not null;default:1;index" json:"status"`
TotalCount int `gorm:"column:total_count;not null;default:0" json:"total_count"`
SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"`
FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"`
ResultItems AssetPackageBatchOrderResultItems `gorm:"column:result_items;type:jsonb;not null;default:'[]'" json:"result_items"`
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:''" json:"error_message"`
CreatorUserType int `gorm:"column:creator_user_type;type:int;not null;default:0" json:"creator_user_type"`
CreatorShopID uint `gorm:"column:creator_shop_id;not null;default:0" json:"creator_shop_id"`
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:''" json:"creator_name"`
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}
// TableName 指定批量订购任务表名。
func (AssetPackageBatchOrderTask) TableName() string {
return "tb_asset_package_batch_order_task"
}
// AssetPackageBatchOrderResultItem 批量订购单行结果。
type AssetPackageBatchOrderResultItem struct {
Line int `json:"line"`
AssetIdentifier string `json:"asset_identifier"`
Status int `json:"status"`
OrderID uint `json:"order_id,omitempty"`
OrderNo string `json:"order_no,omitempty"`
Amount int64 `json:"amount,omitempty"`
Reason string `json:"reason,omitempty"`
}
// AssetPackageBatchOrderResultItems 批量订购单行结果集合。
type AssetPackageBatchOrderResultItems []AssetPackageBatchOrderResultItem
// Value 将批量订购单行结果序列化为 JSONB。
func (items AssetPackageBatchOrderResultItems) Value() (driver.Value, error) {
if items == nil {
return "[]", nil
}
return json.Marshal(items)
}
// Scan 从 JSONB 读取批量订购单行结果。
func (items *AssetPackageBatchOrderResultItems) Scan(value any) error {
if value == nil {
*items = AssetPackageBatchOrderResultItems{}
return nil
}
data, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(data, items)
}

View File

@@ -13,6 +13,10 @@ type DeviceImportTask struct {
gorm.Model gorm.Model
BaseModel `gorm:"embedded"` BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"` TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"`
OperationType string `gorm:"column:operation_type;type:varchar(32);not null;default:'import';comment:任务业务类型(import/assign_shop/assign_series)" json:"operation_type"`
TargetID *uint `gorm:"column:target_id;comment:批量分配目标店铺或套餐系列ID" json:"target_id"`
OperatorType int `gorm:"column:operator_type;type:int;not null;default:0;comment:任务创建时操作者类型快照" json:"operator_type"`
OperatorShopID *uint `gorm:"column:operator_shop_id;comment:任务创建时操作者店铺ID快照" json:"operator_shop_id"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"` StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"`
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"` FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`

View File

@@ -40,6 +40,10 @@ type AccountResponse struct {
ShopName string `json:"shop_name,omitempty" description:"店铺名称"` ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
EnterpriseID *uint `json:"enterprise_id,omitempty" description:"关联企业ID"` EnterpriseID *uint `json:"enterprise_id,omitempty" description:"关联企业ID"`
EnterpriseName string `json:"enterprise_name,omitempty" description:"企业名称"` EnterpriseName string `json:"enterprise_name,omitempty" description:"企业名称"`
WeComCorpID string `json:"wecom_corp_id" description:"已绑定的企业微信企业 ID"`
WeComUserID string `json:"wecom_userid" description:"已绑定的企业微信成员 userid"`
WeComName string `json:"wecom_name" description:"已绑定的企业微信成员姓名快照"`
WeComBound bool `json:"wecom_bound" description:"是否已绑定企业微信成员"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
StatusName string `json:"status_name" description:"状态名称(中文)"` StatusName string `json:"status_name" description:"状态名称(中文)"`
Creator uint `json:"creator" description:"创建人ID"` Creator uint `json:"creator" description:"创建人ID"`
@@ -97,3 +101,15 @@ type UpdateStatusParams struct {
IDReq IDReq
UpdateStatusRequest UpdateStatusRequest
} }
// BindAccountWeComRequest 绑定系统账号与企业微信可见成员请求。
type BindAccountWeComRequest struct {
ApplicationID uint `json:"application_id" validate:"required,gt=0" minimum:"1" description:"企业微信应用配置 ID"`
UserID string `json:"userid" validate:"required,min=1,max=64" minLength:"1" maxLength:"64" description:"企业微信成员 userid"`
}
// BindAccountWeComParams 绑定系统账号与企业微信成员参数。
type BindAccountWeComParams struct {
IDReq
BindAccountWeComRequest
}

Some files were not shown because too many files have changed in this diff Show More