Compare commits
32 Commits
4507de577b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20a80982fe | |||
| 5be74f2698 | |||
| bb9f0a3a1f | |||
| d1a2af3b6d | |||
| 08736d2692 | |||
| 919d4350d0 | |||
| 109c9f014b | |||
| 627ca54c65 | |||
| 4d5cdc0d16 | |||
| 35a1000c18 | |||
| 427d7ef9cd | |||
| fe0e98df69 | |||
| dd8d802279 | |||
| 8e17b97481 | |||
| 9e2d87acb6 | |||
| b3450bd58a | |||
| 9e67914b1b | |||
| a996c604dd | |||
| 38a9a81bae | |||
| 589197e284 | |||
| a6940e78df | |||
| c205b450db | |||
| 028cfaa7aa | |||
| 18f35f3ef4 | |||
| 7ccd3d146c | |||
| 2857175266 | |||
| b1195c16df | |||
| 5556b1028c | |||
| 9c399df6bc | |||
| 2570269c8d | |||
| 6e2dc325d7 | |||
| 2150fb6ab9 |
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Git 相关
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
|
||||||
|
# 文档和规范
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
docs/
|
||||||
|
specs/
|
||||||
|
openspec/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
bin/
|
||||||
|
api
|
||||||
|
worker
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# 测试和覆盖率
|
||||||
|
tests/
|
||||||
|
*_test.go
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage*.out
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# IDE 配置
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.claude/
|
||||||
|
.sisyphus/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# 环境配置(不打包,使用挂载)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
scripts/
|
||||||
|
Makefile
|
||||||
|
openapi.yaml
|
||||||
|
opencode.json
|
||||||
|
ai-gateway.conf
|
||||||
|
.emdash.json
|
||||||
10
.emdash.json
Normal file
10
.emdash.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"preservePatterns": [
|
||||||
|
".env",
|
||||||
|
".env.keys",
|
||||||
|
".env.local",
|
||||||
|
".env.*.local",
|
||||||
|
".envrc",
|
||||||
|
"docker-compose.override.yml"
|
||||||
|
]
|
||||||
|
}
|
||||||
92
.gitea/workflows/deploy.yaml
Normal file
92
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
name: 构建并部署到测试环境(无 SSH)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- test
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: registry.boss160.cn
|
||||||
|
API_IMAGE: registry.boss160.cn/junhong/cmp-fiber-api
|
||||||
|
WORKER_IMAGE: registry.boss160.cn/junhong/cmp-fiber-worker
|
||||||
|
DEPLOY_DIR: /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
run: |
|
||||||
|
git clone https://git.boss160.cn/csxj2026/junhong_cmp_fiber.git .
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 设置镜像标签
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||||
|
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.ref }}" = "refs/heads/test" ]; then
|
||||||
|
echo "tag=test" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=unknown" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 登录 Docker Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" --username "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: 构建 API 镜像
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile.api -t ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} .
|
||||||
|
docker tag ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.API_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 构建 Worker 镜像
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} .
|
||||||
|
docker tag ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.WORKER_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 推送镜像到 Registry
|
||||||
|
run: |
|
||||||
|
docker push ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||||
|
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
|
||||||
|
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||||
|
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 部署到本地(仅 main 分支)
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
cd ${{ env.DEPLOY_DIR }}
|
||||||
|
|
||||||
|
echo "拉取最新镜像..."
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
echo "执行滚动更新..."
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --no-deps
|
||||||
|
|
||||||
|
echo "等待服务健康检查..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo "清理旧镜像(保留最近 3 个版本)..."
|
||||||
|
docker images ${{ env.API_IMAGE }} --format "{{.ID}}" | tail -n +4 | xargs -r docker rmi -f || true
|
||||||
|
docker images ${{ env.WORKER_IMAGE }} --format "{{.ID}}" | tail -n +4 | xargs -r docker rmi -f || true
|
||||||
|
|
||||||
|
echo "清理悬空镜像..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "部署完成!"
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
- name: 构建结果通知
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
echo "✅ 构建成功: ${{ steps.tag.outputs.tag }}"
|
||||||
|
echo "📦 镜像标签: ${{ github.sha }}"
|
||||||
|
else
|
||||||
|
echo "❌ 构建失败"
|
||||||
|
fi
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -72,3 +72,4 @@ cmd/api/api
|
|||||||
api
|
api
|
||||||
.gitignore
|
.gitignore
|
||||||
worker
|
worker
|
||||||
|
ai-gateway.conf
|
||||||
|
|||||||
214
AGENTS.md
214
AGENTS.md
@@ -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 工作流
|
||||||
|
|
||||||
创建提案前的检查清单:
|
创建提案前的检查清单:
|
||||||
|
|||||||
221
CLAUDE.md
221
CLAUDE.md
@@ -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
|
||||||
|
type IotCard struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel `gorm:"embedded"`
|
||||||
|
// 业务字段...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **日志表和只追加(append-only)表不需要软删除和审计字段**:
|
||||||
|
- 这类表只定义 `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"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**表名命名规范:**
|
||||||
|
|
||||||
|
- **所有表名必须使用 `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
|
```go
|
||||||
type User struct {
|
type IotCard struct {
|
||||||
ID uint `gorm:"column:id;primaryKey;comment:用户 ID" json:"id"`
|
gorm.Model // 提供 ID, CreatedAt, UpdatedAt, DeletedAt
|
||||||
UserID string `gorm:"column:user_id;type:varchar(100);uniqueIndex;not null;comment:用户唯一标识" json:"user_id"`
|
BaseModel `gorm:"embedded"` // 提供 Creator, Updater
|
||||||
Email string `gorm:"column:email;type:varchar(255);uniqueIndex;not null;comment:用户邮箱" json:"email"`
|
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
|
||||||
Phone string `gorm:"column:phone;type:varchar(20);comment:手机号码" json:"phone"`
|
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
|
||||||
Nickname string `gorm:"column:nickname;type:varchar(100);comment:用户昵称" json:"nickname"`
|
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||||
Balance int64 `gorm:"column:balance;type:bigint;default:0;comment:账户余额(分为单位)" json:"balance"`
|
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:用户状态 1-正常 2-禁用" json:"status"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-未激活 2-已激活 3-已停用" 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"`
|
|
||||||
|
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. **测试数据**: 不要在迁移文件中插入业务数据;可以插入配置数据或枚举值;测试数据用临时脚本处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 文档规范
|
### 文档规范
|
||||||
|
|
||||||
**文档结构要求:**
|
**文档结构要求:**
|
||||||
|
|||||||
73
Dockerfile.api
Normal file
73
Dockerfile.api
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# ================================
|
||||||
|
# 阶段 1: 构建阶段
|
||||||
|
# ================================
|
||||||
|
FROM golang:1.25.6-alpine AS builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 安装必要的构建工具
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
# 复制 go.mod 和 go.sum(利用 Docker 缓存)
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# 使用国内 Go 代理加速依赖下载
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 编译 API 服务(静态链接)
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-w -s" \
|
||||||
|
-o /build/api \
|
||||||
|
./cmd/api
|
||||||
|
|
||||||
|
# 下载 golang-migrate 工具
|
||||||
|
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# 阶段 2: 运行阶段
|
||||||
|
# ================================
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# 安装运行时依赖
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata bash curl
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
|
RUN addgroup -g 1000 appuser && \
|
||||||
|
adduser -D -u 1000 -G appuser appuser
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 从构建阶段复制二进制文件
|
||||||
|
COPY --from=builder /build/api /app/api
|
||||||
|
COPY --from=builder /go/bin/migrate /usr/local/bin/migrate
|
||||||
|
|
||||||
|
# 复制配置文件和迁移文件
|
||||||
|
COPY configs /app/configs
|
||||||
|
COPY migrations /app/migrations
|
||||||
|
|
||||||
|
# 复制启动脚本
|
||||||
|
COPY docker/entrypoint-api.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# 切换到非 root 用户
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8088
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8088/health || exit 1
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
57
Dockerfile.worker
Normal file
57
Dockerfile.worker
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# ================================
|
||||||
|
# 阶段 1: 构建阶段
|
||||||
|
# ================================
|
||||||
|
FROM golang:1.25.6-alpine AS builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 安装必要的构建工具
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
# 复制 go.mod 和 go.sum(利用 Docker 缓存)
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# 使用国内 Go 代理加速依赖下载
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 编译 Worker 服务(静态链接)
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-w -s" \
|
||||||
|
-o /build/worker \
|
||||||
|
./cmd/worker
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# 阶段 2: 运行阶段
|
||||||
|
# ================================
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# 安装运行时依赖
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata bash
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
|
RUN addgroup -g 1000 appuser && \
|
||||||
|
adduser -D -u 1000 -G appuser appuser
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 从构建阶段复制二进制文件
|
||||||
|
COPY --from=builder /build/worker /app/worker
|
||||||
|
|
||||||
|
# 复制配置文件
|
||||||
|
COPY configs /app/configs
|
||||||
|
|
||||||
|
# 切换到非 root 用户
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["/app/worker"]
|
||||||
203
README.md
203
README.md
@@ -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 卡
|
||||||
|
├── 卡1:ICCID-001
|
||||||
|
├── 卡2:ICCID-002
|
||||||
|
└── 卡3:ICCID-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)和 H5(Agent、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)。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|||||||
@@ -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{
|
||||||
Account: accHandler,
|
AdminAuth: adminAuthHandler,
|
||||||
Role: roleHandler,
|
H5Auth: h5AuthHandler,
|
||||||
Permission: permHandler,
|
Account: accHandler,
|
||||||
|
Role: roleHandler,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -37,56 +37,29 @@ queue:
|
|||||||
timeout: "10m"
|
timeout: "10m"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info"
|
level: "debug" # 开发环境使用 debug 级别
|
||||||
development: false
|
development: true # 启用开发模式(美化日志输出)
|
||||||
app_log:
|
app_log:
|
||||||
filename: "logs/app.log"
|
filename: "logs/app.log"
|
||||||
max_size: 100 # MB
|
max_size: 100
|
||||||
max_backups: 30
|
max_backups: 10 # 开发环境保留较少备份
|
||||||
max_age: 30 # 天
|
max_age: 7 # 7天
|
||||||
compress: true
|
compress: false # 开发环境不压缩
|
||||||
access_log:
|
access_log:
|
||||||
filename: "logs/access.log"
|
filename: "logs/access.log"
|
||||||
max_size: 500 # MB
|
max_size: 100
|
||||||
max_backups: 90
|
max_backups: 10
|
||||||
max_age: 90 # 天
|
max_age: 7
|
||||||
compress: true
|
compress: false
|
||||||
|
|
||||||
middleware:
|
middleware:
|
||||||
# 认证中间件开关
|
enable_auth: true # 开发环境可选禁用认证
|
||||||
enable_auth: true
|
enable_rate_limiter: true
|
||||||
|
|
||||||
# 限流中间件开关(默认禁用,按需启用)
|
|
||||||
enable_rate_limiter: false
|
|
||||||
|
|
||||||
# 限流器配置
|
|
||||||
rate_limiter:
|
rate_limiter:
|
||||||
# 每个时间窗口允许的最大请求数
|
max: 1000
|
||||||
# 建议值:
|
|
||||||
# - 公开 API(严格): 60-100
|
|
||||||
# - 公开 API(宽松): 1000-5000
|
|
||||||
# - 内部 API: 5000-10000
|
|
||||||
max: 100
|
|
||||||
|
|
||||||
# 时间窗口(限流重置周期)
|
|
||||||
# 支持格式:
|
|
||||||
# - "30s" (30秒)
|
|
||||||
# - "1m" (1分钟,推荐)
|
|
||||||
# - "5m" (5分钟)
|
|
||||||
# - "1h" (1小时)
|
|
||||||
expiration: "1m"
|
expiration: "1m"
|
||||||
|
storage: "redis"
|
||||||
|
|
||||||
# 限流存储方式
|
|
||||||
# 选项:
|
|
||||||
# - "memory": 内存存储(单机部署,快速,重启后重置)
|
|
||||||
# - "redis": Redis存储(分布式部署,持久化,跨服务器共享)
|
|
||||||
# 建议:
|
|
||||||
# - 开发/测试环境:使用 "memory"
|
|
||||||
# - 生产环境(单机):使用 "memory"
|
|
||||||
# - 生产环境(多机):使用 "redis"
|
|
||||||
storage: "memory"
|
|
||||||
|
|
||||||
# 短信服务配置
|
|
||||||
sms:
|
sms:
|
||||||
gateway_url: "https://gateway.sms.whjhft.com:8443/sms"
|
gateway_url: "https://gateway.sms.whjhft.com:8443/sms"
|
||||||
username: "JH0001" # TODO: 替换为实际的短信服务账号
|
username: "JH0001" # TODO: 替换为实际的短信服务账号
|
||||||
@@ -94,7 +67,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: "dev-secret-key-for-testing-only-32chars!"
|
||||||
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"
|
||||||
|
|||||||
58
docker-compose.prod.yml
Normal file
58
docker-compose.prod.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
|
||||||
|
container_name: junhong-cmp-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
environment:
|
||||||
|
- CONFIG_ENV=prod
|
||||||
|
- DB_HOST=${DB_HOST}
|
||||||
|
- DB_PORT=${DB_PORT}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_SSLMODE=${DB_SSLMODE}
|
||||||
|
volumes:
|
||||||
|
- ./configs:/app/configs:ro
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
networks:
|
||||||
|
- junhong-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
|
||||||
|
container_name: junhong-cmp-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- CONFIG_ENV=prod
|
||||||
|
volumes:
|
||||||
|
- ./configs:/app/configs:ro
|
||||||
|
- ./logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- junhong-network
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
junhong-network:
|
||||||
|
driver: bridge
|
||||||
39
docker/entrypoint-api.sh
Normal file
39
docker/entrypoint-api.sh
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "君鸿卡管系统 API 服务启动中..."
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# 检查必要的环境变量
|
||||||
|
if [ -z "$DB_HOST" ]; then
|
||||||
|
echo "错误: DB_HOST 环境变量未设置"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建数据库连接 URL
|
||||||
|
DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
|
||||||
|
|
||||||
|
echo "检查数据库连接..."
|
||||||
|
# 等待数据库就绪(最多等待 30 秒)
|
||||||
|
for i in {1..30}; do
|
||||||
|
if migrate -path /app/migrations -database "$DB_URL" version > /dev/null 2>&1; then
|
||||||
|
echo "数据库连接成功"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "等待数据库就绪... ($i/30)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 执行数据库迁移
|
||||||
|
echo "执行数据库迁移..."
|
||||||
|
if migrate -path /app/migrations -database "$DB_URL" up; then
|
||||||
|
echo "数据库迁移完成"
|
||||||
|
else
|
||||||
|
echo "警告: 数据库迁移失败或无新迁移"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 API 服务
|
||||||
|
echo "启动 API 服务..."
|
||||||
|
echo "========================================="
|
||||||
|
exec /app/api
|
||||||
328
docs/add-default-admin-init/功能说明.md
Normal file
328
docs/add-default-admin-init/功能说明.md
Normal 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. 尝试使用默认账号登录管理后台
|
||||||
456
docs/add-wallet-transfer-tag-models/字段说明.md
Normal file
456
docs/add-wallet-transfer-tag-models/字段说明.md
Normal 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分 | 500000(5000元) |
|
||||||
|
| frozen_balance | BIGINT | 是 | 0 | 冻结余额(单位:分),用于待结算的分佣、提现等 | 10000(100元) |
|
||||||
|
| 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 | 是 | 无 | 变动前余额(单位:分) | 100000(1000元) |
|
||||||
|
| balance_after | BIGINT | 是 | 无 | 变动后余额(单位:分) | 150000(1500元) |
|
||||||
|
| 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 | 是 | 无 | 充值金额(单位:分) | 100000(1000元) |
|
||||||
|
| 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 | 钱包支付金额(单位:分) | 30000(300元) |
|
||||||
|
| online_payment_amount | BIGINT | 是 | 0 | 在线支付金额(单位:分) | 20000(200元) |
|
||||||
|
|
||||||
|
**业务规则**:
|
||||||
|
- 订单总金额 `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` 锁定行,适合写多场景
|
||||||
|
|
||||||
|
钱包系统使用乐观锁,因为余额查询频繁,扣款相对较少。
|
||||||
624
docs/add-wallet-transfer-tag-models/数据模型设计.md
Normal file
624
docs/add-wallet-transfer-tag-models/数据模型设计.md
Normal 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. 用户发起充值请求 → 创建 RechargeRecord(status=1 待支付)
|
||||||
|
2. 调用支付网关 → 获取支付链接
|
||||||
|
3. 用户完成支付 → 支付回调更新 RechargeRecord(status=2 已支付)
|
||||||
|
4. 系统处理充值 → 创建 WalletTransaction(type=recharge)
|
||||||
|
5. 更新 Wallet 余额 → 使用乐观锁(version+1)
|
||||||
|
6. 充值完成 → 更新 RechargeRecord(status=3 已完成)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 消费流程
|
||||||
|
```
|
||||||
|
1. 用户购买套餐 → 检查钱包余额
|
||||||
|
2. 冻结金额 → 增加 frozen_balance
|
||||||
|
3. 订单完成 → 扣减 frozen_balance 和 balance
|
||||||
|
4. 创建 WalletTransaction(type=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. 用户申请换卡 → 创建 CardReplacementRecord(status=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_tag(usage_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 元后转手给个人客户 B,B 登录后看不到剩余 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
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚会从备份表恢复数据,但会丢失备份后的新增数据。
|
||||||
292
docs/add-wallet-transfer-tag-models/迁移验证报告.md
Normal file
292
docs/add-wallet-transfer-tag-models/迁移验证报告.md
Normal 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
380
docs/api-doc-update-summary.md
Normal file
380
docs/api-doc-update-summary.md
Normal 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
407
docs/auth-architecture.md
Normal 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_token(24h)
|
||||||
|
TokenMgr->>Redis: 存储 refresh_token(7天)
|
||||||
|
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. TokenManager(Token 管理器)
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 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 Token:24 小时自动过期
|
||||||
|
- Refresh Token:7 天自动过期
|
||||||
|
- 修改密码后立即撤销所有旧 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 |
|
||||||
|
|--------|-------------|-----|
|
||||||
|
| 撤销能力 | ✅ 立即生效 | ❌ 无法撤销 |
|
||||||
|
| 性能 | ✅ 5ms(Redis 查询) | ✅ 0ms(本地验证) |
|
||||||
|
| 存储负担 | ⚠️ Redis 内存 | ✅ 无服务端存储 |
|
||||||
|
| 灵活性 | ✅ 可存储复杂信息 | ⚠️ Payload 有大小限制 |
|
||||||
|
| 适用场景 | B 端系统(需要撤销) | C 端系统(高并发) |
|
||||||
|
|
||||||
|
**决策理由**:
|
||||||
|
- B 端用户数量有限(< 1000),Redis 内存负担可接受
|
||||||
|
- 修改密码、账号禁用等场景需要立即撤销 Token
|
||||||
|
- 需要存储完整的用户上下文信息(ShopID、EnterpriseID 等)
|
||||||
|
|
||||||
|
### 为什么使用双令牌机制?
|
||||||
|
|
||||||
|
**问题**:如果只有一个 Token:
|
||||||
|
- 短生命周期:用户频繁掉线,体验差
|
||||||
|
- 长生命周期:Token 泄露风险增加
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- Access Token(24小时):用于 API 访问,频繁传输,短生命周期降低泄露风险
|
||||||
|
- Refresh Token(7天):用于刷新 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 验证:< 5ms(Redis GET 操作)
|
||||||
|
- Token 生成:< 10ms(Redis SET + SADD 操作)
|
||||||
|
- Token 撤销:< 5ms(Redis 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
505
docs/auth-usage-guide.md
Normal 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 中存储 Token(XSS 风险)
|
||||||
|
- 不要在日志中记录完整 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
|
||||||
|
**维护者**: 君鸿卡管系统开发团队
|
||||||
500
docs/deployment/deployment-guide.md
Normal file
500
docs/deployment/deployment-guide.md
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
# 君鸿卡管系统 - 部署指南
|
||||||
|
|
||||||
|
本文档提供从零开始部署君鸿卡管系统到测试环境的完整步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 部署架构图 │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────▼───────────────────┐
|
||||||
|
│ 开发者 Push 代码到 Gitea │
|
||||||
|
│ (main/dev/test 分支) │
|
||||||
|
└───────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────▼───────────────────┐
|
||||||
|
│ 部署服务器 (47.111.166.169) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Act Runner (docker-runner-01) │ │
|
||||||
|
│ │ 自动触发工作流 │ │
|
||||||
|
│ └───────────┬─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────▼─────────────────────┐ │
|
||||||
|
│ │ 构建 Docker 镜像 │ │
|
||||||
|
│ │ - API 镜像 │ │
|
||||||
|
│ │ - Worker 镜像 │ │
|
||||||
|
│ └───────────┬─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────▼─────────────────────┐ │
|
||||||
|
│ │ Push 到私有 Docker Registry │ │
|
||||||
|
│ │ registry.boss160.cn │ │
|
||||||
|
│ └───────────┬─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────▼─────────────────────┐ │
|
||||||
|
│ │ 本地部署(无 SSH) │ │
|
||||||
|
│ │ - Pull 镜像 │ │
|
||||||
|
│ │ - 滚动更新容器 │ │
|
||||||
|
│ │ - 清理旧镜像(保留 3 个) │ │
|
||||||
|
│ └───────────┬─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────▼─────────────────────┐ │
|
||||||
|
│ │ 服务运行中 │ │
|
||||||
|
│ │ - API: 0.0.0.0:8088 │ │
|
||||||
|
│ │ - Worker: 后台任务处理 │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键优势**:Act Runner 在部署服务器本地运行,无需 SSH,配置简单!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置准备
|
||||||
|
|
||||||
|
### 1. 服务器环境要求
|
||||||
|
|
||||||
|
- **操作系统**:Ubuntu 20.04+ / CentOS 7+
|
||||||
|
- **Docker**:20.10+
|
||||||
|
- **Docker Compose**:1.29+
|
||||||
|
- **内存**:至少 2GB
|
||||||
|
- **磁盘**:至少 20GB
|
||||||
|
- **Act Runner**:已部署并注册到 Gitea(✅ 你已经有了 docker-runner-01)
|
||||||
|
|
||||||
|
### 2. 外部依赖服务
|
||||||
|
|
||||||
|
系统依赖以下外部服务(需提前部署):
|
||||||
|
|
||||||
|
- **PostgreSQL 14+**:数据库
|
||||||
|
- **Redis 6.0+**:缓存和队列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一步:服务器初始化
|
||||||
|
|
||||||
|
### 1.1 确认 Docker 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 到服务器
|
||||||
|
ssh qycard001@47.111.166.169 -p 52022
|
||||||
|
|
||||||
|
# 验证 Docker 和 Docker Compose
|
||||||
|
docker --version
|
||||||
|
docker-compose --version
|
||||||
|
|
||||||
|
# 验证 Act Runner 正在运行
|
||||||
|
docker ps | grep runner
|
||||||
|
# 应该能看到 docker-runner-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 创建部署目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建部署目录
|
||||||
|
mkdir -p /home/qycard001/app/junhong_cmp
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 创建必要的子目录
|
||||||
|
mkdir -p configs logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 准备配置文件
|
||||||
|
|
||||||
|
#### 创建 `.env` 文件(数据库迁移配置)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /home/qycard001/app/junhong_cmp/.env << 'EOF'
|
||||||
|
MIGRATIONS_DIR=migrations
|
||||||
|
DB_HOST=cxd.whcxd.cn
|
||||||
|
DB_PORT=16159
|
||||||
|
DB_USER=erp_pgsql
|
||||||
|
DB_PASSWORD=erp_2025
|
||||||
|
DB_NAME=junhong_cmp_test
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 创建 `configs/config.yaml`(应用配置)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /home/qycard001/app/junhong_cmp/configs/config.yaml << 'EOF'
|
||||||
|
server:
|
||||||
|
port: 8088
|
||||||
|
read_timeout: 60
|
||||||
|
write_timeout: 60
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: cxd.whcxd.cn
|
||||||
|
port: 16159
|
||||||
|
user: erp_pgsql
|
||||||
|
password: erp_2025
|
||||||
|
dbname: junhong_cmp_test
|
||||||
|
sslmode: disable
|
||||||
|
max_open_conns: 100
|
||||||
|
max_idle_conns: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
host: 你的Redis地址
|
||||||
|
port: 6379
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
output: logs/app.log
|
||||||
|
max_size: 100
|
||||||
|
max_backups: 7
|
||||||
|
max_age: 30
|
||||||
|
compress: true
|
||||||
|
|
||||||
|
middleware:
|
||||||
|
enable_auth: true
|
||||||
|
enable_rate_limiter: false
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要**:将 `你的Redis地址` 替换为实际的 Redis 地址。
|
||||||
|
|
||||||
|
### 1.4 复制部署文件
|
||||||
|
|
||||||
|
从代码仓库复制 `docker-compose.prod.yml` 到服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上执行
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 方式1: 使用 Git(推荐)
|
||||||
|
git clone <你的仓库地址> temp
|
||||||
|
cp temp/docker-compose.prod.yml ./docker-compose.prod.yml
|
||||||
|
rm -rf temp
|
||||||
|
|
||||||
|
# 方式2: 从本地上传
|
||||||
|
# 在本地执行:
|
||||||
|
# scp -P 52022 docker-compose.prod.yml qycard001@47.111.166.169:/home/qycard001/app/junhong_cmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二步:配置 Gitea Secrets
|
||||||
|
|
||||||
|
详细步骤请参考 [Gitea Secrets 配置说明](./gitea-secrets-setup.md)。
|
||||||
|
|
||||||
|
**只需要配置 2 个 Secrets**(非常简单!):
|
||||||
|
|
||||||
|
| Secret 名称 | 值 |
|
||||||
|
|------------|-----|
|
||||||
|
| `REGISTRY_USERNAME` | `junhong_admin` |
|
||||||
|
| `REGISTRY_PASSWORD` | `JunHong@2025!Registry` |
|
||||||
|
|
||||||
|
**配置步骤**:
|
||||||
|
1. 进入 Gitea 仓库 → 设置 → Secrets
|
||||||
|
2. 添加 `REGISTRY_USERNAME`,值为 `junhong_admin`
|
||||||
|
3. 添加 `REGISTRY_PASSWORD`,值为 `JunHong@2025!Registry`
|
||||||
|
4. 完成!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三步:首次手动部署
|
||||||
|
|
||||||
|
在自动化 CI/CD 生效前,先进行一次手动部署验证环境。
|
||||||
|
|
||||||
|
### 3.1 登录 Docker Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上执行
|
||||||
|
ssh qycard001@47.111.166.169 -p 52022
|
||||||
|
|
||||||
|
docker login registry.boss160.cn -u junhong_admin
|
||||||
|
# 输入密码:JunHong@2025!Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 手动构建镜像(可选)
|
||||||
|
|
||||||
|
如果 CI/CD 还未运行,可以手动构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <代码仓库目录>
|
||||||
|
|
||||||
|
# 构建 API 镜像
|
||||||
|
docker build -f Dockerfile.api -t registry.boss160.cn/junhong/cmp-fiber-api:latest .
|
||||||
|
|
||||||
|
# 构建 Worker 镜像
|
||||||
|
docker build -f Dockerfile.worker -t registry.boss160.cn/junhong/cmp-fiber-worker:latest .
|
||||||
|
|
||||||
|
# 推送镜像
|
||||||
|
docker push registry.boss160.cn/junhong/cmp-fiber-api:latest
|
||||||
|
docker push registry.boss160.cn/junhong/cmp-fiber-worker:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 拉取镜像
|
||||||
|
docker-compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 验证部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试 API 健康检查
|
||||||
|
curl http://localhost:8088/health
|
||||||
|
|
||||||
|
# 预期输出:
|
||||||
|
# {"code":0,"msg":"ok","data":{"status":"healthy"},"timestamp":1234567890}
|
||||||
|
|
||||||
|
# 查看容器状态
|
||||||
|
docker ps | grep junhong
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四步:自动化部署
|
||||||
|
|
||||||
|
配置完成后,每次 Push 代码到 `main` / `dev` / `test` 分支时,会自动触发构建。
|
||||||
|
|
||||||
|
### 工作流触发规则
|
||||||
|
|
||||||
|
| 分支 | 镜像标签 | 是否自动部署 |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `main` | `latest` | ✅ 是 |
|
||||||
|
| `dev` | `dev` | ❌ 否(仅构建镜像)|
|
||||||
|
| `test` | `test` | ❌ 否(仅构建镜像)|
|
||||||
|
|
||||||
|
### 触发自动部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在本地提交代码
|
||||||
|
git add .
|
||||||
|
git commit -m "测试自动部署"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监控部署进度
|
||||||
|
|
||||||
|
1. 在 Gitea 仓库页面点击 **Actions** 标签
|
||||||
|
2. 查看最新的工作流运行状态
|
||||||
|
3. 点击工作流查看详细日志
|
||||||
|
|
||||||
|
**预期日志输出**:
|
||||||
|
```
|
||||||
|
✅ 检出代码
|
||||||
|
✅ 设置镜像标签: latest
|
||||||
|
✅ 登录 Docker Registry
|
||||||
|
✅ 构建 API 镜像
|
||||||
|
✅ 构建 Worker 镜像
|
||||||
|
✅ 推送镜像到 Registry
|
||||||
|
✅ 部署到本地
|
||||||
|
- 拉取最新镜像...
|
||||||
|
- 执行滚动更新...
|
||||||
|
- 等待服务健康检查...
|
||||||
|
- 清理旧镜像(保留最近 3 个版本)...
|
||||||
|
- 部署完成!
|
||||||
|
✅ 构建结果通知
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运维操作
|
||||||
|
|
||||||
|
### 查看服务日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 查看 API 日志
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f api
|
||||||
|
|
||||||
|
# 查看 Worker 日志
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f worker
|
||||||
|
|
||||||
|
# 查看应用日志文件
|
||||||
|
tail -f logs/app.log | jq .
|
||||||
|
tail -f logs/access.log | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 重启所有服务
|
||||||
|
docker-compose -f docker-compose.prod.yml restart
|
||||||
|
|
||||||
|
# 重启单个服务
|
||||||
|
docker-compose -f docker-compose.prod.yml restart api
|
||||||
|
docker-compose -f docker-compose.prod.yml restart worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动执行数据库迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入 API 容器
|
||||||
|
docker exec -it junhong-cmp-api bash
|
||||||
|
|
||||||
|
# 查看当前迁移版本
|
||||||
|
migrate -path /app/migrations -database "$(cat /app/.env | grep DB_ | xargs)" version
|
||||||
|
|
||||||
|
# 执行迁移(通常容器启动时会自动执行)
|
||||||
|
migrate -path /app/migrations -database "$(cat /app/.env | grep DB_ | xargs)" up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回滚到旧版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/qycard001/app/junhong_cmp
|
||||||
|
|
||||||
|
# 查看可用镜像
|
||||||
|
docker images | grep cmp-fiber
|
||||||
|
|
||||||
|
# 示例输出:
|
||||||
|
# registry.boss160.cn/junhong/cmp-fiber-api latest abc123 2 hours ago 50MB
|
||||||
|
# registry.boss160.cn/junhong/cmp-fiber-api def456 def456 1 day ago 50MB
|
||||||
|
|
||||||
|
# 修改 docker-compose.prod.yml 中的镜像标签
|
||||||
|
# 将 :latest 改为具体的 commit SHA
|
||||||
|
sed -i 's/:latest/:def456/g' docker-compose.prod.yml
|
||||||
|
|
||||||
|
# 重新部署
|
||||||
|
docker-compose -f docker-compose.prod.yml pull
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理磁盘空间
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理未使用的镜像
|
||||||
|
docker image prune -a -f
|
||||||
|
|
||||||
|
# 清理未使用的容器
|
||||||
|
docker container prune -f
|
||||||
|
|
||||||
|
# 清理未使用的卷
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
# 一键清理所有(谨慎使用)
|
||||||
|
docker system prune -a -f --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 容器启动失败,显示 "unhealthy"
|
||||||
|
|
||||||
|
**排查步骤**:
|
||||||
|
1. 查看容器日志:`docker-compose -f docker-compose.prod.yml logs api`
|
||||||
|
2. 检查配置文件是否正确(数据库连接、Redis 连接)
|
||||||
|
3. 确认外部依赖(PostgreSQL、Redis)是否可访问
|
||||||
|
4. 手动测试健康检查:`curl http://localhost:8088/health`
|
||||||
|
|
||||||
|
### Q2: 数据库迁移失败
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 数据库连接信息错误
|
||||||
|
- 迁移文件损坏
|
||||||
|
- 数据库权限不足
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 检查 .env 文件配置
|
||||||
|
cat /home/qycard001/app/junhong_cmp/.env
|
||||||
|
|
||||||
|
# 手动测试数据库连接
|
||||||
|
docker run --rm postgres:14 psql "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" -c "SELECT 1;"
|
||||||
|
|
||||||
|
# 查看迁移日志
|
||||||
|
docker logs junhong-cmp-api | grep migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q3: CI/CD 工作流失败
|
||||||
|
|
||||||
|
**常见原因**:
|
||||||
|
1. **Secrets 未配置或错误**:检查 Gitea Secrets 是否配置了 `REGISTRY_USERNAME` 和 `REGISTRY_PASSWORD`
|
||||||
|
2. **Registry 认证失败**:验证用户名密码是否正确
|
||||||
|
3. **磁盘空间不足**:执行 `df -h` 检查磁盘空间
|
||||||
|
|
||||||
|
**排查方法**:
|
||||||
|
- 在 Gitea Actions 页面查看详细日志
|
||||||
|
- 在服务器上手动测试 Registry 登录:`docker login registry.boss160.cn -u junhong_admin`
|
||||||
|
|
||||||
|
### Q4: 镜像拉取慢或超时
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 检查 Registry 连接
|
||||||
|
curl -I https://registry.boss160.cn
|
||||||
|
|
||||||
|
# 检查网络
|
||||||
|
ping registry.boss160.cn
|
||||||
|
|
||||||
|
# 查看 Docker 日志
|
||||||
|
journalctl -u docker -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q5: Act Runner 权限问题
|
||||||
|
|
||||||
|
**问题现象**:工作流报错 `permission denied` 或 `cannot connect to Docker daemon`
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 确认 Act Runner 用户在 docker 组中
|
||||||
|
docker exec -it docker-runner-01 groups
|
||||||
|
|
||||||
|
# 如果缺少 docker 组,重启 Act Runner 容器
|
||||||
|
docker restart docker-runner-01
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **定期更新**:
|
||||||
|
- 定期更新 Docker 和 Docker Compose
|
||||||
|
- 及时应用系统安全补丁
|
||||||
|
|
||||||
|
2. **防火墙配置**:
|
||||||
|
```bash
|
||||||
|
# 仅开放必要端口
|
||||||
|
sudo ufw allow 52022/tcp # SSH
|
||||||
|
sudo ufw allow 8088/tcp # API(如果需要外部访问)
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **日志监控**:
|
||||||
|
- 配置日志轮转,防止磁盘占满
|
||||||
|
- 定期审查访问日志和错误日志
|
||||||
|
|
||||||
|
4. **备份策略**:
|
||||||
|
- 定期备份数据库
|
||||||
|
- 定期备份配置文件(`.env`、`config.yaml`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
部署完成后:
|
||||||
|
1. ✅ 配置域名和反向代理(Nginx / Caddy)
|
||||||
|
2. ✅ 启用 HTTPS(Let's Encrypt)
|
||||||
|
3. ✅ 配置监控和告警(Prometheus + Grafana)
|
||||||
|
4. ✅ 设置自动备份脚本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持
|
||||||
|
|
||||||
|
如有问题,请联系运维团队或提交 Issue。
|
||||||
114
docs/deployment/gitea-secrets-setup.md
Normal file
114
docs/deployment/gitea-secrets-setup.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Gitea Secrets 配置说明
|
||||||
|
|
||||||
|
本文档说明如何在 Gitea 中配置 CI/CD 工作流所需的 Secrets(密钥)。
|
||||||
|
|
||||||
|
## 重要说明
|
||||||
|
|
||||||
|
因为 Act Runner 运行在部署服务器本地,所以**不需要 SSH 连接**,配置非常简单!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 1. 进入仓库设置
|
||||||
|
|
||||||
|
1. 打开你的 Gitea 仓库页面
|
||||||
|
2. 点击右上角的 **设置(Settings)** 按钮
|
||||||
|
3. 在左侧菜单中找到 **Secrets** 或 **密钥** 选项
|
||||||
|
|
||||||
|
### 2. 添加以下 Secrets
|
||||||
|
|
||||||
|
点击 **添加 Secret** 按钮,逐个添加以下密钥:
|
||||||
|
|
||||||
|
#### Docker Registry 认证信息
|
||||||
|
|
||||||
|
| Secret 名称 | 值 | 说明 |
|
||||||
|
|------------|-----|------|
|
||||||
|
| `REGISTRY_USERNAME` | `junhong_admin` | Docker Registry 用户名 |
|
||||||
|
| `REGISTRY_PASSWORD` | `JunHong@2025!Registry` | Docker Registry 密码 |
|
||||||
|
|
||||||
|
**就这么简单!只需要 2 个 Secrets!** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置完成检查清单
|
||||||
|
|
||||||
|
- [ ] `REGISTRY_USERNAME` 已配置
|
||||||
|
- [ ] `REGISTRY_PASSWORD` 已配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证配置
|
||||||
|
|
||||||
|
### 测试 Registry 登录
|
||||||
|
|
||||||
|
在部署服务器上手动测试 Registry 连接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录测试
|
||||||
|
docker login registry.boss160.cn -u junhong_admin
|
||||||
|
# 输入密码:JunHong@2025!Registry
|
||||||
|
|
||||||
|
# 预期输出:Login Succeeded
|
||||||
|
```
|
||||||
|
|
||||||
|
如果登录成功,说明配置正确。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 工作流显示 "secret not found"
|
||||||
|
|
||||||
|
**原因**:Secret 名称拼写错误或未添加
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
1. 检查 Secret 名称是否完全一致(区分大小写)
|
||||||
|
2. 确认 2 个 Secrets 都已添加
|
||||||
|
|
||||||
|
### Q2: Registry 登录失败
|
||||||
|
|
||||||
|
**原因**:用户名或密码错误
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 在服务器上手动测试登录
|
||||||
|
docker login registry.boss160.cn -u junhong_admin
|
||||||
|
# 输入密码:JunHong@2025!Registry
|
||||||
|
|
||||||
|
# 如果失败,检查 Registry 认证配置
|
||||||
|
cat /home/qycard001/registry/auth/htpasswd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q3: 为什么不需要 SSH 密钥?
|
||||||
|
|
||||||
|
**回答**:因为你的 Act Runner (`docker-runner-01`) 运行在部署服务器本地,可以直接执行 `docker-compose` 命令,不需要通过 SSH 连接到其他机器。
|
||||||
|
|
||||||
|
**架构示意**:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ 部署服务器 (47.111.166.169) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────┐ ┌───────────────┐ │
|
||||||
|
│ │ Act Runner │──→│ Docker Daemon │ │
|
||||||
|
│ │(docker-runner) │ │ │ │
|
||||||
|
│ └────────────────┘ └───────────────┘ │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 执行工作流 部署容器 │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
1. **绝不**在代码中硬编码密钥信息
|
||||||
|
2. **定期更换** Registry 密码
|
||||||
|
3. **监控** Gitea Actions 日志,防止密钥泄露
|
||||||
|
4. 如果 Secrets 泄露,**立即**更换密码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
配置完成后,参考 [部署指南](./deployment-guide.md) 进行首次部署。
|
||||||
317
docs/openapi-enhancement-summary.md
Normal file
317
docs/openapi-enhancement-summary.md
Normal 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 可用性
|
||||||
|
- 减少手动维护文档的工作量
|
||||||
|
|
||||||
|
所有高优先级功能已完成并验证通过,可以投入使用。
|
||||||
311
docs/permission-check-usage.md
Normal file
311
docs/permission-check-usage.md
Normal 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()`
|
||||||
|
- 清除该角色下所有用户的缓存
|
||||||
|
|
||||||
|
#### 缓存性能提升
|
||||||
|
|
||||||
|
根据测试结果:
|
||||||
|
- **首次查询**: ~18ms(3次数据库查询)
|
||||||
|
- **缓存命中**: ~1.5ms(1次 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. **错误不影响安全**:查询失败时返回 false(fail-closed),不会误放行
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [设计文档](../openspec/changes/implement-permission-check/design.md)
|
||||||
|
- [提案文档](../openspec/changes/implement-permission-check/proposal.md)
|
||||||
|
- [权限模型说明](./004-rbac-data-permission/使用指南.md)
|
||||||
817
docs/shop-management/API文档.md
Normal file
817
docs/shop-management/API文档.md
Normal 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) - 项目整体开发规范
|
||||||
422
docs/shop-management/使用指南.md
Normal file
422
docs/shop-management/使用指南.md
Normal 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) - 完整错误码列表
|
||||||
6
go.mod
6
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/break/junhong_cmp_fiber
|
module github.com/break/junhong_cmp_fiber
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.14.2
|
github.com/bytedance/sonic v1.14.2
|
||||||
@@ -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
10
go.sum
@@ -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=
|
||||||
|
|||||||
60
internal/bootstrap/admin.go
Normal file
60
internal/bootstrap/admin.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 // 验证码服务
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 在此初始化
|
||||||
|
|||||||
@@ -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 在此添加字段
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
143
internal/handler/admin/auth.go
Normal file
143
internal/handler/admin/auth.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
80
internal/handler/admin/shop.go
Normal file
80
internal/handler/admin/shop.go
Normal 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)
|
||||||
|
}
|
||||||
103
internal/handler/admin/shop_account.go
Normal file
103
internal/handler/admin/shop_account.go
Normal 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
143
internal/handler/h5/auth.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
41
internal/model/auth_dto.go
Normal file
41
internal/model/auth_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
71
internal/model/card_replacement.go
Normal file
71
internal/model/card_replacement.go
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称(中国移动/中国联通/中国电信)" json:"carrier_name"`
|
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"`
|
||||||
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称(中国移动/中国联通/中国电信)" json:"carrier_name"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
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"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
ChannelName *string `gorm:"column:channel_name;type:varchar(100);comment:渠道名称" json:"channel_name,omitempty"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
|
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"`
|
||||||
|
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (Carrier) TableName() string {
|
func (Carrier) TableName() string {
|
||||||
return "carriers"
|
return "tb_carrier"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
Level int `gorm:"column:level;type:int;not null;comment:代理层级(1, 2, 3...)" json:"level"`
|
ParentAgentID uint `gorm:"column:parent_agent_id;index;comment:上级代理用户ID(NULL表示顶级代理)" json:"parent_agent_id"`
|
||||||
Path string `gorm:"column:path;type:varchar(500);comment:代理路径(如: 1/5/12)" json:"path"`
|
Level int `gorm:"column:level;type:int;not null;comment:代理层级(1, 2, 3...)" json:"level"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Path string `gorm:"column:path;type:varchar(500);comment:代理路径(如: 1/5/12)" json:"path"`
|
||||||
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"`
|
||||||
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
|
||||||
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
||||||
SeriesID uint `gorm:"column:series_id;type:bigint;comment:套餐系列ID(一次性分佣时用)" json:"series_id"`
|
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
||||||
PackageID uint `gorm:"column:package_id;type:bigint;comment:套餐ID(长期分佣时用)" json:"package_id"`
|
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID(一次性分佣时用)" json:"series_id"`
|
||||||
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
PackageID uint `gorm:"column:package_id;index;comment:套餐ID(长期分佣时用)" json:"package_id"`
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
||||||
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"`
|
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
||||||
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
||||||
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
||||||
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
|
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
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"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"`
|
||||||
LadderType string `gorm:"column:ladder_type;type:varchar(50);not null;comment:阶梯类型 activation-激活量 pickup-提货量 deposit-充值量" json:"ladder_type"`
|
RuleID uint `gorm:"column:rule_id;index;not null;comment:分佣规则ID" json:"rule_id"`
|
||||||
ThresholdValue int `gorm:"column:threshold_value;type:int;not null;comment:阈值" json:"threshold_value"`
|
LadderType string `gorm:"column:ladder_type;type:varchar(50);not null;comment:阶梯类型 activation-激活量 pickup-提货量 deposit-充值量" json:"ladder_type"`
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
ThresholdValue int `gorm:"column:threshold_value;type:int;not null;comment:阈值" json:"threshold_value"`
|
||||||
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"`
|
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
||||||
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"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待审批 2-已通过 3-已拒绝" json:"status"`
|
ApproverID uint `gorm:"column:approver_id;index;comment:审批人用户ID" json:"approver_id"`
|
||||||
Reason string `gorm:"column:reason;type:text;comment:原因" json:"reason"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待审批 2-已通过 3-已拒绝" json:"status"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Reason string `gorm:"column:reason;type:text;comment:原因" json:"reason"`
|
||||||
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"`
|
||||||
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
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"`
|
||||||
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
||||||
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
||||||
CommissionValue float64 `gorm:"column:commission_value;type:decimal(10,2);not null;comment:分佣值" json:"commission_value"`
|
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
||||||
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
||||||
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
||||||
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
|
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
|
||||||
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"`
|
||||||
SettlementMonth string `gorm:"column:settlement_month;type:varchar(20);not null;comment:结算月份(如 2026-01)" json:"settlement_month"`
|
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
|
||||||
SettlementAmount float64 `gorm:"column:settlement_amount;type:decimal(18,2);not null;comment:结算金额(元)" json:"settlement_amount"`
|
SettlementMonth string `gorm:"column:settlement_month;type:varchar(20);not null;comment:结算月份(如 2026-01)" json:"settlement_month"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待结算 2-已结算" json:"status"`
|
SettlementAmount int64 `gorm:"column:settlement_amount;type:bigint;not null;comment:结算金额(分为单位)" json:"settlement_amount"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待结算 2-已结算" json:"status"`
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,68 +3,66 @@ 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"`
|
||||||
ArrivalDays int `gorm:"column:arrival_days;type:int;comment:到账天数" json:"arrival_days"`
|
FeeRate int64 `gorm:"column:fee_rate;type:bigint;comment:手续费率(万分比,如100表示1%)" json:"fee_rate"`
|
||||||
IsActive bool `gorm:"column:is_active;type:boolean;default:true;comment:是否生效(最新一条)" json:"is_active"`
|
ArrivalDays int `gorm:"column:arrival_days;type:int;comment:到账天数" json:"arrival_days"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
IsActive bool `gorm:"column:is_active;type:boolean;default:true;comment:是否生效(最新一条)" json:"is_active"`
|
||||||
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"`
|
||||||
MerchantType string `gorm:"column:merchant_type;type:varchar(20);comment:商户类型 alipay-支付宝 wechat-微信 bank-银行卡" json:"merchant_type"`
|
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
|
||||||
AccountName string `gorm:"column:account_name;type:varchar(255);comment:账户名称" json:"account_name"`
|
MerchantType string `gorm:"column:merchant_type;type:varchar(20);comment:商户类型 alipay-支付宝 wechat-微信 bank-银行卡" json:"merchant_type"`
|
||||||
AccountNumber string `gorm:"column:account_number;type:varchar(255);comment:账号" json:"account_number"`
|
AccountName string `gorm:"column:account_name;type:varchar(255);comment:账户名称" json:"account_name"`
|
||||||
BankName string `gorm:"column:bank_name;type:varchar(255);comment:银行名称(仅银行卡)" json:"bank_name"`
|
AccountNumber string `gorm:"column:account_number;type:varchar(255);comment:账号" json:"account_number"`
|
||||||
BankBranch string `gorm:"column:bank_branch;type:varchar(255);comment:开户行(仅银行卡)" json:"bank_branch"`
|
BankName string `gorm:"column:bank_name;type:varchar(255);comment:银行名称(仅银行卡)" json:"bank_name"`
|
||||||
IsVerified bool `gorm:"column:is_verified;type:boolean;default:false;comment:是否已验证" json:"is_verified"`
|
BankBranch string `gorm:"column:bank_branch;type:varchar(255);comment:开户行(仅银行卡)" json:"bank_branch"`
|
||||||
IsDefault bool `gorm:"column:is_default;type:boolean;default:false;comment:是否默认账户" json:"is_default"`
|
IsVerified bool `gorm:"column:is_verified;type:boolean;default:false;comment:是否已验证" json:"is_verified"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
IsDefault bool `gorm:"column:is_default;type:boolean;default:false;comment:是否默认账户" json:"is_default"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
CardName string `gorm:"column:card_name;type:varchar(255);not null;comment:号卡名称" json:"card_name"`
|
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"`
|
||||||
CardType string `gorm:"column:card_type;type:varchar(50);comment:号卡类型" json:"card_type"`
|
CardName string `gorm:"column:card_name;type:varchar(255);not null;comment:号卡名称" json:"card_name"`
|
||||||
Carrier string `gorm:"column:carrier;type:varchar(50);comment:运营商" json:"carrier"`
|
CardType string `gorm:"column:card_type;type:varchar(50);comment:号卡类型" json:"card_type"`
|
||||||
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;comment:流量额度(MB)" json:"data_amount_mb"`
|
Carrier string `gorm:"column:carrier;type:varchar(50);comment:运营商" json:"carrier"`
|
||||||
Price float64 `gorm:"column:price;type:decimal(10,2);comment:价格(元)" json:"price"`
|
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;comment:流量额度(MB)" json:"data_amount_mb"`
|
||||||
AgentID uint `gorm:"column:agent_id;type:bigint;comment:代理用户ID" json:"agent_id"`
|
Price int64 `gorm:"column:price;type:bigint;comment:价格(分为单位)" json:"price"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在售 2-下架" json:"status"`
|
AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在售 2-下架" json:"status"`
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
|
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
|
||||||
IotCardID uint `gorm:"column:iot_card_id;type:bigint;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"`
|
OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
|
||||||
DeviceID uint `gorm:"column:device_id;type:bigint;comment:设备ID(设备级套餐订单时有值)" json:"device_id"`
|
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"`
|
||||||
NumberCardID uint `gorm:"column:number_card_id;type:bigint;comment:号卡ID(号卡订单时有值)" json:"number_card_id"`
|
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐订单时有值)" json:"device_id"`
|
||||||
PackageID uint `gorm:"column:package_id;type:bigint;comment:套餐ID(套餐订单时有值)" json:"package_id"`
|
NumberCardID uint `gorm:"column:number_card_id;index;comment:号卡ID(号卡订单时有值)" json:"number_card_id"`
|
||||||
UserID uint `gorm:"column:user_id;type:bigint;not null;comment:用户ID" json:"user_id"`
|
PackageID uint `gorm:"column:package_id;index;comment:套餐ID(套餐订单时有值)" json:"package_id"`
|
||||||
AgentID uint `gorm:"column:agent_id;type:bigint;comment:代理用户ID" json:"agent_id"`
|
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
|
||||||
Amount float64 `gorm:"column:amount;type:decimal(10,2);not null;comment:订单金额(元)" json:"amount"`
|
AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
|
||||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"`
|
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-已取消 5-已退款" json:"status"`
|
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"`
|
||||||
CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"`
|
WalletPaymentAmount int64 `gorm:"column:wallet_payment_amount;type:bigint;not null;default:0;comment:钱包支付金额(分)" json:"wallet_payment_amount"`
|
||||||
CarrierOrderData pq.StringArray `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"`
|
OnlinePaymentAmount int64 `gorm:"column:online_payment_amount;type:bigint;not null;default:0;comment:在线支付金额(分)" json:"online_payment_amount"`
|
||||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"`
|
||||||
|
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (Order) TableName() string {
|
func (Order) TableName() string {
|
||||||
return "orders"
|
return "tb_order"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"`
|
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"`
|
||||||
Description string `gorm:"column:description;type:text;comment:描述" json:"description"`
|
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
Description string `gorm:"column:description;type:text;comment:描述" json:"description"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"`
|
||||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
|
||||||
SeriesID uint `gorm:"column:series_id;type:bigint;comment:套餐系列ID" json:"series_id"`
|
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
||||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||||
DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"`
|
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"`
|
||||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||||
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
|
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||||
Price float64 `gorm:"column:price;type:decimal(10,2);not null;comment:套餐价格(元)" json:"price"`
|
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
Price int64 `gorm:"column:price;type:bigint;not null;comment:套餐价格(分为单位)" json:"price"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;comment:零售价(分为单位)" json:"retail_price"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ type Permission struct {
|
|||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
BaseModel `gorm:"embedded"`
|
||||||
|
|
||||||
PermName string `gorm:"column:perm_name;not null;size:50;comment:权限名称" json:"perm_name"`
|
PermName string `gorm:"column:perm_name;not null;size:50;comment:权限名称" json:"perm_name"`
|
||||||
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"`
|
||||||
URL string `gorm:"column:url;size:255;comment:URL路径" json:"url,omitempty"`
|
AvailableForRoleTypes string `gorm:"column:available_for_role_types;type:varchar(20);default:'1,2';not null;comment:可用角色类型 1=平台 2=客户" json:"available_for_role_types"`
|
||||||
ParentID *uint `gorm:"column:parent_id;index;comment:上级权限ID" json:"parent_id,omitempty"`
|
URL string `gorm:"column:url;size:255;comment:URL路径" json:"url,omitempty"`
|
||||||
Sort int `gorm:"column:sort;not null;default:0;comment:排序" json:"sort"`
|
ParentID *uint `gorm:"column:parent_id;index;comment:上级权限ID" json:"parent_id,omitempty"`
|
||||||
Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
Sort int `gorm:"column:sort;not null;default:0;comment:排序" json:"sort"`
|
||||||
|
Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -30,31 +30,33 @@ type UpdatePermissionParams struct {
|
|||||||
|
|
||||||
// PermissionListRequest 权限列表查询请求
|
// PermissionListRequest 权限列表查询请求
|
||||||
type PermissionListRequest struct {
|
type PermissionListRequest struct {
|
||||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
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:"每页数量"`
|
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
PermName string `json:"perm_name" query:"perm_name" validate:"omitempty,max=50" maxLength:"50" description:"权限名称模糊查询"`
|
PermName string `json:"perm_name" query:"perm_name" validate:"omitempty,max=50" maxLength:"50" description:"权限名称模糊查询"`
|
||||||
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:"适用端口"`
|
||||||
ParentID *uint `json:"parent_id" query:"parent_id" description:"父权限ID"`
|
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:客户角色)"`
|
||||||
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态"`
|
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:"状态"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionResponse 权限响应
|
// PermissionResponse 权限响应
|
||||||
type PermissionResponse struct {
|
type PermissionResponse struct {
|
||||||
ID uint `json:"id" description:"权限ID"`
|
ID uint `json:"id" description:"权限ID"`
|
||||||
PermName string `json:"perm_name" description:"权限名称"`
|
PermName string `json:"perm_name" description:"权限名称"`
|
||||||
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:"适用端口"`
|
||||||
URL string `json:"url,omitempty" description:"请求路径"`
|
AvailableForRoleTypes string `json:"available_for_role_types" description:"可用角色类型"`
|
||||||
ParentID *uint `json:"parent_id,omitempty" description:"父权限ID"`
|
URL string `json:"url,omitempty" description:"请求路径"`
|
||||||
Sort int `json:"sort" description:"排序值"`
|
ParentID *uint `json:"parent_id,omitempty" description:"父权限ID"`
|
||||||
Status int `json:"status" description:"状态"`
|
Sort int `json:"sort" description:"排序值"`
|
||||||
Creator uint `json:"creator" description:"创建人ID"`
|
Status int `json:"status" description:"状态"`
|
||||||
Updater uint `json:"updater" description:"更新人ID"`
|
Creator uint `json:"creator" description:"创建人ID"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
Updater uint `json:"updater" description:"更新人ID"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionPageResult 权限分页响应
|
// PermissionPageResult 权限分页响应
|
||||||
@@ -67,12 +69,13 @@ type PermissionPageResult struct {
|
|||||||
|
|
||||||
// PermissionTreeNode 权限树节点(用于层级展示)
|
// PermissionTreeNode 权限树节点(用于层级展示)
|
||||||
type PermissionTreeNode struct {
|
type PermissionTreeNode struct {
|
||||||
ID uint `json:"id" description:"权限ID"`
|
ID uint `json:"id" description:"权限ID"`
|
||||||
PermName string `json:"perm_name" description:"权限名称"`
|
PermName string `json:"perm_name" description:"权限名称"`
|
||||||
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:"适用端口"`
|
||||||
URL string `json:"url,omitempty" description:"请求路径"`
|
AvailableForRoleTypes string `json:"available_for_role_types" description:"可用角色类型"`
|
||||||
Sort int `json:"sort" description:"排序值"`
|
URL string `json:"url,omitempty" description:"请求路径"`
|
||||||
Children []*PermissionTreeNode `json:"children,omitempty" description:"子权限列表"`
|
Sort int `json:"sort" description:"排序值"`
|
||||||
|
Children []*PermissionTreeNode `json:"children,omitempty" description:"子权限列表"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
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"`
|
||||||
Description string `gorm:"column:description;type:varchar(500);comment:配置描述" json:"description"`
|
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"`
|
||||||
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件 not_real_name-未实名 real_name-已实名 activated-已激活 suspended-已停用" json:"card_condition"`
|
Description string `gorm:"column:description;type:varchar(500);comment:配置描述" json:"description"`
|
||||||
CarrierID uint `gorm:"column:carrier_id;type:bigint;comment:运营商ID(NULL表示所有运营商)" json:"carrier_id"`
|
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件 not_real_name-未实名 real_name-已实名 activated-已激活 suspended-已停用" json:"card_condition"`
|
||||||
RealNameCheckEnabled bool `gorm:"column:real_name_check_enabled;type:boolean;default:false;comment:是否启用实名检查" json:"real_name_check_enabled"`
|
CarrierID uint `gorm:"column:carrier_id;index;comment:运营商ID(NULL表示所有运营商)" json:"carrier_id"`
|
||||||
RealNameCheckInterval int `gorm:"column:real_name_check_interval;type:int;default:60;comment:实名检查间隔(秒)" json:"real_name_check_interval"`
|
RealNameCheckEnabled bool `gorm:"column:real_name_check_enabled;type:boolean;default:false;comment:是否启用实名检查" json:"real_name_check_enabled"`
|
||||||
CardDataCheckEnabled bool `gorm:"column:card_data_check_enabled;type:boolean;default:false;comment:是否启用卡流量检查" json:"card_data_check_enabled"`
|
RealNameCheckInterval int `gorm:"column:real_name_check_interval;type:int;default:60;comment:实名检查间隔(秒)" json:"real_name_check_interval"`
|
||||||
CardDataCheckInterval int `gorm:"column:card_data_check_interval;type:int;default:60;comment:卡流量检查间隔(秒)" json:"card_data_check_interval"`
|
CardDataCheckEnabled bool `gorm:"column:card_data_check_enabled;type:boolean;default:false;comment:是否启用卡流量检查" json:"card_data_check_enabled"`
|
||||||
PackageCheckEnabled bool `gorm:"column:package_check_enabled;type:boolean;default:false;comment:是否启用套餐流量检查" json:"package_check_enabled"`
|
CardDataCheckInterval int `gorm:"column:card_data_check_interval;type:int;default:60;comment:卡流量检查间隔(秒)" json:"card_data_check_interval"`
|
||||||
PackageCheckInterval int `gorm:"column:package_check_interval;type:int;default:60;comment:套餐流量检查间隔(秒)" json:"package_check_interval"`
|
PackageCheckEnabled bool `gorm:"column:package_check_enabled;type:boolean;default:false;comment:是否启用套餐流量检查" json:"package_check_enabled"`
|
||||||
Priority int `gorm:"column:priority;type:int;default:100;not null;comment:优先级(数字越小优先级越高)" json:"priority"`
|
PackageCheckInterval int `gorm:"column:package_check_interval;type:int;default:60;comment:套餐流量检查间隔(秒)" json:"package_check_interval"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
Priority int `gorm:"column:priority;type:int;default:100;not null;comment:优先级(数字越小优先级越高)" json:"priority"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
48
internal/model/shop_account_dto.go
Normal file
48
internal/model/shop_account_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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 店铺响应
|
||||||
|
|||||||
@@ -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"`
|
||||||
AppName string `gorm:"column:app_name;type:varchar(255);comment:应用名称" json:"app_name"`
|
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID(平台或代理)" json:"user_id"`
|
||||||
AppID string `gorm:"column:app_id;type:varchar(100);uniqueIndex;comment:应用ID" json:"app_id"`
|
AppName string `gorm:"column:app_name;type:varchar(255);comment:应用名称" json:"app_name"`
|
||||||
AppSecret string `gorm:"column:app_secret;type:varchar(255);comment:应用密钥" json:"app_secret"`
|
AppID string `gorm:"column:app_id;type:varchar(100);uniqueIndex:idx_dev_capability_app,where:deleted_at IS NULL;comment:应用ID" json:"app_id"`
|
||||||
CallbackURL string `gorm:"column:callback_url;type:varchar(500);comment:回调地址" json:"callback_url"`
|
AppSecret string `gorm:"column:app_secret;type:varchar(255);comment:应用密钥" json:"app_secret"`
|
||||||
IPWhitelist string `gorm:"column:ip_whitelist;type:text;comment:IP白名单(多个IP用逗号分隔)" json:"ip_whitelist"`
|
CallbackURL string `gorm:"column:callback_url;type:varchar(500);comment:回调地址" json:"callback_url"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
IPWhitelist string `gorm:"column:ip_whitelist;type:text;comment:IP白名单(多个IP用逗号分隔)" json:"ip_whitelist"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
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
41
internal/model/tag.go
Normal 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:归属企业ID(NULL表示非企业标签)" json:"enterprise_id,omitempty"`
|
||||||
|
ShopID *uint `gorm:"column:shop_id;index:idx_tag_shop;uniqueIndex:idx_tag_shop_name,priority:1;comment:归属店铺ID(NULL表示非店铺标签)" 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
99
internal/model/wallet.go
Normal 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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
if handlers.Shop != nil {
|
||||||
|
registerShopRoutes(authGroup, handlers.Shop, doc, basePath)
|
||||||
|
}
|
||||||
|
if handlers.ShopAccount != nil {
|
||||||
|
registerShopAccountRoutes(authGroup, handlers.ShopAccount, doc, basePath)
|
||||||
}
|
}
|
||||||
// TODO: Task routes?
|
}
|
||||||
|
|
||||||
|
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
68
internal/routes/h5.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
23
internal/routes/shop.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
260
internal/service/auth/service.go
Normal file
260
internal/service/auth/service.go
Normal 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 "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,13 +24,24 @@ 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,35 +230,32 @@ 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{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
PermName: p.PermName,
|
PermName: p.PermName,
|
||||||
PermCode: p.PermCode,
|
PermCode: p.PermCode,
|
||||||
PermType: p.PermType,
|
PermType: p.PermType,
|
||||||
Platform: p.Platform,
|
Platform: p.Platform,
|
||||||
URL: p.URL,
|
AvailableForRoleTypes: p.AvailableForRoleTypes,
|
||||||
Sort: p.Sort,
|
URL: p.URL,
|
||||||
Children: make([]*model.PermissionTreeNode, 0),
|
Sort: p.Sort,
|
||||||
|
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(推荐)
|
|
||||||
// 方案2: 在 PermissionStore 中添加一个查询方法
|
|
||||||
// 方案3: 使用缓存层(Redis)来存储用户权限映射
|
|
||||||
|
|
||||||
// 这里先返回一个占位实现
|
cachedData, err := s.redisClient.Get(ctx, cacheKey).Result()
|
||||||
// TODO: 实现完整的权限检查逻辑
|
if err == nil && cachedData != "" {
|
||||||
// 需要在构造函数中注入 AccountRoleStore 和 RolePermissionStore
|
var permissions []permissionCacheItem
|
||||||
return false, errors.New(errors.CodeInternalError, "权限检查功能尚未完全实现")
|
if err := json.Unmarshal([]byte(cachedData), &permissions); err == nil {
|
||||||
|
return s.matchPermission(permissions, permCode, platform), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if err != nil {
|
||||||
_, err := s.permissionStore.GetByID(ctx, permID)
|
return nil, fmt.Errorf("获取权限失败: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, errors.New(errors.CodePermissionNotFound, fmt.Sprintf("权限 %d 不存在", permID))
|
if len(permissions) != len(permIDs) {
|
||||||
}
|
return nil, errors.New(errors.CodePermissionNotFound, "部分权限不存在")
|
||||||
return nil, fmt.Errorf("获取权限失败: %w", err)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "未授权访问")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查店铺编号唯一性
|
existing, err := s.shopStore.GetByCode(ctx, req.ShopCode)
|
||||||
if req.ShopCode != "" {
|
if err == nil && existing != nil {
|
||||||
existing, err := s.shopStore.GetByCode(ctx, req.ShopCode)
|
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
|
||||||
if err == nil && existing != nil {
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &model.Account{
|
||||||
|
Username: req.InitUsername,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新店铺信息
|
func (s *Service) Update(ctx context.Context, id uint, req *model.UpdateShopRequest) (*model.ShopResponse, error) {
|
||||||
func (s *Service) Update(ctx context.Context, id uint, req *model.UpdateShopRequest) (*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, "未授权访问")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询店铺
|
|
||||||
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)
|
||||||
|
|||||||
265
internal/service/shop_account/service.go
Normal file
265
internal/service/shop_account/service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -12,36 +14,65 @@ 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 获取账号的所有角色关联
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 获取角色的所有权限关联
|
||||||
|
|||||||
251
migrations/000006_refactor_iot_models_architecture.down.sql
Normal file
251
migrations/000006_refactor_iot_models_architecture.down.sql
Normal 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 '换卡申请表';
|
||||||
349
migrations/000006_refactor_iot_models_architecture.up.sql
Normal file
349
migrations/000006_refactor_iot_models_architecture.up.sql
Normal 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 '换卡申请表';
|
||||||
50
migrations/000007_add_wallet_transfer_tag_tables.down.sql
Normal file
50
migrations/000007_add_wallet_transfer_tag_tables.down.sql
Normal 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;
|
||||||
285
migrations/000007_add_wallet_transfer_tag_tables.up.sql
Normal file
285
migrations/000007_add_wallet_transfer_tag_tables.up.sql
Normal 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';
|
||||||
83
migrations/000008_fix_wallet_tag_multi_tenant.down.sql
Normal file
83
migrations/000008_fix_wallet_tag_multi_tenant.down.sql
Normal 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;
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 回滚完成
|
||||||
|
-- ========================================
|
||||||
220
migrations/000008_fix_wallet_tag_multi_tenant.up.sql
Normal file
220
migrations/000008_fix_wallet_tag_multi_tenant.up.sql
Normal 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 $$;
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 迁移完成
|
||||||
|
-- ========================================
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 删除权限可用角色类型字段
|
||||||
|
ALTER TABLE tb_permission
|
||||||
|
DROP COLUMN IF EXISTS available_for_role_types;
|
||||||
@@ -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
26
opencode.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-12
|
||||||
@@ -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 数据库迁移策略
|
||||||
|
|
||||||
|
**场景 1:IoT 模块尚未部署(推荐)**
|
||||||
|
- 删除旧的迁移脚本(如果已创建)
|
||||||
|
- 生成新的初始迁移脚本
|
||||||
|
- 重新运行迁移
|
||||||
|
|
||||||
|
**场景 2:IoT 模块已有测试数据**
|
||||||
|
- 保留旧的迁移脚本
|
||||||
|
- 生成新的迁移脚本(包含表重命名、字段修改)
|
||||||
|
- 编写数据转换脚本(金额单位转换等)
|
||||||
|
|
||||||
|
### 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 文档
|
||||||
@@ -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 会自动处理)
|
||||||
@@ -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`
|
||||||
|
- 简化模型结构减少存储开销和查询复杂度
|
||||||
@@ -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,确保符合项目规范
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user