From 1fee19feeb40e80aeabba50b2b807e3b90cb5693 Mon Sep 17 00:00:00 2001 From: break Date: Tue, 28 Jul 2026 15:50:12 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=80=E4=BA=9B=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scratch/tech-global-audit/审计覆盖基线.md | 6 +-- cmd/worker/main.go | 1 + docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md | 8 ++-- docs/7月迭代/七月迭代人工验收清单.md | 14 ++++--- internal/bootstrap/services.go | 1 + .../infrastructure/notification/registry.go | 32 +++++++++++++-- .../packageexpiry/reminder_publisher.go | 38 +++++++++++++++++- .../infrastructure/shop/recipient_resolver.go | 8 ++-- .../infrastructure/wallet/credit_event.go | 18 +++++++++ internal/service/refund/approval_decision.go | 5 ++- internal/service/refund/service.go | 39 ++++++++++++++++++- pkg/constants/notification.go | 8 +++- 12 files changed, 151 insertions(+), 27 deletions(-) diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index d103496..2d3d4ad 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -58,15 +58,15 @@ | 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(列表和数量是普通受权读取;手动入口仅允许超级管理员提交同一幂等扫描任务,操作者进入 Access Log 和任务日志,不直接修改业务事实) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存个人客户通知与已读状态 | N/A(不调用企业微信、短信、邮件或其他外部系统) | 每日或手动任务按资产、到期日、节点和个人客户生成稳定事件 ID,在单个 GORM 事务中幂等写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox | +| 套餐临期列表、数量与每日/手动 15/7/3 天节点提醒 | N/A(列表和数量是普通受权读取;手动入口仅允许超级管理员提交同一幂等扫描任务,操作者进入 Access Log 和任务日志,不直接修改业务事实) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存店铺账号、平台业务员和个人账号通知及已读状态 | N/A(不调用企业微信、短信、邮件或其他外部系统) | 每日或手动任务按资产、到期日和节点生成稳定事件 ID,同一 GORM 事务内向店铺动态接收人写入 `notification.admin.dynamic.requested`,并向绑定个人账号写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox | | 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录连接凭据;明文读取仅允许超级管理员并进入 Access Log,统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A(连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) | | 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A(同步和绑定均为同步事务,不产生必须可靠投递的提交后副作用) | | 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`,记录业务类型、应用 ID、模板 ID、状态和最近校验时间;不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时,不保存 access_token 或完整外部响应 | N/A(配置保存为同步事务,不产生必须可靠投递的提交后副作用) | | 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`,记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应;提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`;Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 | | 企业微信审批加密回调与详情终态同步 | 回调为外部自动事实,不伪造人工 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 Log,Secret、access_token、media_id 和附件正文仍禁止记录 | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 | +| 员工线下代充值申请与企微终态入账 | 申请保存以真实提交人及明文业务快照留痕;资金成功 Audit Event 延期至既有统一钱包治理任务,企微自动终态不伪造人工审批人 | `tb_agent_recharge_record`、`tb_approval_instance` 和 `tb_wecom_approval_context` 同事务保存申请事实;approved 通过 `topup + recharge_record_id` 唯一成功钱包流水幂等入账,其他终态不修改钱包,通过后撤销不自动冲正;`tb_notification` 保存到账通知 | 申请创建本身不外呼;后续附件上传、applyevent、详情与恢复沿用企微 Integration Log,业务参数按用户确认保存明文,日志仍不记录 Secret、access_token、media_id 或附件正文 | 创建事务写 `approval.submission.requested`;标准终态写 `approval.terminal_decision.recorded`,approved 入账事务再写 `wallet.agent_main.credited` 和目标店铺的 `notification.admin.dynamic.requested`;在线充值复用同一入账接缝;新审批单禁止旧人工确认或驳回入口绕过 | +| 退款申请与企微终态处理 | 申请以真实提交人和订单/金额/凭证明文快照留痕;企微自动终态不伪造本地人工审批人,资金与佣金 Audit Event 继续沿用既有治理延期口径 | `tb_refund_request`、通用审批实例和企微上下文同事务保存;approved 条件更新订单与退款单,代理主钱包按 refund ID、资产钱包按退款单号复核成功回款;佣金按记录锁定并失效,套餐按订单及换货迁移关系幂等失效;`tb_notification` 保存退款完成通知 | 申请创建不外呼;附件、applyevent、详情、回调和恢复沿用企微 Integration Log,业务参数明文保存在业务/审批快照中但不复制到 Integration Log,Secret、access_token、media_id 和附件正文仍禁止记录 | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;退款事务幂等写目标店铺的 `notification.admin.dynamic.requested`;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 | | 退款与线下代充值旧审批入口发布切换 | 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 | diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 197eced..5788665 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -361,6 +361,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con refundService.SetAgentWalletRefundService( walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil), ) + refundService.SetNotificationOutbox(outbox.NewRepository()) decisionDispatcher := approvalApp.NewDecisionDispatcher( approvalInfra.NewDecisionDeliveryStore(runtime.db), map[string]approvalApp.BusinessDecisionHandler{ diff --git a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md index 2b75b03..ff00382 100644 --- a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md +++ b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md @@ -1456,7 +1456,7 @@ 8. 代理按既有店铺层级和退款业务权限查看本店及可管理下级退款,不再按creator隔离;代理不见审批人、内部意见或审批人附件,平台/超级管理员也须有退款业务查看权限。 9. 企微终态通过Outbox和可靠Worker处理,processing_status固定0未触发、1处理中、2处理成功、3处理失败,局部失败可安全重试,不使用进程内Goroutine。 -完成标准:审批状态与业务处理状态分离,重复终态不重复回款/扣佣/失效套餐,失败任务可可靠重试;实现期必须通过可编程Adapter自动化和真实企微两张独立退款验收(代理代提交同意、平台本人提交拒绝),INT-06不能替代。 +完成标准:审批状态与业务处理状态分离,重复终态不重复回款/扣佣/失效套餐或发送通知;退款完成后向目标店铺全部启用店铺账号及当前有效绑定的平台业务员发送站内通知;失败任务可可靠重试;实现期必须通过可编程Adapter自动化和真实企微两张独立退款验收(代理代提交同意、平台本人提交拒绝),INT-06不能替代。 ``` ## UR#34 充值审核流程 @@ -1494,7 +1494,7 @@ 交互规则:在线状态每3秒轮询本地状态,页面不可见暂停;每次用户主动创建支付都使用新的request_id创建全新充值单和支付单,旧单等待回调或后端查单自然收敛;线下提交失败保留表单。 -完成标准:微信、支付宝、第三方关闭/迟到成功、重复回调后的最终状态、支付成功但入账失败补偿、线下审批通过入账和驳回状态均可展示;在线和线下实际到账后均发送代理站内到账通知。 +完成标准:微信、支付宝、第三方关闭/迟到成功、重复回调后的最终状态、支付成功但入账失败补偿、线下审批通过入账和驳回状态均可展示;在线和线下实际到账后,均向目标店铺全部启用店铺账号及当前有效绑定的平台业务员发送站内到账通知。 ``` ### 后端研发需求 @@ -1518,7 +1518,7 @@ 线下规则:仅平台/超管创建,金额大于0,目标店铺和1~5个结构化付款凭证必填,金额提交后固定;创建后提交企微,审批只能同意或拒绝。通过后自动增加代理主钱包并写流水,无操作密码;recharge:{recharge_no}防重;驳回终结原单且不支持退回/重提,撤销/删除为已关闭;通过后撤销且已入账不自动扣回。 -完成标准:两条路径和权限严格隔离,支付/审批/钱包处理状态独立,支付成功但入账失败可补偿,支付查单与企微终态重复同步不重复加钱;钱包到账后向目标代理发送防重站内通知。本地支付网络使用可控Adapter,真实微信/支付宝在测试环境手工验收,真实企微线下充值为实现门禁。 +完成标准:两条路径和权限严格隔离,支付/审批/钱包处理状态独立,支付成功但入账失败可补偿,支付查单与企微终态重复同步不重复加钱;钱包到账后向目标店铺全部启用店铺账号及当前有效绑定的平台业务员发送防重站内通知。本地支付网络使用可控Adapter,真实微信/支付宝在测试环境手工验收,真实企微线下充值为实现门禁。 ``` ## UR#33 套餐临期提醒 @@ -1577,7 +1577,7 @@ 规则:按Asia/Shanghai自然日计算0-15天;已过期和不可预计资产不计入;临期页0-3天优先,再按最终到期升序;普通列表不改排序。 -通知:每日任务按package_usage_id+recipient+channel+node防重,节点为15/7/3天;漏跑只补当前最近未发送节点;接收店铺主账号和业务员;只发站内通知。 +通知:每日任务按package_usage_id+recipient+channel+node防重,节点为15/7/3天;漏跑只补当前最近未发送节点;接收资产所属店铺的全部启用店铺账号、当前有效绑定的平台业务员及资产绑定的启用个人账号;只发站内通知。 完成标准:查询、首页计数、C端和通知共用同一最终到期算法,重复任务不重复通知。 ``` diff --git a/docs/7月迭代/七月迭代人工验收清单.md b/docs/7月迭代/七月迭代人工验收清单.md index 230733f..65ab57a 100644 --- a/docs/7月迭代/七月迭代人工验收清单.md +++ b/docs/7月迭代/七月迭代人工验收清单.md @@ -4,9 +4,9 @@ ## 一、业务需求 -- [ ] **UR#33 套餐临期提醒**:核对 0~15 天临期列表、0~3 天置顶、Dashboard 数量、C 端续费入口,以及 15/7/3 天站内通知和防重。 -- [ ] **UR#34 充值审核流程**:核对代理在线扫码充值与平台线下代充值隔离;支付、企微审批、重复回调、入账补偿和到账通知正确。 -- [ ] **UR#35 退款审核**:核对企微审批、整单终结、钱包回充、套餐失效和佣金处理;审批状态与业务处理状态分开,重复终态不重复退款。 +- [ ] **UR#33 套餐临期提醒**:核对 0~15 天临期列表、0~3 天置顶、Dashboard 数量、C 端续费入口,以及 15/7/3 天向全部有效店铺账号、有效绑定的平台业务员和资产绑定个人账号发送防重站内通知。 +- [ ] **UR#34 充值审核流程**:核对代理在线扫码充值与平台线下代充值隔离;支付、企微审批、重复回调和入账补偿正确;到账后通知全部有效店铺账号及有效绑定的平台业务员。 +- [ ] **UR#35 退款审核**:核对企微审批、整单终结、钱包回充、套餐失效和佣金处理;重复终态不重复退款或通知;退款完成后通知全部有效店铺账号及有效绑定的平台业务员。 - [ ] **UR#36 批量订购套餐**:核对 CSV 上传、跨代理资产、统一支付方式、逐行结果、部分成功、失败原因、任务恢复及重复执行不重复扣款。 - [ ] **UR#37 企业微信审核流转**:核对企微应用、模板、成员绑定、代理固定代提交人、回调、主动同步和异常恢复;系统内不得提供本地同意或拒绝入口。 - [ ] **UR#38 不同渠道额度处理**:核对角色默认信用只影响新店铺;已有店铺单独调额;扣款、冻结、退款、充值及并发修改遵守代理主钱包信用边界。 @@ -74,12 +74,15 @@ Authorization: Bearer <超级管理员Token> } ``` -接口只允许超级管理员调用,并立即提交与每日 03:00 相同的异步任务。Worker 只处理当天恰好剩余 15、7、3 天的资产,随后通过 Outbox 生成个人客户站内通知。 +接口只允许超级管理员调用,并立即提交与每日 03:00 相同的异步任务。Worker 只处理当天恰好剩余 15、7、3 天的资产,随后通过 Outbox 向全部有效店铺账号、当前有效平台业务员和资产绑定的个人账号生成站内通知。 - [ ] API 返回成功后 Worker 日志出现“开始执行套餐临期节点提醒”。 - [ ] 15、7、3 天资产产生通知;其他剩余天数不产生节点通知。 +- [ ] 所属店铺的所有启用店铺账号都收到通知,不限于主账号。 +- [ ] 店铺当前绑定且启用的平台业务员收到通知;已禁用、已换绑或非平台账号不收到。 +- [ ] 资产绑定的启用个人账号仍收到通知。 - [ ] 同一资产、到期日、节点和接收人重复触发不重复通知。 -- [ ] 未绑定启用个人客户的资产不产生错误通知。 +- [ ] 未绑定启用个人账号的资产仍可正常向店铺侧通知,不产生错误个人通知。 - [ ] Worker 或 Outbox 暂时失败后可重试,且最终仍只有一条通知。 ## 四、全链路检查 @@ -98,4 +101,3 @@ Authorization: Bearer <超级管理员Token> - [七月迭代技术方案(标准评审稿)](./7月迭代技术方案-标准评审稿.md) - [七月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md) - [七月迭代 AI 实施与验收操作手册](./七月迭代-AI实施与验收操作手册.md) - diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 1f11bcc..eccace1 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -295,6 +295,7 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Logger, ) refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) + refundService.SetNotificationOutbox(walletOutbox) 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) diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go index b4a8dd6..e5c64b2 100644 --- a/internal/infrastructure/notification/registry.go +++ b/internal/infrastructure/notification/registry.go @@ -64,10 +64,36 @@ func NewRegistry() *Registry { TitleTemplate: "套餐即将到期", BodyTemplate: "您的套餐即将到期,请及时查看并处理。", TemplateFields: map[string]struct{}{}, - RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}}, + RecipientKinds: map[string]struct{}{ + constants.NotificationRecipientKindAccount: {}, + constants.NotificationRecipientKindPersonalCustomer: {}, + }, AllowedRefTypes: map[string]struct{}{ - constants.NotificationRefTypePackage: {}, - constants.NotificationRefTypeAsset: {}, + constants.NotificationRefTypePackage: {}, + constants.NotificationRefTypeAsset: {}, + constants.NotificationRefTypeExpiringAsset: {}, + }, + }, + constants.NotificationTypeAgentRechargeCompleted: { + Type: constants.NotificationTypeAgentRechargeCompleted, Category: constants.NotificationCategorySystem, + Severity: constants.NotificationSeverityInfo, + TitleTemplate: "店铺充值已入账", + BodyTemplate: "店铺充值已成功入账,请进入充值详情查看。", + TemplateFields: map[string]struct{}{}, + RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}}, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypeAgentRecharge: {}, + }, + }, + constants.NotificationTypeRefundCompleted: { + Type: constants.NotificationTypeRefundCompleted, Category: constants.NotificationCategorySystem, + Severity: constants.NotificationSeverityInfo, + TitleTemplate: "店铺退款已完成", + BodyTemplate: "店铺退款已完成,请进入退款详情查看。", + TemplateFields: map[string]struct{}{}, + RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}}, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypeRefund: {}, }, }, constants.NotificationTypeExchangeShippingCreated: { diff --git a/internal/infrastructure/packageexpiry/reminder_publisher.go b/internal/infrastructure/packageexpiry/reminder_publisher.go index 3a559a9..128b5c0 100644 --- a/internal/infrastructure/packageexpiry/reminder_publisher.go +++ b/internal/infrastructure/packageexpiry/reminder_publisher.go @@ -18,7 +18,7 @@ import ( var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) -// ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。 +// ReminderPublisher 将临期节点事实转换为店铺和个人客户通知 Outbox。 type ReminderPublisher struct { db *gorm.DB outbox *outbox.Repository @@ -34,7 +34,7 @@ type recipientRow struct { CustomerID uint } -// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。 +// 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 未配置") @@ -49,6 +49,11 @@ func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.Expiri continue } key := recipientKey(candidate.AssetType, candidate.AssetID) + if candidate.ShopID != nil { + if err := p.appendShopNotification(ctx, tx, candidate, *candidate.ShopID); err != nil { + return err + } + } for customerID := range recipients[key] { if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil { return err @@ -59,6 +64,35 @@ func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.Expiri }) } +// appendShopNotification 按资产所属店铺写入一条动态接收人通知事件。 +func (p *ReminderPublisher) appendShopNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, shopID 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:shop:%d", assetCode, candidate.AssetID, expiryDate, node, shopID) + assetID := strconv.FormatUint(uint64(candidate.AssetID), 10) + shopIDText := strconv.FormatUint(uint64(shopID), 10) + _, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10), + ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID, + Payload: notificationapp.AdminDynamicPayload{ + TargetKind: constants.NotificationTargetKindShop, TargetID: shopID, + NotificationType: constants.NotificationTypePackageExpiring, TemplateData: map[string]string{}, + RefType: constants.NotificationRefTypeExpiringAsset, RefID: shopIDText, + ExpiresAt: candidate.EstimatedFinalExpiresAt, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, 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) diff --git a/internal/infrastructure/shop/recipient_resolver.go b/internal/infrastructure/shop/recipient_resolver.go index e9476c2..cb9464f 100644 --- a/internal/infrastructure/shop/recipient_resolver.go +++ b/internal/infrastructure/shop/recipient_resolver.go @@ -13,7 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// RecipientResolver 按店铺当前独立归属解析主账号和可用平台业务员。 +// RecipientResolver 按店铺当前独立归属解析店铺账号和可用平台业务员。 type RecipientResolver struct { db *gorm.DB } @@ -25,7 +25,7 @@ func NewRecipientResolver(db *gorm.DB) *RecipientResolver { return &RecipientResolver{db: db} } -// ResolveNotificationRecipients 返回启用的店铺主账号和当前可用业务员账号 ID。 +// ResolveNotificationRecipients 返回全部启用的店铺账号和当前可用业务员账号 ID。 // // 解析只读取目标店铺当前保存的业务员 ID,不读取父店铺、祖先店铺或创建人。 func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) { @@ -46,8 +46,8 @@ func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, s query := r.db.WithContext(ctx).Model(&model.Account{}). Select("id"). - Where("status = ? AND shop_id = ? AND is_primary = ? AND user_type = ?", - constants.StatusEnabled, shopID, true, constants.UserTypeAgent) + Where("status = ? AND shop_id = ? AND user_type = ?", + constants.StatusEnabled, shopID, constants.UserTypeAgent) if target.BusinessOwnerAccountID != nil { query = query.Or("id = ? AND status = ? AND user_type = ?", *target.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform) diff --git a/internal/infrastructure/wallet/credit_event.go b/internal/infrastructure/wallet/credit_event.go index 3daa3aa..b7b1d09 100644 --- a/internal/infrastructure/wallet/credit_event.go +++ b/internal/infrastructure/wallet/credit_event.go @@ -4,6 +4,7 @@ import ( "context" "strconv" + 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/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -33,5 +34,22 @@ func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walle ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10), BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event, }) + if err != nil || event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge { + return err + } + rechargeID := strconv.FormatUint(uint64(event.ReferenceID), 10) + notificationEventID := "agent-recharge:" + rechargeID + ":completed" + _, err = w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "agent_recharge", AggregateID: rechargeID, + ResourceType: constants.NotificationRefTypeAgentRecharge, ResourceID: rechargeID, + BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, + Payload: notificationapp.AdminDynamicPayload{ + TargetKind: constants.NotificationTargetKindShop, TargetID: event.ShopID, + NotificationType: constants.NotificationTypeAgentRechargeCompleted, TemplateData: map[string]string{}, + RefType: constants.NotificationRefTypeAgentRecharge, RefID: rechargeID, + }, + }) return err } diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 8146f42..52676d1 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -85,7 +85,10 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T default: return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款") } - return s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID) + if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil { + return err + } + return s.appendCompletedNotification(ctx, tx, &refund) }) if err != nil { return err diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 0e92af9..0b80e05 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -14,8 +14,10 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" 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/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store" @@ -47,6 +49,7 @@ type Service struct { assetWalletStore *postgres.AssetWalletStore agentWalletRefundService *walletapp.RefundService refundApprovalCreation *refundapprovalapp.CreationService + notificationOutbox *outbox.Repository logger *zap.Logger } @@ -93,6 +96,11 @@ func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.Cr s.refundApprovalCreation = service } +// SetNotificationOutbox 注入退款完成后的可靠店铺通知 Outbox。 +func (s *Service) SetNotificationOutbox(repository *outbox.Repository) { + s.notificationOutbox = repository +} + // Create 创建退款申请 // 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { @@ -306,8 +314,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil { return err } - - return nil + return s.appendCompletedNotification(ctx, tx, refund) }); err != nil { return err } @@ -326,6 +333,34 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return nil } +// appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。 +func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error { + if refund == nil || refund.ShopID == nil { + return nil + } + if s.notificationOutbox == nil { + return errors.New(errors.CodeInternalError, "退款通知 Outbox 未配置") + } + refundID := fmt.Sprintf("%d", refund.ID) + eventID := "refund:" + refundID + ":completed" + _, err := s.notificationOutbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "refund", AggregateID: refundID, + ResourceType: constants.NotificationRefTypeRefund, ResourceID: refundID, + BusinessKey: eventID, + Payload: notificationapp.AdminDynamicPayload{ + TargetKind: constants.NotificationTargetKindShop, TargetID: *refund.ShopID, + NotificationType: constants.NotificationTypeRefundCompleted, TemplateData: map[string]string{}, + RefType: constants.NotificationRefTypeRefund, RefID: refundID, RefKey: refund.RefundNo, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入退款完成通知事件失败") + } + return nil +} + // refundWalletPayment 处理钱包支付订单的退款回款。 func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { if order.PaymentMethod != model.PaymentMethodWallet { diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index d77752e..07090a5 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -9,7 +9,7 @@ const ( NotificationTargetKindAccount = "account" // NotificationTargetKindPlatformRole 表示由当前平台角色解析接收人。 NotificationTargetKindPlatformRole = "platform_role" - // NotificationTargetKindShop 表示由目标店铺解析主账号和当前业务员。 + // NotificationTargetKindShop 表示由目标店铺解析全部有效店铺账号和当前业务员。 NotificationTargetKindShop = "shop" // NotificationCategoryApproval 表示审批类通知。 @@ -32,8 +32,12 @@ const ( // NotificationTypeSystemNotice 表示受控通用系统通知。 NotificationTypeSystemNotice = "system.notice" - // NotificationTypePackageExpiring 表示个人客户套餐临期提醒。 + // NotificationTypePackageExpiring 表示套餐临期提醒。 NotificationTypePackageExpiring = "package.expiring" + // NotificationTypeAgentRechargeCompleted 表示代理店铺充值已入账。 + NotificationTypeAgentRechargeCompleted = "agent.recharge.completed" + // NotificationTypeRefundCompleted 表示店铺退款已完成。 + NotificationTypeRefundCompleted = "refund.completed" // NotificationTypeExchangeShippingCreated 表示个人客户物流换货待处理提醒。 NotificationTypeExchangeShippingCreated = "exchange.shipping.created" // NotificationTypeAgentMainWalletLowBalance 表示代理主钱包余额低于固定阈值提醒。