临时备份一次

This commit is contained in:
2026-07-13 12:01:18 +09:00
parent 5cdcdad534
commit 2e130b98f5
17 changed files with 3938 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# 7月迭代技术方案总览
> 讨论日期2026-07-11
> 分支Iteration/7-11
> 系统junhong_cmp_fiber已上线迭代阶段
---
## 阶段划分
| 阶段 | 范围 | 说明 |
|------|------|------|
| **Phase 1本迭代** | 需求 1-22除企微对接 | Gateway 接口已有,全部可做 |
| **Phase 2下次迭代** | 需求 18APR-009/22EXP-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临期提醒
```

View 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` |

View 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.IDstatus 回到"待审批"
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为"待审批"时显示操作按钮
- 其他角色:只读

View 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, -- 用户IDadmin 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-count30秒一次
→ 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 里加一段即可。

View File

@@ -0,0 +1,216 @@
# 基础设施系统配置tb_system_config
> 被依赖:需求 02H5流程配置、需求 09C端支付限制
---
## 一、设计目标
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
---
## 二、数据库
```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流程顺序需求2per-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"]` 提交。

View 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, "卡未实名,无法操作")
...
}
```
---
## 前端对接
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。

View File

@@ -0,0 +1,211 @@
# 需求02H5 流程顺序配置化
---
## 背景
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` |
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |

View 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` 参数请求。
---
## 需求07IoT卡/设备管理新增已实名/未实名筛选
### 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充值审批流
---
### 前端
各列表页新增对应字段列,展示即可,无额外交互。

View 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`,有值则展示,无值(无套餐)展示"—"。

View 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`

View 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

View File

@@ -0,0 +1,120 @@
# 需求09C端支付方式限制配置化
> 依赖:[系统配置](./基础设施/系统配置.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`
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。

View 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/s0=不限速
Extend string `json:"extend,omitempty"`
}
```
**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"**
设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。
---
## 数据库变更
```sql
-- 套餐表增加限速字段
ALTER TABLE tb_package
ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0
COMMENT '限速值KB/s0=不限速';
```
---
## Model 变更
```go
// internal/model/package.go
type Package struct {
// ...原有字段...
SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值KB/s0=不限速" 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/s0=取消限速"`
}
```
直接调 Gateway不更新套餐配置临时操作
---
## API 变更(套餐管理)
### 创建/更新套餐新增字段
```
POST /admin/packages
PUT /admin/packages/{id}
```
新增参数:
```go
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值KB/s0=不限速"`
```
### 套餐列表/详情响应新增字段
```go
type PackageResponse struct {
// ...原有字段...
SpeedLimitKbps int `json:"speed_limit_kbps" description:"限速值KB/s0=不限速"`
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. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。

View 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~003IoT卡导出字段新增
**修改文件**`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)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。

View 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 '发展人IDshop_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
→ 创建 RefundRequeststatus=1 待审批)
→ 调 SubmitApprovalUseCase 创建 ApprovalFlowbiz_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_idstatus 回到 1待审批
```
---
## 需求21充值审核流程
### 充值单现有状态
```
tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```
员工线下充值走审批流,需在现有状态基础上**新增"已退回"状态**
```sql
-- status 说明更新(不改原有值语义,追加新状态)
-- 6=已退回(审批人退回给提交人修改)
```
充值业务的状态语义:
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中)
- `2=已支付`:支付成功(线下充值审批通过后跳到已完成,不经过此状态)
- `3=已完成`:充值到账
- `4=已关闭`:取消/超时
- `5=已退款`:退款
- `6=已退回`:审批人退回给提交人修改
### 流程
**代理自行充值(不走审批)**
```
代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包
```
**员工线下代充值(走审批)**
```
POST /admin/agent-recharge-recordspayment_method=offline
→ 创建 AgentRechargeRecordstatus=1
→ 调 SubmitApprovalUseCase 创建 ApprovalFlowbiz_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_idstatus 回到 1待支付/待审批)
```

View 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`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
### 页面:余额显示(全局)
代理管理列表的余额列:支持显示负数,负数红色标注。
### 平台员工余额(角色信用)
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
在批量订购确认页展示可用额度。

View 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`