临时备份一次
This commit is contained in:
91
docs/7月迭代/00-总览.md
Normal file
91
docs/7月迭代/00-总览.md
Normal file
@@ -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(临期提醒)
|
||||
```
|
||||
357
docs/7月迭代/DDD规范.md
Normal file
357
docs/7月迭代/DDD规范.md
Normal file
@@ -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` |
|
||||
414
docs/7月迭代/基础设施/审批流.md
Normal file
414
docs/7月迭代/基础设施/审批流.md
Normal file
@@ -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为"待审批"时显示操作按钮
|
||||
- 其他角色:只读
|
||||
340
docs/7月迭代/基础设施/站内消息.md
Normal file
340
docs/7月迭代/基础设施/站内消息.md
Normal file
@@ -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 里加一段即可。
|
||||
216
docs/7月迭代/基础设施/系统配置.md
Normal file
216
docs/7月迭代/基础设施/系统配置.md
Normal file
@@ -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"]` 提交。
|
||||
54
docs/7月迭代/需求01-复机实名规则.md
Normal file
54
docs/7月迭代/需求01-复机实名规则.md
Normal file
@@ -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, "卡未实名,无法操作")
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。
|
||||
211
docs/7月迭代/需求02-H5流程配置.md
Normal file
211
docs/7月迭代/需求02-H5流程配置.md
Normal file
@@ -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` |
|
||||
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
321
docs/7月迭代/需求03-07-11-12-13-简单改动.md
Normal file
321
docs/7月迭代/需求03-07-11-12-13-简单改动.md
Normal file
@@ -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(充值审批流)。
|
||||
|
||||
---
|
||||
|
||||
### 前端
|
||||
|
||||
各列表页新增对应字段列,展示即可,无额外交互。
|
||||
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
@@ -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`,有值则展示,无值(无套餐)展示"—"。
|
||||
158
docs/7月迭代/需求05-套餐分配生效条件.md
Normal file
158
docs/7月迭代/需求05-套餐分配生效条件.md
Normal file
@@ -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`。
|
||||
154
docs/7月迭代/需求08-设备批量分配Excel.md
Normal file
154
docs/7月迭代/需求08-设备批量分配Excel.md
Normal file
@@ -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: <Excel文件>
|
||||
```
|
||||
|
||||
**处理逻辑(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)
|
||||
120
docs/7月迭代/需求09-C端支付限制配置化.md
Normal file
120
docs/7月迭代/需求09-C端支付限制配置化.md
Normal file
@@ -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`。
|
||||
|
||||
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。
|
||||
194
docs/7月迭代/需求10-限速规则.md
Normal file
194
docs/7月迭代/需求10-限速规则.md
Normal file
@@ -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. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。
|
||||
247
docs/7月迭代/需求14-导出功能.md
Normal file
247
docs/7月迭代/需求14-导出功能.md
Normal file
@@ -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)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。
|
||||
348
docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md
Normal file
348
docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md
Normal file
@@ -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: <Excel文件>
|
||||
```
|
||||
|
||||
**查询任务状态**:
|
||||
```
|
||||
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(待支付/待审批)
|
||||
```
|
||||
264
docs/7月迭代/需求17-信用额度.md
Normal file
264
docs/7月迭代/需求17-信用额度.md
Normal file
@@ -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`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
|
||||
|
||||
### 页面:余额显示(全局)
|
||||
|
||||
代理管理列表的余额列:支持显示负数,负数红色标注。
|
||||
|
||||
### 平台员工余额(角色信用)
|
||||
|
||||
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
|
||||
在批量订购确认页展示可用额度。
|
||||
266
docs/7月迭代/需求22-套餐临期提醒.md
Normal file
266
docs/7月迭代/需求22-套餐临期提醒.md
Normal file
@@ -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`
|
||||
Reference in New Issue
Block a user