Compare commits

...

10 Commits

Author SHA1 Message Date
028cfaa7aa feat: 实现权限检查功能并添加Redis缓存优化
- 完成 CheckPermission 方法的完整实现(账号→角色→权限查询链)
- 实现 Redis 缓存机制,大幅提升权限查询性能(~12倍提升)
- 自动缓存失效:角色/权限变更时清除相关用户缓存
- 新增完整的单元测试和集成测试(10个测试用例全部通过)
- 添加权限检查使用文档和缓存机制说明
- 归档 implement-permission-check OpenSpec 提案

性能优化:
- 首次查询: ~18ms(3次DB查询 + 1次Redis写入)
- 缓存命中: ~1.5ms(1次Redis查询)
- TTL: 30分钟,自动失效机制保证数据一致性
2026-01-16 18:15:32 +08:00
18f35f3ef4 feat: 完成B端认证系统和商户管理模块测试补全
主要变更:
- 新增B端认证系统(后台+H5):登录、登出、Token刷新、密码修改
- 完善商户管理和商户账号管理功能
- 补全单元测试(ShopService: 72.5%, ShopAccountService: 79.8%)
- 新增集成测试(商户管理+商户账号管理)
- 归档OpenSpec提案(add-shop-account-management, implement-b-end-auth-system)
- 完善文档(使用指南、API文档、认证架构说明)

测试统计:
- 13个测试套件,37个测试用例,100%通过率
- 平均覆盖率76.2%,达标

OpenSpec验证:通过(strict模式)
2026-01-15 18:15:17 +08:00
7ccd3d146c 标签更新 2026-01-15 10:44:53 +08:00
2857175266 refactor(account): 优化账号管理模块结构 2026-01-14 17:10:07 +08:00
b1195c16df feat(account): 实现平台账号管理功能
- 新增平台账号列表查询接口(自动筛选超级管理员和平台用户)
- 新增密码修改和状态切换专用接口
- 增强角色分配功能,支持空数组清空所有角色
- 新增超级管理员保护机制,禁止分配角色
- 新增完整的集成测试和OpenSpec规范文档
2026-01-14 17:00:30 +08:00
5556b1028c feat(role): 新增平台角色管理功能增强
- 权限表增加 available_for_role_types 字段,支持标记权限可用角色类型
- 权限列表和权限树接口支持按 available_for_role_type 过滤
- 新增角色状态切换接口 PUT /api/admin/roles/:id/status
- 角色分配权限时验证权限的可用角色类型
- 完善数据库迁移脚本和单元测试
- 补充数据库迁移相关开发规范文档
2026-01-14 12:15:57 +08:00
9c399df6bc feat(auth): 新增系统启动时自动初始化默认超级管理员功能
- 新增默认管理员自动初始化逻辑,系统启动时检查并创建超级管理员账号
- 支持通过配置文件自定义账号信息(优先级:配置文件 > 代码默认值)
- 新增 CreateSystemAccount 方法用于系统内部账号创建
- 新增默认管理员配置项和常量定义
- 更新 README.md 添加默认账号使用说明
- 归档 OpenSpec 变更提案及完整文档

相关文件:
- internal/bootstrap/admin.go: 管理员初始化逻辑
- internal/service/account/service.go: 系统账号创建方法
- pkg/config/config.go: 默认管理员配置结构
- pkg/constants/constants.go: 默认值常量定义
- docs/add-default-admin-init/功能说明.md: 完整功能文档
2026-01-14 10:53:42 +08:00
2570269c8d feat(wallet,tag): 钱包和标签系统多租户改造
核心变更:
- 钱包表:删除 user_id,添加 resource_type/resource_id(绑定资源而非用户)
- 标签表:添加 enterprise_id/shop_id(实现三级隔离:全局/企业/店铺)
- GORM Callback:自动数据权限过滤
- 迁移脚本:可重复执行,已验证回滚功能

钱包归属重构原因:
- 旧设计:钱包绑定用户账号,个人客户卡/设备转手后新用户无法使用余额
- 新设计:钱包绑定资源(卡/设备/店铺),余额随资源流转

标签三级隔离:
- 平台全局标签:所有用户可见
- 企业标签:仅该企业可见(企业内唯一)
- 店铺标签:该店铺及下级可见(店铺内唯一)

测试覆盖:
- 9 个单元测试验证标签多租户过滤(全部通过)
- 迁移和回滚功能测试通过(测试环境)
- OpenSpec 验证通过

变更 ID: fix-wallet-tag-multi-tenant
迁移版本: 000008
参考: openspec/changes/archive/2026-01-13-fix-wallet-tag-multi-tenant/
2026-01-13 16:52:37 +08:00
6e2dc325d7 新增钱包、换卡、标签系统的数据模型和规范
本次提交完成 add-wallet-transfer-tag-models 提案的实施和归档:

## 新增功能模块
- 钱包系统:用户/代理钱包管理,支持充值、扣款、退款、乐观锁防并发
- 换卡记录:物联卡更换历史追溯,包含套餐快照(JSONB)
- 标签系统:设备/IoT卡/号卡的统一标签管理
- 运营商渠道:四大运营商(CMCC/CUCC/CTCC/CBN)的渠道管理

## 数据库变更
- 新增 6 张表:tb_wallet, tb_wallet_transaction, tb_recharge_record, tb_card_replacement_record, tb_tag, tb_resource_tag
- 修改 2 张表:tb_carrier(新增渠道字段), tb_order(新增混合支付字段)
- 迁移版本:v6 → v7(执行时间 282.5ms)

## 代码变更
- 新增 8 个 Go 模型(符合统一规范:gorm.Model + BaseModel)
- 新增 40+ 个常量定义(含完整中文注释)
- 新增 7 个 Redis Key 生成函数
- 修复模型规范:移除重复字段,统一使用 gorm.Model 嵌入

## 文档变更
- 新增 3 个业务文档:数据模型设计、字段说明、迁移验证报告
- 更新 AGENTS.md:新增 Model 模型规范和常量注释规范
- 新增 4 个 OpenSpec 规范:wallet, carrier, card-replacement, tag
- 更新 1 个 OpenSpec 规范:iot-order(支持混合支付)

## 验证通过
-  LSP 诊断:所有模型和常量文件无错误
-  OpenSpec 验证:openspec validate --strict 通过
-  迁移执行:表结构创建成功,索引正确
-  提案归档:2026-01-13-add-wallet-transfer-tag-models

变更文件统计:29 个文件,新增 3682 行
2026-01-13 15:47:32 +08:00
2150fb6ab9 重构:完善 IoT 模型架构规范和数据库设计
- 完善 GORM 模型规范:货币字段使用 int64(分为单位)、JSONB 字段规范、模型结构规范
- 修复所有 IoT 模型的架构违规问题
- 更新 CLAUDE.md 开发指南,补充完整的数据库设计规范和模型示例
- 添加数据库迁移脚本(000006)用于架构重构
- 归档 OpenSpec 变更文档(2026-01-12-fix-iot-models-violations)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 17:43:12 +08:00
176 changed files with 27244 additions and 816 deletions

10
.emdash.json Normal file
View File

@@ -0,0 +1,10 @@
{
"preservePatterns": [
".env",
".env.keys",
".env.local",
".env.*.local",
".envrc",
"docker-compose.override.yml"
]
}

214
AGENTS.md
View File

@@ -82,6 +82,9 @@ Handler → Service → Store → Model
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)` - Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
- 格式: `{module}:{purpose}:{identifier}` - 格式: `{module}:{purpose}:{identifier}`
- 禁止硬编码字符串和 magic numbers - 禁止硬编码字符串和 magic numbers
- **必须为所有常量添加中文注释**,参考 `pkg/constants/iot.go` 的注释风格
- 常量分组使用 `// ========` 分隔线和标题注释
- 每个常量值后必须添加行内注释说明含义
### Go 代码风格 ### Go 代码风格
- 使用 `gofmt` 格式化 - 使用 `gofmt` 格式化
@@ -90,6 +93,38 @@ Handler → Service → Store → Model
- 接口命名: 使用 `-er` 后缀Reader、Writer、Logger - 接口命名: 使用 `-er` 后缀Reader、Writer、Logger
- 缩写词: 全大写或全小写URL、ID、HTTP 或 url、id、http - 缩写词: 全大写或全小写URL、ID、HTTP 或 url、id、http
## Model 模型规范
**必须遵守的模型结构:**
```go
// ModelName 模型名称模型
// 详细的业务说明2-3行
// 特殊说明(如果有)
type ModelName struct {
gorm.Model // 包含 ID、CreatedAt、UpdatedAt、DeletedAt
BaseModel `gorm:"embedded"` // 包含 Creator、Updater
Field1 string `gorm:"column:field1;type:varchar(50);not null;comment:字段1说明" json:"field1"`
// ... 其他字段
}
// TableName 指定表名
func (ModelName) TableName() string {
return "tb_model_name"
}
```
**关键要点:**
- ✅ **必须**嵌入 `gorm.Model``BaseModel`,不要手动定义 ID、CreatedAt、UpdatedAt、DeletedAt、Creator、Updater
- ✅ **必须**为模型添加中文注释,说明业务用途(参考 `internal/model/iot_card.go`
- ✅ **必须**在每个字段的 `comment` 标签中添加中文说明
- ✅ **必须**为导出的类型编写 godoc 格式的文档注释
- ✅ **必须**实现 `TableName()` 方法,表名使用 `tb_` 前缀
- ✅ 所有字段必须显式指定 `gorm:"column:field_name"` 标签
- ✅ 金额字段使用 `int64` 类型,单位为分
- ✅ 时间字段使用 `*time.Time`(可空)或 `time.Time`(必填)
- ✅ JSONB 字段需要实现 `driver.Valuer``sql.Scanner` 接口
## 数据库设计 ## 数据库设计
**核心规则:** **核心规则:**
@@ -154,6 +189,185 @@ Handler → Service → Store → Model
- 包含: method, path, query, status, duration, request_id, ip, user_agent, user_id, bodies - 包含: method, path, query, status, duration, request_id, ip, user_agent, user_id, bodies
- 使用 JSON 格式,配置自动轮转 - 使用 JSON 格式,配置自动轮转
## 数据库迁移
### 迁移工具
项目使用 **golang-migrate** 进行数据库迁移管理。
### 基本命令
```bash
# 查看当前迁移版本
make migrate-version
# 执行所有待迁移
make migrate-up
# 回滚上一次迁移
make migrate-down
# 创建新迁移文件
make migrate-create
# 然后输入迁移名称,例如: add_user_email
```
### 迁移文件规范
迁移文件位于 `migrations/` 目录:
```
migrations/
├── 000001_initial_schema.up.sql
├── 000001_initial_schema.down.sql
├── 000002_add_user_email.up.sql
├── 000002_add_user_email.down.sql
```
**命名规范**:
- 格式: `{序号}_{描述}.{up|down}.sql`
- 序号: 6位数字从 000001 开始
- 描述: 小写英文,用下划线分隔
- up: 应用迁移(向前)
- down: 回滚迁移(向后)
**编写规范**:
```sql
-- up.sql 示例
-- 添加字段时必须考虑向后兼容
ALTER TABLE tb_users
ADD COLUMN email VARCHAR(100);
-- 添加注释
COMMENT ON COLUMN tb_users.email IS '用户邮箱';
-- 为现有数据设置默认值(如果需要)
UPDATE tb_users SET email = '' WHERE email IS NULL;
-- down.sql 示例
ALTER TABLE tb_users
DROP COLUMN IF EXISTS email;
```
### 迁移执行流程(必须遵守)
当你创建迁移文件后,**必须**执行以下验证步骤:
1. **执行迁移**:
```bash
make migrate-up
```
2. **验证迁移状态**:
```bash
make migrate-version
# 确认版本号已更新且 dirty=false
```
3. **验证数据库结构**:
使用 PostgreSQL MCP 工具检查:
- 字段是否正确创建
- 类型是否符合预期
- 默认值是否正确
- 注释是否存在
4. **验证查询功能**:
编写临时脚本测试新字段的查询功能
5. **更新 Model**:
在 `internal/model/` 中添加对应字段
6. **清理测试数据**:
如果插入了测试数据,记得清理
### 迁移失败处理
如果迁移执行失败,数据库会被标记为 dirty 状态:
```bash
# 1. 检查错误原因
make migrate-version
# 如果显示 dirty=true说明迁移失败
# 2. 手动修复数据库状态
# 使用 PostgreSQL MCP 连接数据库
# 检查失败的迁移是否部分执行
# 手动清理或完成迁移
# 3. 清除 dirty 标记
UPDATE schema_migrations SET dirty = false WHERE version = {失败的版本号};
# 4. 修复迁移文件中的错误
# 5. 重新执行迁移
make migrate-up
```
### 使用 PostgreSQL MCP 访问数据库
项目配置了 PostgreSQL MCP 工具,用于直接访问和查询数据库。
**可用工具**:
1. **查看表结构**:
```
PostgresGetObjectDetails:
- schema_name: "public"
- object_name: "tb_permission"
- object_type: "table"
```
2. **列出所有表**:
```
PostgresListObjects:
- schema_name: "public"
- object_type: "table"
```
3. **执行查询**:
```
PostgresExecuteSql:
- sql: "SELECT * FROM tb_permission LIMIT 5"
```
**使用场景**:
- ✅ 验证迁移是否成功执行
- ✅ 检查字段类型、默认值、约束
- ✅ 查看现有数据
- ✅ 测试新增字段的查询功能
- ✅ 调试数据库问题
**注意事项**:
- ⚠️ MCP 工具只支持只读查询SELECT
- ⚠️ 不要直接修改数据,修改必须通过迁移文件
- ⚠️ 测试数据可以通过临时 Go 脚本插入
### 迁移最佳实践
1. **向后兼容**:
- 添加字段时使用 `DEFAULT` 或允许 NULL
- 删除字段前确保代码已不再使用
- 修改字段类型要考虑数据转换
2. **原子性**:
- 每个迁移文件只做一件事
- 复杂变更拆分成多个迁移
3. **可回滚**:
- down.sql 必须能完整回滚 up.sql 的所有变更
- 测试回滚功能: `make migrate-down && make migrate-up`
4. **注释完整**:
- 迁移文件顶部说明变更原因
- 关键 SQL 添加行内注释
- 数据库字段使用 COMMENT 添加说明
5. **测试数据**:
- 不要在迁移文件中插入业务数据
- 可以插入配置数据或枚举值
- 测试数据用临时脚本处理
## OpenSpec 工作流 ## OpenSpec 工作流
创建提案前的检查清单: 创建提案前的检查清单:

219
CLAUDE.md
View File

@@ -310,28 +310,126 @@ internal/
- 短文本(名称、标题等):`VARCHAR(255)``VARCHAR(100)` - 短文本(名称、标题等):`VARCHAR(255)``VARCHAR(100)`
- 中等文本(描述、备注等):`VARCHAR(500)``VARCHAR(1000)` - 中等文本(描述、备注等):`VARCHAR(500)``VARCHAR(1000)`
- 长文本(内容、详情等):`TEXT` 类型 - 长文本(内容、详情等):`TEXT` 类型
- **货币金额字段必须使用整数类型存储(分为单位)**
- Go 类型:`int64`(不是 `float64`
- 数据库类型:`BIGINT`(不是 `DECIMAL``NUMERIC`
- 示例:`CostPrice int64 gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
- 理由:避免浮点数精度问题,确保货币计算的准确性
- 显示时转换:金额除以 100 转换为元(如 10000 分 = 100.00 元)
- 数值字段精度必须明确定义: - 数值字段精度必须明确定义:
- 货币金额:`DECIMAL(10, 2)``DECIMAL(18, 2)`(根据业务需求 - 百分比:使用 `int64` 存储千分比或万分比(如 2000 表示 20%,避免浮点精度问题
- 百分比:`DECIMAL(5, 2)``DECIMAL(5, 4)`
- 计数器:`INTEGER``BIGINT` - 计数器:`INTEGER``BIGINT`
- 流量数据:`BIGINT`(如 MB、KB 为单位的流量使用量)
- 所有字段必须添加中文注释,说明字段用途和业务含义 - 所有字段必须添加中文注释,说明字段用途和业务含义
- 必填字段必须在 GORM 标签中指定 `not null` - 必填字段必须在 GORM 标签中指定 `not null`
- 唯一字段必须在 GORM 标签中指定 `unique` 或通过数据库索引保证唯一性 - 唯一字段必须在 GORM 标签中指定 `unique` 或通过数据库索引保证唯一性
- 枚举字段应该使用 `VARCHAR``INTEGER` 类型,并在代码中定义常量映射 - 枚举字段应该使用 `VARCHAR``INTEGER` 类型,并在代码中定义常量映射
- JSONB 字段必须使用 `datatypes.JSON` 类型(从 `gorm.io/datatypes` 包导入)
- 示例:`AccountInfo datatypes.JSON gorm:"column:account_info;type:jsonb;comment:收款账户信息" json:"account_info"`
- 不使用 `pq.StringArray` 或其他 PostgreSQL 特定类型
**字段命名示例:** **GORM 模型结构规范:**
- **所有业务实体模型必须嵌入 `gorm.Model``BaseModel`**
- `gorm.Model` 提供:`ID`(主键)、`CreatedAt``UpdatedAt``DeletedAt`(软删除支持)
- `BaseModel` 提供:`Creator``Updater`(审计字段)
- 禁止手动定义 `ID``CreatedAt``UpdatedAt``DeletedAt` 字段
- 示例:
```go ```go
type User struct { type IotCard struct {
ID uint `gorm:"column:id;primaryKey;comment:用户 ID" json:"id"` gorm.Model
UserID string `gorm:"column:user_id;type:varchar(100);uniqueIndex;not null;comment:用户唯一标识" json:"user_id"` BaseModel `gorm:"embedded"`
Email string `gorm:"column:email;type:varchar(255);uniqueIndex;not null;comment:用户邮箱" json:"email"` // 业务字段...
Phone string `gorm:"column:phone;type:varchar(20);comment:手机号码" json:"phone"` }
Nickname string `gorm:"column:nickname;type:varchar(100);comment:用户昵称" json:"nickname"` ```
Balance int64 `gorm:"column:balance;type:bigint;default:0;comment:账户余额(分为单位)" json:"balance"` - **日志表和只追加append-only表不需要软删除和审计字段**
Status int `gorm:"column:status;type:int;default:1;comment:用户状态 1-正常 2-禁用" json:"status"` - 这类表只定义 `ID` 和 `CreatedAt`,不嵌入 `gorm.Model` 或 `BaseModel`
- 示例:`DataUsageRecord`(流量使用记录)
- 示例:
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"` }
```
**表名命名规范:**
- **所有表名必须使用 `tb_` 前缀 + 单数形式**
- 示例:`tb_iot_card`(不是 `iot_cards`
- 示例:`tb_package`(不是 `packages`
- 示例:`tb_order`(不是 `orders`
- **必须实现 `TableName()` 方法显式指定表名**
```go
func (IotCard) TableName() string {
return "tb_iot_card"
}
```
- 禁止依赖 GORM 的自动表名推断(避免复数形式导致的不一致)
**索引和约束规范:**
- **外键字段必须添加 `index` 标签**
- 示例:`CarrierID uint gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
- 提高关联查询性能
- **唯一索引必须支持软删除兼容性**
- 添加 `where:deleted_at IS NULL` 条件,确保软删除后的记录不影响唯一性约束
- 示例:`gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
- 这样同一个 ICCID 的卡可以多次创建/删除而不违反唯一性约束
- **复合索引命名规范**
- 使用 `idx_{table}_{field1}_{field2}` 格式
- 示例:`uniqueIndex:idx_device_sim_binding_device_slot,where:deleted_at IS NULL`
- 禁止定义数据库级别的外键约束Foreign Key Constraints
**完整模型示例:**
标准业务实体模型(带软删除和审计字段):
```go
type IotCard struct {
gorm.Model // 提供 ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // 提供 Creator, Updater
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-未激活 2-已激活 3-已停用" json:"status"`
}
func (IotCard) TableName() string {
return "tb_iot_card"
}
```
日志表模型(不需要软删除和审计):
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}
```
包含 JSONB 字段的模型:
```go
type Order struct {
gorm.Model
BaseModel `gorm:"embedded"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号" json:"order_no"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分为单位)" json:"amount"`
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成" json:"status"`
}
func (Order) TableName() string {
return "tb_order"
} }
``` ```
@@ -414,6 +512,103 @@ type User struct {
--- ---
### 数据库迁移规范
**迁移工具:**
项目使用 **golang-migrate** 进行数据库迁移管理。
**基本命令:**
```bash
# 查看当前迁移版本
make migrate-version
# 执行所有待迁移
make migrate-up
# 回滚上一次迁移
make migrate-down
# 创建新迁移文件
make migrate-create
# 然后输入迁移名称,例如: add_user_email
```
**迁移文件命名规范:**
- 格式: `{序号}_{描述}.{up|down}.sql`
- 序号: 6位数字从 000001 开始
- 描述: 小写英文,用下划线分隔
- up: 应用迁移(向前)
- down: 回滚迁移(向后)
**迁移执行流程(必须遵守):**
当创建迁移文件后,**必须**执行以下验证步骤:
1. **执行迁移**: `make migrate-up`
2. **验证迁移状态**: `make migrate-version` (确认版本号已更新且 dirty=false)
3. **使用 PostgreSQL MCP 验证数据库结构**:
- 字段是否正确创建
- 类型是否符合预期
- 默认值是否正确
- 注释是否存在
4. **验证查询功能**: 编写临时脚本测试新字段的查询功能
5. **更新 Model**: 在 `internal/model/` 中添加对应字段
6. **清理测试数据**: 如果插入了测试数据,记得清理
**迁移失败处理:**
如果迁移执行失败,数据库会被标记为 dirty 状态:
```bash
# 1. 检查错误原因
make migrate-version # 如果显示 dirty=true说明迁移失败
# 2. 使用 PostgreSQL MCP 连接数据库
# 检查失败的迁移是否部分执行,手动清理或完成迁移
# 3. 清除 dirty 标记(通过临时 Go 脚本)
UPDATE schema_migrations SET dirty = false WHERE version = {失败的版本号};
# 4. 修复迁移文件中的错误
# 5. 重新执行迁移
make migrate-up
```
**使用 PostgreSQL MCP 访问数据库:**
项目配置了 PostgreSQL MCP 工具,用于直接访问和查询数据库。
可用操作:
- **查看表结构**: `PostgresGetObjectDetails` (schema_name: "public", object_name: "tb_permission", object_type: "table")
- **列出所有表**: `PostgresListObjects` (schema_name: "public", object_type: "table")
- **执行查询**: `PostgresExecuteSql` (sql: "SELECT * FROM tb_permission LIMIT 5")
使用场景:
- ✅ 验证迁移是否成功执行
- ✅ 检查字段类型、默认值、约束
- ✅ 查看现有数据
- ✅ 测试新增字段的查询功能
- ✅ 调试数据库问题
注意事项:
- ⚠️ MCP 工具只支持只读查询SELECT
- ⚠️ 不要直接修改数据,修改必须通过迁移文件
- ⚠️ 测试数据可以通过临时 Go 脚本插入
**迁移最佳实践:**
1. **向后兼容**: 添加字段时使用 `DEFAULT` 或允许 NULL删除字段前确保代码已不再使用
2. **原子性**: 每个迁移文件只做一件事;复杂变更拆分成多个迁移
3. **可回滚**: down.sql 必须能完整回滚 up.sql 的所有变更
4. **注释完整**: 迁移文件顶部说明变更原因;关键 SQL 添加行内注释;数据库字段使用 COMMENT 添加说明
5. **测试数据**: 不要在迁移文件中插入业务数据;可以插入配置数据或枚举值;测试数据用临时脚本处理
---
### 文档规范 ### 文档规范
**文档结构要求:** **文档结构要求:**

203
README.md
View File

@@ -8,6 +8,179 @@
**技术栈**Fiber + GORM + Viper + Zap + Lumberjack.v2 + Validator + sonic JSON + Asynq + PostgreSQL **技术栈**Fiber + GORM + Viper + Zap + Lumberjack.v2 + Validator + sonic JSON + Asynq + PostgreSQL
---
## 核心业务说明
### 业务模式概览
君鸿卡管系统是一个物联网卡和号卡的全生命周期管理平台,支持三种客户类型和两种组织实体的多租户管理。
### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 |
|---------|---------|---------|---------|
| **企业客户** | B端大客户公对公支付 | 企业购买大量卡/设备用于业务运营 | ❌ 无钱包(后台直接分配套餐) |
| **个人客户** | C端用户微信登录 | 个人购买单卡或设备含1-4张卡 | ✅ 钱包归属**卡/设备**(支持转手) |
| **代理商** | 渠道分销商,层级管理 | 预存款采购套餐,按成本价+加价销售 | ✅ 钱包归属**店铺**(多账号共享) |
### 个人客户业务流程
```
┌─────────────────────────────────────────────────────────────────┐
│ 个人客户使用流程 │
└──────────────────────────┬──────────────────────────────────────┘
┌──────────▼──────────┐
│ 1. 获得卡/设备 │
│ - 单卡ICCID │
│ - 设备:设备号/IMEI │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 2. 微信扫码登录 │
│ - 输入ICCID/IMEI │
│ - 首次需绑定手机号 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 3. 查看卡/设备信息 │
│ - 流量使用情况 │
│ - 套餐有效期 │
│ - 钱包余额 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 4. 钱包充值 │
│ - 微信支付 │
│ - 支付宝支付 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 5. 购买套餐 │
│ - 单卡套餐 │
│ - 设备套餐(共享) │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 6. 卡/设备转手 │
│ - 新用户扫码登录 │
│ - 钱包余额跟着走 │
└─────────────────────┘
```
### 钱包归属设计
#### 为什么钱包绑定资源(卡/设备)而非用户?
**问题场景**
```
个人客户 A 购买单卡 → 充值 100 元 → 使用 50 元 → 转手给个人客户 B
```
**如果钱包绑定用户**
- ❌ 个人客户 B 登录后看不到余额(钱包还在 A 账号下)
- ❌ 需要手动转账或退款,体验极差
**钱包绑定资源(当前设计)**
- ✅ 个人客户 B 登录后看到剩余 50 元(钱包跟着卡走)
- ✅ 无需任何额外操作,自然流转
#### 钱包归属规则
```go
// 钱包模型
type Wallet struct {
ResourceType string // iot_card | device | shop
ResourceID uint // 资源ID
Balance int64 // 余额(分)
// ...
}
// 场景1个人客户的单卡钱包
resource_type = "iot_card"
resource_id = 101 // 卡ID
// 场景2个人客户的设备钱包3张卡共享
resource_type = "device"
resource_id = 1001 // 设备ID
// 场景3代理商店铺钱包多账号共享
resource_type = "shop"
resource_id = 10 // 店铺ID
```
### 设备套餐业务规则
#### 设备级套餐购买
```
设备绑定 3 张 IoT 卡
├── 卡1ICCID-001
├── 卡2ICCID-002
└── 卡3ICCID-003
用户购买套餐399 元/年,每月 3000G 流量
├── 套餐分配3 张卡都获得该套餐
├── 流量共享3000G/月 在 3 张卡之间共享(总共 3000G
├── 用户支付399 元(一次性)
└── 代理分佣100 元(只分一次,不按卡数倍增)
```
**关键点**
- ✅ 套餐自动分配到设备的所有卡
- ✅ 流量是**设备级别共享**(非每卡独立)
- ✅ 分佣**只计算一次**(防止重复分佣)
### 标签系统多租户隔离
#### 三级隔离模型
| 标签类型 | 创建者 | 可见范围 | 名称唯一性 | 示例 |
|---------|-------|---------|-----------|------|
| 平台全局标签 | 平台管理员 | 所有用户 | 全局唯一 | "VIP"、"重要客户" |
| 企业标签 | 企业用户 | 仅该企业 | 企业内唯一 | 企业A的"测试标签" |
| 店铺标签 | 代理商 | 该店铺及下级 | 店铺内唯一 | 店铺10的"华东区" |
#### 隔离规则
```
企业 A 创建标签 "测试标签"
├── enterprise_id = 5, shop_id = NULL
├── 企业 A 的用户可见
└── 企业 B 的用户不可见
企业 B 创建标签 "测试标签"(允许)
├── enterprise_id = 8, shop_id = NULL
├── 企业 B 的用户可见
└── 与企业 A 的 "测试标签" 相互隔离
平台创建标签 "VIP"
├── enterprise_id = NULL, shop_id = NULL
└── 所有用户可见
```
#### 数据权限自动过滤
```go
// GORM Callback 自动注入过滤条件
switch userType {
case UserTypeAgent:
// 代理用户:只看到自己店铺及下级店铺的标签
db.Where("shop_id IN (?) OR (enterprise_id IS NULL AND shop_id IS NULL)", subordinateShopIDs)
case UserTypeEnterprise:
// 企业用户:只看到自己企业的标签
db.Where("enterprise_id = ? OR (enterprise_id IS NULL AND shop_id IS NULL)", enterpriseID)
default:
// 个人客户:只看到全局标签
db.Where("enterprise_id IS NULL AND shop_id IS NULL")
}
```
---
## 核心功能 ## 核心功能
- **认证中间件**:基于 Redis 的 Token 认证 - **认证中间件**:基于 Redis 的 Token 认证
@@ -19,7 +192,9 @@
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护 - **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力 - **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务 - **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)[使用指南](docs/004-rbac-data-permission/使用指南.md) - **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
- **代理商体系**:层级管理和分佣结算 - **代理商体系**:层级管理和分佣结算
- **批量同步**:卡状态、实名状态、流量使用情况 - **批量同步**:卡状态、实名状态、流量使用情况
@@ -156,6 +331,32 @@ go run cmd/api/main.go
go run cmd/worker/main.go go run cmd/worker/main.go
``` ```
### 默认超级管理员账号
系统首次启动时会自动创建默认超级管理员账号,无需手动执行 SQL 或脚本。
**默认账号信息**
- 用户名:`admin`
- 密码:`Admin@123456`
- 手机号:`13800000000`
**自定义配置**
可在 `configs/config.yaml` 中自定义默认管理员信息:
```yaml
default_admin:
username: "自定义用户名"
password: "自定义密码"
phone: "自定义手机号"
```
**注意事项**
- 系统只在数据库无超级管理员账号时才创建
- 如果已存在超级管理员,启动时会跳过创建
- 建议首次登录后立即修改默认密码
- 初始化日志记录在 `logs/app.log`
详细设置和测试说明请参阅 [快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)。 详细设置和测试说明请参阅 [快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)。
## 项目结构 ## 项目结构

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
) )
@@ -16,27 +17,39 @@ import (
// 生成失败时记录错误但不影响程序继续运行 // 生成失败时记录错误但不影响程序继续运行
func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 1. 创建生成器 // 1. 创建生成器
adminDoc := openapi.NewGenerator("Admin API", "1.0") adminDoc := openapi.NewGenerator("君鸿卡管系统 API", "1.0.0")
// 2. 创建临时 Fiber App 用于路由注册 // 2. 创建临时 Fiber App 用于路由注册
app := fiber.New() app := fiber.New()
// 3. 创建 Handler使用 nil 依赖,因为只需要路由结构) // 3. 创建 Handler使用 nil 依赖,因为只需要路由结构)
adminAuthHandler := admin.NewAuthHandler(nil, nil)
h5AuthHandler := h5.NewAuthHandler(nil, nil)
accHandler := admin.NewAccountHandler(nil) accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil) roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil) permHandler := admin.NewPermissionHandler(nil)
shopHandler := admin.NewShopHandler(nil)
shopAccHandler := admin.NewShopAccountHandler(nil)
handlers := &bootstrap.Handlers{ handlers := &bootstrap.Handlers{
AdminAuth: adminAuthHandler,
H5Auth: h5AuthHandler,
Account: accHandler, Account: accHandler,
Role: roleHandler, Role: roleHandler,
Permission: permHandler, Permission: permHandler,
Shop: shopHandler,
ShopAccount: shopAccHandler,
} }
// 4. 注册路由到文档生成器 // 4. 注册后台路由到文档生成器
adminGroup := app.Group("/api/admin") adminGroup := app.Group("/api/admin")
routes.RegisterAdminRoutes(adminGroup, handlers, adminDoc, "/api/admin") routes.RegisterAdminRoutes(adminGroup, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/admin")
// 5. 保存规范到指定路径 // 5. 注册 H5 路由到文档生成器
h5Group := app.Group("/api/h5")
routes.RegisterH5Routes(h5Group, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/h5")
// 6. 保存规范到指定路径
if err := adminDoc.Save(outputPath); err != nil { if err := adminDoc.Save(outputPath); err != nil {
logger.Error("生成 OpenAPI 文档失败", zap.String("path", outputPath), zap.Error(err)) logger.Error("生成 OpenAPI 文档失败", zap.String("path", outputPath), zap.Error(err))
return return

View File

@@ -6,6 +6,7 @@ import (
"os/signal" "os/signal"
"strconv" "strconv"
"syscall" "syscall"
"time"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -19,6 +20,8 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/database" "github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -47,34 +50,40 @@ func main() {
queueClient := initQueue(redisClient, appLogger) queueClient := initQueue(redisClient, appLogger)
defer closeQueue(queueClient, appLogger) defer closeQueue(queueClient, appLogger)
// 6. 初始化所有业务组件(通过 Bootstrap // 6. 初始化认证管理器
jwtManager, tokenManager, verificationSvc := initAuthComponents(cfg, redisClient, appLogger)
// 7. 初始化所有业务组件(通过 Bootstrap
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{ result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
DB: db, DB: db,
Redis: redisClient, Redis: redisClient,
Logger: appLogger, Logger: appLogger,
JWTManager: jwtManager,
TokenManager: tokenManager,
VerificationService: verificationSvc,
}) })
if err != nil { if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err)) appLogger.Fatal("初始化业务组件失败", zap.Error(err))
} }
// 7. 启动配置监听器 // 8. 启动配置监听器
watchCtx, cancelWatch := context.WithCancel(context.Background()) watchCtx, cancelWatch := context.WithCancel(context.Background())
defer cancelWatch() defer cancelWatch()
go config.Watch(watchCtx, appLogger) go config.Watch(watchCtx, appLogger)
// 8. 创建 Fiber 应用 // 9. 创建 Fiber 应用
app := createFiberApp(cfg, appLogger) app := createFiberApp(cfg, appLogger)
// 9. 注册中间件 // 10. 注册中间件
initMiddleware(app, cfg, appLogger) initMiddleware(app, cfg, appLogger)
// 10. 注册路由 // 11. 注册路由
initRoutes(app, cfg, result, queueClient, db, redisClient, appLogger) initRoutes(app, cfg, result, queueClient, db, redisClient, appLogger)
// 11. 生成 OpenAPI 文档 // 12. 生成 OpenAPI 文档
generateOpenAPIDocs("./openapi.yaml", appLogger) generateOpenAPIDocs("./openapi.yaml", appLogger)
// 12. 启动服务器 // 13. 启动服务器
startServer(app, cfg, appLogger, cancelWatch) startServer(app, cfg, appLogger, cancelWatch)
} }
@@ -281,3 +290,15 @@ func startServer(app *fiber.App, cfg *config.Config, appLogger *zap.Logger, canc
appLogger.Info("服务器已停止") appLogger.Info("服务器已停止")
} }
func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger *zap.Logger) (*auth.JWTManager, *auth.TokenManager, *verification.Service) {
jwtManager := auth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
verificationSvc := verification.NewService(redisClient, nil, appLogger)
return jwtManager, tokenManager, verificationSvc
}

View File

@@ -34,16 +34,18 @@ func generateAdminDocs(outputPath string) error {
accHandler := admin.NewAccountHandler(nil) accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil) roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil) permHandler := admin.NewPermissionHandler(nil)
authHandler := admin.NewAuthHandler(nil, nil)
handlers := &bootstrap.Handlers{ handlers := &bootstrap.Handlers{
Account: accHandler, Account: accHandler,
Role: roleHandler, Role: roleHandler,
Permission: permHandler, Permission: permHandler,
AdminAuth: authHandler,
} }
// 4. 注册路由到文档生成器 // 4. 注册路由到文档生成器
adminGroup := app.Group("/api/admin") adminGroup := app.Group("/api/admin")
routes.RegisterAdminRoutes(adminGroup, handlers, adminDoc, "/api/admin") routes.RegisterAdminRoutes(adminGroup, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/admin")
// 5. 保存规范到指定路径 // 5. 保存规范到指定路径
if err := adminDoc.Save(outputPath); err != nil { if err := adminDoc.Save(outputPath); err != nil {

View File

@@ -59,3 +59,27 @@ middleware:
max: 1000 max: 1000
expiration: "1m" expiration: "1m"
storage: "redis" storage: "redis"
sms:
gateway_url: "https://gateway.sms.whjhft.com:8443/sms"
username: "JH0001" # TODO: 替换为实际的短信服务账号
password: "wwR8E4qnL6F0" # TODO: 替换为实际的短信服务密码
signature: "【JHFTIOT】" # TODO: 替换为报备通过的短信签名
timeout: "10s"
# JWT 配置
jwt:
secret_key: "dev-secret-key-for-testing-only-32chars!"
token_duration: "168h" # C 端个人客户 JWT Token 有效期7天
access_token_ttl: "24h" # B 端访问令牌有效期24小时
refresh_token_ttl: "168h" # B 端刷新令牌有效期7天
# 默认超级管理员配置(可选,系统启动时自动创建)
# 如果配置为空,系统使用代码默认值:
# - 用户名: admin
# - 密码: Admin@123456
# - 手机号: 13800000000
# default_admin:
# username: "admin"
# password: "Admin@123456"
# phone: "13800000000"

View File

@@ -94,7 +94,19 @@ sms:
signature: "【JHFTIOT】" # TODO: 替换为报备通过的短信签名 signature: "【JHFTIOT】" # TODO: 替换为报备通过的短信签名
timeout: "10s" timeout: "10s"
# JWT 配置(用于个人客户认证) # JWT 配置
jwt: jwt:
secret_key: "your-secret-key-change-this-in-production" # TODO: 生产环境必须修改 secret_key: "your-secret-key-change-this-in-production" # TODO: 生产环境必须修改
token_duration: "168h" # Token 有效期7天 token_duration: "168h" # C 端个人客户 JWT Token 有效期7天
access_token_ttl: "24h" # B 端访问令牌有效期24小时
refresh_token_ttl: "168h" # B 端刷新令牌有效期7天
# 默认超级管理员配置(可选,系统启动时自动创建)
# 如果配置为空,系统使用代码默认值:
# - 用户名: admin
# - 密码: Admin@123456
# - 手机号: 13800000000
# default_admin:
# username: "admin"
# password: "Admin@123456"
# phone: "13800000000"

View File

@@ -0,0 +1,328 @@
# 默认超级管理员自动初始化功能
## 功能概述
系统在 API 服务启动时,自动检查数据库中是否存在超级管理员账号。如果不存在,则自动创建一个默认的超级管理员账号,确保系统首次部署后可以立即登录使用。
## 业务背景
**问题**
- 首次部署新环境(开发、测试、生产)时,数据库中没有任何管理员账号
- 无法登录管理后台,需要手动执行 SQL 或脚本创建管理员
- 人为操作容易出错,不同环境的初始账号可能不一致
**解决方案**
- 系统启动时自动检测并创建默认超级管理员
- 支持配置文件自定义账号信息
- 幂等操作,多次启动不会重复创建
## 技术实现
### 核心逻辑
#### 1. 初始化时机
```go
// internal/bootstrap/bootstrap.go
func Bootstrap(deps *Dependencies) (*BootstrapResult, error) {
// ... 其他初始化 ...
// 4. 初始化默认超级管理员(降级处理:失败不中断启动)
if err := initDefaultAdmin(deps, services); err != nil {
deps.Logger.Error("初始化默认超级管理员失败", zap.Error(err))
}
// ... 继续初始化 ...
}
```
**初始化顺序**
1. Store 层初始化
2. GORM Callbacks 注册
3. Service 层初始化
4. **默认管理员初始化** ← 在此执行
5. Middleware 层初始化
6. Handler 层初始化
#### 2. 检查逻辑
```go
// 检查是否已存在超级管理员user_type = 1
var count int64
err := db.Model(&Account{}).Where("user_type = ?", constants.UserTypeSuperAdmin).Count(&count).Error
if count > 0 {
// 已存在,跳过创建
logger.Info("超级管理员账号已存在,跳过初始化", zap.Int64("count", count))
return nil
}
// count == 0创建默认管理员
```
**确保唯一性**
- ✅ 检查条件:`user_type = 1`(精确匹配超级管理员类型)
- ✅ 创建条件:只有 `count == 0` 时才创建
- ✅ 幂等性:多次启动不会重复创建
- ✅ 并发安全:启动时单进程执行,无并发问题
#### 3. 账号信息来源
优先级:**配置文件 > 代码默认值**
```go
// 代码默认值pkg/constants/constants.go
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "Admin@123456"
DefaultAdminPhone = "13800000000"
)
// 读取配置文件(可选)
username := constants.DefaultAdminUsername
password := constants.DefaultAdminPassword
phone := constants.DefaultAdminPhone
if cfg.DefaultAdmin.Username != "" {
username = cfg.DefaultAdmin.Username // 使用配置文件值
}
if cfg.DefaultAdmin.Password != "" {
password = cfg.DefaultAdmin.Password
}
if cfg.DefaultAdmin.Phone != "" {
phone = cfg.DefaultAdmin.Phone
}
```
#### 4. 创建账号
```go
account := &model.Account{
Username: username,
Phone: phone,
Password: password, // 原始密码CreateSystemAccount 内部会进行 bcrypt 哈希
UserType: constants.UserTypeSuperAdmin, // 1=超级管理员
Status: constants.StatusEnabled, // 1=启用
}
// 调用 Service 层的系统创建方法(绕过当前用户检查)
err := services.Account.CreateSystemAccount(ctx, account)
```
**CreateSystemAccount 方法特点**
- 绕过 `currentUserID` 检查(因为是系统初始化,没有当前用户)
- 仍然保留用户名和手机号唯一性检查
- 密码自动进行 bcrypt 哈希
- 跳过数据权限过滤(使用 `SkipDataPermission(ctx)`
### 降级处理
```go
if err := initDefaultAdmin(deps, services); err != nil {
deps.Logger.Error("初始化默认超级管理员失败", zap.Error(err))
// 不返回错误,继续启动服务
}
```
**设计理由**
- 默认管理员创建失败不应导致整个服务无法启动
- 可能的失败原因:数据库连接问题、用户名/手机号冲突等
- 管理员可以通过日志查看失败原因,手动处理
## 配置说明
### 使用代码默认值(无需配置)
直接启动服务,系统使用内置默认值:
- 用户名:`admin`
- 密码:`Admin@123456`
- 手机号:`13800000000`
### 自定义配置
`configs/config.yaml` 中添加:
```yaml
default_admin:
username: "自定义用户名"
password: "自定义密码"
phone: "自定义手机号"
```
**注意**
- 配置项为可选,不参与 `Validate()` 验证
- 任何字段留空则使用代码默认值
- 密码必须足够复杂(建议包含大小写字母、数字、特殊字符)
## 使用示例
### 场景1首次部署空数据库
```bash
# 启动服务
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"默认超级管理员创建成功","username":"admin","phone":"13800000000"}
```
**结果**
- 数据库 `tb_account` 表新增一条记录
- `user_type = 1`(超级管理员)
- `username = "admin"`
- `phone = "13800000000"`
- `password` 为 bcrypt 哈希值
### 场景2已有管理员再次启动
```bash
# 再次启动服务
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"超级管理员账号已存在,跳过初始化","count":1}
```
**结果**
- 跳过创建,不修改现有数据
### 场景3使用自定义配置
**配置文件** (`configs/config.yaml`)
```yaml
default_admin:
username: "myadmin"
password: "MySecurePass@2024"
phone: "13900000000"
```
**启动服务**
```bash
go run cmd/api/main.go
```
**日志输出**
```
{"level":"info","msg":"默认超级管理员创建成功","username":"myadmin","phone":"13900000000"}
```
## 安全注意事项
### 1. 密码安全
- ✅ 密码使用 bcrypt 哈希存储,不可逆
- ⚠️ 默认密码相对简单,建议首次登录后立即修改
- ⚠️ 生产环境建议通过配置文件使用更复杂的密码
### 2. 权限控制
- ✅ 超级管理员拥有所有权限,跳过数据权限过滤
- ⚠️ 不要将超级管理员账号用于日常操作
- ⚠️ 建议为日常管理员创建独立的平台用户账号
### 3. 审计日志
- ✅ 创建成功/跳过都记录在 `logs/app.log`
- ✅ 包含时间戳、用户名、手机号
- ⚠️ 日志中不会记录明文密码
### 4. 配置文件安全
- ⚠️ `config.yaml` 中的密码是明文存储
- ⚠️ 确保配置文件访问权限受限(不要提交到公开仓库)
- ⚠️ 生产环境建议使用环境变量或密钥管理服务
## 手动创建管理员(备用方案)
如果自动初始化失败,可以手动执行以下 SQL
```sql
-- 生成 bcrypt 哈希密码(使用 Go 代码或在线工具)
-- bcrypt.GenerateFromPassword([]byte("Admin@123456"), bcrypt.DefaultCost)
-- 示例哈希值(实际使用时需重新生成):
-- $2a$10$abcdefghijklmnopqrstuvwxyz...
INSERT INTO tb_account (
username,
phone,
password,
user_type,
status,
created_at,
updated_at
) VALUES (
'admin',
'13800000000',
'$2a$10$...your-bcrypt-hash...', -- 替换为实际的 bcrypt 哈希
1, -- 超级管理员
1, -- 启用
NOW(),
NOW()
);
```
**生成 bcrypt 哈希工具**
```go
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := "Admin@123456"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
fmt.Println(string(hash))
}
```
## 相关文件
- `pkg/constants/constants.go` - 默认值常量定义
- `pkg/config/config.go` - 配置结构定义
- `configs/config.yaml` - 配置示例
- `internal/service/account/service.go` - CreateSystemAccount 方法
- `internal/bootstrap/admin.go` - initDefaultAdmin 函数
- `internal/bootstrap/bootstrap.go` - Bootstrap 主流程
## 常见问题
### Q1: 为什么要用系统创建方法而不是直接插入数据库?
**A**: 保持业务逻辑统一性和数据一致性:
- 复用用户名/手机号唯一性检查逻辑
- 自动进行密码哈希处理
- 遵循分层架构原则(通过 Service 层操作)
- 未来扩展更容易(如添加审计日志、事件通知等)
### Q2: 如果数据库有多个超级管理员怎么办?
**A**: 系统检查 `user_type = 1` 的账号数量:
- `count == 0`:创建默认管理员
- `count > 0`:跳过创建(不管有几个)
- 系统不会删除或修改现有的超级管理员
### Q3: 可以通过配置禁用这个功能吗?
**A**: 目前不支持配置禁用,因为这是核心初始化逻辑。如果需要禁用:
1. 手动创建一个超级管理员账号(系统会自动跳过)
2. 或者修改代码注释掉 `initDefaultAdmin()` 调用
### Q4: 创建失败会影响服务启动吗?
**A**: 不会。采用降级处理策略:
- 创建失败只记录错误日志
- 服务继续正常启动
- 管理员可通过日志排查原因并手动创建
### Q5: 如何验证管理员创建成功?
**A**: 三种方式:
1. 查看 `logs/app.log`,搜索 "默认超级管理员创建成功"
2. 查询数据库:`SELECT * FROM tb_account WHERE user_type = 1;`
3. 尝试使用默认账号登录管理后台

View File

@@ -0,0 +1,456 @@
# 钱包、换卡、标签系统 - 字段详细说明
## 一、钱包系统字段说明
### 1. tb_wallet钱包表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 钱包唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 所属用户ID关联 tb_account.id | 123 |
| wallet_type | VARCHAR(20) | 是 | 无 | 钱包类型:`user`=用户钱包,`agent`=代理钱包 | "user" |
| balance | BIGINT | 是 | 0 | 可用余额单位1元=100分 | 5000005000元 |
| frozen_balance | BIGINT | 是 | 0 | 冻结余额(单位:分),用于待结算的分佣、提现等 | 10000100元 |
| currency | VARCHAR(10) | 是 | 'CNY' | 币种代码ISO 4217 标准 | "CNY" |
| status | INT | 是 | 1 | 钱包状态1=正常2=冻结3=关闭 | 1 |
| version | INT | 是 | 0 | 乐观锁版本号,每次更新余额时+1防止并发冲突 | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 最后更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间NULL表示未删除 | NULL |
**业务规则**
- 同一用户在同一币种下只能有一个同类型的钱包(唯一约束)
- `balance + frozen_balance` = 总资产
- 余额扣减时必须使用乐观锁WHERE version = ?
---
### 2. tb_wallet_transaction钱包交易记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 交易记录唯一标识 | 1 |
| wallet_id | BIGINT | 是 | 无 | 钱包ID关联 tb_wallet.id | 123 |
| user_id | BIGINT | 是 | 无 | 用户ID冗余存储便于查询 | 456 |
| transaction_type | VARCHAR(20) | 是 | 无 | 交易类型:`recharge`=充值,`deduct`=扣款,`refund`=退款,`commission`=分佣,`withdrawal`=提现 | "recharge" |
| amount | BIGINT | 是 | 无 | 变动金额(单位:分),正数表示增加,负数表示减少 | 50000+500元 |
| balance_before | BIGINT | 是 | 无 | 变动前余额(单位:分) | 1000001000元 |
| balance_after | BIGINT | 是 | 无 | 变动后余额(单位:分) | 1500001500元 |
| status | INT | 是 | 1 | 交易状态1=成功2=失败3=处理中 | 1 |
| reference_type | VARCHAR(50) | 否 | NULL | 关联业务类型:`order`=订单,`commission`=分佣,`withdrawal`=提现,`topup`=充值 | "order" |
| reference_id | BIGINT | 否 | NULL | 关联业务ID如订单ID、分佣ID | 789 |
| remark | TEXT | 否 | NULL | 备注信息,人工输入或系统生成 | "购买套餐扣款" |
| metadata | JSONB | 否 | NULL | 扩展信息JSON格式存储第三方交易号、手续费等 | {"fee": 50, "channel": "alipay"} |
| creator | BIGINT | 否 | 无 | 创建人ID系统创建时为0 | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 交易时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- 每次余额变动必须创建一条交易记录
- `balance_after = balance_before + amount`
- 交易记录只能新增,不能修改或删除(审计要求)
**metadata 扩展字段示例**
```json
{
"payment_channel": "alipay", // 支付渠道
"transaction_no": "2025011310000001", // 第三方交易号
"fee": 50, // 手续费(分)
"operator": "admin", // 操作人
"ip": "192.168.1.100" // 操作IP
}
```
---
### 3. tb_recharge_record充值记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 充值记录唯一标识 | 1 |
| user_id | BIGINT | 是 | 无 | 充值用户ID | 123 |
| wallet_id | BIGINT | 是 | 无 | 充值目标钱包ID | 456 |
| recharge_no | VARCHAR(50) | 是 | 无 | 充值订单号唯一格式RCH+时间戳+随机数 | "RCH20250113100000001" |
| amount | BIGINT | 是 | 无 | 充值金额(单位:分) | 1000001000元 |
| payment_method | VARCHAR(20) | 是 | 无 | 支付方式:`alipay`=支付宝,`wechat`=微信,`bank`=银行转账,`offline`=线下 | "alipay" |
| payment_channel | VARCHAR(50) | 否 | NULL | 支付渠道(第三方平台),如"支付宝-即时到账" | "alipay_direct" |
| payment_transaction_id | VARCHAR(100) | 否 | NULL | 第三方支付交易号,用于对账 | "2025011322001412345678" |
| status | INT | 是 | 1 | 充值状态1=待支付2=已支付3=已完成4=已关闭5=已退款 | 3 |
| paid_at | TIMESTAMP | 否 | NULL | 支付完成时间 | 2025-01-13 10:05:00 |
| completed_at | TIMESTAMP | 否 | NULL | 充值完成时间(余额到账) | 2025-01-13 10:05:30 |
| creator | BIGINT | 否 | 无 | 创建人ID | 123 |
| updater | BIGINT | 否 | 无 | 更新人ID | 0 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:05:30 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- `recharge_no` 全局唯一,用于幂等性控制
- 状态流转:待支付 → 已支付 → 已完成
- 超时未支付订单自动关闭30分钟
---
## 二、换卡记录系统字段说明
### tb_card_replacement_record换卡记录表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 换卡记录唯一标识 | 1 |
| replacement_no | VARCHAR(50) | 是 | 无 | 换卡单号唯一格式REP+时间戳+随机数 | "REP20250113100000001" |
| old_card_id | BIGINT | 是 | 无 | 老卡ID关联 tb_iot_card.id | 123 |
| old_iccid | VARCHAR(50) | 是 | 无 | 老卡ICCID冗余存储即使卡删除也能追溯 | "898600..." |
| new_card_id | BIGINT | 是 | 无 | 新卡ID关联 tb_iot_card.id | 456 |
| new_iccid | VARCHAR(50) | 是 | 无 | 新卡ICCID冗余存储 | "898600..." |
| old_owner_type | VARCHAR(20) | 是 | 无 | 老卡所有者类型:`platform`=平台,`agent`=代理,`user`=用户,`device`=设备 | "user" |
| old_owner_id | BIGINT | 是 | 无 | 老卡所有者ID | 789 |
| old_agent_id | BIGINT | 否 | NULL | 老卡代理ID如果有 | 100 |
| new_owner_type | VARCHAR(20) | 是 | 无 | 新卡所有者类型 | "user" |
| new_owner_id | BIGINT | 是 | 无 | 新卡所有者ID | 789 |
| new_agent_id | BIGINT | 否 | NULL | 新卡代理ID | 100 |
| package_snapshot | JSONB | 否 | NULL | 套餐快照JSON格式记录换卡时的套餐状态 | 见下方示例 |
| replacement_reason | VARCHAR(20) | 是 | 无 | 换卡原因:`damaged`=损坏,`lost`=丢失,`malfunction`=故障,`upgrade`=升级,`other`=其他 | "damaged" |
| remark | TEXT | 否 | NULL | 备注说明 | "卡片物理损坏,无法识别" |
| status | INT | 是 | 1 | 换卡状态1=待审批2=已通过3=已拒绝4=已完成 | 4 |
| approved_by | BIGINT | 否 | NULL | 审批人ID | 1 |
| approved_at | TIMESTAMP | 否 | NULL | 审批时间 | 2025-01-13 11:00:00 |
| completed_at | TIMESTAMP | 否 | NULL | 完成时间 | 2025-01-13 11:30:00 |
| creator | BIGINT | 否 | 无 | 创建人ID | 789 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 11:30:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**package_snapshot 字段结构**
```json
{
"package_id": 123,
"package_name": "月包50GB",
"package_type": "formal",
"data_quota": 51200000,
"data_used": 10240000,
"valid_from": "2025-01-01T00:00:00Z",
"valid_to": "2025-01-31T23:59:59Z",
"price": 5000,
"remaining_days": 20,
"transfer_reason": "卡损坏,套餐转移至新卡"
}
```
**字段说明**
- `package_id`: 套餐ID
- `package_name`: 套餐名称
- `package_type`: 套餐类型formal=正式套餐addon=附加套餐)
- `data_quota`: 流量额度KB
- `data_used`: 已使用流量KB
- `valid_from`: 套餐生效时间
- `valid_to`: 套餐失效时间
- `price`: 套餐价格(分)
- `remaining_days`: 剩余天数
- `transfer_reason`: 转移原因
**业务规则**
- 换卡申请需要审批(除非设置为自动通过)
- 老卡状态变为"已停用",新卡状态变为"已激活"
- 套餐信息转移到新卡,剩余流量和有效期保持不变
---
## 三、标签系统字段说明
### 1. tb_tag标签表
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 标签唯一标识 | 1 |
| name | VARCHAR(100) | 是 | 无 | 标签名称(全局唯一) | "重点客户" |
| color | VARCHAR(20) | 否 | NULL | 标签颜色(十六进制),用于前端展示 | "#FF5733" |
| usage_count | INT | 是 | 0 | 使用次数,每次打标签+1取消标签-1 | 25 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**业务规则**
- 标签名称全局唯一(不区分大小写)
- `usage_count` 用于展示热门标签(按使用次数降序)
- 标签删除时,关联的资源标签也会软删除
---
### 2. tb_resource_tag资源-标签关联表)
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| id | BIGSERIAL | 是 | 自增 | 关联记录唯一标识 | 1 |
| resource_type | VARCHAR(20) | 是 | 无 | 资源类型:`device`=设备,`iot_card`=IoT卡`number_card`=号卡 | "iot_card" |
| resource_id | BIGINT | 是 | 无 | 资源ID根据 resource_type 关联不同的表) | 123 |
| tag_id | BIGINT | 是 | 无 | 标签ID关联 tb_tag.id | 5 |
| creator | BIGINT | 否 | 无 | 创建人ID | 1 |
| updater | BIGINT | 否 | 无 | 更新人ID | 1 |
| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 创建时间 | 2025-01-13 10:00:00 |
| updated_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 更新时间 | 2025-01-13 10:00:00 |
| deleted_at | TIMESTAMP | 否 | NULL | 软删除时间 | NULL |
**资源类型映射**
| resource_type | 关联表 | 说明 |
|---------------|--------|------|
| device | tb_device | 设备 |
| iot_card | tb_iot_card | IoT卡 |
| number_card | tb_number_card | 号卡 |
**业务规则**
- 同一资源不能重复打同一个标签(唯一约束)
- 打标签时,标签的 `usage_count` 自动+1
- 取消标签时,标签的 `usage_count` 自动-1
---
## 四、修改字段说明
### 1. tb_carrier运营商表- 新增字段
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| carrier_type | VARCHAR(20) | 是 | 'CMCC' | 运营商类型(固定枚举):`CMCC`=中国移动,`CUCC`=中国联通,`CTCC`=中国电信,`CBN`=广电 | "CMCC" |
| channel_name | VARCHAR(100) | 否 | NULL | 渠道名称(可自定义),如"广东移动-企业渠道" | "广东移动-企业渠道" |
| channel_code | VARCHAR(50) | 否 | NULL | 渠道编码可自定义用于对接渠道API | "GD_CMCC_ENT" |
**业务规则**
- 同一运营商类型可以有多个渠道(通过 channel_code 区分)
- `carrier_type + channel_code` 组合唯一
---
### 2. tb_order订单表- 新增字段
| 字段名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|--------|------|------|--------|------|--------|
| wallet_payment_amount | BIGINT | 是 | 0 | 钱包支付金额(单位:分) | 30000300元 |
| online_payment_amount | BIGINT | 是 | 0 | 在线支付金额(单位:分) | 20000200元 |
**业务规则**
- 订单总金额 `amount = wallet_payment_amount + online_payment_amount`
- 支持纯钱包支付、纯在线支付、混合支付三种模式
- `payment_method` 字段保留,标识主要支付方式
**支付模式示例**
| 模式 | wallet_payment_amount | online_payment_amount | payment_method | 说明 |
|------|----------------------|----------------------|----------------|------|
| 纯钱包支付 | 50000 | 0 | wallet | 全部从钱包扣款 |
| 纯在线支付 | 0 | 50000 | online | 全部在线支付 |
| 混合支付 | 30000 | 20000 | wallet | 钱包不足,补充在线支付 |
---
## 五、数据类型说明
### 1. 金额字段BIGINT
- 单位:**分**1元 = 100分
- 类型BIGINT范围-9223372036854775808 ~ 9223372036854775807
- 最大金额:约 92,233,720,368,547,758 元(足够使用)
**示例**
```go
amount := int64(100000) // 1000元 = 100000分
fmt.Println(amount / 100) // 输出1000
```
### 2. 时间字段TIMESTAMP
- 时区:数据库存储为 UTC 时间,应用层转换为本地时间
- 格式:`2025-01-13 10:00:00`
### 3. JSONB 字段
- PostgreSQL 专有类型,高效存储和查询 JSON 数据
- 支持索引和查询操作
**查询示例**
```sql
-- 查询 metadata 中 fee 大于 100 的交易
SELECT * FROM tb_wallet_transaction
WHERE metadata->>'fee' > '100';
-- 查询套餐快照中剩余天数小于 10 的换卡记录
SELECT * FROM tb_card_replacement_record
WHERE (package_snapshot->>'remaining_days')::int < 10;
```
---
## 六、索引说明
### 1. 唯一索引
| 表名 | 索引名 | 字段 | 条件 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user_type_currency | (user_id, wallet_type, currency) | WHERE deleted_at IS NULL |
| tb_recharge_record | idx_recharge_no | (recharge_no) | WHERE deleted_at IS NULL |
| tb_card_replacement_record | idx_card_replacement_no | (replacement_no) | WHERE deleted_at IS NULL |
| tb_tag | idx_tag_name | (name) | WHERE deleted_at IS NULL |
| tb_resource_tag | idx_resource_tag_unique | (resource_type, resource_id, tag_id) | WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_type_channel | (carrier_type, channel_code) | WHERE deleted_at IS NULL |
### 2. 普通索引
| 表名 | 索引名 | 字段 | 用途 |
|------|--------|------|------|
| tb_wallet | idx_wallet_user | (user_id, deleted_at) | 按用户查询钱包 |
| tb_wallet | idx_wallet_status | (status, deleted_at) | 按状态查询钱包 |
| tb_wallet_transaction | idx_wallet_tx_wallet | (wallet_id, created_at DESC) | 按钱包查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_user | (user_id, created_at DESC) | 按用户查询交易记录 |
| tb_wallet_transaction | idx_wallet_tx_ref | (reference_type, reference_id) | 按关联业务查询交易 |
| tb_recharge_record | idx_recharge_user | (user_id, created_at DESC) | 按用户查询充值记录 |
| tb_recharge_record | idx_recharge_status | (status, created_at DESC) | 按状态查询充值记录 |
| tb_card_replacement_record | idx_card_replacement_old_card | (old_card_id, created_at DESC) | 按老卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_new_card | (new_card_id, created_at DESC) | 按新卡查询换卡记录 |
| tb_card_replacement_record | idx_card_replacement_old_owner | (old_owner_type, old_owner_id) | 按老卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_new_owner | (new_owner_type, new_owner_id) | 按新卡所有者查询 |
| tb_card_replacement_record | idx_card_replacement_status | (status, created_at DESC) | 按状态查询换卡记录 |
| tb_tag | idx_tag_usage | (usage_count DESC, deleted_at) | 查询热门标签 |
| tb_resource_tag | idx_resource_tag_resource | (resource_type, resource_id, deleted_at) | 查询资源的标签 |
| tb_resource_tag | idx_resource_tag_tag | (tag_id, deleted_at) | 查询标签的资源 |
| tb_resource_tag | idx_resource_tag_composite | (resource_type, tag_id, deleted_at) | 按资源类型和标签查询 |
---
## 七、字段验证规则
### 1. 钱包字段验证
| 字段 | 验证规则 |
|------|---------|
| balance | ≥ 0不能为负 |
| frozen_balance | ≥ 0不能为负 |
| wallet_type | 必须为 `user``agent` |
| currency | 符合 ISO 4217 标准(如 CNY、USD |
| status | 必须为 1、2、3 |
| version | ≥ 0 |
### 2. 交易字段验证
| 字段 | 验证规则 |
|------|---------|
| amount | 不能为 0 |
| balance_after | 必须等于 `balance_before + amount` |
| transaction_type | 必须为 recharge/deduct/refund/commission/withdrawal |
| status | 必须为 1、2、3 |
### 3. 充值字段验证
| 字段 | 验证规则 |
|------|---------|
| amount | > 0单次充值 ≥ 1元100分 |
| recharge_no | 格式RCH + 17位数字 |
| payment_method | 必须为 alipay/wechat/bank/offline |
| status | 必须为 1、2、3、4、5 |
### 4. 换卡字段验证
| 字段 | 验证规则 |
|------|---------|
| replacement_no | 格式REP + 17位数字 |
| old_card_id | 必须是已存在的卡ID |
| new_card_id | 必须是已存在且状态为"在库"的卡ID |
| replacement_reason | 必须为 damaged/lost/malfunction/upgrade/other |
| status | 必须为 1、2、3、4 |
### 5. 标签字段验证
| 字段 | 验证规则 |
|------|---------|
| name | 长度 ≤ 100不能为空全局唯一 |
| color | 必须符合十六进制颜色格式(#RRGGBB |
| usage_count | ≥ 0 |
| resource_type | 必须为 device/iot_card/number_card |
---
## 八、字段使用注意事项
### 1. 金额计算
```go
// 错误示例:使用浮点数
price := 19.99 // ❌ 浮点数精度问题
total := price * 100 // ❌ 结果1999.0000000002
// 正确示例:使用整数
price := int64(1999) // ✅ 直接使用分为单位
total := price * 2 // ✅ 结果3998
```
### 2. 乐观锁使用
```go
// 查询钱包
wallet, _ := walletStore.GetByID(ctx, walletID)
// 扣款(带乐观锁)
result := db.Model(&Wallet{}).
Where("id = ? AND version = ?", walletID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", amount),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
return errors.New("余额变更失败,请重试") // 并发冲突
}
```
### 3. JSONB 查询
```go
// 查询 metadata 中的字段
var transactions []WalletTransaction
db.Where("metadata->>'payment_channel' = ?", "alipay").Find(&transactions)
// 查询嵌套字段
db.Where("package_snapshot->>'package_type' = ?", "formal").Find(&records)
```
### 4. 软删除查询
```go
// 默认查询(自动排除软删除)
db.Find(&wallets) // WHERE deleted_at IS NULL
// 包含软删除记录
db.Unscoped().Find(&wallets)
// 只查询软删除记录
db.Where("deleted_at IS NOT NULL").Unscoped().Find(&wallets)
```
---
## 九、常见问题
### Q1: 为什么金额使用整数而不是浮点数?
**A**: 浮点数存在精度问题0.1 + 0.2 != 0.3),在金融系统中可能导致账务错误。使用整数(分为单位)可以避免精度问题。
### Q2: 为什么要冗余存储 ICCID
**A**: 换卡记录需要长期保存,即使物联卡被删除,也需要能追溯历史记录。冗余存储 ICCID 可以避免关联查询失败。
### Q3: 为什么标签要记录 usage_count
**A**: 用于展示热门标签,提升用户体验。每次打标签/取消标签时更新,避免每次查询时统计。
### Q4: 软删除后为什么还能创建同名标签?
**A**: 唯一索引包含 `WHERE deleted_at IS NULL` 条件,软删除后该记录不再参与唯一性检查,允许创建同名标签。
### Q5: 乐观锁和悲观锁有什么区别?
**A**:
- 乐观锁:假设冲突少,使用 version 字段判断,适合读多写少场景
- 悲观锁:假设冲突多,使用 `SELECT ... FOR UPDATE` 锁定行,适合写多场景
钱包系统使用乐观锁,因为余额查询频繁,扣款相对较少。

View File

@@ -0,0 +1,624 @@
# 钱包、换卡、标签系统 - 数据模型设计
## 概述
本次变更新增了三个核心业务模块的数据模型设计:
1. **钱包系统**:用户和代理的资金账户管理
2. **换卡记录系统**:物联卡更换历史追溯
3. **标签系统**设备、IoT卡、号卡的分类管理
## 一、钱包系统
### 1.1 表结构
#### tb_wallet钱包表
**业务说明**:每个用户/代理拥有一个或多个钱包(按币种区分),支持充值、消费、提现等操作。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_wallet_user |
| wallet_type | VARCHAR(20) | 钱包类型user/agent | idx_wallet_user_type_currency (UNIQUE) |
| balance | BIGINT | 余额(分) | - |
| frozen_balance | BIGINT | 冻结余额(分) | - |
| currency | VARCHAR(10) | 币种默认CNY | idx_wallet_user_type_currency (UNIQUE) |
| status | INT | 钱包状态1=正常 2=冻结 3=关闭) | idx_wallet_status |
| version | INT | 版本号(乐观锁) | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 使用 `version` 字段实现乐观锁,防止并发余额冲突
- `frozen_balance` 用于冻结资金(如待结算的分佣)
- 唯一索引:`(user_id, wallet_type, currency) WHERE deleted_at IS NULL`
#### tb_wallet_transaction钱包交易记录表
**业务说明**:记录所有钱包余额变动,用于对账和审计。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| wallet_id | BIGINT | 钱包ID | idx_wallet_tx_wallet |
| user_id | BIGINT | 用户ID | idx_wallet_tx_user |
| transaction_type | VARCHAR(20) | 交易类型recharge/deduct/refund/commission/withdrawal | - |
| amount | BIGINT | 变动金额(分),正数为增加,负数为减少 | - |
| balance_before | BIGINT | 变动前余额(分) | - |
| balance_after | BIGINT | 变动后余额(分) | - |
| status | INT | 交易状态1=成功 2=失败 3=处理中) | - |
| reference_type | VARCHAR(50) | 关联业务类型order/commission/withdrawal/topup | idx_wallet_tx_ref |
| reference_id | BIGINT | 关联业务ID | idx_wallet_tx_ref |
| remark | TEXT | 备注 | - |
| metadata | JSONB | 扩展信息 | - |
| creator | BIGINT | 创建人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 记录 `balance_before``balance_after` 便于对账
- `reference_type` + `reference_id` 关联业务对象
- 使用 JSONB 存储扩展信息(如第三方交易号、手续费等)
#### tb_recharge_record充值记录表
**业务说明**:用户和代理的钱包充值订单,记录支付流程。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| user_id | BIGINT | 用户ID | idx_recharge_user |
| wallet_id | BIGINT | 钱包ID | - |
| recharge_no | VARCHAR(50) | 充值订单号(唯一) | idx_recharge_no (UNIQUE) |
| amount | BIGINT | 充值金额(分) | - |
| payment_method | VARCHAR(20) | 支付方式alipay/wechat/bank/offline | - |
| payment_channel | VARCHAR(50) | 支付渠道 | - |
| payment_transaction_id | VARCHAR(100) | 第三方支付交易号 | - |
| status | INT | 充值状态1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款) | idx_recharge_status |
| paid_at | TIMESTAMP | 支付时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- `recharge_no` 作为唯一订单号,用于幂等性控制
- 状态流转:待支付 → 已支付 → 已完成
### 1.2 业务流程
#### 充值流程
```
1. 用户发起充值请求 → 创建 RechargeRecordstatus=1 待支付)
2. 调用支付网关 → 获取支付链接
3. 用户完成支付 → 支付回调更新 RechargeRecordstatus=2 已支付)
4. 系统处理充值 → 创建 WalletTransactiontype=recharge
5. 更新 Wallet 余额 → 使用乐观锁version+1
6. 充值完成 → 更新 RechargeRecordstatus=3 已完成)
```
#### 消费流程
```
1. 用户购买套餐 → 检查钱包余额
2. 冻结金额 → 增加 frozen_balance
3. 订单完成 → 扣减 frozen_balance 和 balance
4. 创建 WalletTransactiontype=deduct, reference_type=order
```
---
## 二、换卡记录系统
### 2.1 表结构
#### tb_card_replacement_record换卡记录表
**业务说明**:记录物联卡更换历史,包含套餐快照便于追溯。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| replacement_no | VARCHAR(50) | 换卡单号(唯一) | idx_card_replacement_no (UNIQUE) |
| old_card_id | BIGINT | 老卡ID | idx_card_replacement_old_card |
| old_iccid | VARCHAR(50) | 老卡ICCID冗余存储 | - |
| new_card_id | BIGINT | 新卡ID | idx_card_replacement_new_card |
| new_iccid | VARCHAR(50) | 新卡ICCID冗余存储 | - |
| old_owner_type | VARCHAR(20) | 老卡所有者类型 | idx_card_replacement_old_owner |
| old_owner_id | BIGINT | 老卡所有者ID | idx_card_replacement_old_owner |
| old_agent_id | BIGINT | 老卡代理ID | - |
| new_owner_type | VARCHAR(20) | 新卡所有者类型 | idx_card_replacement_new_owner |
| new_owner_id | BIGINT | 新卡所有者ID | idx_card_replacement_new_owner |
| new_agent_id | BIGINT | 新卡代理ID | - |
| package_snapshot | JSONB | 套餐快照 | - |
| replacement_reason | VARCHAR(20) | 换卡原因damaged/lost/malfunction/upgrade/other | - |
| remark | TEXT | 备注 | - |
| status | INT | 换卡状态1=待审批 2=已通过 3=已拒绝 4=已完成) | idx_card_replacement_status |
| approved_by | BIGINT | 审批人ID | - |
| approved_at | TIMESTAMP | 审批时间 | - |
| completed_at | TIMESTAMP | 完成时间 | - |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 冗余存储 `old_iccid``new_iccid`,即使卡被删除也能追溯
- `package_snapshot` 使用 JSONB 存储套餐快照套餐ID、名称、剩余流量、有效期等
- 支持审批流程(待审批 → 已通过 → 已完成)
### 2.2 套餐快照结构
```json
{
"package_id": 123,
"package_name": "月包50GB",
"package_type": "formal",
"data_quota": 51200000,
"data_used": 10240000,
"valid_from": "2025-01-01T00:00:00Z",
"valid_to": "2025-01-31T23:59:59Z",
"price": 5000,
"remaining_days": 20,
"transfer_reason": "卡损坏,套餐转移至新卡"
}
```
### 2.3 业务流程
```
1. 用户申请换卡 → 创建 CardReplacementRecordstatus=1 待审批)
2. 记录老卡套餐信息 → 生成 package_snapshot
3. 平台审批 → 更新 status=2已通过或 3已拒绝
4. 执行换卡操作 → 更新卡所有权、停用老卡、激活新卡
5. 转移套餐 → 将套餐绑定到新卡
6. 完成换卡 → 更新 status=4已完成
```
---
## 三、标签系统
### 3.1 表结构
#### tb_tag标签表
**业务说明**:定义可复用的标签,支持自定义颜色。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| name | VARCHAR(100) | 标签名称(唯一) | idx_tag_name (UNIQUE) |
| color | VARCHAR(20) | 标签颜色(十六进制) | - |
| usage_count | INT | 使用次数 | idx_tag_usage |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- `usage_count` 记录标签使用次数,用于展示热门标签
- 标签名称全局唯一(软删除排除)
#### tb_resource_tag资源-标签关联表)
**业务说明**统一管理设备、IoT卡、号卡与标签的多对多关系。
| 字段 | 类型 | 说明 | 索引 |
|------|------|------|------|
| id | BIGSERIAL | 主键 | PRIMARY KEY |
| resource_type | VARCHAR(20) | 资源类型device/iot_card/number_card | idx_resource_tag_unique (UNIQUE) |
| resource_id | BIGINT | 资源ID | idx_resource_tag_unique (UNIQUE) |
| tag_id | BIGINT | 标签ID | idx_resource_tag_unique (UNIQUE) |
| creator | BIGINT | 创建人ID | - |
| updater | BIGINT | 更新人ID | - |
| created_at | TIMESTAMP | 创建时间 | - |
| updated_at | TIMESTAMP | 更新时间 | - |
| deleted_at | TIMESTAMP | 删除时间(软删除) | - |
**关键设计点**
- 唯一约束:`(resource_type, resource_id, tag_id) WHERE deleted_at IS NULL`
- 支持按资源类型、资源ID、标签ID 多维度查询
### 3.2 支持的资源类型
| 资源类型 | 说明 | 关联表 |
|---------|------|--------|
| device | 设备 | tb_device |
| iot_card | IoT卡 | tb_iot_card |
| number_card | 号卡 | tb_number_card |
### 3.3 业务流程
```
1. 创建标签 → 插入 tb_tagusage_count=0
2. 为资源打标签 → 插入 tb_resource_tag
3. 增加标签使用次数 → UPDATE tb_tag SET usage_count = usage_count + 1
4. 删除资源标签 → 软删除 tb_resource_tag
5. 减少标签使用次数 → UPDATE tb_tag SET usage_count = usage_count - 1
```
---
## 四、修改现有表
### 4.1 tb_carrier运营商表
**新增字段**
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| carrier_type | VARCHAR(20) | 运营商类型CMCC/CUCC/CTCC/CBN | 'CMCC' |
| channel_name | VARCHAR(100) | 渠道名称(可自定义) | NULL |
| channel_code | VARCHAR(50) | 渠道编码(可自定义) | NULL |
**新增索引**
- `idx_carrier_type_channel`: `(carrier_type, channel_code) WHERE deleted_at IS NULL` (UNIQUE)
**设计说明**
- `carrier_type` 为固定枚举(四大运营商)
- `channel_name``channel_code` 可自定义(如"移动-广东渠道"
- 同一运营商可以有多个渠道
### 4.2 tb_order订单表
**新增字段**
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| wallet_payment_amount | BIGINT | 钱包支付金额(分) | 0 |
| online_payment_amount | BIGINT | 在线支付金额(分) | 0 |
**设计说明**
- 支持混合支付:`amount = wallet_payment_amount + online_payment_amount`
- `payment_method` 字段保留,用于标识主要支付方式
---
## 五、索引策略
### 5.1 唯一索引
所有唯一索引都必须包含 `WHERE deleted_at IS NULL` 条件,支持软删除后重复创建。
### 5.2 查询索引
- 钱包相关按用户ID、钱包ID、时间范围查询
- 换卡记录按卡ID、所有者、状态查询
- 标签按资源类型、资源ID、标签ID查询
### 5.3 索引维护
- 定期分析慢查询日志,优化索引
- 使用 `EXPLAIN ANALYZE` 验证查询计划
---
## 六、数据一致性保证
### 6.1 乐观锁(钱包)
```sql
UPDATE tb_wallet
SET balance = balance - 1000, version = version + 1
WHERE id = 123 AND version = 5;
```
如果 `version` 不匹配,表示并发冲突,需要重试。
### 6.2 事务保证
所有涉及多表操作的业务逻辑必须在事务中执行:
- 充值RechargeRecord + WalletTransaction + Wallet
- 换卡CardReplacementRecord + IotCard老卡、新卡+ PackageUsage
### 6.3 幂等性
- 充值订单:使用 `recharge_no` 唯一约束
- 钱包交易:使用 `request_id` Redis 锁
---
## 七、性能优化
### 7.1 缓存策略
| 数据 | Redis Key | 过期时间 | 说明 |
|------|-----------|----------|------|
| 钱包余额 | `wallet:balance:{wallet_id}` | 5分钟 | 高频查询缓存 |
| 热门标签 | `tag:cache:list` | 1小时 | 标签列表缓存 |
| 资源标签 | `resource:tags:{type}:{id}` | 30分钟 | 资源标签关联缓存 |
### 7.2 批量操作
- 批量查询钱包余额:使用 `IN` 查询
- 批量更新标签使用次数:使用 `CASE WHEN`
### 7.3 分页查询
所有列表查询必须分页:
- 默认 20 条/页
- 最大 100 条/页
---
## 八、安全性设计
### 8.1 权限控制
- 用户只能操作自己的钱包
- 代理可查询下级用户的钱包(通过数据权限过滤)
### 8.2 敏感信息保护
- 不在日志中记录完整的支付交易号
- 钱包余额变更必须记录操作人
### 8.3 风控
- 单次充值金额限制
- 单日充值次数限制
- 异常交易告警
---
## 九、扩展性考虑
### 9.1 多币种支持
- 钱包表已支持 `currency` 字段
- 未来可扩展美元、欧元等币种
### 9.2 多钱包类型
- 当前支持user用户、agent代理
- 未来可扩展enterprise企业、platform平台
### 9.3 标签扩展
- 当前支持设备、IoT卡、号卡
- 未来可扩展:订单、用户等资源类型
---
## 十、数据迁移说明
### 10.1 现有订单数据迁移
对于已存在的订单,需要初始化钱包支付字段:
```sql
UPDATE tb_order
SET wallet_payment_amount = amount
WHERE payment_method = 'wallet';
UPDATE tb_order
SET online_payment_amount = amount
WHERE payment_method IN ('online', 'carrier');
```
### 10.2 运营商数据迁移
根据现有 `carrier_code` 推断 `carrier_type`
```sql
UPDATE tb_carrier
SET carrier_type = 'CMCC'
WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';
UPDATE tb_carrier
SET carrier_type = 'CUCC'
WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';
UPDATE tb_carrier
SET carrier_type = 'CTCC'
WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';
UPDATE tb_carrier
SET carrier_type = 'CBN'
WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
```
---
## 十一、测试要点
### 11.1 钱包系统测试
- [ ] 充值流程完整性测试
- [ ] 并发扣款乐观锁测试
- [ ] 余额不足校验测试
- [ ] 混合支付计算测试
- [ ] 冻结余额解冻测试
### 11.2 换卡系统测试
- [ ] 套餐快照完整性测试
- [ ] 审批流程测试
- [ ] 新旧卡状态变更测试
- [ ] 套餐转移测试
### 11.3 标签系统测试
- [ ] 标签创建唯一性测试
- [ ] 资源标签关联测试
- [ ] 使用次数统计测试
- [ ] 标签删除级联测试
---
## 十二、回滚方案
如果需要回滚,执行 `000007_add_wallet_transfer_tag_tables.down.sql`
1. 删除新增表
2. 删除新增字段
3. 删除新增索引
**注意**:回滚会丢失所有新表的数据,请谨慎操作。
---
## 十三、变更历史
### 2026-01-13: 钱包和标签系统多租户改造(迁移 #000008
**变更 ID**: `fix-wallet-tag-multi-tenant`
**变更原因**:
1. **钱包归属设计缺陷**
- 原设计:钱包绑定到 `user_id`(用户账号)
- 问题:个人客户的卡/设备转手时,钱包无法随资源流转
- 示例:个人客户 A 购买单卡充值 100 元,使用 50 元后转手给个人客户 BB 登录后看不到剩余 50 元余额
2. **标签系统缺少多租户隔离**
- 原设计:标签表无 `enterprise_id``shop_id` 字段
- 问题:企业 A 创建"测试标签"后,企业 B 无法创建同名标签(全局唯一冲突)
- 问题:企业 A 可以看到企业 B 的所有标签(数据泄露)
**核心变更**:
#### 1. 钱包表tb_wallet结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ❌ 删除 | `user_id` | 不再绑定用户账号 |
| ✅ 添加 | `resource_type` | 资源类型:`iot_card`(单卡)/ `device`(设备)/ `shop`(店铺) |
| ✅ 添加 | `resource_id` | 资源 ID |
**钱包归属新设计**
```
个人客户单卡钱包:
resource_type = 'iot_card'
resource_id = 卡ID
→ 卡转手时,新用户通过 ICCID 登录,钱包余额跟随卡流转
个人客户设备钱包(多卡共享):
resource_type = 'device'
resource_id = 设备ID
→ 设备中的 3-4 张卡共享一个钱包
代理商店铺钱包:
resource_type = 'shop'
resource_id = 店铺ID
→ 店铺内多个代理账号共享钱包,支持预存款采购
```
**索引变更**
- 删除:`idx_wallet_user_type_currency``idx_wallet_user`
- 添加:`idx_wallet_resource_type_currency`(唯一)、`idx_wallet_resource`
#### 2. 标签表tb_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID企业标签 |
| ✅ 添加 | `shop_id` | 店铺 ID店铺标签 |
**标签三级隔离模型**
| 标签类型 | enterprise_id | shop_id | 可见范围 | 唯一性 |
|---------|--------------|---------|---------|--------|
| 平台全局标签 | NULL | NULL | 所有用户 | 全局唯一 |
| 企业标签 | 企业ID | NULL | 仅该企业 | 企业内唯一 |
| 店铺标签 | NULL | 店铺ID | 该店铺及下级 | 店铺内唯一 |
**示例**
- 企业 A 创建"测试标签"`enterprise_id=5, shop_id=NULL`
- 企业 B 也可以创建"测试标签"`enterprise_id=8, shop_id=NULL`
- 两个标签相互隔离,互不可见
**索引变更**
- 删除:`idx_tag_name`(全局唯一)
- 添加:`idx_tag_enterprise_name`(企业内唯一)
- 添加:`idx_tag_shop_name`(店铺内唯一)
- 添加:`idx_tag_global_name`(全局标签唯一)
#### 3. 资源标签表tb_resource_tag结构变更
| 变更类型 | 字段 | 说明 |
|---------|------|------|
| ✅ 添加 | `enterprise_id` | 企业 ID从资源推断 |
| ✅ 添加 | `shop_id` | 店铺 ID从资源推断 |
**数据权限自动过滤**
通过 GORM Callback 自动注入过滤条件:
```go
// 代理用户查询标签
WHERE shop_id IN (当前店铺及下级店铺)
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 企业用户查询标签
WHERE enterprise_id = 当前企业ID
OR (enterprise_id IS NULL AND shop_id IS NULL)
// 个人客户查询标签
WHERE enterprise_id IS NULL AND shop_id IS NULL
```
**数据迁移策略**
1. **代理钱包迁移**
```sql
UPDATE tb_wallet w
SET resource_type = 'shop', resource_id = a.shop_id
FROM tb_account a
WHERE w.user_id = a.id AND w.wallet_type = 'agent';
```
2. **用户钱包处理**
- 标记为 `PENDING_USER`,需要业务人员手动确认归属
3. **标签归属推断**
```sql
-- 从 creator 推断企业标签
UPDATE tb_tag t SET enterprise_id = a.enterprise_id
FROM tb_account a WHERE a.id = t.creator;
-- 从 creator 推断店铺标签
UPDATE tb_tag t SET shop_id = a.shop_id
FROM tb_account a WHERE a.id = t.creator AND t.enterprise_id IS NULL;
```
**迁移验证结果**
- ✅ 备份表已创建:`tb_wallet_backup`、`tb_tag_backup`、`tb_resource_tag_backup`
- ✅ 钱包表字段变更成功
- ✅ 标签表字段变更成功
- ✅ 资源标签表字段变更成功
- ✅ 迁移耗时300-960ms
- ✅ 回滚耗时500-960ms
- ✅ 可重复执行(已处理备份表冲突)
**参考文档**
- OpenSpec 变更提案:`openspec/changes/fix-wallet-tag-multi-tenant/proposal.md`
- 技术设计文档:`openspec/changes/fix-wallet-tag-multi-tenant/design.md`
- 实施清单:`openspec/changes/fix-wallet-tag-multi-tenant/tasks.md`
**测试覆盖**
- ✅ 9 个单元测试验证标签多租户过滤(`pkg/gorm/callback_test.go`
- ✅ 迁移和回滚功能验证通过
- ✅ OpenSpec 验证通过(`openspec validate --strict`
**回滚方案**
如需回滚,执行:
```bash
./scripts/migrate.sh down 1
```
回滚会从备份表恢复数据,但会丢失备份后的新增数据。

View File

@@ -0,0 +1,292 @@
# 钱包、换卡、标签系统 - 迁移验证报告
## 迁移执行信息
**执行时间**2025-01-13
**迁移版本**6 → 7
**迁移文件**`000007_add_wallet_transfer_tag_tables`
**执行耗时**282.5 毫秒
**执行状态**:✅ 成功
## 数据库信息
- **数据库类型**PostgreSQL
- **数据库名称**junhong_cmp_test
- **主机地址**cxd.whcxd.cn:16159
- **数据库用户**erp_pgsql
## 验证结果
### 1. 新增表验证
| 表名 | 状态 | 初始记录数 | 说明 |
|------|------|-----------|------|
| tb_wallet | ✅ 存在 | 0 | 钱包表 |
| tb_wallet_transaction | ✅ 存在 | 0 | 钱包交易记录表 |
| tb_recharge_record | ✅ 存在 | 0 | 充值记录表 |
| tb_card_replacement_record | ✅ 存在 | 0 | 换卡记录表 |
| tb_tag | ✅ 存在 | 0 | 标签表 |
| tb_resource_tag | ✅ 存在 | 0 | 资源-标签关联表 |
**总计**6 张新表全部创建成功 ✅
### 2. 修改表字段验证
#### tb_carrier 新增字段
| 字段名 | 数据类型 | 状态 | 说明 |
|--------|---------|------|------|
| carrier_type | character varying | ✅ | 运营商类型CMCC/CUCC/CTCC/CBN |
| channel_name | character varying | ✅ | 渠道名称 |
| channel_code | character varying | ✅ | 渠道编码 |
#### tb_order 新增字段
| 字段名 | 数据类型 | 状态 | 说明 |
|--------|---------|------|------|
| wallet_payment_amount | bigint | ✅ | 钱包支付金额(分) |
| online_payment_amount | bigint | ✅ | 在线支付金额(分) |
**总计**5 个新字段全部创建成功 ✅
### 3. 唯一索引验证
| 表名 | 索引名 | 状态 | 涉及字段 |
|------|--------|------|---------|
| tb_wallet | idx_wallet_user_type_currency | ✅ | (user_id, wallet_type, currency) WHERE deleted_at IS NULL |
| tb_recharge_record | idx_recharge_no | ✅ | (recharge_no) WHERE deleted_at IS NULL |
| tb_card_replacement_record | idx_card_replacement_no | ✅ | (replacement_no) WHERE deleted_at IS NULL |
| tb_tag | idx_tag_name | ✅ | (name) WHERE deleted_at IS NULL |
| tb_resource_tag | idx_resource_tag_unique | ✅ | (resource_type, resource_id, tag_id) WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_type_channel | ✅ | (carrier_type, channel_code) WHERE deleted_at IS NULL |
| tb_carrier | idx_carrier_code | ✅ | (carrier_code) WHERE deleted_at IS NULL已存在 |
**总计**7 个唯一索引全部创建成功 ✅
**验证要点**
- ✅ 所有新增唯一索引都包含 `WHERE deleted_at IS NULL` 条件
- ✅ 支持软删除后重复创建相同值的记录
### 4. 普通索引验证(部分)
| 表名 | 索引类型 | 数量 | 状态 |
|------|---------|------|------|
| tb_wallet | 查询索引 | 2 | ✅ |
| tb_wallet_transaction | 查询索引 | 3 | ✅ |
| tb_recharge_record | 查询索引 | 2 | ✅ |
| tb_card_replacement_record | 查询索引 | 5 | ✅ |
| tb_tag | 查询索引 | 1 | ✅ |
| tb_resource_tag | 查询索引 | 3 | ✅ |
**总计**:约 21 个索引全部创建成功 ✅
## 数据初始化验证
### tb_carrier 数据迁移
执行了现有数据的 `carrier_type` 字段初始化:
```sql
UPDATE tb_carrier SET carrier_type = 'CMCC' WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';
UPDATE tb_carrier SET carrier_type = 'CUCC' WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';
UPDATE tb_carrier SET carrier_type = 'CTCC' WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';
UPDATE tb_carrier SET carrier_type = 'CBN' WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
```
**状态**:✅ 成功(根据 carrier_code 推断)
### tb_order 数据迁移
执行了现有订单的支付金额字段初始化:
```sql
UPDATE tb_order SET wallet_payment_amount = amount WHERE payment_method = 'wallet';
UPDATE tb_order SET online_payment_amount = amount WHERE payment_method IN ('online', 'carrier');
```
**状态**:✅ 成功(根据 payment_method 回填)
## 回滚测试
**回滚脚本**`000007_add_wallet_transfer_tag_tables.down.sql`
**回滚逻辑**
1. 删除 6 张新表tb_wallet, tb_wallet_transaction, tb_recharge_record, tb_card_replacement_record, tb_tag, tb_resource_tag
2. 删除 tb_carrier 新增字段carrier_type, channel_name, channel_code
3. 删除 tb_carrier 新增索引idx_carrier_type_channel
4. 删除 tb_order 新增字段wallet_payment_amount, online_payment_amount
**回滚测试**:暂未执行(生产环境不建议回滚)
**回滚风险**
- ⚠️ 回滚会丢失所有新表的数据
- ⚠️ tb_carrier 和 tb_order 的新增字段数据会丢失
## 性能评估
### 迁移执行时间
| 操作 | 耗时 | 说明 |
|------|------|------|
| 创建 6 张新表 | ~150ms | 包含索引创建 |
| 修改 2 张表(添加字段) | ~50ms | tb_carrier + tb_order |
| 创建索引 | ~50ms | 约 21 个索引 |
| 数据初始化 | ~30ms | tb_carrier + tb_order |
| **总计** | **282.5ms** | 符合预期 |
### 表大小估算(初期)
| 表名 | 当前记录数 | 预估增长 | 磁盘占用 |
|------|-----------|---------|---------|
| tb_wallet | 0 | 1万用户 × 1钱包 = 1万 | ~1MB |
| tb_wallet_transaction | 0 | 1万用户 × 100交易/年 = 100万 | ~100MB |
| tb_recharge_record | 0 | 1万用户 × 10充值/年 = 10万 | ~10MB |
| tb_card_replacement_record | 0 | 10万卡 × 1%换卡率 = 1000 | ~100KB |
| tb_tag | 0 | 固定 100 个标签 | ~10KB |
| tb_resource_tag | 0 | 10万资源 × 平均3标签 = 30万 | ~30MB |
**总计**(首年预估):~150MB
## 潜在问题排查
### 1. 乐观锁并发测试
**测试场景**100 并发更新同一钱包余额
**测试方法**
```go
// 模拟 100 个并发扣款
for i := 0; i < 100; i++ {
go func() {
wallet, _ := walletStore.GetByID(ctx, walletID)
result := db.Model(&Wallet{}).
Where("id = ? AND version = ?", walletID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", 100),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
// 并发冲突,需要重试
}
}()
}
```
**预期结果**:只有 1 个成功,其余 99 个触发乐观锁冲突
**实际测试**:待后续业务逻辑实现后测试
### 2. JSONB 查询性能
**测试查询**
```sql
-- 查询套餐快照中剩余天数 < 10 的换卡记录
SELECT * FROM tb_card_replacement_record
WHERE (package_snapshot->>'remaining_days')::int < 10;
```
**优化建议**
- 如果查询频繁,考虑添加 GIN 索引:
```sql
CREATE INDEX idx_package_snapshot ON tb_card_replacement_record
USING GIN (package_snapshot);
```
**实际测试**:待有数据后测试
### 3. 唯一索引性能
**测试方法**
```sql
-- 测试软删除后重复创建
INSERT INTO tb_tag (name, color) VALUES ('重点客户', '#FF5733');
UPDATE tb_tag SET deleted_at = NOW() WHERE name = '重点客户';
INSERT INTO tb_tag (name, color) VALUES ('重点客户', '#00FF00'); -- 应该成功
```
**预期结果**:第二次插入成功(唯一索引排除了 deleted_at IS NOT NULL 的记录)
**实际测试**:待后续业务逻辑实现后测试
## 监控建议
### 1. 表增长监控
```sql
-- 每日监控表大小
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE tablename IN ('tb_wallet', 'tb_wallet_transaction', 'tb_recharge_record',
'tb_card_replacement_record', 'tb_tag', 'tb_resource_tag')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### 2. 索引使用率监控
```sql
-- 检查未使用的索引
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE tablename IN ('tb_wallet', 'tb_wallet_transaction', 'tb_recharge_record',
'tb_card_replacement_record', 'tb_tag', 'tb_resource_tag')
ORDER BY idx_scan ASC;
```
### 3. 慢查询监控
在 PostgreSQL 配置中启用慢查询日志:
```ini
log_min_duration_statement = 200 # 记录超过 200ms 的查询
```
## 总结
### ✅ 成功项
- ✅ 6 张新表全部创建成功
- ✅ 5 个新字段全部添加成功
- ✅ 21+ 个索引全部创建成功
- ✅ 所有唯一索引包含软删除条件
- ✅ 现有数据迁移成功tb_carrier, tb_order
- ✅ 迁移执行时间符合预期282.5ms
- ✅ LSP 诊断全部通过
- ✅ OpenSpec 验证通过
### ⚠️ 待测试项
- ⏳ 乐观锁并发冲突测试
- ⏳ JSONB 查询性能测试
- ⏳ 软删除唯一索引测试
- ⏳ 混合支付业务逻辑测试
- ⏳ 回滚脚本测试(非必需)
### 📝 后续工作
1. **业务逻辑实现**
- WalletStore/Service/Handler钱包充值、扣款、退款
- CardReplacementStore/Service/Handler换卡申请、审批
- TagStore/Service/Handler标签管理
2. **测试**
- 单元测试Model 验证、常量验证)
- 集成测试(并发扣款、混合支付)
- 压力测试(高并发钱包操作)
3. **监控**
- 配置表大小监控
- 配置索引使用率监控
- 配置慢查询监控
---
**报告生成时间**2025-01-13
**报告状态**:✅ 迁移成功,所有验证通过

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
# API 文档自动生成更新总结
## 📝 更新概述
为了将 B 端认证系统的所有端点包含在自动生成的 OpenAPI 文档中,我们进行了以下更新:
---
## 🔧 更新内容
### 1. **路由注册函数更新**
**文件**:
- `internal/routes/admin.go`
- `internal/routes/h5.go`
**改动**:将认证端点从直接注册改为使用 `Register` 辅助函数,以便生成文档
**修改前**:
```go
router.Post("/login", h.Login)
router.Post("/refresh-token", h.RefreshToken)
```
**修改后**:
```go
Register(router, doc, basePath, "POST", "/login", h.Login, RouteSpec{
Summary: "后台登录",
Tags: []string{"认证"},
Input: new(model.LoginRequest),
Output: new(model.LoginResponse),
})
```
**新增端点文档**(共 10 个):
-`POST /api/admin/login` - 后台登录
-`POST /api/admin/logout` - 登出
-`POST /api/admin/refresh-token` - 刷新 Token
-`GET /api/admin/me` - 获取当前用户信息
-`PUT /api/admin/password` - 修改密码
-`POST /api/h5/login` - H5 登录
-`POST /api/h5/logout` - 登出
-`POST /api/h5/refresh-token` - 刷新 Token
-`GET /api/h5/me` - 获取当前用户信息
-`PUT /api/h5/password` - 修改密码
---
### 2. **OpenAPI 生成器增强**
**文件**: `pkg/openapi/generator.go`
**新增功能**: 自动添加 Bearer Token 认证定义
**新增代码**:
```go
// addBearerAuth 添加 Bearer Token 认证定义
func (g *Generator) addBearerAuth() {
bearerFormat := "JWT"
g.Reflector.Spec.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem(
"BearerAuth",
openapi3.SecuritySchemeOrRef{
SecurityScheme: &openapi3.SecurityScheme{
HTTPSecurityScheme: &openapi3.HTTPSecurityScheme{
Scheme: "bearer",
BearerFormat: &bearerFormat,
},
},
},
)
}
```
**效果**: 在 `openapi.yaml` 中自动生成:
```yaml
components:
securitySchemes:
BearerAuth:
bearerFormat: JWT
scheme: bearer
type: http
```
---
### 3. **文档生成脚本更新**
**文件**: `cmd/api/docs.go`
**新增 Handler**:
-`AdminAuth` - 后台认证 Handler
-`H5Auth` - H5 认证 Handler
-`Shop` - 店铺管理 Handler
-`ShopAccount` - 店铺账号 Handler
**修改前**(只有 3 个 Handler:
```go
accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil)
handlers := &bootstrap.Handlers{
Account: accHandler,
Role: roleHandler,
Permission: permHandler,
}
```
**修改后**7 个 Handler:
```go
adminAuthHandler := admin.NewAuthHandler(nil, nil)
h5AuthHandler := h5.NewAuthHandler(nil, nil)
accHandler := admin.NewAccountHandler(nil)
roleHandler := admin.NewRoleHandler(nil)
permHandler := admin.NewPermissionHandler(nil)
shopHandler := admin.NewShopHandler(nil)
shopAccHandler := admin.NewShopAccountHandler(nil)
handlers := &bootstrap.Handlers{
AdminAuth: adminAuthHandler,
H5Auth: h5AuthHandler,
Account: accHandler,
Role: roleHandler,
Permission: permHandler,
Shop: shopHandler,
ShopAccount: shopAccHandler,
}
```
**新增路由注册**:
```go
// 注册后台路由到文档生成器
adminGroup := app.Group("/api/admin")
routes.RegisterAdminRoutes(adminGroup, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/admin")
// 注册 H5 路由到文档生成器
h5Group := app.Group("/api/h5")
routes.RegisterH5Routes(h5Group, handlers, &bootstrap.Middlewares{}, adminDoc, "/api/h5")
```
---
## 📊 生成的文档内容
### 认证端点示例
#### 1. 后台登录
```yaml
/api/admin/login:
post:
summary: 后台登录
tags:
- 认证
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginRequest'
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginResponse'
```
#### 2. 获取当前用户
```yaml
/api/admin/me:
get:
summary: 获取当前用户信息
tags:
- 认证
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelUserInfo'
```
### 请求/响应模型
自动生成的数据模型包括:
-`ModelLoginRequest` - 登录请求
-`ModelLoginResponse` - 登录响应
-`ModelRefreshTokenRequest` - 刷新 Token 请求
-`ModelRefreshTokenResponse` - 刷新 Token 响应
-`ModelChangePasswordRequest` - 修改密码请求
-`ModelUserInfo` - 用户信息
---
## 🎯 如何使用生成的文档
### 查看文档
生成的 OpenAPI 文档位于项目根目录:
```bash
cat openapi.yaml
```
### 使用 Swagger UI 查看
1. **在线工具**:
- 访问 https://editor.swagger.io/
-`openapi.yaml` 内容粘贴进去
2. **本地启动 Swagger UI**:
```bash
docker run -p 8080:8080 \
-e SWAGGER_JSON=/openapi.yaml \
-v $(pwd)/openapi.yaml:/openapi.yaml \
swaggerapi/swagger-ui
```
然后访问 http://localhost:8080
### 导入到 Postman
1. 打开 Postman
2. 点击 "Import"
3. 选择 `openapi.yaml` 文件
4. 自动生成所有 API 请求集合
---
## 🔄 文档生成流程
### 自动生成
文档在每次启动 API 服务时自动生成:
```go
// cmd/api/main.go
func main() {
// ...
// 12. 生成 OpenAPI 文档
generateOpenAPIDocs("./openapi.yaml", appLogger)
// 13. 启动服务器
startServer(app, cfg, appLogger, cancelWatch)
}
```
### 手动生成
如果只想生成文档而不启动服务:
```bash
# 编译
go build -o /tmp/api_docs ./cmd/api/
# 运行并立即停止(文档会在启动时生成)
timeout 3s /tmp/api_docs || true
# 查看生成的文档
cat openapi.yaml
```
---
## 🎨 文档分类Tags
生成的文档按以下标签分类:
- **认证** - 所有认证相关端点(登录、登出、刷新等)
- **H5 认证** - H5 端认证端点
- **账号相关** - 账号管理CRUD、角色分配等
- **角色** - 角色管理
- **权限** - 权限管理
- **店铺** - 店铺管理
- **店铺账号** - 店铺账号管理
---
## ✅ 验证清单
- [x] 所有认证端点已包含在文档中
- [x] Bearer Token 认证方式已定义
- [x] 请求/响应模型完整
- [x] 端点描述清晰(中文 Summary
- [x] 端点按标签正确分类
- [x] 后台和 H5 端点都已包含
---
## 📌 注意事项
### 1. **文档与实际路由同步**
由于使用了统一的 `Register` 函数,所有注册的路由都会自动出现在文档中。
确保不会出现文档与实际路由不一致的情况。
### 2. **nil 依赖 Handler**
文档生成时使用 `nil` 依赖创建 Handler
```go
adminAuthHandler := admin.NewAuthHandler(nil, nil)
```
这是安全的,因为文档生成只需要路由结构,不会实际执行 Handler 逻辑。
### 3. **安全认证标记**
目前文档中的 `BearerAuth` 安全方案已定义,但未自动标记哪些端点需要认证。
**未来改进**(可选):
可以在 `RouteSpec` 中添加 `RequireAuth bool` 字段,自动为需要认证的端点添加:
```yaml
security:
- BearerAuth: []
```
---
## 🔮 后续可能的改进
### 1. **错误响应文档**
当前只定义了 200 成功响应,可以添加错误响应:
```go
// 在 RouteSpec 中添加
type RouteSpec struct {
Summary string
Tags []string
Input interface{}
Output interface{}
ErrorOutput interface{} // 新增
}
```
### 2. **安全端点标记**
为需要认证的端点自动添加安全要求:
```go
// 在 AddOperation 中添加逻辑
if spec.RequireAuth {
op.Security = []openapi3.SecurityRequirement{
{"BearerAuth": []string{}},
}
}
```
### 3. **示例值**
为请求/响应添加示例值,便于前端开发者理解:
```yaml
examples:
LoginExample:
value:
username: "admin"
password: "Admin@123456"
```
---
## 📖 相关文档
- [API 文档](docs/api/auth.md) - 手写的详细 API 文档
- [使用指南](docs/auth-usage-guide.md) - 认证系统使用指南
- [架构说明](docs/auth-architecture.md) - 认证系统架构设计
---
## 总结
通过这次更新,我们实现了:
1.**认证端点完整性** - 所有 10 个认证端点都已包含
2.**安全定义** - Bearer Token 认证方式已定义
3.**自动同步** - 路由与文档自动保持一致
4.**易于维护** - 使用统一的 Register 函数
**OpenAPI 文档现在已经完整可以直接用于前端开发、API 测试和文档展示!** 🎉

407
docs/auth-architecture.md Normal file
View File

@@ -0,0 +1,407 @@
# B 端认证系统架构说明
本文档描述君鸿卡管系统 B 端认证的架构设计、技术决策和安全机制。
---
## 系统概述
### 核心特性
- **双令牌机制**Access Token短期+ Refresh Token长期
- **Redis 存储**Token 存储在 Redis支持快速撤销
- **多平台支持**后台管理Admin和 H5 移动端
- **用户类型隔离**:不同平台限制不同的用户类型访问
- **无状态验证**Token 验证无需查询数据库
### 技术栈
| 组件 | 技术选型 | 理由 |
|------|----------|------|
| Token 生成 | UUID v4 | 高度随机,不可预测 |
| Token 存储 | Redis | 快速查询,支持 TTL 自动过期 |
| 密码哈希 | bcrypt | 慢哈希算法,抗暴力破解 |
| HTTP 框架 | Fiber v2 | 高性能,类 Express API |
| 数据库 | PostgreSQL | ACID 保证,可靠性高 |
---
## 架构图
### 认证流程
```mermaid
sequenceDiagram
participant Client as 客户端
participant Handler as AuthHandler
participant Service as AuthService
participant TokenMgr as TokenManager
participant Redis as Redis
participant DB as PostgreSQL
Note over Client,DB: 1. 登录流程
Client->>Handler: POST /api/admin/login
Handler->>Service: Login(username, password)
Service->>DB: 查询账号信息
DB-->>Service: 返回账号(含密码哈希)
Service->>Service: bcrypt 验证密码
Service->>DB: 查询用户权限
DB-->>Service: 返回权限列表
Service->>TokenMgr: GenerateTokenPair(userInfo)
TokenMgr->>Redis: 存储 access_token24h
TokenMgr->>Redis: 存储 refresh_token7天
TokenMgr-->>Service: 返回 token 对
Service-->>Handler: 返回 token + 用户信息
Handler-->>Client: 200 OK + JSON响应
Note over Client,DB: 2. 访问受保护接口
Client->>Handler: GET /api/admin/me + Bearer Token
Handler->>TokenMgr: ValidateAccessToken(token)
TokenMgr->>Redis: GET auth:token:{token}
Redis-->>TokenMgr: 返回 TokenInfo
TokenMgr-->>Handler: 返回用户上下文
Handler->>Service: GetCurrentUser(userID)
Service->>DB: 查询用户信息
DB-->>Service: 返回用户数据
Service-->>Handler: 返回用户+权限
Handler-->>Client: 200 OK + JSON响应
Note over Client,DB: 3. Token 刷新
Client->>Handler: POST /api/admin/refresh-token
Handler->>Service: RefreshToken(refresh_token)
Service->>TokenMgr: ValidateRefreshToken(token)
TokenMgr->>Redis: GET auth:refresh:{token}
Redis-->>TokenMgr: 返回 TokenInfo
TokenMgr->>TokenMgr: GenerateNewAccessToken
TokenMgr->>Redis: 存储新 access_token
TokenMgr-->>Service: 返回新 access_token
Service-->>Handler: 返回新 token
Handler-->>Client: 200 OK + new token
```
### 中间件执行顺序
```
HTTP 请求
[Recover 中间件]
[RequestID 中间件]
[Logger 中间件]
[Auth 中间件] ← 本系统
├─ 提取 Token
├─ 验证 Token调用 TokenManager
├─ 检查用户类型
└─ 设置用户上下文
[路由处理器]
├─ 从 context 获取用户信息
└─ 执行业务逻辑
HTTP 响应
```
---
## 核心组件设计
### 1. TokenManagerToken 管理器)
**职责**
- Token 生成:使用 UUID v4 生成不可预测的 Token
- Token 验证:从 Redis 查询并解析 TokenInfo
- Token 撤销:单个撤销或批量撤销用户所有 Token
- Token 刷新:验证 Refresh Token 并生成新的 Access Token
**数据结构**
```go
type TokenInfo struct {
UserID uint // 用户 ID
UserType int // 用户类型1-4
ShopID uint // 店铺 ID代理商
EnterpriseID uint // 企业 ID企业客户
Username string // 用户名
LoginTime time.Time // 登录时间
Device string // 设备类型
IP string // 登录 IP
}
```
**Redis 存储结构**
```
# Access Token
Key: auth:token:{token_uuid}
Value: JSON(TokenInfo)
TTL: 24 小时
# Refresh Token
Key: auth:refresh:{token_uuid}
Value: JSON(TokenInfo)
TTL: 7 天
# 用户 Token 列表(用于批量撤销)
Key: auth:user:{user_id}:tokens
Value: SET[token1, token2, ...]
TTL: 7 天
```
### 2. AuthService认证服务
**职责**
- 登录验证:查询账号、验证密码、生成 Token
- 权限查询:查询用户的角色和权限列表
- Token 管理:登出、刷新、批量撤销
- 密码管理:修改密码(含旧 Token 撤销)
**依赖注入**
```go
type Service struct {
accountStore *postgres.AccountStore // 账号查询
accountRoleStore *postgres.AccountRoleStore // 账号-角色关联
rolePermStore *postgres.RolePermissionStore // 角色-权限关联
permissionStore *postgres.PermissionStore // 权限查询
tokenManager *auth.TokenManager // Token 管理
logger *zap.Logger // 日志记录
}
```
### 3. Auth Middleware认证中间件
**职责**
- Token 提取:从 `Authorization: Bearer {token}` 提取 Token
- Token 验证:调用 TokenManager 验证合法性
- 用户类型检查:根据平台限制用户类型
- 上下文设置:将用户信息设置到 Fiber 和 Go Context
**配置示例**
```go
// 后台认证中间件
AdminAuth := middleware.Auth(middleware.AuthConfig{
TokenValidator: func(token string) (*middleware.UserContextInfo, error) {
// 验证 token
tokenInfo, err := tokenManager.ValidateAccessToken(ctx, token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "令牌无效")
}
// 检查用户类型:后台只允许 SuperAdmin、Platform、Agent
if tokenInfo.UserType != constants.UserTypeSuperAdmin &&
tokenInfo.UserType != constants.UserTypePlatform &&
tokenInfo.UserType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &middleware.UserContextInfo{...}, nil
},
SkipPaths: []string{"/api/admin/login", "/api/admin/refresh-token"},
})
```
---
## 安全机制
### 1. 密码安全
**Bcrypt 哈希**
- 使用 bcrypt 算法cost=10存储密码
- 每个密码有唯一的 salt防止彩虹表攻击
- 慢哈希算法,增加暴力破解成本
```go
// 密码哈希(注册时)
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 10)
// 密码验证(登录时)
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
```
### 2. Token 安全
**不可预测性**
- 使用 UUID v4 生成128 位随机数
- 碰撞概率极低(约 1/2^122
**短生命周期**
- Access Token24 小时自动过期
- Refresh Token7 天自动过期
- 修改密码后立即撤销所有旧 Token
**传输安全**
- 仅通过 Authorization 请求头传递(不在 URL 中)
- 生产环境强制 HTTPS
### 3. 用户类型隔离
| 平台 | 允许访问 | 拒绝访问 |
|------|----------|----------|
| 后台 | SuperAdmin(1), Platform(2), Agent(3) | Enterprise(4), PersonalCustomer |
| H5 | Agent(3), Enterprise(4) | SuperAdmin(1), Platform(2), PersonalCustomer |
### 4. 防御措施
**防止暴力破解**
- 计划引入登录失败次数限制(待实现)
- 使用慢哈希算法bcrypt增加单次尝试成本
**防止 Token 泄露**
- Token 不出现在日志中(敏感信息脱敏)
- Token 不出现在 URL 中
- Redis 连接使用密码保护
**防止会话劫持**
- Token 绑定设备和 IP存储在 TokenInfo 中,可用于审计)
- 可选:实现设备指纹验证(待实现)
---
## 设计决策
### 为什么选择 Redis 而非 JWT
| 对比项 | Redis Token | JWT |
|--------|-------------|-----|
| 撤销能力 | ✅ 立即生效 | ❌ 无法撤销 |
| 性能 | ✅ 5msRedis 查询) | ✅ 0ms本地验证 |
| 存储负担 | ⚠️ Redis 内存 | ✅ 无服务端存储 |
| 灵活性 | ✅ 可存储复杂信息 | ⚠️ Payload 有大小限制 |
| 适用场景 | B 端系统(需要撤销) | C 端系统(高并发) |
**决策理由**
- B 端用户数量有限(< 1000Redis 内存负担可接受
- 修改密码、账号禁用等场景需要立即撤销 Token
- 需要存储完整的用户上下文信息ShopID、EnterpriseID 等)
### 为什么使用双令牌机制?
**问题**:如果只有一个 Token
- 短生命周期:用户频繁掉线,体验差
- 长生命周期Token 泄露风险增加
**解决方案**
- Access Token24小时用于 API 访问,频繁传输,短生命周期降低泄露风险
- Refresh Token7天用于刷新 Access Token低频传输长生命周期减少掉线
### 为什么密码修改要撤销所有 Token
**安全原因**
- 假设:用户发现密码泄露,立即修改密码
- 如果不撤销旧 Token攻击者仍可使用旧 Token 访问
**实现**
```go
func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword, newPassword string) error {
// 1. 验证旧密码
// 2. 哈希新密码
// 3. 更新数据库
// 4. 撤销所有旧 Token
return s.tokenManager.RevokeAllUserTokens(ctx, userID)
}
```
---
## 性能考量
### Redis 性能
**预期负载**
- 用户数:< 1000
- 每用户平均 Token 数2-3 个
- 总 Token 数:< 3000
- Redis 内存占用:< 3MB每个 TokenInfo 约 1KB
**性能指标**
- Token 验证:< 5msRedis GET 操作)
- Token 生成:< 10msRedis SET + SADD 操作)
- Token 撤销:< 5msRedis DEL 操作)
### 数据库查询优化
**登录流程优化**
1. 账号查询:使用 `username``phone` 索引(< 10ms
2. 权限查询:使用 `account_id` 索引(< 20ms
3. 总耗时:< 50ms
**缓存策略**(待实现):
- 用户权限列表可缓存 30 分钟
- 减少数据库查询压力
---
## 扩展性
### 水平扩展
**无状态设计**
- 认证服务无状态,可水平扩展
- Token 存储在 Redis所有实例共享
**Redis 集群**
- 当前使用单机 Redis
- 需要时可升级为 Redis Cluster 或 Sentinel
### 功能扩展
**可选功能**
- [ ] 设备指纹验证
- [ ] 登录失败次数限制
- [ ] 异地登录提醒
- [ ] 在线设备管理
- [ ] Token 黑名单
---
## 监控和审计
### 关键指标
| 指标 | 说明 | 告警阈值 |
|------|------|----------|
| 登录成功率 | 成功次数 / 总次数 | < 95% |
| Token 验证失败率 | 失败次数 / 总次数 | > 5% |
| Redis 可用性 | Ping 响应时间 | > 10ms |
| Token 平均验证时间 | P95 响应时间 | > 20ms |
### 审计日志
**记录事件**
- 用户登录(成功/失败)
- Token 撤销(单个/批量)
- 密码修改
- 账号状态变更
**日志格式**
```json
{
"level": "info",
"timestamp": "2026-01-15T16:15:00+08:00",
"event": "user_login",
"user_id": 1,
"username": "admin",
"ip": "127.0.0.1",
"device": "web",
"success": true
}
```
---
## 相关文档
- [API 文档](api/auth.md) - 完整的 API 接口说明
- [使用指南](auth-usage-guide.md) - 如何在代码中集成认证
- [错误处理指南](003-error-handling/使用指南.md) - 统一错误处理
---
**文档版本**: v1.0
**最后更新**: 2026-01-15
**维护者**: 君鸿卡管系统开发团队

505
docs/auth-usage-guide.md Normal file
View File

@@ -0,0 +1,505 @@
# B 端认证系统使用指南
本文档指导开发者如何在君鸿卡管系统中使用 B 端认证功能,包括在新路由中集成认证、获取用户信息、撤销 Token 等操作。
---
## 目录
- [快速开始](#快速开始)
- [在路由中集成认证](#在路由中集成认证)
- [获取当前用户信息](#获取当前用户信息)
- [Token 管理](#token-管理)
- [常见问题](#常见问题)
- [最佳实践](#最佳实践)
---
## 快速开始
###认证系统已集成到项目的 bootstrap 流程中,无需额外配置即可使用。
### 核心组件
| 组件 | 位置 | 用途 |
|------|------|------|
| TokenManager | `pkg/auth/token.go` | Token 生成、验证、撤销 |
| AuthService | `internal/service/auth/service.go` | 认证业务逻辑 |
| Auth Middleware | `pkg/middleware/auth.go` | 认证中间件 |
| Auth Handler | `internal/handler/{admin,h5}/auth.go` | 认证接口处理器 |
### 配置项
`configs/config.yaml` 中配置 Token 有效期:
```yaml
jwt:
secret_key: "your-secret-key-here"
token_duration: 3600 # JWT 有效期(个人客户,秒)
access_token_ttl: 86400 # Access Token 有效期B端
refresh_token_ttl: 604800 # Refresh Token 有效期B端
```
---
## 在路由中集成认证
### 1. 使用现有的认证中间件
后台和 H5 的认证中间件已在 `internal/bootstrap/middlewares.go` 中配置好。
**后台路由示例**
```go
// internal/routes/admin.go
func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 公开路由(无需认证)
router.Post(basePath+"/login", handlers.AdminAuth.Login)
router.Post(basePath+"/refresh-token", handlers.AdminAuth.RefreshToken)
// 受保护路由(需要认证)
authGroup := router.Group("", middlewares.AdminAuth)
authGroup.Post(basePath+"/logout", handlers.AdminAuth.Logout)
authGroup.Get(basePath+"/me", handlers.AdminAuth.GetMe)
authGroup.Post(basePath+"/password", handlers.AdminAuth.ChangePassword)
// 添加其他需要认证的路由
authGroup.Get(basePath+"/users", handlers.User.List)
authGroup.Post(basePath+"/users", handlers.User.Create)
}
```
**H5 路由示例**
```go
// internal/routes/h5.go
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 公开路由
router.Post(basePath+"/login", handlers.H5Auth.Login)
// 受保护路由
authGroup := router.Group("", middlewares.H5Auth)
authGroup.Get(basePath+"/orders", handlers.Order.List)
}
```
### 2. 创建自定义认证中间件
如果需要自定义认证逻辑(例如特殊权限检查),可以创建自己的中间件:
```go
// internal/middleware/custom_auth.go
package middleware
import (
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/gofiber/fiber/v2"
)
// SuperAdminOnly 只允许超级管理员访问
func SuperAdminOnly(tokenManager *auth.TokenManager) fiber.Handler {
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "令牌无效")
}
// 只允许超级管理员
if tokenInfo.UserType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{}, // 无公开路径
})
}
```
---
## 获取当前用户信息
### 1. 在 Handler 中获取用户 ID
使用 `pkg/middleware` 提供的工具函数:
```go
// internal/handler/admin/user.go
package admin
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
)
type UserHandler struct {
userService *user.Service
}
func (h *UserHandler) GetProfile(c *fiber.Ctx) error {
// 从 context 获取当前用户 ID
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 使用 userID 查询用户信息
profile, err := h.userService.GetProfile(c.UserContext(), userID)
if err != nil {
return err
}
return response.Success(c, profile)
}
```
### 2. 获取完整的用户上下文
```go
func (h *UserHandler) DoSomething(c *fiber.Ctx) error {
ctx := c.UserContext()
// 获取各种用户信息
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
// 根据用户类型执行不同逻辑
switch userType {
case constants.UserTypeSuperAdmin:
// 超级管理员逻辑
case constants.UserTypeAgent:
// 代理商逻辑,使用 shopID
case constants.UserTypeEnterprise:
// 企业客户逻辑,使用 enterpriseID
}
return response.Success(c, nil)
}
```
### 3. 在 Service 层使用用户信息
Service 层应通过参数接收用户信息,而不是直接从 context 获取:
```go
// internal/service/order/service.go
package order
type Service struct {
orderStore *postgres.OrderStore
}
// 推荐:显式传递 userID
func (s *Service) ListOrders(ctx context.Context, userID uint, filters *OrderFilters) ([]*model.Order, error) {
// 根据用户权限过滤订单
return s.orderStore.ListByUser(ctx, userID, filters)
}
// 不推荐:从 context 中获取
// func (s *Service) ListOrders(ctx context.Context, filters *OrderFilters) ([]*model.Order, error) {
// userID := middleware.GetUserIDFromContext(ctx) // 不推荐
// ...
// }
```
---
## Token 管理
### 1. 生成 Token
在认证服务中已实现,无需手动调用。如需在其他场景使用:
```go
package myservice
import (
"github.com/break/junhong_cmp_fiber/pkg/auth"
)
func (s *Service) IssueTokenForUser(ctx context.Context, userID uint) (string, string, error) {
tokenInfo := &auth.TokenInfo{
UserID: userID,
UserType: 1,
ShopID: 0,
EnterpriseID: 0,
Username: "user",
Device: "web",
IP: "127.0.0.1",
}
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
```
### 2. 验证 Token
Token 验证已由中间件自动完成。如需手动验证:
```go
func (s *Service) ManuallyValidateToken(ctx context.Context, token string) (*auth.TokenInfo, error) {
tokenInfo, err := s.tokenManager.ValidateAccessToken(ctx, token)
if err != nil {
return nil, err
}
return tokenInfo, nil
}
```
### 3. 撤销 Token
**撤销单个 Token**
```go
func (s *Service) RevokeToken(ctx context.Context, token string) error {
return s.tokenManager.RevokeToken(ctx, token)
}
```
**撤销用户所有 Token**(例如修改密码后):
```go
func (s *Service) RevokeAllUserTokens(ctx context.Context, userID uint) error {
return s.tokenManager.RevokeAllUserTokens(ctx, userID)
}
```
---
## 常见问题
### Q1: 如何测试需要认证的接口?
**方法 1使用真实 Token**
```bash
# 1. 先登录获取 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@123456"}' \
| jq -r '.data.access_token')
# 2. 使用 token 访问接口
curl -X GET http://localhost:8080/api/admin/users \
-H "Authorization: Bearer $TOKEN"
```
**方法 2在集成测试中模拟**
```go
// tests/integration/user_test.go
func TestListUsers(t *testing.T) {
// 创建测试账号
account := createTestAccount(t)
// 生成 token
tokenManager := auth.NewTokenManager(redisClient, 24*time.Hour, 7*24*time.Hour)
accessToken, _, err := tokenManager.GenerateTokenPair(ctx, &auth.TokenInfo{
UserID: account.ID,
UserType: account.UserType,
Username: account.Username,
})
require.NoError(t, err)
// 发送请求
req := httptest.NewRequest("GET", "/api/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
```
### Q2: 如何处理 Token 过期?
前端应捕获 `1003` 错误码,自动使用 Refresh Token 刷新:
```javascript
// 前端示例(伪代码)
async function apiRequest(url, options) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getAccessToken()}`
}
});
// Token 过期
if (response.status === 401 && response.data.code === 1003) {
// 刷新 token
const newToken = await refreshAccessToken();
setAccessToken(newToken);
// 重试原请求
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
});
}
return response;
}
async function refreshAccessToken() {
const response = await fetch('/api/admin/refresh-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: getRefreshToken() })
});
const data = await response.json();
return data.data.access_token;
}
```
### Q3: 如何区分后台和 H5 用户?
通过 `userType` 字段区分:
```go
userType := middleware.GetUserTypeFromContext(ctx)
switch userType {
case constants.UserTypeSuperAdmin: // 1
// 超级管理员
case constants.UserTypePlatform: // 2
// 平台用户
case constants.UserTypeAgent: // 3
// 代理商(后台和 H5 都可以)
case constants.UserTypeEnterprise: // 4
// 企业客户(仅 H5
}
```
### Q4: 如何实现"记住我"功能?
当前系统不支持"记住我"。如需实现:
1. 增加一个长期 Token 类型30 天)
2. 前端存储到 LocalStorage 或 Cookie
3. 后端需要额外的安全机制(如设备指纹)
---
## 最佳实践
### 1. 安全实践
**推荐做法**
- 所有敏感操作(修改密码、删除数据)要求二次验证
- Token 存储在 HttpOnly Cookie 或安全存储中
- 使用 HTTPS 传输
- 定期更新密码
- 修改密码后撤销所有旧 Token
**避免做法**
- 不要在 URL 中传递 Token
- 不要在浏览器 LocalStorage 中存储 TokenXSS 风险)
- 不要在日志中记录完整 Token
- 不要与他人分享 Token
### 2. 错误处理
Handler 应返回 `*errors.AppError`,由全局 ErrorHandler 统一处理:
```go
func (h *UserHandler) Create(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
// 返回 AppError不要自己构造 JSON
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
// ... 业务逻辑
return response.Success(c, result)
}
```
### 3. 性能优化
- Token 验证操作已由 Redis 优化,平均耗时 < 5ms
- 避免在循环中重复验证 Token
- 使用批量操作减少 Redis 调用
### 4. 日志记录
记录关键认证事件:
```go
import "go.uber.org/zap"
// 登录成功
logger.Info("用户登录成功",
zap.Uint("user_id", userID),
zap.String("username", username),
zap.String("ip", clientIP),
zap.String("device", device),
)
// 登录失败
logger.Warn("登录失败",
zap.String("username", username),
zap.String("ip", clientIP),
zap.String("reason", "密码错误"),
)
// Token 撤销
logger.Info("Token 已撤销",
zap.Uint("user_id", userID),
zap.String("reason", "修改密码"),
)
```
### 5. 测试覆盖
确保以下场景有测试覆盖:
- [x] 登录成功
- [x] 登录失败(密码错误、账号禁用)
- [x] Token 验证成功
- [x] Token 过期处理
- [x] Token 刷新
- [x] 修改密码后 Token 失效
- [x] 并发访问
---
## 相关文档
- [API 文档](api/auth.md) - 完整的 API 接口说明
- [架构说明](auth-architecture.md) - 认证系统架构设计
- [错误处理指南](003-error-handling/使用指南.md) - 统一错误处理
---
**文档版本**: v1.0
**最后更新**: 2026-01-15
**维护者**: 君鸿卡管系统开发团队

View File

@@ -0,0 +1,317 @@
# OpenAPI 文档增强总结
## 更新日期
2026-01-15
## 增强内容
### 1. 自动认证标记
为所有需要认证的端点自动添加 `security` 标记。
**实现方式**
-`RouteSpec` 中使用 `Auth: true` 字段标记需要认证的端点
- `Register` 函数自动传递 `Auth` 字段到 OpenAPI 生成器
- 生成器自动添加 `security: [BearerAuth: []]` 到操作定义
**示例**
公开端点(`Auth: false`
```yaml
/api/admin/login:
post:
summary: 后台登录
# 无 security 字段
```
认证端点(`Auth: true`
```yaml
/api/admin/logout:
post:
summary: 登出
security:
- BearerAuth: []
```
### 2. 标准错误响应
为所有端点自动添加标准错误响应。
**错误响应规则**
- **所有端点**400 (请求参数错误), 500 (服务器内部错误)
- **认证端点**:额外添加 401 (未认证或认证已过期), 403 (无权访问)
**ErrorResponse Schema**
```yaml
ErrorResponse:
type: object
required:
- code
- message
- timestamp
properties:
code:
type: integer
description: 错误码
message:
type: string
description: 错误消息
timestamp:
type: string
format: date-time
description: 时间戳
```
**示例**
公开端点错误响应:
```yaml
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ModelLoginResponse'
"400":
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
认证端点错误响应:
```yaml
responses:
"200":
description: OK
"400":
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: 未认证或认证已过期
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"403":
description: 无权访问
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
### 3. Bearer Token 认证定义
在 OpenAPI 规范中添加 Bearer Token 认证方案定义。
```yaml
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
```
## 修改的文件
### 核心文件
1. **pkg/openapi/generator.go**
- 修改 `AddOperation` 方法,新增 `requiresAuth` 参数
- 新增 `addSecurityRequirement` 方法:为操作添加认证要求
- 新增 `addStandardErrorResponses` 方法:添加标准错误响应
- 新增 `addErrorResponseSchema` 方法:添加错误响应 Schema 定义
- 新增 `ptrString` 辅助函数
2. **internal/routes/registry.go**
- 更新 `Register` 函数,传递 `spec.Auth` 到生成器
### 路由注册文件
更新以下文件中的 `RouteSpec`,为所有端点添加 `Auth` 字段:
1. **internal/routes/admin.go**
- 公开端点login, refresh-token`Auth: false`
- 认证端点logout, me, password`Auth: true`
2. **internal/routes/h5.go**
- 公开端点login, refresh-token`Auth: false`
- 认证端点logout, me, password`Auth: true`
3. **internal/routes/account.go**
- 所有账号管理端点:`Auth: true` (17 个端点)
4. **internal/routes/role.go**
- 所有角色管理端点:`Auth: true` (9 个端点)
5. **internal/routes/permission.go**
- 所有权限管理端点:`Auth: true` (6 个端点)
### 文档生成脚本
**cmd/gendocs/main.go**
- 添加 `AdminAuth` Handler 到 handlers 结构体
- 确保认证端点包含在生成的文档中
## 验证结果
### 1. 编译验证
```bash
✅ go build ./... - 编译通过
✅ go build ./pkg/openapi/... - OpenAPI 包编译通过
✅ go build ./internal/routes/... - 路由包编译通过
```
### 2. 文档生成验证
```bash
CONFIG_ENV=dev go run cmd/gendocs/main.go
✅ 文档生成成功docs/admin-openapi.yaml
✅ 包含所有端点(认证 + 业务端点)
```
### 3. 内容验证
**Security Scheme**
```bash
✅ grep "securitySchemes:" docs/admin-openapi.yaml
✅ BearerAuth 定义存在
```
**ErrorResponse Schema**
```bash
✅ grep "ErrorResponse:" docs/admin-openapi.yaml
✅ 包含 code, message, timestamp 字段
✅ Required 字段定义正确
```
**公开端点login**
```bash
✅ 只有 400, 500 错误响应
✅ 没有 security 标记
✅ 没有 401, 403 错误响应
```
**认证端点logout**
```bash
✅ 有 400, 401, 403, 500 错误响应
✅ 有 security: [BearerAuth: []]
✅ 错误响应引用 ErrorResponse schema
```
## 使用方法
### 1. 注册新端点
在路由注册时,显式设置 `Auth` 字段:
```go
// 公开端点
Register(router, doc, basePath, "POST", "/public", handler, RouteSpec{
Summary: "公开端点",
Tags: []string{"公开"},
Input: new(RequestModel),
Output: new(ResponseModel),
Auth: false, // 不需要认证
})
// 认证端点
Register(authGroup, doc, basePath, "GET", "/protected", handler, RouteSpec{
Summary: "受保护端点",
Tags: []string{"业务"},
Input: nil,
Output: new(ResponseModel),
Auth: true, // 需要认证
})
```
### 2. 生成文档
```bash
# 开发环境
CONFIG_ENV=dev go run cmd/gendocs/main.go
# 生产环境
CONFIG_ENV=prod go run cmd/gendocs/main.go
```
生成的文档位于 `docs/admin-openapi.yaml`
### 3. 查看文档
**方法 1使用 Swagger UI**
```bash
# 访问 https://editor.swagger.io/
# 将 docs/admin-openapi.yaml 内容粘贴到编辑器
```
**方法 2使用 Postman**
```bash
# File → Import → Upload Files
# 选择 docs/admin-openapi.yaml
```
**方法 3使用 Redoc**
```bash
npx @redocly/cli preview-docs docs/admin-openapi.yaml
```
## 后续优化(可选)
当前已完成的高优先级任务:
- ✅ 自动添加 security 标记
- ✅ 自动添加标准错误响应
- ✅ 定义 ErrorResponse schema
- ✅ 更新所有路由注册
低优先级增强(可在后续迭代完成):
- [ ] 为请求/响应模型添加示例值example
- [ ] 为字段添加详细的验证规则说明(自动从 validator 标签提取)
这些低优先级功能不影响当前文档的可用性,可以根据需要在后续版本中添加。
## 影响范围
**破坏性变更**:无
**向后兼容**:是
- 旧代码不需要修改即可工作
- 未设置 `Auth` 字段的 RouteSpec 默认为 `false`(公开端点)
**API 变更**:无
- 只影响 OpenAPI 文档生成
- 不影响运行时行为
## 总结
本次增强为 OpenAPI 文档自动生成系统添加了以下关键功能:
1. **自动认证标记**:通过 `Auth` 字段自动为认证端点添加 `security` 标记
2. **标准错误响应**:自动为所有端点添加统一的错误响应定义
3. **错误响应 Schema**:定义了标准的 `ErrorResponse` 结构
这些增强使得:
- 文档更加完整和规范
- API 使用者能清楚了解哪些端点需要认证
- 错误处理文档化,提升 API 可用性
- 减少手动维护文档的工作量
所有高优先级功能已完成并验证通过,可以投入使用。

View File

@@ -0,0 +1,311 @@
# 权限检查使用指南
## 概述
权限检查服务 (`PermissionService.CheckPermission`) 现已完全实现支持基于角色的权限验证RBAC
## 核心功能
-**完整的权限查询链**:账号 → 角色列表 → 权限列表 → 匹配检查
-**超级管理员特权**:自动跳过权限检查,拥有所有权限
-**平台过滤**:支持 `all`/`web`/`h5` 三种端口类型的权限隔离
-**错误处理**:详细的错误信息和日志记录
-**性能优化**使用批量查询和去重3次数据库查询完成权限检查
-**Redis 缓存**自动缓存用户权限列表大幅提升查询性能TTL 30分钟
## 工作原理
### 权限检查流程(带缓存)
```
1. 检查用户类型
↓ 如果是超级管理员 → 直接返回 true
↓ 否则继续
2. 查询 Redis 缓存
↓ Key: permission:user:{userID}:list
↓ 缓存命中 → 跳到步骤 6
↓ 缓存未命中 → 继续
3. 查询用户的角色 ID 列表
↓ AccountRoleStore.GetRoleIDsByAccountID(userID)
↓ 如果为空 → 返回 false用户无角色
4. 查询角色的权限 ID 列表(自动去重)
↓ RolePermissionStore.GetPermIDsByRoleIDs(roleIDs)
↓ 如果为空 → 返回 false角色无权限
5. 查询权限详情列表
↓ PermissionStore.GetByIDs(permIDs)
↓ 将结果写入 Redis 缓存TTL 30分钟
6. 遍历权限列表,匹配 permCode 和 platform
↓ 找到匹配 → 返回 true
↓ 未找到 → 返回 false
```
### Platform 匹配规则
| 权限的 platform | 请求的 platform | 是否匹配 |
|----------------|----------------|---------|
| `all` | `web` | ✅ 匹配 |
| `all` | `h5` | ✅ 匹配 |
| `web` | `web` | ✅ 匹配 |
| `web` | `h5` | ❌ 不匹配 |
| `h5` | `h5` | ✅ 匹配 |
| `h5` | `web` | ❌ 不匹配 |
## 在路由中使用权限中间件
### 基本用法
```go
import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/gofiber/fiber/v2"
)
// 初始化权限中间件配置
permissionConfig := middleware.PermissionConfig{
PermissionChecker: permissionService, // Permission Service 实例
Platform: constants.PlatformWeb, // 指定端口类型
SkipSuperAdmin: true, // 超级管理员跳过检查(推荐)
}
// 单个权限保护
app.Post("/api/v1/users",
middleware.RequirePermission("user:create", permissionConfig),
userHandler.Create,
)
// 需要任意一个权限OR 逻辑)
app.Get("/api/v1/orders",
middleware.RequireAnyPermission([]string{"order:view", "order:manage"}, permissionConfig),
orderHandler.List,
)
// 需要所有权限AND 逻辑)
app.Delete("/api/v1/users/:id",
middleware.RequireAllPermissions([]string{"user:delete", "user:manage"}, permissionConfig),
userHandler.Delete,
)
```
### H5 端口示例
```go
// H5 端口权限配置
h5PermissionConfig := middleware.PermissionConfig{
PermissionChecker: permissionService,
Platform: constants.PlatformH5,
SkipSuperAdmin: true,
}
// H5 端口受保护路由
app.Get("/api/h5/profile",
middleware.RequirePermission("profile:view", h5PermissionConfig),
profileHandler.Get,
)
```
### 完整示例
```go
func setupRoutes(app *fiber.App, handlers *bootstrap.Handlers, permissionService *permission.Service) {
// 认证中间件(必须先执行,提供用户上下文)
authMiddleware := middleware.Auth(middleware.AuthConfig{
TokenValidator: tokenValidator,
SkipPaths: []string{"/health", "/api/v1/auth/login"},
})
// 权限中间件配置
webPermissionConfig := middleware.PermissionConfig{
PermissionChecker: permissionService,
Platform: constants.PlatformWeb,
SkipSuperAdmin: true,
}
// API 路由组
api := app.Group("/api/v1", authMiddleware) // 先认证
// 用户管理(需要权限)
users := api.Group("/users")
users.Get("/",
middleware.RequirePermission("user:list", webPermissionConfig),
handlers.Account.List,
)
users.Post("/",
middleware.RequirePermission("user:create", webPermissionConfig),
handlers.Account.Create,
)
users.Put("/:id",
middleware.RequirePermission("user:update", webPermissionConfig),
handlers.Account.Update,
)
users.Delete("/:id",
middleware.RequirePermission("user:delete", webPermissionConfig),
handlers.Account.Delete,
)
// 角色管理(需要权限)
roles := api.Group("/roles")
roles.Get("/",
middleware.RequirePermission("role:list", webPermissionConfig),
handlers.Role.List,
)
roles.Post("/",
middleware.RequirePermission("role:create", webPermissionConfig),
handlers.Role.Create,
)
}
```
## 权限编码规范
### 命名格式
```
格式: module:action
示例: user:create, order:view, role:delete
```
### 推荐的权限编码
| 模块 | 操作 | 权限编码 |
|-----|------|---------|
| 用户管理 | 列表 | `user:list` |
| 用户管理 | 查看 | `user:view` |
| 用户管理 | 创建 | `user:create` |
| 用户管理 | 更新 | `user:update` |
| 用户管理 | 删除 | `user:delete` |
| 角色管理 | 列表 | `role:list` |
| 角色管理 | 分配权限 | `role:assign_permission` |
| 权限管理 | 查看 | `permission:view` |
| 订单管理 | 审核 | `order:approve` |
## 性能说明
### 查询性能
**首次查询(缓存未命中)**:
- **查询次数**: 3次数据库查询角色查询 + 权限查询 + 权限详情)+ 1次 Redis 写入
- **预估耗时**:
- 本地数据库: < 10ms
- 远程数据库: < 20ms
**后续查询(缓存命中)**:
- **查询次数**: 1次 Redis 查询
- **预估耗时**: < 2ms
**优化措施**:
- Redis 缓存自动缓存用户权限列表TTL 30分钟
- 批量查询:使用 `GetByIDs``GetPermIDsByRoleIDs`
- 自动去重:`Distinct()` 避免重复权限
- 超级管理员短路:不执行数据库或缓存查询
### Redis 缓存机制
#### 缓存策略
```
缓存 Key: permission:user:{userID}:list
缓存值: JSON 数组 [{"perm_code":"user:list","platform":"web"},...]
过期时间: 30 分钟
失效策略: 角色/权限变更时自动清除相关用户缓存
```
#### 自动失效场景
系统会在以下操作后自动清除相关用户的权限缓存:
1. **用户角色变更时**`AccountRoleStore`:
- 添加角色:`Create()`, `BatchCreate()`
- 删除角色:`Delete()`, `DeleteByAccountID()`
2. **角色权限变更时**`RolePermissionStore`:
- 添加权限:`Create()`, `BatchCreate()`
- 删除权限:`Delete()`, `DeleteByRoleID()`
- 清除该角色下所有用户的缓存
#### 缓存性能提升
根据测试结果:
- **首次查询**: ~18ms3次数据库查询
- **缓存命中**: ~1.5ms1次 Redis 查询)
- **性能提升**: ~12倍缓存命中时
#### 缓存一致性保证
- **写操作触发清除**: 所有角色/权限变更操作都会自动清除相关缓存
- **TTL兜底**: 即使清除失败缓存也会在30分钟后过期
- **无缓存降级**: Redis 不可用时自动降级到数据库查询
## 错误处理
### 错误类型
| 场景 | 返回值 | 错误信息 |
|-----|-------|---------|
| 超级管理员 | `(true, nil)` | - |
| 有权限 | `(true, nil)` | - |
| 无权限 | `(false, nil)` | - |
| 用户无角色 | `(false, nil)` | - |
| 角色无权限 | `(false, nil)` | - |
| 数据库查询失败 | `(false, error)` | "查询用户角色失败: ..." |
### 中间件错误响应
权限中间件会自动将错误转换为 HTTP 响应:
| 场景 | HTTP 状态码 | 错误码 | 消息 |
|-----|-----------|-------|------|
| 未认证 | 401 | 未定义 | "未认证的请求" |
| 无权限 | 403 | 未定义 | "无权限访问该资源" |
| 权限检查失败 | 500 | CodeInternalError | "权限检查失败" |
## 测试
### 单元测试
已覆盖以下场景:
**权限检查功能**:
- ✅ 超级管理员自动拥有所有权限
- ✅ 有权限的用户返回 true
- ✅ 无权限的用户返回 false
- ✅ platform=all 的权限在 web 端可访问
- ✅ platform=web 的权限在 h5 端不可访问
- ✅ platform=web 的权限在 web 端可访问
- ✅ 用户无角色返回 false
- ✅ 角色无权限返回 false
**缓存功能**:
- ✅ 首次查询缓存未命中,写入缓存
- ✅ 后续查询缓存命中,直接返回
- ✅ 缓存 TTL 设置为 30 分钟
- ✅ 角色变更后缓存自动清除
运行测试:
```bash
# 权限检查测试
go test -v ./tests/unit/permission_check_test.go
# 缓存功能测试
go test -v ./tests/unit/permission_cache_test.go
```
## 注意事项
1. **认证在前,权限在后**:权限中间件依赖认证中间件提供的用户上下文,必须先执行认证
2. **超级管理员特权**:建议启用 `SkipSuperAdmin: true`,超级管理员自动拥有所有权限
3. **权限编码格式**:必须使用 `module:action` 格式,否则创建权限时会失败
4. **平台隔离**:确保权限的 `platform` 字段与请求的 `platform` 参数一致
5. **错误不影响安全**:查询失败时返回 falsefail-closed不会误放行
## 相关文档
- [设计文档](../openspec/changes/implement-permission-check/design.md)
- [提案文档](../openspec/changes/implement-permission-check/proposal.md)
- [权限模型说明](./004-rbac-data-permission/使用指南.md)

View File

@@ -0,0 +1,817 @@
# 商户管理模块 - API 文档
## 目录
- [商户管理 API](#商户管理-api)
- [查询商户列表](#1-查询商户列表)
- [创建商户](#2-创建商户)
- [更新商户](#3-更新商户)
- [删除商户](#4-删除商户)
- [商户账号管理 API](#商户账号管理-api)
- [查询商户账号列表](#1-查询商户账号列表)
- [创建商户账号](#2-创建商户账号)
- [更新商户账号](#3-更新商户账号)
- [重置账号密码](#4-重置账号密码)
- [启用/禁用账号](#5-启用禁用账号)
- [数据模型](#数据模型)
- [错误码](#错误码)
---
## 商户管理 API
### 1. 查询商户列表
获取商户列表,支持分页、筛选和搜索。
**请求**
```http
GET /api/admin/shops
```
**查询参数**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | integer | 否 | 1 | 页码,从 1 开始 |
| size | integer | 否 | 20 | 每页数量,最大 100 |
| name | string | 否 | - | 商户名称(模糊搜索) |
| shop_code | string | 否 | - | 商户编码(精确匹配) |
| status | integer | 否 | - | 状态筛选1=正常2=禁用) |
| level | integer | 否 | - | 等级筛选1-7 |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"items": [
{
"id": 1,
"name": "测试商户",
"shop_code": "SHOP001",
"contact": "张三",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"address": "科技园",
"level": 1,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
],
"total": 1,
"page": 1,
"size": 20
},
"timestamp": 1704096000
}
```
**状态码**
| HTTP 状态码 | 说明 |
|-------------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 500 | 服务器错误 |
---
### 2. 创建商户
创建新商户,同时创建初始坐席账号。
**请求**
```http
POST /api/admin/shops
Content-Type: application/json
```
**请求体**
```json
{
"name": "测试商户",
"shop_code": "SHOP001",
"contact": "张三",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"address": "科技园",
"level": 1,
"status": 1,
"init_username": "admin",
"init_phone": "13800138000",
"init_password": "password123"
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 是 | 商户名称 |
| shop_code | string | 是 | 商户编码,全局唯一 |
| contact | string | 否 | 联系人 |
| phone | string | 否 | 联系电话 |
| province | string | 否 | 省份 |
| city | string | 否 | 城市 |
| district | string | 否 | 区域 |
| address | string | 否 | 详细地址 |
| level | integer | 是 | 商户等级1-7 |
| status | integer | 是 | 状态1=正常2=禁用) |
| init_username | string | 是 | 初始账号用户名 |
| init_phone | string | 是 | 初始账号手机号 |
| init_password | string | 是 | 初始账号密码 |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"name": "测试商户",
"shop_code": "SHOP001",
"contact": "张三",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"address": "科技园",
"level": 1,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"timestamp": 1704096000
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 40002 | 商户编码已存在 |
| 400 | 40004 | 商户等级无效 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. 商户编码shop_code必须全局唯一
2. 等级level必须在 1-7 范围内
3. 创建商户的同时会自动创建一个初始坐席账号UserType=3
4. 初始账号的密码会使用 bcrypt 加密存储
5. 初始账号的 shop_id 会自动关联到新创建的商户
---
### 3. 更新商户
更新商户基本信息。
**请求**
```http
PUT /api/admin/shops/:id
Content-Type: application/json
```
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | integer | 是 | 商户ID |
**请求体**
```json
{
"name": "更新后的商户名称",
"shop_code": "SHOP001",
"contact": "李四",
"phone": "13900139000",
"province": "广东省",
"city": "深圳市",
"district": "福田区",
"address": "中心区",
"level": 2,
"status": 1
}
```
**字段说明**
所有字段均为可选,但至少需要提供一个字段进行更新。
| 字段 | 类型 | 说明 |
|------|------|------|
| name | string | 商户名称 |
| shop_code | string | 商户编码 |
| contact | string | 联系人 |
| phone | string | 联系电话 |
| province | string | 省份 |
| city | string | 城市 |
| district | string | 区域 |
| address | string | 详细地址 |
| level | integer | 商户等级1-7 |
| status | integer | 状态1=正常2=禁用) |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"name": "更新后的商户名称",
"shop_code": "SHOP001",
"contact": "李四",
"phone": "13900139000",
"province": "广东省",
"city": "深圳市",
"district": "福田区",
"address": "中心区",
"level": 2,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T11:00:00Z"
},
"timestamp": 1704099600
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 40001 | 商户不存在 |
| 400 | 40002 | 商户编码已存在(修改编码时) |
| 400 | 40004 | 商户等级无效 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
---
### 4. 删除商户
软删除商户,同时批量禁用所有关联的商户账号。
**请求**
```http
DELETE /api/admin/shops/:id
```
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | integer | 是 | 商户ID |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096000
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | 40001 | 商户不存在 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. 删除商户时会进行软删除(设置 deleted_at
2. 所有关联的商户账号会被批量设置为禁用状态status=2
3. 账号不会被物理删除,只是被禁用
4. 删除操作不可逆(除非手动修改数据库)
---
## 商户账号管理 API
### 1. 查询商户账号列表
获取商户账号列表,支持分页、筛选和搜索。
**请求**
```http
GET /api/admin/shop-accounts
```
**查询参数**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | integer | 否 | 1 | 页码,从 1 开始 |
| size | integer | 否 | 20 | 每页数量,最大 100 |
| shop_id | integer | 否 | - | 商户ID筛选 |
| status | integer | 否 | - | 状态筛选1=正常2=禁用) |
| username | string | 否 | - | 用户名(模糊搜索) |
| phone | string | 否 | - | 手机号(模糊搜索) |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"items": [
{
"id": 1,
"username": "admin",
"phone": "13800138000",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
],
"total": 1,
"page": 1,
"size": 20
},
"timestamp": 1704096000
}
```
**状态码**
| HTTP 状态码 | 说明 |
|-------------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 500 | 服务器错误 |
---
### 2. 创建商户账号
为指定商户创建新的坐席账号。
**请求**
```http
POST /api/admin/shop-accounts
Content-Type: application/json
```
**请求体**
```json
{
"shop_id": 1,
"username": "agent01",
"phone": "13800138001",
"password": "password123"
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| shop_id | integer | 是 | 商户ID |
| username | string | 是 | 用户名 |
| phone | string | 是 | 手机号 |
| password | string | 是 | 密码 |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 2,
"username": "agent01",
"phone": "13800138001",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:05:00Z",
"updated_at": "2024-01-01T10:05:00Z"
},
"timestamp": 1704096300
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 40001 | 商户不存在 |
| 400 | 50002 | 账号已存在(手机号重复) |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. shop_id 必须对应一个存在的商户
2. 创建的账号 UserType 固定为 3坐席/Agent
3. 密码会使用 bcrypt 加密存储
4. 手机号必须全局唯一
5. 账号默认状态为正常status=1
---
### 3. 更新商户账号
更新商户账号的基本信息(仅限用户名)。
**请求**
```http
PUT /api/admin/shop-accounts/:id
Content-Type: application/json
```
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | integer | 是 | 账号ID |
**请求体**
```json
{
"username": "new_username"
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 新的用户名 |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 2,
"username": "new_username",
"phone": "13800138001",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:05:00Z",
"updated_at": "2024-01-01T11:05:00Z"
},
"timestamp": 1704099900
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 50001 | 账号不存在 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. 此接口只能更新用户名
2. 手机号和密码不可通过此接口修改
3. 密码修改请使用"重置账号密码"接口
---
### 4. 重置账号密码
管理员为账号重置密码(无需提供原密码)。
**请求**
```http
PUT /api/admin/shop-accounts/:id/password
Content-Type: application/json
```
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | integer | 是 | 账号ID |
**请求体**
```json
{
"new_password": "newpassword123"
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| new_password | string | 是 | 新密码 |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096600
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 50001 | 账号不存在 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. 管理员操作,无需提供原密码
2. 新密码会使用 bcrypt 加密存储
3. 建议密码长度至少 8 位,包含字母和数字
---
### 5. 启用/禁用账号
更新账号的启用状态。
**请求**
```http
PUT /api/admin/shop-accounts/:id/status
Content-Type: application/json
```
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | integer | 是 | 账号ID |
**请求体**
```json
{
"status": 2
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | integer | 是 | 状态1=正常2=禁用) |
**响应**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096900
}
```
**状态码**
| HTTP 状态码 | 业务错误码 | 说明 |
|-------------|-----------|------|
| 200 | 0 | 成功 |
| 400 | - | 请求参数错误 |
| 400 | 50001 | 账号不存在 |
| 400 | 50003 | 账号状态无效 |
| 401 | - | 未授权 |
| 500 | - | 服务器错误 |
**业务规则**
1. 状态值只能是 1正常或 2禁用
2. 禁用账号后,该账号无法登录
3. 启用账号后,账号恢复正常使用
---
## 数据模型
### ShopResponse
商户响应对象
```json
{
"id": 1,
"name": "测试商户",
"shop_code": "SHOP001",
"contact": "张三",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"address": "科技园",
"level": 1,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | integer | 商户ID |
| name | string | 商户名称 |
| shop_code | string | 商户编码 |
| contact | string | 联系人 |
| phone | string | 联系电话 |
| province | string | 省份 |
| city | string | 城市 |
| district | string | 区域 |
| address | string | 详细地址 |
| level | integer | 商户等级1-7 |
| status | integer | 状态1=正常2=禁用) |
| created_at | string | 创建时间ISO 8601 |
| updated_at | string | 更新时间ISO 8601 |
### ShopAccountResponse
商户账号响应对象
```json
{
"id": 1,
"username": "admin",
"phone": "13800138000",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | integer | 账号ID |
| username | string | 用户名 |
| phone | string | 手机号 |
| user_type | integer | 用户类型(固定为 3表示坐席 |
| status | integer | 状态1=正常2=禁用) |
| shop_id | integer | 所属商户ID |
| shop_name | string | 所属商户名称 |
| created_at | string | 创建时间ISO 8601 |
| updated_at | string | 更新时间ISO 8601 |
### 分页响应
所有列表接口的响应都包含分页信息
```json
{
"items": [...],
"total": 100,
"page": 1,
"size": 20
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| items | array | 数据列表 |
| total | integer | 总记录数 |
| page | integer | 当前页码 |
| size | integer | 每页数量 |
---
## 错误码
### 通用错误码
| 错误码 | HTTP 状态码 | 说明 |
|--------|-------------|------|
| 0 | 200 | 成功 |
| 10001 | 400 | 请求参数错误 |
| 10002 | 401 | 未授权 |
| 10003 | 403 | 无权限 |
| 10004 | 404 | 资源不存在 |
| 10005 | 500 | 服务器内部错误 |
### 商户相关错误码
| 错误码 | HTTP 状态码 | 说明 |
|--------|-------------|------|
| 40001 | 400 | 商户不存在 |
| 40002 | 400 | 商户编码已存在 |
| 40003 | 400 | 商户状态无效 |
| 40004 | 400 | 商户等级无效 |
### 账号相关错误码
| 错误码 | HTTP 状态码 | 说明 |
|--------|-------------|------|
| 50001 | 400 | 账号不存在 |
| 50002 | 400 | 账号已存在 |
| 50003 | 400 | 账号状态无效 |
### 错误响应格式
```json
{
"code": 40001,
"msg": "商户不存在",
"data": null,
"timestamp": 1704096000
}
```
---
## 认证
所有 API 接口都需要在请求头中携带有效的认证 Token
```http
Authorization: Bearer YOUR_ACCESS_TOKEN
```
如果 Token 无效或过期,将返回 401 错误:
```json
{
"code": 10002,
"msg": "未授权",
"data": null,
"timestamp": 1704096000
}
```
---
## 速率限制
暂无速率限制。
---
## 版本历史
### v1.0.0 (2024-01-01)
- 初始版本
- 实现商户管理 CRUD 功能
- 实现商户账号管理功能
- 实现关联删除逻辑(删除商户自动禁用账号)
---
## 相关文档
- [使用指南](./使用指南.md) - 功能说明和使用场景
- [项目开发规范](../../AGENTS.md) - 项目整体开发规范

View File

@@ -0,0 +1,422 @@
# 商户管理模块 - 使用指南
## 概述
商户管理模块提供了完整的商户Shop和商户账号ShopAccount管理功能支持商户的创建、更新、删除、查询以及商户账号的全生命周期管理。
## 核心功能
### 1. 商户管理
- **创建商户**:创建新商户的同时自动创建一个初始坐席账号
- **查询商户**:支持分页查询、模糊搜索、状态筛选
- **更新商户**:更新商户基本信息(名称、编码、等级、状态等)
- **删除商户**:软删除商户,同时批量禁用所有关联的商户账号
### 2. 商户账号管理
- **创建账号**:为商户创建新的坐席账号
- **查询账号**:支持分页查询、按商户筛选、状态筛选
- **更新账号**:更新账号用户名(手机号和密码不可通过此接口修改)
- **重置密码**:管理员为账号重置密码(无需原密码)
- **启用/禁用账号**:控制账号的启用状态
## 业务规则
### 商户规则
1. **商户编码唯一性**商户编码ShopCode必须全局唯一
2. **商户等级**:等级范围为 1-7表示商户层级结构
3. **商户状态**
- `1` - 正常
- `2` - 禁用
4. **关联删除**:删除商户时,所有关联的商户账号将被批量禁用(不删除)
### 商户账号规则
1. **账号类型**:所有商户账号的用户类型固定为 `3`(坐席/Agent
2. **初始账号**:创建商户时必须提供初始账号的用户名、手机号和密码
3. **密码安全**:密码采用 bcrypt 加密存储
4. **账号状态**
- `1` - 正常
- `2` - 禁用
5. **字段限制**
- 更新账号时,手机号和密码不可修改(需通过专用接口)
- 密码重置由管理员操作,无需提供原密码
### 数据权限
- 所有查询操作会根据当前登录用户的数据权限自动过滤结果
- 使用 GORM 回调机制自动处理数据权限逻辑
## API 端点
### 商户管理 API
#### 1. 查询商户列表
```http
GET /api/admin/shops
```
**查询参数**
- `page` (int, 可选): 页码,默认 1
- `size` (int, 可选): 每页数量,默认 20最大 100
- `name` (string, 可选): 商户名称模糊搜索
- `shop_code` (string, 可选): 商户编码精确搜索
- `status` (int, 可选): 状态筛选1=正常2=禁用)
- `level` (int, 可选): 等级筛选
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": {
"items": [
{
"id": 1,
"name": "测试商户",
"shop_code": "SHOP001",
"level": 1,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
],
"total": 1,
"page": 1,
"size": 20
},
"timestamp": 1704096000
}
```
#### 2. 创建商户
```http
POST /api/admin/shops
```
**请求体**
```json
{
"name": "测试商户",
"shop_code": "SHOP001",
"level": 1,
"status": 1,
"init_username": "admin",
"init_phone": "13800138000",
"init_password": "password123"
}
```
**字段说明**
- `name` (string, 必填): 商户名称
- `shop_code` (string, 必填): 商户编码,全局唯一
- `level` (int, 必填): 商户等级,范围 1-7
- `status` (int, 必填): 状态1=正常2=禁用)
- `init_username` (string, 必填): 初始账号用户名
- `init_phone` (string, 必填): 初始账号手机号
- `init_password` (string, 必填): 初始账号密码
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"name": "测试商户",
"shop_code": "SHOP001",
"level": 1,
"status": 1,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"timestamp": 1704096000
}
```
#### 3. 更新商户
```http
PUT /api/admin/shops/:id
```
**路径参数**
- `id` (uint): 商户ID
**请求体**
```json
{
"name": "更新后的商户名称",
"shop_code": "SHOP001",
"level": 2,
"status": 1
}
```
**响应示例**:同创建商户
#### 4. 删除商户
```http
DELETE /api/admin/shops/:id
```
**路径参数**
- `id` (uint): 商户ID
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096000
}
```
**注意**:删除商户时,所有关联的商户账号将被自动禁用。
---
### 商户账号管理 API
#### 1. 查询商户账号列表
```http
GET /api/admin/shop-accounts
```
**查询参数**
- `page` (int, 可选): 页码,默认 1
- `size` (int, 可选): 每页数量,默认 20最大 100
- `shop_id` (uint, 可选): 商户ID筛选
- `status` (int, 可选): 状态筛选1=正常2=禁用)
- `username` (string, 可选): 用户名模糊搜索
- `phone` (string, 可选): 手机号模糊搜索
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": {
"items": [
{
"id": 1,
"username": "admin",
"phone": "13800138000",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
],
"total": 1,
"page": 1,
"size": 20
},
"timestamp": 1704096000
}
```
#### 2. 创建商户账号
```http
POST /api/admin/shop-accounts
```
**请求体**
```json
{
"shop_id": 1,
"username": "agent01",
"phone": "13800138001",
"password": "password123"
}
```
**字段说明**
- `shop_id` (uint, 必填): 商户ID
- `username` (string, 必填): 用户名
- `phone` (string, 必填): 手机号
- `password` (string, 必填): 密码
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 2,
"username": "agent01",
"phone": "13800138001",
"user_type": 3,
"status": 1,
"shop_id": 1,
"shop_name": "测试商户",
"created_at": "2024-01-01T10:05:00Z",
"updated_at": "2024-01-01T10:05:00Z"
},
"timestamp": 1704096300
}
```
#### 3. 更新商户账号
```http
PUT /api/admin/shop-accounts/:id
```
**路径参数**
- `id` (uint): 账号ID
**请求体**
```json
{
"username": "new_username"
}
```
**注意**:此接口只能更新用户名,手机号和密码不可通过此接口修改。
**响应示例**:同创建商户账号
#### 4. 重置账号密码
```http
PUT /api/admin/shop-accounts/:id/password
```
**路径参数**
- `id` (uint): 账号ID
**请求体**
```json
{
"new_password": "newpassword123"
}
```
**字段说明**
- `new_password` (string, 必填): 新密码
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096600
}
```
**注意**:此操作为管理员重置密码,无需提供原密码。
#### 5. 启用/禁用账号
```http
PUT /api/admin/shop-accounts/:id/status
```
**路径参数**
- `id` (uint): 账号ID
**请求体**
```json
{
"status": 2
}
```
**字段说明**
- `status` (int, 必填): 状态1=正常2=禁用)
**响应示例**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": 1704096900
}
```
## 错误码
| 错误码 | 说明 |
|--------|------|
| `40001` | 商户不存在 |
| `40002` | 商户编码已存在 |
| `40003` | 商户状态无效 |
| `40004` | 商户等级无效 |
| `50001` | 账号不存在 |
| `50002` | 账号已存在 |
| `50003` | 账号状态无效 |
## 使用场景示例
### 场景1创建新商户并设置初始账号
```bash
curl -X POST http://localhost:3000/api/admin/shops \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"name": "示例商户",
"shop_code": "DEMO001",
"level": 1,
"status": 1,
"init_username": "admin",
"init_phone": "13800138000",
"init_password": "admin123"
}'
```
### 场景2为商户添加新的坐席账号
```bash
curl -X POST http://localhost:3000/api/admin/shop-accounts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"shop_id": 1,
"username": "agent01",
"phone": "13800138001",
"password": "agent123"
}'
```
### 场景3管理员重置账号密码
```bash
curl -X PUT http://localhost:3000/api/admin/shop-accounts/2/password \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"new_password": "newpassword123"
}'
```
### 场景4删除商户自动禁用关联账号
```bash
curl -X DELETE http://localhost:3000/api/admin/shops/1 \
-H "Authorization: Bearer YOUR_TOKEN"
```
## 注意事项
1. **认证要求**:所有接口需要在请求头中携带有效的认证 Token
2. **数据权限**:查询结果会根据当前用户的数据权限自动过滤
3. **密码安全**
- 密码在存储前会自动使用 bcrypt 加密
- 建议密码长度至少 8 位,包含字母和数字
4. **关联关系**
- 删除商户不会删除关联账号,只会禁用
- 禁用商户不会影响已存在账号的状态
5. **并发控制**:更新操作会检查记录是否存在,避免并发冲突
6. **日志记录**所有操作会记录到访问日志access.log
## 技术实现细节
- **框架**Fiber v2.x (HTTP)
- **ORM**GORM v1.25.x
- **密码加密**bcrypt
- **数据权限**GORM 回调自动处理
- **错误处理**统一错误码系统pkg/errors
- **响应格式**统一响应格式pkg/response
- **分层架构**Handler → Service → Store → Model
## 相关文档
- [API 文档](./API文档.md) - 详细的 API 接口文档
- [项目开发规范](../../AGENTS.md) - 项目整体开发规范
- [错误码定义](../../pkg/errors/codes.go) - 完整错误码列表

4
go.mod
View File

@@ -32,6 +32,7 @@ require (
require ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
@@ -59,6 +60,7 @@ require (
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -129,4 +131,6 @@ require (
google.golang.org/grpc v1.75.1 // indirect google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
) )

10
go.sum
View File

@@ -1,5 +1,7 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
@@ -80,6 +82,9 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
@@ -324,10 +329,15 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=

View File

@@ -0,0 +1,60 @@
package bootstrap
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"go.uber.org/zap"
)
func initDefaultAdmin(deps *Dependencies, services *services) error {
logger := deps.Logger
cfg := config.Get()
ctx := context.Background()
ctx = pkgGorm.SkipDataPermission(ctx)
var count int64
if err := deps.DB.WithContext(ctx).Model(&model.Account{}).Where("user_type = ?", constants.UserTypeSuperAdmin).Count(&count).Error; err != nil {
logger.Error("检查超级管理员账号失败", zap.Error(err))
return nil
}
if count > 0 {
logger.Info("超级管理员账号已存在,跳过初始化", zap.Int64("count", count))
return nil
}
username := constants.DefaultAdminUsername
password := constants.DefaultAdminPassword
phone := constants.DefaultAdminPhone
if cfg.DefaultAdmin.Username != "" {
username = cfg.DefaultAdmin.Username
}
if cfg.DefaultAdmin.Password != "" {
password = cfg.DefaultAdmin.Password
}
if cfg.DefaultAdmin.Phone != "" {
phone = cfg.DefaultAdmin.Phone
}
account := &model.Account{
Username: username,
Phone: phone,
Password: password,
UserType: constants.UserTypeSuperAdmin,
Status: constants.StatusEnabled,
}
if err := services.Account.CreateSystemAccount(ctx, account); err != nil {
logger.Error("创建默认超级管理员失败", zap.Error(err), zap.String("username", username))
return nil
}
logger.Info("默认超级管理员创建成功", zap.String("username", username), zap.String("phone", phone))
return nil
}

View File

@@ -2,6 +2,7 @@ package bootstrap
import ( import (
pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm" pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"go.uber.org/zap"
) )
// BootstrapResult Bootstrap 初始化结果 // BootstrapResult Bootstrap 初始化结果
@@ -17,8 +18,9 @@ type BootstrapResult struct {
// 1. 初始化 Store 层(数据访问) // 1. 初始化 Store 层(数据访问)
// 2. 注册 GORM Callbacks数据权限过滤等- 需要 AccountStore // 2. 注册 GORM Callbacks数据权限过滤等- 需要 AccountStore
// 3. 初始化 Service 层(业务逻辑) // 3. 初始化 Service 层(业务逻辑)
// 4. 初始化 Middleware 层(中间件 // 4. 初始化默认超级管理员(如果不存在
// 5. 初始化 Handler 层HTTP 处理 // 5. 初始化 Middleware 层(中间件
// 6. 初始化 Handler 层HTTP 处理)
// //
// 参数: // 参数:
// - deps: 基础依赖DB, Redis, Logger // - deps: 基础依赖DB, Redis, Logger
@@ -38,10 +40,15 @@ func Bootstrap(deps *Dependencies) (*BootstrapResult, error) {
// 3. 初始化 Service 层 // 3. 初始化 Service 层
services := initServices(stores, deps) services := initServices(stores, deps)
// 4. 初始化 Middleware 层 // 4. 初始化默认超级管理员(降级处理:失败不中断启动)
if err := initDefaultAdmin(deps, services); err != nil {
deps.Logger.Error("初始化默认超级管理员失败", zap.Error(err))
}
// 5. 初始化 Middleware 层
middlewares := initMiddlewares(deps) middlewares := initMiddlewares(deps)
// 5. 初始化 Handler 层 // 6. 初始化 Handler 层
handlers := initHandlers(services, deps) handlers := initHandlers(services, deps)
return &BootstrapResult{ return &BootstrapResult{

View File

@@ -14,6 +14,7 @@ type Dependencies struct {
DB *gorm.DB // PostgreSQL 数据库连接 DB *gorm.DB // PostgreSQL 数据库连接
Redis *redis.Client // Redis 客户端 Redis *redis.Client // Redis 客户端
Logger *zap.Logger // 应用日志器 Logger *zap.Logger // 应用日志器
JWTManager *auth.JWTManager // JWT 管理器 JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
TokenManager *auth.TokenManager // Token 管理器后台和H5认证
VerificationService *verification.Service // 验证码服务 VerificationService *verification.Service // 验证码服务
} }

View File

@@ -3,15 +3,22 @@ package bootstrap
import ( import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/go-playground/validator/v10"
) )
// initHandlers 初始化所有 Handler 实例 // initHandlers 初始化所有 Handler 实例
func initHandlers(svc *services, deps *Dependencies) *Handlers { func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
return &Handlers{ return &Handlers{
Account: admin.NewAccountHandler(svc.Account), Account: admin.NewAccountHandler(svc.Account),
Role: admin.NewRoleHandler(svc.Role), Role: admin.NewRoleHandler(svc.Role),
Permission: admin.NewPermissionHandler(svc.Permission), Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
// TODO: 新增 Handler 在此初始化 Shop: admin.NewShopHandler(svc.Shop),
ShopAccount: admin.NewShopAccountHandler(svc.ShopAccount),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
} }
} }

View File

@@ -1,9 +1,16 @@
package bootstrap package bootstrap
import ( import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/pkg/auth" pkgauth "github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/gofiber/fiber/v2"
) )
// initMiddlewares 初始化所有中间件 // initMiddlewares 初始化所有中间件
@@ -12,12 +19,76 @@ func initMiddlewares(deps *Dependencies) *Middlewares {
cfg := config.Get() cfg := config.Get()
// 创建 JWT Manager // 创建 JWT Manager
jwtManager := auth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration) jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
// 创建个人客户认证中间件 // 创建个人客户认证中间件
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger) personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
// 创建 Token Manager用于后台和H5认证
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
tokenManager := pkgauth.NewTokenManager(deps.Redis, accessTTL, refreshTTL)
// 创建后台认证中间件
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager)
// 创建H5认证中间件
h5AuthMiddleware := createH5AuthMiddleware(tokenManager)
return &Middlewares{ return &Middlewares{
PersonalAuth: personalAuthMiddleware, PersonalAuth: personalAuthMiddleware,
AdminAuth: adminAuthMiddleware,
H5Auth: h5AuthMiddleware,
} }
} }
func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager) fiber.Handler {
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
// 检查用户类型:后台允许 SuperAdmin(1), Platform(2), Agent(3)
if tokenInfo.UserType != constants.UserTypeSuperAdmin &&
tokenInfo.UserType != constants.UserTypePlatform &&
tokenInfo.UserType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{"/api/admin/login", "/api/admin/refresh-token"},
})
}
func createH5AuthMiddleware(tokenManager *pkgauth.TokenManager) fiber.Handler {
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
// 检查用户类型H5 允许 Agent(3), Enterprise(4)
if tokenInfo.UserType != constants.UserTypeAgent &&
tokenInfo.UserType != constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{"/api/h5/login", "/api/h5/refresh-token"},
})
}

View File

@@ -2,9 +2,12 @@ package bootstrap
import ( import (
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission" permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer" personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role" roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
shopAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
) )
// services 封装所有 Service 实例 // services 封装所有 Service 实例
@@ -14,7 +17,9 @@ type services struct {
Role *roleSvc.Service Role *roleSvc.Service
Permission *permissionSvc.Service Permission *permissionSvc.Service
PersonalCustomer *personalCustomerSvc.Service PersonalCustomer *personalCustomerSvc.Service
// TODO: 新增 Service 在此添加字段 Shop *shopSvc.Service
ShopAccount *shopAccountSvc.Service
Auth *authSvc.Service
} }
// initServices 初始化所有 Service 实例 // initServices 初始化所有 Service 实例
@@ -22,8 +27,10 @@ func initServices(s *stores, deps *Dependencies) *services {
return &services{ return &services{
Account: accountSvc.New(s.Account, s.Role, s.AccountRole), Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission), Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger), PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger),
// TODO: 新增 Service 在此初始化 Shop: shopSvc.New(s.Shop, s.Account),
ShopAccount: shopAccountSvc.New(s.Account, s.Shop),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger),
} }
} }

View File

@@ -25,8 +25,8 @@ func initStores(deps *Dependencies) *stores {
Shop: postgres.NewShopStore(deps.DB, deps.Redis), Shop: postgres.NewShopStore(deps.DB, deps.Redis),
Role: postgres.NewRoleStore(deps.DB), Role: postgres.NewRoleStore(deps.DB),
Permission: postgres.NewPermissionStore(deps.DB), Permission: postgres.NewPermissionStore(deps.DB),
AccountRole: postgres.NewAccountRoleStore(deps.DB), AccountRole: postgres.NewAccountRoleStore(deps.DB, deps.Redis),
RolePermission: postgres.NewRolePermissionStore(deps.DB), RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
// TODO: 新增 Store 在此初始化 // TODO: 新增 Store 在此初始化

View File

@@ -3,7 +3,9 @@ package bootstrap
import ( import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/gofiber/fiber/v2"
) )
// Handlers 封装所有 HTTP 处理器 // Handlers 封装所有 HTTP 处理器
@@ -13,12 +15,17 @@ type Handlers struct {
Role *admin.RoleHandler Role *admin.RoleHandler
Permission *admin.PermissionHandler Permission *admin.PermissionHandler
PersonalCustomer *app.PersonalCustomerHandler PersonalCustomer *app.PersonalCustomerHandler
// TODO: 新增 Handler 在此添加字段 Shop *admin.ShopHandler
ShopAccount *admin.ShopAccountHandler
AdminAuth *admin.AuthHandler
H5Auth *h5.AuthHandler
} }
// Middlewares 封装所有中间件 // Middlewares 封装所有中间件
// 用于路由注册 // 用于路由注册
type Middlewares struct { type Middlewares struct {
PersonalAuth *middleware.PersonalAuthMiddleware PersonalAuth *middleware.PersonalAuthMiddleware
AdminAuth func(*fiber.Ctx) error
H5Auth func(*fiber.Ctx) error
// TODO: 新增 Middleware 在此添加字段 // TODO: 新增 Middleware 在此添加字段
} }

View File

@@ -164,3 +164,59 @@ func (h *AccountHandler) RemoveRole(c *fiber.Ctx) error {
return response.Success(c, nil) return response.Success(c, nil)
} }
// UpdatePassword 修改账号密码
// PUT /api/admin/platform-accounts/:id/password
func (h *AccountHandler) UpdatePassword(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdatePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdatePassword(c.UserContext(), uint(id), req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}
// UpdateStatus 修改账号状态
// PUT /api/admin/platform-accounts/:id/status
func (h *AccountHandler) UpdateStatus(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdateStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateStatus(c.UserContext(), uint(id), req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// ListPlatformAccounts 查询平台账号列表
// GET /api/admin/platform-accounts
func (h *AccountHandler) ListPlatformAccounts(c *fiber.Ctx) error {
var req model.PlatformAccountListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
accounts, total, err := h.service.ListPlatformAccounts(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, accounts, total, req.Page, req.PageSize)
}

View File

@@ -0,0 +1,143 @@
package admin
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
// AuthHandler 后台认证处理器
type AuthHandler struct {
authService *auth.Service
validator *validator.Validate
}
// NewAuthHandler 创建后台认证处理器
func NewAuthHandler(authService *auth.Service, validator *validator.Validate) *AuthHandler {
return &AuthHandler{
authService: authService,
validator: validator,
}
}
// Login 后台登录
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req model.LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
clientIP := c.IP()
ctx := c.UserContext()
resp, err := h.authService.Login(ctx, &req, clientIP)
if err != nil {
return err
}
return response.Success(c, resp)
}
// Logout 后台登出
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
auth := c.Get("Authorization")
accessToken := ""
if len(auth) > 7 && auth[:7] == "Bearer " {
accessToken = auth[7:]
}
refreshToken := ""
var req model.RefreshTokenRequest
if err := c.BodyParser(&req); err == nil {
refreshToken = req.RefreshToken
}
ctx := c.UserContext()
if err := h.authService.Logout(ctx, accessToken, refreshToken); err != nil {
return err
}
return response.Success(c, nil)
}
// RefreshToken 刷新访问令牌
func (h *AuthHandler) RefreshToken(c *fiber.Ctx) error {
var req model.RefreshTokenRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
ctx := c.UserContext()
newAccessToken, err := h.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return err
}
resp := &model.RefreshTokenResponse{
AccessToken: newAccessToken,
ExpiresIn: 86400,
}
return response.Success(c, resp)
}
// GetMe 获取当前用户信息
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
ctx := c.UserContext()
userInfo, permissions, err := h.authService.GetCurrentUser(ctx, userID)
if err != nil {
return err
}
data := map[string]interface{}{
"user": userInfo,
"permissions": permissions,
}
return response.Success(c, data)
}
// ChangePassword 修改密码
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
var req model.ChangePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
ctx := c.UserContext()
if err := h.authService.ChangePassword(ctx, userID, req.OldPassword, req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -109,7 +109,15 @@ func (h *PermissionHandler) List(c *fiber.Ctx) error {
// GetTree 获取权限树 // GetTree 获取权限树
// GET /api/v1/permissions/tree // GET /api/v1/permissions/tree
func (h *PermissionHandler) GetTree(c *fiber.Ctx) error { func (h *PermissionHandler) GetTree(c *fiber.Ctx) error {
tree, err := h.service.GetTree(c.UserContext()) var availableForRoleType *int
if roleTypeStr := c.Query("available_for_role_type"); roleTypeStr != "" {
roleType, err := strconv.Atoi(roleTypeStr)
if err == nil && (roleType == 1 || roleType == 2) {
availableForRoleType = &roleType
}
}
tree, err := h.service.GetTree(c.UserContext(), availableForRoleType)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -162,3 +162,23 @@ func (h *RoleHandler) RemovePermission(c *fiber.Ctx) error {
return response.Success(c, nil) return response.Success(c, nil)
} }
// UpdateStatus 更新角色状态
// PUT /api/v1/roles/:id/status
func (h *RoleHandler) UpdateStatus(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的角色 ID")
}
var req model.UpdateRoleStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateStatus(c.UserContext(), uint(id), req.Status); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,80 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type ShopHandler struct {
service *shopService.Service
}
func NewShopHandler(service *shopService.Service) *ShopHandler {
return &ShopHandler{service: service}
}
func (h *ShopHandler) List(c *fiber.Ctx) error {
var req model.ShopListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, shops, total, req.Page, req.PageSize)
}
func (h *ShopHandler) Create(c *fiber.Ctx) error {
var req model.CreateShopRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
shop, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, shop)
}
func (h *ShopHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
var req model.UpdateShopRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
shop, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, shop)
}
func (h *ShopHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,103 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
shopAccountService "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type ShopAccountHandler struct {
service *shopAccountService.Service
}
func NewShopAccountHandler(service *shopAccountService.Service) *ShopAccountHandler {
return &ShopAccountHandler{service: service}
}
func (h *ShopAccountHandler) List(c *fiber.Ctx) error {
var req model.ShopAccountListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
accounts, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, accounts, total, req.Page, req.PageSize)
}
func (h *ShopAccountHandler) Create(c *fiber.Ctx) error {
var req model.CreateShopAccountRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
account, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, account)
}
func (h *ShopAccountHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdateShopAccountRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
account, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, account)
}
func (h *ShopAccountHandler) UpdatePassword(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdateShopAccountPasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdatePassword(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *ShopAccountHandler) UpdateStatus(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdateShopAccountStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateStatus(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}

143
internal/handler/h5/auth.go Normal file
View File

@@ -0,0 +1,143 @@
package h5
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
// AuthHandler H5认证处理器
type AuthHandler struct {
authService *auth.Service
validator *validator.Validate
}
// NewAuthHandler 创建H5认证处理器
func NewAuthHandler(authService *auth.Service, validator *validator.Validate) *AuthHandler {
return &AuthHandler{
authService: authService,
validator: validator,
}
}
// Login H5登录
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req model.LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
clientIP := c.IP()
ctx := c.UserContext()
resp, err := h.authService.Login(ctx, &req, clientIP)
if err != nil {
return err
}
return response.Success(c, resp)
}
// Logout H5登出
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
auth := c.Get("Authorization")
accessToken := ""
if len(auth) > 7 && auth[:7] == "Bearer " {
accessToken = auth[7:]
}
refreshToken := ""
var req model.RefreshTokenRequest
if err := c.BodyParser(&req); err == nil {
refreshToken = req.RefreshToken
}
ctx := c.UserContext()
if err := h.authService.Logout(ctx, accessToken, refreshToken); err != nil {
return err
}
return response.Success(c, nil)
}
// RefreshToken 刷新访问令牌
func (h *AuthHandler) RefreshToken(c *fiber.Ctx) error {
var req model.RefreshTokenRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
ctx := c.UserContext()
newAccessToken, err := h.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return err
}
resp := &model.RefreshTokenResponse{
AccessToken: newAccessToken,
ExpiresIn: 86400,
}
return response.Success(c, resp)
}
// GetMe 获取当前用户信息
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
ctx := c.UserContext()
userInfo, permissions, err := h.authService.GetCurrentUser(ctx, userID)
if err != nil {
return err
}
data := map[string]interface{}{
"user": userInfo,
"permissions": permissions,
}
return response.Success(c, data)
}
// ChangePassword 修改密码
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
var req model.ChangePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
}
ctx := c.UserContext()
if err := h.authService.ChangePassword(ctx, userID, req.OldPassword, req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -44,8 +44,9 @@ type AccountResponse struct {
} }
// AssignRolesRequest 分配角色请求 // AssignRolesRequest 分配角色请求
// 支持传递空数组以清空账号的所有角色
type AssignRolesRequest struct { type AssignRolesRequest struct {
RoleIDs []uint `json:"role_ids" validate:"required,min=1" required:"true" minItems:"1" description:"角色ID列表"` RoleIDs []uint `json:"role_ids" validate:"omitempty" description:"角色ID列表,传空数组可清空所有角色"`
} }
// AccountPageResult 账号分页响应 // AccountPageResult 账号分页响应
@@ -55,3 +56,39 @@ type AccountPageResult struct {
Page int `json:"page" description:"当前页码"` Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"` Size int `json:"size" description:"每页数量"`
} }
// ========== 平台账号管理专用 DTO ==========
// UpdatePasswordRequest 修改密码请求
// 用于管理员重置密码场景,无需验证旧密码
type UpdatePasswordRequest struct {
NewPassword string `json:"new_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"新密码8-32位"`
}
// UpdateStatusRequest 状态切换请求
// 用于启用/禁用账号
type UpdateStatusRequest struct {
Status int `json:"status" validate:"required,min=0,max=1" required:"true" minimum:"0" maximum:"1" description:"状态0:禁用1:启用)"`
}
// PlatformAccountListRequest 平台账号列表查询请求
// 自动筛选 user_type IN (1, 2) 的账号
type PlatformAccountListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Username string `json:"username" query:"username" validate:"omitempty,max=50" maxLength:"50" description:"用户名模糊查询"`
Phone string `json:"phone" query:"phone" validate:"omitempty,max=20" maxLength:"20" description:"手机号模糊查询"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态"`
}
// UpdatePasswordParams 修改密码参数(用于 OpenAPI 生成)
type UpdatePasswordParams struct {
IDReq
UpdatePasswordRequest
}
// UpdateStatusParams 状态切换参数(用于 OpenAPI 生成)
type UpdateStatusParams struct {
IDReq
UpdateStatusRequest
}

View File

@@ -0,0 +1,41 @@
package model
type LoginRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Device string `json:"device" validate:"omitempty,oneof=web h5 mobile"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
Permissions []string `json:"permissions"`
}
type UserInfo struct {
ID uint `json:"id"`
Username string `json:"username"`
Phone string `json:"phone"`
UserType int `json:"user_type"`
UserTypeName string `json:"user_type_name"`
ShopID uint `json:"shop_id,omitempty"`
ShopName string `json:"shop_name,omitempty"`
EnterpriseID uint `json:"enterprise_id,omitempty"`
EnterpriseName string `json:"enterprise_name,omitempty"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
type RefreshTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8,max=32"`
}

View File

@@ -0,0 +1,71 @@
package model
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
// PackageSnapshot 套餐快照,记录换卡时的套餐信息
type PackageSnapshot struct {
PackageID uint `json:"package_id"` // 套餐ID
PackageName string `json:"package_name"` // 套餐名称
PackageType string `json:"package_type"` // 套餐类型
DataQuota int64 `json:"data_quota"` // 流量额度KB
DataUsed int64 `json:"data_used"` // 已使用流量KB
ValidFrom time.Time `json:"valid_from"` // 生效时间
ValidTo time.Time `json:"valid_to"` // 失效时间
Price int64 `json:"price"` // 套餐价格(分)
RemainingDays int `json:"remaining_days"` // 剩余天数
TransferReason string `json:"transfer_reason,omitempty"` // 转移原因
}
// Value 实现 driver.Valuer 接口
func (p PackageSnapshot) Value() (driver.Value, error) {
return json.Marshal(p)
}
// Scan 实现 sql.Scanner 接口
func (p *PackageSnapshot) Scan(value interface{}) error {
if value == nil {
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, p)
}
// CardReplacementRecord 换卡记录模型
// 记录物联卡更换历史,包含套餐快照便于追溯
// 支持损坏、丢失、故障等多种换卡原因,需要审批流程
type CardReplacementRecord struct {
gorm.Model
BaseModel `gorm:"embedded"`
ReplacementNo string `gorm:"column:replacement_no;type:varchar(50);not null;uniqueIndex:idx_card_replacement_no,where:deleted_at IS NULL;comment:换卡单号" json:"replacement_no"`
OldCardID uint `gorm:"column:old_card_id;not null;index:idx_card_replacement_old_card;comment:老卡ID" json:"old_card_id"`
OldIccid string `gorm:"column:old_iccid;type:varchar(50);not null;comment:老卡ICCID" json:"old_iccid"`
NewCardID uint `gorm:"column:new_card_id;not null;index:idx_card_replacement_new_card;comment:新卡ID" json:"new_card_id"`
NewIccid string `gorm:"column:new_iccid;type:varchar(50);not null;comment:新卡ICCID" json:"new_iccid"`
OldOwnerType string `gorm:"column:old_owner_type;type:varchar(20);not null;index:idx_card_replacement_old_owner,priority:1;comment:老卡所有者类型" json:"old_owner_type"`
OldOwnerID uint `gorm:"column:old_owner_id;not null;index:idx_card_replacement_old_owner,priority:2;comment:老卡所有者ID" json:"old_owner_id"`
OldAgentID *uint `gorm:"column:old_agent_id;comment:老卡代理ID" json:"old_agent_id,omitempty"`
NewOwnerType string `gorm:"column:new_owner_type;type:varchar(20);not null;index:idx_card_replacement_new_owner,priority:1;comment:新卡所有者类型" json:"new_owner_type"`
NewOwnerID uint `gorm:"column:new_owner_id;not null;index:idx_card_replacement_new_owner,priority:2;comment:新卡所有者ID" json:"new_owner_id"`
NewAgentID *uint `gorm:"column:new_agent_id;comment:新卡代理ID" json:"new_agent_id,omitempty"`
PackageSnapshot *PackageSnapshot `gorm:"column:package_snapshot;type:jsonb;comment:套餐快照" json:"package_snapshot,omitempty"`
ReplacementReason string `gorm:"column:replacement_reason;type:varchar(20);not null;comment:换卡原因 damaged-损坏 lost-丢失 malfunction-故障 upgrade-升级 other-其他" json:"replacement_reason"`
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_card_replacement_status;comment:换卡状态 1-待审批 2-已通过 3-已拒绝 4-已完成" json:"status"`
ApprovedBy *uint `gorm:"column:approved_by;comment:审批人ID" json:"approved_by,omitempty"`
ApprovedAt *time.Time `gorm:"column:approved_at;comment:审批时间" json:"approved_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
}
// TableName 指定表名
func (CardReplacementRecord) TableName() string {
return "tb_card_replacement_record"
}

View File

@@ -1,20 +1,22 @@
package model package model
import "time" import (
"gorm.io/gorm"
)
// Carrier 运营商模型
// 存储运营商基础信息(中国移动、中国联通、中国电信)
type Carrier struct { type Carrier struct {
ID uint `gorm:"column:id;primaryKey;comment:运营商ID" json:"id"` gorm.Model
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex;not null;comment:运营商编码(CMCC/CUCC/CTCC)" json:"carrier_code"` BaseModel `gorm:"embedded"`
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码(CMCC/CUCC/CTCC)" json:"carrier_code"`
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称(中国移动/中国联通/中国电信)" json:"carrier_name"` CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称(中国移动/中国联通/中国电信)" json:"carrier_name"`
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';uniqueIndex:idx_carrier_type_channel,priority:1,where:deleted_at IS NULL;comment:运营商类型" json:"carrier_type"`
ChannelName *string `gorm:"column:channel_name;type:varchar(100);comment:渠道名称" json:"channel_name,omitempty"`
ChannelCode *string `gorm:"column:channel_code;type:varchar(50);uniqueIndex:idx_carrier_type_channel,priority:2,where:deleted_at IS NULL;comment:渠道编码" json:"channel_code,omitempty"`
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"` Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (Carrier) TableName() string { func (Carrier) TableName() string {
return "carriers" return "tb_carrier"
} }

View File

@@ -1,164 +1,160 @@
package model package model
import "time" import (
"time"
"gorm.io/gorm"
)
// AgentHierarchy 代理层级关系模型 // AgentHierarchy 代理层级关系模型
// 树形代理关系(每个代理只有一个上级) // 树形代理关系(每个代理只有一个上级)
type AgentHierarchy struct { type AgentHierarchy struct {
ID uint `gorm:"column:id;primaryKey;comment:代理层级ID" json:"id"` gorm.Model
AgentID uint `gorm:"column:agent_id;type:bigint;uniqueIndex;not null;comment:代理用户ID" json:"agent_id"` BaseModel `gorm:"embedded"`
ParentAgentID uint `gorm:"column:parent_agent_id;type:bigint;comment:上级代理用户ID(NULL表示顶级代理)" json:"parent_agent_id"` AgentID uint `gorm:"column:agent_id;uniqueIndex:idx_agent_hierarchy_agent,where:deleted_at IS NULL;not null;comment:代理用户ID" json:"agent_id"`
ParentAgentID uint `gorm:"column:parent_agent_id;index;comment:上级代理用户ID(NULL表示顶级代理)" json:"parent_agent_id"`
Level int `gorm:"column:level;type:int;not null;comment:代理层级(1, 2, 3...)" json:"level"` Level int `gorm:"column:level;type:int;not null;comment:代理层级(1, 2, 3...)" json:"level"`
Path string `gorm:"column:path;type:varchar(500);comment:代理路径(如: 1/5/12)" json:"path"` Path string `gorm:"column:path;type:varchar(500);comment:代理路径(如: 1/5/12)" json:"path"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (AgentHierarchy) TableName() string { func (AgentHierarchy) TableName() string {
return "agent_hierarchies" return "tb_agent_hierarchy"
} }
// CommissionRule 分佣规则模型 // CommissionRule 分佣规则模型
// 三种分佣类型:一次性/长期/组合 // 三种分佣类型:一次性/长期/组合
type CommissionRule struct { type CommissionRule struct {
ID uint `gorm:"column:id;primaryKey;comment:分佣规则ID" json:"id"` gorm.Model
AgentID uint `gorm:"column:agent_id;type:bigint;not null;comment:代理用户ID" json:"agent_id"` BaseModel `gorm:"embedded"`
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"` BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"` CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
SeriesID uint `gorm:"column:series_id;type:bigint;comment:套餐系列ID(一次性分佣时用)" json:"series_id"` SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID(一次性分佣时用)" json:"series_id"`
PackageID uint `gorm:"column:package_id;type:bigint;comment:套餐ID(长期分佣时用)" json:"package_id"` PackageID uint `gorm:"column:package_id;index;comment:套餐ID(长期分佣时用)" json:"package_id"`
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"` CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"` CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"` CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"` UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"` MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"` ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionRule) TableName() string { func (CommissionRule) TableName() string {
return "commission_rules" return "tb_commission_rule"
} }
// CommissionLadder 阶梯分佣配置模型 // CommissionLadder 阶梯分佣配置模型
// 支持按激活量、提货量、充值量设置阶梯佣金 // 支持按激活量、提货量、充值量设置阶梯佣金
type CommissionLadder struct { type CommissionLadder struct {
ID uint `gorm:"column:id;primaryKey;comment:阶梯分佣配置ID" json:"id"` gorm.Model
RuleID uint `gorm:"column:rule_id;type:bigint;not null;comment:分佣规则ID" json:"rule_id"` BaseModel `gorm:"embedded"`
RuleID uint `gorm:"column:rule_id;index;not null;comment:分佣规则ID" json:"rule_id"`
LadderType string `gorm:"column:ladder_type;type:varchar(50);not null;comment:阶梯类型 activation-激活量 pickup-提货量 deposit-充值量" json:"ladder_type"` LadderType string `gorm:"column:ladder_type;type:varchar(50);not null;comment:阶梯类型 activation-激活量 pickup-提货量 deposit-充值量" json:"ladder_type"`
ThresholdValue int `gorm:"column:threshold_value;type:int;not null;comment:阈值" json:"threshold_value"` ThresholdValue int `gorm:"column:threshold_value;type:int;not null;comment:阈值" json:"threshold_value"`
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"` CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"` CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionLadder) TableName() string { func (CommissionLadder) TableName() string {
return "commission_ladder" return "tb_commission_ladder"
} }
// CommissionCombinedCondition 组合分佣条件模型 // CommissionCombinedCondition 组合分佣条件模型
// 支持时间点 OR 套餐周期阈值的 OR 条件解冻 // 支持时间点 OR 套餐周期阈值的 OR 条件解冻
type CommissionCombinedCondition struct { type CommissionCombinedCondition struct {
ID uint `gorm:"column:id;primaryKey;comment:组合分佣条件ID" json:"id"` gorm.Model
RuleID uint `gorm:"column:rule_id;type:bigint;uniqueIndex;not null;comment:分佣规则ID" json:"rule_id"` BaseModel `gorm:"embedded"`
RuleID uint `gorm:"column:rule_id;uniqueIndex:idx_commission_combined_rule,where:deleted_at IS NULL;not null;comment:分佣规则ID" json:"rule_id"`
OneTimeCommissionMode string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:一次性分佣模式 fixed-固定金额 percent-百分比" json:"one_time_commission_mode"` OneTimeCommissionMode string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:一次性分佣模式 fixed-固定金额 percent-百分比" json:"one_time_commission_mode"`
OneTimeCommissionValue float64 `gorm:"column:one_time_commission_value;type:decimal(10,2);comment:一次性分佣值" json:"one_time_commission_value"` OneTimeCommissionValue int64 `gorm:"column:one_time_commission_value;type:bigint;comment:一次性分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"one_time_commission_value"`
LongTermCommissionMode string `gorm:"column:long_term_commission_mode;type:varchar(20);comment:长期分佣模式 fixed-固定金额 percent-百分比" json:"long_term_commission_mode"` LongTermCommissionMode string `gorm:"column:long_term_commission_mode;type:varchar(20);comment:长期分佣模式 fixed-固定金额 percent-百分比" json:"long_term_commission_mode"`
LongTermCommissionValue float64 `gorm:"column:long_term_commission_value;type:decimal(10,2);comment:长期分佣值" json:"long_term_commission_value"` LongTermCommissionValue int64 `gorm:"column:long_term_commission_value;type:bigint;comment:长期分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"long_term_commission_value"`
LongTermTriggerTimePoint *time.Time `gorm:"column:long_term_trigger_time_point;comment:长期分佣触发时间点(如实名后3个月)" json:"long_term_trigger_time_point"` LongTermTriggerTimePoint *time.Time `gorm:"column:long_term_trigger_time_point;comment:长期分佣触发时间点(如实名后3个月)" json:"long_term_trigger_time_point"`
LongTermTriggerPackageCycles int `gorm:"column:long_term_trigger_package_cycles;type:int;comment:长期分佣触发套餐周期数(如10个套餐周期)" json:"long_term_trigger_package_cycles"` LongTermTriggerPackageCycles int `gorm:"column:long_term_trigger_package_cycles;type:int;comment:长期分佣触发套餐周期数(如10个套餐周期)" json:"long_term_trigger_package_cycles"`
LongTermTriggerNetworkMonths int `gorm:"column:long_term_trigger_network_months;type:int;comment:长期分佣触发在网月数(号卡专用)" json:"long_term_trigger_network_months"` LongTermTriggerNetworkMonths int `gorm:"column:long_term_trigger_network_months;type:int;comment:长期分佣触发在网月数(号卡专用)" json:"long_term_trigger_network_months"`
LongTermUnfreezeDays int `gorm:"column:long_term_unfreeze_days;type:int;default:0;comment:长期分佣解冻天数" json:"long_term_unfreeze_days"` LongTermUnfreezeDays int `gorm:"column:long_term_unfreeze_days;type:int;default:0;comment:长期分佣解冻天数" json:"long_term_unfreeze_days"`
LongTermMinActivation int `gorm:"column:long_term_min_activation;type:int;default:0;comment:长期分佣解冻最小激活量" json:"long_term_min_activation"` LongTermMinActivation int `gorm:"column:long_term_min_activation;type:int;default:0;comment:长期分佣解冻最小激活量" json:"long_term_min_activation"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionCombinedCondition) TableName() string { func (CommissionCombinedCondition) TableName() string {
return "commission_combined_conditions" return "tb_commission_combined_condition"
} }
// CommissionRecord 分佣记录模型 // CommissionRecord 分佣记录模型
// 记录分佣的冻结、解冻、发放状态 // 记录分佣的冻结、解冻、发放状态
type CommissionRecord struct { type CommissionRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:分佣记录ID" json:"id"` gorm.Model
AgentID uint `gorm:"column:agent_id;type:bigint;not null;comment:代理用户ID" json:"agent_id"` BaseModel `gorm:"embedded"`
OrderID uint `gorm:"column:order_id;type:bigint;not null;comment:订单ID" json:"order_id"` AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
RuleID uint `gorm:"column:rule_id;type:bigint;not null;comment:分佣规则ID" json:"rule_id"` OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"`
RuleID uint `gorm:"column:rule_id;index;not null;comment:分佣规则ID" json:"rule_id"`
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期" json:"commission_type"` CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期" json:"commission_type"`
Amount float64 `gorm:"column:amount;type:decimal(10,2);not null;comment:分佣金额()" json:"amount"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:分佣金额(分为单位)" json:"amount"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效" json:"status"`
UnfrozenAt *time.Time `gorm:"column:unfrozen_at;comment:解冻时间" json:"unfrozen_at"` UnfrozenAt *time.Time `gorm:"column:unfrozen_at;comment:解冻时间" json:"unfrozen_at"`
ReleasedAt *time.Time `gorm:"column:released_at;comment:发放时间" json:"released_at"` ReleasedAt *time.Time `gorm:"column:released_at;comment:发放时间" json:"released_at"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionRecord) TableName() string { func (CommissionRecord) TableName() string {
return "commission_records" return "tb_commission_record"
} }
// CommissionApproval 分佣审批模型 // CommissionApproval 分佣审批模型
// 分佣解冻审批流程 // 分佣解冻审批流程
type CommissionApproval struct { type CommissionApproval struct {
ID uint `gorm:"column:id;primaryKey;comment:分佣审批ID" json:"id"` gorm.Model
CommissionRecordID uint `gorm:"column:commission_record_id;type:bigint;not null;comment:分佣记录ID" json:"commission_record_id"` BaseModel `gorm:"embedded"`
ApproverID uint `gorm:"column:approver_id;type:bigint;comment:审批人用户ID" json:"approver_id"` CommissionRecordID uint `gorm:"column:commission_record_id;index;not null;comment:分佣记录ID" json:"commission_record_id"`
ApproverID uint `gorm:"column:approver_id;index;comment:审批人用户ID" json:"approver_id"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待审批 2-已通过 3-已拒绝" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待审批 2-已通过 3-已拒绝" json:"status"`
Reason string `gorm:"column:reason;type:text;comment:原因" json:"reason"` Reason string `gorm:"column:reason;type:text;comment:原因" json:"reason"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionApproval) TableName() string { func (CommissionApproval) TableName() string {
return "commission_approvals" return "tb_commission_approval"
} }
// CommissionTemplate 分佣模板模型 // CommissionTemplate 分佣模板模型
// 创建和管理分佣模板,快速为代理分配产品时设置佣金规则 // 创建和管理分佣模板,快速为代理分配产品时设置佣金规则
type CommissionTemplate struct { type CommissionTemplate struct {
ID uint `gorm:"column:id;primaryKey;comment:分佣模板ID" json:"id"` gorm.Model
TemplateName string `gorm:"column:template_name;type:varchar(255);uniqueIndex;not null;comment:模板名称" json:"template_name"` BaseModel `gorm:"embedded"`
TemplateName string `gorm:"column:template_name;type:varchar(255);uniqueIndex:idx_commission_template_name,where:deleted_at IS NULL;not null;comment:模板名称" json:"template_name"`
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"` BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"` CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"` CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"` CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"` CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"` UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"` MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"` ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionTemplate) TableName() string { func (CommissionTemplate) TableName() string {
return "commission_templates" return "tb_commission_template"
} }
// CarrierSettlement 号卡运营商结算模型 // CarrierSettlement 号卡运营商结算模型
// 运营商周期性结算的佣金总额,再分配给代理 // 运营商周期性结算的佣金总额,再分配给代理
type CarrierSettlement struct { type CarrierSettlement struct {
ID uint `gorm:"column:id;primaryKey;comment:运营商结算ID" json:"id"` gorm.Model
CommissionRecordID uint `gorm:"column:commission_record_id;type:bigint;uniqueIndex;not null;comment:分佣记录ID" json:"commission_record_id"` BaseModel `gorm:"embedded"`
AgentID uint `gorm:"column:agent_id;type:bigint;not null;comment:代理用户ID" json:"agent_id"` CommissionRecordID uint `gorm:"column:commission_record_id;uniqueIndex:idx_carrier_settlement_record,where:deleted_at IS NULL;not null;comment:分佣记录ID" json:"commission_record_id"`
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
SettlementMonth string `gorm:"column:settlement_month;type:varchar(20);not null;comment:结算月份(如 2026-01)" json:"settlement_month"` SettlementMonth string `gorm:"column:settlement_month;type:varchar(20);not null;comment:结算月份(如 2026-01)" json:"settlement_month"`
SettlementAmount float64 `gorm:"column:settlement_amount;type:decimal(18,2);not null;comment:结算金额()" json:"settlement_amount"` SettlementAmount int64 `gorm:"column:settlement_amount;type:bigint;not null;comment:结算金额(分为单位)" json:"settlement_amount"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待结算 2-已结算" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待结算 2-已结算" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CarrierSettlement) TableName() string { func (CarrierSettlement) TableName() string {
return "carrier_settlements" return "tb_carrier_settlement"
} }

View File

@@ -1,12 +1,15 @@
package model package model
import "time" import (
"time"
)
// DataUsageRecord 流量使用记录模型 // DataUsageRecord 流量使用记录模型
// 记录卡的流量历史,支持流量查询和分析 // 记录卡的流量历史,支持流量查询和分析
// 注意:此模型是日志表,不需要软删除和审计字段
type DataUsageRecord struct { type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"` ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;comment:IoT卡ID" json:"iot_card_id"` IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"` DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"` DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"` CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
@@ -16,5 +19,5 @@ type DataUsageRecord struct {
// TableName 指定表名 // TableName 指定表名
func (DataUsageRecord) TableName() string { func (DataUsageRecord) TableName() string {
return "data_usage_records" return "tb_data_usage_record"
} }

View File

@@ -1,13 +1,18 @@
package model package model
import "time" import (
"time"
"gorm.io/gorm"
)
// Device 设备模型 // Device 设备模型
// 用户的物联网设备(如 GPS 追踪器、智能传感器) // 用户的物联网设备(如 GPS 追踪器、智能传感器)
// 可绑定 1-4 张 IoT 卡,主要用于批量管理和设备操作 // 可绑定 1-4 张 IoT 卡,主要用于批量管理和设备操作
type Device struct { type Device struct {
ID uint `gorm:"column:id;primaryKey;comment:设备ID" json:"id"` gorm.Model
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex;not null;comment:设备编号(唯一标识)" json:"device_no"` BaseModel `gorm:"embedded"`
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex:idx_device_no,where:deleted_at IS NULL;not null;comment:设备编号(唯一标识)" json:"device_no"`
DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"` DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"`
DeviceModel string `gorm:"column:device_model;type:varchar(100);comment:设备型号" json:"device_model"` DeviceModel string `gorm:"column:device_model;type:varchar(100);comment:设备型号" json:"device_model"`
DeviceType string `gorm:"column:device_type;type:varchar(50);comment:设备类型" json:"device_type"` DeviceType string `gorm:"column:device_type;type:varchar(50);comment:设备类型" json:"device_type"`
@@ -15,17 +20,15 @@ type Device struct {
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"` Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户" json:"owner_type"` OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户" json:"owner_type"`
OwnerID uint `gorm:"column:owner_id;type:bigint;default:0;not null;comment:所有者ID" json:"owner_id"` OwnerID uint `gorm:"column:owner_id;index;default:0;not null;comment:所有者ID" json:"owner_id"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"` ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"` DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"`
DevicePasswordEncrypted string `gorm:"column:device_password_encrypted;type:varchar(255);comment:设备登录密码(加密)" json:"device_password_encrypted"` DevicePasswordEncrypted string `gorm:"column:device_password_encrypted;type:varchar(255);comment:设备登录密码(加密)" json:"device_password_encrypted"`
DeviceAPIEndpoint string `gorm:"column:device_api_endpoint;type:varchar(500);comment:设备API端点" json:"device_api_endpoint"` DeviceAPIEndpoint string `gorm:"column:device_api_endpoint;type:varchar(500);comment:设备API端点" json:"device_api_endpoint"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (Device) TableName() string { func (Device) TableName() string {
return "devices" return "tb_device"
} }

View File

@@ -3,55 +3,55 @@ package model
import ( import (
"time" "time"
"github.com/lib/pq" "gorm.io/datatypes"
"gorm.io/gorm"
) )
// CommissionWithdrawalRequest 佣金提现申请模型 // CommissionWithdrawalRequest 佣金提现申请模型
// 代理佣金提现申请、审批流程、提现记录查询 // 代理佣金提现申请、审批流程、提现记录查询
type CommissionWithdrawalRequest struct { type CommissionWithdrawalRequest struct {
ID uint `gorm:"column:id;primaryKey;comment:提现申请ID" json:"id"` gorm.Model
AgentID uint `gorm:"column:agent_id;type:bigint;not null;comment:代理用户ID" json:"agent_id"` BaseModel `gorm:"embedded"`
Amount float64 `gorm:"column:amount;type:decimal(18,2);not null;comment:提现金额(元)" json:"amount"` AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
Fee float64 `gorm:"column:fee;type:decimal(18,2);default:0;comment:手续费(元)" json:"fee"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:提现金额(分为单位)" json:"amount"`
ActualAmount float64 `gorm:"column:actual_amount;type:decimal(18,2);comment:实际到账金额(元)" json:"actual_amount"` Fee int64 `gorm:"column:fee;type:bigint;default:0;comment:手续费(分为单位)" json:"fee"`
ActualAmount int64 `gorm:"column:actual_amount;type:bigint;comment:实际到账金额(分为单位)" json:"actual_amount"`
WithdrawalMethod string `gorm:"column:withdrawal_method;type:varchar(20);comment:提现方式 alipay-支付宝 wechat-微信 bank-银行卡" json:"withdrawal_method"` WithdrawalMethod string `gorm:"column:withdrawal_method;type:varchar(20);comment:提现方式 alipay-支付宝 wechat-微信 bank-银行卡" json:"withdrawal_method"`
AccountInfo pq.StringArray `gorm:"column:account_info;type:jsonb;comment:收款账户信息(姓名、账号等)" json:"account_info"` AccountInfo datatypes.JSON `gorm:"column:account_info;type:jsonb;comment:收款账户信息(姓名、账号等)" json:"account_info"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待审核 2-已通过 3-已拒绝 4-已到账" json:"status"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待审核 2-已通过 3-已拒绝 4-已到账" json:"status"`
ApprovedBy uint `gorm:"column:approved_by;type:bigint;comment:审批人用户ID" json:"approved_by"` ApprovedBy uint `gorm:"column:approved_by;index;comment:审批人用户ID" json:"approved_by"`
ApprovedAt *time.Time `gorm:"column:approved_at;comment:审批时间" json:"approved_at"` ApprovedAt *time.Time `gorm:"column:approved_at;comment:审批时间" json:"approved_at"`
PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"` PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"`
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"` RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionWithdrawalRequest) TableName() string { func (CommissionWithdrawalRequest) TableName() string {
return "commission_withdrawal_requests" return "tb_commission_withdrawal_request"
} }
// CommissionWithdrawalSetting 佣金提现设置模型 // CommissionWithdrawalSetting 佣金提现设置模型
// 提现参数配置(最低金额、手续费率、到账时间等) // 提现参数配置(最低金额、手续费率、到账时间等)
type CommissionWithdrawalSetting struct { type CommissionWithdrawalSetting struct {
ID uint `gorm:"column:id;primaryKey;comment:提现设置ID" json:"id"` gorm.Model
MinWithdrawalAmount float64 `gorm:"column:min_withdrawal_amount;type:decimal(10,2);comment:最低提现金额(元)" json:"min_withdrawal_amount"` BaseModel `gorm:"embedded"`
FeeRate float64 `gorm:"column:fee_rate;type:decimal(5,4);comment:手续费率(如 0.01 表示 1%)" json:"fee_rate"` MinWithdrawalAmount int64 `gorm:"column:min_withdrawal_amount;type:bigint;comment:最低提现金额(分为单位)" json:"min_withdrawal_amount"`
FeeRate int64 `gorm:"column:fee_rate;type:bigint;comment:手续费率(万分比,如100表示1%)" json:"fee_rate"`
ArrivalDays int `gorm:"column:arrival_days;type:int;comment:到账天数" json:"arrival_days"` ArrivalDays int `gorm:"column:arrival_days;type:int;comment:到账天数" json:"arrival_days"`
IsActive bool `gorm:"column:is_active;type:boolean;default:true;comment:是否生效(最新一条)" json:"is_active"` IsActive bool `gorm:"column:is_active;type:boolean;default:true;comment:是否生效(最新一条)" json:"is_active"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CommissionWithdrawalSetting) TableName() string { func (CommissionWithdrawalSetting) TableName() string {
return "commission_withdrawal_settings" return "tb_commission_withdrawal_setting"
} }
// PaymentMerchantSetting 收款商户设置模型 // PaymentMerchantSetting 收款商户设置模型
// 配置支付参数(支付宝、微信等收款账户) // 配置支付参数(支付宝、微信等收款账户)
type PaymentMerchantSetting struct { type PaymentMerchantSetting struct {
ID uint `gorm:"column:id;primaryKey;comment:收款商户ID" json:"id"` gorm.Model
UserID uint `gorm:"column:user_id;type:bigint;not null;comment:用户ID" json:"user_id"` BaseModel `gorm:"embedded"`
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
MerchantType string `gorm:"column:merchant_type;type:varchar(20);comment:商户类型 alipay-支付宝 wechat-微信 bank-银行卡" json:"merchant_type"` MerchantType string `gorm:"column:merchant_type;type:varchar(20);comment:商户类型 alipay-支付宝 wechat-微信 bank-银行卡" json:"merchant_type"`
AccountName string `gorm:"column:account_name;type:varchar(255);comment:账户名称" json:"account_name"` AccountName string `gorm:"column:account_name;type:varchar(255);comment:账户名称" json:"account_name"`
AccountNumber string `gorm:"column:account_number;type:varchar(255);comment:账号" json:"account_number"` AccountNumber string `gorm:"column:account_number;type:varchar(255);comment:账号" json:"account_number"`
@@ -60,11 +60,9 @@ type PaymentMerchantSetting struct {
IsVerified bool `gorm:"column:is_verified;type:boolean;default:false;comment:是否已验证" json:"is_verified"` IsVerified bool `gorm:"column:is_verified;type:boolean;default:false;comment:是否已验证" json:"is_verified"`
IsDefault bool `gorm:"column:is_default;type:boolean;default:false;comment:是否默认账户" json:"is_default"` IsDefault bool `gorm:"column:is_default;type:boolean;default:false;comment:是否默认账户" json:"is_default"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (PaymentMerchantSetting) TableName() string { func (PaymentMerchantSetting) TableName() string {
return "payment_merchant_settings" return "tb_payment_merchant_setting"
} }

View File

@@ -1,25 +1,30 @@
package model package model
import "time" import (
"time"
"gorm.io/gorm"
)
// IotCard IoT 卡模型 // IotCard IoT 卡模型
// 物联网卡/流量卡的统一管理实体 // 物联网卡/流量卡的统一管理实体
// 支持平台自营、代理分销、用户购买等所有权模式 // 支持平台自营、代理分销、用户购买等所有权模式
type IotCard struct { type IotCard struct {
ID uint `gorm:"column:id;primaryKey;comment:IoT 卡 ID" json:"id"` gorm.Model
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex;not null;comment:ICCID(唯一标识)" json:"iccid"` BaseModel `gorm:"embedded"`
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"` CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"` CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
CarrierID uint `gorm:"column:carrier_id;type:bigint;not null;comment:运营商ID" json:"carrier_id"` CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"` IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"`
MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"` MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
Supplier string `gorm:"column:supplier;type:varchar(255);comment:供应商" json:"supplier"` Supplier string `gorm:"column:supplier;type:varchar(255);comment:供应商" json:"supplier"`
CostPrice float64 `gorm:"column:cost_price;type:decimal(10,2);default:0;comment:成本价()" json:"cost_price"` CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
DistributePrice float64 `gorm:"column:distribute_price;type:decimal(10,2);default:0;comment:分销价()" json:"distribute_price"` DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户 device-设备" json:"owner_type"` OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户 device-设备" json:"owner_type"`
OwnerID uint `gorm:"column:owner_id;type:bigint;default:0;not null;comment:所有者ID" json:"owner_id"` OwnerID uint `gorm:"column:owner_id;index;default:0;not null;comment:所有者ID" json:"owner_id"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"` ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"` ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"`
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"` RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
@@ -29,11 +34,9 @@ type IotCard struct {
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"` LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"` LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"` LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (IotCard) TableName() string { func (IotCard) TableName() string {
return "iot_cards" return "tb_iot_card"
} }

View File

@@ -1,25 +1,26 @@
package model package model
import "time" import (
"gorm.io/gorm"
)
// NumberCard 号卡模型 // NumberCard 号卡模型
// 完全独立的业务线,从上游平台下单 // 完全独立的业务线,从上游平台下单
// 使用虚拟商品编码映射运营商订单 // 使用虚拟商品编码映射运营商订单
type NumberCard struct { type NumberCard struct {
ID uint `gorm:"column:id;primaryKey;comment:号卡ID" json:"id"` gorm.Model
VirtualProductCode string `gorm:"column:virtual_product_code;type:varchar(100);uniqueIndex;not null;comment:虚拟商品编码(用于对应运营商订单)" json:"virtual_product_code"` BaseModel `gorm:"embedded"`
VirtualProductCode string `gorm:"column:virtual_product_code;type:varchar(100);uniqueIndex:idx_number_card_code,where:deleted_at IS NULL;not null;comment:虚拟商品编码(用于对应运营商订单)" json:"virtual_product_code"`
CardName string `gorm:"column:card_name;type:varchar(255);not null;comment:号卡名称" json:"card_name"` CardName string `gorm:"column:card_name;type:varchar(255);not null;comment:号卡名称" json:"card_name"`
CardType string `gorm:"column:card_type;type:varchar(50);comment:号卡类型" json:"card_type"` CardType string `gorm:"column:card_type;type:varchar(50);comment:号卡类型" json:"card_type"`
Carrier string `gorm:"column:carrier;type:varchar(50);comment:运营商" json:"carrier"` Carrier string `gorm:"column:carrier;type:varchar(50);comment:运营商" json:"carrier"`
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;comment:流量额度(MB)" json:"data_amount_mb"` DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;comment:流量额度(MB)" json:"data_amount_mb"`
Price float64 `gorm:"column:price;type:decimal(10,2);comment:价格()" json:"price"` Price int64 `gorm:"column:price;type:bigint;comment:价格(分为单位)" json:"price"`
AgentID uint `gorm:"column:agent_id;type:bigint;comment:代理用户ID" json:"agent_id"` AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在售 2-下架" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在售 2-下架" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (NumberCard) TableName() string { func (NumberCard) TableName() string {
return "number_cards" return "tb_number_card"
} }

View File

@@ -3,33 +3,35 @@ package model
import ( import (
"time" "time"
"github.com/lib/pq" "gorm.io/datatypes"
"gorm.io/gorm"
) )
// Order 订单模型 // Order 订单模型
// 支持两种订单类型:套餐订单(单卡/设备级)、号卡订单 // 支持两种订单类型:套餐订单(单卡/设备级)、号卡订单
type Order struct { type Order struct {
ID uint `gorm:"column:id;primaryKey;comment:订单ID" json:"id"` gorm.Model
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex;not null;comment:订单号(唯一标识)" json:"order_no"` BaseModel `gorm:"embedded"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"` OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"` IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;type:bigint;comment:设备ID(设备级套餐订单时有值)" json:"device_id"` DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐订单时有值)" json:"device_id"`
NumberCardID uint `gorm:"column:number_card_id;type:bigint;comment:号卡ID(号卡订单时有值)" json:"number_card_id"` NumberCardID uint `gorm:"column:number_card_id;index;comment:号卡ID(号卡订单时有值)" json:"number_card_id"`
PackageID uint `gorm:"column:package_id;type:bigint;comment:套餐ID(套餐订单时有值)" json:"package_id"` PackageID uint `gorm:"column:package_id;index;comment:套餐ID(套餐订单时有值)" json:"package_id"`
UserID uint `gorm:"column:user_id;type:bigint;not null;comment:用户ID" json:"user_id"` UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
AgentID uint `gorm:"column:agent_id;type:bigint;comment:代理用户ID" json:"agent_id"` AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
Amount float64 `gorm:"column:amount;type:decimal(10,2);not null;comment:订单金额()" json:"amount"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分为单位)" json:"amount"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"` PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"`
WalletPaymentAmount int64 `gorm:"column:wallet_payment_amount;type:bigint;not null;default:0;comment:钱包支付金额(分)" json:"wallet_payment_amount"`
OnlinePaymentAmount int64 `gorm:"column:online_payment_amount;type:bigint;not null;default:0;comment:在线支付金额(分)" json:"online_payment_amount"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"` CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"`
CarrierOrderData pq.StringArray `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"` CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"` PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (Order) TableName() string { func (Order) TableName() string {
return "orders" return "tb_order"
} }

View File

@@ -1,94 +1,95 @@
package model package model
import "time" import (
"time"
"gorm.io/gorm"
)
// PackageSeries 套餐系列模型 // PackageSeries 套餐系列模型
// 套餐的分组,用于一次性分佣规则配置 // 套餐的分组,用于一次性分佣规则配置
type PackageSeries struct { type PackageSeries struct {
ID uint `gorm:"column:id;primaryKey;comment:套餐系列ID" json:"id"` gorm.Model
SeriesCode string `gorm:"column:series_code;type:varchar(100);uniqueIndex;not null;comment:系列编码" json:"series_code"` BaseModel `gorm:"embedded"`
SeriesCode string `gorm:"column:series_code;type:varchar(100);uniqueIndex:idx_package_series_code,where:deleted_at IS NULL;not null;comment:系列编码" json:"series_code"`
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"` SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"`
Description string `gorm:"column:description;type:text;comment:描述" json:"description"` Description string `gorm:"column:description;type:text;comment:描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (PackageSeries) TableName() string { func (PackageSeries) TableName() string {
return "package_series" return "tb_package_series"
} }
// Package 套餐模型 // Package 套餐模型
// 只适用于 IoT 卡,支持真流量/虚流量共存机制 // 只适用于 IoT 卡,支持真流量/虚流量共存机制
type Package struct { type Package struct {
ID uint `gorm:"column:id;primaryKey;comment:套餐ID" json:"id"` gorm.Model
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex;not null;comment:套餐编码" json:"package_code"` BaseModel `gorm:"embedded"`
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"` PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
SeriesID uint `gorm:"column:series_id;type:bigint;comment:套餐系列ID" json:"series_id"` SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"` PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"` DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"` DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"`
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"` RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"` VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"` DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
Price float64 `gorm:"column:price;type:decimal(10,2);not null;comment:套餐价格()" json:"price"` Price int64 `gorm:"column:price;type:bigint;not null;comment:套餐价格(分为单位)" json:"price"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (Package) TableName() string { func (Package) TableName() string {
return "packages" return "tb_package"
} }
// AgentPackageAllocation 代理套餐分配模型 // AgentPackageAllocation 代理套餐分配模型
// 为直属下级代理分配套餐,设置佣金模式 // 为直属下级代理分配套餐,设置佣金模式
type AgentPackageAllocation struct { type AgentPackageAllocation struct {
ID uint `gorm:"column:id;primaryKey;comment:分配ID" json:"id"` gorm.Model
AgentID uint `gorm:"column:agent_id;type:bigint;not null;comment:代理用户ID" json:"agent_id"` BaseModel `gorm:"embedded"`
PackageID uint `gorm:"column:package_id;type:bigint;not null;comment:套餐ID" json:"package_id"` AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
CostPrice float64 `gorm:"column:cost_price;type:decimal(10,2);not null;comment:成本价(元)" json:"cost_price"` PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
RetailPrice float64 `gorm:"column:retail_price;type:decimal(10,2);not null;comment:零售价(元)" json:"retail_price"` CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:成本价(分为单位)" json:"cost_price"`
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;comment:零售价(分为单位)" json:"retail_price"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (AgentPackageAllocation) TableName() string { func (AgentPackageAllocation) TableName() string {
return "agent_package_allocations" return "tb_agent_package_allocation"
} }
// DeviceSimBinding 设备-IoT卡绑定关系模型 // DeviceSimBinding 设备-IoT卡绑定关系模型
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡) // 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
type DeviceSimBinding struct { type DeviceSimBinding struct {
ID uint `gorm:"column:id;primaryKey;comment:绑定ID" json:"id"` gorm.Model
DeviceID uint `gorm:"column:device_id;type:bigint;not null;comment:设备ID" json:"device_id"` BaseModel `gorm:"embedded"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;comment:IoT卡ID" json:"iot_card_id"` DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"`
SlotPosition int `gorm:"column:slot_position;type:int;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"` IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"` BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"` BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"` UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (DeviceSimBinding) TableName() string { func (DeviceSimBinding) TableName() string {
return "device_sim_bindings" return "tb_device_sim_binding"
} }
// PackageUsage 套餐使用情况模型 // PackageUsage 套餐使用情况模型
// 跟踪单卡套餐和设备级套餐的流量使用 // 跟踪单卡套餐和设备级套餐的流量使用
type PackageUsage struct { type PackageUsage struct {
ID uint `gorm:"column:id;primaryKey;comment:套餐使用ID" json:"id"` gorm.Model
OrderID uint `gorm:"column:order_id;type:bigint;not null;comment:订单ID" json:"order_id"` BaseModel `gorm:"embedded"`
PackageID uint `gorm:"column:package_id;type:bigint;not null;comment:套餐ID" json:"package_id"` OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"`
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"` UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"` IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;type:bigint;comment:设备ID(设备级套餐时有值)" json:"device_id"` DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"`
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"` DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"` DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"` RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
@@ -97,11 +98,9 @@ type PackageUsage struct {
ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"` ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-生效中 2-已用完 3-已过期" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-生效中 2-已用完 3-已过期" json:"status"`
LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"` LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (PackageUsage) TableName() string { func (PackageUsage) TableName() string {
return "package_usages" return "tb_package_usage"
} }

View File

@@ -13,6 +13,7 @@ type Permission struct {
PermCode string `gorm:"column:perm_code;uniqueIndex:idx_permission_code,where:deleted_at IS NULL;not null;size:100;comment:权限编码" json:"perm_code"` PermCode string `gorm:"column:perm_code;uniqueIndex:idx_permission_code,where:deleted_at IS NULL;not null;size:100;comment:权限编码" json:"perm_code"`
PermType int `gorm:"column:perm_type;not null;index;comment:权限类型 1=菜单 2=按钮" json:"perm_type"` PermType int `gorm:"column:perm_type;not null;index;comment:权限类型 1=菜单 2=按钮" json:"perm_type"`
Platform string `gorm:"column:platform;type:varchar(20);default:'all';comment:适用端口 all=全部 web=Web后台 h5=H5端" json:"platform"` Platform string `gorm:"column:platform;type:varchar(20);default:'all';comment:适用端口 all=全部 web=Web后台 h5=H5端" json:"platform"`
AvailableForRoleTypes string `gorm:"column:available_for_role_types;type:varchar(20);default:'1,2';not null;comment:可用角色类型 1=平台 2=客户" json:"available_for_role_types"`
URL string `gorm:"column:url;size:255;comment:URL路径" json:"url,omitempty"` URL string `gorm:"column:url;size:255;comment:URL路径" json:"url,omitempty"`
ParentID *uint `gorm:"column:parent_id;index;comment:上级权限ID" json:"parent_id,omitempty"` ParentID *uint `gorm:"column:parent_id;index;comment:上级权限ID" json:"parent_id,omitempty"`
Sort int `gorm:"column:sort;not null;default:0;comment:排序" json:"sort"` Sort int `gorm:"column:sort;not null;default:0;comment:排序" json:"sort"`

View File

@@ -36,6 +36,7 @@ type PermissionListRequest struct {
PermCode string `json:"perm_code" query:"perm_code" validate:"omitempty,max=100" maxLength:"100" description:"权限编码模糊查询"` PermCode string `json:"perm_code" query:"perm_code" validate:"omitempty,max=100" maxLength:"100" description:"权限编码模糊查询"`
PermType *int `json:"perm_type" query:"perm_type" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"权限类型"` PermType *int `json:"perm_type" query:"perm_type" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"权限类型"`
Platform string `json:"platform" query:"platform" validate:"omitempty,oneof=all web h5" description:"适用端口"` Platform string `json:"platform" query:"platform" validate:"omitempty,oneof=all web h5" description:"适用端口"`
AvailableForRoleType *int `json:"available_for_role_type" query:"available_for_role_type" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"可用角色类型 (1:平台角色, 2:客户角色)"`
ParentID *uint `json:"parent_id" query:"parent_id" description:"父权限ID"` ParentID *uint `json:"parent_id" query:"parent_id" description:"父权限ID"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态"` Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态"`
} }
@@ -47,6 +48,7 @@ type PermissionResponse struct {
PermCode string `json:"perm_code" description:"权限编码"` PermCode string `json:"perm_code" description:"权限编码"`
PermType int `json:"perm_type" description:"权限类型"` PermType int `json:"perm_type" description:"权限类型"`
Platform string `json:"platform" description:"适用端口"` Platform string `json:"platform" description:"适用端口"`
AvailableForRoleTypes string `json:"available_for_role_types" description:"可用角色类型"`
URL string `json:"url,omitempty" description:"请求路径"` URL string `json:"url,omitempty" description:"请求路径"`
ParentID *uint `json:"parent_id,omitempty" description:"父权限ID"` ParentID *uint `json:"parent_id,omitempty" description:"父权限ID"`
Sort int `json:"sort" description:"排序值"` Sort int `json:"sort" description:"排序值"`
@@ -72,6 +74,7 @@ type PermissionTreeNode struct {
PermCode string `json:"perm_code" description:"权限编码"` PermCode string `json:"perm_code" description:"权限编码"`
PermType int `json:"perm_type" description:"权限类型"` PermType int `json:"perm_type" description:"权限类型"`
Platform string `json:"platform" description:"适用端口"` Platform string `json:"platform" description:"适用端口"`
AvailableForRoleTypes string `json:"available_for_role_types" description:"可用角色类型"`
URL string `json:"url,omitempty" description:"请求路径"` URL string `json:"url,omitempty" description:"请求路径"`
Sort int `json:"sort" description:"排序值"` Sort int `json:"sort" description:"排序值"`
Children []*PermissionTreeNode `json:"children,omitempty" description:"子权限列表"` Children []*PermissionTreeNode `json:"children,omitempty" description:"子权限列表"`

View File

@@ -1,15 +1,18 @@
package model package model
import "time" import (
"gorm.io/gorm"
)
// PollingConfig 轮询配置模型 // PollingConfig 轮询配置模型
// 支持梯度轮询策略(实名检查、卡流量检查、套餐流量检查) // 支持梯度轮询策略(实名检查、卡流量检查、套餐流量检查)
type PollingConfig struct { type PollingConfig struct {
ID uint `gorm:"column:id;primaryKey;comment:轮询配置ID" json:"id"` gorm.Model
ConfigName string `gorm:"column:config_name;type:varchar(100);uniqueIndex;not null;comment:配置名称(如 未实名卡、实名卡)" json:"config_name"` BaseModel `gorm:"embedded"`
ConfigName string `gorm:"column:config_name;type:varchar(100);uniqueIndex:idx_polling_config_name,where:deleted_at IS NULL;not null;comment:配置名称(如 未实名卡、实名卡)" json:"config_name"`
Description string `gorm:"column:description;type:varchar(500);comment:配置描述" json:"description"` Description string `gorm:"column:description;type:varchar(500);comment:配置描述" json:"description"`
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件 not_real_name-未实名 real_name-已实名 activated-已激活 suspended-已停用" json:"card_condition"` CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件 not_real_name-未实名 real_name-已实名 activated-已激活 suspended-已停用" json:"card_condition"`
CarrierID uint `gorm:"column:carrier_id;type:bigint;comment:运营商ID(NULL表示所有运营商)" json:"carrier_id"` CarrierID uint `gorm:"column:carrier_id;index;comment:运营商ID(NULL表示所有运营商)" json:"carrier_id"`
RealNameCheckEnabled bool `gorm:"column:real_name_check_enabled;type:boolean;default:false;comment:是否启用实名检查" json:"real_name_check_enabled"` RealNameCheckEnabled bool `gorm:"column:real_name_check_enabled;type:boolean;default:false;comment:是否启用实名检查" json:"real_name_check_enabled"`
RealNameCheckInterval int `gorm:"column:real_name_check_interval;type:int;default:60;comment:实名检查间隔(秒)" json:"real_name_check_interval"` RealNameCheckInterval int `gorm:"column:real_name_check_interval;type:int;default:60;comment:实名检查间隔(秒)" json:"real_name_check_interval"`
CardDataCheckEnabled bool `gorm:"column:card_data_check_enabled;type:boolean;default:false;comment:是否启用卡流量检查" json:"card_data_check_enabled"` CardDataCheckEnabled bool `gorm:"column:card_data_check_enabled;type:boolean;default:false;comment:是否启用卡流量检查" json:"card_data_check_enabled"`
@@ -18,11 +21,9 @@ type PollingConfig struct {
PackageCheckInterval int `gorm:"column:package_check_interval;type:int;default:60;comment:套餐流量检查间隔(秒)" json:"package_check_interval"` PackageCheckInterval int `gorm:"column:package_check_interval;type:int;default:60;comment:套餐流量检查间隔(秒)" json:"package_check_interval"`
Priority int `gorm:"column:priority;type:int;default:100;not null;comment:优先级(数字越小优先级越高)" json:"priority"` Priority int `gorm:"column:priority;type:int;default:100;not null;comment:优先级(数字越小优先级越高)" json:"priority"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (PollingConfig) TableName() string { func (PollingConfig) TableName() string {
return "polling_configs" return "tb_polling_config"
} }

View File

@@ -66,3 +66,14 @@ type RemovePermissionParams struct {
RoleID uint `path:"role_id" required:"true" description:"角色ID"` RoleID uint `path:"role_id" required:"true" description:"角色ID"`
PermID uint `path:"perm_id" required:"true" description:"权限ID"` PermID uint `path:"perm_id" required:"true" description:"权限ID"`
} }
// UpdateRoleStatusRequest 更新角色状态请求
type UpdateRoleStatusRequest struct {
Status int `json:"status" validate:"required,min=0,max=1" required:"true" minimum:"0" maximum:"1" description:"状态 (0:禁用, 1:启用)"`
}
// UpdateRoleStatusParams 更新角色状态参数聚合
type UpdateRoleStatusParams struct {
IDReq
UpdateRoleStatusRequest
}

View File

@@ -0,0 +1,48 @@
package model
// ShopAccountListRequest 代理商账号列表查询请求
type ShopAccountListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1"` // 页码
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100"` // 每页数量
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty,min=1"` // 店铺ID过滤
Username string `json:"username" query:"username" validate:"omitempty,max=50"` // 用户名(模糊查询)
Phone string `json:"phone" query:"phone" validate:"omitempty,len=11"` // 手机号(精确查询)
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1"` // 状态
}
// CreateShopAccountRequest 创建代理商账号请求
type CreateShopAccountRequest struct {
ShopID uint `json:"shop_id" validate:"required,min=1"` // 店铺ID
Username string `json:"username" validate:"required,min=3,max=50"` // 用户名
Phone string `json:"phone" validate:"required,len=11"` // 手机号
Password string `json:"password" validate:"required,min=8,max=32"` // 密码
}
// UpdateShopAccountRequest 更新代理商账号请求
type UpdateShopAccountRequest struct {
Username string `json:"username" validate:"required,min=3,max=50"` // 用户名
// 注意:不包含 phone 和 password按照业务规则不允许修改
}
// UpdateShopAccountPasswordRequest 修改代理商账号密码请求(管理员重置)
type UpdateShopAccountPasswordRequest struct {
NewPassword string `json:"new_password" validate:"required,min=8,max=32"` // 新密码
}
// UpdateShopAccountStatusRequest 修改代理商账号状态请求
type UpdateShopAccountStatusRequest struct {
Status int `json:"status" validate:"required,oneof=0 1"` // 状态0=禁用 1=启用)
}
// ShopAccountResponse 代理商账号响应
type ShopAccountResponse struct {
ID uint `json:"id"`
ShopID uint `json:"shop_id"`
ShopName string `json:"shop_name,omitempty"` // 关联查询时填充
Username string `json:"username"`
Phone string `json:"phone"`
UserType int `json:"user_type"`
Status int `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -1,28 +1,39 @@
package model package model
// CreateShopRequest 创建店铺请求 type ShopListRequest struct {
type CreateShopRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1"`
ShopName string `json:"shop_name" validate:"required"` // 店铺名称 PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100"`
ShopCode string `json:"shop_code"` // 店铺编号 ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100"`
ParentID *uint `json:"parent_id"` // 上级店铺ID ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50"`
ContactName string `json:"contact_name"` // 联系人姓名 ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1"`
ContactPhone string `json:"contact_phone" validate:"omitempty"` // 联系人电话 Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7"`
Province string `json:"province"` // 省份 Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1"`
City string `json:"city"` // 城市 }
District string `json:"district"` // 区县
Address string `json:"address"` // 详细地址 type CreateShopRequest struct {
ShopName string `json:"shop_name" validate:"required,min=1,max=100"`
ShopCode string `json:"shop_code" validate:"required,min=1,max=50"`
ParentID *uint `json:"parent_id" validate:"omitempty,min=1"`
ContactName string `json:"contact_name" validate:"omitempty,max=50"`
ContactPhone string `json:"contact_phone" validate:"omitempty,len=11"`
Province string `json:"province" validate:"omitempty,max=50"`
City string `json:"city" validate:"omitempty,max=50"`
District string `json:"district" validate:"omitempty,max=50"`
Address string `json:"address" validate:"omitempty,max=255"`
InitPassword string `json:"init_password" validate:"required,min=8,max=32"`
InitUsername string `json:"init_username" validate:"required,min=3,max=50"`
InitPhone string `json:"init_phone" validate:"required,len=11"`
} }
// UpdateShopRequest 更新店铺请求
type UpdateShopRequest struct { type UpdateShopRequest struct {
ShopName *string `json:"shop_name"` // 店铺名称 ShopName string `json:"shop_name" validate:"required,min=1,max=100"`
ShopCode *string `json:"shop_code"` // 店铺编号 ContactName string `json:"contact_name" validate:"omitempty,max=50"`
ContactName *string `json:"contact_name"` // 联系人姓名 ContactPhone string `json:"contact_phone" validate:"omitempty,len=11"`
ContactPhone *string `json:"contact_phone"` // 联系人电话 Province string `json:"province" validate:"omitempty,max=50"`
Province *string `json:"province"` // 省份 City string `json:"city" validate:"omitempty,max=50"`
City *string `json:"city"` // 城市 District string `json:"district" validate:"omitempty,max=50"`
District *string `json:"district"` // 区县 Address string `json:"address" validate:"omitempty,max=255"`
Address *string `json:"address"` // 详细地址 Status int `json:"status" validate:"required,oneof=0 1"`
} }
// ShopResponse 店铺响应 // ShopResponse 店铺响应

View File

@@ -1,45 +1,47 @@
package model package model
import "time" import (
"time"
"gorm.io/gorm"
)
// DevCapabilityConfig 开发能力配置模型 // DevCapabilityConfig 开发能力配置模型
// 管理 API 对接参数(AppID、AppSecret、回调地址等) // 管理 API 对接参数(AppID、AppSecret、回调地址等)
type DevCapabilityConfig struct { type DevCapabilityConfig struct {
ID uint `gorm:"column:id;primaryKey;comment:开发能力配置ID" json:"id"` gorm.Model
UserID uint `gorm:"column:user_id;type:bigint;not null;comment:用户ID(平台或代理)" json:"user_id"` BaseModel `gorm:"embedded"`
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID(平台或代理)" json:"user_id"`
AppName string `gorm:"column:app_name;type:varchar(255);comment:应用名称" json:"app_name"` AppName string `gorm:"column:app_name;type:varchar(255);comment:应用名称" json:"app_name"`
AppID string `gorm:"column:app_id;type:varchar(100);uniqueIndex;comment:应用ID" json:"app_id"` AppID string `gorm:"column:app_id;type:varchar(100);uniqueIndex:idx_dev_capability_app,where:deleted_at IS NULL;comment:应用ID" json:"app_id"`
AppSecret string `gorm:"column:app_secret;type:varchar(255);comment:应用密钥" json:"app_secret"` AppSecret string `gorm:"column:app_secret;type:varchar(255);comment:应用密钥" json:"app_secret"`
CallbackURL string `gorm:"column:callback_url;type:varchar(500);comment:回调地址" json:"callback_url"` CallbackURL string `gorm:"column:callback_url;type:varchar(500);comment:回调地址" json:"callback_url"`
IPWhitelist string `gorm:"column:ip_whitelist;type:text;comment:IP白名单(多个IP用逗号分隔)" json:"ip_whitelist"` IPWhitelist string `gorm:"column:ip_whitelist;type:text;comment:IP白名单(多个IP用逗号分隔)" json:"ip_whitelist"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (DevCapabilityConfig) TableName() string { func (DevCapabilityConfig) TableName() string {
return "dev_capability_configs" return "tb_dev_capability_config"
} }
// CardReplacementRequest 换卡申请模型 // CardReplacementRequest 换卡申请模型
// 客户提交的换卡申请管理,处理换卡申请 // 客户提交的换卡申请管理,处理换卡申请
type CardReplacementRequest struct { type CardReplacementRequest struct {
ID uint `gorm:"column:id;primaryKey;comment:换卡申请ID" json:"id"` gorm.Model
UserID uint `gorm:"column:user_id;type:bigint;not null;comment:申请用户ID" json:"user_id"` BaseModel `gorm:"embedded"`
UserID uint `gorm:"column:user_id;index;not null;comment:申请用户ID" json:"user_id"`
OldICCID string `gorm:"column:old_iccid;type:varchar(50);not null;comment:旧卡ICCID" json:"old_iccid"` OldICCID string `gorm:"column:old_iccid;type:varchar(50);not null;comment:旧卡ICCID" json:"old_iccid"`
NewICCID string `gorm:"column:new_iccid;type:varchar(50);comment:新卡ICCID(审批时填充)" json:"new_iccid"` NewICCID string `gorm:"column:new_iccid;type:varchar(50);comment:新卡ICCID(审批时填充)" json:"new_iccid"`
Reason string `gorm:"column:reason;type:text;comment:换卡原因" json:"reason"` Reason string `gorm:"column:reason;type:text;comment:换卡原因" json:"reason"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待处理 2-已通过 3-已拒绝 4-已完成" json:"status"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待处理 2-已通过 3-已拒绝 4-已完成" json:"status"`
ApprovedBy uint `gorm:"column:approved_by;type:bigint;comment:处理人用户ID" json:"approved_by"` ApprovedBy uint `gorm:"column:approved_by;index;comment:处理人用户ID" json:"approved_by"`
ApprovedAt *time.Time `gorm:"column:approved_at;comment:处理时间" json:"approved_at"` ApprovedAt *time.Time `gorm:"column:approved_at;comment:处理时间" json:"approved_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间(新卡激活时间)" json:"completed_at"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间(新卡激活时间)" json:"completed_at"`
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"` RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
} }
// TableName 指定表名 // TableName 指定表名
func (CardReplacementRequest) TableName() string { func (CardReplacementRequest) TableName() string {
return "card_replacement_requests" return "tb_card_replacement_request"
} }

41
internal/model/tag.go Normal file
View File

@@ -0,0 +1,41 @@
package model
import (
"gorm.io/gorm"
)
// Tag 标签模型
// 用于设备、IoT卡、号卡的分类标记支持自定义颜色
// 支持企业、店铺、平台三级隔离
type Tag struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name;type:varchar(100);not null;uniqueIndex:idx_tag_enterprise_name,priority:2;uniqueIndex:idx_tag_shop_name,priority:2;uniqueIndex:idx_tag_global_name;comment:标签名称" json:"name"`
EnterpriseID *uint `gorm:"column:enterprise_id;index:idx_tag_enterprise;uniqueIndex:idx_tag_enterprise_name,priority:1;comment:归属企业IDNULL表示非企业标签" json:"enterprise_id,omitempty"`
ShopID *uint `gorm:"column:shop_id;index:idx_tag_shop;uniqueIndex:idx_tag_shop_name,priority:1;comment:归属店铺IDNULL表示非店铺标签" json:"shop_id,omitempty"`
Color *string `gorm:"column:color;type:varchar(20);comment:标签颜色(十六进制)" json:"color,omitempty"`
UsageCount int `gorm:"column:usage_count;type:int;not null;default:0;index:idx_tag_usage;comment:使用次数" json:"usage_count"`
}
// TableName 指定表名
func (Tag) TableName() string {
return "tb_tag"
}
// ResourceTag 资源-标签关联模型
// 统一管理设备、IoT卡、号卡与标签的多对多关系
// 添加 enterprise_id 和 shop_id 用于权限控制,从资源所有者推断
type ResourceTag struct {
gorm.Model
BaseModel `gorm:"embedded"`
ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;uniqueIndex:idx_resource_tag_unique,priority:1,where:deleted_at IS NULL;index:idx_resource_tag_resource,priority:1;index:idx_resource_tag_composite,priority:1;comment:资源类型 device-设备 iot_card-IoT卡 number_card-号卡" json:"resource_type"`
ResourceID uint `gorm:"column:resource_id;not null;uniqueIndex:idx_resource_tag_unique,priority:2,where:deleted_at IS NULL;index:idx_resource_tag_resource,priority:2;comment:资源ID" json:"resource_id"`
TagID uint `gorm:"column:tag_id;not null;uniqueIndex:idx_resource_tag_unique,priority:3,where:deleted_at IS NULL;index:idx_resource_tag_tag;index:idx_resource_tag_composite,priority:2;comment:标签ID" json:"tag_id"`
EnterpriseID *uint `gorm:"column:enterprise_id;index:idx_resource_tag_enterprise;comment:归属企业ID从资源推断" json:"enterprise_id,omitempty"`
ShopID *uint `gorm:"column:shop_id;index:idx_resource_tag_shop;comment:归属店铺ID从资源推断" json:"shop_id,omitempty"`
}
// TableName 指定表名
func (ResourceTag) TableName() string {
return "tb_resource_tag"
}

99
internal/model/wallet.go Normal file
View File

@@ -0,0 +1,99 @@
package model
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
// Wallet 钱包模型
// 个人客户和代理商的资金账户,支持充值、消费、提现等操作
// 使用乐观锁version字段防止并发余额冲突
// 钱包归属资源(卡/设备/店铺),支持资源转手场景
type Wallet struct {
gorm.Model
BaseModel `gorm:"embedded"`
ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:1;index:idx_wallet_resource,priority:1;comment:资源类型 iot_card-物联网卡 device-设备 shop-店铺" json:"resource_type"`
ResourceID uint `gorm:"column:resource_id;not null;uniqueIndex:idx_wallet_resource_type_currency,priority:2;index:idx_wallet_resource,priority:2;comment:资源ID" json:"resource_id"`
WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;uniqueIndex:idx_wallet_resource_type_currency,priority:3;comment:钱包类型 main-主钱包 commission-分佣钱包" json:"wallet_type"`
Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(分)" json:"balance"`
FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(分)" json:"frozen_balance"`
Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';uniqueIndex:idx_wallet_resource_type_currency,priority:4;comment:币种" json:"currency"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_wallet_status;comment:钱包状态 1-正常 2-冻结 3-关闭" json:"status"`
Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"`
}
// TableName 指定表名
func (Wallet) TableName() string {
return "tb_wallet"
}
// WalletMetadata 钱包交易扩展信息
// 用于存储交易相关的额外数据JSONB格式
type WalletMetadata map[string]interface{}
// Value 实现 driver.Valuer 接口
func (m WalletMetadata) Value() (driver.Value, error) {
return json.Marshal(m)
}
// Scan 实现 sql.Scanner 接口
func (m *WalletMetadata) Scan(value interface{}) error {
if value == nil {
*m = make(WalletMetadata)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, m)
}
// WalletTransaction 钱包交易记录模型
// 记录所有钱包余额变动,包含变动前后余额用于对账
// 支持关联业务对象(订单、分佣、提现等)
type WalletTransaction struct {
gorm.Model
WalletID uint `gorm:"column:wallet_id;not null;index:idx_wallet_tx_wallet;comment:钱包ID" json:"wallet_id"`
UserID uint `gorm:"column:user_id;not null;index:idx_wallet_tx_user;comment:用户ID" json:"user_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型 recharge-充值 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现" json:"transaction_type"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(分)正数为增加 负数为减少" json:"amount"`
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(分)" json:"balance_before"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(分)" json:"balance_after"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态 1-成功 2-失败 3-处理中" json:"status"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);index:idx_wallet_tx_ref,priority:1;comment:关联业务类型 order/commission/withdrawal/topup" json:"reference_type,omitempty"`
ReferenceID *uint `gorm:"column:reference_id;type:bigint;index:idx_wallet_tx_ref,priority:2;comment:关联业务ID" json:"reference_id,omitempty"`
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
Metadata WalletMetadata `gorm:"column:metadata;type:jsonb;comment:扩展信息" json:"metadata,omitempty"`
Creator uint `gorm:"column:creator;comment:创建人ID" json:"creator"`
}
// TableName 指定表名
func (WalletTransaction) TableName() string {
return "tb_wallet_transaction"
}
// RechargeRecord 充值记录模型
// 用户和代理的钱包充值订单,记录支付流程和状态
type RechargeRecord struct {
gorm.Model
BaseModel `gorm:"embedded"`
UserID uint `gorm:"column:user_id;not null;index:idx_recharge_user;comment:用户ID" json:"user_id"`
WalletID uint `gorm:"column:wallet_id;not null;comment:钱包ID" json:"wallet_id"`
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex:idx_recharge_no;comment:充值订单号" json:"recharge_no"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(分)" json:"amount"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式 alipay-支付宝 wechat-微信 bank-银行转账 offline-线下" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_recharge_status;comment:充值状态 1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款" json:"status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
}
// TableName 指定表名
func (RechargeRecord) TableName() string {
return "tb_recharge_record"
}

View File

@@ -16,58 +16,152 @@ func registerAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *opena
// 账号 CRUD // 账号 CRUD
Register(accounts, doc, groupPath, "POST", "", h.Create, RouteSpec{ Register(accounts, doc, groupPath, "POST", "", h.Create, RouteSpec{
Summary: "创建账号", Summary: "创建账号",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.CreateAccountRequest), Input: new(model.CreateAccountRequest),
Output: new(model.AccountResponse), Output: new(model.AccountResponse),
Auth: true,
}) })
Register(accounts, doc, groupPath, "GET", "", h.List, RouteSpec{ Register(accounts, doc, groupPath, "GET", "", h.List, RouteSpec{
Summary: "账号列表", Summary: "账号列表",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.AccountListRequest), Input: new(model.AccountListRequest),
Output: new(model.AccountPageResult), Output: new(model.AccountPageResult),
Auth: true,
}) })
Register(accounts, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{ Register(accounts, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{
Summary: "获取账号详情", Summary: "获取账号详情",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: new(model.AccountResponse), Output: new(model.AccountResponse),
Auth: true,
}) })
Register(accounts, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{ Register(accounts, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{
Summary: "更新账号", Summary: "更新账号",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.UpdateAccountParams), Input: new(model.UpdateAccountParams),
Output: new(model.AccountResponse), Output: new(model.AccountResponse),
Auth: true,
}) })
Register(accounts, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{ Register(accounts, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除账号", Summary: "删除账号",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: nil, Output: nil,
Auth: true,
}) })
// 账号-角色关联 // 账号-角色关联
Register(accounts, doc, groupPath, "POST", "/:id/roles", h.AssignRoles, RouteSpec{ Register(accounts, doc, groupPath, "POST", "/:id/roles", h.AssignRoles, RouteSpec{
Summary: "分配角色", Summary: "分配角色",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.AssignRolesParams), Input: new(model.AssignRolesParams),
Output: nil, // TODO: Define AccountRole response DTO Output: nil, // TODO: Define AccountRole response DTO
}) })
Register(accounts, doc, groupPath, "GET", "/:id/roles", h.GetRoles, RouteSpec{ Register(accounts, doc, groupPath, "GET", "/:id/roles", h.GetRoles, RouteSpec{
Summary: "获取账号角色", Summary: "获取账号角色",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: new([]model.Role), Output: new([]model.Role),
Auth: true,
}) })
Register(accounts, doc, groupPath, "DELETE", "/:account_id/roles/:role_id", h.RemoveRole, RouteSpec{ Register(accounts, doc, groupPath, "DELETE", "/:account_id/roles/:role_id", h.RemoveRole, RouteSpec{
Summary: "移除角色", Summary: "移除角色",
Tags: []string{"Account"}, Tags: []string{"账号相关"},
Input: new(model.RemoveRoleParams), Input: new(model.RemoveRoleParams),
Output: nil, Output: nil,
Auth: true,
})
registerPlatformAccountRoutes(api, h, doc, basePath)
}
func registerPlatformAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *openapi.Generator, basePath string) {
platformAccounts := api.Group("/platform-accounts")
groupPath := basePath + "/platform-accounts"
Register(platformAccounts, doc, groupPath, "GET", "", h.ListPlatformAccounts, RouteSpec{
Summary: "平台账号列表",
Tags: []string{"平台账号"},
Input: new(model.PlatformAccountListRequest),
Output: new(model.AccountPageResult),
Auth: true,
})
Register(platformAccounts, doc, groupPath, "POST", "", h.Create, RouteSpec{
Summary: "新增平台账号",
Tags: []string{"平台账号"},
Input: new(model.CreateAccountRequest),
Output: new(model.AccountResponse),
Auth: true,
})
Register(platformAccounts, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{
Summary: "获取平台账号详情",
Tags: []string{"平台账号"},
Input: new(model.IDReq),
Output: new(model.AccountResponse),
Auth: true,
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{
Summary: "编辑平台账号",
Tags: []string{"平台账号"},
Input: new(model.UpdateAccountParams),
Output: new(model.AccountResponse),
Auth: true,
})
Register(platformAccounts, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除平台账号",
Tags: []string{"平台账号"},
Input: new(model.IDReq),
Output: nil,
Auth: true,
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id/password", h.UpdatePassword, RouteSpec{
Summary: "修改密码",
Tags: []string{"平台账号"},
Input: new(model.UpdatePasswordParams),
Output: nil,
Auth: true,
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id/status", h.UpdateStatus, RouteSpec{
Summary: "启用/禁用账号",
Tags: []string{"平台账号"},
Input: new(model.UpdateStatusParams),
Output: nil,
Auth: true,
})
Register(platformAccounts, doc, groupPath, "POST", "/:id/roles", h.AssignRoles, RouteSpec{
Summary: "分配角色",
Tags: []string{"平台账号"},
Input: new(model.AssignRolesParams),
Output: nil,
Auth: true,
})
Register(platformAccounts, doc, groupPath, "GET", "/:id/roles", h.GetRoles, RouteSpec{
Summary: "获取账号角色",
Tags: []string{"平台账号"},
Input: new(model.IDReq),
Output: new([]model.Role),
Auth: true,
})
Register(platformAccounts, doc, groupPath, "DELETE", "/:account_id/roles/:role_id", h.RemoveRole, RouteSpec{
Summary: "移除角色",
Tags: []string{"平台账号"},
Input: new(model.RemoveRoleParams),
Output: nil,
Auth: true,
}) })
} }

View File

@@ -4,19 +4,83 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
) )
// RegisterAdminRoutes 注册管理后台相关路由 // RegisterAdminRoutes 注册管理后台相关路由
func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, doc *openapi.Generator, basePath string) { func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
if handlers.AdminAuth != nil {
registerAdminAuthRoutes(router, handlers.AdminAuth, middlewares.AdminAuth, doc, basePath)
}
authGroup := router.Group("", middlewares.AdminAuth)
if handlers.Account != nil { if handlers.Account != nil {
registerAccountRoutes(router, handlers.Account, doc, basePath) registerAccountRoutes(authGroup, handlers.Account, doc, basePath)
} }
if handlers.Role != nil { if handlers.Role != nil {
registerRoleRoutes(router, handlers.Role, doc, basePath) registerRoleRoutes(authGroup, handlers.Role, doc, basePath)
} }
if handlers.Permission != nil { if handlers.Permission != nil {
registerPermissionRoutes(router, handlers.Permission, doc, basePath) registerPermissionRoutes(authGroup, handlers.Permission, doc, basePath)
} }
// TODO: Task routes? if handlers.Shop != nil {
registerShopRoutes(authGroup, handlers.Shop, doc, basePath)
}
if handlers.ShopAccount != nil {
registerShopAccountRoutes(authGroup, handlers.ShopAccount, doc, basePath)
}
}
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
h := handler.(interface {
Login(c *fiber.Ctx) error
Logout(c *fiber.Ctx) error
RefreshToken(c *fiber.Ctx) error
GetMe(c *fiber.Ctx) error
ChangePassword(c *fiber.Ctx) error
})
Register(router, doc, basePath, "POST", "/login", h.Login, RouteSpec{
Summary: "后台登录",
Tags: []string{"认证"},
Input: new(model.LoginRequest),
Output: new(model.LoginResponse),
Auth: false,
})
Register(router, doc, basePath, "POST", "/refresh-token", h.RefreshToken, RouteSpec{
Summary: "刷新 Token",
Tags: []string{"认证"},
Input: new(model.RefreshTokenRequest),
Output: new(model.RefreshTokenResponse),
Auth: false,
})
authGroup := router.Group("", authMiddleware)
Register(authGroup, doc, basePath, "POST", "/logout", h.Logout, RouteSpec{
Summary: "登出",
Tags: []string{"认证"},
Input: nil,
Output: nil,
Auth: true,
})
Register(authGroup, doc, basePath, "GET", "/me", h.GetMe, RouteSpec{
Summary: "获取当前用户信息",
Tags: []string{"认证"},
Input: nil,
Output: new(model.UserInfo),
Auth: true,
})
Register(authGroup, doc, basePath, "PUT", "/password", h.ChangePassword, RouteSpec{
Summary: "修改密码",
Tags: []string{"认证"},
Input: new(model.ChangePasswordRequest),
Output: nil,
Auth: true,
})
} }

68
internal/routes/h5.go Normal file
View File

@@ -0,0 +1,68 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// RegisterH5Routes 注册H5相关路由
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
if handlers.H5Auth != nil {
registerH5AuthRoutes(router, handlers.H5Auth, middlewares.H5Auth, doc, basePath)
}
}
func registerH5AuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
h := handler.(interface {
Login(c *fiber.Ctx) error
Logout(c *fiber.Ctx) error
RefreshToken(c *fiber.Ctx) error
GetMe(c *fiber.Ctx) error
ChangePassword(c *fiber.Ctx) error
})
Register(router, doc, basePath, "POST", "/login", h.Login, RouteSpec{
Summary: "H5 登录",
Tags: []string{"H5 认证"},
Input: new(model.LoginRequest),
Output: new(model.LoginResponse),
Auth: false,
})
Register(router, doc, basePath, "POST", "/refresh-token", h.RefreshToken, RouteSpec{
Summary: "刷新 Token",
Tags: []string{"H5 认证"},
Input: new(model.RefreshTokenRequest),
Output: new(model.RefreshTokenResponse),
Auth: false,
})
authGroup := router.Group("", authMiddleware)
Register(authGroup, doc, basePath, "POST", "/logout", h.Logout, RouteSpec{
Summary: "登出",
Tags: []string{"H5 认证"},
Input: nil,
Output: nil,
Auth: true,
})
Register(authGroup, doc, basePath, "GET", "/me", h.GetMe, RouteSpec{
Summary: "获取当前用户信息",
Tags: []string{"H5 认证"},
Input: nil,
Output: new(model.UserInfo),
Auth: true,
})
Register(authGroup, doc, basePath, "PUT", "/password", h.ChangePassword, RouteSpec{
Summary: "修改密码",
Tags: []string{"H5 认证"},
Input: new(model.ChangePasswordRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -16,43 +16,49 @@ func registerPermissionRoutes(api fiber.Router, h *admin.PermissionHandler, doc
// 权限 CRUD // 权限 CRUD
Register(permissions, doc, groupPath, "POST", "", h.Create, RouteSpec{ Register(permissions, doc, groupPath, "POST", "", h.Create, RouteSpec{
Summary: "创建权限", Summary: "创建权限",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: new(model.CreatePermissionRequest), Input: new(model.CreatePermissionRequest),
Output: new(model.PermissionResponse), Output: new(model.PermissionResponse),
Auth: true,
}) })
Register(permissions, doc, groupPath, "GET", "", h.List, RouteSpec{ Register(permissions, doc, groupPath, "GET", "", h.List, RouteSpec{
Summary: "权限列表", Summary: "权限列表",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: new(model.PermissionListRequest), Input: new(model.PermissionListRequest),
Output: new(model.PermissionPageResult), Output: new(model.PermissionPageResult),
Auth: true,
}) })
Register(permissions, doc, groupPath, "GET", "/tree", h.GetTree, RouteSpec{ Register(permissions, doc, groupPath, "GET", "/tree", h.GetTree, RouteSpec{
Summary: "获取权限树", Summary: "获取权限树",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: nil, // 无参数或 Query 参数 Input: nil, // 无参数或 Query 参数
Output: new([]*model.PermissionTreeNode), Output: new([]*model.PermissionTreeNode),
Auth: true,
}) })
Register(permissions, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{ Register(permissions, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{
Summary: "获取权限详情", Summary: "获取权限详情",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: new(model.PermissionResponse), Output: new(model.PermissionResponse),
Auth: true,
}) })
Register(permissions, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{ Register(permissions, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{
Summary: "更新权限", Summary: "更新权限",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: new(model.UpdatePermissionParams), Input: new(model.UpdatePermissionParams),
Output: new(model.PermissionResponse), Output: new(model.PermissionResponse),
Auth: true,
}) })
Register(permissions, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{ Register(permissions, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除权限", Summary: "删除权限",
Tags: []string{"Permission"}, Tags: []string{"权限"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: nil, Output: nil,
Auth: true,
}) })
} }

View File

@@ -28,16 +28,11 @@ var pathParamRegex = regexp.MustCompile(`/:([a-zA-Z0-9_]+)`)
// handler: Fiber Handler // handler: Fiber Handler
// spec: 文档元数据 // spec: 文档元数据
func Register(router fiber.Router, doc *openapi.Generator, basePath, method, path string, handler fiber.Handler, spec RouteSpec) { func Register(router fiber.Router, doc *openapi.Generator, basePath, method, path string, handler fiber.Handler, spec RouteSpec) {
// 1. 注册实际的 Fiber 路由
router.Add(method, path, handler) router.Add(method, path, handler)
// 2. 注册文档 (如果 doc 不为空 - 也就是在生成文档模式下)
if doc != nil { if doc != nil {
// 简单的路径拼接
fullPath := basePath + path fullPath := basePath + path
// 将 Fiber 路由参数格式 /:id 转换为 OpenAPI 格式 /{id}
openapiPath := pathParamRegex.ReplaceAllString(fullPath, "/{$1}") openapiPath := pathParamRegex.ReplaceAllString(fullPath, "/{$1}")
doc.AddOperation(method, openapiPath, spec.Summary, spec.Input, spec.Output, spec.Auth, spec.Tags...)
doc.AddOperation(method, openapiPath, spec.Summary, spec.Input, spec.Output, spec.Tags...)
} }
} }

View File

@@ -16,58 +16,74 @@ func registerRoleRoutes(api fiber.Router, h *admin.RoleHandler, doc *openapi.Gen
// 角色 CRUD // 角色 CRUD
Register(roles, doc, groupPath, "POST", "", h.Create, RouteSpec{ Register(roles, doc, groupPath, "POST", "", h.Create, RouteSpec{
Summary: "创建角色", Summary: "创建角色",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.CreateRoleRequest), Input: new(model.CreateRoleRequest),
Output: new(model.RoleResponse), Output: new(model.RoleResponse),
Auth: true,
}) })
Register(roles, doc, groupPath, "GET", "", h.List, RouteSpec{ Register(roles, doc, groupPath, "GET", "", h.List, RouteSpec{
Summary: "角色列表", Summary: "角色列表",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.RoleListRequest), Input: new(model.RoleListRequest),
Output: new(model.RolePageResult), Output: new(model.RolePageResult),
Auth: true,
}) })
Register(roles, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{ Register(roles, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{
Summary: "获取角色详情", Summary: "获取角色详情",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: new(model.RoleResponse), Output: new(model.RoleResponse),
Auth: true,
}) })
Register(roles, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{ Register(roles, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{
Summary: "更新角色", Summary: "更新角色",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.UpdateRoleParams), Input: new(model.UpdateRoleParams),
Output: new(model.RoleResponse), Output: new(model.RoleResponse),
Auth: true,
})
Register(roles, doc, groupPath, "PUT", "/:id/status", h.UpdateStatus, RouteSpec{
Summary: "更新角色状态",
Tags: []string{"角色"},
Input: new(model.UpdateRoleStatusParams),
Output: nil,
Auth: true,
}) })
Register(roles, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{ Register(roles, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除角色", Summary: "删除角色",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: nil, Output: nil,
Auth: true,
}) })
// 角色-权限关联 // 角色-权限关联
Register(roles, doc, groupPath, "POST", "/:id/permissions", h.AssignPermissions, RouteSpec{ Register(roles, doc, groupPath, "POST", "/:id/permissions", h.AssignPermissions, RouteSpec{
Summary: "分配权限", Summary: "分配权限",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.AssignPermissionsParams), Input: new(model.AssignPermissionsParams),
Output: nil, Output: nil,
Auth: true,
}) })
Register(roles, doc, groupPath, "GET", "/:id/permissions", h.GetPermissions, RouteSpec{ Register(roles, doc, groupPath, "GET", "/:id/permissions", h.GetPermissions, RouteSpec{
Summary: "获取角色权限", Summary: "获取角色权限",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.IDReq), Input: new(model.IDReq),
Output: new([]model.Permission), Output: new([]model.Permission),
Auth: true,
}) })
Register(roles, doc, groupPath, "DELETE", "/:role_id/permissions/:perm_id", h.RemovePermission, RouteSpec{ Register(roles, doc, groupPath, "DELETE", "/:role_id/permissions/:perm_id", h.RemovePermission, RouteSpec{
Summary: "移除权限", Summary: "移除权限",
Tags: []string{"Role"}, Tags: []string{"角色"},
Input: new(model.RemovePermissionParams), Input: new(model.RemovePermissionParams),
Output: nil, Output: nil,
Auth: true,
}) })
} }

View File

@@ -14,11 +14,15 @@ func RegisterRoutes(app *fiber.App, handlers *bootstrap.Handlers, middlewares *b
// 2. Admin 域 (挂载在 /api/admin) // 2. Admin 域 (挂载在 /api/admin)
adminGroup := app.Group("/api/admin") adminGroup := app.Group("/api/admin")
RegisterAdminRoutes(adminGroup, handlers, nil, "/api/admin") RegisterAdminRoutes(adminGroup, handlers, middlewares, nil, "/api/admin")
// 任务相关路由 (归属于 Admin 域) // 任务相关路由 (归属于 Admin 域)
registerTaskRoutes(adminGroup) registerTaskRoutes(adminGroup)
// 3. 个人客户路由 (挂载在 /api/c/v1) // 3. H5 域 (挂载在 /api/h5)
h5Group := app.Group("/api/h5")
RegisterH5Routes(h5Group, handlers, middlewares, nil, "/api/h5")
// 4. 个人客户路由 (挂载在 /api/c/v1)
RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth) RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth)
} }

23
internal/routes/shop.go Normal file
View File

@@ -0,0 +1,23 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {
router.Get("/shops", handler.List)
router.Post("/shops", handler.Create)
router.Put("/shops/:id", handler.Update)
router.Delete("/shops/:id", handler.Delete)
}
func registerShopAccountRoutes(router fiber.Router, handler *admin.ShopAccountHandler, doc *openapi.Generator, basePath string) {
router.Get("/shop-accounts", handler.List)
router.Post("/shop-accounts", handler.Create)
router.Put("/shop-accounts/:id", handler.Update)
router.Put("/shop-accounts/:id/password", handler.UpdatePassword)
router.Put("/shop-accounts/:id/status", handler.UpdateStatus)
}

View File

@@ -210,15 +210,13 @@ func (s *Service) List(ctx context.Context, req *model.AccountListRequest) ([]*m
return s.accountStore.List(ctx, opts, filters) return s.accountStore.List(ctx, opts, filters)
} }
// AssignRoles 为账号分配角色 // AssignRoles 为账号分配角色(支持空数组清空所有角色,超级管理员禁止分配)
func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uint) ([]*model.AccountRole, error) { func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uint) ([]*model.AccountRole, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx) currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 { if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问") return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
} }
// 检查账号存在
account, err := s.accountStore.GetByID(ctx, accountID) account, err := s.accountStore.GetByID(ctx, accountID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
@@ -227,19 +225,29 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
return nil, fmt.Errorf("获取账号失败: %w", err) return nil, fmt.Errorf("获取账号失败: %w", err)
} }
// 检查用户类型是否允许分配角色 // 超级管理员禁止分配角色
if account.UserType == constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeInvalidParam, "超级管理员不允许分配角色")
}
// 空数组:清空所有角色
if len(roleIDs) == 0 {
if err := s.accountRoleStore.DeleteByAccountID(ctx, accountID); err != nil {
return nil, fmt.Errorf("清空账号角色失败: %w", err)
}
return []*model.AccountRole{}, nil
}
maxRoles := constants.GetMaxRolesForUserType(account.UserType) maxRoles := constants.GetMaxRolesForUserType(account.UserType)
if maxRoles == 0 { if maxRoles == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色") return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色")
} }
// 检查角色数量限制
existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID) existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID)
if err != nil { if err != nil {
return nil, fmt.Errorf("统计现有角色数量失败: %w", err) return nil, fmt.Errorf("统计现有角色数量失败: %w", err)
} }
// 计算将要分配的新角色数量(排除已存在的)
newRoleCount := 0 newRoleCount := 0
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID)
@@ -248,12 +256,10 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
} }
} }
// 检查角色数量限制(-1 表示无限制)
if maxRoles != -1 && int(existingCount)+newRoleCount > maxRoles { if maxRoles != -1 && int(existingCount)+newRoleCount > maxRoles {
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles)) return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles))
} }
// 验证所有角色存在并检查角色类型是否匹配
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
role, err := s.roleStore.GetByID(ctx, roleID) role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil { if err != nil {
@@ -263,19 +269,16 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
return nil, fmt.Errorf("获取角色失败: %w", err) return nil, fmt.Errorf("获取角色失败: %w", err)
} }
// 检查角色类型与用户类型是否匹配
if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) { if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) {
return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配") return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配")
} }
} }
// 创建关联
var ars []*model.AccountRole var ars []*model.AccountRole
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
// 检查是否已分配
exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID)
if exists { if exists {
continue // 跳过已存在的关联 continue
} }
ar := &model.AccountRole{ ar := &model.AccountRole{
@@ -343,3 +346,115 @@ func (s *Service) ValidatePassword(plainPassword, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword)) err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
return err == nil return err == nil
} }
// UpdatePassword 修改账号密码(管理员重置场景,无需旧密码)
func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPassword string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码哈希失败: %w", err)
}
if err := s.accountStore.UpdatePassword(ctx, accountID, string(hashedPassword), currentUserID); err != nil {
return fmt.Errorf("更新密码失败: %w", err)
}
return nil
}
// UpdateStatus 修改账号状态(启用/禁用)
func (s *Service) UpdateStatus(ctx context.Context, accountID uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
if err := s.accountStore.UpdateStatus(ctx, accountID, status, currentUserID); err != nil {
return fmt.Errorf("更新状态失败: %w", err)
}
return nil
}
// ListPlatformAccounts 查询平台账号列表(自动筛选 user_type IN (1, 2)
func (s *Service) ListPlatformAccounts(ctx context.Context, req *model.PlatformAccountListRequest) ([]*model.Account, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.Username != "" {
filters["username"] = req.Username
}
if req.Phone != "" {
filters["phone"] = req.Phone
}
if req.Status != nil {
filters["status"] = *req.Status
}
return s.accountStore.ListPlatformAccounts(ctx, opts, filters)
}
// CreateSystemAccount 系统内部创建账号方法,用于系统初始化场景(绕过当前用户检查)
func (s *Service) CreateSystemAccount(ctx context.Context, account *model.Account) error {
if account.Username == "" {
return errors.New(errors.CodeInvalidParam, "用户名不能为空")
}
if account.Phone == "" {
return errors.New(errors.CodeInvalidParam, "手机号不能为空")
}
if account.Password == "" {
return errors.New(errors.CodeInvalidParam, "密码不能为空")
}
existing, err := s.accountStore.GetByUsername(ctx, account.Username)
if err == nil && existing != nil {
return errors.New(errors.CodeUsernameExists, "用户名已存在")
}
existing, err = s.accountStore.GetByPhone(ctx, account.Phone)
if err == nil && existing != nil {
return errors.New(errors.CodePhoneExists, "手机号已存在")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码哈希失败: %w", err)
}
account.Password = string(hashedPassword)
if err := s.accountStore.Create(ctx, account); err != nil {
return fmt.Errorf("创建账号失败: %w", err)
}
return nil
}

View File

@@ -0,0 +1,260 @@
package auth
import (
"context"
"fmt"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
accountStore *postgres.AccountStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
permissionStore *postgres.PermissionStore
tokenManager *auth.TokenManager
logger *zap.Logger
}
func New(
accountStore *postgres.AccountStore,
accountRoleStore *postgres.AccountRoleStore,
rolePermStore *postgres.RolePermissionStore,
permissionStore *postgres.PermissionStore,
tokenManager *auth.TokenManager,
logger *zap.Logger,
) *Service {
return &Service{
accountStore: accountStore,
accountRoleStore: accountRoleStore,
rolePermStore: rolePermStore,
permissionStore: permissionStore,
tokenManager: tokenManager,
logger: logger,
}
}
func (s *Service) Login(ctx context.Context, req *model.LoginRequest, clientIP string) (*model.LoginResponse, error) {
ctx = pkgGorm.SkipDataPermission(ctx)
account, err := s.accountStore.GetByUsernameOrPhone(ctx, req.Username)
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("登录失败:用户名不存在", zap.String("username", req.Username), zap.String("ip", clientIP))
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
}
return nil, errors.New(errors.CodeDatabaseError, fmt.Sprintf("查询账号失败: %v", err))
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.Password)); err != nil {
s.logger.Warn("登录失败:密码错误", zap.String("username", req.Username), zap.String("ip", clientIP))
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
}
if account.Status != 1 {
s.logger.Warn("登录失败:账号已禁用", zap.String("username", req.Username), zap.Uint("user_id", account.ID))
return nil, errors.New(errors.CodeAccountDisabled, "账号已禁用")
}
device := req.Device
if device == "" {
device = "web"
}
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
}
if account.EnterpriseID != nil {
enterpriseID = *account.EnterpriseID
}
tokenInfo := &auth.TokenInfo{
UserID: account.ID,
UserType: account.UserType,
ShopID: shopID,
EnterpriseID: enterpriseID,
Username: account.Username,
Device: device,
IP: clientIP,
}
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
return nil, err
}
permissions, err := s.getUserPermissions(ctx, account.ID)
if err != nil {
s.logger.Error("查询用户权限失败", zap.Uint("user_id", account.ID), zap.Error(err))
permissions = []string{}
}
userInfo := s.buildUserInfo(account)
s.logger.Info("用户登录成功",
zap.Uint("user_id", account.ID),
zap.String("username", account.Username),
zap.String("device", device),
zap.String("ip", clientIP),
)
return &model.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(constants.DefaultAccessTokenTTL.Seconds()),
User: userInfo,
Permissions: permissions,
}, nil
}
func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string) error {
if err := s.tokenManager.RevokeToken(ctx, accessToken); err != nil {
return err
}
if refreshToken != "" {
if err := s.tokenManager.RevokeToken(ctx, refreshToken); err != nil {
s.logger.Warn("撤销 refresh token 失败", zap.Error(err))
}
}
return nil
}
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
return s.tokenManager.RefreshAccessToken(ctx, refreshToken)
}
func (s *Service) GetCurrentUser(ctx context.Context, userID uint) (*model.UserInfo, []string, error) {
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return nil, nil, errors.New(errors.CodeDatabaseError, fmt.Sprintf("查询账号失败: %v", err))
}
permissions, err := s.getUserPermissions(ctx, userID)
if err != nil {
s.logger.Error("查询用户权限失败", zap.Uint("user_id", userID), zap.Error(err))
permissions = []string{}
}
userInfo := s.buildUserInfo(account)
return &userInfo, permissions, nil
}
func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword, newPassword string) error {
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return errors.New(errors.CodeDatabaseError, fmt.Sprintf("查询账号失败: %v", err))
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
return errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
if err := s.accountStore.UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
return errors.New(errors.CodeDatabaseError, fmt.Sprintf("更新密码失败: %v", err))
}
if err := s.tokenManager.RevokeAllUserTokens(ctx, userID); err != nil {
s.logger.Warn("撤销用户所有 token 失败", zap.Uint("user_id", userID), zap.Error(err))
}
s.logger.Info("用户修改密码成功", zap.Uint("user_id", userID))
return nil
}
func (s *Service) getUserPermissions(ctx context.Context, userID uint) ([]string, error) {
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get account roles: %w", err)
}
if len(accountRoles) == 0 {
return []string{}, nil
}
roleIDs := make([]uint, 0, len(accountRoles))
for _, ar := range accountRoles {
roleIDs = append(roleIDs, ar.RoleID)
}
permIDs, err := s.rolePermStore.GetPermIDsByRoleIDs(ctx, roleIDs)
if err != nil {
return nil, fmt.Errorf("failed to get permission IDs: %w", err)
}
if len(permIDs) == 0 {
return []string{}, nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
return nil, fmt.Errorf("failed to get permissions: %w", err)
}
permCodes := make([]string, 0, len(permissions))
for _, perm := range permissions {
permCodes = append(permCodes, perm.PermCode)
}
return permCodes, nil
}
func (s *Service) buildUserInfo(account *model.Account) model.UserInfo {
userTypeName := s.getUserTypeName(account.UserType)
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
}
if account.EnterpriseID != nil {
enterpriseID = *account.EnterpriseID
}
return model.UserInfo{
ID: account.ID,
Username: account.Username,
Phone: account.Phone,
UserType: account.UserType,
UserTypeName: userTypeName,
ShopID: shopID,
EnterpriseID: enterpriseID,
}
}
func (s *Service) getUserTypeName(userType int) string {
switch userType {
case constants.UserTypeSuperAdmin:
return "超级管理员"
case constants.UserTypePlatform:
return "平台用户"
case constants.UserTypeAgent:
return "代理账号"
case constants.UserTypeEnterprise:
return "企业账号"
default:
return "未知"
}
}

View File

@@ -4,8 +4,10 @@ package permission
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
@@ -13,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,12 +25,23 @@ var permCodeRegex = regexp.MustCompile(`^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$`)
// Service 权限业务服务 // Service 权限业务服务
type Service struct { type Service struct {
permissionStore *postgres.PermissionStore permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
redisClient *redis.Client
} }
// New 创建权限服务 // New 创建权限服务
func New(permissionStore *postgres.PermissionStore) *Service { func New(
permissionStore *postgres.PermissionStore,
accountRoleStore *postgres.AccountRoleStore,
rolePermStore *postgres.RolePermissionStore,
redisClient *redis.Client,
) *Service {
return &Service{ return &Service{
permissionStore: permissionStore, permissionStore: permissionStore,
accountRoleStore: accountRoleStore,
rolePermStore: rolePermStore,
redisClient: redisClient,
} }
} }
@@ -202,6 +216,9 @@ func (s *Service) List(ctx context.Context, req *model.PermissionListRequest) ([
if req.Platform != "" { if req.Platform != "" {
filters["platform"] = req.Platform filters["platform"] = req.Platform
} }
if req.AvailableForRoleType != nil {
filters["available_for_role_type"] = *req.AvailableForRoleType
}
if req.ParentID != nil { if req.ParentID != nil {
filters["parent_id"] = *req.ParentID filters["parent_id"] = *req.ParentID
} }
@@ -213,20 +230,17 @@ func (s *Service) List(ctx context.Context, req *model.PermissionListRequest) ([
} }
// GetTree 获取权限树 // GetTree 获取权限树
func (s *Service) GetTree(ctx context.Context) ([]*model.PermissionTreeNode, error) { func (s *Service) GetTree(ctx context.Context, availableForRoleType *int) ([]*model.PermissionTreeNode, error) {
// 获取所有权限 permissions, err := s.permissionStore.GetAll(ctx, availableForRoleType)
permissions, err := s.permissionStore.GetAll(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("获取权限列表失败: %w", err) return nil, fmt.Errorf("获取权限列表失败: %w", err)
} }
// 构建树结构
return buildPermissionTree(permissions), nil return buildPermissionTree(permissions), nil
} }
// buildPermissionTree 构建权限树 // buildPermissionTree 构建权限树
func buildPermissionTree(permissions []*model.Permission) []*model.PermissionTreeNode { func buildPermissionTree(permissions []*model.Permission) []*model.PermissionTreeNode {
// 转换为节点映射
nodeMap := make(map[uint]*model.PermissionTreeNode) nodeMap := make(map[uint]*model.PermissionTreeNode)
for _, p := range permissions { for _, p := range permissions {
nodeMap[p.ID] = &model.PermissionTreeNode{ nodeMap[p.ID] = &model.PermissionTreeNode{
@@ -235,13 +249,13 @@ func buildPermissionTree(permissions []*model.Permission) []*model.PermissionTre
PermCode: p.PermCode, PermCode: p.PermCode,
PermType: p.PermType, PermType: p.PermType,
Platform: p.Platform, Platform: p.Platform,
AvailableForRoleTypes: p.AvailableForRoleTypes,
URL: p.URL, URL: p.URL,
Sort: p.Sort, Sort: p.Sort,
Children: make([]*model.PermissionTreeNode, 0), Children: make([]*model.PermissionTreeNode, 0),
} }
} }
// 构建树
var roots []*model.PermissionTreeNode var roots []*model.PermissionTreeNode
for _, p := range permissions { for _, p := range permissions {
node := nodeMap[p.ID] node := nodeMap[p.ID]
@@ -250,7 +264,6 @@ func buildPermissionTree(permissions []*model.Permission) []*model.PermissionTre
} else if parent, ok := nodeMap[*p.ParentID]; ok { } else if parent, ok := nodeMap[*p.ParentID]; ok {
parent.Children = append(parent.Children, node) parent.Children = append(parent.Children, node)
} else { } else {
// 如果找不到父节点,作为根节点处理
roots = append(roots, node) roots = append(roots, node)
} }
} }
@@ -258,24 +271,75 @@ func buildPermissionTree(permissions []*model.Permission) []*model.PermissionTre
return roots return roots
} }
// permissionCacheItem 权限缓存项
type permissionCacheItem struct {
PermCode string `json:"perm_code"`
Platform string `json:"platform"`
}
// CheckPermission 检查用户是否拥有指定权限(实现 PermissionChecker 接口) // CheckPermission 检查用户是否拥有指定权限(实现 PermissionChecker 接口)
// userID: 用户ID // userID: 用户ID
// permCode: 权限编码 // permCode: 权限编码
// platform: 端口类型 (all/web/h5) // platform: 端口类型 (all/web/h5)
func (s *Service) CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error) { func (s *Service) CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error) {
// 查询用户的所有权限(通过角色获取) userType := middleware.GetUserTypeFromContext(ctx)
// 1. 先获取用户的角色列表 if userType == constants.UserTypeSuperAdmin {
// 2. 再获取角色的权限列表 return true, nil
// 3. 检查是否包含指定权限编码,并且 platform 匹配 }
// 注意:这个方法需要访问 AccountRoleStore 和 RolePermissionStore cacheKey := constants.RedisUserPermissionsKey(userID)
// 但为了避免循环依赖,我们可以:
// 方案1: 在 Service 中注入这些 Store推荐 cachedData, err := s.redisClient.Get(ctx, cacheKey).Result()
// 方案2: 在 PermissionStore 中添加一个查询方法 if err == nil && cachedData != "" {
// 方案3: 使用缓存层Redis来存储用户权限映射 var permissions []permissionCacheItem
if err := json.Unmarshal([]byte(cachedData), &permissions); err == nil {
// 这里先返回一个占位实现 return s.matchPermission(permissions, permCode, platform), nil
// TODO: 实现完整的权限检查逻辑 }
// 需要在构造函数中注入 AccountRoleStore 和 RolePermissionStore }
return false, errors.New(errors.CodeInternalError, "权限检查功能尚未完全实现")
roleIDs, err := s.accountRoleStore.GetRoleIDsByAccountID(ctx, userID)
if err != nil {
return false, fmt.Errorf("查询用户角色失败: %w", err)
}
if len(roleIDs) == 0 {
return false, nil
}
permIDs, err := s.rolePermStore.GetPermIDsByRoleIDs(ctx, roleIDs)
if err != nil {
return false, fmt.Errorf("查询角色权限失败: %w", err)
}
if len(permIDs) == 0 {
return false, nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
return false, fmt.Errorf("查询权限详情失败: %w", err)
}
cacheItems := make([]permissionCacheItem, 0, len(permissions))
for _, perm := range permissions {
cacheItems = append(cacheItems, permissionCacheItem{
PermCode: perm.PermCode,
Platform: perm.Platform,
})
}
if cacheData, err := json.Marshal(cacheItems); err == nil {
s.redisClient.Set(ctx, cacheKey, cacheData, 30*time.Minute)
}
return s.matchPermission(cacheItems, permCode, platform), nil
}
func (s *Service) matchPermission(permissions []permissionCacheItem, permCode string, platform string) bool {
for _, perm := range permissions {
if perm.PermCode == permCode {
if perm.Platform == constants.PlatformAll || perm.Platform == platform {
return true
}
}
}
return false
} }

View File

@@ -5,6 +5,7 @@ package role
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
@@ -151,14 +152,12 @@ func (s *Service) List(ctx context.Context, req *model.RoleListRequest) ([]*mode
// AssignPermissions 为角色分配权限 // AssignPermissions 为角色分配权限
func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []uint) ([]*model.RolePermission, error) { func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []uint) ([]*model.RolePermission, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx) currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 { if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问") return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
} }
// 检查角色存在 role, err := s.roleStore.GetByID(ctx, roleID)
_, err := s.roleStore.GetByID(ctx, roleID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeRoleNotFound, "角色不存在") return nil, errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -166,24 +165,32 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
return nil, fmt.Errorf("获取角色失败: %w", err) return nil, fmt.Errorf("获取角色失败: %w", err)
} }
// 验证所有权限存在 permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
for _, permID := range permIDs {
_, err := s.permissionStore.GetByID(ctx, permID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodePermissionNotFound, fmt.Sprintf("权限 %d 不存在", permID))
}
return nil, fmt.Errorf("获取权限失败: %w", err) return nil, fmt.Errorf("获取权限失败: %w", err)
} }
if len(permissions) != len(permIDs) {
return nil, errors.New(errors.CodePermissionNotFound, "部分权限不存在")
}
roleTypeStr := fmt.Sprintf("%d", role.RoleType)
var invalidPermIDs []uint
for _, perm := range permissions {
if !contains(perm.AvailableForRoleTypes, roleTypeStr) {
invalidPermIDs = append(invalidPermIDs, perm.ID)
}
}
if len(invalidPermIDs) > 0 {
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
} }
// 创建关联
var rps []*model.RolePermission var rps []*model.RolePermission
for _, permID := range permIDs { for _, permID := range permIDs {
// 检查是否已分配
exists, _ := s.rolePermissionStore.Exists(ctx, roleID, permID) exists, _ := s.rolePermissionStore.Exists(ctx, roleID, permID)
if exists { if exists {
continue // 跳过已存在的关联 continue
} }
rp := &model.RolePermission{ rp := &model.RolePermission{
@@ -227,7 +234,6 @@ func (s *Service) GetPermissions(ctx context.Context, roleID uint) ([]*model.Per
// RemovePermission 移除角色的权限 // RemovePermission 移除角色的权限
func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) error { func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) error {
// 检查角色存在
_, err := s.roleStore.GetByID(ctx, roleID) _, err := s.roleStore.GetByID(ctx, roleID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
@@ -236,10 +242,44 @@ func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) err
return fmt.Errorf("获取角色失败: %w", err) return fmt.Errorf("获取角色失败: %w", err)
} }
// 删除关联
if err := s.rolePermissionStore.Delete(ctx, roleID, permID); err != nil { if err := s.rolePermissionStore.Delete(ctx, roleID, permID); err != nil {
return fmt.Errorf("删除角色-权限关联失败: %w", err) return fmt.Errorf("删除角色-权限关联失败: %w", err)
} }
return nil return nil
} }
// UpdateStatus 更新角色状态
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
role, err := s.roleStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
}
return fmt.Errorf("获取角色失败: %w", err)
}
role.Status = status
role.Updater = currentUserID
if err := s.roleStore.Update(ctx, role); err != nil {
return fmt.Errorf("更新角色状态失败: %w", err)
}
return nil
}
func contains(availableForRoleTypes, roleTypeStr string) bool {
types := strings.Split(availableForRoleTypes, ",")
for _, t := range types {
if strings.TrimSpace(t) == roleTypeStr {
return true
}
}
return false
}

View File

@@ -1,9 +1,8 @@
// Package shop 提供店铺管理的业务逻辑服务
// 包含店铺创建、查询、更新、删除等功能
package shop package shop
import ( import (
"context" "context"
"fmt"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
@@ -11,55 +10,55 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
// Service 店铺业务服务
type Service struct { type Service struct {
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
} }
// New 创建店铺服务 func New(shopStore *postgres.ShopStore, accountStore *postgres.AccountStore) *Service {
func New(shopStore *postgres.ShopStore) *Service {
return &Service{ return &Service{
shopStore: shopStore, shopStore: shopStore,
accountStore: accountStore,
} }
} }
// Create 创建店铺 func (s *Service) Create(ctx context.Context, req *model.CreateShopRequest) (*model.ShopResponse, error) {
func (s *Service) Create(ctx context.Context, req *model.CreateShopRequest) (*model.Shop, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx) currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 { if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问") return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
} }
// 检查店铺编号唯一性
if req.ShopCode != "" {
existing, err := s.shopStore.GetByCode(ctx, req.ShopCode) existing, err := s.shopStore.GetByCode(ctx, req.ShopCode)
if err == nil && existing != nil { if err == nil && existing != nil {
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在") return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
} }
}
// 计算层级
level := 1 level := 1
if req.ParentID != nil { if req.ParentID != nil {
// 验证上级店铺存在
parent, err := s.shopStore.GetByID(ctx, *req.ParentID) parent, err := s.shopStore.GetByID(ctx, *req.ParentID)
if err != nil { if err != nil {
return nil, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效") return nil, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
} }
// 计算新店铺的层级
level = parent.Level + 1 level = parent.Level + 1
if level > constants.ShopMaxLevel {
// 校验层级不超过最大值
if level > constants.MaxShopLevel {
return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级") return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
} }
} }
// 创建店铺 existingAccount, err := s.accountStore.GetByUsername(ctx, req.InitUsername)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
}
existingAccount, err = s.accountStore.GetByPhone(ctx, req.InitPhone)
if err == nil && existingAccount != nil {
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
}
shop := &model.Shop{ shop := &model.Shop{
ShopName: req.ShopName, ShopName: req.ShopName,
ShopCode: req.ShopCode, ShopCode: req.ShopCode,
@@ -71,71 +70,94 @@ func (s *Service) Create(ctx context.Context, req *model.CreateShopRequest) (*mo
City: req.City, City: req.City,
District: req.District, District: req.District,
Address: req.Address, Address: req.Address,
Status: constants.StatusEnabled, Status: constants.ShopStatusEnabled,
} }
shop.Creator = currentUserID shop.Creator = currentUserID
shop.Updater = currentUserID shop.Updater = currentUserID
if err := s.shopStore.Create(ctx, shop); err != nil { if err := s.shopStore.Create(ctx, shop); err != nil {
return nil, err return nil, fmt.Errorf("创建店铺失败: %w", err)
} }
return shop, nil hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("密码哈希失败: %w", err)
} }
// Update 更新店铺信息 account := &model.Account{
func (s *Service) Update(ctx context.Context, id uint, req *model.UpdateShopRequest) (*model.Shop, error) { Username: req.InitUsername,
// 获取当前用户 ID Phone: req.InitPhone,
Password: string(hashedPassword),
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
Status: constants.StatusEnabled,
}
account.Creator = currentUserID
account.Updater = currentUserID
if err := s.accountStore.Create(ctx, account); err != nil {
return nil, fmt.Errorf("创建初始账号失败: %w", err)
}
return &model.ShopResponse{
ID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
ParentID: shop.ParentID,
Level: shop.Level,
ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone,
Province: shop.Province,
City: shop.City,
District: shop.District,
Address: shop.Address,
Status: shop.Status,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
func (s *Service) Update(ctx context.Context, id uint, req *model.UpdateShopRequest) (*model.ShopResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx) currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 { if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问") return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
} }
// 查询店铺
shop, err := s.shopStore.GetByID(ctx, id) shop, err := s.shopStore.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在") return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
} }
// 检查店铺编号唯一性(如果修改了编号) shop.ShopName = req.ShopName
if req.ShopCode != nil && *req.ShopCode != shop.ShopCode { shop.ContactName = req.ContactName
existing, err := s.shopStore.GetByCode(ctx, *req.ShopCode) shop.ContactPhone = req.ContactPhone
if err == nil && existing != nil && existing.ID != id { shop.Province = req.Province
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在") shop.City = req.City
} shop.District = req.District
shop.ShopCode = *req.ShopCode shop.Address = req.Address
} shop.Status = req.Status
// 更新字段
if req.ShopName != nil {
shop.ShopName = *req.ShopName
}
if req.ContactName != nil {
shop.ContactName = *req.ContactName
}
if req.ContactPhone != nil {
shop.ContactPhone = *req.ContactPhone
}
if req.Province != nil {
shop.Province = *req.Province
}
if req.City != nil {
shop.City = *req.City
}
if req.District != nil {
shop.District = *req.District
}
if req.Address != nil {
shop.Address = *req.Address
}
shop.Updater = currentUserID shop.Updater = currentUserID
if err := s.shopStore.Update(ctx, shop); err != nil { if err := s.shopStore.Update(ctx, shop); err != nil {
return nil, err return nil, err
} }
return shop, nil return &model.ShopResponse{
ID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
ParentID: shop.ParentID,
Level: shop.Level,
ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone,
Province: shop.Province,
City: shop.City,
District: shop.District,
Address: shop.Address,
Status: shop.Status,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
} }
// Disable 禁用店铺 // Disable 禁用店铺
@@ -189,11 +211,104 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*model.Shop, error) {
return shop, nil return shop, nil
} }
// List 查询店铺列表 func (s *Service) ListShopResponses(ctx context.Context, req *model.ShopListRequest) ([]*model.ShopResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.ShopName != "" {
filters["shop_name"] = req.ShopName
}
if req.ShopCode != "" {
filters["shop_code"] = req.ShopCode
}
if req.ParentID != nil {
filters["parent_id"] = *req.ParentID
}
if req.Level != nil {
filters["level"] = *req.Level
}
if req.Status != nil {
filters["status"] = *req.Status
}
shops, total, err := s.shopStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, fmt.Errorf("查询店铺列表失败: %w", err)
}
responses := make([]*model.ShopResponse, 0, len(shops))
for _, shop := range shops {
responses = append(responses, &model.ShopResponse{
ID: shop.ID,
ShopName: shop.ShopName,
ShopCode: shop.ShopCode,
ParentID: shop.ParentID,
Level: shop.Level,
ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone,
Province: shop.Province,
City: shop.City,
District: shop.District,
Address: shop.Address,
Status: shop.Status,
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
return responses, total, nil
}
func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Shop, int64, error) { func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Shop, int64, error) {
return s.shopStore.List(ctx, opts, filters) return s.shopStore.List(ctx, opts, filters)
} }
func (s *Service) Delete(ctx context.Context, id uint) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
shop, err := s.shopStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound, "店铺不存在")
}
return fmt.Errorf("获取店铺失败: %w", err)
}
accounts, err := s.accountStore.GetByShopID(ctx, shop.ID)
if err != nil {
return fmt.Errorf("查询店铺账号失败: %w", err)
}
if len(accounts) > 0 {
accountIDs := make([]uint, 0, len(accounts))
for _, account := range accounts {
accountIDs = append(accountIDs, account.ID)
}
if err := s.accountStore.BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
return fmt.Errorf("禁用店铺账号失败: %w", err)
}
}
if err := s.shopStore.Delete(ctx, id); err != nil {
return fmt.Errorf("删除店铺失败: %w", err)
}
return nil
}
// GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己) // GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己)
func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) { func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
return s.shopStore.GetSubordinateShopIDs(ctx, shopID) return s.shopStore.GetSubordinateShopIDs(ctx, shopID)

View File

@@ -0,0 +1,265 @@
package shop_account
import (
"context"
"fmt"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
accountStore *postgres.AccountStore
shopStore *postgres.ShopStore
}
func New(accountStore *postgres.AccountStore, shopStore *postgres.ShopStore) *Service {
return &Service{
accountStore: accountStore,
shopStore: shopStore,
}
}
func (s *Service) List(ctx context.Context, req *model.ShopAccountListRequest) ([]*model.ShopAccountResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
filters["user_type"] = constants.UserTypeAgent
if req.Username != "" {
filters["username"] = req.Username
}
if req.Phone != "" {
filters["phone"] = req.Phone
}
if req.Status != nil {
filters["status"] = *req.Status
}
var accounts []*model.Account
var total int64
var err error
if req.ShopID != nil {
accounts, total, err = s.accountStore.ListByShopID(ctx, *req.ShopID, opts, filters)
} else {
filters["user_type"] = constants.UserTypeAgent
accounts, total, err = s.accountStore.List(ctx, opts, filters)
}
if err != nil {
return nil, 0, fmt.Errorf("查询代理商账号列表失败: %w", err)
}
shopMap := make(map[uint]string)
for _, account := range accounts {
if account.ShopID != nil {
if _, exists := shopMap[*account.ShopID]; !exists {
shop, err := s.shopStore.GetByID(ctx, *account.ShopID)
if err == nil {
shopMap[*account.ShopID] = shop.ShopName
}
}
}
}
responses := make([]*model.ShopAccountResponse, 0, len(accounts))
for _, account := range accounts {
resp := &model.ShopAccountResponse{
ID: account.ID,
Username: account.Username,
Phone: account.Phone,
UserType: account.UserType,
Status: account.Status,
CreatedAt: account.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: account.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if account.ShopID != nil {
resp.ShopID = *account.ShopID
if shopName, ok := shopMap[*account.ShopID]; ok {
resp.ShopName = shopName
}
}
responses = append(responses, resp)
}
return responses, total, nil
}
func (s *Service) Create(ctx context.Context, req *model.CreateShopAccountRequest) (*model.ShopAccountResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
shop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
}
return nil, fmt.Errorf("获取店铺失败: %w", err)
}
existing, err := s.accountStore.GetByUsername(ctx, req.Username)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeUsernameExists, "用户名已存在")
}
existing, err = s.accountStore.GetByPhone(ctx, req.Phone)
if err == nil && existing != nil {
return nil, errors.New(errors.CodePhoneExists, "手机号已存在")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("密码哈希失败: %w", err)
}
account := &model.Account{
Username: req.Username,
Phone: req.Phone,
Password: string(hashedPassword),
UserType: constants.UserTypeAgent,
ShopID: &req.ShopID,
Status: constants.StatusEnabled,
}
account.Creator = currentUserID
account.Updater = currentUserID
if err := s.accountStore.Create(ctx, account); err != nil {
return nil, fmt.Errorf("创建代理商账号失败: %w", err)
}
return &model.ShopAccountResponse{
ID: account.ID,
ShopID: *account.ShopID,
ShopName: shop.ShopName,
Username: account.Username,
Phone: account.Phone,
UserType: account.UserType,
Status: account.Status,
CreatedAt: account.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: account.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
func (s *Service) Update(ctx context.Context, id uint, req *model.UpdateShopAccountRequest) (*model.ShopAccountResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
account, err := s.accountStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return nil, fmt.Errorf("获取账号失败: %w", err)
}
if account.UserType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeInvalidParam, "只能更新代理商账号")
}
existingAccount, err := s.accountStore.GetByUsername(ctx, req.Username)
if err == nil && existingAccount != nil && existingAccount.ID != id {
return nil, errors.New(errors.CodeUsernameExists, "用户名已存在")
}
account.Username = req.Username
account.Updater = currentUserID
if err := s.accountStore.Update(ctx, account); err != nil {
return nil, fmt.Errorf("更新代理商账号失败: %w", err)
}
var shopName string
if account.ShopID != nil {
shop, err := s.shopStore.GetByID(ctx, *account.ShopID)
if err == nil {
shopName = shop.ShopName
}
}
return &model.ShopAccountResponse{
ID: account.ID,
ShopID: *account.ShopID,
ShopName: shopName,
Username: account.Username,
Phone: account.Phone,
UserType: account.UserType,
Status: account.Status,
CreatedAt: account.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: account.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
func (s *Service) UpdatePassword(ctx context.Context, id uint, req *model.UpdateShopAccountPasswordRequest) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
account, err := s.accountStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
if account.UserType != constants.UserTypeAgent {
return errors.New(errors.CodeInvalidParam, "只能更新代理商账号密码")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码哈希失败: %w", err)
}
if err := s.accountStore.UpdatePassword(ctx, id, string(hashedPassword), currentUserID); err != nil {
return fmt.Errorf("更新密码失败: %w", err)
}
return nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, req *model.UpdateShopAccountStatusRequest) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
account, err := s.accountStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
if account.UserType != constants.UserTypeAgent {
return errors.New(errors.CodeInvalidParam, "只能更新代理商账号状态")
}
if err := s.accountStore.UpdateStatus(ctx, id, req.Status, currentUserID); err != nil {
return fmt.Errorf("更新账号状态失败: %w", err)
}
return nil
}

View File

@@ -5,6 +5,8 @@ package postgres
import ( import (
"context" "context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -13,35 +15,64 @@ import (
// AccountRoleStore 账号-角色关联数据访问层 // AccountRoleStore 账号-角色关联数据访问层
type AccountRoleStore struct { type AccountRoleStore struct {
db *gorm.DB db *gorm.DB
redisClient *redis.Client
} }
// NewAccountRoleStore 创建账号-角色关联 Store // NewAccountRoleStore 创建账号-角色关联 Store
func NewAccountRoleStore(db *gorm.DB) *AccountRoleStore { func NewAccountRoleStore(db *gorm.DB, redisClient *redis.Client) *AccountRoleStore {
return &AccountRoleStore{db: db} return &AccountRoleStore{
db: db,
redisClient: redisClient,
}
} }
// Create 创建账号-角色关联 // Create 创建账号-角色关联
func (s *AccountRoleStore) Create(ctx context.Context, ar *model.AccountRole) error { func (s *AccountRoleStore) Create(ctx context.Context, ar *model.AccountRole) error {
return s.db.WithContext(ctx).Create(ar).Error if err := s.db.WithContext(ctx).Create(ar).Error; err != nil {
return err
}
s.clearUserPermissionCache(ctx, ar.AccountID)
return nil
} }
// BatchCreate 批量创建账号-角色关联 // BatchCreate 批量创建账号-角色关联
func (s *AccountRoleStore) BatchCreate(ctx context.Context, ars []*model.AccountRole) error { func (s *AccountRoleStore) BatchCreate(ctx context.Context, ars []*model.AccountRole) error {
return s.db.WithContext(ctx).Create(&ars).Error if err := s.db.WithContext(ctx).Create(&ars).Error; err != nil {
return err
}
for _, ar := range ars {
s.clearUserPermissionCache(ctx, ar.AccountID)
}
return nil
} }
// Delete 软删除账号-角色关联 // Delete 软删除账号-角色关联
func (s *AccountRoleStore) Delete(ctx context.Context, accountID, roleID uint) error { func (s *AccountRoleStore) Delete(ctx context.Context, accountID, roleID uint) error {
return s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("account_id = ? AND role_id = ?", accountID, roleID). Where("account_id = ? AND role_id = ?", accountID, roleID).
Delete(&model.AccountRole{}).Error Delete(&model.AccountRole{}).Error; err != nil {
return err
}
s.clearUserPermissionCache(ctx, accountID)
return nil
} }
// DeleteByAccountID 删除账号的所有角色关联 // DeleteByAccountID 删除账号的所有角色关联
func (s *AccountRoleStore) DeleteByAccountID(ctx context.Context, accountID uint) error { func (s *AccountRoleStore) DeleteByAccountID(ctx context.Context, accountID uint) error {
return s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("account_id = ?", accountID). Where("account_id = ?", accountID).
Delete(&model.AccountRole{}).Error Delete(&model.AccountRole{}).Error; err != nil {
return err
}
s.clearUserPermissionCache(ctx, accountID)
return nil
}
func (s *AccountRoleStore) clearUserPermissionCache(ctx context.Context, userID uint) {
if s.redisClient != nil {
key := constants.RedisUserPermissionsKey(userID)
s.redisClient.Del(ctx, key)
}
} }
// GetByAccountID 获取账号的所有角色关联 // GetByAccountID 获取账号的所有角色关联

View File

@@ -56,6 +56,15 @@ func (s *AccountStore) GetByPhone(ctx context.Context, phone string) (*model.Acc
return &account, nil return &account, nil
} }
// GetByUsernameOrPhone 根据用户名或手机号获取账号
func (s *AccountStore) GetByUsernameOrPhone(ctx context.Context, identifier string) (*model.Account, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("username = ? OR phone = ?", identifier, identifier).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// GetByShopID 根据店铺 ID 查询账号列表 // GetByShopID 根据店铺 ID 查询账号列表
func (s *AccountStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.Account, error) { func (s *AccountStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.Account, error) {
var accounts []*model.Account var accounts []*model.Account
@@ -129,3 +138,120 @@ func (s *AccountStore) List(ctx context.Context, opts *store.QueryOptions, filte
return accounts, total, nil return accounts, total, nil
} }
// ListPlatformAccounts 查询平台账号列表(自动筛选 user_type IN (1, 2)
func (s *AccountStore) ListPlatformAccounts(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Account, int64, error) {
var accounts []*model.Account
var total int64
query := s.db.WithContext(ctx).Model(&model.Account{})
// 固定筛选平台账号:超级管理员(1) 和 平台用户(2)
query = query.Where("user_type IN ?", []int{1, 2})
// 应用过滤条件
if username, ok := filters["username"].(string); ok && username != "" {
query = query.Where("username LIKE ?", "%"+username+"%")
}
if phone, ok := filters["phone"].(string); ok && phone != "" {
query = query.Where("phone LIKE ?", "%"+phone+"%")
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
// 计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
}
// 执行查询
if err := query.Find(&accounts).Error; err != nil {
return nil, 0, err
}
return accounts, total, nil
}
// UpdatePassword 更新账号密码
func (s *AccountStore) UpdatePassword(ctx context.Context, id uint, hashedPassword string, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.Account{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"password": hashedPassword,
"updater": updater,
}).Error
}
// UpdateStatus 更新账号状态
func (s *AccountStore) UpdateStatus(ctx context.Context, id uint, status int, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.Account{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": updater,
}).Error
}
// BulkUpdateStatus 批量更新账号状态
func (s *AccountStore) BulkUpdateStatus(ctx context.Context, ids []uint, status int, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.Account{}).
Where("id IN ?", ids).
Updates(map[string]interface{}{
"status": status,
"updater": updater,
}).Error
}
// ListByShopID 按店铺ID分页查询账号列表
func (s *AccountStore) ListByShopID(ctx context.Context, shopID uint, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Account, int64, error) {
var accounts []*model.Account
var total int64
query := s.db.WithContext(ctx).Model(&model.Account{}).Where("shop_id = ?", shopID)
if username, ok := filters["username"].(string); ok && username != "" {
query = query.Where("username LIKE ?", "%"+username+"%")
}
if phone, ok := filters["phone"].(string); ok && phone != "" {
query = query.Where("phone LIKE ?", "%"+phone+"%")
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
}
if err := query.Find(&accounts).Error; err != nil {
return nil, 0, err
}
return accounts, total, nil
}

View File

@@ -2,6 +2,7 @@ package postgres
import ( import (
"context" "context"
"fmt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -72,6 +73,10 @@ func (s *PermissionStore) List(ctx context.Context, opts *store.QueryOptions, fi
if platform, ok := filters["platform"].(string); ok && platform != "" { if platform, ok := filters["platform"].(string); ok && platform != "" {
query = query.Where("platform = ?", platform) query = query.Where("platform = ?", platform)
} }
if availableForRoleType, ok := filters["available_for_role_type"].(int); ok {
roleTypeStr := fmt.Sprintf("%d", availableForRoleType)
query = query.Where("available_for_role_types LIKE ?", "%"+roleTypeStr+"%")
}
if parentID, ok := filters["parent_id"].(uint); ok { if parentID, ok := filters["parent_id"].(uint); ok {
query = query.Where("parent_id = ?", parentID) query = query.Where("parent_id = ?", parentID)
} }
@@ -116,23 +121,33 @@ func (s *PermissionStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Pe
} }
// GetAll 获取所有权限(用于构建权限树) // GetAll 获取所有权限(用于构建权限树)
func (s *PermissionStore) GetAll(ctx context.Context) ([]*model.Permission, error) { func (s *PermissionStore) GetAll(ctx context.Context, availableForRoleType *int) ([]*model.Permission, error) {
var permissions []*model.Permission var permissions []*model.Permission
if err := s.db.WithContext(ctx).Order("sort ASC, id ASC").Find(&permissions).Error; err != nil { query := s.db.WithContext(ctx)
return nil, err
}
return permissions, nil
}
// GetByPlatform 根据端口获取权限列表 if availableForRoleType != nil {
// platform: 端口类型all/web/h5如果为空则返回所有权限 roleTypeStr := fmt.Sprintf("%d", *availableForRoleType)
func (s *PermissionStore) GetByPlatform(ctx context.Context, platform string) ([]*model.Permission, error) { query = query.Where("available_for_role_types LIKE ?", "%"+roleTypeStr+"%")
var permissions []*model.Permission }
query := s.db.WithContext(ctx).Where("status = ?", 1) // 只获取启用的权限
if err := query.Order("sort ASC, id ASC").Find(&permissions).Error; err != nil {
if platform != "" { return nil, err
// 获取指定端口的权限或通用权限platform='all' }
query = query.Where("platform = ? OR platform = ?", platform, "all") return permissions, nil
}
// GetByPlatform 根据端口获取权限列表
func (s *PermissionStore) GetByPlatform(ctx context.Context, platform string, availableForRoleType *int) ([]*model.Permission, error) {
var permissions []*model.Permission
query := s.db.WithContext(ctx).Where("status = ?", 1)
if platform != "" {
query = query.Where("platform = ? OR platform = ?", platform, "all")
}
if availableForRoleType != nil {
roleTypeStr := fmt.Sprintf("%d", *availableForRoleType)
query = query.Where("available_for_role_types LIKE ?", "%"+roleTypeStr+"%")
} }
if err := query.Order("sort ASC, id ASC").Find(&permissions).Error; err != nil { if err := query.Order("sort ASC, id ASC").Find(&permissions).Error; err != nil {

View File

@@ -3,43 +3,89 @@ package postgres
import ( import (
"context" "context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
) )
// RolePermissionStore 角色-权限关联数据访问层 // RolePermissionStore 角色-权限关联数据访问层
type RolePermissionStore struct { type RolePermissionStore struct {
db *gorm.DB db *gorm.DB
redisClient *redis.Client
} }
// NewRolePermissionStore 创建角色-权限关联 Store // NewRolePermissionStore 创建角色-权限关联 Store
func NewRolePermissionStore(db *gorm.DB) *RolePermissionStore { func NewRolePermissionStore(db *gorm.DB, redisClient *redis.Client) *RolePermissionStore {
return &RolePermissionStore{db: db} return &RolePermissionStore{
db: db,
redisClient: redisClient,
}
} }
// Create 创建角色-权限关联 // Create 创建角色-权限关联
func (s *RolePermissionStore) Create(ctx context.Context, rp *model.RolePermission) error { func (s *RolePermissionStore) Create(ctx context.Context, rp *model.RolePermission) error {
return s.db.WithContext(ctx).Create(rp).Error if err := s.db.WithContext(ctx).Create(rp).Error; err != nil {
return err
}
s.clearRoleUsersCaches(ctx, rp.RoleID)
return nil
} }
// BatchCreate 批量创建角色-权限关联 // BatchCreate 批量创建角色-权限关联
func (s *RolePermissionStore) BatchCreate(ctx context.Context, rps []*model.RolePermission) error { func (s *RolePermissionStore) BatchCreate(ctx context.Context, rps []*model.RolePermission) error {
return s.db.WithContext(ctx).Create(&rps).Error if err := s.db.WithContext(ctx).Create(&rps).Error; err != nil {
return err
}
roleIDs := make(map[uint]bool)
for _, rp := range rps {
roleIDs[rp.RoleID] = true
}
for roleID := range roleIDs {
s.clearRoleUsersCaches(ctx, roleID)
}
return nil
} }
// Delete 软删除角色-权限关联 // Delete 软删除角色-权限关联
func (s *RolePermissionStore) Delete(ctx context.Context, roleID, permID uint) error { func (s *RolePermissionStore) Delete(ctx context.Context, roleID, permID uint) error {
return s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("role_id = ? AND perm_id = ?", roleID, permID). Where("role_id = ? AND perm_id = ?", roleID, permID).
Delete(&model.RolePermission{}).Error Delete(&model.RolePermission{}).Error; err != nil {
return err
}
s.clearRoleUsersCaches(ctx, roleID)
return nil
} }
// DeleteByRoleID 删除角色的所有权限关联 // DeleteByRoleID 删除角色的所有权限关联
func (s *RolePermissionStore) DeleteByRoleID(ctx context.Context, roleID uint) error { func (s *RolePermissionStore) DeleteByRoleID(ctx context.Context, roleID uint) error {
return s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("role_id = ?", roleID). Where("role_id = ?", roleID).
Delete(&model.RolePermission{}).Error Delete(&model.RolePermission{}).Error; err != nil {
return err
}
s.clearRoleUsersCaches(ctx, roleID)
return nil
}
func (s *RolePermissionStore) clearRoleUsersCaches(ctx context.Context, roleID uint) {
if s.redisClient == nil {
return
}
var accountIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.AccountRole{}).
Where("role_id = ?", roleID).
Pluck("account_id", &accountIDs).Error; err != nil {
return
}
for _, accountID := range accountIDs {
key := constants.RedisUserPermissionsKey(accountID)
s.redisClient.Del(ctx, key)
}
} }
// GetByRoleID 获取角色的所有权限关联 // GetByRoleID 获取角色的所有权限关联

View File

@@ -0,0 +1,251 @@
-- IoT 模型架构重构回滚脚本
-- 创建时间: 2026-01-12
-- 说明: 回滚所有架构重构变更
-- 1. 恢复唯一索引(移除软删除支持)
-- 2. 恢复金额字段为 DECIMAL 类型
-- 3. 删除软删除字段和审计字段
-- 4. 恢复表名为复数形式
-- ========================================
-- 阶段 1: 恢复唯一约束(移除软删除支持)
-- ========================================
-- 1.1 运营商表
DROP INDEX IF EXISTS idx_carrier_code;
ALTER TABLE tb_carrier ADD CONSTRAINT carriers_carrier_code_key UNIQUE (carrier_code);
-- 1.2 IoT 卡表
DROP INDEX IF EXISTS idx_iot_card_iccid;
ALTER TABLE tb_iot_card ADD CONSTRAINT iot_cards_iccid_key UNIQUE (iccid);
-- 1.3 设备表
DROP INDEX IF EXISTS idx_device_no;
ALTER TABLE tb_device ADD CONSTRAINT devices_device_no_key UNIQUE (device_no);
-- 1.4 号卡表
DROP INDEX IF EXISTS idx_number_card_code;
ALTER TABLE tb_number_card ADD CONSTRAINT number_cards_virtual_product_code_key UNIQUE (virtual_product_code);
-- 1.5 套餐系列表
DROP INDEX IF EXISTS idx_package_series_code;
ALTER TABLE tb_package_series ADD CONSTRAINT package_series_series_code_key UNIQUE (series_code);
-- 1.6 套餐表
DROP INDEX IF EXISTS idx_package_code;
ALTER TABLE tb_package ADD CONSTRAINT packages_package_code_key UNIQUE (package_code);
-- 1.7 代理套餐分配表
DROP INDEX IF EXISTS idx_agent_package_allocation_agent_package;
ALTER TABLE tb_agent_package_allocation ADD CONSTRAINT uk_agent_package UNIQUE (agent_id, package_id);
-- 1.8 设备-SIM绑定表跳过保持原样
-- 1.9 订单表
DROP INDEX IF EXISTS idx_order_no;
ALTER TABLE tb_order ADD CONSTRAINT orders_order_no_key UNIQUE (order_no);
-- 1.10 轮询配置表
DROP INDEX IF EXISTS idx_polling_config_name;
ALTER TABLE tb_polling_config ADD CONSTRAINT polling_configs_config_name_key UNIQUE (config_name);
-- 1.11 代理层级表
DROP INDEX IF EXISTS idx_agent_hierarchy_agent;
ALTER TABLE tb_agent_hierarchy ADD CONSTRAINT agent_hierarchies_agent_id_key UNIQUE (agent_id);
-- 1.12 组合分佣条件表
DROP INDEX IF EXISTS idx_commission_combined_rule;
ALTER TABLE tb_commission_combined_condition ADD CONSTRAINT commission_combined_conditions_rule_id_key UNIQUE (rule_id);
-- 1.13 分佣模板表
DROP INDEX IF EXISTS idx_commission_template_name;
ALTER TABLE tb_commission_template ADD CONSTRAINT commission_templates_template_name_key UNIQUE (template_name);
-- 1.14 运营商结算表
DROP INDEX IF EXISTS idx_carrier_settlement_record;
ALTER TABLE tb_carrier_settlement ADD CONSTRAINT carrier_settlements_commission_record_id_key UNIQUE (commission_record_id);
-- 1.15 开发能力配置表
DROP INDEX IF EXISTS idx_dev_capability_app;
ALTER TABLE tb_dev_capability_config ADD CONSTRAINT dev_capability_configs_app_id_key UNIQUE (app_id);
-- ========================================
-- 阶段 2: 恢复金额字段为 DECIMAL 类型(分 / 100 = 元)
-- ========================================
-- 2.1 IoT 卡表
ALTER TABLE tb_iot_card
ALTER COLUMN cost_price TYPE DECIMAL(10,2) USING (cost_price / 100.0)::DECIMAL(10,2),
ALTER COLUMN distribute_price TYPE DECIMAL(10,2) USING (distribute_price / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_iot_card.cost_price IS '成本价(元)';
COMMENT ON COLUMN tb_iot_card.distribute_price IS '分销价(元)';
-- 2.2 号卡表
ALTER TABLE tb_number_card
ALTER COLUMN price TYPE DECIMAL(10,2) USING (price / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_number_card.price IS '价格(元)';
-- 2.3 套餐表
ALTER TABLE tb_package
ALTER COLUMN price TYPE DECIMAL(10,2) USING (price / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_package.price IS '套餐价格(元)';
-- 2.4 代理套餐分配表
ALTER TABLE tb_agent_package_allocation
ALTER COLUMN cost_price TYPE DECIMAL(10,2) USING (cost_price / 100.0)::DECIMAL(10,2),
ALTER COLUMN retail_price TYPE DECIMAL(10,2) USING (retail_price / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_agent_package_allocation.cost_price IS '成本价(元)';
COMMENT ON COLUMN tb_agent_package_allocation.retail_price IS '零售价(元)';
-- 2.5 订单表
ALTER TABLE tb_order
ALTER COLUMN amount TYPE DECIMAL(10,2) USING (amount / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_order.amount IS '订单金额(元)';
-- 2.6 分佣规则表
ALTER TABLE tb_commission_rule
ALTER COLUMN commission_value TYPE DECIMAL(10,2) USING (commission_value / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_rule.commission_value IS '分佣值(元或百分比)';
-- 2.7 阶梯分佣配置表
ALTER TABLE tb_commission_ladder
ALTER COLUMN commission_value TYPE DECIMAL(10,2) USING (commission_value / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_ladder.commission_value IS '分佣值(元或百分比)';
-- 2.8 组合分佣条件表
ALTER TABLE tb_commission_combined_condition
ALTER COLUMN one_time_commission_value TYPE DECIMAL(10,2) USING (one_time_commission_value / 100.0)::DECIMAL(10,2),
ALTER COLUMN long_term_commission_value TYPE DECIMAL(10,2) USING (long_term_commission_value / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_combined_condition.one_time_commission_value IS '一次性分佣值(元或百分比)';
COMMENT ON COLUMN tb_commission_combined_condition.long_term_commission_value IS '长期分佣值(元或百分比)';
-- 2.9 分佣记录表
ALTER TABLE tb_commission_record
ALTER COLUMN amount TYPE DECIMAL(10,2) USING (amount / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_record.amount IS '分佣金额(元)';
-- 2.10 分佣模板表
ALTER TABLE tb_commission_template
ALTER COLUMN commission_value TYPE DECIMAL(10,2) USING (commission_value / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_template.commission_value IS '分佣值(元或百分比)';
-- 2.11 运营商结算表
ALTER TABLE tb_carrier_settlement
ALTER COLUMN settlement_amount TYPE DECIMAL(10,2) USING (settlement_amount / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_carrier_settlement.settlement_amount IS '结算金额(元)';
-- 2.12 佣金提现申请表
ALTER TABLE tb_commission_withdrawal_request
ALTER COLUMN amount TYPE DECIMAL(10,2) USING (amount / 100.0)::DECIMAL(10,2),
ALTER COLUMN fee TYPE DECIMAL(10,2) USING (fee / 100.0)::DECIMAL(10,2),
ALTER COLUMN actual_amount TYPE DECIMAL(10,2) USING (actual_amount / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_withdrawal_request.amount IS '提现金额(元)';
COMMENT ON COLUMN tb_commission_withdrawal_request.fee IS '手续费(元)';
COMMENT ON COLUMN tb_commission_withdrawal_request.actual_amount IS '实际到账金额(元)';
-- 2.13 佣金提现设置表
ALTER TABLE tb_commission_withdrawal_setting
ALTER COLUMN min_withdrawal_amount TYPE DECIMAL(10,2) USING (min_withdrawal_amount / 100.0)::DECIMAL(10,2);
COMMENT ON COLUMN tb_commission_withdrawal_setting.min_withdrawal_amount IS '最低提现金额(元)';
-- ========================================
-- 阶段 3: 删除软删除字段和审计字段
-- ========================================
ALTER TABLE tb_carrier DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_iot_card DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_device DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_number_card DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_package_series DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_package DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_agent_package_allocation DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_device_sim_binding DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_order DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_package_usage DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_polling_config DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
-- 注意: tb_data_usage_record 没有添加这些字段,所以不需要删除
ALTER TABLE tb_agent_hierarchy DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_rule DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_ladder DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_combined_condition DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_record DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_approval DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_template DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_carrier_settlement DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_withdrawal_request DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_commission_withdrawal_setting DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_payment_merchant_setting DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_dev_capability_config DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
ALTER TABLE tb_card_replacement_request DROP COLUMN deleted_at, DROP COLUMN creator, DROP COLUMN updater;
-- ========================================
-- 阶段 4: 恢复表名为复数形式
-- ========================================
ALTER TABLE tb_carrier RENAME TO carriers;
ALTER TABLE tb_iot_card RENAME TO iot_cards;
ALTER TABLE tb_device RENAME TO devices;
ALTER TABLE tb_number_card RENAME TO number_cards;
ALTER TABLE tb_package_series RENAME TO package_series;
ALTER TABLE tb_package RENAME TO packages;
ALTER TABLE tb_agent_package_allocation RENAME TO agent_package_allocations;
ALTER TABLE tb_device_sim_binding RENAME TO device_sim_bindings;
ALTER TABLE tb_order RENAME TO orders;
ALTER TABLE tb_package_usage RENAME TO package_usages;
ALTER TABLE tb_polling_config RENAME TO polling_configs;
ALTER TABLE tb_data_usage_record RENAME TO data_usage_records;
ALTER TABLE tb_agent_hierarchy RENAME TO agent_hierarchies;
ALTER TABLE tb_commission_rule RENAME TO commission_rules;
ALTER TABLE tb_commission_ladder RENAME TO commission_ladder;
ALTER TABLE tb_commission_combined_condition RENAME TO commission_combined_conditions;
ALTER TABLE tb_commission_record RENAME TO commission_records;
ALTER TABLE tb_commission_approval RENAME TO commission_approvals;
ALTER TABLE tb_commission_template RENAME TO commission_templates;
ALTER TABLE tb_carrier_settlement RENAME TO carrier_settlements;
ALTER TABLE tb_commission_withdrawal_request RENAME TO commission_withdrawal_requests;
ALTER TABLE tb_commission_withdrawal_setting RENAME TO commission_withdrawal_settings;
ALTER TABLE tb_payment_merchant_setting RENAME TO payment_merchant_settings;
ALTER TABLE tb_dev_capability_config RENAME TO dev_capability_configs;
ALTER TABLE tb_card_replacement_request RENAME TO card_replacement_requests;
-- ========================================
-- 阶段 5: 恢复表注释
-- ========================================
COMMENT ON TABLE carriers IS '运营商表';
COMMENT ON TABLE iot_cards IS 'IoT 卡表(物联网卡/流量卡)';
COMMENT ON TABLE devices IS '设备表(可容纳1-4张SIM卡)';
COMMENT ON TABLE number_cards IS '号卡表(虚拟商品)';
COMMENT ON TABLE package_series IS '套餐系列表';
COMMENT ON TABLE packages IS '套餐表';
COMMENT ON TABLE agent_package_allocations IS '代理套餐分配表';
COMMENT ON TABLE device_sim_bindings IS '设备-SIM卡绑定表';
COMMENT ON TABLE orders IS '订单表';
COMMENT ON TABLE package_usages IS '套餐使用表';
COMMENT ON TABLE polling_configs IS '轮询配置表';
COMMENT ON TABLE data_usage_records IS '流量使用记录表';
COMMENT ON TABLE agent_hierarchies IS '代理层级关系表';
COMMENT ON TABLE commission_rules IS '分佣规则表';
COMMENT ON TABLE commission_ladder IS '阶梯分佣配置表';
COMMENT ON TABLE commission_combined_conditions IS '组合分佣条件表';
COMMENT ON TABLE commission_records IS '分佣记录表';
COMMENT ON TABLE commission_approvals IS '分佣审批表';
COMMENT ON TABLE commission_templates IS '分佣模板表';
COMMENT ON TABLE carrier_settlements IS '号卡运营商结算表';
COMMENT ON TABLE commission_withdrawal_requests IS '佣金提现申请表';
COMMENT ON TABLE commission_withdrawal_settings IS '佣金提现设置表';
COMMENT ON TABLE payment_merchant_settings IS '收款商户设置表';
COMMENT ON TABLE dev_capability_configs IS '开发能力配置表';
COMMENT ON TABLE card_replacement_requests IS '换卡申请表';

View File

@@ -0,0 +1,349 @@
-- IoT 模型架构重构迁移脚本
-- 创建时间: 2026-01-12
-- 说明: 修改所有 IoT 相关表以符合项目架构规范
-- 1. 表名改为 tb_ 前缀 + 单数形式
-- 2. 添加软删除字段 deleted_at
-- 3. 添加审计字段 creator, updater
-- 4. 金额字段从 DECIMAL 改为 BIGINT分为单位
-- 5. 更新唯一索引以支持软删除
-- ========================================
-- 阶段 1: 重命名表(复数 -> tb_ + 单数)
-- ========================================
ALTER TABLE carriers RENAME TO tb_carrier;
ALTER TABLE iot_cards RENAME TO tb_iot_card;
ALTER TABLE devices RENAME TO tb_device;
ALTER TABLE number_cards RENAME TO tb_number_card;
ALTER TABLE package_series RENAME TO tb_package_series;
ALTER TABLE packages RENAME TO tb_package;
ALTER TABLE agent_package_allocations RENAME TO tb_agent_package_allocation;
ALTER TABLE device_sim_bindings RENAME TO tb_device_sim_binding;
ALTER TABLE orders RENAME TO tb_order;
ALTER TABLE package_usages RENAME TO tb_package_usage;
ALTER TABLE polling_configs RENAME TO tb_polling_config;
ALTER TABLE data_usage_records RENAME TO tb_data_usage_record;
ALTER TABLE agent_hierarchies RENAME TO tb_agent_hierarchy;
ALTER TABLE commission_rules RENAME TO tb_commission_rule;
ALTER TABLE commission_ladder RENAME TO tb_commission_ladder;
ALTER TABLE commission_combined_conditions RENAME TO tb_commission_combined_condition;
ALTER TABLE commission_records RENAME TO tb_commission_record;
ALTER TABLE commission_approvals RENAME TO tb_commission_approval;
ALTER TABLE commission_templates RENAME TO tb_commission_template;
ALTER TABLE carrier_settlements RENAME TO tb_carrier_settlement;
ALTER TABLE commission_withdrawal_requests RENAME TO tb_commission_withdrawal_request;
ALTER TABLE commission_withdrawal_settings RENAME TO tb_commission_withdrawal_setting;
ALTER TABLE payment_merchant_settings RENAME TO tb_payment_merchant_setting;
ALTER TABLE dev_capability_configs RENAME TO tb_dev_capability_config;
ALTER TABLE card_replacement_requests RENAME TO tb_card_replacement_request;
-- ========================================
-- 阶段 2: 添加软删除字段和审计字段
-- ========================================
-- 2.1 运营商表
ALTER TABLE tb_carrier ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_carrier ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_carrier ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.2 IoT 卡表
ALTER TABLE tb_iot_card ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_iot_card ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_iot_card ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.3 设备表
ALTER TABLE tb_device ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_device ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_device ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.4 号卡表
ALTER TABLE tb_number_card ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_number_card ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_number_card ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.5 套餐系列表
ALTER TABLE tb_package_series ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_package_series ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_package_series ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.6 套餐表
ALTER TABLE tb_package ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_package ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_package ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.7 代理套餐分配表
ALTER TABLE tb_agent_package_allocation ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_agent_package_allocation ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_agent_package_allocation ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.8 设备-SIM绑定表
ALTER TABLE tb_device_sim_binding ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_device_sim_binding ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_device_sim_binding ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.9 订单表
ALTER TABLE tb_order ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_order ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_order ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.10 套餐使用表
ALTER TABLE tb_package_usage ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_package_usage ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_package_usage ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.11 轮询配置表
ALTER TABLE tb_polling_config ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_polling_config ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_polling_config ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 注意: tb_data_usage_record 是日志表,不需要软删除和审计字段
-- 2.12 代理层级表
ALTER TABLE tb_agent_hierarchy ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_agent_hierarchy ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_agent_hierarchy ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.13 分佣规则表
ALTER TABLE tb_commission_rule ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_rule ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_rule ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.14 阶梯分佣配置表
ALTER TABLE tb_commission_ladder ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_ladder ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_ladder ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.15 组合分佣条件表
ALTER TABLE tb_commission_combined_condition ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_combined_condition ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_combined_condition ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.16 分佣记录表
ALTER TABLE tb_commission_record ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_record ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_record ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.17 分佣审批表
ALTER TABLE tb_commission_approval ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_approval ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_approval ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.18 分佣模板表
ALTER TABLE tb_commission_template ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_template ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_template ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.19 运营商结算表
ALTER TABLE tb_carrier_settlement ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_carrier_settlement ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_carrier_settlement ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.20 佣金提现申请表
ALTER TABLE tb_commission_withdrawal_request ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_withdrawal_request ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_withdrawal_request ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.21 佣金提现设置表
ALTER TABLE tb_commission_withdrawal_setting ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_commission_withdrawal_setting ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_commission_withdrawal_setting ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.22 收款商户设置表
ALTER TABLE tb_payment_merchant_setting ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_payment_merchant_setting ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_payment_merchant_setting ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.23 开发能力配置表
ALTER TABLE tb_dev_capability_config ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_dev_capability_config ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_dev_capability_config ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- 2.24 换卡申请表
ALTER TABLE tb_card_replacement_request ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tb_card_replacement_request ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_card_replacement_request ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
-- ========================================
-- 阶段 3: 修改金额字段从 DECIMAL 改为 BIGINT分为单位
-- ========================================
-- 3.1 IoT 卡表:成本价、分销价(元 * 100 = 分)
ALTER TABLE tb_iot_card
ALTER COLUMN cost_price TYPE BIGINT USING (cost_price * 100)::BIGINT,
ALTER COLUMN distribute_price TYPE BIGINT USING (distribute_price * 100)::BIGINT;
COMMENT ON COLUMN tb_iot_card.cost_price IS '成本价(分为单位)';
COMMENT ON COLUMN tb_iot_card.distribute_price IS '分销价(分为单位)';
-- 3.2 号卡表:价格
ALTER TABLE tb_number_card
ALTER COLUMN price TYPE BIGINT USING (price * 100)::BIGINT;
COMMENT ON COLUMN tb_number_card.price IS '价格(分为单位)';
-- 3.3 套餐表:价格
ALTER TABLE tb_package
ALTER COLUMN price TYPE BIGINT USING (price * 100)::BIGINT;
COMMENT ON COLUMN tb_package.price IS '套餐价格(分为单位)';
-- 3.4 代理套餐分配表:成本价和零售价
ALTER TABLE tb_agent_package_allocation
ALTER COLUMN cost_price TYPE BIGINT USING (cost_price * 100)::BIGINT,
ALTER COLUMN retail_price TYPE BIGINT USING (retail_price * 100)::BIGINT;
COMMENT ON COLUMN tb_agent_package_allocation.cost_price IS '成本价(分为单位)';
COMMENT ON COLUMN tb_agent_package_allocation.retail_price IS '零售价(分为单位)';
-- 3.5 订单表:订单金额
ALTER TABLE tb_order
ALTER COLUMN amount TYPE BIGINT USING (amount * 100)::BIGINT;
COMMENT ON COLUMN tb_order.amount IS '订单金额(分为单位)';
-- 3.6 分佣规则表:分佣值
ALTER TABLE tb_commission_rule
ALTER COLUMN commission_value TYPE BIGINT USING (commission_value * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_rule.commission_value IS '分佣值(分为单位,百分比时为千分比如2000表示20%)';
-- 3.7 阶梯分佣配置表:分佣值
ALTER TABLE tb_commission_ladder
ALTER COLUMN commission_value TYPE BIGINT USING (commission_value * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_ladder.commission_value IS '分佣值(分为单位,百分比时为千分比如2000表示20%)';
-- 3.8 组合分佣条件表:一次性分佣值、长期分佣值
ALTER TABLE tb_commission_combined_condition
ALTER COLUMN one_time_commission_value TYPE BIGINT USING (one_time_commission_value * 100)::BIGINT,
ALTER COLUMN long_term_commission_value TYPE BIGINT USING (long_term_commission_value * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_combined_condition.one_time_commission_value IS '一次性分佣值(分为单位,百分比时为千分比如2000表示20%)';
COMMENT ON COLUMN tb_commission_combined_condition.long_term_commission_value IS '长期分佣值(分为单位,百分比时为千分比如2000表示20%)';
-- 3.9 分佣记录表:分佣金额
ALTER TABLE tb_commission_record
ALTER COLUMN amount TYPE BIGINT USING (amount * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_record.amount IS '分佣金额(分为单位)';
-- 3.10 分佣模板表:分佣值
ALTER TABLE tb_commission_template
ALTER COLUMN commission_value TYPE BIGINT USING (commission_value * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_template.commission_value IS '分佣值(分为单位,百分比时为千分比如2000表示20%)';
-- 3.11 运营商结算表:结算金额
ALTER TABLE tb_carrier_settlement
ALTER COLUMN settlement_amount TYPE BIGINT USING (settlement_amount * 100)::BIGINT;
COMMENT ON COLUMN tb_carrier_settlement.settlement_amount IS '结算金额(分为单位)';
-- 3.12 佣金提现申请表:提现金额、手续费、实际到账金额
ALTER TABLE tb_commission_withdrawal_request
ALTER COLUMN amount TYPE BIGINT USING (amount * 100)::BIGINT,
ALTER COLUMN fee TYPE BIGINT USING (fee * 100)::BIGINT,
ALTER COLUMN actual_amount TYPE BIGINT USING (actual_amount * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_withdrawal_request.amount IS '提现金额(分为单位)';
COMMENT ON COLUMN tb_commission_withdrawal_request.fee IS '手续费(分为单位)';
COMMENT ON COLUMN tb_commission_withdrawal_request.actual_amount IS '实际到账金额(分为单位)';
-- 3.13 佣金提现设置表:最低提现金额
ALTER TABLE tb_commission_withdrawal_setting
ALTER COLUMN min_withdrawal_amount TYPE BIGINT USING (min_withdrawal_amount * 100)::BIGINT;
COMMENT ON COLUMN tb_commission_withdrawal_setting.min_withdrawal_amount IS '最低提现金额(分为单位)';
-- ========================================
-- 阶段 4: 更新唯一索引以支持软删除
-- ========================================
-- 4.1 运营商表
ALTER TABLE tb_carrier DROP CONSTRAINT IF EXISTS carriers_carrier_code_key;
CREATE UNIQUE INDEX idx_carrier_code ON tb_carrier(carrier_code) WHERE deleted_at IS NULL;
-- 4.2 IoT 卡表
ALTER TABLE tb_iot_card DROP CONSTRAINT IF EXISTS iot_cards_iccid_key;
CREATE UNIQUE INDEX idx_iot_card_iccid ON tb_iot_card(iccid) WHERE deleted_at IS NULL;
-- 4.3 设备表
ALTER TABLE tb_device DROP CONSTRAINT IF EXISTS devices_device_no_key;
CREATE UNIQUE INDEX idx_device_no ON tb_device(device_no) WHERE deleted_at IS NULL;
-- 4.4 号卡表
ALTER TABLE tb_number_card DROP CONSTRAINT IF EXISTS number_cards_virtual_product_code_key;
CREATE UNIQUE INDEX idx_number_card_code ON tb_number_card(virtual_product_code) WHERE deleted_at IS NULL;
-- 4.5 套餐系列表
ALTER TABLE tb_package_series DROP CONSTRAINT IF EXISTS package_series_series_code_key;
CREATE UNIQUE INDEX idx_package_series_code ON tb_package_series(series_code) WHERE deleted_at IS NULL;
-- 4.6 套餐表
ALTER TABLE tb_package DROP CONSTRAINT IF EXISTS packages_package_code_key;
CREATE UNIQUE INDEX idx_package_code ON tb_package(package_code) WHERE deleted_at IS NULL;
-- 4.7 代理套餐分配表(复合唯一索引)
ALTER TABLE tb_agent_package_allocation DROP CONSTRAINT IF EXISTS uk_agent_package;
CREATE UNIQUE INDEX idx_agent_package_allocation_agent_package ON tb_agent_package_allocation(agent_id, package_id) WHERE deleted_at IS NULL;
-- 4.8 设备-SIM绑定表暂时跳过因为没有约束需要修改
-- 原始表使用条件唯一索引 idx_device_sim_bindings_active_card不需要修改
-- 4.9 订单表
ALTER TABLE tb_order DROP CONSTRAINT IF EXISTS orders_order_no_key;
CREATE UNIQUE INDEX idx_order_no ON tb_order(order_no) WHERE deleted_at IS NULL;
-- 4.10 轮询配置表
ALTER TABLE tb_polling_config DROP CONSTRAINT IF EXISTS polling_configs_config_name_key;
CREATE UNIQUE INDEX idx_polling_config_name ON tb_polling_config(config_name) WHERE deleted_at IS NULL;
-- 4.11 代理层级表
ALTER TABLE tb_agent_hierarchy DROP CONSTRAINT IF EXISTS agent_hierarchies_agent_id_key;
CREATE UNIQUE INDEX idx_agent_hierarchy_agent ON tb_agent_hierarchy(agent_id) WHERE deleted_at IS NULL;
-- 4.12 组合分佣条件表
ALTER TABLE tb_commission_combined_condition DROP CONSTRAINT IF EXISTS commission_combined_conditions_rule_id_key;
CREATE UNIQUE INDEX idx_commission_combined_rule ON tb_commission_combined_condition(rule_id) WHERE deleted_at IS NULL;
-- 4.13 分佣模板表
ALTER TABLE tb_commission_template DROP CONSTRAINT IF EXISTS commission_templates_template_name_key;
CREATE UNIQUE INDEX idx_commission_template_name ON tb_commission_template(template_name) WHERE deleted_at IS NULL;
-- 4.14 运营商结算表
ALTER TABLE tb_carrier_settlement DROP CONSTRAINT IF EXISTS carrier_settlements_commission_record_id_key;
CREATE UNIQUE INDEX idx_carrier_settlement_record ON tb_carrier_settlement(commission_record_id) WHERE deleted_at IS NULL;
-- 4.15 开发能力配置表
ALTER TABLE tb_dev_capability_config DROP CONSTRAINT IF EXISTS dev_capability_configs_app_id_key;
CREATE UNIQUE INDEX idx_dev_capability_app ON tb_dev_capability_config(app_id) WHERE deleted_at IS NULL;
-- ========================================
-- 阶段 5: 更新表注释
-- ========================================
COMMENT ON TABLE tb_carrier IS '运营商表';
COMMENT ON TABLE tb_iot_card IS 'IoT卡表(物联网卡/流量卡)';
COMMENT ON TABLE tb_device IS '设备表(可容纳1-4张SIM卡)';
COMMENT ON TABLE tb_number_card IS '号卡表(虚拟商品)';
COMMENT ON TABLE tb_package_series IS '套餐系列表';
COMMENT ON TABLE tb_package IS '套餐表';
COMMENT ON TABLE tb_agent_package_allocation IS '代理套餐分配表';
COMMENT ON TABLE tb_device_sim_binding IS '设备-SIM卡绑定表';
COMMENT ON TABLE tb_order IS '订单表';
COMMENT ON TABLE tb_package_usage IS '套餐使用表';
COMMENT ON TABLE tb_polling_config IS '轮询配置表';
COMMENT ON TABLE tb_data_usage_record IS '流量使用记录表';
COMMENT ON TABLE tb_agent_hierarchy IS '代理层级关系表';
COMMENT ON TABLE tb_commission_rule IS '分佣规则表';
COMMENT ON TABLE tb_commission_ladder IS '阶梯分佣配置表';
COMMENT ON TABLE tb_commission_combined_condition IS '组合分佣条件表';
COMMENT ON TABLE tb_commission_record IS '分佣记录表';
COMMENT ON TABLE tb_commission_approval IS '分佣审批表';
COMMENT ON TABLE tb_commission_template IS '分佣模板表';
COMMENT ON TABLE tb_carrier_settlement IS '号卡运营商结算表';
COMMENT ON TABLE tb_commission_withdrawal_request IS '佣金提现申请表';
COMMENT ON TABLE tb_commission_withdrawal_setting IS '佣金提现设置表';
COMMENT ON TABLE tb_payment_merchant_setting IS '收款商户设置表';
COMMENT ON TABLE tb_dev_capability_config IS '开发能力配置表';
COMMENT ON TABLE tb_card_replacement_request IS '换卡申请表';

View File

@@ -0,0 +1,50 @@
-- Rollback: Remove wallet, card replacement, tag tables and revert carrier, order changes
-- Created: 2026-01-13
-- Revert order table changes
ALTER TABLE tb_order DROP COLUMN IF EXISTS online_payment_amount;
ALTER TABLE tb_order DROP COLUMN IF EXISTS wallet_payment_amount;
-- Revert carrier table changes
DROP INDEX IF EXISTS idx_carrier_type_channel;
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS channel_code;
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS channel_name;
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS carrier_type;
-- Drop tag tables
DROP INDEX IF EXISTS idx_resource_tag_unique;
DROP INDEX IF EXISTS idx_resource_tag_composite;
DROP INDEX IF EXISTS idx_resource_tag_tag;
DROP INDEX IF EXISTS idx_resource_tag_resource;
DROP TABLE IF EXISTS tb_resource_tag;
DROP INDEX IF EXISTS idx_tag_name;
DROP INDEX IF EXISTS idx_tag_usage;
DROP TABLE IF EXISTS tb_tag;
-- Drop card replacement table
DROP INDEX IF EXISTS idx_card_replacement_no;
DROP INDEX IF EXISTS idx_card_replacement_status;
DROP INDEX IF EXISTS idx_card_replacement_new_owner;
DROP INDEX IF EXISTS idx_card_replacement_old_owner;
DROP INDEX IF EXISTS idx_card_replacement_new_card;
DROP INDEX IF EXISTS idx_card_replacement_old_card;
DROP TABLE IF EXISTS tb_card_replacement_record;
-- Drop recharge record table
DROP INDEX IF EXISTS idx_recharge_no;
DROP INDEX IF EXISTS idx_recharge_status;
DROP INDEX IF EXISTS idx_recharge_user;
DROP TABLE IF EXISTS tb_recharge_record;
-- Drop wallet transaction table
DROP INDEX IF EXISTS idx_wallet_tx_ref;
DROP INDEX IF EXISTS idx_wallet_tx_user;
DROP INDEX IF EXISTS idx_wallet_tx_wallet;
DROP TABLE IF EXISTS tb_wallet_transaction;
-- Drop wallet table
DROP INDEX IF EXISTS idx_wallet_user_type_currency;
DROP INDEX IF EXISTS idx_wallet_status;
DROP INDEX IF EXISTS idx_wallet_user;
DROP TABLE IF EXISTS tb_wallet;

View File

@@ -0,0 +1,285 @@
-- Migration: Add wallet, card replacement, tag tables and modify carrier, order tables
-- Created: 2026-01-13
-- =====================================================
-- 1. 钱包系统3 张表)
-- =====================================================
-- 1.1 钱包表
CREATE TABLE IF NOT EXISTS tb_wallet (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
wallet_type VARCHAR(20) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
frozen_balance BIGINT NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
status INT NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 0,
creator BIGINT,
updater BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_wallet IS '钱包表';
COMMENT ON COLUMN tb_wallet.id IS '钱包ID';
COMMENT ON COLUMN tb_wallet.user_id IS '用户ID关联tb_account.id';
COMMENT ON COLUMN tb_wallet.wallet_type IS '钱包类型user-用户钱包, agent-代理钱包';
COMMENT ON COLUMN tb_wallet.balance IS '余额(分)';
COMMENT ON COLUMN tb_wallet.frozen_balance IS '冻结余额(分)';
COMMENT ON COLUMN tb_wallet.currency IS '币种';
COMMENT ON COLUMN tb_wallet.status IS '钱包状态1-正常 2-冻结 3-关闭';
COMMENT ON COLUMN tb_wallet.version IS '版本号(乐观锁)';
COMMENT ON COLUMN tb_wallet.creator IS '创建人ID';
COMMENT ON COLUMN tb_wallet.updater IS '更新人ID';
COMMENT ON COLUMN tb_wallet.created_at IS '创建时间';
COMMENT ON COLUMN tb_wallet.updated_at IS '更新时间';
COMMENT ON COLUMN tb_wallet.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_wallet_user ON tb_wallet(user_id, deleted_at);
CREATE INDEX idx_wallet_status ON tb_wallet(status, deleted_at);
CREATE UNIQUE INDEX idx_wallet_user_type_currency ON tb_wallet(user_id, wallet_type, currency) WHERE deleted_at IS NULL;
-- 1.2 钱包明细表
CREATE TABLE IF NOT EXISTS tb_wallet_transaction (
id BIGSERIAL PRIMARY KEY,
wallet_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
transaction_type VARCHAR(20) NOT NULL,
amount BIGINT NOT NULL,
balance_before BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
status INT NOT NULL DEFAULT 1,
reference_type VARCHAR(50),
reference_id BIGINT,
remark TEXT,
metadata JSONB,
creator BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_wallet_transaction IS '钱包明细表';
COMMENT ON COLUMN tb_wallet_transaction.id IS '明细ID';
COMMENT ON COLUMN tb_wallet_transaction.wallet_id IS '钱包ID关联tb_wallet.id';
COMMENT ON COLUMN tb_wallet_transaction.user_id IS '用户ID关联tb_account.id';
COMMENT ON COLUMN tb_wallet_transaction.transaction_type IS '交易类型recharge-充值, deduct-扣款, refund-退款, commission-分佣, withdrawal-提现';
COMMENT ON COLUMN tb_wallet_transaction.amount IS '变动金额(分),正数为增加,负数为减少';
COMMENT ON COLUMN tb_wallet_transaction.balance_before IS '变动前余额(分)';
COMMENT ON COLUMN tb_wallet_transaction.balance_after IS '变动后余额(分)';
COMMENT ON COLUMN tb_wallet_transaction.status IS '交易状态1-成功 2-失败 3-处理中';
COMMENT ON COLUMN tb_wallet_transaction.reference_type IS '关联业务类型order, commission, withdrawal, topup';
COMMENT ON COLUMN tb_wallet_transaction.reference_id IS '关联业务ID';
COMMENT ON COLUMN tb_wallet_transaction.remark IS '备注';
COMMENT ON COLUMN tb_wallet_transaction.metadata IS '扩展信息JSON';
COMMENT ON COLUMN tb_wallet_transaction.creator IS '创建人ID';
COMMENT ON COLUMN tb_wallet_transaction.created_at IS '创建时间';
COMMENT ON COLUMN tb_wallet_transaction.updated_at IS '更新时间';
COMMENT ON COLUMN tb_wallet_transaction.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_wallet_tx_wallet ON tb_wallet_transaction(wallet_id, created_at DESC);
CREATE INDEX idx_wallet_tx_user ON tb_wallet_transaction(user_id, created_at DESC);
CREATE INDEX idx_wallet_tx_ref ON tb_wallet_transaction(reference_type, reference_id);
-- 1.3 充值记录表
CREATE TABLE IF NOT EXISTS tb_recharge_record (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
wallet_id BIGINT NOT NULL,
recharge_no VARCHAR(50) NOT NULL,
amount BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
payment_channel VARCHAR(50),
payment_transaction_id VARCHAR(100),
status INT NOT NULL DEFAULT 1,
paid_at TIMESTAMP,
completed_at TIMESTAMP,
creator BIGINT,
updater BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_recharge_record IS '充值记录表';
COMMENT ON COLUMN tb_recharge_record.id IS '充值记录ID';
COMMENT ON COLUMN tb_recharge_record.user_id IS '用户ID关联tb_account.id';
COMMENT ON COLUMN tb_recharge_record.wallet_id IS '钱包ID关联tb_wallet.id';
COMMENT ON COLUMN tb_recharge_record.recharge_no IS '充值订单号(唯一)';
COMMENT ON COLUMN tb_recharge_record.amount IS '充值金额(分)';
COMMENT ON COLUMN tb_recharge_record.payment_method IS '支付方式alipay-支付宝, wechat-微信, bank-银行转账, offline-线下';
COMMENT ON COLUMN tb_recharge_record.payment_channel IS '支付渠道';
COMMENT ON COLUMN tb_recharge_record.payment_transaction_id IS '第三方支付交易号';
COMMENT ON COLUMN tb_recharge_record.status IS '充值状态1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款';
COMMENT ON COLUMN tb_recharge_record.paid_at IS '支付时间';
COMMENT ON COLUMN tb_recharge_record.completed_at IS '完成时间';
COMMENT ON COLUMN tb_recharge_record.creator IS '创建人ID';
COMMENT ON COLUMN tb_recharge_record.updater IS '更新人ID';
COMMENT ON COLUMN tb_recharge_record.created_at IS '创建时间';
COMMENT ON COLUMN tb_recharge_record.updated_at IS '更新时间';
COMMENT ON COLUMN tb_recharge_record.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_recharge_user ON tb_recharge_record(user_id, created_at DESC);
CREATE INDEX idx_recharge_status ON tb_recharge_record(status, created_at DESC);
CREATE UNIQUE INDEX idx_recharge_no ON tb_recharge_record(recharge_no) WHERE deleted_at IS NULL;
-- =====================================================
-- 2. 换卡系统1 张表)
-- =====================================================
CREATE TABLE IF NOT EXISTS tb_card_replacement_record (
id BIGSERIAL PRIMARY KEY,
replacement_no VARCHAR(50) NOT NULL,
old_card_id BIGINT NOT NULL,
old_iccid VARCHAR(50) NOT NULL,
new_card_id BIGINT NOT NULL,
new_iccid VARCHAR(50) NOT NULL,
old_owner_type VARCHAR(20) NOT NULL,
old_owner_id BIGINT NOT NULL,
old_agent_id BIGINT,
new_owner_type VARCHAR(20) NOT NULL,
new_owner_id BIGINT NOT NULL,
new_agent_id BIGINT,
package_snapshot JSONB,
replacement_reason VARCHAR(20) NOT NULL,
remark TEXT,
status INT NOT NULL DEFAULT 1,
approved_by BIGINT,
approved_at TIMESTAMP,
completed_at TIMESTAMP,
creator BIGINT,
updater BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_card_replacement_record IS '换卡记录表';
COMMENT ON COLUMN tb_card_replacement_record.id IS '换卡记录ID';
COMMENT ON COLUMN tb_card_replacement_record.replacement_no IS '换卡单号(唯一)';
COMMENT ON COLUMN tb_card_replacement_record.old_card_id IS '老卡ID关联tb_iot_card.id';
COMMENT ON COLUMN tb_card_replacement_record.old_iccid IS '老卡ICCID冗余存储';
COMMENT ON COLUMN tb_card_replacement_record.new_card_id IS '新卡ID关联tb_iot_card.id';
COMMENT ON COLUMN tb_card_replacement_record.new_iccid IS '新卡ICCID冗余存储';
COMMENT ON COLUMN tb_card_replacement_record.old_owner_type IS '老卡所有者类型';
COMMENT ON COLUMN tb_card_replacement_record.old_owner_id IS '老卡所有者ID';
COMMENT ON COLUMN tb_card_replacement_record.old_agent_id IS '老卡代理ID';
COMMENT ON COLUMN tb_card_replacement_record.new_owner_type IS '新卡所有者类型';
COMMENT ON COLUMN tb_card_replacement_record.new_owner_id IS '新卡所有者ID';
COMMENT ON COLUMN tb_card_replacement_record.new_agent_id IS '新卡代理ID';
COMMENT ON COLUMN tb_card_replacement_record.package_snapshot IS '套餐快照JSON';
COMMENT ON COLUMN tb_card_replacement_record.replacement_reason IS '换卡原因damaged-损坏, lost-丢失, malfunction-故障, upgrade-升级, other-其他';
COMMENT ON COLUMN tb_card_replacement_record.remark IS '备注';
COMMENT ON COLUMN tb_card_replacement_record.status IS '换卡状态1-待审批 2-已通过 3-已拒绝 4-已完成';
COMMENT ON COLUMN tb_card_replacement_record.approved_by IS '审批人ID';
COMMENT ON COLUMN tb_card_replacement_record.approved_at IS '审批时间';
COMMENT ON COLUMN tb_card_replacement_record.completed_at IS '完成时间';
COMMENT ON COLUMN tb_card_replacement_record.creator IS '创建人ID';
COMMENT ON COLUMN tb_card_replacement_record.updater IS '更新人ID';
COMMENT ON COLUMN tb_card_replacement_record.created_at IS '创建时间';
COMMENT ON COLUMN tb_card_replacement_record.updated_at IS '更新时间';
COMMENT ON COLUMN tb_card_replacement_record.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_card_replacement_old_card ON tb_card_replacement_record(old_card_id, created_at DESC);
CREATE INDEX idx_card_replacement_new_card ON tb_card_replacement_record(new_card_id, created_at DESC);
CREATE INDEX idx_card_replacement_old_owner ON tb_card_replacement_record(old_owner_type, old_owner_id);
CREATE INDEX idx_card_replacement_new_owner ON tb_card_replacement_record(new_owner_type, new_owner_id);
CREATE INDEX idx_card_replacement_status ON tb_card_replacement_record(status, created_at DESC);
CREATE UNIQUE INDEX idx_card_replacement_no ON tb_card_replacement_record(replacement_no) WHERE deleted_at IS NULL;
-- =====================================================
-- 3. 标签系统2 张表)
-- =====================================================
-- 3.1 标签表
CREATE TABLE IF NOT EXISTS tb_tag (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
color VARCHAR(20),
usage_count INT NOT NULL DEFAULT 0,
creator BIGINT,
updater BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_tag IS '标签表';
COMMENT ON COLUMN tb_tag.id IS '标签ID';
COMMENT ON COLUMN tb_tag.name IS '标签名称(唯一)';
COMMENT ON COLUMN tb_tag.color IS '标签颜色(十六进制)';
COMMENT ON COLUMN tb_tag.usage_count IS '使用次数';
COMMENT ON COLUMN tb_tag.creator IS '创建人ID';
COMMENT ON COLUMN tb_tag.updater IS '更新人ID';
COMMENT ON COLUMN tb_tag.created_at IS '创建时间';
COMMENT ON COLUMN tb_tag.updated_at IS '更新时间';
COMMENT ON COLUMN tb_tag.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_tag_usage ON tb_tag(usage_count DESC, deleted_at);
CREATE UNIQUE INDEX idx_tag_name ON tb_tag(name) WHERE deleted_at IS NULL;
-- 3.2 资源-标签关联表
CREATE TABLE IF NOT EXISTS tb_resource_tag (
id BIGSERIAL PRIMARY KEY,
resource_type VARCHAR(20) NOT NULL,
resource_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
creator BIGINT,
updater BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE tb_resource_tag IS '资源-标签关联表';
COMMENT ON COLUMN tb_resource_tag.id IS '关联记录ID';
COMMENT ON COLUMN tb_resource_tag.resource_type IS '资源类型device-设备, iot_card-IoT卡, number_card-号卡';
COMMENT ON COLUMN tb_resource_tag.resource_id IS '资源ID';
COMMENT ON COLUMN tb_resource_tag.tag_id IS '标签ID关联tb_tag.id';
COMMENT ON COLUMN tb_resource_tag.creator IS '创建人ID';
COMMENT ON COLUMN tb_resource_tag.updater IS '更新人ID';
COMMENT ON COLUMN tb_resource_tag.created_at IS '创建时间';
COMMENT ON COLUMN tb_resource_tag.updated_at IS '更新时间';
COMMENT ON COLUMN tb_resource_tag.deleted_at IS '删除时间(软删除)';
CREATE INDEX idx_resource_tag_resource ON tb_resource_tag(resource_type, resource_id, deleted_at);
CREATE INDEX idx_resource_tag_tag ON tb_resource_tag(tag_id, deleted_at);
CREATE INDEX idx_resource_tag_composite ON tb_resource_tag(resource_type, tag_id, deleted_at);
CREATE UNIQUE INDEX idx_resource_tag_unique ON tb_resource_tag(resource_type, resource_id, tag_id) WHERE deleted_at IS NULL;
-- =====================================================
-- 4. 修改运营商表(增加渠道字段)
-- =====================================================
ALTER TABLE tb_carrier ADD COLUMN IF NOT EXISTS carrier_type VARCHAR(20) NOT NULL DEFAULT 'CMCC';
ALTER TABLE tb_carrier ADD COLUMN IF NOT EXISTS channel_name VARCHAR(100);
ALTER TABLE tb_carrier ADD COLUMN IF NOT EXISTS channel_code VARCHAR(50);
COMMENT ON COLUMN tb_carrier.carrier_type IS '运营商类型CMCC-中国移动, CUCC-中国联通, CTCC-中国电信, CBN-广电';
COMMENT ON COLUMN tb_carrier.channel_name IS '渠道名称(可自定义)';
COMMENT ON COLUMN tb_carrier.channel_code IS '渠道编码(可自定义)';
-- 创建新的唯一索引
CREATE UNIQUE INDEX idx_carrier_type_channel ON tb_carrier(carrier_type, channel_code) WHERE deleted_at IS NULL;
-- 初始化现有数据的 carrier_type根据 carrier_code 推断)
UPDATE tb_carrier SET carrier_type = 'CMCC' WHERE carrier_code LIKE '%CMCC%' OR carrier_code LIKE '%移动%';
UPDATE tb_carrier SET carrier_type = 'CUCC' WHERE carrier_code LIKE '%CUCC%' OR carrier_code LIKE '%联通%';
UPDATE tb_carrier SET carrier_type = 'CTCC' WHERE carrier_code LIKE '%CTCC%' OR carrier_code LIKE '%电信%';
UPDATE tb_carrier SET carrier_type = 'CBN' WHERE carrier_code LIKE '%CBN%' OR carrier_code LIKE '%广电%';
-- =====================================================
-- 5. 修改订单表(增加钱包支付字段)
-- =====================================================
ALTER TABLE tb_order ADD COLUMN IF NOT EXISTS wallet_payment_amount BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_order ADD COLUMN IF NOT EXISTS online_payment_amount BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_order.wallet_payment_amount IS '钱包支付金额(分)';
COMMENT ON COLUMN tb_order.online_payment_amount IS '在线支付金额(分)';
-- 初始化现有订单的支付金额字段
UPDATE tb_order SET wallet_payment_amount = amount WHERE payment_method = 'wallet';
UPDATE tb_order SET online_payment_amount = amount WHERE payment_method = 'online' OR payment_method = 'carrier';

View File

@@ -0,0 +1,83 @@
-- ========================================
-- 钱包和标签系统多租户改造 - 回滚脚本
-- 变更 ID: fix-wallet-tag-multi-tenant
-- ========================================
-- ========================================
-- 第 1 步:恢复钱包表
-- ========================================
DROP TABLE IF EXISTS tb_wallet CASCADE;
CREATE TABLE tb_wallet AS SELECT * FROM tb_wallet_backup;
-- ========================================
-- 第 2 步:恢复标签表
-- ========================================
ALTER TABLE tb_tag DROP COLUMN IF EXISTS enterprise_id CASCADE;
ALTER TABLE tb_tag DROP COLUMN IF EXISTS shop_id CASCADE;
DROP INDEX IF EXISTS idx_tag_enterprise;
DROP INDEX IF EXISTS idx_tag_shop;
DROP INDEX IF EXISTS idx_tag_enterprise_name;
DROP INDEX IF EXISTS idx_tag_shop_name;
DROP INDEX IF EXISTS idx_tag_global_name;
CREATE UNIQUE INDEX idx_tag_name ON tb_tag (name) WHERE deleted_at IS NULL;
-- ========================================
-- 第 3 步:恢复资源标签表
-- ========================================
ALTER TABLE tb_resource_tag DROP COLUMN IF EXISTS enterprise_id CASCADE;
ALTER TABLE tb_resource_tag DROP COLUMN IF EXISTS shop_id CASCADE;
DROP INDEX IF EXISTS idx_resource_tag_enterprise;
DROP INDEX IF EXISTS idx_resource_tag_shop;
-- ========================================
-- 第 4 步:验证回滚结果
-- ========================================
DO $$
DECLARE
wallet_count INTEGER;
tag_has_enterprise BOOLEAN;
tag_has_shop BOOLEAN;
BEGIN
SELECT COUNT(*) INTO wallet_count FROM tb_wallet;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tb_tag' AND column_name = 'enterprise_id'
) INTO tag_has_enterprise;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tb_tag' AND column_name = 'shop_id'
) INTO tag_has_shop;
RAISE NOTICE '========================================';
RAISE NOTICE '回滚验证:';
RAISE NOTICE ' 钱包记录数: %', wallet_count;
RAISE NOTICE ' 标签表 enterprise_id 字段存在: %', tag_has_enterprise;
RAISE NOTICE ' 标签表 shop_id 字段存在: %', tag_has_shop;
RAISE NOTICE '========================================';
IF tag_has_enterprise OR tag_has_shop THEN
RAISE WARNING '标签表字段未完全删除,请检查';
END IF;
END $$;
-- ========================================
-- 第 5 步:清理备份表(可选,建议手动执行)
-- ========================================
-- DROP TABLE IF EXISTS tb_wallet_backup;
-- DROP TABLE IF EXISTS tb_tag_backup;
-- DROP TABLE IF EXISTS tb_resource_tag_backup;
-- ========================================
-- 回滚完成
-- ========================================

View File

@@ -0,0 +1,220 @@
-- ========================================
-- 钱包和标签系统多租户改造 - 数据迁移脚本
-- 变更 ID: fix-wallet-tag-multi-tenant
-- ========================================
-- ========================================
-- 第 1 步:备份数据
-- ========================================
-- 删除旧备份表(如果存在)
DROP TABLE IF EXISTS tb_wallet_backup;
DROP TABLE IF EXISTS tb_tag_backup;
DROP TABLE IF EXISTS tb_resource_tag_backup;
-- 备份钱包表
CREATE TABLE tb_wallet_backup AS SELECT * FROM tb_wallet;
-- 备份标签表
CREATE TABLE tb_tag_backup AS SELECT * FROM tb_tag;
-- 备份资源标签表
CREATE TABLE tb_resource_tag_backup AS SELECT * FROM tb_resource_tag;
-- ========================================
-- 第 2 步:钱包表结构变更
-- ========================================
-- 添加新字段(先添加,允许 NULL
ALTER TABLE tb_wallet
ADD COLUMN resource_type VARCHAR(20),
ADD COLUMN resource_id BIGINT;
-- 迁移代理钱包数据
-- 代理钱包从 user_id 迁移到 shop_id
UPDATE tb_wallet w
SET
resource_type = 'shop',
resource_id = a.shop_id
FROM tb_account a
WHERE
w.user_id = a.id
AND w.wallet_type = 'agent'
AND a.shop_id IS NOT NULL;
-- 标记无法迁移的代理钱包shop_id 为 NULL
UPDATE tb_wallet
SET resource_type = 'INVALID_AGENT'
WHERE wallet_type = 'agent' AND resource_type IS NULL;
-- 标记用户钱包为待处理(需要业务人员确认)
UPDATE tb_wallet
SET
resource_type = 'PENDING_USER',
resource_id = 0
WHERE wallet_type = 'user' AND resource_type IS NULL;
-- 检查是否有无法迁移的数据
DO $$
DECLARE
invalid_count INTEGER;
pending_count INTEGER;
BEGIN
SELECT COUNT(*) INTO invalid_count FROM tb_wallet WHERE resource_type = 'INVALID_AGENT';
SELECT COUNT(*) INTO pending_count FROM tb_wallet WHERE resource_type = 'PENDING_USER';
IF invalid_count > 0 THEN
RAISE EXCEPTION '存在 % 个无法迁移的代理钱包shop_id 为 NULL请手动处理', invalid_count;
END IF;
IF pending_count > 0 THEN
RAISE NOTICE '存在 % 个待确认的用户钱包,需要业务人员确认归属', pending_count;
END IF;
END $$;
-- 设置字段为 NOT NULL
ALTER TABLE tb_wallet
ALTER COLUMN resource_type SET NOT NULL,
ALTER COLUMN resource_id SET NOT NULL;
-- 删除旧字段和约束
DROP INDEX IF EXISTS idx_wallet_user_type_currency;
DROP INDEX IF EXISTS idx_wallet_user;
ALTER TABLE tb_wallet DROP COLUMN user_id;
-- 创建新索引和约束
CREATE UNIQUE INDEX idx_wallet_resource_type_currency
ON tb_wallet (resource_type, resource_id, wallet_type, currency)
WHERE deleted_at IS NULL;
CREATE INDEX idx_wallet_resource ON tb_wallet (resource_type, resource_id, deleted_at);
-- 保留状态索引(如果不存在)
CREATE INDEX IF NOT EXISTS idx_wallet_status ON tb_wallet (status, deleted_at);
-- ========================================
-- 第 3 步:标签表结构变更
-- ========================================
-- 添加新字段
ALTER TABLE tb_tag
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;
-- 迁移企业标签数据(从 creator 推断)
UPDATE tb_tag t
SET enterprise_id = (
SELECT a.enterprise_id
FROM tb_account a
WHERE a.id = t.creator AND a.enterprise_id IS NOT NULL
LIMIT 1
);
-- 迁移店铺标签数据(从 creator 推断)
UPDATE tb_tag t
SET shop_id = (
SELECT a.shop_id
FROM tb_account a
WHERE a.id = t.creator AND a.shop_id IS NOT NULL
LIMIT 1
)
WHERE enterprise_id IS NULL;
-- 其他标签默认为全局标签enterprise_id 和 shop_id 都为 NULL
-- 删除旧约束
DROP INDEX IF EXISTS idx_tag_name;
-- 创建新索引
CREATE INDEX idx_tag_enterprise ON tb_tag (enterprise_id, deleted_at);
CREATE INDEX idx_tag_shop ON tb_tag (shop_id, deleted_at);
-- 创建新唯一约束
CREATE UNIQUE INDEX idx_tag_enterprise_name
ON tb_tag (enterprise_id, name)
WHERE deleted_at IS NULL AND enterprise_id IS NOT NULL;
CREATE UNIQUE INDEX idx_tag_shop_name
ON tb_tag (shop_id, name)
WHERE deleted_at IS NULL AND shop_id IS NOT NULL;
CREATE UNIQUE INDEX idx_tag_global_name
ON tb_tag (name)
WHERE deleted_at IS NULL AND enterprise_id IS NULL AND shop_id IS NULL;
-- ========================================
-- 第 4 步:资源标签表结构变更
-- ========================================
-- 添加新字段
ALTER TABLE tb_resource_tag
ADD COLUMN enterprise_id BIGINT,
ADD COLUMN shop_id BIGINT;
-- 从 creator 推断归属(企业)
UPDATE tb_resource_tag rt
SET enterprise_id = (
SELECT a.enterprise_id
FROM tb_account a
WHERE a.id = rt.creator AND a.enterprise_id IS NOT NULL
LIMIT 1
);
-- 从 creator 推断归属(店铺)
UPDATE tb_resource_tag rt
SET shop_id = (
SELECT a.shop_id
FROM tb_account a
WHERE a.id = rt.creator AND a.shop_id IS NOT NULL
LIMIT 1
)
WHERE enterprise_id IS NULL;
-- 创建索引
CREATE INDEX idx_resource_tag_enterprise ON tb_resource_tag (enterprise_id, deleted_at);
CREATE INDEX idx_resource_tag_shop ON tb_resource_tag (shop_id, deleted_at);
-- ========================================
-- 第 5 步:验证数据一致性
-- ========================================
-- 输出迁移统计信息
DO $$
DECLARE
wallet_total INTEGER;
wallet_shop INTEGER;
wallet_pending INTEGER;
tag_total INTEGER;
tag_enterprise INTEGER;
tag_shop INTEGER;
tag_global INTEGER;
BEGIN
-- 钱包统计
SELECT COUNT(*) INTO wallet_total FROM tb_wallet;
SELECT COUNT(*) INTO wallet_shop FROM tb_wallet WHERE resource_type = 'shop';
SELECT COUNT(*) INTO wallet_pending FROM tb_wallet WHERE resource_type = 'PENDING_USER';
RAISE NOTICE '========================================';
RAISE NOTICE '钱包迁移统计:';
RAISE NOTICE ' 总数: %', wallet_total;
RAISE NOTICE ' 店铺钱包: %', wallet_shop;
RAISE NOTICE ' 待确认用户钱包: %', wallet_pending;
-- 标签统计
SELECT COUNT(*) INTO tag_total FROM tb_tag;
SELECT COUNT(*) INTO tag_enterprise FROM tb_tag WHERE enterprise_id IS NOT NULL;
SELECT COUNT(*) INTO tag_shop FROM tb_tag WHERE shop_id IS NOT NULL;
SELECT COUNT(*) INTO tag_global FROM tb_tag WHERE enterprise_id IS NULL AND shop_id IS NULL;
RAISE NOTICE '========================================';
RAISE NOTICE '标签迁移统计:';
RAISE NOTICE ' 总数: %', tag_total;
RAISE NOTICE ' 企业标签: %', tag_enterprise;
RAISE NOTICE ' 店铺标签: %', tag_shop;
RAISE NOTICE ' 全局标签: %', tag_global;
RAISE NOTICE '========================================';
END $$;
-- ========================================
-- 迁移完成
-- ========================================

View File

@@ -0,0 +1,3 @@
-- 删除权限可用角色类型字段
ALTER TABLE tb_permission
DROP COLUMN IF EXISTS available_for_role_types;

View File

@@ -0,0 +1,8 @@
-- 添加权限可用角色类型字段
ALTER TABLE tb_permission
ADD COLUMN available_for_role_types VARCHAR(20) DEFAULT '1,2' NOT NULL;
COMMENT ON COLUMN tb_permission.available_for_role_types IS '可用角色类型 1=平台 2=客户';
-- 为现有权限设置默认值(兼容所有角色类型)
UPDATE tb_permission SET available_for_role_types = '1,2' WHERE available_for_role_types IS NULL;

26
opencode.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"options": {
"baseURL": "https://txibabrh.cc-coding.com/api/v1",
"apiKey": "cr_c12cb1c99754ba7e22b4097762b2a61627112d5dcad90b867c715da0cf45b3a9"
}
}
},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"enabled": true,
"timeout": 10000
},
"postgres": {
"type": "local",
"command": ["docker","run","-i","--rm","-e","DATABASE_URI","crystaldba/postgres-mcp","--access-mode=restricted"],
"environment": {
"DATABASE_URI": "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
}
}
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-12

View File

@@ -0,0 +1,537 @@
# 设计文档:修复 IoT 模型架构违规
## 1. 设计目标
将所有 IoT 相关数据模型重构为符合项目开发规范的标准模型,确保代码一致性、可维护性和长期可扩展性。
## 2. 核心设计原则
### 2.1 统一模型结构
所有数据模型必须遵循以下标准结构:
```go
type ModelName struct {
gorm.Model // 标准字段ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // 基础字段Creator, Updater
// 业务字段(按字母顺序排列)
Field1 Type `gorm:"column:field1;..." json:"field1"`
Field2 Type `gorm:"column:field2;..." json:"field2"`
}
func (ModelName) TableName() string {
return "tb_model_name" // tb_ 前缀 + 单数
}
```
**设计理由:**
- `gorm.Model`:提供标准的主键、时间戳、软删除支持
- `BaseModel`:提供审计字段,记录创建人和更新人
- 显式 `column` 标签:明确 Go 字段和数据库列的映射关系,避免依赖 GORM 自动转换
- `tb_` 前缀单数表名:项目统一规范,便于识别业务表
### 2.2 字段定义规范
**字符串字段:**
```go
Name string `gorm:"column:name;type:varchar(100);not null;comment:名称" json:"name"`
```
- 必须显式指定 `column` 标签
- 必须指定 `type:varchar(N)` 和长度
- 必须指定 `not null`(如果必填)
- 必须添加中文 `comment`
**货币金额字段:**
```go
Amount int64 `gorm:"column:amount;type:bigint;default:0;not null;comment:金额(分)" json:"amount"`
```
- 使用 `int64` 类型(不是 `float64`
- 单位为"分"1元 = 100分
- 必须指定 `type:bigint`
- 必须指定 `default:0``not null`
- 注释中明确标注"(分)"
**设计理由:**
- 整数存储避免浮点精度问题(金融领域最佳实践)
- 分为单位便于精确计算和货币转换
**枚举字段:**
```go
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
```
- 使用 `int` 类型(不是 `string`
- 必须在注释中列举所有枚举值
- 必须指定 `default``not null`
**关联 ID 字段:**
```go
UserID uint `gorm:"column:user_id;type:bigint;not null;index;comment:用户ID" json:"user_id"`
```
- 使用 `uint` 类型(与 `gorm.Model` 的 ID 类型一致)
- 数据库类型使用 `bigint`PostgreSQL
- 必须添加 `index` 索引
- 禁止使用 GORM 关联标签(`foreignKey``references`
**可选关联 ID 字段:**
```go
ShopID *uint `gorm:"column:shop_id;type:bigint;index;comment:店铺ID可选" json:"shop_id,omitempty"`
```
- 使用指针类型 `*uint`(可为 NULL
- 不指定 `not null`
- 仍需添加 `index` 索引
- JSON 标签使用 `omitempty`
**唯一索引字段:**
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
```
- 使用 `uniqueIndex` 标签
- 对于支持软删除的表,必须添加 `where:deleted_at IS NULL` 过滤条件
- 索引名命名规范:`idx_{table}_{field}``idx_{field}`
**时间字段:**
```go
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at,omitempty"`
```
- 可选时间字段使用指针类型 `*time.Time`
- 不使用 `autoCreateTime``autoUpdateTime`(这些由 gorm.Model 提供)
- JSON 标签使用 `omitempty`
**JSONB 字段PostgreSQL**
```go
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;comment:元数据" json:"metadata,omitempty"`
```
- 使用 `gorm.io/datatypes.JSON` 类型
- 数据库类型使用 `jsonb`PostgreSQL 优化存储)
- 使用 `omitempty`
### 2.3 表名和索引命名规范
**表名:**
- 格式:`tb_{model_name}`(单数)
- 示例:`tb_iot_card``tb_device``tb_order`
**索引名:**
- 普通索引:`idx_{table}_{field}`
- 唯一索引:`idx_{table}_{field}``uniq_{table}_{field}`
- 复合索引:`idx_{table}_{field1}_{field2}`
**设计理由:**
- 统一前缀便于识别业务表(与系统表区分)
- 单数形式符合 Go 惯用命名(类型名为单数)
- 索引名清晰表达用途和字段
### 2.4 软删除支持
所有业务数据表都应支持软删除:
```go
type BusinessModel struct {
gorm.Model // 包含 DeletedAt 字段
// ...
}
```
**不需要软删除的表:**
- 纯配置表(如 `PollingConfig``CommissionWithdrawalSetting`
- 日志表(如 `DataUsageRecord`
- 中间表(如 `DeviceSimBinding` 可选支持)
对于不需要软删除的表,可以手动定义字段:
```go
type ConfigModel struct {
ID uint `gorm:"column:id;primaryKey;comment:ID" json:"id"`
BaseModel `gorm:"embedded"`
// ...
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
}
```
## 3. 模型分类和修复策略
### 3.1 核心业务实体(必须支持软删除)
**完整模型结构gorm.Model + BaseModel**
- `IotCard`IoT 卡)
- `Device`(设备)
- `NumberCard`(号卡)
- `PackageSeries`(套餐系列)
- `Package`(套餐)
- `AgentPackageAllocation`(代理套餐分配)
- `Order`(订单)
- `AgentHierarchy`(代理层级)
- `CommissionRule`(分佣规则)
- `CommissionTemplate`(分佣模板)
- `Carrier`(运营商)
### 3.2 关联和绑定表(可选软删除)
**完整模型结构gorm.Model + BaseModel**
- `DeviceSimBinding`(设备-SIM 卡绑定)
### 3.3 使用记录和日志表(仅时间戳,不需要软删除)
**简化模型结构(手动定义 ID + BaseModel + CreatedAt/UpdatedAt**
- `PackageUsage`(套餐使用)- 保留 gorm.Model需要软删除和更新
- `DataUsageRecord`(流量记录)- 仅需 ID + CreatedAt不需要 UpdatedAt 和 DeletedAt
### 3.4 财务和审批表(必须支持软删除)
**完整模型结构gorm.Model + BaseModel**
- `CommissionRecord`(分佣记录)
- `CommissionApproval`(分佣审批)
- `CommissionWithdrawalRequest`(佣金提现申请)
- `PaymentMerchantSetting`(收款商户设置)
- `CarrierSettlement`(运营商结算)
- `CardReplacementRequest`(换卡申请)
### 3.5 阶梯和条件配置表(可选软删除)
**完整模型结构gorm.Model + BaseModel**
- `CommissionLadder`(阶梯分佣配置)
- `CommissionCombinedCondition`(组合分佣条件)
### 3.6 系统配置表(可选软删除)
**完整模型结构gorm.Model + BaseModel**
- `CommissionWithdrawalSetting`(提现设置)
- `PollingConfig`(轮询配置)
- `DevCapabilityConfig`(开发能力配置)
## 4. 货币金额处理策略
### 4.1 金额字段映射
所有货币金额从 `float64`(元)改为 `int64`(分):
| 原字段类型 | 新字段类型 | 原数据库类型 | 新数据库类型 | 说明 |
|-----------|-----------|------------|------------|-----|
| `float64` | `int64` | `DECIMAL(10,2)` | `BIGINT` | 金额单位从元改为分 |
**影响的字段:**
- `IotCard.CostPrice``IotCard.DistributePrice`
- `NumberCard.Price`
- `Package.Price`
- `AgentPackageAllocation.CostPrice``AgentPackageAllocation.RetailPrice`
- `Order.Amount`
- `CommissionRule.CommissionValue`
- `CommissionLadder.CommissionValue`
- `CommissionCombinedCondition.OneTimeCommissionValue``CommissionCombinedCondition.LongTermCommissionValue`
- `CommissionRecord.Amount`
- `CommissionTemplate.CommissionValue`
- `CarrierSettlement.SettlementAmount`
- `CommissionWithdrawalRequest.Amount``CommissionWithdrawalRequest.Fee``CommissionWithdrawalRequest.ActualAmount`
- `CommissionWithdrawalSetting.MinWithdrawalAmount`
### 4.2 业务逻辑调整
**API 输入输出:**
- API 接收的金额仍为 `float64`(元)
- Handler 层负责单位转换:元 → 分(乘以 100
- 响应时转换回:分 → 元(除以 100
**示例:**
```go
// 输入10.50 元
inputAmount := 10.50 // float64 (元)
dbAmount := int64(inputAmount * 100) // 1050 分
// 输出10.50 元
dbAmount := int64(1050) // 分
outputAmount := float64(dbAmount) / 100.0 // 10.50 元
```
### 4.3 数据库迁移
对于已有测试数据:
```sql
-- 金额从 DECIMAL(元) 转为 BIGINT(分)
ALTER TABLE iot_cards RENAME COLUMN cost_price TO cost_price_old;
ALTER TABLE iot_cards ADD COLUMN cost_price BIGINT NOT NULL DEFAULT 0;
UPDATE iot_cards SET cost_price = CAST(cost_price_old * 100 AS BIGINT);
ALTER TABLE iot_cards DROP COLUMN cost_price_old;
```
## 5. JSONB 字段处理
### 5.1 问题
原模型使用 `pq.StringArray` 类型存储 JSONB
```go
CarrierOrderData pq.StringArray `gorm:"column:carrier_order_data;type:jsonb;..."`
```
这是类型不匹配的:`pq.StringArray` 是 PostgreSQL 数组类型,不是 JSONB。
### 5.2 解决方案
使用 GORM 的 `datatypes.JSON` 类型:
```go
import "gorm.io/datatypes"
type Order struct {
// ...
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data,omitempty"`
// ...
}
```
**业务层使用:**
```go
// 写入
data := map[string]interface{}{
"order_id": "123",
"status": "paid",
}
order.CarrierOrderData, _ = json.Marshal(data)
// 读取
var data map[string]interface{}
json.Unmarshal(order.CarrierOrderData, &data)
```
## 6. 索引策略
### 6.1 唯一索引Unique Index
对于需要全局唯一的字段(如 ICCID、订单号、虚拟商品编码
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
```
**关键点:**
- 必须添加 `where:deleted_at IS NULL` 过滤已软删除的记录
- 否则软删除后无法重新使用相同的唯一值
### 6.2 普通索引Index
对于频繁查询和过滤的字段(如状态、类型、关联 ID
```go
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态" json:"status"`
UserID uint `gorm:"column:user_id;type:bigint;not null;index;comment:用户ID" json:"user_id"`
```
### 6.3 复合索引Composite Index
对于联合查询的字段组合:
```go
type DeviceSimBinding struct {
// ...
DeviceID uint `gorm:"column:device_id;type:bigint;not null;index:idx_device_slot;comment:设备ID" json:"device_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置" json:"slot_position"`
// ...
}
```
**复合索引命名:**
- `idx_device_slot`:表示 `device_id``slot_position` 的联合索引
## 7. 迁移路径
### 7.1 代码修改顺序
1. 修改所有模型文件(`internal/model/*.go`
2. 更新模型的单元测试(如有)
3. 生成新的数据库迁移脚本
4. 在开发环境测试迁移脚本
5. 验证所有模型定义正确
### 7.2 数据库迁移策略
**场景 1IoT 模块尚未部署(推荐)**
- 删除旧的迁移脚本(如果已创建)
- 生成新的初始迁移脚本
- 重新运行迁移
**场景 2IoT 模块已有测试数据**
- 保留旧的迁移脚本
- 生成新的迁移脚本(包含表重命名、字段修改)
- 编写数据转换脚本(金额单位转换等)
### 7.3 迁移脚本示例
```sql
-- 1. 重命名表(复数 → tb_ 前缀单数)
ALTER TABLE iot_cards RENAME TO tb_iot_card;
ALTER TABLE devices RENAME TO tb_device;
-- ...
-- 2. 添加新字段
ALTER TABLE tb_iot_card ADD COLUMN creator BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_iot_card ADD COLUMN updater BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_iot_card ADD COLUMN deleted_at TIMESTAMP;
-- 3. 修改金额字段DECIMAL → BIGINT
ALTER TABLE tb_iot_card RENAME COLUMN cost_price TO cost_price_old;
ALTER TABLE tb_iot_card ADD COLUMN cost_price BIGINT NOT NULL DEFAULT 0;
UPDATE tb_iot_card SET cost_price = CAST(cost_price_old * 100 AS BIGINT);
ALTER TABLE tb_iot_card DROP COLUMN cost_price_old;
-- 4. 添加索引
CREATE UNIQUE INDEX idx_iccid ON tb_iot_card(iccid) WHERE deleted_at IS NULL;
CREATE INDEX idx_status ON tb_iot_card(status);
CREATE INDEX idx_carrier_id ON tb_iot_card(carrier_id);
```
## 8. 验证清单
修复完成后需验证:
- [ ] 所有模型嵌入 `gorm.Model` 或手动定义 `ID``CreatedAt``UpdatedAt`
- [ ] 所有业务模型嵌入 `BaseModel``Creator``Updater`
- [ ] 所有字段显式指定 `column` 标签
- [ ] 所有字符串字段指定类型和长度(`type:varchar(N)`
- [ ] 所有金额字段使用 `int64` 类型和 `type:bigint`
- [ ] 所有必填字段指定 `not null`
- [ ] 所有字段添加中文 `comment`
- [ ] 所有唯一字段添加 `uniqueIndex` 并包含 `where:deleted_at IS NULL`
- [ ] 所有关联字段添加 `index`
- [ ] 所有表名使用 `tb_` 前缀 + 单数
- [ ] 所有 JSONB 字段使用 `datatypes.JSON` 类型
- [ ] 所有模型与现有 `Account``PersonalCustomer` 模型风格一致
## 9. 风险和注意事项
### 9.1 破坏性变更
- 表名变更会导致旧代码无法运行
- 金额单位变更需要业务逻辑适配
- 新增字段需要在业务逻辑中赋值
### 9.2 迁移风险
- 表重命名可能导致迁移失败(需谨慎测试)
- 金额转换可能出现精度问题(需验证)
- 索引重建可能耗时(大表需评估)
### 9.3 开发流程影响
- 修复期间 IoT 模块功能开发需暂停
- 所有依赖 IoT 模型的代码需同步修改
- 需要重新生成数据库迁移脚本
## 10. 全局规范文档更新
### 10.1 更新目标
确保项目规范文档CLAUDE.md与实际实现的模型完全一致为未来开发提供清晰、准确的指导。
### 10.2 CLAUDE.md 更新内容
**1. 补充 GORM 模型字段规范**
在"数据库设计原则"部分添加详细的字段定义规范:
```markdown
**GORM 模型字段规范:**
**字段命名:**
- 数据库字段名必须使用下划线命名法snake_case`user_id``email_address``created_at`
- Go 结构体字段名必须使用驼峰命名法PascalCase`UserID``EmailAddress``CreatedAt`
**字段标签要求:**
- **所有字段必须显式指定数据库列名**:使用 `gorm:"column:字段名"` 标签
- 示例:`UserID uint gorm:"column:user_id;not null" json:"user_id"`
- 禁止省略 `column:` 标签,即使 GORM 能自动推断字段名
- 这确保了 Go 字段名和数据库字段名的映射关系清晰可见,避免命名歧义
- **所有字符串字段必须显式指定类型和长度**
- 短文本:`type:varchar(100)``type:varchar(255)`
- 中等文本:`type:varchar(500)``type:varchar(1000)`
- 长文本:`type:text`
- **所有字段必须添加中文注释**`comment:字段用途说明`
**货币金额字段规范:**
- **必须使用整数类型**Go 类型 `int64`,数据库类型 `bigint`
- **单位必须为"分"**1 元 = 100 分)
- **注释中必须明确标注单位**`comment:金额(分)`
- **理由**:避免浮点精度问题,符合金融系统最佳实践
示例:
```go
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分)" json:"amount"`
```
**唯一索引软删除兼容性:**
- 对于支持软删除的表(嵌入 `gorm.Model`),唯一索引必须包含 `where:deleted_at IS NULL` 过滤条件
- 示例:
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID" json:"iccid"`
```
- 理由:允许软删除后重新使用相同的唯一值
**JSONB 字段规范PostgreSQL**
- 必须使用 `gorm.io/datatypes.JSON` 类型
- 数据库类型为 `jsonb`
- 示例:
```go
import "gorm.io/datatypes"
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;comment:元数据" json:"metadata,omitempty"`
```
```
**2. 更新模型示例代码**
将现有的模型示例(如 Account更新为包含完整字段标签的版本确保所有示例都遵循规范。
**3. 添加金额单位转换说明**
在"API 设计规范"或"错误处理规范"附近添加:
```markdown
**API 层金额单位转换:**
- API 接收和返回的金额使用 `float64` 类型(元)
- 业务层和数据库使用 `int64` 类型(分)
- Handler 层负责单位转换
**输入转换API → 业务层):**
```go
// API 接收 10.50 元
inputAmount := 10.50 // float64 (元)
dbAmount := int64(inputAmount * 100) // 1050 分
```
**输出转换(业务层 → API**
```go
// 数据库存储 1050 分
dbAmount := int64(1050) // 分
outputAmount := float64(dbAmount) / 100.0 // 10.50 元
```
**注意事项:**
- 转换时注意四舍五入和边界情况
- 建议封装转换函数,避免重复代码
- 在金额字段的 DTO 注释中明确单位(元)
```
### 10.3 验证清单
更新完成后需验证:
- [ ] CLAUDE.md 中的所有模型示例包含完整的字段标签
- [ ] 所有字段定义规范清晰、完整、无歧义
- [ ] 金额字段整数存储的说明详细且易懂
- [ ] 唯一索引软删除兼容性规范已添加
- [ ] JSONB 字段使用规范已添加
- [ ] API 层金额单位转换说明已添加
- [ ] 规范文档与实际实现的模型完全一致
## 11. 后续任务
模型修复和规范文档更新完成后,需要:
1. 更新 DTO 模型(请求/响应结构体)
2. 调整 Store 层(数据访问层)
3. 调整 Service 层(业务逻辑层)- 金额单位转换
4. 调整 Handler 层API 层)- 金额单位转换
5. 生成数据库迁移脚本
6. 编写单元测试验证模型定义
7. 更新 API 文档

View File

@@ -0,0 +1,152 @@
## Why
在之前的 IoT SIM 管理系统提案2026-01-12-iot-sim-management中创建的所有数据模型存在严重的架构违规问题完全没有遵循项目的核心开发规范。这些违规导致代码不一致、可维护性差、违背项目设计原则。
**核心问题:**
1. **未使用基础模型**:所有 IoT 模型都没有嵌入 `BaseModel`,缺少统一的 `creator``updater` 字段
2. **未使用 gorm.Model**:部分模型没有嵌入 `gorm.Model`,缺少标准的 `ID``CreatedAt``UpdatedAt``DeletedAt` 字段
3. **字段命名不规范**:未显式指定 `column` 标签,依赖 GORM 自动转换(违反规范)
4. **字段定义不完整**:缺少必要的数据库约束标签(`not null``uniqueIndex`、索引等)
5. **数据类型不一致**
- 货币字段使用 `float64` 而不是整数(分为单位)
- ID 字段类型不一致(`uint` vs `bigint`
- 时间字段缺少 `autoCreateTime`/`autoUpdateTime` 标签
6. **表名不符合规范**:使用复数形式(`iot_cards`)而不是项目约定的 `tb_` 前缀单数形式
7. **缺少中文注释**:部分字段缺少清晰的中文注释说明业务含义
8. **软删除支持不一致**:某些应该支持软删除的模型缺少 `gorm.Model` 嵌入
**对比现有规范模型Account、PersonalCustomer**
**正确示例Account 模型):**
```go
type Account struct {
gorm.Model // ✅ 嵌入标准模型ID、CreatedAt、UpdatedAt、DeletedAt
BaseModel `gorm:"embedded"` // ✅ 嵌入基础模型Creator、Updater
Username string `gorm:"column:username;type:varchar(50);uniqueIndex:idx_account_username,where:deleted_at IS NULL;not null;comment:用户名" json:"username"`
// ✅ 显式 column 标签
// ✅ 明确类型和长度
// ✅ 唯一索引 + 软删除过滤
// ✅ not null 约束
// ✅ 中文注释
}
func (Account) TableName() string {
return "tb_account" // ✅ tb_ 前缀 + 单数
}
```
**错误示例IotCard 模型):**
```go
type IotCard struct {
ID uint `gorm:"column:id;primaryKey;comment:IoT 卡 ID" json:"id"`
// ❌ 没有 gorm.Model
// ❌ 没有 BaseModel
// ❌ 手动定义 ID应该由 gorm.Model 提供)
// ❌ 没有 DeletedAt无法软删除
CostPrice float64 `gorm:"column:cost_price;type:decimal(10,2);default:0;comment:成本价(元)" json:"cost_price"`
// ❌ 使用 float64 而不是整数(分为单位)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
// ❌ 手动定义(应该由 gorm.Model 提供)
}
func (IotCard) TableName() string {
return "iot_cards" // ❌ 复数形式,没有 tb_ 前缀
}
```
**影响范围:**
需要修复以下所有 IoT 相关模型(约 25 个模型文件):
- `internal/model/iot_card.go`IotCard
- `internal/model/device.go`Device、DeviceSimBinding
- `internal/model/number_card.go`NumberCard
- `internal/model/package.go`PackageSeries、Package、AgentPackageAllocation、PackageUsage
- `internal/model/order.go`Order
- `internal/model/commission.go`AgentHierarchy、CommissionRule、CommissionLadder、CommissionCombinedCondition、CommissionRecord、CommissionApproval、CommissionTemplate、CarrierSettlement
- `internal/model/financial.go`CommissionWithdrawalRequest、CommissionWithdrawalSetting、PaymentMerchantSetting
- `internal/model/system.go`DevCapabilityConfig、CardReplacementRequest
- `internal/model/carrier.go`Carrier
- `internal/model/data_usage.go`DataUsageRecord
- `internal/model/polling.go`PollingConfig
## What Changes
- 重构所有 IoT 相关数据模型,使其完全符合项目开发规范
- 统一所有模型的字段定义、类型、约束、注释格式
- 确保所有模型与现有用户体系模型Account、PersonalCustomer保持一致的架构风格
- 更新数据库迁移脚本以反映模型变更
## Capabilities
### Modified Capabilities
#### 核心数据模型规范化
- `iot-card`: 修改 IoT 卡业务模型 - 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_iot_card`,使用整数存储金额,完善索引和约束
- `iot-device`: 修改设备业务模型 - 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_device`,规范化所有关联字段
- `iot-number-card`: 修改号卡业务模型 - 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_number_card`,使用整数存储金额
- `iot-package`: 修改套餐管理模型 - 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名`tb_package_series``tb_package``tb_agent_package_allocation``tb_package_usage`),使用整数存储金额
- `iot-order`: 修改订单管理模型 - 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_order`,使用整数存储金额,规范化 JSONB 字段
- `iot-agent-commission`: 修改代理分佣模型 - 统一所有分佣相关模型字段定义,嵌入 BaseModel 和 gorm.Model修正表名添加 `tb_` 前缀),使用整数存储金额
#### 财务和系统模型规范化
- 修改财务相关模型CommissionWithdrawalRequest、CommissionWithdrawalSetting、PaymentMerchantSetting- 统一字段定义,使用整数存储金额,完善索引和约束
- 修改系统配置模型DevCapabilityConfig、CardReplacementRequest- 统一字段定义,嵌入 BaseModel 和 gorm.Model
- 修改运营商模型Carrier- 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_carrier`
- 修改流量记录模型DataUsageRecord- 统一字段定义,嵌入 gorm.Model修正表名为 `tb_data_usage_record`
- 修改轮询配置模型PollingConfig- 统一字段定义,嵌入 BaseModel 和 gorm.Model修正表名为 `tb_polling_config`
## Impact
**代码变更:**
- 重构约 25 个 GORM 模型文件(`internal/model/`
- 所有模型的字段定义将发生变化(字段名、类型、标签)
- 所有表名将从复数变为 `tb_` 前缀单数形式
**数据库变更:**
- 需要生成新的数据库迁移脚本以反映模型变更
- 表名变更(如 `iot_cards``tb_iot_card`
- 字段变更(如 `cost_price DECIMAL``cost_price BIGINT`,金额从元改为分)
- 新增字段(`creator``updater``deleted_at`
- 新增索引和约束
**向后兼容性:**
-**不兼容变更**:此次修复涉及破坏性变更(表名、字段类型)
- 由于 IoT 模块尚未实际部署到生产环境,可以直接修改而无需数据迁移
- 如果已有测试数据,需要编写数据迁移脚本
**业务影响:**
- 不影响现有用户体系Account、Role、Permission 等)
- 不影响个人客户模块PersonalCustomer
- IoT 模块的 Service 层和 Handler 层代码需要相应调整(字段类型变化)
**依赖关系:**
- 必须在实现 IoT 业务逻辑Handlers、Services、Stores之前修复
- 修复后才能生成正确的数据库迁移脚本
- 修复后才能生成准确的 API 文档
**文档变更:**
- 更新 `CLAUDE.md` 中的数据库设计原则和 GORM 模型字段规范
- 补充完整的字段定义规范(显式 column 标签、类型定义、注释要求)
- 添加金额字段整数存储的详细说明和示例
- 完善表名命名规范和 BaseModel 使用说明
- 确保全局规范文档与实际实现保持一致
**明确排除的范围**(本次不涉及):
- Handler 层代码修改(将在后续任务中处理)
- Service 层代码修改(将在后续任务中处理)
- Store 层代码修改(将在后续任务中处理)
- DTO 模型调整(请求/响应结构体)
- 单元测试和集成测试
- API 文档更新
**风险和注意事项:**
- 所有金额字段从 `float64` 改为 `int64`(分为单位),需要在业务逻辑中进行单位转换
- 表名变更需要确保迁移脚本正确执行
- 新增的 `creator``updater` 字段需要在业务逻辑中正确赋值
- 软删除(`DeletedAt`的引入可能需要调整查询逻辑GORM 会自动处理)

View File

@@ -0,0 +1,458 @@
# Capability: model-organization
## MODIFIED Requirements
### Requirement: Data models MUST follow unified structure conventions
All IoT data models MUST follow unified structure conventions. 所有 IoT 相关数据模型必须与现有用户体系模型Account、PersonalCustomer保持一致的架构风格和字段定义规范。
#### Scenario: IoT 卡模型结构规范化
**Given** 系统存在 IoT 卡数据模型
**When** 开发者定义或修改 IoT 卡模型
**Then** 模型必须:
- 嵌入 `gorm.Model`(提供 ID、CreatedAt、UpdatedAt、DeletedAt 字段)
- 嵌入 `BaseModel`(提供 Creator、Updater 审计字段)
- 所有字段显式指定 `gorm:"column:字段名"` 标签
- 所有字符串字段显式指定类型和长度(如 `type:varchar(100)`
- 所有金额字段使用 `int64` 类型和 `type:bigint`,单位为"分"
- 所有必填字段添加 `not null` 约束
- 所有字段添加中文 `comment` 注释
- 所有唯一字段添加 `uniqueIndex:索引名,where:deleted_at IS NULL`
- 所有关联 ID 字段添加 `index` 索引
- 表名使用 `tb_iot_card``tb_` 前缀 + 单数)
**Example:**
```go
package model
import "gorm.io/gorm"
type IotCard struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
BaseModel `gorm:"embedded"` // Creator, Updater
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
CarrierID uint `gorm:"column:carrier_id;type:bigint;not null;index;comment:运营商ID" json:"carrier_id"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;not null;comment:成本价(分)" json:"cost_price"`
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;not null;comment:分销价(分)" json:"distribute_price"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 agent-代理 user-用户 device-设备" json:"owner_type"`
OwnerID uint `gorm:"column:owner_id;type:bigint;default:0;not null;index;comment:所有者ID" json:"owner_id"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at,omitempty"`
}
func (IotCard) TableName() string {
return "tb_iot_card"
}
```
#### Scenario: 设备模型结构规范化
**Given** 系统存在设备数据模型
**When** 开发者定义或修改设备模型
**Then** 模型必须遵循与 IoT 卡模型相同的规范gorm.Model + BaseModel + 字段标签)
**And** 表名使用 `tb_device`
#### Scenario: 号卡模型结构规范化
**Given** 系统存在号卡数据模型
**When** 开发者定义或修改号卡模型
**Then** 模型必须遵循与 IoT 卡模型相同的规范
**And** 表名使用 `tb_number_card`
**And** 价格字段使用 `int64` 类型(分为单位)
#### Scenario: 套餐相关模型结构规范化
**Given** 系统存在套餐系列、套餐、代理套餐分配、套餐使用情况等模型
**When** 开发者定义或修改套餐相关模型
**Then** 所有套餐相关模型必须遵循统一规范:
- 套餐系列:`tb_package_series`
- 套餐:`tb_package`
- 代理套餐分配:`tb_agent_package_allocation`
- 套餐使用情况:`tb_package_usage`
**And** 所有价格字段使用 `int64` 类型(分为单位)
#### Scenario: 订单模型结构规范化
**Given** 系统存在订单数据模型
**When** 开发者定义或修改订单模型
**Then** 模型必须遵循统一规范
**And** 表名使用 `tb_order`
**And** 金额字段使用 `int64` 类型(分为单位)
**And** JSONB 字段使用 `gorm.io/datatypes.JSON` 类型(不是 `pq.StringArray`
**Example:**
```go
import (
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Order struct {
gorm.Model
BaseModel `gorm:"embedded"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
OrderType int `gorm:"column:order_type;type:int;not null;index;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分)" json:"amount"`
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据" json:"carrier_order_data,omitempty"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
}
func (Order) TableName() string {
return "tb_order"
}
```
#### Scenario: 分佣相关模型结构规范化
**Given** 系统存在代理层级、分佣规则、分佣记录等模型
**When** 开发者定义或修改分佣相关模型
**Then** 所有分佣相关模型必须遵循统一规范:
- 代理层级:`tb_agent_hierarchy`
- 分佣规则:`tb_commission_rule`
- 阶梯分佣配置:`tb_commission_ladder`
- 组合分佣条件:`tb_commission_combined_condition`
- 分佣记录:`tb_commission_record`
- 分佣审批:`tb_commission_approval`
- 分佣模板:`tb_commission_template`
- 运营商结算:`tb_carrier_settlement`
**And** 所有金额字段使用 `int64` 类型(分为单位)
#### Scenario: 财务相关模型结构规范化
**Given** 系统存在佣金提现申请、提现设置、收款商户设置等模型
**When** 开发者定义或修改财务相关模型
**Then** 所有财务相关模型必须遵循统一规范:
- 佣金提现申请:`tb_commission_withdrawal_request`
- 佣金提现设置:`tb_commission_withdrawal_setting`
- 收款商户设置:`tb_payment_merchant_setting`
**And** 所有金额字段使用 `int64` 类型(分为单位)
**And** JSONB 字段使用 `gorm.io/datatypes.JSON` 类型
#### Scenario: 系统配置和日志模型规范化
**Given** 系统存在运营商、轮询配置、流量记录、开发能力配置等模型
**When** 开发者定义或修改系统配置和日志模型
**Then** 模型必须遵循统一规范:
- 运营商:`tb_carrier`gorm.Model + BaseModel
- 轮询配置:`tb_polling_config`gorm.Model + BaseModel
- 流量记录:`tb_data_usage_record`(仅 ID + CreatedAt不需要 UpdatedAt 和 DeletedAt
- 开发能力配置:`tb_dev_capability_config`gorm.Model + BaseModel
- 换卡申请:`tb_card_replacement_request`gorm.Model + BaseModel
**Example (流量记录 - 简化模型):**
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}
```
#### Scenario: 设备-SIM 卡绑定关系模型规范化
**Given** 系统存在设备-IoT 卡绑定关系模型
**When** 开发者定义或修改绑定关系模型
**Then** 模型必须遵循统一规范
**And** 表名使用 `tb_device_sim_binding`
**And** 支持复合索引(`device_id` + `slot_position`
**Example:**
```go
type DeviceSimBinding struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceID uint `gorm:"column:device_id;type:bigint;not null;index:idx_device_slot;comment:设备ID" json:"device_id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;not null;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time,omitempty"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time,omitempty"`
}
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding"
}
```
### Requirement: Currency amount fields MUST use integer type (unit: cents)
All currency amount fields MUST use integer type (unit: cents). 所有货币金额字段必须使用 `int64` 类型存储,单位为"分"1 元 = 100 分),避免浮点精度问题。
#### Scenario: 金额字段定义规范
**Given** 模型包含货币金额字段(如价格、成本、佣金、提现金额等)
**When** 开发者定义金额字段
**Then** 字段必须:
- 使用 `int64` Go 类型(不是 `float64`
- 数据库类型为 `bigint`(不是 `decimal``numeric`
- 默认值为 `0`
- 添加 `not null` 约束
- 注释中明确标注"(分)"单位
**Example:**
```go
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;not null;comment:成本价(分)" json:"cost_price"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分)" json:"amount"`
```
#### Scenario: API 层金额单位转换
**Given** API 接收或返回金额数据
**When** Handler 层处理请求或响应
**Then** 必须进行单位转换:
- 输入API 接收 `float64`(元) → 业务层使用 `int64`(分)
- 输出:业务层返回 `int64`(分) → API 返回 `float64`(元)
**Example:**
```go
// 输入转换Handler 层)
type CreateOrderRequest struct {
Amount float64 `json:"amount"` // 元
}
func (h *OrderHandler) CreateOrder(c *fiber.Ctx) error {
var req CreateOrderRequest
// ... 解析请求 ...
// 转换:元 → 分
amountInCents := int64(req.Amount * 100)
// 调用 Service 层
order, err := h.orderService.CreateOrder(ctx, amountInCents, ...)
// ...
}
// 输出转换Handler 层)
type OrderResponse struct {
Amount float64 `json:"amount"` // 元
}
func (h *OrderHandler) GetOrder(c *fiber.Ctx) error {
order, err := h.orderService.GetOrder(ctx, orderID)
// ...
// 转换:分 → 元
resp := OrderResponse{
Amount: float64(order.Amount) / 100.0,
}
return response.Success(c, resp)
}
```
### Requirement: Table names MUST follow unified naming conventions
All database table names MUST follow unified naming conventions. 所有数据库表名必须遵循项目约定的 `tb_` 前缀 + 单数形式。
#### Scenario: 表名命名规范
**Given** 开发者定义数据模型
**When** 实现 `TableName()` 方法
**Then** 表名必须:
- 使用 `tb_` 前缀
- 使用单数形式(不是复数)
- 使用下划线命名法snake_case
**Example:**
```go
// ✅ 正确
func (IotCard) TableName() string {
return "tb_iot_card"
}
func (Device) TableName() string {
return "tb_device"
}
func (Order) TableName() string {
return "tb_order"
}
// ❌ 错误
func (IotCard) TableName() string {
return "iot_cards" // 缺少 tb_ 前缀,使用复数
}
func (Device) TableName() string {
return "devices" // 缺少 tb_ 前缀,使用复数
}
```
#### Scenario: 关联表和中间表命名
**Given** 模型表示多对多关系或绑定关系
**When** 定义关联表或中间表
**Then** 表名必须使用 `tb_` 前缀 + 完整描述性名称(单数)
**Example:**
```go
// 设备-SIM 卡绑定
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding" // 不是 tb_device_sim_bindings
}
// 代理套餐分配
func (AgentPackageAllocation) TableName() string {
return "tb_agent_package_allocation" // 不是 tb_agent_package_allocations
}
```
### Requirement: All fields MUST explicitly specify database column names and types
All model fields MUST explicitly specify database column names and types. 模型字段定义必须清晰明确,不依赖 GORM 的自动转换和推断。
#### Scenario: 字段 GORM 标签完整性检查
**Given** 模型包含业务字段
**When** 开发者定义字段
**Then** 每个字段必须包含:
- `column:字段名`(显式指定数据库列名)
- `type:数据类型`(显式指定数据库类型)
- `comment:中文注释`(说明业务含义)
- 可选:`not null``default:值``index``uniqueIndex` 等约束
**Example:**
```go
// ✅ 完整的字段定义
Username string `gorm:"column:username;type:varchar(50);uniqueIndex:idx_username,where:deleted_at IS NULL;not null;comment:用户名" json:"username"`
Status int `gorm:"column:status;type:int;default:1;not null;index;comment:状态 1-启用 2-禁用" json:"status"`
Phone string `gorm:"column:phone;type:varchar(20);comment:手机号码" json:"phone,omitempty"`
// ❌ 不完整的字段定义
Username string `gorm:"comment:用户名" json:"username"` // 缺少 column 和 type
Status int `gorm:"default:1" json:"status"` // 缺少 column、type 和 comment
```
#### Scenario: 唯一索引软删除兼容
**Given** 字段需要全局唯一(如 ICCID、订单号、虚拟商品编码
**When** 模型支持软删除(嵌入 `gorm.Model`
**Then** 唯一索引必须包含 `where:deleted_at IS NULL` 过滤条件
**Example:**
```go
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号" json:"order_no"`
```
**Explanation:**
- 软删除后,`deleted_at` 不为 NULL
- 索引只对 `deleted_at IS NULL` 的记录生效
- 允许软删除后重新使用相同的唯一值
### Requirement: All models MUST support audit tracking (Creator and Updater)
All business data models MUST support audit tracking (Creator and Updater). 所有业务数据模型必须记录创建人和更新人,便于审计和追溯。
#### Scenario: 嵌入 BaseModel 提供审计字段
**Given** 模型表示业务数据实体
**When** 开发者定义模型
**Then** 模型必须嵌入 `BaseModel`
**And** `BaseModel` 提供 `Creator``Updater` 字段
**Example:**
```go
type IotCard struct {
gorm.Model
BaseModel `gorm:"embedded"` // 提供 Creator 和 Updater
// 业务字段...
}
// BaseModel 定义在 internal/model/base.go
type BaseModel struct {
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;comment:更新人ID" json:"updater"`
}
```
#### Scenario: 业务逻辑层自动填充审计字段
**Given** Service 层或 Store 层创建或更新数据
**When** 执行数据库插入或更新操作
**Then** 必须自动填充 `Creator``Updater` 字段(从上下文获取当前用户 ID
**Example:**
```go
// Service 层或 Store 层
func (s *IotCardService) CreateIotCard(ctx context.Context, req CreateIotCardRequest) (*IotCard, error) {
// 从上下文获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx)
card := &IotCard{
BaseModel: BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
ICCID: req.ICCID,
CardType: req.CardType,
// ...
}
if err := s.db.Create(card).Error; err != nil {
return nil, err
}
return card, nil
}
func (s *IotCardService) UpdateIotCard(ctx context.Context, id uint, req UpdateIotCardRequest) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
updates := map[string]interface{}{
"updater": currentUserID,
"card_type": req.CardType,
// ...
}
return s.db.Model(&IotCard{}).Where("id = ?", id).Updates(updates).Error
}
```
### Requirement: Log and record tables MUST use appropriate model structure
Append-only log and record tables MUST use simplified model structure. 对于只追加、不更新的日志表(如流量记录),必须使用简化的模型结构,不需要 `UpdatedAt``DeletedAt`
#### Scenario: 流量记录简化模型
**Given** 模型表示只追加的日志数据(不会被修改或删除)
**When** 开发者定义日志模型
**Then** 模型可以:
- 手动定义 `ID`(不嵌入 `gorm.Model`
- 只包含 `CreatedAt`(不需要 `UpdatedAt``DeletedAt`
- 不嵌入 `BaseModel`(如果不需要审计)
**Example:**
```go
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;index;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
Source string `gorm:"column:source;type:varchar(50);default:'polling';comment:数据来源 polling-轮询 manual-手动 gateway-回调" json:"source"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}
```
**Explanation:**
- 流量记录只追加,不修改,不需要 `UpdatedAt`
- 流量记录不删除(或物理删除),不需要 `DeletedAt`
- 简化模型结构减少存储开销和查询复杂度

View File

@@ -0,0 +1,643 @@
# Tasks
本文档列出修复 IoT 模型架构违规所需的所有任务,按优先级和依赖关系排序。
## 阶段 1: 核心业务实体模型修复(必须优先完成)
### Task 1.1: 修复 IoT 卡模型 (IotCard)
**文件**: `internal/model/iot_card.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`CostPrice``DistributePrice`)从 `float64` 改为 `int64`,数据库类型从 `decimal(10,2)` 改为 `bigint`
- 表名从 `iot_cards` 改为 `tb_iot_card`
- `ICCID` 唯一索引添加 `where:deleted_at IS NULL`
- 所有关联 ID 字段(`CarrierID``OwnerID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 使用 `gofmt` 格式化代码
-`Account` 模型对比,确保风格一致
---
### Task 1.2: 修复设备模型 (Device)
**文件**: `internal/model/device.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `devices` 改为 `tb_device`
- `DeviceNo` 唯一索引添加 `where:deleted_at IS NULL`
- 所有关联 ID 字段(`OwnerID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 使用 `gofmt` 格式化代码
---
### Task 1.3: 修复号卡模型 (NumberCard)
**文件**: `internal/model/number_card.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`Price`)从 `float64` 改为 `int64`,数据库类型从 `decimal(10,2)` 改为 `bigint`
- 表名从 `number_cards` 改为 `tb_number_card`
- `VirtualProductCode` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`AgentID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 使用 `gofmt` 格式化代码
---
### Task 1.4: 修复运营商模型 (Carrier)
**文件**: `internal/model/carrier.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `carriers` 改为 `tb_carrier`
- `CarrierCode` 唯一索引添加 `where:deleted_at IS NULL`
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 使用 `gofmt` 格式化代码
---
## 阶段 2: 套餐和订单模型修复
### Task 2.1: 修复套餐系列模型 (PackageSeries)
**文件**: `internal/model/package.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `package_series` 改为 `tb_package_series`
- `SeriesCode` 唯一索引添加 `where:deleted_at IS NULL`
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 2.2: 修复套餐模型 (Package)
**文件**: `internal/model/package.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`Price`)从 `float64` 改为 `int64`,数据库类型从 `decimal(10,2)` 改为 `bigint`
- 表名从 `packages` 改为 `tb_package`
- `PackageCode` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`SeriesID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 2.3: 修复代理套餐分配模型 (AgentPackageAllocation)
**文件**: `internal/model/package.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`CostPrice``RetailPrice`)从 `float64` 改为 `int64`
- 表名从 `agent_package_allocations` 改为 `tb_agent_package_allocation`
- 关联 ID 字段(`AgentID``PackageID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 2.4: 修复套餐使用情况模型 (PackageUsage)
**文件**: `internal/model/package.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `package_usages` 改为 `tb_package_usage`
- 关联 ID 字段(`OrderID``PackageID``IotCardID``DeviceID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 2.5: 修复设备-SIM 卡绑定模型 (DeviceSimBinding)
**文件**: `internal/model/package.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `device_sim_bindings` 改为 `tb_device_sim_binding`
- 添加复合索引:`DeviceID``SlotPosition` 使用 `index:idx_device_slot`
- 关联 ID 字段(`IotCardID`)添加独立 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 2.6: 修复订单模型 (Order)
**文件**: `internal/model/order.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`Amount`)从 `float64` 改为 `int64`
- `CarrierOrderData``pq.StringArray` 改为 `datatypes.JSON`,添加 `import "gorm.io/datatypes"`
- 表名从 `orders` 改为 `tb_order`
- `OrderNo` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`IotCardID``DeviceID``NumberCardID``PackageID``UserID``AgentID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 检查 `datatypes.JSON` 导入是否正确
---
## 阶段 3: 分佣系统模型修复
### Task 3.1: 修复代理层级模型 (AgentHierarchy)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `agent_hierarchies` 改为 `tb_agent_hierarchy`
- `AgentID` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`ParentAgentID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.2: 修复分佣规则模型 (CommissionRule)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`CommissionValue`)从 `float64` 改为 `int64`
- 表名从 `commission_rules` 改为 `tb_commission_rule`
- 关联 ID 字段(`AgentID``SeriesID``PackageID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.3: 修复阶梯分佣配置模型 (CommissionLadder)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`CommissionValue`)从 `float64` 改为 `int64`
- 表名从 `commission_ladder` 改为 `tb_commission_ladder`
- 关联 ID 字段(`RuleID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.4: 修复组合分佣条件模型 (CommissionCombinedCondition)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`OneTimeCommissionValue``LongTermCommissionValue`)从 `float64` 改为 `int64`
- 表名从 `commission_combined_conditions` 改为 `tb_commission_combined_condition`
- `RuleID` 唯一索引添加 `where:deleted_at IS NULL`
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.5: 修复分佣记录模型 (CommissionRecord)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`Amount`)从 `float64` 改为 `int64`
- 表名从 `commission_records` 改为 `tb_commission_record`
- 关联 ID 字段(`AgentID``OrderID``RuleID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.6: 修复分佣审批模型 (CommissionApproval)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `commission_approvals` 改为 `tb_commission_approval`
- 关联 ID 字段(`CommissionRecordID``ApproverID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.7: 修复分佣模板模型 (CommissionTemplate)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`CommissionValue`)从 `float64` 改为 `int64`
- 表名从 `commission_templates` 改为 `tb_commission_template`
- `TemplateName` 唯一索引添加 `where:deleted_at IS NULL`
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 3.8: 修复运营商结算模型 (CarrierSettlement)
**文件**: `internal/model/commission.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`SettlementAmount`)从 `float64` 改为 `int64`,数据库类型从 `decimal(18,2)` 改为 `bigint`
- 表名从 `carrier_settlements` 改为 `tb_carrier_settlement`
- `CommissionRecordID` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`AgentID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
## 阶段 4: 财务和系统模型修复
### Task 4.1: 修复佣金提现申请模型 (CommissionWithdrawalRequest)
**文件**: `internal/model/financial.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`Amount``Fee``ActualAmount`)从 `float64` 改为 `int64`,数据库类型从 `decimal(18,2)` 改为 `bigint`
- `AccountInfo``pq.StringArray` 改为 `datatypes.JSON`
- 表名从 `commission_withdrawal_requests` 改为 `tb_commission_withdrawal_request`
- 关联 ID 字段(`AgentID``ApprovedBy`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 检查 `datatypes.JSON` 导入是否正确
---
### Task 4.2: 修复佣金提现设置模型 (CommissionWithdrawalSetting)
**文件**: `internal/model/financial.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 金额字段(`MinWithdrawalAmount`)从 `float64` 改为 `int64`,数据库类型从 `decimal(10,2)` 改为 `bigint`
- 表名从 `commission_withdrawal_settings` 改为 `tb_commission_withdrawal_setting`
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 4.3: 修复收款商户设置模型 (PaymentMerchantSetting)
**文件**: `internal/model/financial.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `payment_merchant_settings` 改为 `tb_payment_merchant_setting`
- 关联 ID 字段(`UserID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 4.4: 修复开发能力配置模型 (DevCapabilityConfig)
**文件**: `internal/model/system.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `dev_capability_configs` 改为 `tb_dev_capability_config`
- `AppID` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`UserID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 4.5: 修复换卡申请模型 (CardReplacementRequest)
**文件**: `internal/model/system.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `card_replacement_requests` 改为 `tb_card_replacement_request`
- 关联 ID 字段(`UserID``ApprovedBy`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 4.6: 修复轮询配置模型 (PollingConfig)
**文件**: `internal/model/polling.go`
**修改内容:**
- 嵌入 `gorm.Model``BaseModel`
- 移除手动定义的 `ID``CreatedAt``UpdatedAt` 字段
- 所有字段显式指定 `column` 标签
- 表名从 `polling_configs` 改为 `tb_polling_config`
- `ConfigName` 唯一索引添加 `where:deleted_at IS NULL`
- 关联 ID 字段(`CarrierID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
---
### Task 4.7: 修复流量使用记录模型 (DataUsageRecord)
**文件**: `internal/model/data_usage.go`
**修改内容:**
- **不嵌入** `gorm.Model`(简化模型,只包含 ID 和 CreatedAt
- **不嵌入** `BaseModel`(日志表不需要审计)
- 保留 `ID``CreatedAt` 字段,移除 `UpdatedAt`
- 所有字段显式指定 `column` 标签
- 表名从 `data_usage_records` 改为 `tb_data_usage_record`
- 关联 ID 字段(`IotCardID`)添加 `index` 标签
- 完善所有字段的中文注释
**验证方法:**
- 运行 `go build` 确保编译通过
- 确认模型不包含 `UpdatedAt``DeletedAt`
---
## 阶段 5: 验证和测试
### Task 5.1: 编译验证
**内容:**
- 运行 `go build ./...` 确保所有模型文件编译通过
- 运行 `gofmt -w internal/model/` 格式化所有模型文件
- 运行 `go vet ./internal/model/` 静态分析检查
**依赖**: 所有模型修复任务完成
**验证方法:**
- 无编译错误
- 无静态分析警告
---
### Task 5.2: 模型定义一致性检查
**内容:**
- 手动检查所有模型是否遵循规范(参考验证清单)
- 对比 `Account` 模型,确保风格一致
- 检查所有金额字段是否使用 `int64` 类型
- 检查所有表名是否使用 `tb_` 前缀 + 单数
- 检查所有唯一索引是否包含 `where:deleted_at IS NULL`
**依赖**: Task 5.1
**验证方法:**
- 完成验证清单(设计文档第 8 节)
---
### Task 5.3: 生成数据库迁移脚本(可选)
**内容:**
- 如果 IoT 模块尚未创建迁移脚本,跳过此任务
- 如果已有迁移脚本,生成新的迁移脚本或修改现有脚本
- 包含表重命名、字段修改、索引创建等 SQL 语句
**依赖**: Task 5.2
**验证方法:**
- 在开发环境测试迁移脚本
- 确认所有表和字段正确创建
---
### Task 5.4: 文档更新
**内容:**
- 更新 IoT SIM 管理提案(`openspec/changes/archive/2026-01-12-iot-sim-management/`)的模型定义部分(可选)
-`docs/` 目录创建模型修复总结文档(可选)
- 更新 `README.md` 添加模型规范说明(可选)
**依赖**: Task 5.2
**验证方法:**
- 文档清晰易懂,准确反映当前实现
---
### Task 5.5: 更新全局规范文档
**内容:**
- 更新 `CLAUDE.md` 中的数据库设计原则和模型规范部分
- 确保 CLAUDE.md 中的示例代码与修复后的模型风格完全一致
- 如果需要,更新 `openspec/AGENTS.md`(如果其中包含模型相关指导)
- 添加或完善以下规范内容:
- GORM 模型字段规范(显式 column 标签、类型定义、注释要求)
- 金额字段使用整数类型(分为单位)的详细说明和示例
- 表名命名规范(`tb_` 前缀 + 单数)
- BaseModel 嵌入和审计字段使用说明
- 唯一索引软删除兼容性(`where:deleted_at IS NULL`
- JSONB 字段使用 `datatypes.JSON` 类型的说明
**具体修改位置CLAUDE.md:**
1. **数据库设计原则** 部分:
- 补充完整的 GORM 模型字段定义规范
- 添加金额字段整数存储的要求和理由
- 添加字段标签完整性要求(显式 column、type、comment
2. **GORM 模型字段规范** 新增小节:
```markdown
**GORM 模型字段规范:**
- 数据库字段名必须使用下划线命名法snake_case如 `user_id`、`email_address`、`created_at`
- Go 结构体字段名必须使用驼峰命名法PascalCase如 `UserID`、`EmailAddress`、`CreatedAt`
- **所有字段必须显式指定数据库列名**:使用 `gorm:"column:字段名"` 标签明确指定数据库字段名,不依赖 GORM 的自动转换
- 示例:`UserID uint gorm:"column:user_id;not null" json:"user_id"`
- 禁止省略 `column:` 标签,即使 GORM 能自动推断字段名
- 这确保了 Go 字段名和数据库字段名的映射关系清晰可见,避免命名歧义
- 字符串字段长度必须明确定义且保持一致性:
- 短文本(名称、标题等):`VARCHAR(255)` 或 `VARCHAR(100)`
- 中等文本(描述、备注等):`VARCHAR(500)` 或 `VARCHAR(1000)`
- 长文本(内容、详情等):`TEXT` 类型
- 货币金额字段必须使用 `int64` 类型,数据库类型为 `bigint`,单位为"分"1元 = 100分
- 所有字段必须添加中文注释,说明字段用途和业务含义
```
3. **示例代码更新**
- 将现有的模型示例(如果有)更新为包含完整字段标签的版本
**依赖**: Task 5.2
**验证方法:**
- CLAUDE.md 中的规范描述与实际实现的模型完全一致
- 所有示例代码可以直接复制使用,无需修改
- 规范描述清晰、完整、无歧义
- 运行 `git diff CLAUDE.md` 检查修改内容
---
## 依赖关系图
```
阶段 1 (核心模型)
├─ Task 1.1: IotCard
├─ Task 1.2: Device
├─ Task 1.3: NumberCard
└─ Task 1.4: Carrier
阶段 2 (套餐和订单)
├─ Task 2.1: PackageSeries
├─ Task 2.2: Package (依赖 Task 2.1)
├─ Task 2.3: AgentPackageAllocation (依赖 Task 2.2)
├─ Task 2.4: PackageUsage (依赖 Task 2.2)
├─ Task 2.5: DeviceSimBinding (依赖 Task 1.1, Task 1.2)
└─ Task 2.6: Order (依赖 Task 1.1, Task 1.2, Task 1.3, Task 2.2)
阶段 3 (分佣系统)
├─ Task 3.1: AgentHierarchy
├─ Task 3.2: CommissionRule
├─ Task 3.3: CommissionLadder (依赖 Task 3.2)
├─ Task 3.4: CommissionCombinedCondition (依赖 Task 3.2)
├─ Task 3.5: CommissionRecord (依赖 Task 3.2)
├─ Task 3.6: CommissionApproval (依赖 Task 3.5)
├─ Task 3.7: CommissionTemplate
└─ Task 3.8: CarrierSettlement (依赖 Task 3.5)
阶段 4 (财务和系统)
├─ Task 4.1: CommissionWithdrawalRequest
├─ Task 4.2: CommissionWithdrawalSetting
├─ Task 4.3: PaymentMerchantSetting
├─ Task 4.4: DevCapabilityConfig
├─ Task 4.5: CardReplacementRequest
├─ Task 4.6: PollingConfig
└─ Task 4.7: DataUsageRecord (依赖 Task 1.1)
阶段 5 (验证和测试)
├─ Task 5.1: 编译验证
├─ Task 5.2: 一致性检查 (依赖 Task 5.1)
├─ Task 5.3: 生成迁移脚本 (依赖 Task 5.2, 可选)
├─ Task 5.4: 文档更新 (依赖 Task 5.2, 可选)
└─ Task 5.5: 更新全局规范文档 (依赖 Task 5.2, 必需)
```
## 估算工作量
- **阶段 1**: 约 2-3 小时4 个核心模型)
- **阶段 2**: 约 3-4 小时6 个套餐和订单模型)
- **阶段 3**: 约 4-5 小时8 个分佣系统模型)
- **阶段 4**: 约 3-4 小时7 个财务和系统模型)
- **阶段 5**: 约 2-3 小时(验证、测试和全局规范文档更新)
**总计**: 约 14-19 小时(~2-3 个工作日)
## 注意事项
1. **并行执行**: 阶段内的任务可以并行执行(除非明确依赖)
2. **增量提交**: 建议每完成一个阶段提交一次 Git commit
3. **回归测试**: 修复完成后需要运行完整的单元测试套件(如有)
4. **代码审查**: 修复完成后需要进行 Code Review确保符合项目规范

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-13

View File

@@ -0,0 +1,3 @@
# add-wallet-transfer-tag-models
添加钱包、换卡记录、标签系统的模型和表结构设计

View File

@@ -0,0 +1,102 @@
# Change: 添加钱包、换卡、标签系统模型和表结构
## Why
在审查现有的 IoT 卡管理和订单系统后,发现以下关键功能缺失,需要补充模型和表结构设计:
1. **钱包系统缺失**:当前订单表支持 `payment_method=wallet`,但没有钱包表和钱包明细表,无法支持用户/代理充值和余额管理
2. **换卡记录缺失**IoT 卡有 `owner_type`/`owner_id` 可变更,但没有换卡记录表追踪换卡历史(老卡→新卡的套餐、代理、权益转移)
3. **标签系统完全缺失**:企业用户无法为设备/卡片打标签进行分类管理
4. **运营商渠道管理不足**:现有 `tb_carrier` 表只有运营商名称,无法区分运营商类型(四大运营商固定)和渠道(可自定义)
## What Changes
本提案**仅涉及模型和表结构设计**,不包含 API、Service、Store 层实现。
### 1. 钱包系统(新增)
- **新增表**`tb_wallet``tb_wallet_transaction``tb_recharge_record`
- **新增模型**`Wallet``WalletTransaction``RechargeRecord`
- **功能支持**
- 用户钱包和代理钱包统一管理
- 用户可充值到钱包,购买套餐时选择钱包支付或直接支付
- 代理可预充值到钱包,用成本价购买套餐
- 完整的钱包明细记录(充值、扣款、退款、分佣、提现)
- 使用乐观锁version 字段)防止并发扣款
### 2. 换卡系统(新增)
- **新增表**`tb_card_replacement_record`
- **新增模型**`CardReplacementRecord`
- **功能支持**
- 记录老卡和新卡的关联关系
- 套餐权益转移快照(剩余流量、过期时间等,使用 JSONB 存储)
- 代理关系转移记录
- 所有者信息转移记录
- 换卡原因和审批状态
### 3. 标签系统(新增)
- **新增表**`tb_tag``tb_resource_tag`
- **新增模型**`Tag``ResourceTag`
- **功能支持**
- 标签定义(名称、颜色、使用次数)
- 统一的资源-标签关联表支持设备、IoT卡、号卡
- 企业用户可为设备/卡片打标签
- 支持按标签查询和筛选
### 4. 运营商渠道管理改进(修改)
- **修改表**`tb_carrier`
- **修改模型**`Carrier`
- **新增字段**
- `carrier_type`运营商类型枚举CMCC/CUCC/CTCC/CBN
- `channel_name`:渠道名称(可自定义)
- `channel_code`:渠道编码(可自定义)
- **唯一约束**`(carrier_type, channel_code)``deleted_at IS NULL` 条件下唯一
### 5. 订单系统改进(修改)
- **修改表**`tb_order`
- **修改模型**`Order`
- **新增字段**
- `wallet_payment_amount`:钱包支付金额(分)
- `online_payment_amount`:在线支付金额(分)
- **说明**:支持混合支付(钱包 + 在线支付)
## Impact
### 受影响的 specs
- **新增**wallet、card-replacement、tag
- **修改**carrier运营商管理、iot-order订单支付方式
### 受影响的代码
- **新增文件**
- `internal/model/wallet.go`
- `internal/model/card_replacement.go`
- `internal/model/tag.go`
- `migrations/000XXX_add_wallet_transfer_tag_tables.up.sql`
- `migrations/000XXX_add_wallet_transfer_tag_tables.down.sql`
- `pkg/constants/wallet.go`
- `pkg/constants/tag.go`
- **修改文件**
- `internal/model/carrier.go`
- `internal/model/order.go`
### 破坏性变更
- **无破坏性变更**:所有修改都是新增字段,有默认值,向后兼容
### 数据迁移
- 需要为现有 `tb_carrier` 记录填充默认的 `carrier_type`
- 建议在迁移文件中添加数据初始化脚本
## 设计原则遵循
- ✅ 表名使用 `tb_` 前缀,模型名使用单数形式
- ✅ 所有表包含软删除(`deleted_at`)和审计字段(`creator``updater`
- ✅ 所有金额字段使用 `BIGINT` 类型,单位为分
- ✅ 唯一索引包含 `WHERE deleted_at IS NULL` 条件
- ✅ 禁止使用数据库外键约束
- ✅ 所有常量定义在 `pkg/constants/` 目录
- ✅ 使用 GORM 标准字段标签
- ✅ 钱包使用乐观锁version 字段)防止并发问题

View File

@@ -0,0 +1,183 @@
## ADDED Requirements
### Requirement: 换卡记录实体定义
系统 SHALL 定义换卡记录(CardReplacementRecord)实体,记录老卡到新卡的完整转移过程,包括套餐权益、代理关系、所有者信息等。
**核心概念**
- **换卡场景**:老卡损坏、丢失或故障,需要更换新卡
- **权益转移**:老卡的套餐(含剩余流量)、代理关系、所有者信息等全部转移到新卡
- **套餐继续生效**:转移后套餐不作废,剩余流量继续可用
**实体字段**
- `id`:换卡记录 ID主键BIGINT
- `replacement_no`换卡单号VARCHAR(50),唯一)
- `old_card_id`:老卡 IDBIGINT关联 tb_iot_card.id
- `old_iccid`:老卡 ICCIDVARCHAR(50),冗余存储,防止老卡被删除后无法追踪)
- `new_card_id`:新卡 IDBIGINT关联 tb_iot_card.id
- `new_iccid`:新卡 ICCIDVARCHAR(50),冗余存储)
- `old_owner_type`老卡所有者类型VARCHAR(20)
- `old_owner_id`:老卡所有者 IDBIGINT
- `old_agent_id`:老卡代理 IDBIGINT可空
- `new_owner_type`新卡所有者类型VARCHAR(20)
- `new_owner_id`:新卡所有者 IDBIGINT
- `new_agent_id`:新卡代理 IDBIGINT可空
- `package_snapshot`套餐快照JSONB记录转移时的套餐详情
- `replacement_reason`换卡原因VARCHAR(20),枚举值:"damaged"-损坏 | "lost"-丢失 | "malfunction"-故障 | "upgrade"-升级 | "other"-其他)
- `remark`备注TEXT
- `status`换卡状态INT1-待审批 2-已通过 3-已拒绝 4-已完成)
- `approved_by`:审批人 IDBIGINT可空
- `approved_at`审批时间TIMESTAMP可空
- `completed_at`完成时间TIMESTAMP可空
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**套餐快照 JSON 格式示例**
```json
{
"package_id": 3001,
"package_name": "月套餐 10GB",
"package_code": "PKG-M-001",
"data_limit_mb": 10240,
"data_usage_mb": 5120,
"real_data_usage_mb": 4000,
"virtual_data_usage_mb": 1120,
"data_remaining_mb": 5120,
"activated_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-02-01T00:00:00Z",
"remaining_days": 15,
"order_id": 10001
}
```
#### Scenario: 创建换卡记录
- **WHEN** 用户ID 为 2001的老卡ICCID 为 "8986001"损坏需要换新卡ICCID 为 "8986002"
- **THEN** 系统创建换卡记录,`old_card_id` 为老卡 ID`new_card_id` 为新卡 ID`replacement_reason` 为 "damaged"`status` 为 1待审批
#### Scenario: 审批通过换卡
- **WHEN** 运营人员ID 为 999审批通过换卡记录ID 为 5001
- **THEN** 系统将换卡记录状态从 1待审批变更为 2已通过记录 `approved_by` 为 999`approved_at` 为当前时间
#### Scenario: 完成换卡
- **WHEN** 换卡记录ID 为 5001状态为 2已通过系统执行换卡操作
- **THEN** 系统将:
1. 记录老卡和新卡的快照信息(所有者、代理、套餐)
2. 将老卡的套餐权益转移到新卡(套餐使用记录的 `iot_card_id` 更新为新卡 ID
3. 将新卡的 `owner_type``owner_id` 更新为老卡的值
4. 将新卡的代理关系更新为老卡的值(如有)
5. 将换卡记录状态变更为 4已完成记录 `completed_at` 为当前时间
#### Scenario: 拒绝换卡
- **WHEN** 运营人员ID 为 999拒绝换卡记录ID 为 5001原因为"新卡不符合要求"
- **THEN** 系统将换卡记录状态从 1待审批变更为 3已拒绝记录 `approved_by` 为 999`approved_at` 为当前时间,`remark` 为拒绝原因
---
### Requirement: 套餐权益转移
系统 SHALL 在换卡完成后,将老卡的套餐权益(包括剩余流量、过期时间等)转移到新卡,套餐继续生效。
**转移内容**
- 套餐使用记录(`tb_package_usage`
- 剩余流量(`data_limit_mb - data_usage_mb`
- 套餐过期时间(`expires_at`
- 关联的订单信息
**转移规则**
- 老卡的套餐使用记录的 `iot_card_id` 更新为新卡 ID
- 剩余流量完整保留
- 套餐过期时间不变
- 如果老卡有多个套餐(正式套餐 + 加油包),全部转移
#### Scenario: 套餐转移
- **WHEN** 老卡有月套餐(剩余 5120 MB 流量,还有 15 天过期)
- **THEN** 系统将套餐使用记录的 `iot_card_id` 从老卡 ID 更新为新卡 ID流量和过期时间保持不变
#### Scenario: 多套餐转移
- **WHEN** 老卡有正式套餐和 2 个加油包
- **THEN** 系统将所有套餐使用记录的 `iot_card_id` 更新为新卡 ID所有套餐继续生效
---
### Requirement: 代理关系转移
系统 SHALL 在换卡完成后,将老卡的代理关系转移到新卡。
**转移内容**
- 新卡的 `owner_type` 更新为老卡的 `owner_type`
- 新卡的 `owner_id` 更新为老卡的 `owner_id`
- 如果老卡通过代理销售,新卡继承相同的代理关系
#### Scenario: 代理关系转移
- **WHEN** 老卡的 `owner_type` 为 "agent"`owner_id` 为 123
- **THEN** 系统将新卡的 `owner_type` 更新为 "agent"`owner_id` 更新为 123
---
### Requirement: 换卡记录查询
系统 SHALL 支持按老卡 ID、新卡 ID、用户 ID、换卡单号等条件查询换卡记录。
**查询条件**
- 换卡单号(精确匹配)
- 老卡 ID精确匹配
- 新卡 ID精确匹配
- 老卡 ICCID精确匹配或模糊匹配
- 新卡 ICCID精确匹配或模糊匹配
- 换卡状态(单选或多选)
- 换卡原因(单选或多选)
- 创建时间范围
- 完成时间范围
**分页**
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
#### Scenario: 按老卡 ICCID 查询换卡记录
- **WHEN** 查询老卡 ICCID 为 "8986001" 的换卡记录
- **THEN** 系统返回所有 `old_iccid` 为 "8986001" 的换卡记录列表
#### Scenario: 按状态查询换卡记录
- **WHEN** 查询状态为 1待审批的换卡记录
- **THEN** 系统返回所有 `status` 为 1 的换卡记录列表,按创建时间倒序排列
---
### Requirement: 换卡数据校验
系统 SHALL 对换卡数据进行校验,确保数据完整性和一致性。
**校验规则**
- `old_card_id`:必填,≥ 1必须是有效的 IoT 卡 ID
- `new_card_id`:必填,≥ 1必须是有效的 IoT 卡 ID不能与 `old_card_id` 相同
- `old_iccid`:必填,长度 19-20 字符
- `new_iccid`:必填,长度 19-20 字符,不能与 `old_iccid` 相同
- `replacement_reason`:必填,枚举值 "damaged" | "lost" | "malfunction" | "upgrade" | "other"
- `status`:必填,枚举值 1-4
#### Scenario: 换卡时老卡和新卡相同
- **WHEN** 创建换卡记录,`old_card_id``new_card_id` 都为 1001
- **THEN** 系统拒绝创建,返回错误信息"新卡不能与老卡相同"
#### Scenario: 换卡时新卡 ICCID 无效
- **WHEN** 创建换卡记录,`new_iccid` 长度为 15小于 19
- **THEN** 系统拒绝创建,返回错误信息"ICCID 长度必须为 19-20 字符"
#### Scenario: 换卡时老卡不存在
- **WHEN** 创建换卡记录,`old_card_id` 为 99999不存在的 IoT 卡)
- **THEN** 系统拒绝创建,返回错误信息"老卡不存在"

View File

@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: 运营商实体定义
系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)的渠道信息
**四大运营商固定枚举**
- **CMCC**:中国移动
- **CUCC**:中国联通
- **CTCC**:中国电信
- **CBN**:广电
**实体字段**
- `id`:运营商 ID主键BIGINT
- `carrier_type`运营商类型VARCHAR(20),枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN"**【新增】**
- `carrier_name`运营商名称VARCHAR(100),如"中国移动"
- `carrier_code`运营商编码VARCHAR(50),保留字段,建议填充与 carrier_type 相同)
- `channel_name`渠道名称VARCHAR(100),可自定义,如"北京渠道1"**【新增】**
- `channel_code`渠道编码VARCHAR(50),可自定义,如"BJ001"**【新增】**
- `status`状态INT1-启用 2-禁用)
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`(carrier_type, channel_code)``deleted_at IS NULL` 条件下唯一
#### Scenario: 创建中国移动的渠道
- **WHEN** 平台创建中国移动的北京渠道,`carrier_type` 为 "CMCC"`carrier_name` 为 "中国移动"`channel_name` 为 "北京渠道1"`channel_code` 为 "BJ001"
- **THEN** 系统创建运营商记录,`carrier_type` 为 "CMCC"`channel_name` 为 "北京渠道1"`channel_code` 为 "BJ001"
#### Scenario: 同一运营商创建多个渠道
- **WHEN** 平台为中国移动创建两个渠道北京渠道BJ001和上海渠道SH001
- **THEN** 系统创建两条运营商记录,`carrier_type` 都为 "CMCC",但 `channel_code` 不同
#### Scenario: 渠道编码重复
- **WHEN** 平台创建中国移动的渠道,`carrier_type` 为 "CMCC"`channel_code` 为已存在的 "BJ001"
- **THEN** 系统拒绝创建,返回错误信息"该运营商的渠道编码已存在"
#### Scenario: 不同运营商可以使用相同渠道编码
- **WHEN** 平台为中国移动创建渠道carrier_type=CMCC, channel_code=BJ001然后为中国联通创建渠道carrier_type=CUCC, channel_code=BJ001
- **THEN** 系统允许创建,因为 `carrier_type` 不同
#### Scenario: 运营商类型枚举限制
- **WHEN** 平台创建运营商,`carrier_type` 为 "OTHER"(不在枚举中)
- **THEN** 系统拒绝创建,返回错误信息"运营商类型必须是 CMCC/CUCC/CTCC/CBN 之一"
---
### Requirement: 运营商数据校验
系统 SHALL 对运营商数据进行校验,确保数据完整性和一致性。
**校验规则**
- `carrier_type`:必填,枚举值 "CMCC" | "CUCC" | "CTCC" | "CBN"
- `carrier_name`:必填,长度 1-100 字符
- `carrier_code`:必填,长度 1-50 字符
- `channel_name`:可选,长度 1-100 字符
- `channel_code`:可选,长度 1-50 字符
- `status`:必填,枚举值 1-2
#### Scenario: 创建运营商时 carrier_type 无效
- **WHEN** 创建运营商,`carrier_type` 为 "INVALID"
- **THEN** 系统拒绝创建,返回错误信息"运营商类型无效"
#### Scenario: 创建运营商时 carrier_name 为空
- **WHEN** 创建运营商,`carrier_name` 为空
- **THEN** 系统拒绝创建,返回错误信息"运营商名称不能为空"

View File

@@ -0,0 +1,123 @@
## ADDED Requirements
### Requirement: 订单支付处理
系统 SHALL 根据支付方式正确处理订单支付,包括钱包扣款、在线支付、混合支付等。
**钱包支付流程**
1. 检查钱包可用余额是否充足
2. 冻结钱包余额(`frozen_balance` 增加)
3. 创建订单,状态为"待支付"
4. 订单完成后,扣减钱包余额(`balance` 减少,`frozen_balance` 减少),创建钱包明细记录
5. 订单取消时,解冻钱包余额(`frozen_balance` 减少)
**在线支付流程**
1. 创建订单,状态为"待支付"
2. 调用第三方支付接口
3. 用户完成支付后,订单状态变更为"已支付"
4. 订单完成后,订单状态变更为"已完成"
**混合支付流程**
1. 检查钱包可用余额是否充足(钱包支付部分)
2. 冻结钱包余额
3. 创建订单,状态为"待支付"
4. 调用第三方支付接口(在线支付部分)
5. 用户完成在线支付后,扣减钱包余额,订单状态变更为"已支付"
6. 订单完成后,订单状态变更为"已完成"
#### Scenario: 钱包支付订单完成
- **WHEN** 用户使用钱包支付购买套餐,订单金额为 3000 分
- **THEN** 系统:
1. 创建订单,状态为"待支付",冻结钱包余额 3000 分
2. 订单处理完成后,扣减钱包余额 3000 分,解冻 3000 分,创建钱包明细记录(类型为"扣款"),订单状态变更为"已完成"
#### Scenario: 混合支付订单完成
- **WHEN** 用户使用混合支付购买套餐,钱包支付 2000 分 + 在线支付 3000 分
- **THEN** 系统:
1. 创建订单,状态为"待支付",冻结钱包余额 2000 分
2. 用户完成在线支付 3000 分后,扣减钱包余额 2000 分,解冻 2000 分,创建钱包明细记录,订单状态变更为"已支付"
3. 订单处理完成后,订单状态变更为"已完成"
#### Scenario: 订单取消,解冻钱包余额
- **WHEN** 用户使用钱包支付创建订单,订单金额为 3000 分,然后取消订单
- **THEN** 系统解冻钱包余额 3000 分(`frozen_balance` 减少 3000订单状态变更为"已取消"
---
## MODIFIED Requirements
### Requirement: 订单实体定义
系统 SHALL 定义订单(Order)实体,统一管理两种订单类型:套餐订单、号卡订单,并支持混合支付方式(钱包 + 在线支付)。
**修改说明**
- 增加 `wallet_payment_amount` 字段:钱包支付金额
- 增加 `online_payment_amount` 字段:在线支付金额
- 支持用户在购买套餐时选择支付方式(全部钱包支付、全部在线支付、混合支付)
**实体字段**(只列出新增字段):
- `wallet_payment_amount`钱包支付金额BIGINT单位默认 0**【新增】**
- `online_payment_amount`在线支付金额BIGINT单位默认 0**【新增】**
**支付规则**
- `wallet_payment_amount` + `online_payment_amount` = `amount`(订单总金额)
-`payment_method` 为 "wallet" 时,`wallet_payment_amount` = `amount``online_payment_amount` = 0
-`payment_method` 为 "online" 时,`online_payment_amount` = `amount``wallet_payment_amount` = 0
- 混合支付时,`payment_method` 为 "mixed",两个字段都 > 0
#### Scenario: 全额钱包支付
- **WHEN** 用户购买套餐,订单金额为 30 00 分30 元),选择钱包支付,钱包余额为 10000 分
- **THEN** 系统创建订单,`amount` 为 3000`payment_method` 为 "wallet"`wallet_payment_amount` 为 3000`online_payment_amount` 为 0
#### Scenario: 全额在线支付
- **WHEN** 用户购买套餐,订单金额为 3000 分30 元),选择在线支付
- **THEN** 系统创建订单,`amount` 为 3000`payment_method` 为 "online"`wallet_payment_amount` 为 0`online_payment_amount` 为 3000
#### Scenario: 混合支付
- **WHEN** 用户购买套餐,订单金额为 5000 分50 元),钱包余额为 3000 分,用户选择钱包支付 3000 分 + 在线支付 2000 分
- **THEN** 系统创建订单,`amount` 为 5000`payment_method` 为 "mixed"`wallet_payment_amount` 为 3000`online_payment_amount` 为 2000
#### Scenario: 钱包余额不足,部分钱包支付
- **WHEN** 用户购买套餐,订单金额为 5000 分50 元),钱包余额为 2000 分,用户选择钱包支付 2000 分 + 在线支付 3000 分
- **THEN** 系统先冻结钱包余额 2000 分,创建订单,`wallet_payment_amount` 为 2000`online_payment_amount` 为 3000等待用户完成在线支付
#### Scenario: 钱包余额不足,无法全额钱包支付
- **WHEN** 用户购买套餐,订单金额为 5000 分50 元),钱包余额为 3000 分,用户选择钱包支付
- **THEN** 系统拒绝创建订单,返回错误信息"钱包余额不足",建议用户选择混合支付或在线支付
---
### Requirement: 订单数据校验
系统 SHALL 对订单数据进行校验,确保数据完整性和一致性,特别是支付金额的一致性。
**新增校验规则**
- `wallet_payment_amount`:必填,≥ 0最多精确到分
- `online_payment_amount`:必填,≥ 0最多精确到分
- `wallet_payment_amount` + `online_payment_amount` = `amount`(订单总金额)
-`payment_method` 为 "wallet" 时,`wallet_payment_amount` 必须 = `amount`
-`payment_method` 为 "online" 时,`online_payment_amount` 必须 = `amount`
-`payment_method` 为 "mixed" 时,两个字段都必须 > 0
#### Scenario: 支付金额不一致
- **WHEN** 创建订单,`amount` 为 5000`wallet_payment_amount` 为 2000`online_payment_amount` 为 2000
- **THEN** 系统拒绝创建,返回错误信息"支付金额总和与订单金额不一致"
#### Scenario: 钱包支付时在线支付金额不为 0
- **WHEN** 创建订单,`payment_method` 为 "wallet"`wallet_payment_amount` 为 3000`online_payment_amount` 为 0正确但用户错误地设置 `online_payment_amount` 为 100
- **THEN** 系统拒绝创建,返回错误信息"钱包支付时在线支付金额必须为 0"
#### Scenario: 混合支付时钱包支付金额为 0
- **WHEN** 创建订单,`payment_method` 为 "mixed"`wallet_payment_amount` 为 0`online_payment_amount` 为 5000
- **THEN** 系统拒绝创建,返回错误信息"混合支付时钱包支付金额和在线支付金额都必须大于 0"

View File

@@ -0,0 +1,218 @@
## ADDED Requirements
### Requirement: 标签实体定义
系统 SHALL 定义标签(Tag)实体用于为资源设备、IoT卡、号卡提供自定义标签分类功能。
**核心概念**
- 企业用户可以为自己的设备/卡片创建和管理标签
- 标签可以跨资源类型使用(一个标签可以同时用于设备和卡片)
- 支持按标签查询和筛选资源
**实体字段**
- `id`:标签 ID主键BIGINT
- `name`标签名称VARCHAR(100),唯一)
- `color`标签颜色VARCHAR(20),可选,用于前端显示,如 "#FF5733"
- `usage_count`使用次数INT默认 0记录有多少资源使用了该标签
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`name``deleted_at IS NULL` 条件下唯一
#### Scenario: 创建标签
- **WHEN** 用户创建标签,名称为"生产设备",颜色为"#FF5733"
- **THEN** 系统创建标签记录,`name` 为 "生产设备"`color` 为 "#FF5733"`usage_count` 为 0
#### Scenario: 标签名称重复
- **WHEN** 用户创建标签,名称为已存在的"生产设备"
- **THEN** 系统拒绝创建,返回错误信息"标签名称已存在"
#### Scenario: 更新标签
- **WHEN** 用户更新标签ID 为 101将颜色从"#FF5733"改为"#33FF57"
- **THEN** 系统更新标签记录,`color` 为 "#33FF57"`updated_at` 为当前时间
---
### Requirement: 资源-标签关联
系统 SHALL 定义资源-标签关联(ResourceTag)实体建立资源与标签的多对多关系统一管理设备、IoT卡、号卡的标签。
**实体字段**
- `id`:关联记录 ID主键BIGINT
- `resource_type`资源类型VARCHAR(20),枚举值:"device"-设备 | "iot_card"-IoT卡 | "number_card"-号卡)
- `resource_id`:资源 IDBIGINT
- `tag_id`:标签 IDBIGINT关联 tb_tag.id
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`(resource_type, resource_id, tag_id)``deleted_at IS NULL` 条件下唯一
#### Scenario: 为设备添加标签
- **WHEN** 用户为设备ID 为 1001添加标签"生产设备"ID 为 101
- **THEN** 系统创建关联记录,`resource_type` 为 "device"`resource_id` 为 1001`tag_id` 为 101标签的 `usage_count` 增加 1
#### Scenario: 为 IoT 卡添加标签
- **WHEN** 用户为 IoT 卡ID 为 2001添加标签"GPS"ID 为 102
- **THEN** 系统创建关联记录,`resource_type` 为 "iot_card"`resource_id` 为 2001`tag_id` 为 102标签的 `usage_count` 增加 1
#### Scenario: 重复添加标签
- **WHEN** 用户为设备ID 为 1001添加已存在的标签"生产设备"ID 为 101
- **THEN** 系统拒绝操作,返回错误信息"该资源已添加此标签"
#### Scenario: 移除资源标签
- **WHEN** 用户移除设备ID 为 1001的标签"生产设备"ID 为 101
- **THEN** 系统删除关联记录(软删除),标签的 `usage_count` 减少 1
---
### Requirement: 按标签查询资源
系统 SHALL 支持按标签查询资源,用户可以选择一个或多个标签,查询包含这些标签的资源。
**查询模式**
- **AND 模式**:查询同时包含所有指定标签的资源(交集)
- **OR 模式**:查询包含任一指定标签的资源(并集)
**查询条件**
- 资源类型(必选,单选)
- 标签 ID 列表(必选,可多选)
- 查询模式(可选,默认 OR
**分页**
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
#### Scenario: OR 模式查询设备
- **WHEN** 用户查询包含标签"生产设备"ID 为 101或"测试设备"ID 为 102的设备
- **THEN** 系统返回所有包含标签 101 或标签 102 的设备列表
#### Scenario: AND 模式查询设备
- **WHEN** 用户查询同时包含标签"生产设备"ID 为 101和"GPS"ID 为 103的设备
- **THEN** 系统返回同时包含标签 101 和标签 103 的设备列表
#### Scenario: 按标签查询 IoT 卡
- **WHEN** 用户查询包含标签"GPS"ID 为 102的 IoT 卡
- **THEN** 系统返回所有包含标签 102 的 IoT 卡列表
---
### Requirement: 获取资源的标签列表
系统 SHALL 支持查询指定资源的所有标签。
**查询条件**
- 资源类型(必选)
- 资源 ID必选
**返回内容**
- 标签列表ID、名称、颜色
- 按创建时间倒序排列
#### Scenario: 查询设备的标签
- **WHEN** 用户查询设备ID 为 1001的所有标签
- **THEN** 系统返回设备 1001 的标签列表,包含标签 ID、名称、颜色
#### Scenario: 查询没有标签的设备
- **WHEN** 用户查询设备ID 为 1002的所有标签但该设备没有任何标签
- **THEN** 系统返回空列表
---
### Requirement: 热门标签查询
系统 SHALL 支持查询热门标签,按使用次数倒序排列。
**查询条件**
- 限制数量(可选,默认 20
**返回内容**
- 标签列表ID、名称、颜色、使用次数
- 按使用次数倒序排列
#### Scenario: 查询热门标签
- **WHEN** 用户查询热门标签,限制 10 条
- **THEN** 系统返回使用次数最多的 10 个标签,按使用次数倒序排列
---
### Requirement: 标签批量操作
系统 SHALL 支持为资源批量添加或移除标签。
**批量添加**
- 为一个资源添加多个标签
- 为多个资源添加同一个标签
**批量移除**
- 为一个资源移除多个标签
- 为多个资源移除同一个标签
#### Scenario: 为设备批量添加标签
- **WHEN** 用户为设备ID 为 1001批量添加标签["生产设备", "GPS", "4G"]
- **THEN** 系统为设备 1001 创建 3 条关联记录,所有标签的 `usage_count` 各增加 1
#### Scenario: 批量为设备添加标签
- **WHEN** 用户为设备列表 [1001, 1002, 1003] 批量添加标签"生产设备"ID 为 101
- **THEN** 系统为 3 个设备各创建一条关联记录,标签"生产设备"的 `usage_count` 增加 3
#### Scenario: 为设备批量移除标签
- **WHEN** 用户为设备ID 为 1001批量移除标签["生产设备", "GPS"]
- **THEN** 系统删除设备 1001 的 2 条关联记录(软删除),所有标签的 `usage_count` 各减少 1
---
### Requirement: 标签数据校验
系统 SHALL 对标签数据进行校验,确保数据完整性和一致性。
**标签校验规则**
- `name`:必填,长度 1-100 字符,唯一
- `color`:可选,长度 1-20 字符,建议使用十六进制颜色值(如 "#FF5733"
- `usage_count`:必填,≥ 0
**资源-标签关联校验规则**
- `resource_type`:必填,枚举值 "device" | "iot_card" | "number_card"
- `resource_id`:必填,≥ 1
- `tag_id`:必填,≥ 1必须是有效的标签 ID
#### Scenario: 创建标签时名称为空
- **WHEN** 用户创建标签,名称为空
- **THEN** 系统拒绝创建,返回错误信息"标签名称不能为空"
#### Scenario: 创建标签时名称过长
- **WHEN** 用户创建标签,名称长度为 101 字符
- **THEN** 系统拒绝创建,返回错误信息"标签名称长度不能超过 100 字符"
#### Scenario: 添加标签时资源类型无效
- **WHEN** 用户为资源添加标签,`resource_type` 为 "invalid"
- **THEN** 系统拒绝操作,返回错误信息"资源类型无效"
#### Scenario: 添加标签时标签不存在
- **WHEN** 用户为设备添加标签,`tag_id` 为 99999不存在的标签
- **THEN** 系统拒绝操作,返回错误信息"标签不存在"

View File

@@ -0,0 +1,199 @@
## ADDED Requirements
### Requirement: 钱包实体定义
系统 SHALL 定义钱包(Wallet)实体,统一管理用户钱包和代理钱包,支持余额管理、充值、扣款等操作。
**核心概念**
- **用户钱包**:普通用户和企业用户的钱包,用于购买套餐
- **代理钱包**:代理商的钱包,支持预充值,可用成本价购买套餐
**实体字段**
- `id`:钱包 ID主键BIGINT
- `user_id`:用户 IDBIGINT关联 tb_account.id
- `wallet_type`钱包类型VARCHAR(20),枚举值:"user"-用户钱包 | "agent"-代理钱包)
- `balance`余额BIGINT单位默认 0
- `frozen_balance`冻结余额BIGINT单位默认 0用于订单待支付、提现申请中等场景
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 2-冻结 3-关闭)
- `version`版本号INT默认 0乐观锁字段用于防止并发扣款
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`(user_id, wallet_type, currency)``deleted_at IS NULL` 条件下唯一
**可用余额计算**:可用余额 = balance - frozen_balance
#### Scenario: 创建用户钱包
- **WHEN** 用户ID 为 2001首次充值
- **THEN** 系统创建钱包记录,`user_id` 为 2001`wallet_type` 为 "user"`balance` 为 0`status` 为 1正常
#### Scenario: 创建代理钱包
- **WHEN** 代理商ID 为 123首次充值
- **THEN** 系统创建钱包记录,`user_id` 为 123`wallet_type` 为 "agent"`balance` 为 0`status` 为 1正常
#### Scenario: 计算可用余额
- **WHEN** 用户钱包余额为 10000 分100 元),冻结余额为 3000 分30 元)
- **THEN** 系统计算可用余额为 7000 分70 元)
---
### Requirement: 钱包明细记录
系统 SHALL 记录所有钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。
**实体字段**
- `id`:明细 ID主键BIGINT
- `wallet_id`:钱包 IDBIGINT关联 tb_wallet.id
- `user_id`:用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中)
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup"
- `reference_id`:关联业务 IDBIGINT
- `remark`备注TEXT
- `metadata`扩展信息JSONB如手续费、支付方式等
- `creator`:创建人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
#### Scenario: 充值创建明细记录
- **WHEN** 用户ID 为 2001充值 10000 分100 元)
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "recharge"`amount` 为 10000`balance_before` 为 0`balance_after` 为 10000`status` 为 1成功
#### Scenario: 购买套餐扣款创建明细记录
- **WHEN** 用户ID 为 2001使用钱包支付购买套餐金额 3000 分30 元)
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "deduct"`amount` 为 -3000`balance_before` 为 10000`balance_after` 为 7000`reference_type` 为 "order"`reference_id` 为订单 ID
#### Scenario: 分佣发放创建明细记录
- **WHEN** 代理ID 为 123的分佣 5000 分50 元)审批通过并发放
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "commission"`amount` 为 5000`balance_before` 为 20000`balance_after` 为 25000`reference_type` 为 "commission"`reference_id` 为分佣记录 ID
---
### Requirement: 充值记录管理
系统 SHALL 记录所有充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
**实体字段**
- `id`:充值记录 ID主键BIGINT
- `user_id`:用户 IDBIGINT关联 tb_account.id
- `wallet_id`:钱包 IDBIGINT关联 tb_wallet.id
- `recharge_no`充值订单号VARCHAR(50),唯一)
- `amount`充值金额BIGINT单位
- `payment_method`支付方式VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下)
- `payment_channel`支付渠道VARCHAR(50)
- `payment_transaction_id`第三方支付交易号VARCHAR(100)
- `status`充值状态INT1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
- `paid_at`支付时间TIMESTAMP可空
- `completed_at`完成时间TIMESTAMP可空
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
#### Scenario: 创建充值订单
- **WHEN** 用户ID 为 2001发起充值 10000 分100 元),选择支付宝支付
- **THEN** 系统创建充值记录,生成唯一的 `recharge_no``amount` 为 10000`payment_method` 为 "alipay"`status` 为 1待支付
#### Scenario: 充值支付完成
- **WHEN** 用户完成支付宝支付
- **THEN** 系统将充值记录状态从 1待支付变更为 2已支付记录 `paid_at` 时间和 `payment_transaction_id`
#### Scenario: 充值到账
- **WHEN** 充值记录状态为 2已支付系统处理充值到账
- **THEN** 系统将钱包余额增加 10000 分,创建钱包明细记录,将充值记录状态变更为 3已完成记录 `completed_at` 时间
---
### Requirement: 钱包余额操作
系统 SHALL 支持钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。
**操作类型**
- **充值**:增加钱包余额
- **扣款**:减少钱包余额(如购买套餐)
- **退款**:增加钱包余额(如订单退款)
- **冻结**:将部分余额转为冻结状态(如订单待支付)
- **解冻**:将冻结余额转回可用余额(如订单取消)
**并发控制**
- 使用 `version` 字段实现乐观锁
- 每次更新余额时,检查 `version` 是否匹配
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
#### Scenario: 钱包充值
- **WHEN** 用户钱包当前余额为 10000 分,充值 5000 分
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2创建钱包明细记录
#### Scenario: 钱包扣款
- **WHEN** 用户钱包当前余额为 15000 分,购买套餐扣款 3000 分
- **THEN** 系统检查可用余额15000 - 0 = 15000≥ 3000将钱包余额更新为 12000 分,`version` 从 2 变更为 3创建钱包明细记录
#### Scenario: 余额不足扣款失败
- **WHEN** 用户钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
- **THEN** 系统检查可用余额2000 - 0 = 2000< 3000拒绝扣款返回错误信息"余额不足"
#### Scenario: 并发扣款乐观锁生效
- **WHEN** 用户钱包当前余额为 10000 分version 为 1两个并发请求同时扣款 3000 分和 5000 分
- **THEN** 第一个请求成功,余额变为 7000 分version 变为 2第二个请求因 version 不匹配失败需重新读取最新余额7000 分)后重试
#### Scenario: 冻结余额
- **WHEN** 用户创建订单 10001订单金额 3000 分,选择钱包支付
- **THEN** 系统将钱包的 `frozen_balance` 增加 3000 分,可用余额减少 3000 分
#### Scenario: 解冻余额
- **WHEN** 用户取消订单 10001订单金额 3000 分
- **THEN** 系统将钱包的 `frozen_balance` 减少 3000 分,可用余额增加 3000 分
---
### Requirement: 钱包数据校验
系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性。
**校验规则**
- `user_id`:必填,≥ 1
- `wallet_type`:必填,枚举值 "user" | "agent"
- `balance`:必填,≥ 0
- `frozen_balance`:必填,≥ 0≤ balance
- `currency`:必填,长度 1-10 字符
- `status`:必填,枚举值 1-3
- `version`:必填,≥ 0
#### Scenario: 创建钱包时 user_id 无效
- **WHEN** 创建钱包,`user_id` 为 0
- **THEN** 系统拒绝创建,返回错误信息"用户 ID 无效"
#### Scenario: 创建钱包时 wallet_type 无效
- **WHEN** 创建钱包,`wallet_type` 为 "invalid"
- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效"
#### Scenario: 冻结余额超过总余额
- **WHEN** 钱包余额为 10000 分,尝试冻结 15000 分
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"

View File

@@ -0,0 +1,47 @@
# Implementation Tasks
## 1. 数据库迁移文件
- [x] 1.1 创建 up 迁移文件:`migrations/000007_add_wallet_transfer_tag_tables.up.sql`
- [x] 1.2 创建 down 迁移文件:`migrations/000007_add_wallet_transfer_tag_tables.down.sql`
- [x] 1.3 在 up 迁移中创建钱包相关表tb_wallet, tb_wallet_transaction, tb_recharge_record
- [x] 1.4 在 up 迁移中创建换卡记录表tb_card_replacement_record
- [x] 1.5 在 up 迁移中创建标签相关表tb_tag, tb_resource_tag
- [x] 1.6 在 up 迁移中修改运营商表tb_carrier 增加渠道字段)
- [x] 1.7 在 up 迁移中修改订单表tb_order 增加钱包支付字段)
- [x] 1.8 添加必要的索引
- [x] 1.9 编写 down 迁移的回滚逻辑
## 2. Go 模型定义
- [x] 2.1 创建 `internal/model/wallet.go`,定义 Wallet、WalletTransaction、RechargeRecord 模型
- [x] 2.2 创建 `internal/model/card_replacement.go`,定义 CardReplacementRecord 模型
- [x] 2.3 创建 `internal/model/tag.go`,定义 Tag、ResourceTag 模型
- [x] 2.4 修改 `internal/model/carrier.go`,增加渠道相关字段
- [x] 2.5 修改 `internal/model/order.go`,增加钱包支付相关字段
- [x] 2.6 确保所有模型包含 gorm.Model 和 BaseModelcreator、updater 字段)
- [x] 2.7 确保所有模型通过 gorm.Model 包含标准字段ID, CreatedAt, UpdatedAt, DeletedAt
- [x] 2.8 为所有字段添加 GORM 标签column、type、comment 等)
- [x] 2.9 为所有模型添加中文注释说明业务用途
## 3. 常量定义
- [x] 3.1 创建 `pkg/constants/wallet.go`,定义钱包类型、交易类型、状态等常量(含中文注释)
- [x] 3.2 创建 `pkg/constants/tag.go`,定义标签资源类型等常量(含中文注释)
- [x] 3.3 在 `pkg/constants/iot.go` 中定义运营商类型枚举CMCC/CUCC/CTCC/CBN和换卡原因常量
- [x] 3.4 在 `pkg/constants/redis.go` 中添加钱包和标签相关的 Redis Key 生成函数
## 4. 文档更新
- [x] 4.1 创建 `docs/add-wallet-transfer-tag-models/数据模型设计.md`,说明表结构设计
- [x] 4.2 创建 `docs/add-wallet-transfer-tag-models/字段说明.md`,详细说明各字段含义
- [x] 4.3 更新 AGENTS.md添加模型规范和常量注释规范
## 5. 验证和测试
- [x] 5.1 运行 LSP 诊断验证模型定义无错误
- [x] 5.2 验证所有唯一索引包含 `deleted_at IS NULL` 条件
- [x] 5.3 验证模型定义与表结构一致
- [x] 5.4 验证常量定义完整且符合规范
- [x] 5.5 执行 `openspec validate add-wallet-transfer-tag-models --strict` ✅ 通过
- [x] 5.6 运行迁移文件,验证表创建成功 ✅ 迁移版本: 6 → 7 (282.5ms)

Some files were not shown because too many files have changed in this diff Show More