From 9303af3d46dc439755c47a3eeaab175c11bd9a04 Mon Sep 17 00:00:00 2001 From: break Date: Tue, 28 Jul 2026 14:25:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A6=E5=8F=91=E9=80=9A=E7=9F=A5=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scratch/tech-global-audit/审计覆盖基线.md | 2 +- README.md | 1 + docs/7月迭代/README.md | 1 + docs/7月迭代/七月迭代人工验收清单.md | 101 +++++++++++++++++++++ docs/admin-openapi.yaml | 68 ++++++++++++++ internal/bootstrap/handlers.go | 1 + internal/handler/admin/asset.go | 41 +++++++++ internal/model/dto/package_expiry_dto.go | 6 ++ internal/routes/package_expiry.go | 8 ++ 9 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 docs/7月迭代/七月迭代人工验收清单.md diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index 1f216c8..d103496 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -58,7 +58,7 @@ | 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 | +| 套餐临期列表、数量与每日/手动 15/7/3 天节点提醒 | N/A(列表和数量是普通受权读取;手动入口仅允许超级管理员提交同一幂等扫描任务,操作者进入 Access Log 和任务日志,不直接修改业务事实) | `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、应用状态及明文 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(配置保存为同步事务,不产生必须可靠投递的提交后副作用) | diff --git a/README.md b/README.md index 83cf9d4..f6186c9 100644 --- a/README.md +++ b/README.md @@ -947,6 +947,7 @@ rdb.Set(ctx, key, status, time.Hour) - **[七月迭代 AI 实施与验收操作手册](docs/7月迭代/七月迭代-AI实施与验收操作手册.md)**:PRD 拆票、Issues 实现、测试、双轴评审与验收流程 - **[七月迭代实现与接口对接说明](docs/7月迭代/七月迭代实现与接口对接说明.md)**:按需求说明实现关键节点、口径检查、接口变化和前端应补齐的调用 - **[七月迭代联调交付说明](docs/7月迭代/七月迭代联调交付说明.md)**:企业微信、Gateway、Redis/Asynq、对象存储、前端契约、限制和回滚步骤 +- **[七月迭代人工验收清单](docs/7月迭代/七月迭代人工验收清单.md)**:逐项业务验收、全链路检查及临期通知手动扫描方法 ### 功能指南 diff --git a/docs/7月迭代/README.md b/docs/7月迭代/README.md index 967342f..3be903f 100644 --- a/docs/7月迭代/README.md +++ b/docs/7月迭代/README.md @@ -9,6 +9,7 @@ - [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md) - [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md) - [七月迭代 AI 实施与验收操作手册](./七月迭代-AI实施与验收操作手册.md) +- [七月迭代人工验收清单](./七月迭代人工验收清单.md) 评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。 diff --git a/docs/7月迭代/七月迭代人工验收清单.md b/docs/7月迭代/七月迭代人工验收清单.md new file mode 100644 index 0000000..230733f --- /dev/null +++ b/docs/7月迭代/七月迭代人工验收清单.md @@ -0,0 +1,101 @@ +# 七月迭代人工验收清单 + +> 用途:前后端联调完成后的逐项人工核对。页面可访问不等于验收通过;每项至少检查权限、正常路径、失败路径、重复操作和最终数据。 + +## 一、业务需求 + +- [ ] **UR#33 套餐临期提醒**:核对 0~15 天临期列表、0~3 天置顶、Dashboard 数量、C 端续费入口,以及 15/7/3 天站内通知和防重。 +- [ ] **UR#34 充值审核流程**:核对代理在线扫码充值与平台线下代充值隔离;支付、企微审批、重复回调、入账补偿和到账通知正确。 +- [ ] **UR#35 退款审核**:核对企微审批、整单终结、钱包回充、套餐失效和佣金处理;审批状态与业务处理状态分开,重复终态不重复退款。 +- [ ] **UR#36 批量订购套餐**:核对 CSV 上传、跨代理资产、统一支付方式、逐行结果、部分成功、失败原因、任务恢复及重复执行不重复扣款。 +- [ ] **UR#37 企业微信审核流转**:核对企微应用、模板、成员绑定、代理固定代提交人、回调、主动同步和异常恢复;系统内不得提供本地同意或拒绝入口。 +- [ ] **UR#38 不同渠道额度处理**:核对角色默认信用只影响新店铺;已有店铺单独调额;扣款、冻结、退款、充值及并发修改遵守代理主钱包信用边界。 +- [ ] **UR#40 下架套餐历史续费**:核对下架套餐不能新购,但历史使用资产可以续费;直接请求不能绕过资格校验。 +- [ ] **UR#42 统一导出与字段权限**:核对角色字段配置、不同角色最终列、数据行范围、CSV/XLSX、任务恢复及受保护附件。当前仓库已知角色级导出字段权限尚未实现。 +- [ ] **UR#43 代理系列套餐批量授权**:核对首次授权必须包含套餐;后续支持批量新增、改价和移除;已授权项不可重复选择,并兼容 `packages[].remove`。 +- [ ] **UR#44 列表提交人与审批摘要**:核对退款、充值、换货列表的提交人、审批来源、审批状态、当前审批人和处理状态;分页不得逐行请求审批详情。 +- [ ] **UR#45 换货新旧资产独立搜索**:核对旧资产和新资产筛选互不混淆,支持 ICCID、接入号和虚拟号,组合筛选及数据权限正确。 +- [ ] **UR#46 预计最终到期时间**:核对无套餐、单套餐、多个排队套餐和未知激活时间;后台详情、卡/设备列表与 C 端结果一致。 +- [ ] **UR#47 卡片手动限速**:核对仅 IoT 卡详情提供固定档位和恢复不限速;设备无入口;明确失败与结果未知提示正确。 +- [ ] **UR#48 不同资产使用不同支付方式**:核对卡、设备允许方式不同;订单创建固化方式,支付再次校验;配置变化后旧单取消重建。 +- [ ] **UR#49 设备批量分配代理和套餐系列**:核对两个独立入口、CSV 文本格式、任务进度、部分成功、失败明细、重试幂等及平台代理权限。 +- [ ] **UR#53 卡和设备实名状态筛选**:核对全部、已实名、未实名筛选;设备状态随绑定、解绑、换卡正确变化,翻页保留筛选条件。 +- [ ] **UR#55 套餐生效条件**:核对套餐默认值、代理覆盖值和最终值;只影响未来购买;订单和使用记录保存并使用购买快照。 +- [ ] **UR#57 退款中禁止换货**:核对活跃退款资产创建换货直接失败且不产生半成品;退款拒绝、撤销或处理完成后允许换货。 +- [ ] **UR#60 店铺联系电话检索**:核对 11 位联系电话精确查询、非法号码、空结果及代理数据范围。 +- [ ] **UR#62 H5 购买与实名顺序**:核对无需实名、先实名后购买、先购买后实名三种流程;卡/设备批量配置和冲突提示正确。 +- [ ] **UR#73 按运营商实名能力控制复机**:核对不能只按行业卡放行;不同运营商的无需实名、模板实名、Gateway 实名规则分别生效。 +- [ ] **UR#86 资产换货链**:核对资产详情前代、后代和链路跳转;旧资产、新资产、中间资产及无权限场景正确。 +- [ ] **UR#94 卡状态同步与运营商回调**:核对轮询、手动刷新、业务操作和移动/联通/电信回调统一更新;设备号不得传给要求 ICCID 的 Gateway。 +- [ ] **UR#96 店铺业务员归属**:核对店铺创建、编辑、清空、筛选和详情;只能绑定启用的平台账号,代理不能指定其他业务员。 +- [ ] **UR#97 代理钱包低余额提醒**:核对现金可用余额首次降至 100 元以下时通知一次;持续低位不重复,回升后可再次触发。 +- [ ] **UR#98 换货新资产继承店铺**:核对换货完成后新资产自动继承旧资产店铺;前端不能选择目标店铺;重复完成幂等且不能越权换入其他店铺资产。 + +需求16明确排除,不应出现对应入口、迁移或业务逻辑。 + +## 二、公共技术能力 + +- [ ] **七月公共开发基础**:Outbox、幂等、异步任务五态、配置、迁移和任务中断恢复。 +- [ ] **公共站内通知**:未读数、列表、已读、防重、接收人和受控跳转。 +- [ ] **全局审计与外部集成追踪**:人员、资源、资金、风险、请求链路和外部调用。该部分曾移出当前交付范围,应单独确认最终约定。 + +## 三、临期功能专项验证 + +### 3.1 实时列表 + +```http +GET /api/admin/expiring-assets +``` + +该接口每次请求都根据当前套餐、排队套餐和购买快照实时计算,不依赖定时任务。建议准备预计最终到期分别为 15、8、7、4、3、0、已过期和无法预计的资产,核对: + +- [ ] 只有剩余 0~15 个上海自然日且可精确推算的资产进入列表。 +- [ ] 0~3 天为红色并优先,4~7 天为紫色,8~15 天为粉色。 +- [ ] 已过期、无套餐和等待未知实名激活的资产不进入临期列表。 +- [ ] 卡、设备数量汇总和分页列表使用相同筛选及权限口径。 + +### 3.2 手动触发通知扫描 + +```http +POST /api/admin/expiring-assets/reminder-scan +Authorization: Bearer <超级管理员Token> +``` + +成功响应示例: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "task_type": "package:expiry:reminder", + "message": "套餐临期提醒扫描任务已提交" + } +} +``` + +接口只允许超级管理员调用,并立即提交与每日 03:00 相同的异步任务。Worker 只处理当天恰好剩余 15、7、3 天的资产,随后通过 Outbox 生成个人客户站内通知。 + +- [ ] API 返回成功后 Worker 日志出现“开始执行套餐临期节点提醒”。 +- [ ] 15、7、3 天资产产生通知;其他剩余天数不产生节点通知。 +- [ ] 同一资产、到期日、节点和接收人重复触发不重复通知。 +- [ ] 未绑定启用个人客户的资产不产生错误通知。 +- [ ] Worker 或 Outbox 暂时失败后可重试,且最终仍只有一条通知。 + +## 四、全链路检查 + +- [ ] **INT-01**:实名、复机、卡状态同步和运营商回调。 +- [ ] **INT-02**:换货创建、退款拦截、完成、资产归属和换货链。 +- [ ] **INT-03**:套餐授权、购买、生效条件、续费、预计到期和临期提醒。 +- [ ] **INT-04**:CSV 批量任务、进度恢复、导出和字段权限。 +- [ ] **INT-05**:支付方式、扫码充值、钱包信用、退款回充和余额预警。 +- [ ] **INT-06**:企微审批、退款、线下充值和重复回调。 +- [ ] **INT-07**:IoT 卡限速和 Gateway 调用。 +- [ ] **INT-08**:权限收回、重复提交、Worker 重启、外部超时和发布回滚。 + +## 五、依据 + +- [七月迭代技术方案(标准评审稿)](./7月迭代技术方案-标准评审稿.md) +- [七月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md) +- [七月迭代 AI 实施与验收操作手册](./七月迭代-AI实施与验收操作手册.md) + diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index c5586b2..2c17d9f 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -9889,6 +9889,15 @@ components: description: 表名,为空则清理所有 type: string type: object + DtoTriggerPackageExpiryReminderResponse: + properties: + message: + description: 任务提交结果说明 + type: string + task_type: + description: 已提交的异步任务类型 + type: string + type: object DtoTriggerSingleReq: properties: card_id: @@ -18959,6 +18968,65 @@ paths: summary: 查询临期资产列表 tags: - 资产管理 + /api/admin/expiring-assets/reminder-scan: + post: + description: 仅超级管理员可调用。立即提交与每日 03:00 相同的 15、7、3 天节点扫描任务;任务异步执行并沿用通知防重,不生成临期列表快照。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoTriggerPackageExpiryReminderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 手动触发套餐临期提醒扫描 + tags: + - 资产管理 /api/admin/export-tasks: get: description: 支持按 scene/status/time 过滤,分页默认 20,最大 100。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index e0c4154..8cedce4 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -258,6 +258,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { h.SetLifecycleService(svc.AssetLifecycle) h.SetObservationSeriesDispatcher(svc.ObservationSeries) h.SetPackageExpiryQuery(packageExpiry) + h.SetPackageExpiryQueue(deps.QueueClient) return h }(), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index f46ba43..b1ac8b1 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -8,6 +8,7 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/gofiber/fiber/v2" + "github.com/hibiken/asynq" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" @@ -20,6 +21,7 @@ import ( "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/queue" "github.com/break/junhong_cmp_fiber/pkg/response" "go.uber.org/zap" ) @@ -37,6 +39,7 @@ type AssetHandler struct { exchangeTraceQuery AssetExchangeTraceResolver observationSeries cardObservationApp.BestEffortSeriesDispatcher packageExpiryQuery *packageExpiryQuery.Query + packageExpiryTrigger func(context.Context) error } // SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。 @@ -49,6 +52,23 @@ func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) { h.packageExpiryQuery = query } +// SetPackageExpiryQueue 注入套餐临期提醒任务队列。 +func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) { + if client == nil { + h.packageExpiryTrigger = nil + return + } + h.packageExpiryTrigger = func(ctx context.Context) error { + return client.EnqueueTask( + ctx, + constants.TaskTypePackageExpiryReminder, + struct{}{}, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + ) + } +} + // AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 type AssetExchangeTraceResolver interface { Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) @@ -135,6 +155,27 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error { }) } +// TriggerPackageExpiryReminder 手动提交套餐临期提醒扫描任务。 +// POST /api/admin/expiring-assets/reminder-scan +func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden) + } + if h.packageExpiryTrigger == nil { + return errors.New(errors.CodeServiceUnavailable, "套餐临期提醒任务队列未配置") + } + if err := h.packageExpiryTrigger(c.UserContext()); err != nil { + logger.GetAppLogger().Error("手动提交套餐临期提醒扫描任务失败", zap.Error(err)) + return errors.Wrap(errors.CodeTaskQueueError, err, "提交套餐临期提醒扫描任务失败") + } + logger.GetAppLogger().Info("已手动提交套餐临期提醒扫描任务", + zap.Uint("operator_id", middleware.GetUserIDFromContext(c.UserContext()))) + return response.Success(c, dto.TriggerPackageExpiryReminderResponse{ + TaskType: constants.TaskTypePackageExpiryReminder, + Message: "套餐临期提醒扫描任务已提交", + }) +} + // RealtimeStatus 获取资产实时状态 // GET /api/admin/assets/:identifier/realtime-status func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { diff --git a/internal/model/dto/package_expiry_dto.go b/internal/model/dto/package_expiry_dto.go index e20fa91..e00aff0 100644 --- a/internal/model/dto/package_expiry_dto.go +++ b/internal/model/dto/package_expiry_dto.go @@ -57,3 +57,9 @@ type ExpiringAssetListResponse struct { Size int `json:"size" description:"每页数量"` Summary ExpiringAssetSummary `json:"summary" description:"同一筛选与权限范围内的卡、设备数量汇总"` } + +// TriggerPackageExpiryReminderResponse 手动触发套餐临期提醒扫描响应。 +type TriggerPackageExpiryReminderResponse struct { + TaskType string `json:"task_type" description:"已提交的异步任务类型"` + Message string `json:"message" description:"任务提交结果说明"` +} diff --git a/internal/routes/package_expiry.go b/internal/routes/package_expiry.go index 594375e..5774d21 100644 --- a/internal/routes/package_expiry.go +++ b/internal/routes/package_expiry.go @@ -18,4 +18,12 @@ func registerPackageExpiryRoutes(router fiber.Router, handler *admin.AssetHandle Output: new(dto.ExpiringAssetListResponse), Auth: true, }) + + Register(router, doc, basePath, "POST", "/expiring-assets/reminder-scan", handler.TriggerPackageExpiryReminder, RouteSpec{ + Summary: "手动触发套餐临期提醒扫描", + Description: "仅超级管理员可调用。立即提交与每日 03:00 相同的 15、7、3 天节点扫描任务;任务异步执行并沿用通知防重,不生成临期列表快照。", + Tags: []string{"资产管理"}, + Output: new(dto.TriggerPackageExpiryReminderResponse), + Auth: true, + }) }