From 2e130b98f5e672784245e091551fb3199b0ac9f1 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 13 Jul 2026 12:01:18 +0900 Subject: [PATCH] =?UTF-8?q?=E4=B8=B4=E6=97=B6=E5=A4=87=E4=BB=BD=E4=B8=80?= =?UTF-8?q?=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/7月迭代/00-总览.md | 91 ++++ docs/7月迭代/DDD规范.md | 357 +++++++++++++++ docs/7月迭代/基础设施/审批流.md | 414 ++++++++++++++++++ docs/7月迭代/基础设施/站内消息.md | 340 ++++++++++++++ docs/7月迭代/基础设施/系统配置.md | 216 +++++++++ docs/7月迭代/需求01-复机实名规则.md | 54 +++ docs/7月迭代/需求02-H5流程配置.md | 211 +++++++++ docs/7月迭代/需求03-07-11-12-13-简单改动.md | 321 ++++++++++++++ .../需求04-06-退款拦截与最后到期时间.md | 183 ++++++++ docs/7月迭代/需求05-套餐分配生效条件.md | 158 +++++++ docs/7月迭代/需求08-设备批量分配Excel.md | 154 +++++++ docs/7月迭代/需求09-C端支付限制配置化.md | 120 +++++ docs/7月迭代/需求10-限速规则.md | 194 ++++++++ docs/7月迭代/需求14-导出功能.md | 247 +++++++++++ .../7月迭代/需求15-16-18-19-20-21-复杂需求.md | 348 +++++++++++++++ docs/7月迭代/需求17-信用额度.md | 264 +++++++++++ docs/7月迭代/需求22-套餐临期提醒.md | 266 +++++++++++ 17 files changed, 3938 insertions(+) create mode 100644 docs/7月迭代/00-总览.md create mode 100644 docs/7月迭代/DDD规范.md create mode 100644 docs/7月迭代/基础设施/审批流.md create mode 100644 docs/7月迭代/基础设施/站内消息.md create mode 100644 docs/7月迭代/基础设施/系统配置.md create mode 100644 docs/7月迭代/需求01-复机实名规则.md create mode 100644 docs/7月迭代/需求02-H5流程配置.md create mode 100644 docs/7月迭代/需求03-07-11-12-13-简单改动.md create mode 100644 docs/7月迭代/需求04-06-退款拦截与最后到期时间.md create mode 100644 docs/7月迭代/需求05-套餐分配生效条件.md create mode 100644 docs/7月迭代/需求08-设备批量分配Excel.md create mode 100644 docs/7月迭代/需求09-C端支付限制配置化.md create mode 100644 docs/7月迭代/需求10-限速规则.md create mode 100644 docs/7月迭代/需求14-导出功能.md create mode 100644 docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md create mode 100644 docs/7月迭代/需求17-信用额度.md create mode 100644 docs/7月迭代/需求22-套餐临期提醒.md diff --git a/docs/7月迭代/00-总览.md b/docs/7月迭代/00-总览.md new file mode 100644 index 0000000..2cfb988 --- /dev/null +++ b/docs/7月迭代/00-总览.md @@ -0,0 +1,91 @@ +# 7月迭代技术方案总览 + +> 讨论日期:2026-07-11 +> 分支:Iteration/7-11 +> 系统:junhong_cmp_fiber(已上线,迭代阶段) + +--- + +## 阶段划分 + +| 阶段 | 范围 | 说明 | +|------|------|------| +| **Phase 1(本迭代)** | 需求 1-22(除企微对接) | Gateway 接口已有,全部可做 | +| **Phase 2(下次迭代)** | 需求 18(APR-009)/22(EXP-003)的企微对接部分 | 仅依赖企业微信审批/推送 API | + +--- + +## 需求总览 + +### 简单改动(无新表,影响范围小) + +| # | 需求 | 文档 | 实现要点 | +|---|------|------|---------| +| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | `ManualStartCard` 换用 `isRealnameOK()` | +| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-店铺搜索电话.md) | Shop Store 加 contact_phone 过滤 | +| 4 | 退款中资产禁止换货 | [需求04](./需求04-退款拦截换货.md) | 换货前检查活跃退款单 | +| 7 | IoT卡/设备加实名筛选 | [需求07](./需求07-实名筛选.md) | 列表接口加 real_name_status filter | +| 11 | 资产详情-套餐到期时间高亮 | [需求11](./需求11-套餐到期字段高亮.md) | 接口新增字段 + 前端高亮 | +| 12 | 换货管理显示问题修复 | [需求12](./需求12-换货管理修复.md) | EXC-001~004,字段显示对齐 | +| 13 | 列表字段新增 | [需求13](./需求13-列表字段新增.md) | 退款/代理充值/换号管理加提交人审批人 | + +### 中等复杂(有新字段或配置机制) + +| # | 需求 | 文档 | 实现要点 | +|---|------|------|---------| +| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 依赖[系统配置](./基础设施/系统配置.md) | +| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | ShopPackageAllocation 加 expiry_base_override | +| 6 | 资产最后到期时间 | [需求06](./需求06-资产最后到期时间.md) | 查 PackageUsage 计算 max(expires_at) | +| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 参考现有 Excel 导入体系 | +| 9 | C端支付方式限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 恢复已注释代码 + 系统配置 | +| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | Gateway `SetSpeedLimit` 已有,套餐加限速字段 | +| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 6 个模块导出,复用 ExportTask 体系 | +| 15 | 下架套餐允许续费 | [需求15](./需求15-套餐下架续费.md) | ShelfStatus 判断 + 续费入口 | + +### 复杂新模块(DDD 结构) + +| # | 需求 | 文档 | 实现要点 | +|---|------|------|---------| +| 16 | 代理分销码与佣金提现 | [需求16](./需求16-代理分销码佣金提现.md) | 二维码注册 + 发展人体系 | +| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | Shop/角色信用字段 + 负余额支持 | +| 18 | 多人审批 | [需求18](./需求18-多人审批.md) | 依赖[审批流](./基础设施/审批流.md) + [站内消息](./基础设施/站内消息.md) | +| 19 | 批量订购套餐 | [需求19](./需求19-批量订购.md) | Excel 导入 + 代理余额扣款 | +| 20 | 退款审批 | [需求20](./需求20-退款审批.md) | 依赖审批流 | +| 21 | 充值审核流程 | [需求21](./需求21-充值审核流程.md) | 依赖审批流 | +| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 定时任务 + 站内消息 | + +--- + +## 基础设施文档 + +| 文档 | 说明 | 被哪些需求依赖 | +|------|------|--------------| +| [DDD规范](./DDD规范.md) | 领域模型设计规范、目录结构、代码示例 | 复杂新模块全部 | +| [系统配置](./基础设施/系统配置.md) | tb_system_config + 管理接口 | 需求 2、9 | +| [站内消息](./基础设施/站内消息.md) | tb_notification + Asynq 异步分发 | 需求 18、20、21、22 | +| [审批流](./基础设施/审批流.md) | 通用多级审批引擎 | 需求 18、20、21 | + +--- + +## 执行顺序建议 + +``` +第一周:基础设施 + 1. tb_system_config + 管理接口 + 2. tb_notification + 任务 handler + 3. 通用审批流引擎 + +第二周:简单改动批量完成 + 4. 需求 1/3/4/7/11/12/13(均可并行) + +第三周:中等复杂 + 5. 需求 2/5/6/8/9(依赖基础设施) + 6. 需求 14/15 + +第四周:复杂新模块 + 7. 需求 17(信用额度) + 8. 需求 16(分销码) + 9. 需求 18/20/21(审批流对接) + 10. 需求 19(批量订购) + 11. 需求 22(临期提醒) +``` diff --git a/docs/7月迭代/DDD规范.md b/docs/7月迭代/DDD规范.md new file mode 100644 index 0000000..a7b1b15 --- /dev/null +++ b/docs/7月迭代/DDD规范.md @@ -0,0 +1,357 @@ +# 项目 DDD 设计规范 + +> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 +> 基准文档:`docs/改造方向.md`(绞杀者模式) + +--- + +## 一、为什么用 DDD / 什么不是 DDD + +### 现状问题 + +- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起 +- Model 只是 GORM 映射,没有业务行为(贫血模型) +- 新增业务联动必须修改原有 Service,牵一发动全身 + +### 目标 + +不是重写,是**渐进替换**: + +1. **新功能按新结构写**,不往旧 Service 加代码 +2. **旧功能迁移**:每次迁移一个用例,跑通后再下一个 +3. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置 + +--- + +## 二、目录结构 + +``` +internal/ +├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM) +│ ├── wallet/ +│ │ ├── wallet.go ← 聚合根(含业务方法) +│ │ ├── events.go ← 领域事件定义 +│ │ ├── repository.go ← 仓储接口(interface) +│ │ └── vo.go ← 值对象(Money, CreditLimit) +│ ├── approval/ ← 新:审批流领域 +│ ├── notification/ ← 新:通知领域 +│ ├── distribution/ ← 新:分销领域 +│ └── package/ ← 套餐领域(迁移中) +│ +├── application/ ← 应用层(用例,只做编排,不含业务判断) +│ ├── wallet/ +│ │ ├── debit_wallet.go ← 一个文件 = 一个用例 +│ │ ├── credit_wallet.go +│ │ └── grant_credit.go ← 新:授信 +│ ├── approval/ +│ │ ├── submit_approval.go +│ │ ├── advance_step.go +│ │ └── reject_approval.go +│ └── notification/ +│ └── send_notification.go +│ +├── infrastructure/ ← 基础设施层(实现 domain 里的 interface) +│ └── persistence/ ← 原来的 store/postgres/(保持不动) +│ +├── handler/ ← 原有 handler(保持不动) +├── service/ ← 原有 service(保持不动,新模块不在这里) +└── store/ ← 原有 store(保持不动) +``` + +**过渡期规则**: +- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx` +- 新模块:`internal/domain/xxx` + `internal/application/xxx` +- Handler 层统一调 Application 层(新)或 Service 层(旧) + +--- + +## 三、领域模块划分 + +| 领域 | 聚合根 | 核心业务规则 | 状态 | +|------|--------|------------|------| +| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 | +| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 | +| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 | +| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** | +| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 | +| **Approval(审批)** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** | +| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** | +| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** | + +--- + +## 四、聚合根设计原则(富模型) + +### 4.1 核心原则 + +业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。 + +```go +// ❌ 贫血模型(禁止)——所有判断在 Service 里 +func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error { + wallet, _ := s.store.Get(ctx, walletID) + if wallet.Balance-wallet.FrozenBalance < amount { + return errors.New(errors.CodeInsufficientBalance) + } + wallet.Balance -= amount + s.store.Update(ctx, wallet) +} + +// ✅ 富模型(推荐)——判断逻辑在聚合根里 +func (w *AgentWallet) Debit(amount Money) error { + available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度 + if available < amount.Cents() { + return ErrInsufficientBalance + } + w.Balance -= amount.Cents() + w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance}) + return nil +} + +// Application 层只做编排 +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + wallet, err := uc.repo.GetByID(ctx, cmd.WalletID) + if err != nil { return err } + if err := wallet.Debit(cmd.Amount); err != nil { return err } + return uc.repo.Save(ctx, wallet) +} +``` + +### 4.2 聚合根必须包含 + +```go +type AggregateRoot struct { + // 内嵌 GORM model(过渡期) + gorm.Model + + // 业务字段... + + // 未发布领域事件(内存中,Save 后由框架分发) + domainEvents []DomainEvent `gorm:"-" json:"-"` +} + +// 获取并清空领域事件(由 Repository.Save 调用) +func (a *AggregateRoot) PopEvents() []DomainEvent { + events := a.domainEvents + a.domainEvents = nil + return events +} + +func (a *AggregateRoot) recordEvent(e DomainEvent) { + a.domainEvents = append(a.domainEvents, e) +} +``` + +### 4.3 与现有 GORM 的共存 + +过渡期,聚合根 **就是** 现有的 GORM Model(加业务方法),不需要单独建领域对象: + +```go +// internal/domain/wallet/wallet.go +// AgentWallet 钱包聚合根 +// 直接复用并扩展现有 model.AgentWallet +type AgentWallet struct { + model.AgentWallet // 内嵌原有 GORM 模型 + domainEvents []DomainEvent `gorm:"-"` +} + +// Debit 从钱包扣款(含信用额度) +func (w *AgentWallet) Debit(amount Money) error { + available := w.Balance - w.FrozenBalance + w.CreditLimit + if available < amount.Cents() { + return ErrInsufficientBalance + } + w.Balance -= amount.Cents() + w.recordEvent(WalletDebitedEvent{...}) + return nil +} +``` + +--- + +## 五、值对象设计 + +值对象:没有 ID,靠值来判断相等,不可变。 + +```go +// internal/domain/wallet/vo.go + +// Money 金额值对象(分为单位,防止浮点数精度问题) +type Money struct { + cents int64 +} + +func NewMoney(cents int64) (Money, error) { + if cents < 0 { + return Money{}, ErrNegativeMoney + } + return Money{cents: cents}, nil +} + +func (m Money) Cents() int64 { return m.cents } +func (m Money) Yuan() float64 { return float64(m.cents) / 100 } +func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} } + +// CreditLimit 信用额度值对象 +type CreditLimit struct { + maxCents int64 // 最大授信额度(分) + allowNegative bool // 是否允许负余额 +} +``` + +--- + +## 六、领域事件(用 Asynq 实现) + +领域事件不用消息队列,直接复用现有 Asynq 体系。 + +```go +// internal/domain/wallet/events.go + +// DomainEvent 领域事件接口 +type DomainEvent interface { + EventType() string + OccurredAt() time.Time +} + +// WalletDebitedEvent 钱包扣款事件 +type WalletDebitedEvent struct { + WalletID uint + ShopID uint + Amount Money + BalanceBefore int64 + BalanceAfter int64 + RefType string + RefID uint + occurredAt time.Time +} + +func (e WalletDebitedEvent) EventType() string { return "wallet.debited" } +func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt } +``` + +**Repository.Save 发布事件**: + +```go +// internal/infrastructure/persistence/wallet_repo.go +func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error { + err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error + if err != nil { + return err + } + // 发布领域事件 + for _, evt := range wallet.PopEvents() { + if err := r.publishEvent(ctx, evt); err != nil { + r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType())) + // 事件发布失败不回滚业务(异步,可补偿) + } + } + return nil +} +``` + +--- + +## 七、仓储接口 + +```go +// internal/domain/wallet/repository.go + +// WalletRepository 钱包仓储接口 +type WalletRepository interface { + GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error) + GetByID(ctx context.Context, id uint) (*AgentWallet, error) + Save(ctx context.Context, wallet *AgentWallet) error + SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error +} +``` + +实现在 `internal/infrastructure/persistence/wallet_repo.go`,复用现有 Store 逻辑。 + +--- + +## 八、应用服务(用例) + +一个文件 = 一个用例。用例只做编排,不含业务判断。 + +```go +// internal/application/wallet/debit_wallet.go + +// DebitCommand 扣款命令 +type DebitCommand struct { + WalletID uint + Amount domainWallet.Money + RefType string + RefID uint + OperatorID uint +} + +// DebitWalletUseCase 扣款用例 +type DebitWalletUseCase struct { + walletRepo domainWallet.WalletRepository + txManager TxManager +} + +// Execute 执行扣款 +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error { + wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID) + if err != nil { + return err + } + if err := wallet.Debit(cmd.Amount); err != nil { + return err + } + return uc.walletRepo.SaveInTx(ctx, tx, wallet) + }) +} +``` + +--- + +## 九、Handler 层调用方式 + +新模块:Handler → Application UseCase(不经过 Service 层) + +```go +// internal/handler/admin/wallet_handler.go +func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error { + var req dto.GrantCreditRequest + // ... 参数解析 + + cmd := walletApp.GrantCreditCommand{ + ShopID: req.ShopID, + MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents), + OperatorID: middleware.GetUserID(c), + } + if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil { + return err + } + return response.OK(c, nil) +} +``` + +--- + +## 十、禁止事项 + +| 禁止 | 原因 | +|------|------| +| 在 Application 层做业务判断 | 业务规则必须在领域对象里 | +| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 | +| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 | +| 在一个用例文件里实现多个业务流程 | 一文件一用例 | +| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 | +| 在聚合根里调用 Repository | 聚合根不依赖仓储 | + +--- + +## 十一、本次迭代 DDD 落地计划 + +| 新模块 | 领域路径 | 用例路径 | +|--------|---------|---------| +| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` | +| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/` | +| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` | +| 分销码(需求16) | `internal/domain/distribution/` | `internal/application/distribution/` | +| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` | diff --git a/docs/7月迭代/基础设施/审批流.md b/docs/7月迭代/基础设施/审批流.md new file mode 100644 index 0000000..0d751ed --- /dev/null +++ b/docs/7月迭代/基础设施/审批流.md @@ -0,0 +1,414 @@ +# 基础设施:通用审批流引擎 + +> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) +> 设计:DDD 结构,`internal/domain/approval/` + +--- + +## 一、业务规则 + +- 审批顺序:**提交人部门领导 → 财务**(固定两级) +- 每级只能有一个审批人(通过角色确定,不是指定个人) +- 上一级审批通过后,下一级审批人收到站内消息 +- **驳回**:永久拒绝,流程终止,通知申请人含驳回原因 +- **退回**:退回给提交人修改,流程终止,提交人可修改后重新提交(新建审批流) +- **通过**:最后一级通过后,通知申请人,触发业务回调 + +--- + +## 二、数据库 + +```sql +-- 审批流实例表 +CREATE TABLE tb_approval_flow ( + id BIGSERIAL PRIMARY KEY, + flow_no VARCHAR(30) NOT NULL UNIQUE, + biz_type VARCHAR(50) NOT NULL, -- 业务类型:recharge | refund + biz_id BIGINT NOT NULL, + biz_no VARCHAR(50) NOT NULL DEFAULT '', -- 关联业务单号(快照) + submitter_id BIGINT NOT NULL, + submitter_name VARCHAR(50) NOT NULL DEFAULT '', + current_step INT NOT NULL DEFAULT 1, -- 当前步骤(1=部门领导, 2=财务) + total_steps INT NOT NULL DEFAULT 2, + status INT NOT NULL DEFAULT 1, -- 1=审批中 2=已通过 3=已驳回 4=已退回 + reject_reason TEXT, -- 驳回/退回原因 + completed_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 审批步骤记录表 +CREATE TABLE tb_approval_step_record ( + id BIGSERIAL PRIMARY KEY, + flow_id BIGINT NOT NULL, + step INT NOT NULL, + step_name VARCHAR(50) NOT NULL, + approver_id BIGINT, + approver_name VARCHAR(50), + result INT NOT NULL DEFAULT 0, -- 0=待审批 1=通过 2=驳回 3=退回 + remark TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_approval_flow_biz ON tb_approval_flow (biz_type, biz_id); +CREATE INDEX idx_approval_step_flow ON tb_approval_step_record (flow_id, step); +``` + +--- + +## 三、Model + +```go +// internal/model/approval.go + +const ( + ApprovalBizTypeRecharge = "recharge" + ApprovalBizTypeRefund = "refund" + + ApprovalStatusPending = 1 // 审批中 + ApprovalStatusApproved = 2 // 已通过 + ApprovalStatusRejected = 3 // 已驳回(永久拒绝,流程终止) + ApprovalStatusReturned = 4 // 已退回(退回给提交人修改,可重新提交) + + ApprovalStepResultPending = 0 // 待审批 + ApprovalStepResultApproved = 1 // 通过 + ApprovalStepResultRejected = 2 // 驳回 + ApprovalStepResultReturned = 3 // 退回 +) +``` + +--- + +## 四、领域层(DDD) + +```go +// internal/domain/approval/approval_flow.go + +type ApprovalFlowAggregate struct { + model.ApprovalFlow + Steps []*model.ApprovalStepRecord + domainEvents []DomainEvent `gorm:"-"` +} + +// CanAdvance 检查当前用户是否可以审批当前步骤 +func (f *ApprovalFlowAggregate) CanAdvance(approverRoles []string) bool { + if f.Status != model.ApprovalStatusPending { + return false + } + if f.CurrentStep == 1 { + return contains(approverRoles, "department_leader") + } + if f.CurrentStep == 2 { + return contains(approverRoles, "finance") + } + return false +} + +// Approve 通过当前步骤 +func (f *ApprovalFlowAggregate) Approve(approverID uint, approverName string, remark string) error { + if f.Status != model.ApprovalStatusPending { + return ErrFlowNotPending + } + now := time.Now() + step := f.currentStepRecord() + step.ApproverID = &approverID + step.ApproverName = &approverName + step.Result = model.ApprovalStepResultApproved + step.Remark = &remark + step.ApprovedAt = &now + + if f.CurrentStep >= f.TotalSteps { + f.Status = model.ApprovalStatusApproved + f.CompletedAt = &now + f.recordEvent(ApprovalCompletedEvent{FlowID: f.ID, BizType: f.BizType, BizID: f.BizID}) + } else { + f.CurrentStep++ + f.recordEvent(ApprovalStepAdvancedEvent{ + FlowID: f.ID, + NextStep: f.CurrentStep, + BizType: f.BizType, + SubmitterID: f.SubmitterID, + }) + } + return nil +} + +// Reject 驳回(永久拒绝,流程终止) +func (f *ApprovalFlowAggregate) Reject(approverID uint, approverName string, reason string) error { + if f.Status != model.ApprovalStatusPending { + return ErrFlowNotPending + } + now := time.Now() + step := f.currentStepRecord() + step.ApproverID = &approverID + step.ApproverName = &approverName + step.Result = model.ApprovalStepResultRejected + step.Remark = &reason + step.ApprovedAt = &now + + f.Status = model.ApprovalStatusRejected + f.RejectReason = &reason + f.CompletedAt = &now + f.recordEvent(ApprovalRejectedEvent{ + FlowID: f.ID, + BizType: f.BizType, + BizID: f.BizID, + SubmitterID: f.SubmitterID, + Reason: reason, + }) + return nil +} + +// Return 退回给提交人修改(流程终止,提交人可重新提交) +func (f *ApprovalFlowAggregate) Return(approverID uint, approverName string, reason string) error { + if f.Status != model.ApprovalStatusPending { + return ErrFlowNotPending + } + now := time.Now() + step := f.currentStepRecord() + step.ApproverID = &approverID + step.ApproverName = &approverName + step.Result = model.ApprovalStepResultReturned + step.Remark = &reason + step.ApprovedAt = &now + + f.Status = model.ApprovalStatusReturned + f.RejectReason = &reason + f.CompletedAt = &now + f.recordEvent(ApprovalReturnedEvent{ + FlowID: f.ID, + BizType: f.BizType, + BizID: f.BizID, + SubmitterID: f.SubmitterID, + Reason: reason, + }) + return nil +} +``` + +--- + +## 五、领域事件 + +```go +// internal/domain/approval/events.go + +// ApprovalStepAdvancedEvent 步骤推进(通知下一级审批人) +type ApprovalStepAdvancedEvent struct { + FlowID uint + NextStep int + BizType string + SubmitterID uint +} + +// ApprovalCompletedEvent 审批完成(触发业务回调 + 通知申请人) +type ApprovalCompletedEvent struct { + FlowID uint + BizType string + BizID uint +} + +// ApprovalRejectedEvent 驳回(通知申请人,流程永久终止) +type ApprovalRejectedEvent struct { + FlowID uint + BizType string + BizID uint + SubmitterID uint + Reason string +} + +// ApprovalReturnedEvent 退回给提交人修改(通知申请人,可重新提交) +type ApprovalReturnedEvent struct { + FlowID uint + BizType string + BizID uint + SubmitterID uint + Reason string +} +``` + +--- + +## 六、接入协议(业务层如何接入审批流) + +任何业务接入审批流需做以下四件事: + +### 6.1 业务表新增字段 + +```sql +ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL; +``` + +### 6.2 提交时创建审批流 + +业务提交接口(如 `POST /admin/refund-requests`): + +```go +// 1. 创建业务单(status=待审批) +bizRecord, _ := s.bizStore.Create(ctx, req) + +// 2. 创建审批流 +flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{ + BizType: model.ApprovalBizTypeRefund, + BizID: bizRecord.ID, + BizNo: bizRecord.RefundNo, + SubmitterID: middleware.GetUserID(ctx), + SubmitterName: middleware.GetUsername(ctx), +}) + +// 3. 把 flow_id 写回业务单 +s.bizStore.UpdateApprovalFlowID(ctx, bizRecord.ID, flow.ID) +``` + +### 6.3 监听审批事件,更新业务状态 + +每个接入方在 `internal/task/` 注册对应的 Asynq 事件处理器: + +```go +// internal/task/refund_approval_handler.go + +// OnApprovalCompleted 审批通过 → 退款单状态变"已通过",触发退款 +func (h *RefundApprovalHandler) OnApprovalCompleted(ctx context.Context, event ApprovalCompletedEvent) error { + // 按 biz_type 过滤,只处理退款类型 + if event.BizType != model.ApprovalBizTypeRefund { return nil } + return h.refundService.ApproveRefund(ctx, event.BizID) +} + +// OnApprovalRejected 审批驳回 → 退款单状态变"已拒绝" +func (h *RefundApprovalHandler) OnApprovalRejected(ctx context.Context, event ApprovalRejectedEvent) error { + if event.BizType != model.ApprovalBizTypeRefund { return nil } + return h.refundService.RejectRefund(ctx, event.BizID, event.Reason) +} + +// OnApprovalReturned 审批退回 → 退款单状态变"已退回",提交人可修改重提 +func (h *RefundApprovalHandler) OnApprovalReturned(ctx context.Context, event ApprovalReturnedEvent) error { + if event.BizType != model.ApprovalBizTypeRefund { return nil } + return h.refundService.ReturnRefund(ctx, event.BizID, event.Reason) +} +``` + +充值单同理,注册 `RechargeApprovalHandler`。 + +### 6.4 退回后重新提交 + +业务层新增重新提交接口(如 `POST /admin/refund-requests/{id}/resubmit`): + +```go +// 1. 校验业务单 status=已退回 +if bizRecord.Status != model.RefundStatusReturned { + return errors.New(errors.CodeForbidden, "当前状态不允许重新提交") +} + +// 2. 新建审批流(biz_type+biz_id 相同,旧的流程作为历史保留) +flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{...}) + +// 3. 更新业务单:approval_flow_id = 新 flow.ID,status 回到"待审批" +s.bizStore.Resubmit(ctx, bizRecord.ID, flow.ID) +``` + +--- + +## 七、应用层(用例) + +```go +// internal/application/approval/submit_approval.go + +type SubmitApprovalCommand struct { + BizType string + BizID uint + BizNo string + SubmitterID uint + SubmitterName string +} + +type SubmitApprovalUseCase struct { + approvalRepo approval.ApprovalRepository + notifyPublisher *notification.NotificationPublisher + userStore UserStore +} + +func (uc *SubmitApprovalUseCase) Execute(ctx context.Context, cmd SubmitApprovalCommand) (*model.ApprovalFlow, error) { + flow := &domain_approval.ApprovalFlowAggregate{...} + // 初始化两个步骤记录(均为待审批) + if err := uc.approvalRepo.Create(ctx, flow); err != nil { + return nil, err + } + // 通知部门领导 + leaderIDs := uc.userStore.GetUsersByRole(ctx, "department_leader") + uc.notifyPublisher.Publish(ctx, notification.SendPayload{ + RecipientIDs: leaderIDs, + Type: constants.NotifyTypeApprovalPending, + Title: "您有一条待审批记录", + Body: fmt.Sprintf("「%s」提交了%s申请", cmd.SubmitterName, bizTypeLabel(cmd.BizType)), + RefType: "approval", + RefID: flow.ID, + }) + return &flow.ApprovalFlow, nil +} +``` + +--- + +## 八、API 设计 + +### 8.1 待我审批列表 + +``` +GET /admin/approvals?status=1&biz_type=recharge&page=1&page_size=20 +``` + +### 8.2 审批通过 + +``` +POST /admin/approvals/{flow_id}/approve +{ "remark": "审批通过" } +``` + +### 8.3 审批驳回(永久拒绝) + +``` +POST /admin/approvals/{flow_id}/reject +{ "reason": "金额不符,请重新提交" } +``` + +### 8.4 退回给提交人修改 + +``` +POST /admin/approvals/{flow_id}/return +{ "reason": "请补充支付凭证后重新提交" } +``` + +退回后,提交人修改业务单,再通过业务接口重新提交(如 `POST /admin/refund-requests/{id}/resubmit`)。 + +### 8.5 审批详情 + +``` +GET /admin/approvals/{flow_id} +``` + +响应包含:flow 基本信息 + steps 列表(每步审批人、结果【通过/驳回/退回】、时间、备注) + +--- + +## 九、前端对接 + +### 审批详情页操作区 + +当前登录用户是当前步骤审批人时,显示三个按钮: + +``` +[审批通过] [退回修改] [驳回] +备注/原因输入框 +``` + +- **审批通过** → `POST /admin/approvals/{id}/approve` +- **退回修改** → `POST /admin/approvals/{id}/return`(需填退回原因) +- **驳回** → `POST /admin/approvals/{id}/reject`(需填驳回原因) + +### 权限控制 + +- `department_leader` 角色:步骤1为"待审批"时显示操作按钮 +- `finance` 角色:步骤2为"待审批"时显示操作按钮 +- 其他角色:只读 diff --git a/docs/7月迭代/基础设施/站内消息.md b/docs/7月迭代/基础设施/站内消息.md new file mode 100644 index 0000000..6bd6c0c --- /dev/null +++ b/docs/7月迭代/基础设施/站内消息.md @@ -0,0 +1,340 @@ +# 基础设施:站内消息(Notification) + +> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒) +> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口) + +--- + +## 一、设计原则 + +- **业务代码不直接写通知**:只发 Asynq 任务,异步处理 +- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码) +- **前端轮询**:30秒一次 `/admin/notifications/unread-count`,不用 WebSocket + +--- + +## 二、数据库 + +```sql +-- 迁移文件:YYYYMMDD_create_tb_notification.sql +CREATE TABLE tb_notification ( + id BIGSERIAL PRIMARY KEY, + recipient_id BIGINT NOT NULL, -- 用户ID(admin user 或 agent user) + recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent + type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义) + title VARCHAR(200) NOT NULL, -- 标题 + body TEXT NOT NULL DEFAULT '', -- 正文(支持简单 HTML) + ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | package + ref_id BIGINT, -- 关联业务ID + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用) +); + +CREATE INDEX idx_notification_recipient ON tb_notification (recipient_id, recipient_type, is_read); +CREATE INDEX idx_notification_created ON tb_notification (created_at DESC); + +COMMENT ON TABLE tb_notification IS '站内消息通知表'; +``` + +--- + +## 三、常量定义 + +```go +// pkg/constants/notification.go + +// 通知类型 +const ( + NotifyTypeApprovalPending = "approval.pending" // 待我审批 + NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收) + NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止) + NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交) + NotifyTypePackageExpiring = "package.expiring" // 套餐临期 + NotifyTypeSystemAlert = "system.alert" // 系统通知 +) + +// 通知接收者类型 +const ( + NotifyRecipientAdmin = "admin" // 平台用户 + NotifyRecipientAgent = "agent" // 代理用户(预留) +) +``` + +--- + +## 四、Model + +```go +// internal/model/notification.go + +// Notification 站内消息模型 +type Notification struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + RecipientID uint `gorm:"column:recipient_id;not null;index" json:"recipient_id"` + RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin'" json:"recipient_type"` + Type string `gorm:"column:type;type:varchar(50);not null" json:"type"` + Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` + Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` + RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"` + RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"` + IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"` + ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"` +} + +func (Notification) TableName() string { return "tb_notification" } +``` + +--- + +## 五、发布侧:NotificationPublisher + +业务代码调这个发布通知,内部异步入队: + +```go +// internal/service/notification/publisher.go + +// SendPayload 发送通知的参数 +type SendPayload struct { + RecipientIDs []uint // 接收人ID列表 + RecipientType string // admin | agent + Type string // 通知类型常量 + Title string // 标题 + Body string // 正文 + RefType string // 关联业务类型(可选) + RefID uint // 关联业务ID(可选) + ExpiresAt *time.Time // 过期时间(可选) +} + +// NotificationPublisher 通知发布器(非接口,简单具体实现) +type NotificationPublisher struct { + queueClient *queue.Client + logger *zap.Logger +} + +// Publish 发布通知(异步,不阻塞业务) +// 失败只记录日志,不影响业务事务 +func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) { + if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil { + p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type)) + } +} +``` + +业务代码调用示例(审批推进后通知下一审批人): + +```go +p.notifyPublisher.Publish(ctx, notification.SendPayload{ + RecipientIDs: []uint{nextApproverID}, + RecipientType: constants.NotifyRecipientAdmin, + Type: constants.NotifyTypeApprovalPending, + Title: "您有一条待审批记录", + Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName), + RefType: "approval", + RefID: approvalFlowID, +}) +``` + +--- + +## 六、消费侧:Asynq Task Handler + +```go +// internal/task/notification_handler.go + +// HandleNotification 处理通知发送任务 +func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error { + var payload notification.SendPayload + if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { + return fmt.Errorf("反序列化通知载荷失败: %w", err) + } + + // 批量写入 tb_notification + records := make([]model.Notification, 0, len(payload.RecipientIDs)) + for _, uid := range payload.RecipientIDs { + ref_type := (*string)(nil) + ref_id := (*uint)(nil) + if payload.RefType != "" { + ref_type = &payload.RefType + } + if payload.RefID != 0 { + ref_id = &payload.RefID + } + records = append(records, model.Notification{ + RecipientID: uid, + RecipientType: payload.RecipientType, + Type: payload.Type, + Title: payload.Title, + Body: payload.Body, + RefType: ref_type, + RefID: ref_id, + ExpiresAt: payload.ExpiresAt, + }) + } + + if err := h.db.WithContext(ctx).CreateInBatches(records, 100).Error; err != nil { + return fmt.Errorf("批量写入通知失败: %w", err) + } + + // Phase 2:在此处加企微/短信调用 + // for _, sender := range h.extraSenders { sender.Send(ctx, payload) } + + return nil +} +``` + +--- + +## 七、API 设计 + +### 7.1 未读数量(前端轮询用,30秒一次) + +``` +GET /admin/notifications/unread-count +``` + +响应: +```json +{ "code": 0, "data": { "count": 5 } } +``` + +### 7.2 通知列表 + +``` +GET /admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20 +``` + +请求参数: +```go +type NotificationListRequest struct { + IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"` + Type string `query:"type" description:"通知类型过滤(可选)"` + Page int `query:"page" description:"页码"` + PageSize int `query:"page_size" description:"每页数量(最大50)"` +} +``` + +响应: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "type": "approval.pending", + "title": "您有一条待审批记录", + "body": "代理「XX店」提交了充值申请,请及时审批。", + "ref_type": "approval", + "ref_id": 42, + "is_read": false, + "created_at": "2026-07-11T10:00:00Z" + } + ], + "total": 3, + "page": 1, + "page_size": 20 + } +} +``` + +### 7.3 标记已读 + +``` +PUT /admin/notifications/{id}/read +``` + +### 7.4 全部标记已读 + +``` +PUT /admin/notifications/read-all +``` + +可传 `type` 过滤(只把某类型全部已读): +```json +{ "type": "approval.pending" } +``` + +### DTO + +```go +// internal/model/dto/notification_dto.go + +type NotificationListRequest struct { + IsRead *bool `query:"is_read"` + Type string `query:"type"` + Page int `query:"page" default:"1"` + PageSize int `query:"page_size" default:"20"` +} + +type NotificationItem struct { + ID uint `json:"id"` + Type string `json:"type" description:"通知类型"` + Title string `json:"title"` + Body string `json:"body"` + RefType *string `json:"ref_type,omitempty"` + RefID *uint `json:"ref_id,omitempty"` + IsRead bool `json:"is_read"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type UnreadCountResponse struct { + Count int64 `json:"count"` +} + +type ReadAllRequest struct { + Type string `json:"type" description:"通知类型(为空则全部已读)"` +} +``` + +--- + +## 八、前端对接 + +### 顶部导航栏铃铛 + +``` +组件挂载 → 轮询 GET /admin/notifications/unread-count(30秒一次) +→ count > 0 时铃铛显示红点 + 数字 +→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面 +→ 打开时调 GET /admin/notifications?is_read=false +→ 点击某条通知 → PUT /admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页 +``` + +### 跳转逻辑(ref_type) + +| ref_type | 跳转页面 | +|----------|---------| +| `approval` | `/approval/{ref_id}` 审批详情 | +| `recharge` | `/recharge/{ref_id}` 充值单详情 | +| `refund` | `/refund/{ref_id}` 退款单详情 | +| `package` | `/assets/{ref_id}` 资产详情(临期提醒) | + +### 通知列表页(/notifications) + +筛选:通知类型(下拉)、已读状态 +操作:全部已读按钮 +列表字段:类型、标题、时间、已读状态 +点击行:跳转关联业务详情 + +--- + +## 九、Phase 2 扩展预留 + +当需要接入企微通知时,只需在 `HandleNotification` 里追加: + +```go +// 企微通知(Phase 2) +if h.wecomClient != nil { + for _, uid := range payload.RecipientIDs { + wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid) + h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body) + } +} +``` + +业务代码零修改,Handler 里加一段即可。 diff --git a/docs/7月迭代/基础设施/系统配置.md b/docs/7月迭代/基础设施/系统配置.md new file mode 100644 index 0000000..26a6e1b --- /dev/null +++ b/docs/7月迭代/基础设施/系统配置.md @@ -0,0 +1,216 @@ +# 基础设施:系统配置(tb_system_config) + +> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制) + +--- + +## 一、设计目标 + +将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。 + +--- + +## 二、数据库 + +```sql +-- 迁移文件:YYYYMMDD_create_tb_system_config.sql +CREATE TABLE tb_system_config ( + id BIGSERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name + config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串) + value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json + module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询) + description TEXT, -- 中文说明(前端展示用) + is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改) + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_system_config_key UNIQUE (config_key) +); + +COMMENT ON TABLE tb_system_config IS '系统全局配置表'; + +-- 初始化数据 +INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES +-- C端支付限制(需求9) +('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'), +('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'), + +-- H5流程顺序(需求2,per-asset 覆盖,此处是全局默认) +-- 注意:per-asset 的 realname_policy 已在 iot_card.realname_policy 字段存储 +-- 此处仅存"H5绑定手机后的默认流程" +('h5.flow.default_realname_policy', 'after_order', 'string', 'h5.flow', 'H5新用户默认实名策略(none/before_order/after_order)'); +``` + +--- + +## 三、Model + +```go +// internal/model/system_config.go + +// SystemConfig 系统全局配置模型 +type SystemConfig struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"` + ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"` + ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"` + Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"` + Description string `gorm:"column:description;type:text" json:"description"` + IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"` + 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" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (SystemConfig) TableName() string { return "tb_system_config" } +``` + +--- + +## 四、Store + +```go +// internal/store/postgres/system_config_store.go + +type SystemConfigStore struct { + db *gorm.DB +} + +func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) +func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error) +func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error +func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error) +``` + +--- + +## 五、配置读取辅助包 + +业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL): + +```go +// pkg/sysconfig/config.go + +// GetString 读取字符串配置,返回默认值 +func GetString(ctx context.Context, key string, defaultVal string) string + +// GetStringSlice 读取 JSON 数组配置 +func GetStringSlice(ctx context.Context, key string) ([]string, error) + +// GetBool 读取布尔配置 +func GetBool(ctx context.Context, key string, defaultVal bool) bool + +// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用) +func InvalidateCache(ctx context.Context, key string) +``` + +Redis Key:`sys:config:{config_key}`,TTL 5分钟。 + +业务代码用法: +```go +// 读取卡的允许支付方式 +allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods") +// 返回 ["alipay","wallet"] +``` + +--- + +## 六、API 设计 + +### 6.1 获取配置列表 + +``` +GET /admin/system/config?module=c2b.payment +``` + +响应: +```json +{ + "code": 0, + "data": { + "list": [ + { + "config_key": "c2b.payment.card_allowed_methods", + "config_value": "[\"alipay\",\"wallet\"]", + "value_type": "json", + "description": "卡资产C端允许的支付方式", + "is_readonly": false, + "updated_at": "2026-07-11T10:00:00Z" + } + ] + } +} +``` + +### 6.2 更新配置 + +``` +PUT /admin/system/config/{config_key} +``` + +请求体: +```json +{ + "config_value": "[\"alipay\",\"wallet\",\"wechat\"]" +} +``` + +权限:仅平台超管可操作。 + +### DTO + +```go +// internal/model/dto/system_config_dto.go + +// SystemConfigListRequest 配置列表查询请求 +type SystemConfigListRequest struct { + Module string `query:"module" description:"按模块过滤(可选)"` +} + +// SystemConfigItem 配置项响应 +type SystemConfigItem struct { + ConfigKey string `json:"config_key"` + ConfigValue string `json:"config_value"` + ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"` + Module string `json:"module"` + Description string `json:"description"` + IsReadonly bool `json:"is_readonly"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UpdateSystemConfigRequest 更新配置请求 +type UpdateSystemConfigRequest struct { + ConfigValue string `json:"config_value" validate:"required" description:"新配置值"` +} +``` + +--- + +## 七、前端对接 + +### 页面:系统设置 > 系统配置 + +**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。** + +调用流程: +1. 进入页面 → `GET /admin/system/config`(不传 module = 返回全部) +2. 按 module 分组展示,`is_readonly=true` 的配置只读 +3. 修改某项 → `PUT /admin/system/config/{config_key}`,body: `{config_value}` +4. 修改成功后提示"配置已更新,约5分钟后生效"(Redis 缓存 TTL) + +**C端支付限制配置展示建议**(针对 `c2b.payment` 模块): + +不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup: + +``` +卡资产允许支付方式: + ☑ 支付宝 ☑ 钱包 ☐ 微信 + +设备允许支付方式: + ☐ 支付宝 ☑ 钱包 ☑ 微信 +``` + +前端把选中项序列化成 `["alipay","wallet"]` 提交。 diff --git a/docs/7月迭代/需求01-复机实名规则.md b/docs/7月迭代/需求01-复机实名规则.md new file mode 100644 index 0000000..073ad85 --- /dev/null +++ b/docs/7月迭代/需求01-复机实名规则.md @@ -0,0 +1,54 @@ +# 需求01:行业卡后台手动复机允许未实名 + +--- + +## 背景 + +**当前代码**:`internal/service/iot_card/stop_resume_service.go:938` + +```go +// ManualStartCard - 当前写法(错误) +if card.RealNameStatus != constants.RealNameStatusVerified { + return errors.New(errors.CodeForbidden, "卡未实名,无法操作") +} +``` + +`isRealnameOK()` 已经正确处理行业卡豁免: + +```go +// 第234行:行业卡无需实名 +func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool { + return card.CardCategory == constants.CardCategoryIndustry || + card.RealNameStatus == constants.RealNameStatusVerified +} +``` + +但 `ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。 + +--- + +## 修改范围 + +**只改一行**,影响范围极小。 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +```go +// 修改前(第938行) +if card.RealNameStatus != constants.RealNameStatusVerified { + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + ... +} + +// 修改后 +if !s.isRealnameOK(card) { + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + ... +} +``` + +--- + +## 前端对接 + +无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。 diff --git a/docs/7月迭代/需求02-H5流程配置.md b/docs/7月迭代/需求02-H5流程配置.md new file mode 100644 index 0000000..25f2d0e --- /dev/null +++ b/docs/7月迭代/需求02-H5流程配置.md @@ -0,0 +1,211 @@ +# 需求02:H5 流程顺序配置化 + +--- + +## 背景 + +H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死) + +需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。 + +--- + +## 流程图 + +### 图一:H5 登录后资产视角判断与策略读取 + +```mermaid +flowchart TD + A[用户扫码 / 输入虚拟号] --> B[解析 identifier] + B --> C{资产类型} + + C -->|card| D{该卡是否绑定设备?} + D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy] + D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略] + + C -->|device| F + + E --> G{realname_policy} + F --> G + + G -->|none| H[无需实名\n直接进充值页] + G -->|before_order| I[先进实名页\n实名完成后才能充值] + G -->|after_order| J[先进充值页\n充值完成后提示实名] +``` + +### 图二:H5 充值/购买前的策略拦截逻辑 + +```mermaid +flowchart TD + A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy] + B --> C{策略是 before_order?} + C -->|否| D[放行,正常创建订单] + C -->|是| E{当前资产 RealNameStatus == 1?} + E -->|已实名| D + E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页] +``` + +### 图三:GetEffectiveRealnamePolicy 取值逻辑 + +```mermaid +flowchart TD + A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?} + B -->|是| C[返回 device.RealnamePolicy] + B -->|否| D{card != nil?} + D -->|是| E[返回 card.RealnamePolicy] + D -->|否| F[返回 none] +``` + +--- + +## 资产类型与策略归属 + +系统有两类资产:**独立卡** 和 **设备**。 + +| 资产类型 | realname_policy 归属 | H5 视角 | +|---------|---------------------|---------| +| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 | +| 设备 | 设备自身的 `realname_policy` | 设备视角 | +| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 | + +**关键规则**:设备下的卡无法以卡视角独立登录 H5,登录后自动进入设备视角,因此实名流程策略由设备决定,卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。 + +该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。 + +--- + +## 当前字段状态 + +两个模型都已有 `realname_policy` 字段,无需迁移: + +```go +// internal/model/iot_card.go +RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; + comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` + +// internal/model/device.go +RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; + comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` +``` + +值含义: +- `none` — 无需实名 +- `before_order` — 先实名后充值 +- `after_order` — 先充值后实名(**当前默认**) + +--- + +## 后端实现 + +### 1. 单条修改接口(已有,无需新建) + +``` +PATCH /api/admin/assets/:identifier/realname-mode +``` + +该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。 + +请求体(已有 DTO): +```go +type UpdateAssetRealnamePolicyRequest struct { + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +### 2. 新增批量修改接口(需新建) + +卡和设备分开批量接口,因为两者在后台是不同的列表页。 + +#### 2a. 批量修改卡实名策略 + +``` +POST /admin/iot-cards/batch-update-realname-policy +``` + +请求体: +```go +type BatchUpdateIotCardRealnamePolicy struct { + IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1" description:"卡ID列表"` + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +Service:`UPDATE tb_iot_cards SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志。 + +#### 2b. 批量修改设备实名策略 + +``` +POST /admin/devices/batch-update-realname-policy +``` + +请求体: +```go +type BatchUpdateDeviceRealnamePolicy struct { + DeviceIDs []uint `json:"device_ids" validate:"required,min=1" description:"设备ID列表"` + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +Service:`UPDATE tb_devices SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志。 + +### 3. 全局默认值(兜底) + +新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`。 + +--- + +## 前端对接 + +### 卡列表页 + +"操作"列或批量操作下拉增加"设置实名策略": + +- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode` +- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /admin/iot-cards/batch-update-realname-policy` + +> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效(H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。 + +### 设备列表页 + +"操作"列或批量操作下拉增加"设置实名策略": + +- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode` +- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /admin/devices/batch-update-realname-policy` + +### 字段展示 + +卡列表/详情、设备列表/详情均展示"实名策略"字段: + +| realname_policy | 展示文案 | +|----------------|---------| +| `none` | 无需实名 | +| `before_order` | 先实名后充值 | +| `after_order` | 先充值后实名 | + +### H5 侧(C端) + +H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。 + +- 独立卡登录 → 读卡的 `realname_policy` +- 设备/设备下的卡登录 → 读设备的 `realname_policy` + +该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理,H5 无需区分资产类型,直接用接口返回值即可。 + +--- + +## 实施范围汇总 + +| 项目 | 状态 | 说明 | +|------|------|------| +| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 | +| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 | +| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` | +| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 | +| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 | +| 批量修改卡接口 | ❌ 待建 | `POST /admin/iot-cards/batch-update-realname-policy` | +| 批量修改设备接口 | ❌ 待建 | `POST /admin/devices/batch-update-realname-policy` | +| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 | +| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 | diff --git a/docs/7月迭代/需求03-07-11-12-13-简单改动.md b/docs/7月迭代/需求03-07-11-12-13-简单改动.md new file mode 100644 index 0000000..c239ad4 --- /dev/null +++ b/docs/7月迭代/需求03-07-11-12-13-简单改动.md @@ -0,0 +1,321 @@ +# 需求03/07/11/12/13:简单改动合集 + +--- + +## 需求03:店铺列表搜索新增联系电话 + +### 后端 + +`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。 + +**文件**:`internal/store/postgres/shop_store.go`(列表查询 Store 方法) + +```go +// 现有过滤条件基础上追加 +if req.ContactPhone != "" { + query = query.Where("contact_phone = ?", req.ContactPhone) +} +``` + +**DTO 变更**:`internal/model/dto/shop_dto.go` 的 `ShopListRequest` 新增: + +```go +ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话(精确匹配,11位)"` +``` + +### 前端 + +店铺列表搜索栏新增"联系电话"输入框,填入后带入 `contact_phone` 参数请求。 + +--- + +## 需求07:IoT卡/设备管理新增已实名/未实名筛选 + +### IoT 卡 + +`IotCard.real_name_status` 已有(0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。 + +**DTO 变更**(`internal/model/dto/iot_card_dto.go` → `ListStandaloneIotCardRequest` 新增): + +```go +RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` +``` + +**Store 追加**(`internal/store/postgres/iot_card_store.go`): +```go +if req.RealNameStatus != nil { + query = query.Where("real_name_status = ?", *req.RealNameStatus) +} +``` + +### 设备 + +设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。 + +为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。 + +#### 迁移 + +`tb_devices` 新增字段: + +```sql +ALTER TABLE tb_devices ADD COLUMN real_name_status int NOT NULL DEFAULT 0 + COMMENT '实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1,由轮询异步维护'; +``` + +**Model**(`internal/model/device.go`): +```go +RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1" json:"real_name_status"` +``` + +#### 快照更新时机 + +以下两处卡实名状态变化时,需同步更新所属设备的快照: + +**1. 轮询实名处理**(`internal/task/polling_realname_handler.go`) + +卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照: + +```go +// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照 +if statusChanged { + if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil { + h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID) + } +} +``` + +**2. 管理员手动修改卡实名状态**(`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`) + +更新卡状态成功后,查所属设备并更新快照(同上逻辑)。 + +#### 快照计算 + +`DeviceStore.RefreshRealnameSnapshot`: + +```go +// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照 +func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error { + var count int64 + s.db.WithContext(ctx).Raw(` + SELECT COUNT(*) FROM tb_device_sim_binding dsb + JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id + WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL + AND ic.real_name_status = 1 AND ic.deleted_at IS NULL + `, deviceID).Scan(&count) + status := 0 + if count > 0 { + status = 1 + } + return s.db.WithContext(ctx).Model(&model.Device{}). + Where("id = ?", deviceID). + Update("real_name_status", status).Error +} +``` + +#### DTO 变更 + +**请求**(`internal/model/dto/device_dto.go` → `ListDeviceRequest` 新增): +```go +RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` +``` + +**响应**(`DeviceResponse` 新增): +```go +RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` +RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` +``` + +**Store 过滤**(直接 WHERE,无需 EXISTS): +```go +if req.RealNameStatus != nil { + query = query.Where("real_name_status = ?", *req.RealNameStatus) +} +``` + +### 前端 + +IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。 +设备管理同上,列表展示 `real_name_status_name` 字段。 + +--- + +## 需求11:资产详情-套餐到期时间字段 + 15天高亮 + +### 后端 + +**无需改动。** + +后台资产详情页实际调用的是: + +``` +GET /api/admin/assets/resolve/:identifier +``` + +该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含: + +```go +CurrentPackage string `json:"current_package"` // 当前套餐名称 +CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间 +CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null) +``` + +到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。 + +### 前端 + +资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮: + +- 读取 `resolve` 接口返回的 `current_package_expires_at` +- 若为 `null`:展示"暂无套餐" +- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)` +- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签) + +--- + +## 需求12:换货管理显示修复 + +### 背景 + +换货单表 `tb_exchange_order`: +- `old_asset_identifier` — 旧资产标识符快照 +- `new_asset_identifier` — 新资产标识符快照 +- `old_asset_id` / `new_asset_id` — 旧/新资产主键 + +### EXC-001/EXC-002:旧/新资产标识显示不一致 + +**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。 + +- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`** +- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`** +- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`,**逻辑正确,无需改动** + +**修复**(`internal/service/exchange/service.go`): + +`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`: + +```go +// 修复前 +return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...} + +// 修复后 +return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...} +``` + +历史数据不回填,仅修正后续新建换货单的快照行为。 + +### EXC-003/EXC-004:旧/新资产搜索支持 ICCID/接入号/虚拟号 + +**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。 + +**DTO 变更**(`internal/model/dto/exchange_dto.go` → `ExchangeListRequest`): + +废弃原有 `Identifier` 字段,改为: +```go +OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索(ICCID/接入号/虚拟号)"` +NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索(ICCID/接入号/虚拟号)"` +``` + +**Store 修改**(`internal/store/postgres/exchange_order_store.go`): + +两步查询——先在资产表搜出 ID,再过滤换货表: + +```go +// 步骤1:旧资产关键词搜索 +if req.OldAssetKeyword != "" { + kw := "%" + req.OldAssetKeyword + "%" + var cardIDs []uint + s.db.WithContext(ctx).Table("tb_iot_card"). + Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw). + Pluck("id", &cardIDs) + var deviceIDs []uint + s.db.WithContext(ctx).Table("tb_device"). + Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw). + Pluck("id", &deviceIDs) + + if len(cardIDs) == 0 && len(deviceIDs) == 0 { + return &ExchangeListResult{}, nil // 无匹配,直接返回空 + } + query = query.Where( + "(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)", + cardIDs, deviceIDs, + ) +} +// new_asset_keyword 同理,过滤 new_asset_id +``` + +### 前端 + +- EXC-001/002:后端修复后,`old_asset_identifier` 和 `new_asset_identifier` 均为 ICCID(卡)或设备号(设备),展示直接读这两个字段即可 +- EXC-003/004:搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword` 和 `new_asset_keyword` + +--- + +## 需求13:列表字段新增 + +### 核心原则 + +提交人、审批人均在**写入时快照**到单据本身,读取时直接返回,不做额外查询。 + +--- + +### COL-003:换货管理列表新增提交人(待建) + +> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。 + +**迁移**:`tb_exchange_order` 新增字段: +```sql +ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; +``` + +**Model**(`internal/model/exchange_order.go`): +```go +SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"` +``` + +**创建换货单时**(`internal/service/exchange/service.go`)快照当前操作人 username: +```go +SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username +``` + +**响应 DTO**(`internal/model/dto/exchange_dto.go` → `ExchangeOrderResponse` 新增): +```go +SubmitterName string `json:"submitter_name" description:"提交人账号名"` +``` + +--- + +### COL-001:退款管理列表新增提交人、审批人(依赖审批流) + +**迁移**:`tb_refund` 新增字段: +```sql +ALTER TABLE tb_refund ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; +ALTER TABLE tb_refund ADD COLUMN dept_leader_name varchar(50) NOT NULL DEFAULT ''; +ALTER TABLE tb_refund ADD COLUMN finance_approver_name varchar(50) NOT NULL DEFAULT ''; +``` + +- `submitter_name`:创建退款单时快照操作人 username +- `dept_leader_name` / `finance_approver_name`:审批流各环节通过时快照对应审批人 username + +> **实施依赖**:`dept_leader_name` / `finance_approver_name` 在审批流(需求20)实现后才能写入,未实现前返回空字符串。`submitter_name` 本迭代即可实现。 + +**响应 DTO**(退款列表响应新增): +```go +SubmitterName string `json:"submitter_name" description:"提交人账号名"` +DeptLeaderName string `json:"dept_leader_name" description:"部门领导审批人账号名"` +FinanceApproverName string `json:"finance_approver_name" description:"财务审批人账号名"` +``` + +--- + +### COL-002:代理充值列表新增提交人、审批人(依赖审批流) + +与 COL-001 同理,`tb_agent_recharge`(充值单表)新增同样三个字段,快照逻辑一致。 + +> **实施依赖**:`submitter_name` 本迭代可实现;审批人字段依赖需求21(充值审批流)。 + +--- + +### 前端 + +各列表页新增对应字段列,展示即可,无额外交互。 diff --git a/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md b/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md new file mode 100644 index 0000000..9b40ad0 --- /dev/null +++ b/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md @@ -0,0 +1,183 @@ +# 需求04:退款中资产禁止换货 +# 需求06:资产最后到期时间 + +--- + +## 需求04:退款中禁止换货 + +### 业务规则 + +资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。 + +未结束状态:1=待审批、4=已退回(退回给提交人修改中)。 +终态(不拦截):2=已通过、3=已拒绝。 + +### 数据模型 + +退款模型:`RefundRequest`(表 `tb_refund_request`) + +资产字段为两个独立字段(无 asset_type/asset_id): +- `iot_card_id *uint`:IoT卡ID(卡类资产) +- `device_id *uint`:设备ID(设备类资产) + +状态常量(`internal/model/refund.go`): +```go +RefundStatusPending = 1 // 待审批 +RefundStatusApproved = 2 // 已通过 +RefundStatusRejected = 3 // 已拒绝 +RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货) +``` + +### 实现位置 + +换货单创建入口:`internal/service/exchange/service.go` 创建前校验。 + +### 后端 + +**Store 新增方法**(`internal/store/postgres/refund_store.go`): + +```go +// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请 +func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). + Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", + cardID, + model.RefundStatusPending, // 1=待审批 + model.RefundStatusReturned, // 4=已退回(拦截) + ).Count(&count).Error + return count > 0, err +} + +// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请 +func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). + Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", + deviceID, + model.RefundStatusPending, // 1=待审批 + model.RefundStatusReturned, // 4=已退回(拦截) + ).Count(&count).Error + return count > 0, err +} +``` + +**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用): + +```go +// validateNoActiveRefund 校验资产是否有未结束的退款申请 +func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error { + var hasActive bool + var err error + if asset.CardID != nil { + hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID) + } else if asset.DeviceID != nil { + hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID) + } + if err != nil { + return err + } + if hasActive { + return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货") + } + return nil +} +``` + +### 前端 + +无需改动。换货申请时后端返回错误,前端展示错误信息即可。 + +--- + +## 需求06:资产详情-所有套餐的最后到期时间 + +### 业务规则 + +资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。 + +(即所有排队套餐依次激活完毕后,资产最终什么时候到期) + +### 接口 + +后台资产详情页实际调用的是: + +``` +GET /api/admin/assets/resolve/:identifier +``` + +响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`) + +该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。 + +### 后端 + +**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): + +```go +// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 null +LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐(生效中+待生效)的最后到期时间,无套餐时为 null"` +``` + +**Store 新增方法**(`internal/store/postgres/package_usage_store.go`): + +```go +// GetActiveAndQueuedByCardID 获取卡的生效中和待生效套餐 +func (s *PackageUsageStore) GetActiveAndQueuedByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + err := s.db.WithContext(ctx). + Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID, + constants.PackageUsageStatusActive, // 1=生效中 + constants.PackageUsageStatusPending, // 0=待生效 + ). + Order("created_at ASC"). + Find(&usages).Error + return usages, err +} + +// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐 +func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + err := s.db.WithContext(ctx). + Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", deviceID, + constants.PackageUsageStatusActive, // 1=生效中 + constants.PackageUsageStatusPending, // 0=待生效 + ). + Order("created_at ASC"). + Find(&usages).Error + return usages, err +} +``` + +> 注:待生效套餐的 `expires_at` 可能为 null(实名即生效尚未实名),取已知 expires_at 中的最大值。 + +**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加): + +```go +var lastExpiry *time.Time +var usages []*model.PackageUsage +if card != nil { + usages, _ = s.packageUsageStore.GetActiveAndQueuedByCardID(ctx, card.ID) +} else if device != nil { + usages, _ = s.packageUsageStore.GetActiveAndQueuedByDeviceID(ctx, device.ID) +} +for _, u := range usages { + if u.ExpiresAt == nil { + continue + } + if lastExpiry == nil || u.ExpiresAt.After(*lastExpiry) { + lastExpiry = u.ExpiresAt + } +} +resp.LastPackageExpiresAt = lastExpiry +``` + +### 前端 + +资产详情"套餐信息"板块新增展示: + +``` +最后到期时间:2027-01-01 +``` + +读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。 diff --git a/docs/7月迭代/需求05-套餐分配生效条件.md b/docs/7月迭代/需求05-套餐分配生效条件.md new file mode 100644 index 0000000..867ffa6 --- /dev/null +++ b/docs/7月迭代/需求05-套餐分配生效条件.md @@ -0,0 +1,158 @@ +# 需求05:套餐分配生效条件(ExpiryBase 覆盖) + +--- + +## 背景 + +`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。 + +需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。 + +--- + +## 快照链设计 + +``` +ShopPackageAllocation.expiry_base_override(分配时设置覆盖值) + ↓ 订单创建时读取,取有效值(override 优先,否则用套餐默认) +PackageUsage.expiry_base_snapshot(快照进去) + ↓ 激活时读取 +activation_service 读 usage.ExpiryBaseSnapshot,不再直接读 pkg.ExpiryBase +``` + +遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。 + +--- + +## 数据库变更 + +### 1. ShopPackageAllocation 新增覆盖字段 + +```sql +ALTER TABLE tb_shop_package_allocation + ADD COLUMN expiry_base_override VARCHAR(30) DEFAULT NULL + COMMENT '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)'; +``` + +### 2. PackageUsage 新增快照字段 + +```sql +ALTER TABLE tb_package_usage + ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '' + COMMENT '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)'; +``` + +旧数据不回填,默认空字符串,激活时自动兜底。 + +--- + +## Model 变更 + +### ShopPackageAllocation(`internal/model/shop_package_allocation.go`) + +```go +// ExpiryBaseOverride 生效条件覆盖 +// NULL = 使用宿主套餐的 ExpiryBase;有值 = 分配时指定,不受套餐后续修改影响 +ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"` +``` + +### PackageUsage(`internal/model/package.go`) + +```go +// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值) +ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"` +``` + +--- + +## 业务逻辑变更 + +### 1. 订单创建时快照(`internal/service/order/service.go`) + +订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加: + +```go +// 取生效条件有效值:分配覆盖 > 套餐默认 +expiryBase := pkg.ExpiryBase +if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" { + expiryBase = *allocation.ExpiryBaseOverride +} +// 创建 PackageUsage 时写入快照 +usage.ExpiryBaseSnapshot = expiryBase +``` + +### 2. 激活时读快照(`internal/service/package/activation_service.go`) + +```go +// 修改前:直接读套餐原值 +expiryBase := pkg.ExpiryBase + +// 修改后:优先读快照,旧数据兜底 +expiryBase := pkg.ExpiryBase // 兜底(旧数据 ExpiryBaseSnapshot 为空) +if usage.ExpiryBaseSnapshot != "" { + expiryBase = usage.ExpiryBaseSnapshot +} +``` + +同文件另一处(`activation_service.go:556`)同理修改。 + +`internal/service/order/service.go:2290` 中后台囤货路径的 `ExpiryBase` 判断也需同样改为读快照(兜底逻辑一致)。 + +--- + +## API 变更 + +### 1. 分配套餐接口(新增参数) + +``` +POST /admin/shop-package-allocations +``` + +请求 DTO 新增字段: + +```go +ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` +``` + +### 2. 修改已分配套餐的生效条件(新接口) + +``` +PATCH /admin/shop-package-allocations/{id}/expiry-base +``` + +请求 DTO: + +```go +type UpdateAllocationExpiryBaseRequest struct { + ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件(null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` +} +``` + +> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage(快照已定),只影响后续新建的订单。 + +--- + +## 前端对接 + +### 套餐分配弹框 + +新增"生效条件"选择项: + +``` +生效条件: + ○ 跟随套餐默认(默认选中,不传 expiry_base_override) + ○ 购买即生效(from_purchase) + ○ 实名即生效(from_activation) +``` + +### 已分配套餐列表 + +列表新增"生效条件"列: + +| 值 | 展示 | +|----|------| +| NULL | 套餐默认 | +| `from_activation` | 实名即生效(已覆盖) | +| `from_purchase` | 购买即生效(已覆盖) | + +操作列增加"修改生效条件"按钮,调用 `PATCH /admin/shop-package-allocations/{id}/expiry-base`。 diff --git a/docs/7月迭代/需求08-设备批量分配Excel.md b/docs/7月迭代/需求08-设备批量分配Excel.md new file mode 100644 index 0000000..2bf9604 --- /dev/null +++ b/docs/7月迭代/需求08-设备批量分配Excel.md @@ -0,0 +1,154 @@ +# 需求08:设备批量分配代理和套餐系列(Excel导入) + +--- + +## 背景 + +设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)来批量指定分配目标。 + +`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。 + +--- + +## 数据库变更 + +新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同): + +```sql +CREATE TABLE tb_device_batch_allocation_task ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + task_no VARCHAR(30) NOT NULL, + shop_id BIGINT NOT NULL, + series_id BIGINT, + operator_id BIGINT NOT NULL, + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + failed_items JSONB, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL; +``` + +状态常量:1=处理中,2=已完成,3=失败。 + +失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。 + +--- + +## Model(`internal/model/device_batch_allocation_task.go`) + +```go +// DeviceBatchAllocationTask 设备批量分配任务模型 +type DeviceBatchAllocationTask struct { + gorm.Model + BaseModel `gorm:"embedded"` + TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"` + ShopID uint `gorm:"column:shop_id;not null;comment:分配目标代理店铺ID" json:"shop_id"` + SeriesID *uint `gorm:"column:series_id;comment:套餐系列ID(可选)" json:"series_id,omitempty"` + OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"` + TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"` + FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"` + FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"` + StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` +} + +func (DeviceBatchAllocationTask) TableName() string { + return "tb_device_batch_allocation_task" +} +``` + +> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。 + +--- + +## API 设计 + +### 1. 下载 Excel 模板 + +``` +GET /admin/devices/batch-allocation/template +``` + +响应:Excel 文件,表头为"设备号"(一列)。 + +### 2. 上传并提交 + +``` +POST /admin/devices/batch-allocation +Content-Type: multipart/form-data + +字段: + shop_id: 123 (必填,分配给哪个代理) + series_id: 45 (可选,同时分配套餐系列) + file: +``` + +**处理逻辑(Asynq 异步)**: + +1. 解析 Excel,读取"设备号"列 +2. 逐行查找 `tb_device` 中匹配的设备(按 `virtual_no` 或 `imei`) +3. 对找到的设备:更新 `shop_id` + `series_id` +4. 未找到的:记录失败原因"设备号不存在" +5. 已分配给其他代理的:记录"该设备已分配,请先回收" + +### 3. 查询任务状态 + +``` +GET /admin/devices/batch-allocation/{task_id} +``` + +响应: + +```json +{ + "task_no": "DBA20240101001", + "status": 2, + "status_name": "已完成", + "total_count": 100, + "success_count": 95, + "fail_count": 5, + "failed_items": [ + { "line": 3, "virtual_no": "12345", "reason": "设备号不存在" }, + { "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" } + ], + "started_at": "2024-01-01T10:00:00Z", + "completed_at": "2024-01-01T10:00:05Z" +} +``` + +--- + +## 前端对接 + +### 入口 + +设备管理页 > 批量操作 > "批量分配代理" + +### 操作流程 + +1. 点击"批量分配代理" +2. 弹框内: + - 代理选择器(搜索+选择目标代理,必填) + - 套餐系列选择器(可选) + - Excel 上传区域(含模板下载链接) +3. 上传后点击"提交" → `POST /admin/devices/batch-allocation` +4. 提示"任务提交成功,正在处理..." +5. 轮询 `GET /admin/devices/batch-allocation/{task_id}` 直到 `status != 1` +6. 展示结果:成功X条,失败X条,失败明细在页面展示 + +### Excel 规范 + +- 表头:`设备号` +- 每行一个设备号(支持虚拟号或IMEI) diff --git a/docs/7月迭代/需求09-C端支付限制配置化.md b/docs/7月迭代/需求09-C端支付限制配置化.md new file mode 100644 index 0000000..9df9ed3 --- /dev/null +++ b/docs/7月迭代/需求09-C端支付限制配置化.md @@ -0,0 +1,120 @@ +# 需求09:C端支付方式限制配置化 + +> 依赖:[系统配置](./基础设施/系统配置.md) + +--- + +## 背景 + +代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。 + +注释位置: +- `internal/handler/app/client_wallet.go`(钱包充值入口) +- `internal/service/client_order/service.go`(订单支付入口) + +两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)` + +--- + +## 业务规则 + +| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 | +|---------|----------------|----------------| +| IoT 卡 | 支付宝、钱包 | 微信 | +| 设备 | 微信、钱包 | 支付宝 | + +**钱包支付对所有资产类型均允许。** + +--- + +## 实现方案 + +### 1. 系统配置初始化(已在系统配置文档中定义) + +```go +// tb_system_config 初始数据 +config_key: "c2b.payment.card_allowed_methods" → ["alipay","wallet"] +config_key: "c2b.payment.device_allowed_methods" → ["wechat","wallet"] +``` + +### 2. 恢复注释代码,改为读取配置 + +**文件**:`internal/service/client_order/service.go` + +```go +// validatePaymentMethod 校验资产类型与支付方式是否匹配 +func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, paymentMethod string) error { + // 钱包支付始终允许 + if paymentMethod == model.PaymentMethodWallet { + return nil + } + + var configKey string + switch assetType { + case model.AssetTypeIotCard: // "iot_card",定义在 internal/model/asset_identifier.go + configKey = "c2b.payment.card_allowed_methods" + case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go + configKey = "c2b.payment.device_allowed_methods" + default: + return nil // 未知资产类型不限制 + } + + allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey) + if err != nil || len(allowedMethods) == 0 { + // 配置读取失败,降级为允许(防止配置问题影响支付) + s.logger.Warn("读取支付方式配置失败,降级为允许", zap.String("config_key", configKey)) + return nil + } + + for _, m := range allowedMethods { + if m == paymentMethod { + return nil + } + } + return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式") +} +``` + +在 `client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。 + +### 3. 错误信息 + +用户端错误提示(友好文案): + +```go +// 根据资产类型给出具体提示 +switch assetType { +case model.AssetTypeIotCard: + return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付") +case model.AssetTypeDevice: + return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付") +} +``` + +--- + +## 前端对接 + +### C端支付页面 + +前端在展示支付方式时,根据资产类型**过滤不可用的支付方式**(前端主动隐藏,后端兜底拦截)。 + +前端需要从哪里知道"当前资产是卡还是设备"?登录/初始化时返回 `asset_type`,前端保存上下文。 + +``` +资产类型 = "iot_card" → 展示支付方式:支付宝、余额 +资产类型 = "device" → 展示支付方式:微信、余额 +``` + +### 后台配置页面 + +在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示: + +``` +卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信 +设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信 +``` + +修改后调用 `PUT /admin/system/config/c2b.payment.card_allowed_methods`。 + +> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。 diff --git a/docs/7月迭代/需求10-限速规则.md b/docs/7月迭代/需求10-限速规则.md new file mode 100644 index 0000000..fae21fb --- /dev/null +++ b/docs/7月迭代/需求10-限速规则.md @@ -0,0 +1,194 @@ +# 需求10:限速规则 + +--- + +## 背景 + +Gateway 已有 `SetSpeedLimit` 接口,支持卡和设备: + +```go +// internal/gateway/device.go +func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error + +// internal/gateway/models.go +type SpeedLimitReq struct { + CardNo string `json:"cardNo,omitempty"` // 卡(与 DeviceID 二选一) + DeviceID string `json:"deviceId,omitempty"` // 设备(与 CardNo 二选一) + SpeedLimit int `json:"speedLimit"` // 限速值(KB/s),0=不限速 + Extend string `json:"extend,omitempty"` +} +``` + +**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"** + +设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。 + +--- + +## 数据库变更 + +```sql +-- 套餐表增加限速字段 +ALTER TABLE tb_package + ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0 + COMMENT '限速值(KB/s),0=不限速'; +``` + +--- + +## Model 变更 + +```go +// internal/model/package.go +type Package struct { + // ...原有字段... + SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值(KB/s),0=不限速" json:"speed_limit_kbps"` +} +``` + +--- + +## 后端实现 + +### 1. 套餐激活时设置限速 + +在 `internal/service/package/activation_service.go` 中,套餐激活(`ActivatedAt` 赋值)时调用限速: + +```go +// activatePackageUsage 套餐激活核心逻辑(已有函数,追加限速调用) +func (s *ActivationService) activatePackageUsage(ctx context.Context, usage *model.PackageUsage, pkg *model.Package) error { + // ...现有激活逻辑... + + // 套餐有限速配置时,调用 Gateway 设置 + if pkg.SpeedLimitKbps > 0 { + if err := s.applySpeedLimit(ctx, usage, pkg.SpeedLimitKbps); err != nil { + // 限速失败不阻断套餐激活,记录错误日志 + s.logger.Error("设置限速失败", zap.Error(err), + zap.Uint("package_usage_id", usage.ID), + zap.Int("speed_limit", pkg.SpeedLimitKbps)) + } + } + return nil +} + +// applySpeedLimit 调用 Gateway 设置限速 +func (s *ActivationService) applySpeedLimit(ctx context.Context, usage *model.PackageUsage, speedKbps int) error { + req := &gateway.SpeedLimitReq{SpeedLimit: speedKbps} + + switch usage.UsageType { + case constants.PackageUsageTypeSingleCard: // "single_card",定义在 pkg/constants/iot.go + // 查卡的 ICCID + card, err := s.iotCardStore.GetByID(ctx, usage.IotCardID) + if err != nil { return err } + req.CardNo = card.ICCID + + case constants.PackageUsageTypeDevice: // "device",定义在 pkg/constants/iot.go + // 查设备的 IMEI + device, err := s.deviceStore.GetByID(ctx, usage.DeviceID) + if err != nil { return err } + req.DeviceID = device.IMEI + } + + return s.gatewayClient.SetSpeedLimit(ctx, req) +} +``` + +### 2. 套餐到期/失效时取消限速 + +当套餐状态变为 `已过期(3)` 或 `已失效(4)` 时,重置限速为 0(不限速)。 + +在套餐过期处理任务中追加: + +```go +// 套餐过期时取消限速(SpeedLimit=0 表示不限速) +if expiredPkg.SpeedLimitKbps > 0 { + s.applySpeedLimit(ctx, usage, 0) // 0 = 不限速 +} +``` + +> **注意**:如果有续费的排队套餐立即生效,应以新套餐的限速为准,而不是先取消再设置。 + +### 3. 手动操作限速(后台管理) + +``` +POST /admin/iot-cards/{id}/speed-limit +POST /admin/devices/{id}/speed-limit +``` + +请求体: +```go +type SetSpeedLimitRequest struct { + SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/s),0=取消限速"` +} +``` + +直接调 Gateway,不更新套餐配置(临时操作)。 + +--- + +## API 变更(套餐管理) + +### 创建/更新套餐新增字段 + +``` +POST /admin/packages +PUT /admin/packages/{id} +``` + +新增参数: +```go +SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/s),0=不限速"` +``` + +### 套餐列表/详情响应新增字段 + +```go +type PackageResponse struct { + // ...原有字段... + SpeedLimitKbps int `json:"speed_limit_kbps" description:"限速值(KB/s),0=不限速"` + SpeedLimitDesc string `json:"speed_limit_desc" description:"限速描述(如 '512 KB/s',0时为'不限速')"` +} +``` + +--- + +## 前端对接 + +### 页面:套餐创建/编辑 + +在"套餐配置"区域新增限速项: + +``` +限速配置: + 限速值:[____] KB/s (0 或留空 = 不限速) + 提示文案:留空或填 0 表示不限速;建议根据运营商规则填写 +``` + +前端换算提示(可选):512 KB/s ≈ 4 Mbps + +### 页面:套餐列表 + +新增"限速"列: +- `0` → 显示"不限速" +- `>0` → 显示"XXX KB/s" + +### 页面:资产详情 > 操作区 + +新增"手动设置限速"按钮(仅当有生效套餐时显示): + +``` +弹框: + 当前限速:xxx KB/s(来自套餐配置) + 临时设置:[____] KB/s [0=取消限速] + + [确认设置] → POST /admin/iot-cards/{id}/speed-limit +``` + +--- + +## 注意事项 + +1. **Gateway 调用失败不阻断业务**:限速设置失败只记录日志,套餐仍然激活。 +2. **限速精度**:Gateway `SpeedLimit` 单位是 KB/s,最小值为 1;填 0 表示不限速。 +3. **运营商差异**:不同运营商通过创建不同套餐来体现限速差异,不需要在套餐外额外维护运营商-限速映射表。 +4. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。 diff --git a/docs/7月迭代/需求14-导出功能.md b/docs/7月迭代/需求14-导出功能.md new file mode 100644 index 0000000..cfd50f3 --- /dev/null +++ b/docs/7月迭代/需求14-导出功能.md @@ -0,0 +1,247 @@ +# 需求14:导出功能 + +> 复用现有 ExportTask 体系(`tb_export_task` + Asynq) + +--- + +## 导出模块总览 + +| 编号 | 模块 | 新增/修改 | +|------|------|---------| +| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 | +| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** | +| 6.8.3 | 套餐列表导出 | **全新** | +| 6.8.4 | 退款管理退款列表导出 | **全新** | +| 6.8.5 | 换货管理导出 | **全新** | +| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) | + +--- + +## 现有导出体系说明 + +系统已有异步导出框架(`internal/exporter/`): +- `tb_export_task` 表记录导出任务 +- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`) +- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景 +- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行 +- 前端轮询任务状态后下载 + +新增导出模块需要: +1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量 +2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`) +3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()` +4. 新增对应的 Export API(创建导出任务,传入 `scene` 字段) + +--- + +## EXPD-001~003:IoT卡导出字段新增 + +**修改文件**:`internal/exporter/iot_card_scene.go` + +在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段: + +```go +// Headers() 新增 +"套餐名称", "使用流量(MB)", "剩余流量(MB)" + +// baseQuery() 或 Fetch() 新增 JOIN +LEFT JOIN LATERAL ( + SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name + FROM tb_package_usage pu + JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL + WHERE pu.iot_card_id = c.id AND pu.status = 1 + AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL + LIMIT 1 +) AS pkg ON TRUE + +// iotCardExportRow 新增 +PackageName string `gorm:"column:package_name"` +DataUsageMB int64 `gorm:"column:data_usage_mb"` +DataLimitMB int64 `gorm:"column:data_limit_mb"` +``` + +剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算) + +--- + +## 6.8.2:代理资金概况-预充值钱包流水导出 + +**新增接口**:`POST /admin/agent-wallet-transactions/export` + +支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。 + +导出字段映射: + +| 字段 | 数据来源 | +|------|---------| +| 店铺名称 | JOIN `tb_shop` | +| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) | +| 交易金额 | `amount / 100` 转元 | +| 状态 | `status` 中文化 | +| 资产类型 | `asset_type` 中文化 | +| 资产标识 | `asset_identifier` | +| 交易时间 | `created_at` | +| 交易前金额 | `balance_before / 100` | +| 交易后金额 | `balance_after / 100` | +| 购买套餐名称 | `metadata` 中 JSON 字段 `package_name`,或 JOIN 订单 | +| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) | +| 交易 ID | `id` | +| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) | +| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 | + +--- + +## 6.8.3:套餐列表导出 + +**新增接口**:`POST /admin/packages/export` + +支持现有套餐列表筛选条件。 + +导出字段(按需求文档 24 个字段): + +```go +type PackageExportRow struct { + PackageCode string `xlsx:"套餐编码"` + PackageName string `xlsx:"套餐名称"` + SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series + PackageType string `xlsx:"套餐类型"` // formal/addon 中文化 + DurationMonths int `xlsx:"套餐时长(月)"` + DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明 + CalendarType string `xlsx:"套餐周期类型"` + DurationDays int `xlsx:"套餐天数"` + RealDataMB int64 `xlsx:"真流量额度(MB)"` + VirtualDataMB int64 `xlsx:"虚流量额度(MB)"` + EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否 + VirtualRatio float64 `xlsx:"虚流量比例"` + DataResetCycle string `xlsx:"流量重置周期"` + ExpiryBase string `xlsx:"到期时间基准"` + CostPrice string `xlsx:"成本价(元)"` // 分→元 + SuggestedRetailPrice string `xlsx:"建议售价(元)"` + PriceConfigStatus string `xlsx:"价格配置状态"` + Status string `xlsx:"状态"` + ShelfStatus string `xlsx:"上架状态"` + IsGift string `xlsx:"是否赠送套餐"` + CreatorID uint `xlsx:"创建人ID"` + UpdaterID uint `xlsx:"更新人ID"` + CreatedAt string `xlsx:"创建时间"` + UpdatedAt string `xlsx:"更新时间"` + DeletedAt string `xlsx:"删除时间"` +} +``` + +--- + +## 6.8.4:退款管理退款列表导出 + +**新增接口**:`POST /admin/refund-orders/export` + +导出字段(去掉"退款到账方式",保留其余字段,部门领导/财务审批人依赖审批流): + +```go +type RefundExportRow struct { + RefundNo string `xlsx:"退款单号"` + ShopName string `xlsx:"代理店铺名称"` + PaymentOrderNo string `xlsx:"关联的支付订单号"` + AssetType string `xlsx:"资产类型"` + AssetIdentifier string `xlsx:"资产标识"` + PackageName string `xlsx:"套餐名称"` + OriginalAmount string `xlsx:"原订单金额"` + ActualAmount string `xlsx:"实收金额"` + RefundableAmount string `xlsx:"可退金额"` + AppliedAmount string `xlsx:"申请退款金额"` + ActualRefundAmount string `xlsx:"实际退款金额"` + Status string `xlsx:"状态"` + RefundReason string `xlsx:"退款原因"` + Remark string `xlsx:"备注"` + ApprovalRemark string `xlsx:"审批备注"` + AppliedAt string `xlsx:"退款申请时间"` + CompletedAt string `xlsx:"退款完成时间"` + SubmitterName string `xlsx:"提交人"` + DeptLeaderName string `xlsx:"部门领导审批人"` + FinanceName string `xlsx:"财务审批人"` + VoucherURLs string `xlsx:"退款凭证"` +} +``` + +--- + +## 6.8.5:换货管理导出 + +**新增接口**:`POST /admin/exchange-orders/export` + +```go +type ExchangeExportRow struct { + ExchangeNo string `xlsx:"换货单号"` + ExchangeType string `xlsx:"换货类型"` + ExchangeReason string `xlsx:"换货原因"` + ProblemDesc string `xlsx:"问题描述"` + OldAssetType string `xlsx:"旧资产类型"` + OldAssetIdentifier string `xlsx:"旧资产标识符"` + NewAssetIdentifier string `xlsx:"新资产标识符"` + ReceiverName string `xlsx:"收货人姓名"` + ReceiverPhone string `xlsx:"收货人电话"` + ReceiverAddress string `xlsx:"收货地址"` + ExpressCompany string `xlsx:"快递公司"` + TrackingNo string `xlsx:"快递单号"` + Status string `xlsx:"状态"` + CreatorName string `xlsx:"创建人"` + CreatedAt string `xlsx:"创建时间"` +} +``` + +--- + +## 6.8.6:代理充值导出 + +**新增接口**:`POST /admin/agent-recharge-orders/export` + +去掉"支付通道"字段(需求文档中明确去掉),保留其他字段: + +```go +type AgentRechargeExportRow struct { + RechargeNo string `xlsx:"充值单号"` + ShopName string `xlsx:"店铺名称"` + RechargeType string `xlsx:"充值类型"` + RechargeAmount string `xlsx:"充值金额"` + ActualAmount string `xlsx:"实付金额"` + BalanceBefore string `xlsx:"充值前余额"` + BalanceAfter string `xlsx:"充值后余额"` + Status string `xlsx:"状态"` + PaymentMethod string `xlsx:"支付方式"` + // 去掉支付通道 + OperationRemark string `xlsx:"运营备注"` + RejectReason string `xlsx:"驳回原因"` + CreatedAt string `xlsx:"创建时间"` + PaidAt string `xlsx:"支付时间"` + CompletedAt string `xlsx:"完成时间"` + SubmitterName string `xlsx:"提交人"` + DeptLeaderName string `xlsx:"部门领导审批人"` + FinanceName string `xlsx:"财务审批人"` + VoucherURLs string `xlsx:"支付凭证"` + Remark string `xlsx:"备注"` +} +``` + +--- + +## 前端对接(通用模式) + +各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。 + +调用流程: +1. 点击"导出" → 携带当前筛选条件 → `POST /admin/{module}/export` +2. 返回 `export_task_id` +3. 前端轮询 `GET /admin/export-tasks/{id}` 直到 `status=completed` +4. 下载 `download_url` + +(与现有导出体系完全一致,复用现有前端导出 Hook) + +--- + +## 权限导出配置(预留) + +需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。 + +**本次迭代**:统一导出全量字段,不做权限差异化(复杂度高,单独排期)。 + +**预留方案**:在导出 Handler 里预留 `filterFieldsByRole(userRoles, rows)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。 diff --git a/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md b/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md new file mode 100644 index 0000000..10c6fdc --- /dev/null +++ b/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md @@ -0,0 +1,348 @@ +# 需求15/16/18/19/20/21 技术方案 + +--- + +## 需求15:套餐下架后允许续费 + +### 业务规则 + +- 下架套餐(`shelf_status=2`)不可被**新购** +- 下架套餐**可以续费**(已在使用该套餐的客户) +- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户) + +### 后端 + +**当前逻辑**:下架套餐在购买时被拦截。 + +修改:在订单创建校验中,区分"新购"和"续费"场景: + +```go +// internal/service/order/service.go 或 client_order/service.go +func (s *Service) validatePackageAvailability( + ctx context.Context, + pkg *model.Package, + assetID uint, + isRenewal bool, +) error { + if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go + return errors.New(errors.CodeForbidden, "套餐已禁用") + } + if pkg.ShelfStatus == constants.ShelfStatusOff && !isRenewal { // 2=下架 + return errors.New(errors.CodeForbidden, "套餐已下架,不可新购") + } + return nil +} +``` + +**isRenewal 判断**:查当前资产是否有该套餐的历史生效记录: + +```go +func (s *Service) isRenewal(ctx context.Context, assetType string, assetID uint, packageID uint) bool { + count, _ := s.packageUsageStore.CountByAssetAndPackage(ctx, assetType, assetID, packageID) + return count > 0 +} +``` + +### 前端 + +C端续费页面:下架套餐不在"新购"列表中展示,但在"续费"入口中仍可展示。 + +后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。 + +--- + +## 需求16:代理分销码与佣金提现 + +### 业务规则 + +**DST-001**:新建代理时自动建立分销归属关系(指定发展人) +**DST-002**:员工可作为代理发展人进行标识 +**二维码**:代理或员工生成推广二维码 → 扫码进入H5填写信息 → 提交成为代理申请 → 平台审批 + +### 数据库变更 + +```sql +-- 1. Shop 表新增发展人字段 +ALTER TABLE tb_shop + ADD COLUMN referrer_type VARCHAR(20) DEFAULT NULL + COMMENT '发展人类型 shop=代理介绍 admin=员工介绍', + ADD COLUMN referrer_id BIGINT DEFAULT NULL + COMMENT '发展人ID(shop_id 或 tb_account.id)', + ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL + COMMENT '发展人姓名快照'; + +-- 2. 分销码表 +CREATE TABLE tb_distribution_code ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + code VARCHAR(32) NOT NULL, + owner_type VARCHAR(20) NOT NULL, -- shop=代理 | admin=员工 + owner_id BIGINT NOT NULL, + owner_name VARCHAR(50) NOT NULL DEFAULT '', + qr_code_url TEXT, + use_count INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, -- 1=有效 0=禁用 + expires_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX idx_distribution_code ON tb_distribution_code(code) WHERE deleted_at IS NULL; + +-- 3. 代理申请表(H5扫码提交) +CREATE TABLE tb_agent_application ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + apply_no VARCHAR(30) NOT NULL, + distribution_code VARCHAR(32) NOT NULL, + referrer_type VARCHAR(20) NOT NULL, + referrer_id BIGINT NOT NULL, + applicant_name VARCHAR(50) NOT NULL, + applicant_phone VARCHAR(20) NOT NULL, + shop_name VARCHAR(100) NOT NULL, + province VARCHAR(50), + city VARCHAR(50), + district VARCHAR(50), + address VARCHAR(255), + status INT NOT NULL DEFAULT 1, -- 1=待审批 2=已通过 3=已拒绝 + reject_reason TEXT, + reviewed_by BIGINT, + reviewed_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX idx_agent_application_no ON tb_agent_application(apply_no) WHERE deleted_at IS NULL; +``` + +### API 设计 + +**生成分销码**: +``` +POST /admin/distribution-codes +body: { owner_type: "admin"|"shop", owner_id: 123 } +``` + +**H5 扫码获取分销码信息**: +``` +GET /app/distribution-codes/{code} +``` + +**H5 提交代理申请**: +``` +POST /app/agent-applications +body: { distribution_code, applicant_name, applicant_phone, shop_name, ... } +``` + +**后台代理申请列表/审批**: +``` +GET /admin/agent-applications?status=1 +POST /admin/agent-applications/{id}/approve +POST /admin/agent-applications/{id}/reject +body: { reject_reason: "..." } +``` + +审批通过时:自动创建 `tb_shop` + `tb_account`,设置 `referrer_type` 和 `referrer_id`。 + +**佣金提现(DST-003~007)**: + +现有 commission 框架基础上新增材料上传字段,提现申请时一次性上传所有材料(对象存储 Key 列表)。 + +--- + +## 需求18:多人审批(APR-001~009) + +### 依赖 + +基于 [审批流基础设施](./基础设施/审批流.md) 和 [站内消息](./基础设施/站内消息.md)。 + +APR-009(企微审批对接)= Phase 2。 + +### 实现要点 + +| 编号 | 需求 | 实现 | +|------|------|------| +| APR-001~003 | 充值/退款多级审核 | 见需求20/21 | +| APR-004 | 审核环节:部门领导→财务 | 审批流固定两步:step=1 部门领导,step=2 财务 | +| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` | +| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` | +| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler | +| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler | +| APR-009 | 对接企微 | **Phase 2** | + +### 业务表与审批流的关联 + +充值单、退款单接入审批流,各自新增 `approval_flow_id` 字段: + +```sql +ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL + COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)'; + +ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL + COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)'; +``` + +接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。 + +--- + +## 需求19:批量订购套餐(BPO-001~008) + +### 业务流程 + +1. 员工进入批量订购页面 +2. 选择代理 + 上传 Excel(字段:资产类型/资产标识/套餐编码/套餐名称/支付方式) +3. 系统解析并校验每一行 +4. 支付方式:`offline=线下支付(默认成功)` / `agent_wallet=代理钱包支付` +5. 代理钱包支付时从代理余额扣款(含信用额度) +6. 线下支付时标记为已支付(不走钱包) +7. 校验失败的行展示在页面,带失败原因 +8. **线下支付需上传整批次的支付凭证** + +### 数据库变更 + +```sql +CREATE TABLE tb_bulk_purchase_task ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + task_no VARCHAR(30) NOT NULL, + shop_id BIGINT NOT NULL, + operator_id BIGINT NOT NULL, + payment_method VARCHAR(20) NOT NULL, -- offline | agent_wallet + voucher_keys JSONB, -- 线下支付凭证列表 + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, -- 1=处理中 2=已完成 + failed_items JSONB -- 失败明细 +); +CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL; +``` + +> 失败明细存 JSONB(`failed_items`),与现有 DeviceImportTask 模式一致,无需单独明细表。 + +### API 设计 + +**下载 Excel 模板**: +``` +GET /admin/bulk-purchases/template +``` + +**上传并提交**: +``` +POST /admin/bulk-purchases +Content-Type: multipart/form-data + +字段: + shop_id: 123 + payment_method: offline | agent_wallet + voucher_keys: ["key1","key2"] (offline 时必填) + file: +``` + +**查询任务状态**: +``` +GET /admin/bulk-purchases/{task_id} +``` + +--- + +## 需求20:退款审批 + +### 依赖 + +基于 [审批流基础设施](./基础设施/审批流.md)。 + +### 退款单现有状态(不变) + +``` +1=待审批 2=已通过 3=已拒绝 4=已退回 +``` + +退款单本身的状态含义不变,审批进度由 `tb_approval_flow` 管理,两者通过 `approval_flow_id` 关联。 + +### 流程 + +``` +提交退款申请(POST /admin/refund-requests) + → 创建 RefundRequest(status=1 待审批) + → 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=refund) + → 把 approval_flow_id 写入 RefundRequest + +审批人操作(POST /admin/approvals/{flow_id}/approve|reject|return) + → ApprovalCompletedEvent → RefundRequest.status=2(已通过)→ 触发实际退款 + → ApprovalRejectedEvent → RefundRequest.status=3(已拒绝) + → ApprovalReturnedEvent → RefundRequest.status=4(已退回)→ 通知提交人可修改 +``` + +### 退回后重新提交 + +``` +PUT /admin/refund-requests/{id} 仅 status=4 时可编辑退款单内容 +POST /admin/refund-requests/{id}/resubmit + → 校验 status=4 + → 新建 ApprovalFlow + → 更新 approval_flow_id,status 回到 1(待审批) +``` + +--- + +## 需求21:充值审核流程 + +### 充值单现有状态 + +``` +tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 +``` + +员工线下充值走审批流,需在现有状态基础上**新增"已退回"状态**: + +```sql +-- status 说明更新(不改原有值语义,追加新状态) +-- 6=已退回(审批人退回给提交人修改) +``` + +充值业务的状态语义: +- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中) +- `2=已支付`:支付成功(线下充值审批通过后跳到已完成,不经过此状态) +- `3=已完成`:充值到账 +- `4=已关闭`:取消/超时 +- `5=已退款`:退款 +- `6=已退回`:审批人退回给提交人修改 + +### 流程 + +**代理自行充值(不走审批)**: + +``` +代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包 +``` + +**员工线下代充值(走审批)**: + +``` +POST /admin/agent-recharge-records(payment_method=offline) + → 创建 AgentRechargeRecord(status=1) + → 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=recharge) + → 把 approval_flow_id 写入记录 + +审批通过 → AgentRechargeRecord.status=3(已完成)→ 实际充值到钱包 +审批驳回 → AgentRechargeRecord.status=4(已关闭) +审批退回 → AgentRechargeRecord.status=6(已退回)→ 通知提交人可修改 +``` + +### 退回后重新提交 + +``` +PUT /admin/agent-recharge-records/{id} 仅 status=6 时可编辑 +POST /admin/agent-recharge-records/{id}/resubmit + → 校验 status=6 + → 新建 ApprovalFlow + → 更新 approval_flow_id,status 回到 1(待支付/待审批) +``` diff --git a/docs/7月迭代/需求17-信用额度.md b/docs/7月迭代/需求17-信用额度.md new file mode 100644 index 0000000..967c18b --- /dev/null +++ b/docs/7月迭代/需求17-信用额度.md @@ -0,0 +1,264 @@ +# 需求17:信用额度 + +> DDD 结构:`internal/domain/wallet/` + +--- + +## 业务规则 + +### 两类主体 + +| 主体 | 信用额度来源 | 修改条件 | +|------|------------|---------| +| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`)| +| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 | + +### 可用余额公式 + +``` +可用额度 = balance + credit_limit - frozen_balance +``` + +- `balance` 可以为负(表示欠款) +- `balance < 0` 时表示已在使用信用额度 +- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护) + +### BPO-009 "是否可授权额度"开关 + +- 代理创建时,新增 `enable_credit` 开关 +- 开关关闭 = `credit_limit` 强制为 0,不允许使用信用额度 +- 平台员工账号通过角色自动拥有额度,无需开关 + +### BPO-012 负余额显示 + +- 代理前台(AgentWallet):`balance` 正常展示(可为负) +- 前端不做"不能为负"的前端校验,由后端控制 + +--- + +## 数据库变更 + +### 1. Shop 表加信用额度字段 + +```sql +ALTER TABLE tb_shop + ADD COLUMN enable_credit BOOLEAN NOT NULL DEFAULT FALSE + COMMENT '是否可使用信用额度', + ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 + COMMENT '授信额度上限(分)'; +``` + +### 2. AgentWallet 表加信用额度字段 + +```sql +ALTER TABLE tb_agent_wallet + ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 + COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit'; +``` + +> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包。 + +### 3. 角色信用额度配置(平台员工) + +```sql +ALTER TABLE tb_role + ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 + COMMENT '该角色的信用额度(分),仅对平台员工角色有效'; +``` + +--- + +## Model 变更 + +```go +// internal/model/shop.go +type Shop struct { + // ...原有字段... + EnableCredit bool `gorm:"column:enable_credit;not null;default:false;comment:是否可使用信用额度" json:"enable_credit"` + CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:授信额度上限(分)" json:"credit_limit"` +} + +// internal/model/agent_wallet.go +type AgentWallet struct { + // ...原有字段(Balance, FrozenBalance 已存在)... + CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"` +} + +// GetAvailableBalance 更新:可用余额 = 余额 - 冻结 + 信用额度 +func (w *AgentWallet) GetAvailableBalance() int64 { + return w.Balance - w.FrozenBalance + w.CreditLimit +} +``` + +--- + +## 领域层(DDD) + +```go +// internal/domain/wallet/wallet.go + +// AgentWallet 钱包聚合根(扩展现有模型) +type AgentWalletAggregate struct { + model.AgentWallet + domainEvents []DomainEvent `gorm:"-"` +} + +// Debit 扣款(含信用额度) +func (w *AgentWalletAggregate) Debit(amountCents int64, refType string, refID uint) error { + available := w.Balance - w.FrozenBalance + w.CreditLimit + if available < amountCents { + return ErrInsufficientBalance + } + before := w.Balance + w.Balance -= amountCents + w.recordEvent(WalletDebitedEvent{ + WalletID: w.ID, + ShopID: w.ShopID, + Amount: amountCents, + BalanceBefore: before, + BalanceAfter: w.Balance, + RefType: refType, + RefID: refID, + }) + return nil +} +``` + +--- + +## 应用层 + +```go +// internal/application/wallet/grant_credit.go + +// GrantCreditCommand 授信命令 +type GrantCreditCommand struct { + ShopID uint + CreditLimit int64 // 分 + OperatorID uint +} + +// GrantCreditUseCase 设置代理信用额度 +type GrantCreditUseCase struct { + shopStore ShopStore + walletStore WalletStore + txManager TxManager +} + +func (uc *GrantCreditUseCase) Execute(ctx context.Context, cmd GrantCreditCommand) error { + return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error { + shop, err := uc.shopStore.GetByID(ctx, cmd.ShopID) + if err != nil { return err } + + // 修改信用额度前,必须当前余额 >= 0(无欠款) + wallet, err := uc.walletStore.GetMainWallet(ctx, cmd.ShopID) + if err != nil { return err } + if wallet.Balance < 0 { + return errors.New(errors.CodeForbidden, "存在欠款,请先清零后再修改信用额度") + } + + // 同步更新 shop 和 wallet + if err := uc.shopStore.UpdateCreditLimit(ctx, tx, cmd.ShopID, cmd.CreditLimit); err != nil { + return err + } + return uc.walletStore.UpdateCreditLimit(ctx, tx, wallet.ID, cmd.CreditLimit) + }) +} +``` + +--- + +## API 设计 + +### 1. 创建店铺(新增字段) + +``` +POST /admin/shops +``` + +新增参数: +```go +EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` +CreditLimit int64 `json:"credit_limit" description:"授信额度(分),enable_credit=true 时有效"` +``` + +### 2. 修改信用额度 + +``` +PUT /admin/shops/{id}/credit-limit +``` + +```go +type UpdateCreditLimitRequest struct { + CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"` +} +``` + +错误场景:余额为负时,返回 `CodeForbidden`,msg="存在欠款(-XX元),请先清零后再修改信用额度" + +### 3. 角色信用额度配置(平台员工) + +``` +PUT /admin/roles/{id}/credit-limit +``` + +```go +type UpdateRoleCreditLimitRequest struct { + CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"该角色的信用额度(分)"` +} +``` + +### 4. 查询钱包(余额展示更新) + +现有 `GET /admin/agent-wallets/{shopID}` 响应新增字段: + +```go +type AgentWalletResponse struct { + // ...原有字段... + CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"` + AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen + credit_limit(分),可为负"` + IsInDebt bool `json:"is_in_debt" description:"是否有欠款(balance < 0)"` + DebtAmount int64 `json:"debt_amount" description:"欠款金额(分),balance<0时有值"` +} +``` + +--- + +## 前端对接 + +### 页面:创建/编辑代理 + +新增"信用额度"区块: +``` +信用额度: + [☑ 开启信用额度] + 授信上限:[____] 元 (enable_credit=true 时展示) +``` + +提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换) + +### 页面:代理详情 > 钱包信息 + +``` +钱包余额:-¥200.00 (红色显示) +信用额度:¥1,000.00 +可用余额:¥800.00 +``` + +当 `is_in_debt=true` 时,余额展示红色,并加"欠款"标签。 + +### 页面:修改信用额度 + +入口:代理详情 > 钱包 > "修改信用额度"按钮 +弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit` + +如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。 + +### 页面:余额显示(全局) + +代理管理列表的余额列:支持显示负数,负数红色标注。 + +### 平台员工余额(角色信用) + +平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。 +在批量订购确认页展示可用额度。 diff --git a/docs/7月迭代/需求22-套餐临期提醒.md b/docs/7月迭代/需求22-套餐临期提醒.md new file mode 100644 index 0000000..7c407ec --- /dev/null +++ b/docs/7月迭代/需求22-套餐临期提醒.md @@ -0,0 +1,266 @@ +# 需求22:套餐临期提醒 + +--- + +## 业务规则 + +### 临期定义 + +当资产的**当前生效套餐**(`PackageUsage.status=1`)的 `expires_at` ≤ 15天后,该资产进入临期状态。 + +**例外**:资产若有**排队中(status=0)的套餐**,不属于临期,不触发提醒。 + +### 颜色规则(Version 2) + +| 剩余天数 | 颜色 | +|---------|------| +| ≤ 15天 | 粉红色 | +| ≤ 7天 | 紫色 | +| ≤ 3天 | 黄色 | + +### 各端提醒规则 + +| 场景 | 提醒方式 | 触发节点 | +|------|---------|---------| +| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 | +| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 | +| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | +| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | + +--- + +## 数据库变更 + +无新表。临期数据实时计算,不存储(避免数据陈旧)。 + +企业客户的**每日临期推送记录**需防重,用一个 key 记录已推送记录: + +```sql +-- 临期推送记录(防重) +CREATE TABLE tb_expiry_push_record ( + id BIGSERIAL PRIMARY KEY, + asset_type VARCHAR(20) NOT NULL, -- iot_card | device + asset_id BIGINT NOT NULL, + push_node INT NOT NULL, -- 推送节点(3/7/15天) + push_date DATE NOT NULL, -- 推送日期 + pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_expiry_push UNIQUE (asset_type, asset_id, push_node, push_date) +); +``` + +--- + +## 后端实现 + +### 1. 现有接口新增临期字段 + +#### 资产列表接口(`GET /admin/iot-cards` / `GET /admin/devices`) + +响应新增字段: +```go +type IotCardListItem struct { + // ...原有字段... + DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"` + IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` +} +``` + +**计算方式**(在 SQL 层计算,避免 N+1): + +```sql +-- 列表查询时 JOIN PackageUsage 获取剩余天数 +SELECT + ic.*, + CASE + WHEN pu.expires_at IS NULL THEN NULL + WHEN EXISTS ( + SELECT 1 FROM tb_package_usage q + WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL + ) THEN NULL -- 有排队套餐,不临期 + ELSE GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT) + END AS days_until_expiry +FROM tb_iot_card ic +LEFT JOIN tb_package_usage pu + ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL +``` + +#### 资产列表筛选条件新增 + +```go +type IotCardListRequest struct { + // ...原有字段... + ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"` +} +``` + +#### 资产详情接口 + +后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。 + +新增字段: +```go +// AssetResolveResponse 追加 +DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"` +IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` +``` + +### 2. 新增临期列表接口(独立页面) + +``` +GET /admin/expiring-assets +``` + +查询参数: +```go +type ExpiringAssetsRequest struct { + AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"` + AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"` + PackageName string `query:"package_name" description:"套餐名称"` + ShopID *uint `query:"shop_id" description:"店铺ID"` + ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"` + ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"` + MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"` + Page int `query:"page" default:"1"` + PageSize int `query:"page_size" default:"20"` +} +``` + +响应: +```go +type ExpiringAssetItem struct { + AssetType string `json:"asset_type"` + AssetIdentifier string `json:"asset_identifier"` + AssetStatus int `json:"asset_status"` + ShopName string `json:"shop_name"` + PackageName string `json:"package_name"` + DaysUntilExpiry int `json:"days_until_expiry"` + ExpiresAt time.Time `json:"expires_at"` + DataUsageMB int64 `json:"data_usage_mb"` + RemainingDataMB int64 `json:"remaining_data_mb"` +} +``` + +### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧) + +```go +// internal/task/expiry_reminder_handler.go + +// HandleExpiryReminder 每日03:00执行,生成临期数据 + 写站内消息 +func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error { + nodes := []int{15, 7, 3} + for _, node := range nodes { + assets, err := h.packageUsageStore.GetExpiringAssets(ctx, node) + if err != nil { return err } + + for _, asset := range assets { + // 防重检查(同一资产同一节点今天已推送过) + today := time.Now().Format("2006-01-02") + pushed, _ := h.expiryPushStore.AlreadyPushed(ctx, asset.AssetType, asset.AssetID, node, today) + if pushed { continue } + + // 获取对应业务员ID(企业客户场景) + // 此处先写站内消息,Phase 2 改为企微推送 + h.notifyPublisher.Publish(ctx, notification.SendPayload{ + RecipientIDs: asset.SalesmanIDs, + Type: constants.NotifyTypePackageExpiring, + Title: fmt.Sprintf("套餐临期提醒(%d天)", node), + Body: fmt.Sprintf("资产 %s 的套餐将在 %d 天后到期", asset.Identifier, node), + RefType: "asset", + RefID: asset.AssetID, + }) + + // 记录推送防重 + h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, today) + } + } + return nil +} +``` + +--- + +## 导出功能(临期列表) + +``` +GET /admin/expiring-assets/export +``` + +导出字段: + +| 字段 | 说明 | +|------|------| +| 资产标识 | ICCID/设备号 | +| 资产类型 | 卡/设备 | +| 店铺名称 | | +| 套餐名称 | | +| 套餐到期时间 | | +| 剩余天数 | | +| 资产状态 | | +| 已用流量(MB) | | +| 剩余流量(MB) | | + +--- + +## C端接口变更 + +### 公众号首页 + +现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: + +```go +DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"` +IsExpiring bool `json:"is_expiring" description:"是否临期"` +``` + +前端逻辑:`is_expiring=true` 时展示续费提醒模块: + +``` +您的套餐即将到期 + +卡号/设备号:XXXX +剩余有效期:XX 天 + +为避免到期后影响正常使用,请您提前完成续费。 + +[立即续费] +``` + +--- + +## 前端对接(后台管理) + +### 资产列表(IoT卡管理 / 设备管理) + +1. 列表新增"剩余天数"列 +2. 根据 `days_until_expiry` 高亮行: + - ≤ 15天:行背景粉红色 + - ≤ 7天:行背景紫色 + - ≤ 3天:行背景黄色 +3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天) + - 选中后传 `expiring_within_days=15` + +### 临期资产独立列表页 + +路由:`/expiring-assets` + +``` +筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺 + +列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量 + +操作:导出按钮 → GET /admin/expiring-assets/export +``` + +### 代理端首页 + +在首页数据接口新增字段(需要确认代理端首页接口): + +```go +type AgentDashboardResponse struct { + // ...原有字段... + ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"` + ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"` +} +``` + +前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`