feat: 实现套餐管理模块,包含套餐系列、双状态管理、废弃模型清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s
- 新增套餐系列管理 (CRUD + 状态切换) - 新增套餐管理 (CRUD + 启用/禁用 + 上架/下架双状态) - 清理 8 个废弃分佣模型及对应数据库表 - Package 模型新增建议成本价、建议售价、上架状态字段 - 完整的 Store/Service/Handler 三层实现 - 包含单元测试和集成测试 - 归档 add-package-module change - 新增多个 OpenSpec changes (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定)
This commit is contained in:
@@ -165,6 +165,15 @@ handler.HandleIotCardImport(ctx, asynqTask) // 测试完整流程,验证真
|
|||||||
|
|
||||||
**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md)
|
**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md)
|
||||||
|
|
||||||
|
**⚠️ 运行测试必须先加载环境变量**:
|
||||||
|
```bash
|
||||||
|
# ✅ 正确
|
||||||
|
source .env.local && go test -v ./internal/service/xxx/...
|
||||||
|
|
||||||
|
# ❌ 错误(会因缺少配置而失败)
|
||||||
|
go test -v ./internal/service/xxx/...
|
||||||
|
```
|
||||||
|
|
||||||
**标准模板**:
|
**标准模板**:
|
||||||
```go
|
```go
|
||||||
func TestXxx(t *testing.T) {
|
func TestXxx(t *testing.T) {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
||||||
Storage: admin.NewStorageHandler(nil),
|
Storage: admin.NewStorageHandler(nil),
|
||||||
Carrier: admin.NewCarrierHandler(nil),
|
Carrier: admin.NewCarrierHandler(nil),
|
||||||
|
PackageSeries: admin.NewPackageSeriesHandler(nil),
|
||||||
|
Package: admin.NewPackageHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
|
||||||
Storage: admin.NewStorageHandler(nil),
|
Storage: admin.NewStorageHandler(nil),
|
||||||
Carrier: admin.NewCarrierHandler(nil),
|
Carrier: admin.NewCarrierHandler(nil),
|
||||||
|
PackageSeries: admin.NewPackageSeriesHandler(nil),
|
||||||
|
Package: admin.NewPackageHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -821,6 +821,91 @@ components:
|
|||||||
description: 提现单号
|
description: 提现单号
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
DtoCreatePackageRequest:
|
||||||
|
properties:
|
||||||
|
data_amount_mb:
|
||||||
|
description: 总流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
data_type:
|
||||||
|
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
duration_months:
|
||||||
|
description: 套餐时长(月数)
|
||||||
|
maximum: 120
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
package_code:
|
||||||
|
description: 套餐编码
|
||||||
|
maxLength: 100
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
package_name:
|
||||||
|
description: 套餐名称
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
package_type:
|
||||||
|
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
description: 套餐价格(分)
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
real_data_mb:
|
||||||
|
description: 真流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
series_id:
|
||||||
|
description: 套餐系列ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
suggested_cost_price:
|
||||||
|
description: 建议成本价(分)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
suggested_retail_price:
|
||||||
|
description: 建议售价(分)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
virtual_data_mb:
|
||||||
|
description: 虚流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- package_code
|
||||||
|
- package_name
|
||||||
|
- package_type
|
||||||
|
- duration_months
|
||||||
|
- price
|
||||||
|
type: object
|
||||||
|
DtoCreatePackageSeriesRequest:
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
description: 描述
|
||||||
|
maxLength: 500
|
||||||
|
type: string
|
||||||
|
series_code:
|
||||||
|
description: 系列编码
|
||||||
|
maxLength: 100
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
series_name:
|
||||||
|
description: 系列名称
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- series_code
|
||||||
|
- series_name
|
||||||
|
type: object
|
||||||
DtoCreatePermissionRequest:
|
DtoCreatePermissionRequest:
|
||||||
properties:
|
properties:
|
||||||
parent_id:
|
parent_id:
|
||||||
@@ -1963,6 +2048,130 @@ components:
|
|||||||
description: 已提现佣金(分)
|
description: 已提现佣金(分)
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
DtoPackagePageResult:
|
||||||
|
properties:
|
||||||
|
list:
|
||||||
|
description: 套餐列表
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DtoPackageResponse'
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
page:
|
||||||
|
description: 当前页
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
description: 每页数量
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
description: 总数
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
description: 总页数
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoPackageResponse:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
description: 创建时间
|
||||||
|
type: string
|
||||||
|
data_amount_mb:
|
||||||
|
description: 总流量额度(MB)
|
||||||
|
type: integer
|
||||||
|
data_type:
|
||||||
|
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||||
|
type: string
|
||||||
|
duration_months:
|
||||||
|
description: 套餐时长(月数)
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
description: 套餐ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
package_code:
|
||||||
|
description: 套餐编码
|
||||||
|
type: string
|
||||||
|
package_name:
|
||||||
|
description: 套餐名称
|
||||||
|
type: string
|
||||||
|
package_type:
|
||||||
|
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
description: 套餐价格(分)
|
||||||
|
type: integer
|
||||||
|
real_data_mb:
|
||||||
|
description: 真流量额度(MB)
|
||||||
|
type: integer
|
||||||
|
series_id:
|
||||||
|
description: 套餐系列ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
shelf_status:
|
||||||
|
description: 上架状态 (1:上架, 2:下架)
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
type: integer
|
||||||
|
suggested_cost_price:
|
||||||
|
description: 建议成本价(分)
|
||||||
|
type: integer
|
||||||
|
suggested_retail_price:
|
||||||
|
description: 建议售价(分)
|
||||||
|
type: integer
|
||||||
|
updated_at:
|
||||||
|
description: 更新时间
|
||||||
|
type: string
|
||||||
|
virtual_data_mb:
|
||||||
|
description: 虚流量额度(MB)
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoPackageSeriesPageResult:
|
||||||
|
properties:
|
||||||
|
list:
|
||||||
|
description: 套餐系列列表
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DtoPackageSeriesResponse'
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
page:
|
||||||
|
description: 当前页
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
description: 每页数量
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
description: 总数
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
description: 总页数
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoPackageSeriesResponse:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
description: 创建时间
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: 描述
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
description: 系列ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
series_code:
|
||||||
|
description: 系列编码
|
||||||
|
type: string
|
||||||
|
series_name:
|
||||||
|
description: 系列名称
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
type: integer
|
||||||
|
updated_at:
|
||||||
|
description: 更新时间
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
DtoPermissionPageResult:
|
DtoPermissionPageResult:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -2873,6 +3082,102 @@ components:
|
|||||||
required:
|
required:
|
||||||
- status
|
- status
|
||||||
type: object
|
type: object
|
||||||
|
DtoUpdatePackageParams:
|
||||||
|
properties:
|
||||||
|
data_amount_mb:
|
||||||
|
description: 总流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
data_type:
|
||||||
|
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
duration_months:
|
||||||
|
description: 套餐时长(月数)
|
||||||
|
maximum: 120
|
||||||
|
minimum: 1
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
package_name:
|
||||||
|
description: 套餐名称
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
package_type:
|
||||||
|
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
price:
|
||||||
|
description: 套餐价格(分)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
real_data_mb:
|
||||||
|
description: 真流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
series_id:
|
||||||
|
description: 套餐系列ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
suggested_cost_price:
|
||||||
|
description: 建议成本价(分)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
suggested_retail_price:
|
||||||
|
description: 建议售价(分)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
virtual_data_mb:
|
||||||
|
description: 虚流量额度(MB)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoUpdatePackageSeriesParams:
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
description: 描述
|
||||||
|
maxLength: 500
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
series_name:
|
||||||
|
description: 系列名称
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
DtoUpdatePackageSeriesStatusParams:
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
type: object
|
||||||
|
DtoUpdatePackageShelfStatusParams:
|
||||||
|
properties:
|
||||||
|
shelf_status:
|
||||||
|
description: 上架状态 (1:上架, 2:下架)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- shelf_status
|
||||||
|
type: object
|
||||||
|
DtoUpdatePackageStatusParams:
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
type: object
|
||||||
DtoUpdatePasswordParams:
|
DtoUpdatePasswordParams:
|
||||||
properties:
|
properties:
|
||||||
new_password:
|
new_password:
|
||||||
@@ -7062,6 +7367,664 @@ paths:
|
|||||||
summary: 发起提现申请
|
summary: 发起提现申请
|
||||||
tags:
|
tags:
|
||||||
- 我的佣金
|
- 我的佣金
|
||||||
|
/api/admin/package-series:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 页码
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
description: 页码
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
schema:
|
||||||
|
description: 每页数量
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 系列名称(模糊搜索)
|
||||||
|
in: query
|
||||||
|
name: series_name
|
||||||
|
schema:
|
||||||
|
description: 系列名称(模糊搜索)
|
||||||
|
maxLength: 255
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
- description: 状态 (1:启用, 2:禁用)
|
||||||
|
in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageSeriesPageResult'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 套餐系列列表
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
post:
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoCreatePackageSeriesRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageSeriesResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 创建套餐系列
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
/api/admin/package-series/{id}:
|
||||||
|
delete:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 删除套餐系列
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageSeriesResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取套餐系列详情
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
put:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoUpdatePackageSeriesParams'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageSeriesResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新套餐系列
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
/api/admin/package-series/{id}/status:
|
||||||
|
patch:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoUpdatePackageSeriesStatusParams'
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新套餐系列状态
|
||||||
|
tags:
|
||||||
|
- 套餐系列管理
|
||||||
|
/api/admin/packages:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 页码
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
description: 页码
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
schema:
|
||||||
|
description: 每页数量
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 套餐名称(模糊搜索)
|
||||||
|
in: query
|
||||||
|
name: package_name
|
||||||
|
schema:
|
||||||
|
description: 套餐名称(模糊搜索)
|
||||||
|
maxLength: 255
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
- description: 套餐系列ID
|
||||||
|
in: query
|
||||||
|
name: series_id
|
||||||
|
schema:
|
||||||
|
description: 套餐系列ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
- description: 状态 (1:启用, 2:禁用)
|
||||||
|
in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
description: 状态 (1:启用, 2:禁用)
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
- description: 上架状态 (1:上架, 2:下架)
|
||||||
|
in: query
|
||||||
|
name: shelf_status
|
||||||
|
schema:
|
||||||
|
description: 上架状态 (1:上架, 2:下架)
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
- description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||||
|
in: query
|
||||||
|
name: package_type
|
||||||
|
schema:
|
||||||
|
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackagePageResult'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 套餐列表
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
post:
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoCreatePackageRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 创建套餐
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
/api/admin/packages/{id}:
|
||||||
|
delete:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 删除套餐
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取套餐详情
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
put:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoUpdatePackageParams'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoPackageResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新套餐
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
/api/admin/packages/{id}/shelf:
|
||||||
|
patch:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoUpdatePackageShelfStatusParams'
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新套餐上架状态
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
|
/api/admin/packages/{id}/status:
|
||||||
|
patch:
|
||||||
|
parameters:
|
||||||
|
- description: ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoUpdatePackageStatusParams'
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 未认证或认证已过期
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 无权访问
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新套餐状态
|
||||||
|
tags:
|
||||||
|
- 套餐管理
|
||||||
/api/admin/password:
|
/api/admin/password:
|
||||||
put:
|
put:
|
||||||
requestBody:
|
requestBody:
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
本文档是测试连接管理的**唯一标准**,所有新测试必须遵循此规范。
|
本文档是测试连接管理的**唯一标准**,所有新测试必须遵循此规范。
|
||||||
|
|
||||||
|
## ⚠️ 运行测试前必须加载环境变量
|
||||||
|
|
||||||
|
**所有测试命令必须先加载 `.env.local` 环境变量**,否则测试将因缺少数据库/Redis 配置而失败。
|
||||||
|
|
||||||
|
### 命令格式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ✅ 正确:先 source 环境变量
|
||||||
|
source .env.local && go test -v ./internal/service/xxx/...
|
||||||
|
|
||||||
|
# ✅ 正确:运行所有测试
|
||||||
|
source .env.local && go test ./...
|
||||||
|
|
||||||
|
# ❌ 错误:直接运行测试(会因缺少配置而失败)
|
||||||
|
go test -v ./internal/service/xxx/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量文件
|
||||||
|
|
||||||
|
- **`.env.local`**: 本地开发/测试环境配置(不提交到 Git)
|
||||||
|
- 包含数据库连接、Redis 地址、JWT 密钥等必要配置
|
||||||
|
- 如果文件不存在,从 `.env.example` 复制并填写实际值
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
如果看到以下错误,说明未加载环境变量:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- SKIP: TestXxx (0.00s)
|
||||||
|
test_helpers.go:xx: 跳过测试:无法连接测试数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```
|
||||||
|
panic: 配置加载失败: 缺少必要的数据库配置
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:确保运行 `source .env.local` 后再执行测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|||||||
@@ -34,5 +34,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||||
Storage: admin.NewStorageHandler(deps.StorageService),
|
Storage: admin.NewStorageHandler(deps.StorageService),
|
||||||
Carrier: admin.NewCarrierHandler(svc.Carrier),
|
Carrier: admin.NewCarrierHandler(svc.Carrier),
|
||||||
|
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||||
|
Package: admin.NewPackageHandler(svc.Package),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||||
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
||||||
|
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
|
packageSeriesSvc "github.com/break/junhong_cmp_fiber/internal/service/package_series"
|
||||||
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"
|
||||||
@@ -45,6 +47,8 @@ type services struct {
|
|||||||
DeviceImport *deviceImportSvc.Service
|
DeviceImport *deviceImportSvc.Service
|
||||||
AssetAllocationRecord *assetAllocationRecordSvc.Service
|
AssetAllocationRecord *assetAllocationRecordSvc.Service
|
||||||
Carrier *carrierSvc.Service
|
Carrier *carrierSvc.Service
|
||||||
|
PackageSeries *packageSeriesSvc.Service
|
||||||
|
Package *packageSvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServices(s *stores, deps *Dependencies) *services {
|
func initServices(s *stores, deps *Dependencies) *services {
|
||||||
@@ -70,5 +74,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||||
Carrier: carrierSvc.New(s.Carrier),
|
Carrier: carrierSvc.New(s.Carrier),
|
||||||
|
PackageSeries: packageSeriesSvc.New(s.PackageSeries),
|
||||||
|
Package: packageSvc.New(s.Package, s.PackageSeries),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type stores struct {
|
|||||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||||
Carrier *postgres.CarrierStore
|
Carrier *postgres.CarrierStore
|
||||||
|
PackageSeries *postgres.PackageSeriesStore
|
||||||
|
Package *postgres.PackageStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStores(deps *Dependencies) *stores {
|
func initStores(deps *Dependencies) *stores {
|
||||||
@@ -53,5 +55,7 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||||
Carrier: postgres.NewCarrierStore(deps.DB),
|
Carrier: postgres.NewCarrierStore(deps.DB),
|
||||||
|
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||||
|
Package: postgres.NewPackageStore(deps.DB),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type Handlers struct {
|
|||||||
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
||||||
Storage *admin.StorageHandler
|
Storage *admin.StorageHandler
|
||||||
Carrier *admin.CarrierHandler
|
Carrier *admin.CarrierHandler
|
||||||
|
PackageSeries *admin.PackageSeriesHandler
|
||||||
|
Package *admin.PackageHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
|
|||||||
130
internal/handler/admin/package.go
Normal file
130
internal/handler/admin/package.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageHandler struct {
|
||||||
|
service *packageService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageHandler(service *packageService.Service) *PackageHandler {
|
||||||
|
return &PackageHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.PackageListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, packages, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreatePackageRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的套餐 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) 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 dto.UpdatePackageRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) 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 dto.UpdatePackageStatusRequest
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的套餐 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdatePackageShelfStatusRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.UpdateShelfStatus(c.UserContext(), uint(id), req.ShelfStatus); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
112
internal/handler/admin/package_series.go
Normal file
112
internal/handler/admin/package_series.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
packageSeriesService "github.com/break/junhong_cmp_fiber/internal/service/package_series"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageSeriesHandler struct {
|
||||||
|
service *packageSeriesService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageSeriesHandler(service *packageSeriesService.Service) *PackageSeriesHandler {
|
||||||
|
return &PackageSeriesHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.PackageSeriesListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, seriesList, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreatePackageSeriesRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, series)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的套餐系列 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, series)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) 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 dto.UpdatePackageSeriesRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, series)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PackageSeriesHandler) 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 dto.UpdatePackageSeriesStatusRequest
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -6,85 +6,6 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentHierarchy 代理层级关系模型
|
|
||||||
// 树形代理关系(每个代理只有一个上级)
|
|
||||||
type AgentHierarchy struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
AgentID uint `gorm:"column:agent_id;uniqueIndex:idx_agent_hierarchy_agent,where:deleted_at IS NULL;not null;comment:代理用户ID" json:"agent_id"`
|
|
||||||
ParentAgentID uint `gorm:"column:parent_agent_id;index;comment:上级代理用户ID(NULL表示顶级代理)" json:"parent_agent_id"`
|
|
||||||
Level int `gorm:"column:level;type:int;not null;comment:代理层级(1, 2, 3...)" json:"level"`
|
|
||||||
Path string `gorm:"column:path;type:varchar(500);comment:代理路径(如: 1/5/12)" json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (AgentHierarchy) TableName() string {
|
|
||||||
return "tb_agent_hierarchy"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommissionRule 分佣规则模型
|
|
||||||
// 三种分佣类型:一次性/长期/组合
|
|
||||||
type CommissionRule struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
|
|
||||||
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
|
||||||
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
|
||||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID(一次性分佣时用)" json:"series_id"`
|
|
||||||
PackageID uint `gorm:"column:package_id;index;comment:套餐ID(长期分佣时用)" json:"package_id"`
|
|
||||||
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
|
||||||
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
|
||||||
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
|
||||||
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
|
||||||
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
|
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CommissionRule) TableName() string {
|
|
||||||
return "tb_commission_rule"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommissionLadder 阶梯分佣配置模型
|
|
||||||
// 支持按激活量、提货量、充值量设置阶梯佣金
|
|
||||||
type CommissionLadder struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
RuleID uint `gorm:"column:rule_id;index;not null;comment:分佣规则ID" json:"rule_id"`
|
|
||||||
LadderType string `gorm:"column:ladder_type;type:varchar(50);not null;comment:阶梯类型 activation-激活量 pickup-提货量 deposit-充值量" json:"ladder_type"`
|
|
||||||
ThresholdValue int `gorm:"column:threshold_value;type:int;not null;comment:阈值" json:"threshold_value"`
|
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
|
||||||
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CommissionLadder) TableName() string {
|
|
||||||
return "tb_commission_ladder"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommissionCombinedCondition 组合分佣条件模型
|
|
||||||
// 支持时间点 OR 套餐周期阈值的 OR 条件解冻
|
|
||||||
type CommissionCombinedCondition struct {
|
|
||||||
gorm.Model
|
|
||||||
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"`
|
|
||||||
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"`
|
|
||||||
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"`
|
|
||||||
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"`
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CommissionCombinedCondition) TableName() string {
|
|
||||||
return "tb_commission_combined_condition"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommissionRecord 分佣记录模型
|
// CommissionRecord 分佣记录模型
|
||||||
// 记录分佣的冻结、解冻、发放状态
|
// 记录分佣的冻结、解冻、发放状态
|
||||||
type CommissionRecord struct {
|
type CommissionRecord struct {
|
||||||
@@ -106,57 +27,3 @@ type CommissionRecord struct {
|
|||||||
func (CommissionRecord) TableName() string {
|
func (CommissionRecord) TableName() string {
|
||||||
return "tb_commission_record"
|
return "tb_commission_record"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommissionApproval 分佣审批模型
|
|
||||||
// 分佣解冻审批流程
|
|
||||||
type CommissionApproval struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
CommissionRecordID uint `gorm:"column:commission_record_id;index;not null;comment:分佣记录ID" json:"commission_record_id"`
|
|
||||||
ApproverID uint `gorm:"column:approver_id;index;comment:审批人用户ID" json:"approver_id"`
|
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待审批 2-已通过 3-已拒绝" json:"status"`
|
|
||||||
Reason string `gorm:"column:reason;type:text;comment:原因" json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CommissionApproval) TableName() string {
|
|
||||||
return "tb_commission_approval"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommissionTemplate 分佣模板模型
|
|
||||||
// 创建和管理分佣模板,快速为代理分配产品时设置佣金规则
|
|
||||||
type CommissionTemplate struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
TemplateName string `gorm:"column:template_name;type:varchar(255);uniqueIndex:idx_commission_template_name,where:deleted_at IS NULL;not null;comment:模板名称" json:"template_name"`
|
|
||||||
BusinessType string `gorm:"column:business_type;type:varchar(50);not null;comment:业务类型" json:"business_type"`
|
|
||||||
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型 number_card-号卡 iot_card-IoT卡" json:"card_type"`
|
|
||||||
CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期 combined-组合" json:"commission_type"`
|
|
||||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;comment:分佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
|
||||||
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:分佣值(分为单位,百分比时为千分比如2000表示20%)" json:"commission_value"`
|
|
||||||
UnfreezeDays int `gorm:"column:unfreeze_days;type:int;default:0;comment:解冻天数" json:"unfreeze_days"`
|
|
||||||
MinActivationForUnfreeze int `gorm:"column:min_activation_for_unfreeze;type:int;default:0;comment:解冻最小激活量" json:"min_activation_for_unfreeze"`
|
|
||||||
ApprovalType string `gorm:"column:approval_type;type:varchar(20);default:'auto';comment:审批类型 auto-自动 manual-人工" json:"approval_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CommissionTemplate) TableName() string {
|
|
||||||
return "tb_commission_template"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CarrierSettlement 号卡运营商结算模型
|
|
||||||
// 运营商周期性结算的佣金总额,再分配给代理
|
|
||||||
type CarrierSettlement struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
CommissionRecordID uint `gorm:"column:commission_record_id;uniqueIndex:idx_carrier_settlement_record,where:deleted_at IS NULL;not null;comment:分佣记录ID" json:"commission_record_id"`
|
|
||||||
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
|
|
||||||
SettlementMonth string `gorm:"column:settlement_month;type:varchar(20);not null;comment:结算月份(如 2026-01)" json:"settlement_month"`
|
|
||||||
SettlementAmount int64 `gorm:"column:settlement_amount;type:bigint;not null;comment:结算金额(分为单位)" json:"settlement_amount"`
|
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待结算 2-已结算" json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (CarrierSettlement) TableName() string {
|
|
||||||
return "tb_carrier_settlement"
|
|
||||||
}
|
|
||||||
|
|||||||
101
internal/model/dto/package_dto.go
Normal file
101
internal/model/dto/package_dto.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreatePackageRequest 创建套餐请求
|
||||||
|
type CreatePackageRequest struct {
|
||||||
|
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"`
|
||||||
|
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||||
|
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||||
|
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||||
|
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||||
|
DataType *string `json:"data_type" validate:"omitempty,oneof=real virtual" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||||
|
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||||
|
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||||
|
DataAmountMB *int64 `json:"data_amount_mb" validate:"omitempty,min=0" minimum:"0" description:"总流量额度(MB)"`
|
||||||
|
Price int64 `json:"price" validate:"required,min=0" required:"true" minimum:"0" description:"套餐价格(分)"`
|
||||||
|
SuggestedCostPrice *int64 `json:"suggested_cost_price" validate:"omitempty,min=0" minimum:"0" description:"建议成本价(分)"`
|
||||||
|
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageRequest 更新套餐请求
|
||||||
|
type UpdatePackageRequest struct {
|
||||||
|
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||||
|
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||||
|
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||||
|
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||||
|
DataType *string `json:"data_type" validate:"omitempty,oneof=real virtual" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||||
|
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||||
|
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||||
|
DataAmountMB *int64 `json:"data_amount_mb" validate:"omitempty,min=0" minimum:"0" description:"总流量额度(MB)"`
|
||||||
|
Price *int64 `json:"price" validate:"omitempty,min=0" minimum:"0" description:"套餐价格(分)"`
|
||||||
|
SuggestedCostPrice *int64 `json:"suggested_cost_price" validate:"omitempty,min=0" minimum:"0" description:"建议成本价(分)"`
|
||||||
|
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageListRequest 套餐列表请求
|
||||||
|
type PackageListRequest 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:"每页数量"`
|
||||||
|
PackageName *string `json:"package_name" query:"package_name" validate:"omitempty,max=255" maxLength:"255" description:"套餐名称(模糊搜索)"`
|
||||||
|
SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||||
|
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
ShelfStatus *int `json:"shelf_status" query:"shelf_status" validate:"omitempty,oneof=1 2" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
|
PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageStatusRequest 更新套餐状态请求
|
||||||
|
type UpdatePackageStatusRequest struct {
|
||||||
|
Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageShelfStatusRequest 更新套餐上架状态请求
|
||||||
|
type UpdatePackageShelfStatusRequest struct {
|
||||||
|
ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageResponse 套餐响应
|
||||||
|
type PackageResponse struct {
|
||||||
|
ID uint `json:"id" description:"套餐ID"`
|
||||||
|
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||||
|
PackageName string `json:"package_name" description:"套餐名称"`
|
||||||
|
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||||
|
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||||
|
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||||
|
DataType string `json:"data_type" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||||
|
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||||
|
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||||
|
DataAmountMB int64 `json:"data_amount_mb" description:"总流量额度(MB)"`
|
||||||
|
Price int64 `json:"price" description:"套餐价格(分)"`
|
||||||
|
SuggestedCostPrice int64 `json:"suggested_cost_price" description:"建议成本价(分)"`
|
||||||
|
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||||
|
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageParams 更新套餐聚合参数
|
||||||
|
type UpdatePackageParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdatePackageRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageStatusParams 更新套餐状态聚合参数
|
||||||
|
type UpdatePackageStatusParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdatePackageStatusRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageShelfStatusParams 更新套餐上架状态聚合参数
|
||||||
|
type UpdatePackageShelfStatusParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdatePackageShelfStatusRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePageResult 套餐分页结果
|
||||||
|
type PackagePageResult struct {
|
||||||
|
List []*PackageResponse `json:"list" description:"套餐列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"当前页"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
TotalPages int `json:"total_pages" description:"总页数"`
|
||||||
|
}
|
||||||
59
internal/model/dto/package_series_dto.go
Normal file
59
internal/model/dto/package_series_dto.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreatePackageSeriesRequest 创建套餐系列请求
|
||||||
|
type CreatePackageSeriesRequest struct {
|
||||||
|
SeriesCode string `json:"series_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"系列编码"`
|
||||||
|
SeriesName string `json:"series_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||||
|
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageSeriesRequest 更新套餐系列请求
|
||||||
|
type UpdatePackageSeriesRequest struct {
|
||||||
|
SeriesName *string `json:"series_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||||
|
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageSeriesListRequest 套餐系列列表请求
|
||||||
|
type PackageSeriesListRequest 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:"每页数量"`
|
||||||
|
SeriesName *string `json:"series_name" query:"series_name" validate:"omitempty,max=255" maxLength:"255" description:"系列名称(模糊搜索)"`
|
||||||
|
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageSeriesStatusRequest 更新套餐系列状态请求
|
||||||
|
type UpdatePackageSeriesStatusRequest struct {
|
||||||
|
Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageSeriesResponse 套餐系列响应
|
||||||
|
type PackageSeriesResponse struct {
|
||||||
|
ID uint `json:"id" description:"系列ID"`
|
||||||
|
SeriesCode string `json:"series_code" description:"系列编码"`
|
||||||
|
SeriesName string `json:"series_name" description:"系列名称"`
|
||||||
|
Description string `json:"description" description:"描述"`
|
||||||
|
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageSeriesParams 更新套餐系列聚合参数
|
||||||
|
type UpdatePackageSeriesParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdatePackageSeriesRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageSeriesStatusParams 更新套餐系列状态聚合参数
|
||||||
|
type UpdatePackageSeriesStatusParams struct {
|
||||||
|
IDReq
|
||||||
|
UpdatePackageSeriesStatusRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageSeriesPageResult 套餐系列分页结果
|
||||||
|
type PackageSeriesPageResult struct {
|
||||||
|
List []*PackageSeriesResponse `json:"list" description:"套餐系列列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"当前页"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
TotalPages int `json:"total_pages" description:"总页数"`
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ type Package struct {
|
|||||||
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
|
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
|
||||||
Price int64 `gorm:"column:price;type:bigint;not null;comment:套餐价格(分为单位)" json:"price"`
|
Price int64 `gorm:"column:price;type:bigint;not null;comment:套餐价格(分为单位)" json:"price"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||||
|
SuggestedCostPrice int64 `gorm:"column:suggested_cost_price;type:bigint;default:0;comment:建议成本价(分为单位)" json:"suggested_cost_price"`
|
||||||
|
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||||
|
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
@@ -45,23 +48,6 @@ func (Package) TableName() string {
|
|||||||
return "tb_package"
|
return "tb_package"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentPackageAllocation 代理套餐分配模型
|
|
||||||
// 为直属下级代理分配套餐,设置佣金模式
|
|
||||||
type AgentPackageAllocation struct {
|
|
||||||
gorm.Model
|
|
||||||
BaseModel `gorm:"embedded"`
|
|
||||||
AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"`
|
|
||||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
|
||||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:成本价(分为单位)" json:"cost_price"`
|
|
||||||
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;comment:零售价(分为单位)" json:"retail_price"`
|
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (AgentPackageAllocation) TableName() string {
|
|
||||||
return "tb_agent_package_allocation"
|
|
||||||
}
|
|
||||||
|
|
||||||
// PackageUsage 套餐使用情况模型
|
// PackageUsage 套餐使用情况模型
|
||||||
// 跟踪单卡套餐和设备级套餐的流量使用
|
// 跟踪单卡套餐和设备级套餐的流量使用
|
||||||
type PackageUsage struct {
|
type PackageUsage struct {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.Carrier != nil {
|
if handlers.Carrier != nil {
|
||||||
registerCarrierRoutes(authGroup, handlers.Carrier, doc, basePath)
|
registerCarrierRoutes(authGroup, handlers.Carrier, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.PackageSeries != nil {
|
||||||
|
registerPackageSeriesRoutes(authGroup, handlers.PackageSeries, doc, basePath)
|
||||||
|
}
|
||||||
|
if handlers.Package != nil {
|
||||||
|
registerPackageRoutes(authGroup, handlers.Package, doc, basePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
||||||
|
|||||||
70
internal/routes/package.go
Normal file
70
internal/routes/package.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPackageRoutes(router fiber.Router, handler *admin.PackageHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
packages := router.Group("/packages")
|
||||||
|
groupPath := basePath + "/packages"
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||||
|
Summary: "套餐列表",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.PackageListRequest),
|
||||||
|
Output: new(dto.PackagePageResult),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建套餐",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.CreatePackageRequest),
|
||||||
|
Output: new(dto.PackageResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取套餐详情",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.PackageResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
||||||
|
Summary: "更新套餐",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.UpdatePackageParams),
|
||||||
|
Output: new(dto.PackageResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||||
|
Summary: "删除套餐",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "PATCH", "/:id/status", handler.UpdateStatus, RouteSpec{
|
||||||
|
Summary: "更新套餐状态",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.UpdatePackageStatusParams),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(packages, doc, groupPath, "PATCH", "/:id/shelf", handler.UpdateShelfStatus, RouteSpec{
|
||||||
|
Summary: "更新套餐上架状态",
|
||||||
|
Tags: []string{"套餐管理"},
|
||||||
|
Input: new(dto.UpdatePackageShelfStatusParams),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
62
internal/routes/package_series.go
Normal file
62
internal/routes/package_series.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPackageSeriesRoutes(router fiber.Router, handler *admin.PackageSeriesHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
series := router.Group("/package-series")
|
||||||
|
groupPath := basePath + "/package-series"
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||||
|
Summary: "套餐系列列表",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.PackageSeriesListRequest),
|
||||||
|
Output: new(dto.PackageSeriesPageResult),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建套餐系列",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.CreatePackageSeriesRequest),
|
||||||
|
Output: new(dto.PackageSeriesResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取套餐系列详情",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: new(dto.PackageSeriesResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
||||||
|
Summary: "更新套餐系列",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.UpdatePackageSeriesParams),
|
||||||
|
Output: new(dto.PackageSeriesResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||||
|
Summary: "删除套餐系列",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.IDReq),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(series, doc, groupPath, "PATCH", "/:id/status", handler.UpdateStatus, RouteSpec{
|
||||||
|
Summary: "更新套餐系列状态",
|
||||||
|
Tags: []string{"套餐系列管理"},
|
||||||
|
Input: new(dto.UpdatePackageSeriesStatusParams),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
305
internal/service/package/service.go
Normal file
305
internal/service/package/service.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package packagepkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
packageStore *postgres.PackageStore
|
||||||
|
packageSeriesStore *postgres.PackageSeriesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(packageStore *postgres.PackageStore, packageSeriesStore *postgres.PackageSeriesStore) *Service {
|
||||||
|
return &Service{
|
||||||
|
packageStore: packageStore,
|
||||||
|
packageSeriesStore: packageSeriesStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, _ := s.packageStore.GetByCode(ctx, req.PackageCode)
|
||||||
|
if existing != nil {
|
||||||
|
return nil, errors.New(errors.CodeConflict, "套餐编码已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||||
|
_, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: req.PackageCode,
|
||||||
|
PackageName: req.PackageName,
|
||||||
|
PackageType: req.PackageType,
|
||||||
|
DurationMonths: req.DurationMonths,
|
||||||
|
Price: req.Price,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
}
|
||||||
|
if req.SeriesID != nil {
|
||||||
|
pkg.SeriesID = *req.SeriesID
|
||||||
|
}
|
||||||
|
if req.DataType != nil {
|
||||||
|
pkg.DataType = *req.DataType
|
||||||
|
}
|
||||||
|
if req.RealDataMB != nil {
|
||||||
|
pkg.RealDataMB = *req.RealDataMB
|
||||||
|
}
|
||||||
|
if req.VirtualDataMB != nil {
|
||||||
|
pkg.VirtualDataMB = *req.VirtualDataMB
|
||||||
|
}
|
||||||
|
if req.DataAmountMB != nil {
|
||||||
|
pkg.DataAmountMB = *req.DataAmountMB
|
||||||
|
}
|
||||||
|
if req.SuggestedCostPrice != nil {
|
||||||
|
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||||||
|
}
|
||||||
|
if req.SuggestedRetailPrice != nil {
|
||||||
|
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||||
|
}
|
||||||
|
pkg.Creator = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toResponse(pkg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
return s.toResponse(pkg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||||
|
_, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
pkg.SeriesID = *req.SeriesID
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PackageName != nil {
|
||||||
|
pkg.PackageName = *req.PackageName
|
||||||
|
}
|
||||||
|
if req.PackageType != nil {
|
||||||
|
pkg.PackageType = *req.PackageType
|
||||||
|
}
|
||||||
|
if req.DurationMonths != nil {
|
||||||
|
pkg.DurationMonths = *req.DurationMonths
|
||||||
|
}
|
||||||
|
if req.DataType != nil {
|
||||||
|
pkg.DataType = *req.DataType
|
||||||
|
}
|
||||||
|
if req.RealDataMB != nil {
|
||||||
|
pkg.RealDataMB = *req.RealDataMB
|
||||||
|
}
|
||||||
|
if req.VirtualDataMB != nil {
|
||||||
|
pkg.VirtualDataMB = *req.VirtualDataMB
|
||||||
|
}
|
||||||
|
if req.DataAmountMB != nil {
|
||||||
|
pkg.DataAmountMB = *req.DataAmountMB
|
||||||
|
}
|
||||||
|
if req.Price != nil {
|
||||||
|
pkg.Price = *req.Price
|
||||||
|
}
|
||||||
|
if req.SuggestedCostPrice != nil {
|
||||||
|
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||||||
|
}
|
||||||
|
if req.SuggestedRetailPrice != nil {
|
||||||
|
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||||
|
}
|
||||||
|
pkg.Updater = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||||
|
return nil, fmt.Errorf("更新套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toResponse(pkg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||||
|
_, err := s.packageStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("获取套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.packageStore.Delete(ctx, id); err != nil {
|
||||||
|
return fmt.Errorf("删除套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, 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.PackageName != nil {
|
||||||
|
filters["package_name"] = *req.PackageName
|
||||||
|
}
|
||||||
|
if req.SeriesID != nil {
|
||||||
|
filters["series_id"] = *req.SeriesID
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
filters["status"] = *req.Status
|
||||||
|
}
|
||||||
|
if req.ShelfStatus != nil {
|
||||||
|
filters["shelf_status"] = *req.ShelfStatus
|
||||||
|
}
|
||||||
|
if req.PackageType != nil {
|
||||||
|
filters["package_type"] = *req.PackageType
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, total, err := s.packageStore.List(ctx, opts, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("查询套餐列表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.PackageResponse, len(packages))
|
||||||
|
for i, pkg := range packages {
|
||||||
|
responses[i] = s.toResponse(pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("获取套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg.Status = status
|
||||||
|
pkg.Updater = currentUserID
|
||||||
|
|
||||||
|
if status == constants.StatusDisabled {
|
||||||
|
pkg.ShelfStatus = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||||
|
return fmt.Errorf("更新套餐状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("获取套餐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelfStatus == 1 && pkg.Status == constants.StatusDisabled {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg.ShelfStatus = shelfStatus
|
||||||
|
pkg.Updater = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||||
|
return fmt.Errorf("更新套餐上架状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toResponse(pkg *model.Package) *dto.PackageResponse {
|
||||||
|
var seriesID *uint
|
||||||
|
if pkg.SeriesID > 0 {
|
||||||
|
seriesID = &pkg.SeriesID
|
||||||
|
}
|
||||||
|
return &dto.PackageResponse{
|
||||||
|
ID: pkg.ID,
|
||||||
|
PackageCode: pkg.PackageCode,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
SeriesID: seriesID,
|
||||||
|
PackageType: pkg.PackageType,
|
||||||
|
DurationMonths: pkg.DurationMonths,
|
||||||
|
DataType: pkg.DataType,
|
||||||
|
RealDataMB: pkg.RealDataMB,
|
||||||
|
VirtualDataMB: pkg.VirtualDataMB,
|
||||||
|
DataAmountMB: pkg.DataAmountMB,
|
||||||
|
Price: pkg.Price,
|
||||||
|
SuggestedCostPrice: pkg.SuggestedCostPrice,
|
||||||
|
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||||
|
Status: pkg.Status,
|
||||||
|
ShelfStatus: pkg.ShelfStatus,
|
||||||
|
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,12 +122,12 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||||
|
|
||||||
err = svc.UpdateStatus(ctx, created.ID, 2)
|
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pkg, err = svc.Get(ctx, created.ID)
|
pkg, err = svc.Get(ctx, created.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, pkg.Status)
|
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,20 +145,20 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
|||||||
err = svc.UpdateShelfStatus(ctx, created2.ID, 1)
|
err = svc.UpdateShelfStatus(ctx, created2.ID, 1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = svc.UpdateStatus(ctx, created2.ID, 2)
|
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusDisabled)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pkg, err := svc.Get(ctx, created2.ID)
|
pkg, err := svc.Get(ctx, created2.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, pkg.Status)
|
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||||
|
|
||||||
err = svc.UpdateStatus(ctx, created2.ID, 1)
|
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusEnabled)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
pkg, err = svc.Get(ctx, created2.ID)
|
pkg, err = svc.Get(ctx, created2.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, pkg.Status)
|
assert.Equal(t, constants.StatusEnabled, pkg.Status)
|
||||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
|||||||
created, err := svc.Create(ctx, req)
|
created, err := svc.Create(ctx, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = svc.UpdateStatus(ctx, created.ID, 2)
|
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||||
|
|||||||
177
internal/service/package_series/service.go
Normal file
177
internal/service/package_series/service.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package package_series
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
packageSeriesStore *postgres.PackageSeriesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(packageSeriesStore *postgres.PackageSeriesStore) *Service {
|
||||||
|
return &Service{packageSeriesStore: packageSeriesStore}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, _ := s.packageSeriesStore.GetByCode(ctx, req.SeriesCode)
|
||||||
|
if existing != nil {
|
||||||
|
return nil, errors.New(errors.CodeConflict, "系列编码已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: req.SeriesCode,
|
||||||
|
SeriesName: req.SeriesName,
|
||||||
|
Description: req.Description,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
series.Creator = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageSeriesStore.Create(ctx, series); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toResponse(series), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageSeriesResponse, error) {
|
||||||
|
series, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
return s.toResponse(series), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SeriesName != nil {
|
||||||
|
series.SeriesName = *req.SeriesName
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
series.Description = *req.Description
|
||||||
|
}
|
||||||
|
series.Updater = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageSeriesStore.Update(ctx, series); err != nil {
|
||||||
|
return nil, fmt.Errorf("更新套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toResponse(series), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||||
|
_, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.packageSeriesStore.Delete(ctx, id); err != nil {
|
||||||
|
return fmt.Errorf("删除套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) ([]*dto.PackageSeriesResponse, 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.SeriesName != nil {
|
||||||
|
filters["series_name"] = *req.SeriesName
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
filters["status"] = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList, total, err := s.packageSeriesStore.List(ctx, opts, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("查询套餐系列列表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.PackageSeriesResponse, len(seriesList))
|
||||||
|
for i, series := range seriesList {
|
||||||
|
responses[i] = s.toResponse(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||||
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("获取套餐系列失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
series.Status = status
|
||||||
|
series.Updater = currentUserID
|
||||||
|
|
||||||
|
if err := s.packageSeriesStore.Update(ctx, series); err != nil {
|
||||||
|
return fmt.Errorf("更新套餐系列状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toResponse(series *model.PackageSeries) *dto.PackageSeriesResponse {
|
||||||
|
return &dto.PackageSeriesResponse{
|
||||||
|
ID: series.ID,
|
||||||
|
SeriesCode: series.SeriesCode,
|
||||||
|
SeriesName: series.SeriesName,
|
||||||
|
Description: series.Description,
|
||||||
|
Status: series.Status,
|
||||||
|
CreatedAt: series.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: series.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
313
internal/service/package_series/service_test.go
Normal file
313
internal/service/package_series/service_test.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package package_series
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"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"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackageSeriesService_Create(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("创建成功", func(t *testing.T) {
|
||||||
|
seriesCode := fmt.Sprintf("SVC_CREATE_%d", time.Now().UnixNano())
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "测试套餐系列",
|
||||||
|
Description: "服务层测试",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Create(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, resp.ID)
|
||||||
|
assert.Equal(t, req.SeriesCode, resp.SeriesCode)
|
||||||
|
assert.Equal(t, req.SeriesName, resp.SeriesName)
|
||||||
|
assert.Equal(t, constants.StatusEnabled, resp.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("编码重复失败", func(t *testing.T) {
|
||||||
|
seriesCode := fmt.Sprintf("SVC_DUP_%d", time.Now().UnixNano())
|
||||||
|
req1 := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "第一个系列",
|
||||||
|
Description: "测试重复",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.Create(ctx, req1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req2 := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "第二个系列",
|
||||||
|
Description: "重复编码",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Create(ctx, req2)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("未授权失败", func(t *testing.T) {
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: fmt.Sprintf("SVC_UNAUTH_%d", time.Now().UnixNano()),
|
||||||
|
SeriesName: "未授权测试",
|
||||||
|
Description: "无用户上下文",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.Create(context.Background(), req)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeUnauthorized, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesService_Get(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
seriesCode := fmt.Sprintf("SVC_GET_%d", time.Now().UnixNano())
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "查询测试",
|
||||||
|
Description: "用于查询测试",
|
||||||
|
}
|
||||||
|
created, err := svc.Create(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("获取存在的系列", func(t *testing.T) {
|
||||||
|
resp, err := svc.Get(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.SeriesCode, resp.SeriesCode)
|
||||||
|
assert.Equal(t, created.SeriesName, resp.SeriesName)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("获取不存在的系列", func(t *testing.T) {
|
||||||
|
_, err := svc.Get(ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesService_Update(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
seriesCode := fmt.Sprintf("SVC_UPD_%d", time.Now().UnixNano())
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "更新测试",
|
||||||
|
Description: "原始描述",
|
||||||
|
}
|
||||||
|
created, err := svc.Create(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("更新成功", func(t *testing.T) {
|
||||||
|
newName := "更新后的名称"
|
||||||
|
newDesc := "更新后的描述"
|
||||||
|
updateReq := &dto.UpdatePackageSeriesRequest{
|
||||||
|
SeriesName: &newName,
|
||||||
|
Description: &newDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newName, resp.SeriesName)
|
||||||
|
assert.Equal(t, newDesc, resp.Description)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("更新不存在的系列", func(t *testing.T) {
|
||||||
|
newName := "test"
|
||||||
|
updateReq := &dto.UpdatePackageSeriesRequest{
|
||||||
|
SeriesName: &newName,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.Update(ctx, 99999, updateReq)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesService_Delete(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
seriesCode := fmt.Sprintf("SVC_DEL_%d", time.Now().UnixNano())
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "删除测试",
|
||||||
|
Description: "用于删除测试",
|
||||||
|
}
|
||||||
|
created, err := svc.Create(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("删除成功", func(t *testing.T) {
|
||||||
|
err := svc.Delete(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = svc.Get(ctx, created.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("删除不存在的系列", func(t *testing.T) {
|
||||||
|
err := svc.Delete(ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesService_List(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
seriesList := []dto.CreatePackageSeriesRequest{
|
||||||
|
{
|
||||||
|
SeriesCode: fmt.Sprintf("SVC_LIST_001_%d", time.Now().UnixNano()),
|
||||||
|
SeriesName: "基础套餐",
|
||||||
|
Description: "列表测试1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SeriesCode: fmt.Sprintf("SVC_LIST_002_%d", time.Now().UnixNano()),
|
||||||
|
SeriesName: "高级套餐",
|
||||||
|
Description: "列表测试2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SeriesCode: fmt.Sprintf("SVC_LIST_003_%d", time.Now().UnixNano()),
|
||||||
|
SeriesName: "企业套餐",
|
||||||
|
Description: "列表测试3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, s := range seriesList {
|
||||||
|
_, err := svc.Create(ctx, &s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("查询列表", func(t *testing.T) {
|
||||||
|
req := &dto.PackageSeriesListRequest{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
}
|
||||||
|
result, total, err := svc.List(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.GreaterOrEqual(t, len(result), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按状态过滤", func(t *testing.T) {
|
||||||
|
status := constants.StatusEnabled
|
||||||
|
req := &dto.PackageSeriesListRequest{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
Status: &status,
|
||||||
|
}
|
||||||
|
result, total, err := svc.List(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
for _, s := range result {
|
||||||
|
assert.Equal(t, constants.StatusEnabled, s.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||||
|
seriesName := "高级"
|
||||||
|
req := &dto.PackageSeriesListRequest{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
SeriesName: &seriesName,
|
||||||
|
}
|
||||||
|
result, total, err := svc.List(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
assert.GreaterOrEqual(t, len(result), 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesService_UpdateStatus(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
store := postgres.NewPackageSeriesStore(tx)
|
||||||
|
svc := New(store)
|
||||||
|
|
||||||
|
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypePlatform,
|
||||||
|
})
|
||||||
|
|
||||||
|
seriesCode := fmt.Sprintf("SVC_STATUS_%d", time.Now().UnixNano())
|
||||||
|
req := &dto.CreatePackageSeriesRequest{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "状态测试",
|
||||||
|
Description: "用于状态更新测试",
|
||||||
|
}
|
||||||
|
created, err := svc.Create(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.StatusEnabled, created.Status)
|
||||||
|
|
||||||
|
t.Run("禁用系列", func(t *testing.T) {
|
||||||
|
err := svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := svc.Get(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("启用系列", func(t *testing.T) {
|
||||||
|
err := svc.UpdateStatus(ctx, created.ID, constants.StatusEnabled)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := svc.Get(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.StatusEnabled, updated.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("更新不存在的系列状态", func(t *testing.T) {
|
||||||
|
err := svc.UpdateStatus(ctx, 99999, constants.StatusDisabled)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"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"
|
||||||
@@ -11,6 +13,10 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func uniqueICCIDPrefix() string {
|
||||||
|
return fmt.Sprintf("T%d", time.Now().UnixNano()%1000000000)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIotCardStore_Create(t *testing.T) {
|
func TestIotCardStore_Create(t *testing.T) {
|
||||||
tx := testutils.NewTestTransaction(t)
|
tx := testutils.NewTestTransaction(t)
|
||||||
rdb := testutils.GetTestRedis(t)
|
rdb := testutils.GetTestRedis(t)
|
||||||
@@ -94,15 +100,16 @@ func TestIotCardStore_ListStandalone(t *testing.T) {
|
|||||||
s := NewIotCardStore(tx, rdb)
|
s := NewIotCardStore(tx, rdb)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prefix := uniqueICCIDPrefix()
|
||||||
standaloneCards := []*model.IotCard{
|
standaloneCards := []*model.IotCard{
|
||||||
{ICCID: "89860012345678903001", CardType: "data_card", CarrierID: 1, Status: 1},
|
{ICCID: prefix + "0001", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||||
{ICCID: "89860012345678903002", CardType: "data_card", CarrierID: 1, Status: 1},
|
{ICCID: prefix + "0002", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||||
{ICCID: "89860012345678903003", CardType: "data_card", CarrierID: 2, Status: 2},
|
{ICCID: prefix + "0003", CardType: "data_card", CarrierID: 2, Status: 2},
|
||||||
}
|
}
|
||||||
require.NoError(t, s.CreateBatch(ctx, standaloneCards))
|
require.NoError(t, s.CreateBatch(ctx, standaloneCards))
|
||||||
|
|
||||||
boundCard := &model.IotCard{
|
boundCard := &model.IotCard{
|
||||||
ICCID: "89860012345678903004",
|
ICCID: prefix + "0004",
|
||||||
CardType: "data_card",
|
CardType: "data_card",
|
||||||
CarrierID: 1,
|
CarrierID: 1,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
@@ -117,7 +124,8 @@ func TestIotCardStore_ListStandalone(t *testing.T) {
|
|||||||
require.NoError(t, tx.Create(binding).Error)
|
require.NoError(t, tx.Create(binding).Error)
|
||||||
|
|
||||||
t.Run("查询所有单卡", func(t *testing.T) {
|
t.Run("查询所有单卡", func(t *testing.T) {
|
||||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
filters := map[string]interface{}{"iccid": prefix}
|
||||||
|
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(3), total)
|
assert.Equal(t, int64(3), total)
|
||||||
assert.Len(t, cards, 3)
|
assert.Len(t, cards, 3)
|
||||||
@@ -128,7 +136,7 @@ func TestIotCardStore_ListStandalone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("按运营商ID过滤", func(t *testing.T) {
|
t.Run("按运营商ID过滤", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"carrier_id": uint(1)}
|
filters := map[string]interface{}{"carrier_id": uint(1), "iccid": prefix}
|
||||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(2), total)
|
assert.Equal(t, int64(2), total)
|
||||||
@@ -138,7 +146,7 @@ func TestIotCardStore_ListStandalone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("按状态过滤", func(t *testing.T) {
|
t.Run("按状态过滤", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"status": 2}
|
filters := map[string]interface{}{"status": 2, "iccid": prefix}
|
||||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), total)
|
assert.Equal(t, int64(1), total)
|
||||||
@@ -147,26 +155,28 @@ func TestIotCardStore_ListStandalone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("按ICCID模糊查询", func(t *testing.T) {
|
t.Run("按ICCID模糊查询", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"iccid": "903001"}
|
filters := map[string]interface{}{"iccid": prefix + "0001"}
|
||||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), total)
|
assert.Equal(t, int64(1), total)
|
||||||
assert.Contains(t, cards[0].ICCID, "903001")
|
assert.Contains(t, cards[0].ICCID, prefix+"0001")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("分页查询", func(t *testing.T) {
|
t.Run("分页查询", func(t *testing.T) {
|
||||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
filters := map[string]interface{}{"iccid": prefix}
|
||||||
|
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(3), total)
|
assert.Equal(t, int64(3), total)
|
||||||
assert.Len(t, cards, 2)
|
assert.Len(t, cards, 2)
|
||||||
|
|
||||||
cards2, _, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 2, PageSize: 2}, nil)
|
cards2, _, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 2, PageSize: 2}, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, cards2, 1)
|
assert.Len(t, cards2, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("默认分页选项", func(t *testing.T) {
|
t.Run("默认分页选项", func(t *testing.T) {
|
||||||
cards, total, err := s.ListStandalone(ctx, nil, nil)
|
filters := map[string]interface{}{"iccid": prefix}
|
||||||
|
cards, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(3), total)
|
assert.Equal(t, int64(3), total)
|
||||||
assert.Len(t, cards, 3)
|
assert.Len(t, cards, 3)
|
||||||
@@ -181,39 +191,43 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
|||||||
s := NewIotCardStore(tx, rdb)
|
s := NewIotCardStore(tx, rdb)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
shopID := uint(100)
|
prefix := uniqueICCIDPrefix()
|
||||||
|
batchPrefix := "B" + prefix
|
||||||
|
msisdnPrefix := "199" + prefix[1:8]
|
||||||
|
shopID := uint(time.Now().UnixNano() % 1000000)
|
||||||
|
|
||||||
cards := []*model.IotCard{
|
cards := []*model.IotCard{
|
||||||
{ICCID: "89860012345678904001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: "BATCH001", MSISDN: "13800000001"},
|
{ICCID: prefix + "A001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "01"},
|
||||||
{ICCID: "89860012345678904002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001", MSISDN: "13800000002"},
|
{ICCID: prefix + "A002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "02"},
|
||||||
{ICCID: "89860012345678904003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH002", MSISDN: "13800000003"},
|
{ICCID: prefix + "A003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: batchPrefix + "02", MSISDN: msisdnPrefix + "03"},
|
||||||
}
|
}
|
||||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||||
|
|
||||||
t.Run("按店铺ID过滤", func(t *testing.T) {
|
t.Run("按店铺ID过滤", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"shop_id": shopID}
|
filters := map[string]interface{}{"shop_id": shopID, "iccid": prefix}
|
||||||
cards, total, err := s.ListStandalone(ctx, nil, filters)
|
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), total)
|
assert.Equal(t, int64(1), total)
|
||||||
assert.Equal(t, shopID, *cards[0].ShopID)
|
assert.Equal(t, shopID, *result[0].ShopID)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("按批次号过滤", func(t *testing.T) {
|
t.Run("按批次号过滤", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"batch_no": "BATCH001"}
|
filters := map[string]interface{}{"batch_no": batchPrefix + "01", "iccid": prefix}
|
||||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(2), total)
|
assert.Equal(t, int64(2), total)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("按MSISDN模糊查询", func(t *testing.T) {
|
t.Run("按MSISDN模糊查询", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"msisdn": "000001"}
|
filters := map[string]interface{}{"msisdn": msisdnPrefix + "01"}
|
||||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), total)
|
assert.Equal(t, int64(1), total)
|
||||||
assert.Contains(t, result[0].MSISDN, "000001")
|
assert.Contains(t, result[0].MSISDN, msisdnPrefix+"01")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("已分销过滤-true", func(t *testing.T) {
|
t.Run("已分销过滤-true", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"is_distributed": true}
|
filters := map[string]interface{}{"is_distributed": true, "iccid": prefix}
|
||||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), total)
|
assert.Equal(t, int64(1), total)
|
||||||
@@ -221,7 +235,7 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("已分销过滤-false", func(t *testing.T) {
|
t.Run("已分销过滤-false", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{"is_distributed": false}
|
filters := map[string]interface{}{"is_distributed": false, "iccid": prefix}
|
||||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(2), total)
|
assert.Equal(t, int64(2), total)
|
||||||
@@ -232,8 +246,8 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ICCID范围查询", func(t *testing.T) {
|
t.Run("ICCID范围查询", func(t *testing.T) {
|
||||||
filters := map[string]interface{}{
|
filters := map[string]interface{}{
|
||||||
"iccid_start": "89860012345678904001",
|
"iccid_start": prefix + "A001",
|
||||||
"iccid_end": "89860012345678904002",
|
"iccid_end": prefix + "A002",
|
||||||
}
|
}
|
||||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
84
internal/store/postgres/package_series_store.go
Normal file
84
internal/store/postgres/package_series_store.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageSeriesStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageSeriesStore(db *gorm.DB) *PackageSeriesStore {
|
||||||
|
return &PackageSeriesStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) Create(ctx context.Context, series *model.PackageSeries) error {
|
||||||
|
return s.db.WithContext(ctx).Create(series).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) GetByID(ctx context.Context, id uint) (*model.PackageSeries, error) {
|
||||||
|
var series model.PackageSeries
|
||||||
|
if err := s.db.WithContext(ctx).First(&series, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) GetByCode(ctx context.Context, code string) (*model.PackageSeries, error) {
|
||||||
|
var series model.PackageSeries
|
||||||
|
if err := s.db.WithContext(ctx).Where("series_code = ?", code).First(&series).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) Update(ctx context.Context, series *model.PackageSeries) error {
|
||||||
|
return s.db.WithContext(ctx).Save(series).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) Delete(ctx context.Context, id uint) error {
|
||||||
|
return s.db.WithContext(ctx).Delete(&model.PackageSeries{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.PackageSeries, int64, error) {
|
||||||
|
var seriesList []*model.PackageSeries
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.PackageSeries{})
|
||||||
|
|
||||||
|
if seriesName, ok := filters["series_name"].(string); ok && seriesName != "" {
|
||||||
|
query = query.Where("series_name LIKE ?", "%"+seriesName+"%")
|
||||||
|
}
|
||||||
|
if status, ok := filters["status"]; 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(&seriesList).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return seriesList, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageSeriesStore) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||||
|
return s.db.WithContext(ctx).Model(&model.PackageSeries{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
191
internal/store/postgres/package_series_store_test.go
Normal file
191
internal/store/postgres/package_series_store_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_Create(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "SERIES_TEST_001",
|
||||||
|
SeriesName: "测试系列",
|
||||||
|
Description: "测试描述",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Create(ctx, series)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, series.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_GetByID(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "SERIES_TEST_002",
|
||||||
|
SeriesName: "测试系列2",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
|
||||||
|
t.Run("查询存在的系列", func(t *testing.T) {
|
||||||
|
result, err := s.GetByID(ctx, series.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, series.SeriesCode, result.SeriesCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的系列", func(t *testing.T) {
|
||||||
|
_, err := s.GetByID(ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_GetByCode(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "SERIES_TEST_003",
|
||||||
|
SeriesName: "测试系列3",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
|
||||||
|
t.Run("查询存在的编码", func(t *testing.T) {
|
||||||
|
result, err := s.GetByCode(ctx, "SERIES_TEST_003")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, series.ID, result.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的编码", func(t *testing.T) {
|
||||||
|
_, err := s.GetByCode(ctx, "NOT_EXISTS")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_Update(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "SERIES_TEST_004",
|
||||||
|
SeriesName: "测试系列4",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
|
||||||
|
series.SeriesName = "测试系列4-更新"
|
||||||
|
series.Description = "更新后的描述"
|
||||||
|
err := s.Update(ctx, series)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, series.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "测试系列4-更新", updated.SeriesName)
|
||||||
|
assert.Equal(t, "更新后的描述", updated.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_Delete(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "SERIES_DEL_001",
|
||||||
|
SeriesName: "待删除系列",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
|
||||||
|
err := s.Delete(ctx, series.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.GetByID(ctx, series.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_List(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seriesList := []*model.PackageSeries{
|
||||||
|
{SeriesCode: "LIST_S_001", SeriesName: "基础套餐", Status: constants.StatusEnabled},
|
||||||
|
{SeriesCode: "LIST_S_002", SeriesName: "高级套餐", Status: constants.StatusEnabled},
|
||||||
|
{SeriesCode: "LIST_S_003", SeriesName: "企业套餐", Status: constants.StatusEnabled},
|
||||||
|
}
|
||||||
|
for _, series := range seriesList {
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
}
|
||||||
|
seriesList[2].Status = constants.StatusDisabled
|
||||||
|
require.NoError(t, s.Update(ctx, seriesList[2]))
|
||||||
|
|
||||||
|
t.Run("查询所有系列", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.GreaterOrEqual(t, len(result), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"series_name": "高级"}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, series := range result {
|
||||||
|
assert.Contains(t, series.SeriesName, "高级")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按状态过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"status": constants.StatusDisabled}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, series := range result {
|
||||||
|
assert.Equal(t, constants.StatusDisabled, series.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("分页查询", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.LessOrEqual(t, len(result), 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesStore_UpdateStatus(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageSeriesStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "STATUS_TEST_001",
|
||||||
|
SeriesName: "状态测试系列",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, series))
|
||||||
|
|
||||||
|
err := s.UpdateStatus(ctx, series.ID, constants.StatusDisabled)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, series.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||||
|
}
|
||||||
97
internal/store/postgres/package_store.go
Normal file
97
internal/store/postgres/package_store.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageStore(db *gorm.DB) *PackageStore {
|
||||||
|
return &PackageStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Create(ctx context.Context, pkg *model.Package) error {
|
||||||
|
return s.db.WithContext(ctx).Create(pkg).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByID(ctx context.Context, id uint) (*model.Package, error) {
|
||||||
|
var pkg model.Package
|
||||||
|
if err := s.db.WithContext(ctx).First(&pkg, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByCode(ctx context.Context, code string) (*model.Package, error) {
|
||||||
|
var pkg model.Package
|
||||||
|
if err := s.db.WithContext(ctx).Where("package_code = ?", code).First(&pkg).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Update(ctx context.Context, pkg *model.Package) error {
|
||||||
|
return s.db.WithContext(ctx).Save(pkg).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Delete(ctx context.Context, id uint) error {
|
||||||
|
return s.db.WithContext(ctx).Delete(&model.Package{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Package, int64, error) {
|
||||||
|
var packages []*model.Package
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.Package{})
|
||||||
|
|
||||||
|
if packageName, ok := filters["package_name"].(string); ok && packageName != "" {
|
||||||
|
query = query.Where("package_name LIKE ?", "%"+packageName+"%")
|
||||||
|
}
|
||||||
|
if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 {
|
||||||
|
query = query.Where("series_id = ?", seriesID)
|
||||||
|
}
|
||||||
|
if status, ok := filters["status"]; ok {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if shelfStatus, ok := filters["shelf_status"]; ok {
|
||||||
|
query = query.Where("shelf_status = ?", shelfStatus)
|
||||||
|
}
|
||||||
|
if packageType, ok := filters["package_type"].(string); ok && packageType != "" {
|
||||||
|
query = query.Where("package_type = ?", packageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(&packages).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||||
|
return s.db.WithContext(ctx).Model(&model.Package{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
|
||||||
|
return s.db.WithContext(ctx).Model(&model.Package{}).Where("id = ?", id).Update("shelf_status", shelfStatus).Error
|
||||||
|
}
|
||||||
332
internal/store/postgres/package_store_test.go
Normal file
332
internal/store/postgres/package_store_test.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackageStore_Create(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "PKG_TEST_001",
|
||||||
|
PackageName: "测试套餐",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 1024,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
Price: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Create(ctx, pkg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, pkg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_GetByID(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "PKG_TEST_002",
|
||||||
|
PackageName: "测试套餐2",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 2048,
|
||||||
|
DataAmountMB: 2048,
|
||||||
|
Price: 19900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
t.Run("查询存在的套餐", func(t *testing.T) {
|
||||||
|
result, err := s.GetByID(ctx, pkg.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pkg.PackageCode, result.PackageCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的套餐", func(t *testing.T) {
|
||||||
|
_, err := s.GetByID(ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_GetByCode(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "PKG_TEST_003",
|
||||||
|
PackageName: "测试套餐3",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 3072,
|
||||||
|
DataAmountMB: 3072,
|
||||||
|
Price: 29900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
t.Run("查询存在的编码", func(t *testing.T) {
|
||||||
|
result, err := s.GetByCode(ctx, "PKG_TEST_003")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pkg.ID, result.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的编码", func(t *testing.T) {
|
||||||
|
_, err := s.GetByCode(ctx, "NOT_EXISTS")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_Update(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "PKG_TEST_004",
|
||||||
|
PackageName: "测试套餐4",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 4096,
|
||||||
|
DataAmountMB: 4096,
|
||||||
|
Price: 39900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
pkg.PackageName = "测试套餐4-更新"
|
||||||
|
pkg.Price = 49900
|
||||||
|
err := s.Update(ctx, pkg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, pkg.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "测试套餐4-更新", updated.PackageName)
|
||||||
|
assert.Equal(t, int64(49900), updated.Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_Delete(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "PKG_DEL_001",
|
||||||
|
PackageName: "待删除套餐",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 1024,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
Price: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
err := s.Delete(ctx, pkg.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.GetByID(ctx, pkg.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_List(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkgList := []*model.Package{
|
||||||
|
{
|
||||||
|
PackageCode: "LIST_P_001",
|
||||||
|
PackageName: "基础套餐",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 1024,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
Price: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PackageCode: "LIST_P_002",
|
||||||
|
PackageName: "高级套餐",
|
||||||
|
SeriesID: 2,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 12,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 10240,
|
||||||
|
DataAmountMB: 10240,
|
||||||
|
Price: 99900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PackageCode: "LIST_P_003",
|
||||||
|
PackageName: "企业套餐",
|
||||||
|
SeriesID: 3,
|
||||||
|
PackageType: "addon",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "virtual",
|
||||||
|
VirtualDataMB: 5120,
|
||||||
|
DataAmountMB: 5120,
|
||||||
|
Price: 4900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, pkg := range pkgList {
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
}
|
||||||
|
pkgList[2].Status = constants.StatusDisabled
|
||||||
|
require.NoError(t, s.Update(ctx, pkgList[2]))
|
||||||
|
|
||||||
|
t.Run("查询所有套餐", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.GreaterOrEqual(t, len(result), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"package_name": "高级"}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, pkg := range result {
|
||||||
|
assert.Contains(t, pkg.PackageName, "高级")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按系列筛选", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"series_id": uint(2)}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, pkg := range result {
|
||||||
|
assert.Equal(t, uint(2), pkg.SeriesID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按状态过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"status": constants.StatusDisabled}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, pkg := range result {
|
||||||
|
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按上架状态过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"shelf_status": 2}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, pkg := range result {
|
||||||
|
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按类型过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]interface{}{"package_type": "addon"}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, pkg := range result {
|
||||||
|
assert.Equal(t, "addon", pkg.PackageType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("分页查询", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.LessOrEqual(t, len(result), 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_UpdateStatus(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "STATUS_TEST_001",
|
||||||
|
PackageName: "状态测试套餐",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 1024,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
Price: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
err := s.UpdateStatus(ctx, pkg.ID, constants.StatusDisabled)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, pkg.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageStore_UpdateShelfStatus(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
s := NewPackageStore(tx)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "SHELF_TEST_001",
|
||||||
|
PackageName: "上架测试套餐",
|
||||||
|
SeriesID: 1,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataType: "real",
|
||||||
|
RealDataMB: 1024,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
Price: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, pkg))
|
||||||
|
|
||||||
|
err := s.UpdateShelfStatus(ctx, pkg.ID, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, pkg.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, updated.ShelfStatus)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
-- 回滚: 删除 tb_package 表的新字段
|
||||||
|
ALTER TABLE tb_package DROP COLUMN IF EXISTS suggested_cost_price;
|
||||||
|
ALTER TABLE tb_package DROP COLUMN IF EXISTS suggested_retail_price;
|
||||||
|
ALTER TABLE tb_package DROP COLUMN IF EXISTS shelf_status;
|
||||||
|
|
||||||
|
-- 回滚: 重建废弃表结构(仅结构,不恢复数据)
|
||||||
|
|
||||||
|
-- 代理层级关系表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_agent_hierarchy (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
agent_id BIGINT NOT NULL,
|
||||||
|
parent_agent_id BIGINT,
|
||||||
|
level INT NOT NULL,
|
||||||
|
path VARCHAR(500)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_hierarchy_agent ON tb_agent_hierarchy(agent_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_hierarchy_parent ON tb_agent_hierarchy(parent_agent_id);
|
||||||
|
COMMENT ON TABLE tb_agent_hierarchy IS '代理层级关系表';
|
||||||
|
|
||||||
|
-- 分佣规则表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_commission_rule (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
agent_id BIGINT NOT NULL,
|
||||||
|
business_type VARCHAR(50) NOT NULL,
|
||||||
|
card_type VARCHAR(50) NOT NULL,
|
||||||
|
series_id BIGINT,
|
||||||
|
package_id BIGINT,
|
||||||
|
commission_type VARCHAR(50) NOT NULL,
|
||||||
|
commission_mode VARCHAR(20) NOT NULL,
|
||||||
|
commission_value BIGINT NOT NULL,
|
||||||
|
unfreeze_days INT DEFAULT 0,
|
||||||
|
min_activation_for_unfreeze INT DEFAULT 0,
|
||||||
|
approval_type VARCHAR(20) DEFAULT 'auto',
|
||||||
|
status INT DEFAULT 1 NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_rule_agent ON tb_commission_rule(agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_rule_series ON tb_commission_rule(series_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_rule_package ON tb_commission_rule(package_id);
|
||||||
|
COMMENT ON TABLE tb_commission_rule IS '分佣规则表';
|
||||||
|
|
||||||
|
-- 阶梯分佣配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_commission_ladder (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
rule_id BIGINT NOT NULL,
|
||||||
|
ladder_type VARCHAR(50) NOT NULL,
|
||||||
|
threshold_value INT NOT NULL,
|
||||||
|
commission_mode VARCHAR(20) NOT NULL,
|
||||||
|
commission_value BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_ladder_rule ON tb_commission_ladder(rule_id);
|
||||||
|
COMMENT ON TABLE tb_commission_ladder IS '阶梯分佣配置表';
|
||||||
|
|
||||||
|
-- 组合分佣条件表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_commission_combined_condition (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
rule_id BIGINT NOT NULL,
|
||||||
|
one_time_commission_mode VARCHAR(20),
|
||||||
|
one_time_commission_value BIGINT,
|
||||||
|
long_term_commission_mode VARCHAR(20),
|
||||||
|
long_term_commission_value BIGINT,
|
||||||
|
long_term_trigger_time_point TIMESTAMPTZ,
|
||||||
|
long_term_trigger_package_cycles INT,
|
||||||
|
long_term_trigger_network_months INT,
|
||||||
|
long_term_unfreeze_days INT DEFAULT 0,
|
||||||
|
long_term_min_activation INT DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_commission_combined_rule ON tb_commission_combined_condition(rule_id) WHERE deleted_at IS NULL;
|
||||||
|
COMMENT ON TABLE tb_commission_combined_condition IS '组合分佣条件表';
|
||||||
|
|
||||||
|
-- 分佣审批表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_commission_approval (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
commission_record_id BIGINT NOT NULL,
|
||||||
|
approver_id BIGINT,
|
||||||
|
status INT DEFAULT 1 NOT NULL,
|
||||||
|
reason TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_approval_record ON tb_commission_approval(commission_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_commission_approval_approver ON tb_commission_approval(approver_id);
|
||||||
|
COMMENT ON TABLE tb_commission_approval IS '分佣审批表';
|
||||||
|
|
||||||
|
-- 分佣模板表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_commission_template (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
template_name VARCHAR(255) NOT NULL,
|
||||||
|
business_type VARCHAR(50) NOT NULL,
|
||||||
|
card_type VARCHAR(50) NOT NULL,
|
||||||
|
commission_type VARCHAR(50) NOT NULL,
|
||||||
|
commission_mode VARCHAR(20) NOT NULL,
|
||||||
|
commission_value BIGINT NOT NULL,
|
||||||
|
unfreeze_days INT DEFAULT 0,
|
||||||
|
min_activation_for_unfreeze INT DEFAULT 0,
|
||||||
|
approval_type VARCHAR(20) DEFAULT 'auto'
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_commission_template_name ON tb_commission_template(template_name) WHERE deleted_at IS NULL;
|
||||||
|
COMMENT ON TABLE tb_commission_template IS '分佣模板表';
|
||||||
|
|
||||||
|
-- 运营商结算表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_carrier_settlement (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
commission_record_id BIGINT NOT NULL,
|
||||||
|
agent_id BIGINT NOT NULL,
|
||||||
|
settlement_month VARCHAR(20) NOT NULL,
|
||||||
|
settlement_amount BIGINT NOT NULL,
|
||||||
|
status INT DEFAULT 1 NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_carrier_settlement_record ON tb_carrier_settlement(commission_record_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_carrier_settlement_agent ON tb_carrier_settlement(agent_id);
|
||||||
|
COMMENT ON TABLE tb_carrier_settlement IS '运营商结算表';
|
||||||
|
|
||||||
|
-- 代理套餐分配表
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_agent_package_allocation (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT DEFAULT 0,
|
||||||
|
updater BIGINT DEFAULT 0,
|
||||||
|
agent_id BIGINT NOT NULL,
|
||||||
|
package_id BIGINT NOT NULL,
|
||||||
|
cost_price BIGINT NOT NULL,
|
||||||
|
retail_price BIGINT NOT NULL,
|
||||||
|
status INT DEFAULT 1 NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_package_allocation_agent ON tb_agent_package_allocation(agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_package_allocation_package ON tb_agent_package_allocation(package_id);
|
||||||
|
COMMENT ON TABLE tb_agent_package_allocation IS '代理套餐分配表';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 清理废弃的分佣相关表
|
||||||
|
-- 这些表从未在生产环境使用,可以直接删除
|
||||||
|
|
||||||
|
-- 删除废弃表
|
||||||
|
DROP TABLE IF EXISTS tb_agent_hierarchy;
|
||||||
|
DROP TABLE IF EXISTS tb_commission_rule;
|
||||||
|
DROP TABLE IF EXISTS tb_commission_ladder;
|
||||||
|
DROP TABLE IF EXISTS tb_commission_combined_condition;
|
||||||
|
DROP TABLE IF EXISTS tb_commission_approval;
|
||||||
|
DROP TABLE IF EXISTS tb_commission_template;
|
||||||
|
DROP TABLE IF EXISTS tb_carrier_settlement;
|
||||||
|
DROP TABLE IF EXISTS tb_agent_package_allocation;
|
||||||
|
|
||||||
|
-- 为 tb_package 表添加新字段
|
||||||
|
ALTER TABLE tb_package ADD COLUMN IF NOT EXISTS suggested_cost_price BIGINT DEFAULT 0;
|
||||||
|
ALTER TABLE tb_package ADD COLUMN IF NOT EXISTS suggested_retail_price BIGINT DEFAULT 0;
|
||||||
|
ALTER TABLE tb_package ADD COLUMN IF NOT EXISTS shelf_status INT DEFAULT 2 NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tb_package.suggested_cost_price IS '建议成本价(分为单位)';
|
||||||
|
COMMENT ON COLUMN tb_package.suggested_retail_price IS '建议售价(分为单位)';
|
||||||
|
COMMENT ON COLUMN tb_package.shelf_status IS '上架状态 1-上架 2-下架';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-27
|
||||||
123
openspec/changes/add-card-device-series-bindng/design.md
Normal file
123
openspec/changes/add-card-device-series-bindng/design.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 2 完成了代理套餐分配机制,但卡和设备还没有关联到具体的套餐系列。本期在 IotCard 和 Device 模型上新增字段,记录其所属的套餐系列分配,为后续的套餐购买和佣金计算做准备。
|
||||||
|
|
||||||
|
**关键业务规则**:
|
||||||
|
- 卡/设备关联后才能购买该系列下的套餐
|
||||||
|
- 设备关联后,其绑定的所有卡共享该套餐系列(设备级套餐)
|
||||||
|
- 每张卡/设备只能触发一次一次性佣金
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 在 IotCard 模型新增套餐系列关联和佣金状态字段
|
||||||
|
- 在 Device 模型新增相同字段
|
||||||
|
- 提供批量设置卡/设备套餐系列的 API
|
||||||
|
- 验证关联的系列必须是当前店铺被分配的
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不实现订单支付(Phase 4)
|
||||||
|
- 不实现佣金计算(Phase 5)
|
||||||
|
- 不自动同步设备和卡的关联(手动设置)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 新增字段设计
|
||||||
|
|
||||||
|
**决策**:在 IotCard 和 Device 模型各新增 3 个字段
|
||||||
|
|
||||||
|
```go
|
||||||
|
// IotCard 新增字段
|
||||||
|
SeriesAllocationID uint `gorm:"column:series_allocation_id;index;comment:套餐系列分配ID" json:"series_allocation_id"`
|
||||||
|
FirstCommissionPaid bool `gorm:"column:first_commission_paid;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
|
||||||
|
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
|
||||||
|
|
||||||
|
// Device 新增字段(相同)
|
||||||
|
SeriesAllocationID uint `gorm:"column:series_allocation_id;index;comment:套餐系列分配ID" json:"series_allocation_id"`
|
||||||
|
FirstCommissionPaid bool `gorm:"column:first_commission_paid;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
|
||||||
|
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `series_allocation_id`:关联到 ShopSeriesAllocation,决定可购买的套餐
|
||||||
|
- `first_commission_paid`:标记一次性佣金状态,防止重复发放
|
||||||
|
- `accumulated_recharge`:累计充值金额,用于累计充值触发条件
|
||||||
|
|
||||||
|
### 2. 设备与卡的关系
|
||||||
|
|
||||||
|
**决策**:设备和卡独立设置套餐系列
|
||||||
|
|
||||||
|
```
|
||||||
|
场景 1:单卡销售
|
||||||
|
- IotCard.series_allocation_id 有值
|
||||||
|
- 购买套餐时使用卡的 series_allocation_id
|
||||||
|
|
||||||
|
场景 2:设备销售(整机出货)
|
||||||
|
- Device.series_allocation_id 有值
|
||||||
|
- 设备下的卡可以不设置 series_allocation_id
|
||||||
|
- 购买套餐时优先使用 Device.series_allocation_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 单卡和设备是两种不同的销售模式
|
||||||
|
- 设备级套餐购买时,所有卡共享流量
|
||||||
|
- 佣金按设备计算,不按卡数倍增
|
||||||
|
|
||||||
|
### 3. 批量设置 API 设计
|
||||||
|
|
||||||
|
**决策**:使用 PATCH 方法批量更新
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/admin/iot-cards/series-bindng
|
||||||
|
Body: { "iccids": ["xxx", "yyy"], "series_allocation_id": 123 }
|
||||||
|
|
||||||
|
PATCH /api/admin/devices/series-bindng
|
||||||
|
Body: { "device_ids": [1, 2, 3], "series_allocation_id": 123 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- PATCH 语义合适(部分更新)
|
||||||
|
- 支持批量操作提高效率
|
||||||
|
- 通过 ICCID/设备 ID 定位资源
|
||||||
|
|
||||||
|
### 4. 权限验证
|
||||||
|
|
||||||
|
**决策**:只能关联当前店铺被分配的套餐系列
|
||||||
|
|
||||||
|
验证逻辑:
|
||||||
|
1. 获取卡/设备的 shop_id
|
||||||
|
2. 检查 series_allocation_id 对应的分配是否属于该店铺
|
||||||
|
3. 检查分配状态是否启用
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 防止关联未被分配的系列
|
||||||
|
- 确保数据一致性
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 风险 1:批量操作性能
|
||||||
|
|
||||||
|
**风险**:大批量设置时可能超时
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 限制单次批量数量(如最多 500 条)
|
||||||
|
- 使用批量更新 SQL 而非循环单条更新
|
||||||
|
|
||||||
|
### 风险 2:设备和卡关联不一致
|
||||||
|
|
||||||
|
**风险**:设备设置了系列但卡没设置,或反过来
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 购买套餐时明确优先级:设备级 > 卡级
|
||||||
|
- 查询接口明确返回实际使用的系列来源
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **是否需要清除关联功能?**
|
||||||
|
- 当前设计:可以将 series_allocation_id 设为 0 清除关联
|
||||||
|
- 待确认:清除后是否影响已购买的套餐?
|
||||||
|
|
||||||
|
2. **设备和卡的 accumulated_recharge 如何同步?**
|
||||||
|
- 当前设计:设备级购买时更新 Device.accumulated_recharge
|
||||||
|
- 单卡购买时更新 IotCard.accumulated_recharge
|
||||||
|
- 待确认:是否需要双向同步?
|
||||||
58
openspec/changes/add-card-device-series-bindng/proposal.md
Normal file
58
openspec/changes/add-card-device-series-bindng/proposal.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 2 完成了代理套餐分配,但卡和设备还没有关联到具体的套餐系列分配。需要在卡/设备上记录其所属的套餐系列分配,以便后续购买套餐时验证权限、计算佣金。同时需要记录一次性佣金状态和累计充值金额,为 Phase 5 的佣金计算做准备。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**IotCard 模型调整:**
|
||||||
|
- 新增 `series_allocation_id`:关联的套餐系列分配 ID
|
||||||
|
- 新增 `first_commission_paid`:一次性佣金是否已发放(bool)
|
||||||
|
- 新增 `accumulated_recharge`:累计充值金额(分)
|
||||||
|
|
||||||
|
**Device 模型调整:**
|
||||||
|
- 新增 `series_allocation_id`:关联的套餐系列分配 ID
|
||||||
|
- 新增 `first_commission_paid`:一次性佣金是否已发放(bool)
|
||||||
|
- 新增 `accumulated_recharge`:累计充值金额(分)
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
- 批量设置卡的套餐系列分配
|
||||||
|
- 批量设置设备的套餐系列分配
|
||||||
|
- 查询卡/设备的套餐系列分配信息
|
||||||
|
|
||||||
|
**业务规则:**
|
||||||
|
- 卡/设备只能关联当前所属店铺被分配的套餐系列
|
||||||
|
- 设备关联后,其绑定的所有卡共享该套餐系列
|
||||||
|
- 关联后可购买该系列下的套餐
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `card-series-bindng`: 卡套餐系列关联 - 为 IoT 卡设置套餐系列分配,记录佣金状态
|
||||||
|
- `device-series-bindng`: 设备套餐系列关联 - 为设备设置套餐系列分配,设备下所有卡共享
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- 无 -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**代码影响:**
|
||||||
|
- `internal/model/iot_card.go` - 新增 3 个字段
|
||||||
|
- `internal/model/device.go` - 新增 3 个字段
|
||||||
|
- `migrations/` - 修改 tb_iot_card 和 tb_device 表
|
||||||
|
- `internal/handler/admin/` - 扩展卡/设备 Handler
|
||||||
|
- `internal/service/` - 扩展卡/设备 Service
|
||||||
|
- `internal/model/dto/` - 新增请求 DTO
|
||||||
|
|
||||||
|
**API 影响:**
|
||||||
|
- 新增 `PATCH /api/admin/iot-cards/series-bindng` 批量设置卡系列
|
||||||
|
- 新增 `PATCH /api/admin/devices/series-bindng` 批量设置设备系列
|
||||||
|
|
||||||
|
**数据库影响:**
|
||||||
|
- 修改表:`tb_iot_card` 新增 3 个字段
|
||||||
|
- 修改表:`tb_device` 新增 3 个字段
|
||||||
|
|
||||||
|
**依赖关系:**
|
||||||
|
- 依赖 Phase 2(add-shop-package-allocation)完成
|
||||||
|
- Phase 4(订单与支付)依赖本期
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 批量设置卡的套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许代理批量为 IoT 卡设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
|
||||||
|
|
||||||
|
#### Scenario: 成功批量设置
|
||||||
|
- **WHEN** 代理提交多个 ICCID 和一个有效的 series_allocation_id
|
||||||
|
- **THEN** 系统更新这些卡的 series_allocation_id 字段
|
||||||
|
|
||||||
|
#### Scenario: 系列未分配给店铺
|
||||||
|
- **WHEN** 代理尝试设置一个未分配给卡所属店铺的系列
|
||||||
|
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
|
||||||
|
|
||||||
|
#### Scenario: 系列分配已禁用
|
||||||
|
- **WHEN** 代理尝试设置一个已禁用的系列分配
|
||||||
|
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
|
||||||
|
|
||||||
|
#### Scenario: ICCID 不存在
|
||||||
|
- **WHEN** 提交的 ICCID 中有不存在的卡
|
||||||
|
- **THEN** 系统返回错误,列出不存在的 ICCID
|
||||||
|
|
||||||
|
#### Scenario: 卡不属于当前店铺
|
||||||
|
- **WHEN** 代理尝试设置不属于自己店铺的卡
|
||||||
|
- **THEN** 系统返回错误 "部分卡不属于您的店铺"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 清除卡的套餐系列关联
|
||||||
|
|
||||||
|
系统 SHALL 允许代理清除卡的套餐系列关联(将 series_allocation_id 设为 0)。
|
||||||
|
|
||||||
|
#### Scenario: 清除单卡关联
|
||||||
|
- **WHEN** 代理将卡的 series_allocation_id 设为 0
|
||||||
|
- **THEN** 系统清除该卡的套餐系列关联
|
||||||
|
|
||||||
|
#### Scenario: 批量清除关联
|
||||||
|
- **WHEN** 代理批量提交 ICCID 列表,series_allocation_id 为 0
|
||||||
|
- **THEN** 系统清除这些卡的套餐系列关联
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询卡的套餐系列信息
|
||||||
|
|
||||||
|
系统 SHALL 在卡详情和列表中返回套餐系列关联信息。
|
||||||
|
|
||||||
|
#### Scenario: 卡详情包含系列信息
|
||||||
|
- **WHEN** 查询卡详情
|
||||||
|
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
|
||||||
|
|
||||||
|
#### Scenario: 卡列表支持按系列筛选
|
||||||
|
- **WHEN** 代理按 series_allocation_id 筛选卡列表
|
||||||
|
- **THEN** 系统只返回关联该系列的卡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: IotCard 模型新增字段
|
||||||
|
|
||||||
|
系统 MUST 在 IotCard 模型中新增以下字段:
|
||||||
|
- `series_allocation_id`:套餐系列分配 ID
|
||||||
|
- `first_commission_paid`:一次性佣金是否已发放(默认 false)
|
||||||
|
- `accumulated_recharge`:累计充值金额(默认 0)
|
||||||
|
|
||||||
|
#### Scenario: 新卡默认值
|
||||||
|
- **WHEN** 创建新的 IoT 卡
|
||||||
|
- **THEN** series_allocation_id 为空,first_commission_paid 为 false,accumulated_recharge 为 0
|
||||||
|
|
||||||
|
#### Scenario: 字段在响应中可见
|
||||||
|
- **WHEN** 查询卡信息
|
||||||
|
- **THEN** 响应包含这三个新字段
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 批量设置设备的套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许代理批量为设备设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
|
||||||
|
|
||||||
|
#### Scenario: 成功批量设置
|
||||||
|
- **WHEN** 代理提交多个设备 ID 和一个有效的 series_allocation_id
|
||||||
|
- **THEN** 系统更新这些设备的 series_allocation_id 字段
|
||||||
|
|
||||||
|
#### Scenario: 系列未分配给店铺
|
||||||
|
- **WHEN** 代理尝试设置一个未分配给设备所属店铺的系列
|
||||||
|
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
|
||||||
|
|
||||||
|
#### Scenario: 系列分配已禁用
|
||||||
|
- **WHEN** 代理尝试设置一个已禁用的系列分配
|
||||||
|
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
|
||||||
|
|
||||||
|
#### Scenario: 设备不存在
|
||||||
|
- **WHEN** 提交的设备 ID 中有不存在的设备
|
||||||
|
- **THEN** 系统返回错误,列出不存在的设备 ID
|
||||||
|
|
||||||
|
#### Scenario: 设备不属于当前店铺
|
||||||
|
- **WHEN** 代理尝试设置不属于自己店铺的设备
|
||||||
|
- **THEN** 系统返回错误 "部分设备不属于您的店铺"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 清除设备的套餐系列关联
|
||||||
|
|
||||||
|
系统 SHALL 允许代理清除设备的套餐系列关联。
|
||||||
|
|
||||||
|
#### Scenario: 清除单设备关联
|
||||||
|
- **WHEN** 代理将设备的 series_allocation_id 设为 0
|
||||||
|
- **THEN** 系统清除该设备的套餐系列关联
|
||||||
|
|
||||||
|
#### Scenario: 批量清除关联
|
||||||
|
- **WHEN** 代理批量提交设备 ID 列表,series_allocation_id 为 0
|
||||||
|
- **THEN** 系统清除这些设备的套餐系列关联
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询设备的套餐系列信息
|
||||||
|
|
||||||
|
系统 SHALL 在设备详情和列表中返回套餐系列关联信息。
|
||||||
|
|
||||||
|
#### Scenario: 设备详情包含系列信息
|
||||||
|
- **WHEN** 查询设备详情
|
||||||
|
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
|
||||||
|
|
||||||
|
#### Scenario: 设备列表支持按系列筛选
|
||||||
|
- **WHEN** 代理按 series_allocation_id 筛选设备列表
|
||||||
|
- **THEN** 系统只返回关联该系列的设备
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Device 模型新增字段
|
||||||
|
|
||||||
|
系统 MUST 在 Device 模型中新增以下字段:
|
||||||
|
- `series_allocation_id`:套餐系列分配 ID
|
||||||
|
- `first_commission_paid`:一次性佣金是否已发放(默认 false)
|
||||||
|
- `accumulated_recharge`:累计充值金额(默认 0)
|
||||||
|
|
||||||
|
#### Scenario: 新设备默认值
|
||||||
|
- **WHEN** 创建新设备
|
||||||
|
- **THEN** series_allocation_id 为空,first_commission_paid 为 false,accumulated_recharge 为 0
|
||||||
|
|
||||||
|
#### Scenario: 字段在响应中可见
|
||||||
|
- **WHEN** 查询设备信息
|
||||||
|
- **THEN** 响应包含这三个新字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 设备级套餐购买优先级
|
||||||
|
|
||||||
|
设备购买套餐时 MUST 使用 Device.series_allocation_id 确定可购买的套餐系列,而非设备下单卡的 series_allocation_id。
|
||||||
|
|
||||||
|
#### Scenario: 设备有系列关联
|
||||||
|
- **WHEN** 设备有 series_allocation_id,且其下的卡也有各自的 series_allocation_id
|
||||||
|
- **THEN** 设备级套餐购买使用设备的 series_allocation_id
|
||||||
|
|
||||||
|
#### Scenario: 设备无系列关联
|
||||||
|
- **WHEN** 设备的 series_allocation_id 为空
|
||||||
|
- **THEN** 该设备无法购买设备级套餐
|
||||||
85
openspec/changes/add-card-device-series-bindng/tasks.md
Normal file
85
openspec/changes/add-card-device-series-bindng/tasks.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## 1. IotCard 模型调整
|
||||||
|
|
||||||
|
- [ ] 1.1 在 `internal/model/iot_card.go` 中新增 `series_allocation_id` 字段(uint, index, 可空)
|
||||||
|
- [ ] 1.2 新增 `first_commission_paid` 字段(bool, 默认 false)
|
||||||
|
- [ ] 1.3 新增 `accumulated_recharge` 字段(bigint, 默认 0)
|
||||||
|
|
||||||
|
## 2. Device 模型调整
|
||||||
|
|
||||||
|
- [ ] 2.1 在 `internal/model/device.go` 中新增 `series_allocation_id` 字段(uint, index, 可空)
|
||||||
|
- [ ] 2.2 新增 `first_commission_paid` 字段(bool, 默认 false)
|
||||||
|
- [ ] 2.3 新增 `accumulated_recharge` 字段(bigint, 默认 0)
|
||||||
|
|
||||||
|
## 3. 数据库迁移
|
||||||
|
|
||||||
|
- [ ] 3.1 创建迁移文件,为 tb_iot_card 添加 3 个新字段
|
||||||
|
- [ ] 3.2 为 tb_device 添加 3 个新字段
|
||||||
|
- [ ] 3.3 为 series_allocation_id 添加索引
|
||||||
|
- [ ] 3.4 本地执行迁移验证
|
||||||
|
|
||||||
|
## 4. DTO 更新
|
||||||
|
|
||||||
|
- [ ] 4.1 更新 IotCard 相关 DTO,新增 series_allocation_id、first_commission_paid、accumulated_recharge 字段
|
||||||
|
- [ ] 4.2 更新 Device 相关 DTO,新增相同字段
|
||||||
|
- [ ] 4.3 创建 BatchSetSeriesBindngRequest(iccids/device_ids + series_allocation_id)
|
||||||
|
- [ ] 4.4 创建 BatchSetSeriesBindngResponse(成功数、失败列表)
|
||||||
|
|
||||||
|
## 5. IotCard Store 更新
|
||||||
|
|
||||||
|
- [ ] 5.1 在 IotCardStore 中添加 BatchUpdateSeriesAllocation 方法
|
||||||
|
- [ ] 5.2 添加 ListBySeriesAllocationID 方法(按系列筛选)
|
||||||
|
- [ ] 5.3 更新 List 方法支持 series_allocation_id 筛选
|
||||||
|
|
||||||
|
## 6. Device Store 更新
|
||||||
|
|
||||||
|
- [ ] 6.1 在 DeviceStore 中添加 BatchUpdateSeriesAllocation 方法
|
||||||
|
- [ ] 6.2 添加 ListBySeriesAllocationID 方法
|
||||||
|
- [ ] 6.3 更新 List 方法支持 series_allocation_id 筛选
|
||||||
|
|
||||||
|
## 7. IotCard Service 更新
|
||||||
|
|
||||||
|
- [ ] 7.1 在 IotCardService 中添加 BatchSetSeriesBindng 方法(验证权限、验证系列分配)
|
||||||
|
- [ ] 7.2 添加 ValidateSeriesAllocation 辅助方法(检查系列是否分配给店铺)
|
||||||
|
|
||||||
|
## 8. Device Service 更新
|
||||||
|
|
||||||
|
- [ ] 8.1 在 DeviceService 中添加 BatchSetSeriesBindng 方法
|
||||||
|
- [ ] 8.2 添加 ValidateSeriesAllocation 辅助方法
|
||||||
|
|
||||||
|
## 9. IotCard Handler 更新
|
||||||
|
|
||||||
|
- [ ] 9.1 在 IotCardHandler 中添加 BatchSetSeriesBindng 接口(PATCH /api/admin/iot-cards/series-bindng)
|
||||||
|
- [ ] 9.2 更新 List 接口支持 series_allocation_id 筛选参数
|
||||||
|
- [ ] 9.3 更新 Get 接口响应包含系列关联信息
|
||||||
|
|
||||||
|
## 10. Device Handler 更新
|
||||||
|
|
||||||
|
- [ ] 10.1 在 DeviceHandler 中添加 BatchSetSeriesBindng 接口(PATCH /api/admin/devices/series-bindng)
|
||||||
|
- [ ] 10.2 更新 List 接口支持 series_allocation_id 筛选参数
|
||||||
|
- [ ] 10.3 更新 Get 接口响应包含系列关联信息
|
||||||
|
|
||||||
|
## 11. 路由注册
|
||||||
|
|
||||||
|
- [ ] 11.1 注册 `PATCH /api/admin/iot-cards/series-bindng` 路由
|
||||||
|
- [ ] 11.2 注册 `PATCH /api/admin/devices/series-bindng` 路由
|
||||||
|
|
||||||
|
## 12. 文档生成器更新
|
||||||
|
|
||||||
|
- [ ] 12.1 更新 docs.go 和 gendocs/main.go(如有新 Handler)
|
||||||
|
- [ ] 12.2 执行文档生成验证
|
||||||
|
|
||||||
|
## 13. 测试
|
||||||
|
|
||||||
|
- [ ] 13.1 IotCardStore 批量更新方法单元测试
|
||||||
|
- [ ] 13.2 DeviceStore 批量更新方法单元测试
|
||||||
|
- [ ] 13.3 IotCardService BatchSetSeriesBindng 单元测试(覆盖权限验证)
|
||||||
|
- [ ] 13.4 DeviceService BatchSetSeriesBindng 单元测试
|
||||||
|
- [ ] 13.5 卡系列关联 API 集成测试
|
||||||
|
- [ ] 13.6 设备系列关联 API 集成测试
|
||||||
|
- [ ] 13.7 执行 `go test ./...` 确认通过
|
||||||
|
|
||||||
|
## 14. 最终验证
|
||||||
|
|
||||||
|
- [ ] 14.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [ ] 14.2 启动服务,手动测试批量设置功能
|
||||||
|
- [ ] 14.3 验证列表筛选功能正常
|
||||||
2
openspec/changes/add-one-time-commission/.openspec.yaml
Normal file
2
openspec/changes/add-one-time-commission/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-27
|
||||||
252
openspec/changes/add-one-time-commission/design.md
Normal file
252
openspec/changes/add-one-time-commission/design.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 4 完成了订单和支付流程,现在需要实现佣金计算。当终端用户购买套餐支付成功后,系统自动计算各级代理的佣金并入账。
|
||||||
|
|
||||||
|
**佣金来源**:
|
||||||
|
1. **成本价差收入**:每笔订单必触发,售价 - 成本价 = 代理收入
|
||||||
|
2. **一次性佣金**:满足触发条件时发放一次,金额从梯度配置获取
|
||||||
|
|
||||||
|
**当前 CommissionRecord 模型过于复杂**(包含冻结/解冻字段),需要简化。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 简化 CommissionRecord 模型
|
||||||
|
- 实现成本价差收入计算(每笔订单)
|
||||||
|
- 实现一次性佣金触发(充值阈值)
|
||||||
|
- 佣金直接入账到店铺钱包
|
||||||
|
- 提供佣金记录查询和统计
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不实现冻结/解冻机制
|
||||||
|
- 不实现长期佣金(号卡专用)
|
||||||
|
- 不实现梯度佣金统计(本期只做配置,统计后续优化)
|
||||||
|
- 不实现佣金审批流程
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. CommissionRecord 模型简化
|
||||||
|
|
||||||
|
**决策**:删除冻结相关字段,新增来源和关联字段
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 简化后的 CommissionRecord
|
||||||
|
type CommissionRecord struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
ShopID uint // 店铺ID(佣金归属)
|
||||||
|
OrderID uint // 关联订单ID
|
||||||
|
IotCardID uint // 关联卡ID(可空)
|
||||||
|
DeviceID uint // 关联设备ID(可空)
|
||||||
|
CommissionSource string // 佣金来源: cost_diff-成本价差 one_time-一次性佣金 tier_bonus-梯度奖励
|
||||||
|
Amount int64 // 佣金金额(分)
|
||||||
|
BalanceAfter int64 // 入账后钱包余额(分)
|
||||||
|
Status int // 状态: 1-已入账 2-已失效
|
||||||
|
ReleasedAt *time.Time // 入账时间
|
||||||
|
Remark string // 备注
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**删除字段**:
|
||||||
|
- `agent_id`(改用 shop_id)
|
||||||
|
- `rule_id`(不再关联复杂规则)
|
||||||
|
- `commission_type`(改用 commission_source)
|
||||||
|
- `unfrozen_at`、冻结相关状态
|
||||||
|
|
||||||
|
### 2. 佣金计算流程
|
||||||
|
|
||||||
|
**决策**:订单支付成功后异步计算
|
||||||
|
|
||||||
|
```
|
||||||
|
订单支付成功
|
||||||
|
↓
|
||||||
|
发送异步任务 (Asynq)
|
||||||
|
↓
|
||||||
|
佣金计算任务执行:
|
||||||
|
1. 获取订单信息
|
||||||
|
2. 遍历代理层级(从销售店铺到顶级)
|
||||||
|
3. 每级计算成本价差收入
|
||||||
|
4. 检查一次性佣金触发条件
|
||||||
|
5. 创建 CommissionRecord
|
||||||
|
6. 更新店铺钱包余额
|
||||||
|
7. 更新订单 commission_status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 成本价差收入计算
|
||||||
|
|
||||||
|
**决策**:各级代理按自己的成本价差计算
|
||||||
|
|
||||||
|
**计算规则**:
|
||||||
|
- 终端销售代理:收入 = 售价 - 自己的成本价
|
||||||
|
- 中间层级代理:收入 = 下级的成本价 - 自己的成本价
|
||||||
|
|
||||||
|
```go
|
||||||
|
func CalculateCostDiffCommission(order *Order) []CommissionRecord {
|
||||||
|
var records []CommissionRecord
|
||||||
|
|
||||||
|
// 获取销售店铺(终端销售的代理)
|
||||||
|
sellerShop := GetShop(order.SellerShopID)
|
||||||
|
sellerCostPrice := GetCostPrice(sellerShop.ID, order.PackageID)
|
||||||
|
|
||||||
|
// 终端销售代理的收入 = 售价 - 成本价
|
||||||
|
sellerProfit := order.TotalAmount - sellerCostPrice
|
||||||
|
if sellerProfit > 0 {
|
||||||
|
records = append(records, CommissionRecord{
|
||||||
|
ShopID: sellerShop.ID,
|
||||||
|
OrderID: order.ID,
|
||||||
|
CommissionSource: "cost_diff",
|
||||||
|
Amount: sellerProfit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历上级代理链
|
||||||
|
childCostPrice := sellerCostPrice
|
||||||
|
currentShop := GetShop(sellerShop.ParentID)
|
||||||
|
|
||||||
|
for currentShop != nil {
|
||||||
|
// 获取当前店铺的成本价
|
||||||
|
myCostPrice := GetCostPrice(currentShop.ID, order.PackageID)
|
||||||
|
|
||||||
|
// 收入 = 下级成本价 - 自己成本价
|
||||||
|
profit := childCostPrice - myCostPrice
|
||||||
|
if profit > 0 {
|
||||||
|
records = append(records, CommissionRecord{
|
||||||
|
ShopID: currentShop.ID,
|
||||||
|
OrderID: order.ID,
|
||||||
|
CommissionSource: "cost_diff",
|
||||||
|
Amount: profit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动到上级
|
||||||
|
childCostPrice = myCostPrice
|
||||||
|
currentShop = GetShop(currentShop.ParentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 一次性佣金触发
|
||||||
|
|
||||||
|
**决策**:两种触发类型,每张卡/设备只触发一次
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 触发类型 A:一次性充值 ≥ 阈值
|
||||||
|
func CheckOneTimeRecharge(order *Order, threshold int64) bool {
|
||||||
|
return order.TotalAmount >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发类型 B:累计充值 ≥ 阈值
|
||||||
|
func CheckAccumulatedRecharge(card *IotCard, threshold int64) bool {
|
||||||
|
return card.AccumulatedRecharge >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并发放一次性佣金
|
||||||
|
func TriggerOneTimeCommission(order *Order, card *IotCard) {
|
||||||
|
if card.FirstCommissionPaid {
|
||||||
|
return // 已发放过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置的触发条件和金额
|
||||||
|
tier := GetCommissionTier(card.SeriesAllocationID)
|
||||||
|
|
||||||
|
// 检查触发条件
|
||||||
|
triggered := false
|
||||||
|
switch tier.TriggerType {
|
||||||
|
case "one_time_recharge":
|
||||||
|
triggered = CheckOneTimeRecharge(order, tier.ThresholdValue)
|
||||||
|
case "accumulated_recharge":
|
||||||
|
triggered = CheckAccumulatedRecharge(card, tier.ThresholdValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if triggered {
|
||||||
|
// 发放佣金
|
||||||
|
CreateCommissionRecord(...)
|
||||||
|
// 标记已发放
|
||||||
|
card.FirstCommissionPaid = true
|
||||||
|
UpdateCard(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 钱包入账
|
||||||
|
|
||||||
|
**决策**:直接入账,无冻结期
|
||||||
|
|
||||||
|
```go
|
||||||
|
func CreditCommission(record *CommissionRecord) error {
|
||||||
|
return Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. 获取店铺钱包
|
||||||
|
wallet := GetWallet("shop", record.ShopID)
|
||||||
|
|
||||||
|
// 2. 增加余额
|
||||||
|
wallet.Balance += record.Amount
|
||||||
|
UpdateWallet(wallet)
|
||||||
|
|
||||||
|
// 3. 记录余额
|
||||||
|
record.BalanceAfter = wallet.Balance
|
||||||
|
record.Status = 1 // 已入账
|
||||||
|
record.ReleasedAt = time.Now()
|
||||||
|
UpdateCommissionRecord(record)
|
||||||
|
|
||||||
|
// 4. 创建钱包交易记录
|
||||||
|
CreateWalletTransaction(...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. API 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
# 佣金记录查询
|
||||||
|
GET /api/admin/commission-records 佣金记录列表
|
||||||
|
GET /api/admin/commission-records/:id 佣金记录详情
|
||||||
|
|
||||||
|
# 佣金统计
|
||||||
|
GET /api/admin/commission-stats 佣金统计(总收入、各来源占比)
|
||||||
|
GET /api/admin/commission-stats/daily 每日佣金统计
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 风险 1:异步计算失败
|
||||||
|
|
||||||
|
**风险**:佣金计算任务失败导致佣金未发放
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- Asynq 自动重试机制
|
||||||
|
- 记录任务执行日志
|
||||||
|
- 提供手动触发补偿接口
|
||||||
|
|
||||||
|
### 风险 2:并发更新钱包余额
|
||||||
|
|
||||||
|
**风险**:多笔佣金同时入账导致余额计算错误
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 使用数据库事务
|
||||||
|
- 钱包更新使用乐观锁或悲观锁
|
||||||
|
|
||||||
|
### 风险 3:代理层级变更
|
||||||
|
|
||||||
|
**风险**:订单支付后代理层级变更,佣金计算基于哪个时间点?
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 佣金计算基于订单支付时的代理关系
|
||||||
|
- 订单中可记录销售店铺ID快照
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **梯度佣金何时统计?**
|
||||||
|
- 当前设计:本期只做配置,不做实际统计
|
||||||
|
- 待确认:是否需要定时任务统计并发放梯度奖励?
|
||||||
|
|
||||||
|
2. **累计充值是否包含当前订单?**
|
||||||
|
- 当前设计:先更新累计充值,再检查触发条件
|
||||||
|
- 待确认:是否正确?
|
||||||
|
|
||||||
|
3. **一次性佣金发放给谁?**
|
||||||
|
- 当前设计:发放给卡/设备的直接归属店铺
|
||||||
|
- 待确认:是否需要多级分佣?
|
||||||
77
openspec/changes/add-one-time-commission/proposal.md
Normal file
77
openspec/changes/add-one-time-commission/proposal.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 4 完成了订单与支付流程,现在需要实现一次性佣金计算。当终端用户购买套餐时,各级代理根据成本价差获得收入,并根据配置的触发条件(一次性充值阈值/累计充值阈值)发放一次性佣金。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**CommissionRecord 模型简化:**
|
||||||
|
- 移除冻结/解冻相关字段(unfreeze_days, unfrozen_at 等)
|
||||||
|
- 移除 rule_id(不再关联复杂规则)
|
||||||
|
- 移除 agent_id(改用 shop_id,佣金归属店铺而非个人账号)
|
||||||
|
- 保留:shop_id, order_id, amount, status, released_at, balance_after
|
||||||
|
- 新增:commission_source(成本价差/一次性佣金/梯度佣金)
|
||||||
|
- 新增:iot_card_id/device_id(关联的卡/设备)
|
||||||
|
- 新增:remark(备注)
|
||||||
|
|
||||||
|
**佣金计算逻辑:**
|
||||||
|
|
||||||
|
1. **成本价差收入**(每笔订单必触发)
|
||||||
|
- 终端销售代理:售价 - 自己的成本价 = 收入
|
||||||
|
- 中间层级代理:下级的成本价 - 自己的成本价 = 收入
|
||||||
|
- 各级代理按自己的成本价差计算,确保每级都有利润
|
||||||
|
|
||||||
|
2. **一次性佣金**(满足条件触发一次)
|
||||||
|
- 触发类型 A:一次性充值 ≥ 阈值
|
||||||
|
- 触发类型 B:累计充值 ≥ 阈值
|
||||||
|
- 每张卡/设备只触发一次
|
||||||
|
- 佣金金额从 ShopSeriesCommissionTier 获取(支持梯度)
|
||||||
|
|
||||||
|
3. **多级分佣**
|
||||||
|
- 订单支付成功后,遍历代理层级
|
||||||
|
- 每级代理计算成本价差收入
|
||||||
|
- 检查一次性佣金触发条件
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
- 佣金记录列表查询(按店铺/时间/来源筛选)
|
||||||
|
- 佣金统计(总收入、各来源占比)
|
||||||
|
- 手动触发佣金计算(补偿机制)
|
||||||
|
|
||||||
|
**业务规则:**
|
||||||
|
- 佣金直接入账到店铺钱包,无冻结期
|
||||||
|
- 一次性佣金只发放一次,通过 card.first_commission_paid 标记
|
||||||
|
- 累计充值记录在 card.accumulated_recharge
|
||||||
|
- 梯度佣金根据配置的时间范围统计销量/销售额
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `commission-calculation`: 佣金计算 - 订单支付后自动计算各级代理的成本价差收入
|
||||||
|
- `one-time-commission-trigger`: 一次性佣金触发 - 根据充值阈值触发一次性佣金发放
|
||||||
|
- `commission-record-query`: 佣金记录查询 - 查询佣金明细和统计数据
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- 无,CommissionRecord 的修改在 proposal 中说明,不需要单独 spec -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**代码影响:**
|
||||||
|
- `internal/model/commission.go` - 简化 CommissionRecord 模型
|
||||||
|
- `migrations/` - 修改 tb_commission_record 表结构
|
||||||
|
- `internal/handler/admin/` - 新增/修改佣金查询 Handler
|
||||||
|
- `internal/service/` - 新增佣金计算 Service
|
||||||
|
- `internal/store/postgres/` - 修改 CommissionRecordStore
|
||||||
|
- `internal/task/` - 佣金计算异步任务
|
||||||
|
|
||||||
|
**API 影响:**
|
||||||
|
- 修改 `/api/admin/commission-records/*` 佣金记录查询
|
||||||
|
- 新增 `/api/admin/commission-stats` 佣金统计
|
||||||
|
|
||||||
|
**数据库影响:**
|
||||||
|
- 修改表:`tb_commission_record`(简化字段、新增字段)
|
||||||
|
|
||||||
|
**依赖关系:**
|
||||||
|
- 依赖 Phase 4(add-order-payment)完成
|
||||||
|
- 依赖 Phase 2 的 ShopSeriesCommissionTier 梯度配置
|
||||||
|
- 依赖 Phase 3 的卡/设备佣金状态字段
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 订单支付后触发佣金计算
|
||||||
|
|
||||||
|
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。
|
||||||
|
|
||||||
|
#### Scenario: 支付成功触发计算
|
||||||
|
- **WHEN** 订单支付状态变为已支付
|
||||||
|
- **THEN** 系统发送佣金计算异步任务
|
||||||
|
|
||||||
|
#### Scenario: 重复支付不重复计算
|
||||||
|
- **WHEN** 订单已计算过佣金(commission_status=2)
|
||||||
|
- **THEN** 系统不重复触发计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 成本价差收入计算
|
||||||
|
|
||||||
|
系统 SHALL 为代理链上的每一级代理计算成本价差收入。终端销售代理收入 = 售价 - 成本价;中间层级代理收入 = 下级成本价 - 自己成本价。
|
||||||
|
|
||||||
|
#### Scenario: 单级代理
|
||||||
|
- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元
|
||||||
|
- **THEN** 一级代理获得 20 元(100 - 80)成本价差收入
|
||||||
|
|
||||||
|
#### Scenario: 多级代理
|
||||||
|
- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80
|
||||||
|
- **THEN** 三级获得 20 元(100 - 80),二级获得 10 元(80 - 70),一级获得 10 元(70 - 60),平台获得 10 元(60 - 50)
|
||||||
|
|
||||||
|
#### Scenario: 成本价相同
|
||||||
|
- **WHEN** 某级代理成本价等于下级成本价
|
||||||
|
- **THEN** 该级代理成本价差收入为 0,不创建佣金记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 佣金直接入账
|
||||||
|
|
||||||
|
成本价差收入 SHALL 直接入账到店铺钱包,无冻结期。
|
||||||
|
|
||||||
|
#### Scenario: 佣金入账
|
||||||
|
- **WHEN** 计算出代理的成本价差收入
|
||||||
|
- **THEN** 系统直接增加店铺钱包余额,创建佣金记录和钱包交易记录
|
||||||
|
|
||||||
|
#### Scenario: 记录入账后余额
|
||||||
|
- **WHEN** 佣金入账
|
||||||
|
- **THEN** CommissionRecord.balance_after 记录入账后的钱包余额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新累计充值金额
|
||||||
|
|
||||||
|
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。
|
||||||
|
|
||||||
|
#### Scenario: 单卡订单更新累计充值
|
||||||
|
- **WHEN** 单卡订单支付成功,金额 100 元
|
||||||
|
- **THEN** IotCard.accumulated_recharge 增加 10000 分
|
||||||
|
|
||||||
|
#### Scenario: 设备订单更新累计充值
|
||||||
|
- **WHEN** 设备订单支付成功,金额 300 元
|
||||||
|
- **THEN** Device.accumulated_recharge 增加 30000 分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: CommissionRecord 模型简化
|
||||||
|
|
||||||
|
系统 MUST 简化 CommissionRecord 模型,移除冻结相关字段。
|
||||||
|
|
||||||
|
#### Scenario: 新佣金记录字段
|
||||||
|
- **WHEN** 创建佣金记录
|
||||||
|
- **THEN** 包含:shop_id, order_id, iot_card_id, device_id, commission_source, amount, balance_after, status, released_at, remark
|
||||||
|
|
||||||
|
#### Scenario: 佣金来源类型
|
||||||
|
- **WHEN** 创建佣金记录
|
||||||
|
- **THEN** commission_source 为以下之一:cost_diff(成本价差)、one_time(一次性佣金)、tier_bonus(梯度奖励)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 查询佣金记录列表
|
||||||
|
|
||||||
|
系统 SHALL 提供佣金记录列表查询,支持按店铺、佣金来源、时间范围、状态筛选。
|
||||||
|
|
||||||
|
#### Scenario: 代理查询自己店铺的佣金
|
||||||
|
- **WHEN** 代理查询佣金记录列表
|
||||||
|
- **THEN** 系统返回该店铺的所有佣金记录
|
||||||
|
|
||||||
|
#### Scenario: 按佣金来源筛选
|
||||||
|
- **WHEN** 指定 commission_source 为 cost_diff
|
||||||
|
- **THEN** 系统只返回成本价差类型的佣金记录
|
||||||
|
|
||||||
|
#### Scenario: 按时间范围筛选
|
||||||
|
- **WHEN** 指定开始时间和结束时间
|
||||||
|
- **THEN** 系统只返回该时间范围内的佣金记录
|
||||||
|
|
||||||
|
#### Scenario: 响应包含关联信息
|
||||||
|
- **WHEN** 查询佣金记录列表
|
||||||
|
- **THEN** 每条记录包含:订单号、卡/设备信息、套餐名称
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询佣金记录详情
|
||||||
|
|
||||||
|
系统 SHALL 允许查询单条佣金记录的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询佣金详情
|
||||||
|
- **WHEN** 代理查询指定佣金记录详情
|
||||||
|
- **THEN** 系统返回完整的佣金信息和关联的订单、卡/设备信息
|
||||||
|
|
||||||
|
#### Scenario: 查询他人佣金
|
||||||
|
- **WHEN** 代理尝试查询其他店铺的佣金记录
|
||||||
|
- **THEN** 系统返回 "记录不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 佣金统计
|
||||||
|
|
||||||
|
系统 SHALL 提供佣金统计功能,包含总收入和各来源占比。
|
||||||
|
|
||||||
|
#### Scenario: 查询总收入
|
||||||
|
- **WHEN** 代理查询佣金统计
|
||||||
|
- **THEN** 系统返回总收入金额(所有已入账佣金之和)
|
||||||
|
|
||||||
|
#### Scenario: 各来源占比
|
||||||
|
- **WHEN** 代理查询佣金统计
|
||||||
|
- **THEN** 系统返回各佣金来源的金额和占比(cost_diff、one_time、tier_bonus)
|
||||||
|
|
||||||
|
#### Scenario: 按时间范围统计
|
||||||
|
- **WHEN** 指定时间范围查询统计
|
||||||
|
- **THEN** 系统只统计该时间范围内的佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 每日佣金统计
|
||||||
|
|
||||||
|
系统 SHALL 提供每日佣金统计查询。
|
||||||
|
|
||||||
|
#### Scenario: 查询每日统计
|
||||||
|
- **WHEN** 代理查询指定日期范围的每日统计
|
||||||
|
- **THEN** 系统返回每天的佣金总额和笔数
|
||||||
|
|
||||||
|
#### Scenario: 默认最近30天
|
||||||
|
- **WHEN** 代理查询每日统计不指定日期范围
|
||||||
|
- **THEN** 系统返回最近 30 天的数据
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 一次性充值触发佣金
|
||||||
|
|
||||||
|
系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。
|
||||||
|
|
||||||
|
#### Scenario: 达到一次性充值阈值
|
||||||
|
- **WHEN** 订单金额 500 元,配置阈值 300 元,该卡未发放过一次性佣金
|
||||||
|
- **THEN** 系统发放一次性佣金,标记卡的 first_commission_paid 为 true
|
||||||
|
|
||||||
|
#### Scenario: 未达到阈值
|
||||||
|
- **WHEN** 订单金额 200 元,配置阈值 300 元
|
||||||
|
- **THEN** 系统不发放一次性佣金
|
||||||
|
|
||||||
|
#### Scenario: 已发放过一次性佣金
|
||||||
|
- **WHEN** 订单金额 500 元,但卡的 first_commission_paid 已为 true
|
||||||
|
- **THEN** 系统不重复发放一次性佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 累计充值触发佣金
|
||||||
|
|
||||||
|
系统 SHALL 支持"累计充值"触发条件:当卡/设备的累计充值金额 ≥ 配置阈值时触发一次性佣金。
|
||||||
|
|
||||||
|
#### Scenario: 累计达到阈值
|
||||||
|
- **WHEN** 卡之前累计充值 200 元,本次充值 150 元,配置阈值 300 元
|
||||||
|
- **THEN** 累计 350 元 ≥ 300 元,系统发放一次性佣金
|
||||||
|
|
||||||
|
#### Scenario: 累计未达到阈值
|
||||||
|
- **WHEN** 卡之前累计充值 100 元,本次充值 100 元,配置阈值 300 元
|
||||||
|
- **THEN** 累计 200 元 < 300 元,系统不发放一次性佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 一次性佣金只发放一次
|
||||||
|
|
||||||
|
每张卡/设备的一次性佣金 SHALL 只发放一次,通过 first_commission_paid 字段控制。
|
||||||
|
|
||||||
|
#### Scenario: 首次触发
|
||||||
|
- **WHEN** 首次满足触发条件
|
||||||
|
- **THEN** 发放佣金,设置 first_commission_paid = true
|
||||||
|
|
||||||
|
#### Scenario: 再次满足条件
|
||||||
|
- **WHEN** 再次满足触发条件但 first_commission_paid 已为 true
|
||||||
|
- **THEN** 不发放佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 一次性佣金配置获取
|
||||||
|
|
||||||
|
一次性佣金的触发条件和金额 SHALL 从 ShopSeriesAllocation 配置获取。
|
||||||
|
|
||||||
|
#### Scenario: 获取触发条件和金额
|
||||||
|
- **WHEN** 触发一次性佣金检查
|
||||||
|
- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger(触发类型)、one_time_commission_threshold(阈值)、one_time_commission_amount(金额)
|
||||||
|
|
||||||
|
#### Scenario: 无一次性佣金配置
|
||||||
|
- **WHEN** 卡关联的系列分配未配置一次性佣金(one_time_commission_amount = 0)
|
||||||
|
- **THEN** 不发放一次性佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 一次性佣金发放对象
|
||||||
|
|
||||||
|
一次性佣金 SHALL 发放给卡/设备的直接归属店铺。
|
||||||
|
|
||||||
|
#### Scenario: 发放给归属店铺
|
||||||
|
- **WHEN** 卡归属店铺 A,触发一次性佣金
|
||||||
|
- **THEN** 佣金入账到店铺 A 的钱包
|
||||||
99
openspec/changes/add-one-time-commission/tasks.md
Normal file
99
openspec/changes/add-one-time-commission/tasks.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
## 1. CommissionRecord 模型简化
|
||||||
|
|
||||||
|
- [ ] 1.1 修改 `internal/model/commission.go`,简化 CommissionRecord 结构
|
||||||
|
- [ ] 1.2 删除冻结相关字段(unfrozen_at 等)
|
||||||
|
- [ ] 1.3 删除 rule_id、agent_id 字段
|
||||||
|
- [ ] 1.4 新增 commission_source 字段(varchar: cost_diff, one_time, tier_bonus)
|
||||||
|
- [ ] 1.5 新增 iot_card_id、device_id 字段
|
||||||
|
- [ ] 1.6 新增 remark 字段
|
||||||
|
|
||||||
|
## 2. 数据库迁移
|
||||||
|
|
||||||
|
- [ ] 2.1 创建迁移文件,修改 tb_commission_record 表结构
|
||||||
|
- [ ] 2.2 删除废弃字段
|
||||||
|
- [ ] 2.3 添加新字段
|
||||||
|
- [ ] 2.4 添加索引(shop_id, order_id, commission_source, iot_card_id, device_id)
|
||||||
|
- [ ] 2.5 本地执行迁移验证
|
||||||
|
|
||||||
|
## 3. DTO 更新
|
||||||
|
|
||||||
|
- [ ] 3.1 更新 `internal/model/dto/commission.go`,调整 CommissionRecordResponse
|
||||||
|
- [ ] 3.2 定义 CommissionRecordListRequest(shop_id, commission_source, start_time, end_time, status)
|
||||||
|
- [ ] 3.3 定义 CommissionStatsResponse(total_amount, cost_diff_amount, one_time_amount, tier_bonus_amount)
|
||||||
|
- [ ] 3.4 定义 DailyCommissionStatsResponse
|
||||||
|
|
||||||
|
## 4. CommissionRecord Store 更新
|
||||||
|
|
||||||
|
- [ ] 4.1 更新 `internal/store/postgres/commission_record_store.go`,适配新模型
|
||||||
|
- [ ] 4.2 更新 Create 方法
|
||||||
|
- [ ] 4.3 更新 List 方法支持新筛选条件
|
||||||
|
- [ ] 4.4 实现 GetStats 方法(统计总收入和各来源占比)
|
||||||
|
- [ ] 4.5 实现 GetDailyStats 方法(每日统计)
|
||||||
|
|
||||||
|
## 5. 佣金计算 Service
|
||||||
|
|
||||||
|
- [ ] 5.1 创建 `internal/service/commission_calculation/service.go`
|
||||||
|
- [ ] 5.2 实现 CalculateCommission 主方法(协调整体计算流程)
|
||||||
|
- [ ] 5.3 实现 CalculateCostDiffCommission 方法(遍历代理层级计算成本价差)
|
||||||
|
- [ ] 5.4 实现 CheckAndTriggerOneTimeCommission 方法(检查一次性佣金触发条件)
|
||||||
|
- [ ] 5.5 实现 CreditCommission 方法(佣金入账到钱包)
|
||||||
|
- [ ] 5.6 实现 UpdateAccumulatedRecharge 方法(更新累计充值金额)
|
||||||
|
|
||||||
|
## 6. 异步任务
|
||||||
|
|
||||||
|
- [ ] 6.1 创建 `internal/task/commission_calculation.go`,定义佣金计算任务类型
|
||||||
|
- [ ] 6.2 实现任务处理函数 HandleCommissionCalculation
|
||||||
|
- [ ] 6.3 在 OrderService.WalletPay 中添加任务发送逻辑
|
||||||
|
- [ ] 6.4 在支付回调处理中添加任务发送逻辑
|
||||||
|
- [ ] 6.5 在 Worker 中注册任务处理器
|
||||||
|
|
||||||
|
## 7. 佣金查询 Service
|
||||||
|
|
||||||
|
- [ ] 7.1 更新 `internal/service/my_commission/service.go`,适配新模型
|
||||||
|
- [ ] 7.2 实现 List 方法
|
||||||
|
- [ ] 7.3 实现 Get 方法
|
||||||
|
- [ ] 7.4 实现 GetStats 方法
|
||||||
|
- [ ] 7.5 实现 GetDailyStats 方法
|
||||||
|
|
||||||
|
## 8. Handler 更新
|
||||||
|
|
||||||
|
- [ ] 8.1 更新 `internal/handler/admin/my_commission.go`,适配新接口
|
||||||
|
- [ ] 8.2 实现 List 接口
|
||||||
|
- [ ] 8.3 实现 Get 接口
|
||||||
|
- [ ] 8.4 实现 GetStats 接口
|
||||||
|
- [ ] 8.5 实现 GetDailyStats 接口
|
||||||
|
|
||||||
|
## 9. Bootstrap 注册
|
||||||
|
|
||||||
|
- [ ] 9.1 在 services.go 中注册 CommissionCalculationService
|
||||||
|
- [ ] 9.2 确认 MyCommissionService 注册正确
|
||||||
|
|
||||||
|
## 10. 路由更新
|
||||||
|
|
||||||
|
- [ ] 10.1 确认 `/api/admin/my-commission/records` 路由
|
||||||
|
- [ ] 10.2 添加 `/api/admin/my-commission/stats` 路由
|
||||||
|
- [ ] 10.3 添加 `/api/admin/my-commission/daily-stats` 路由
|
||||||
|
|
||||||
|
## 11. 文档生成器更新
|
||||||
|
|
||||||
|
- [ ] 11.1 更新 docs.go 和 gendocs/main.go
|
||||||
|
- [ ] 11.2 执行文档生成验证
|
||||||
|
|
||||||
|
## 12. 测试
|
||||||
|
|
||||||
|
- [ ] 12.1 CommissionRecordStore 单元测试
|
||||||
|
- [ ] 12.2 CommissionCalculationService 单元测试(覆盖成本价差计算)
|
||||||
|
- [ ] 12.3 一次性佣金触发逻辑测试(覆盖各种触发条件)
|
||||||
|
- [ ] 12.4 佣金入账事务测试
|
||||||
|
- [ ] 12.5 异步任务测试
|
||||||
|
- [ ] 12.6 佣金统计 API 集成测试
|
||||||
|
- [ ] 12.7 执行 `go test ./...` 确认通过
|
||||||
|
|
||||||
|
## 13. 最终验证
|
||||||
|
|
||||||
|
- [ ] 13.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [ ] 13.2 启动服务,创建订单并支付
|
||||||
|
- [ ] 13.3 验证佣金记录正确创建
|
||||||
|
- [ ] 13.4 验证钱包余额正确增加
|
||||||
|
- [ ] 13.5 验证一次性佣金触发逻辑
|
||||||
|
- [ ] 13.6 验证佣金统计数据正确
|
||||||
2
openspec/changes/add-order-payment/.openspec.yaml
Normal file
2
openspec/changes/add-order-payment/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-27
|
||||||
237
openspec/changes/add-order-payment/design.md
Normal file
237
openspec/changes/add-order-payment/design.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户必须通过购买套餐来充值,不能直接给钱包充值。这确保每笔资金流入都有对应的套餐购买记录。
|
||||||
|
|
||||||
|
**三类买家**:
|
||||||
|
1. 个人客户:通过 H5/小程序购买,使用卡/设备钱包或第三方支付
|
||||||
|
2. 代理商:通过后台购买,使用店铺钱包
|
||||||
|
3. 企业客户:后台直接分配套餐,不走订单流程(本期不做)
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 设计订单和订单明细模型
|
||||||
|
- 实现套餐购买订单创建流程
|
||||||
|
- 实现钱包支付和第三方支付回调
|
||||||
|
- 验证购买权限(卡/设备的套餐系列关联)
|
||||||
|
- 套餐生效后更新流量额度
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不实现企业客户的套餐分配(后台直接操作)
|
||||||
|
- 不实现第三方支付发起(仅处理回调)
|
||||||
|
- 不实现佣金计算(Phase 5)
|
||||||
|
- 不实现退款流程
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 订单模型设计
|
||||||
|
|
||||||
|
**决策**:Order + OrderItem 两级结构
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Order 订单模型
|
||||||
|
type Order struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
OrderNo string // 订单号(唯一)
|
||||||
|
OrderType string // 订单类型: single_card-单卡购买 device-设备购买
|
||||||
|
BuyerType string // 买家类型: personal-个人客户 agent-代理商
|
||||||
|
BuyerID uint // 买家ID(个人客户ID或店铺ID)
|
||||||
|
IotCardID uint // IoT卡ID(单卡购买时)
|
||||||
|
DeviceID uint // 设备ID(设备购买时)
|
||||||
|
TotalAmount int64 // 订单总金额(分)
|
||||||
|
PaymentMethod string // 支付方式: wallet-钱包 wechat-微信 alipay-支付宝
|
||||||
|
PaymentStatus int // 支付状态: 1-待支付 2-已支付 3-已取消 4-已退款
|
||||||
|
PaidAt *time.Time // 支付时间
|
||||||
|
CommissionStatus int // 佣金状态: 1-待计算 2-已计算
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderItem 订单明细模型
|
||||||
|
type OrderItem struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
OrderID uint // 订单ID
|
||||||
|
PackageID uint // 套餐ID
|
||||||
|
PackageName string // 套餐名称(快照)
|
||||||
|
Quantity int // 数量(通常为1)
|
||||||
|
UnitPrice int64 // 单价(分)
|
||||||
|
Amount int64 // 小计(分)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 支持一个订单购买多个套餐(虽然初期可能只买一个)
|
||||||
|
- 快照套餐名称,避免套餐修改影响历史订单
|
||||||
|
- 佣金状态用于 Phase 5 的异步佣金计算
|
||||||
|
|
||||||
|
### 2. 订单号生成规则
|
||||||
|
|
||||||
|
**决策**:时间戳 + 随机数
|
||||||
|
|
||||||
|
```
|
||||||
|
格式:ORD{YYYYMMDDHHMMSS}{6位随机数}
|
||||||
|
示例:ORD20260127143052123456
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 可读性好,包含时间信息
|
||||||
|
- 随机数避免并发冲突
|
||||||
|
- 长度固定,便于存储和展示
|
||||||
|
|
||||||
|
### 3. 购买价格确定
|
||||||
|
|
||||||
|
**决策**:使用 Package.suggested_retail_price 作为统一售价
|
||||||
|
|
||||||
|
```
|
||||||
|
个人客户购买:支付金额 = Package.suggested_retail_price
|
||||||
|
代理为店铺购买:支付金额 = 代理的成本价(用于囤货/测试)
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 简化首期实现,所有终端用户统一售价
|
||||||
|
- 代理的利润 = suggested_retail_price - 成本价
|
||||||
|
- 后续如需支持代理自定义售价,可扩展 ShopPackageAllocation 增加 retail_price 字段
|
||||||
|
|
||||||
|
**非首期功能**:
|
||||||
|
- 代理自定义售价
|
||||||
|
- 促销折扣价
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 购买权限验证
|
||||||
|
|
||||||
|
**决策**:多层验证
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidatePurchase(card/device, packageID) error {
|
||||||
|
// 1. 获取卡/设备的 series_allocation_id
|
||||||
|
allocationID := card.SeriesAllocationID
|
||||||
|
if allocationID == 0 {
|
||||||
|
return "该卡未关联套餐系列"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取套餐信息
|
||||||
|
pkg := GetPackage(packageID)
|
||||||
|
|
||||||
|
// 3. 验证套餐属于该系列
|
||||||
|
allocation := GetAllocation(allocationID)
|
||||||
|
if pkg.SeriesID != allocation.SeriesID {
|
||||||
|
return "该套餐不在可购买范围内"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证套餐状态
|
||||||
|
if pkg.Status != 1 || pkg.ShelfStatus != 1 {
|
||||||
|
return "该套餐已下架"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 支付流程
|
||||||
|
|
||||||
|
**决策**:同步钱包支付 + 异步第三方支付
|
||||||
|
|
||||||
|
```
|
||||||
|
钱包支付流程:
|
||||||
|
1. 创建订单(待支付)
|
||||||
|
2. 检查钱包余额
|
||||||
|
3. 扣减钱包余额(事务)
|
||||||
|
4. 更新订单状态(已支付)
|
||||||
|
5. 套餐生效
|
||||||
|
6. 触发佣金计算(异步)
|
||||||
|
|
||||||
|
第三方支付流程:
|
||||||
|
1. 创建订单(待支付)
|
||||||
|
2. 返回订单信息,前端发起支付
|
||||||
|
3. 支付回调更新订单状态
|
||||||
|
4. 套餐生效
|
||||||
|
5. 触发佣金计算(异步)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 套餐生效逻辑
|
||||||
|
|
||||||
|
**决策**:创建 PackageUsage 记录
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ActivatePackage(order *Order) {
|
||||||
|
for _, item := range order.Items {
|
||||||
|
pkg := GetPackage(item.PackageID)
|
||||||
|
|
||||||
|
usage := &PackageUsage{
|
||||||
|
OrderID: order.ID,
|
||||||
|
PackageID: item.PackageID,
|
||||||
|
UsageType: order.OrderType, // single_card 或 device
|
||||||
|
IotCardID: order.IotCardID,
|
||||||
|
DeviceID: order.DeviceID,
|
||||||
|
DataLimitMB: pkg.DataAmountMB,
|
||||||
|
ActivatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().AddDate(0, pkg.DurationMonths, 0),
|
||||||
|
Status: 1, // 生效中
|
||||||
|
}
|
||||||
|
CreatePackageUsage(usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. API 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
# 订单管理(后台)
|
||||||
|
POST /api/admin/orders 代理创建订单
|
||||||
|
GET /api/admin/orders 订单列表
|
||||||
|
GET /api/admin/orders/:id 订单详情
|
||||||
|
POST /api/admin/orders/:id/cancel 取消订单
|
||||||
|
|
||||||
|
# 订单操作(H5/个人客户)
|
||||||
|
POST /api/h5/orders 个人客户创建订单
|
||||||
|
GET /api/h5/orders 我的订单列表
|
||||||
|
GET /api/h5/orders/:id 订单详情
|
||||||
|
POST /api/h5/orders/:id/pay 钱包支付
|
||||||
|
|
||||||
|
# 支付回调
|
||||||
|
POST /api/callback/wechat-pay 微信支付回调
|
||||||
|
POST /api/callback/alipay 支付宝回调
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 风险 1:并发支付
|
||||||
|
|
||||||
|
**风险**:同一订单被重复支付
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 支付前检查订单状态
|
||||||
|
- 使用数据库乐观锁或 Redis 分布式锁
|
||||||
|
- 支付回调幂等处理
|
||||||
|
|
||||||
|
### 风险 2:套餐生效失败
|
||||||
|
|
||||||
|
**风险**:支付成功但套餐生效失败
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 使用事务保证支付和套餐生效原子性
|
||||||
|
- 失败时自动退款或人工处理
|
||||||
|
- 记录详细日志便于排查
|
||||||
|
|
||||||
|
### 风险 3:价格不一致
|
||||||
|
|
||||||
|
**风险**:下单时和支付时套餐价格变化
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 订单中存储下单时的价格快照
|
||||||
|
- 支付时使用订单金额,不重新查询套餐价格
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **订单超时取消?**
|
||||||
|
- 当前设计:不自动取消
|
||||||
|
- 待确认:是否需要定时任务取消超时未支付订单?
|
||||||
|
|
||||||
|
2. **部分支付?**
|
||||||
|
- 当前设计:不支持
|
||||||
|
- 待确认:是否需要支持钱包余额不足时组合支付?
|
||||||
|
|
||||||
|
3. **代理为终端用户购买?**
|
||||||
|
- 当前设计:代理只能为自己店铺购买
|
||||||
|
- 待确认:是否需要代理帮终端用户购买的场景?
|
||||||
70
openspec/changes/add-order-payment/proposal.md
Normal file
70
openspec/changes/add-order-payment/proposal.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户不能直接给钱包充值,必须通过购买套餐来充值。这样每笔充值都有对应的套餐购买记录,便于佣金计算和业务追踪。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**新增模型:**
|
||||||
|
- `Order`:订单模型,记录套餐购买信息
|
||||||
|
- `OrderItem`:订单明细(支持一个订单购买多个套餐)
|
||||||
|
|
||||||
|
**Order 核心字段:**
|
||||||
|
- 订单号、订单类型(单卡购买/设备购买)
|
||||||
|
- 买家信息(个人客户/代理店铺)
|
||||||
|
- 关联的卡/设备 ID
|
||||||
|
- 支付金额、支付状态、支付方式
|
||||||
|
- 佣金计算状态
|
||||||
|
|
||||||
|
**强充业务流程:**
|
||||||
|
1. 用户选择套餐,创建订单
|
||||||
|
2. 用户支付(微信/支付宝/钱包余额)
|
||||||
|
3. 支付成功后,套餐生效,流量额度增加
|
||||||
|
4. 触发佣金计算(Phase 5)
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
- 创建套餐购买订单
|
||||||
|
- 查询订单列表/详情
|
||||||
|
- 订单支付(钱包支付)
|
||||||
|
- 支付回调处理
|
||||||
|
- 取消订单
|
||||||
|
|
||||||
|
**业务规则:**
|
||||||
|
- 只能购买卡/设备关联的套餐系列下的套餐
|
||||||
|
- 只能购买已上架且启用的套餐
|
||||||
|
- 设备购买时,套餐分配给设备下所有卡(流量共享)
|
||||||
|
- 订单金额 = 套餐零售价(代理设置的售价)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `order-management`: 订单管理 - 创建/查询/取消套餐购买订单
|
||||||
|
- `order-payment`: 订单支付 - 钱包支付、第三方支付回调处理
|
||||||
|
- `package-purchase-validation`: 套餐购买验证 - 验证卡/设备是否有权购买指定套餐
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- 无 -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**代码影响:**
|
||||||
|
- `internal/model/` - 新增 order.go(Order, OrderItem)
|
||||||
|
- `migrations/` - 创建 tb_order, tb_order_item 表
|
||||||
|
- `internal/handler/` - 新增订单 Handler(admin + app/h5)
|
||||||
|
- `internal/service/` - 新增订单 Service
|
||||||
|
- `internal/store/postgres/` - 新增订单 Store
|
||||||
|
- `internal/model/dto/` - 新增订单相关 DTO
|
||||||
|
|
||||||
|
**API 影响:**
|
||||||
|
- 新增 `/api/admin/orders/*` 后台订单管理
|
||||||
|
- 新增 `/api/h5/orders/*` H5 端订单操作
|
||||||
|
- 新增 `/api/app/orders/*` 个人客户订单操作
|
||||||
|
|
||||||
|
**数据库影响:**
|
||||||
|
- 新增表:`tb_order`, `tb_order_item`
|
||||||
|
|
||||||
|
**依赖关系:**
|
||||||
|
- 依赖 Phase 3(add-card-device-series-bindng)完成
|
||||||
|
- Phase 5(一次性佣金)依赖本期
|
||||||
|
- 依赖现有 Wallet 模型
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐购买订单
|
||||||
|
|
||||||
|
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户创建单卡订单
|
||||||
|
- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐
|
||||||
|
- **THEN** 系统创建订单,状态为待支付,返回订单信息
|
||||||
|
|
||||||
|
#### Scenario: 个人客户创建设备订单
|
||||||
|
- **WHEN** 个人客户为自己的设备创建订单
|
||||||
|
- **THEN** 系统创建订单,订单类型为设备购买
|
||||||
|
|
||||||
|
#### Scenario: 代理创建订单
|
||||||
|
- **WHEN** 代理为店铺关联的卡/设备创建订单
|
||||||
|
- **THEN** 系统创建订单,买家类型为代理商,买家ID为店铺ID
|
||||||
|
|
||||||
|
#### Scenario: 套餐不在可购买范围
|
||||||
|
- **WHEN** 买家尝试购买不在关联系列下的套餐
|
||||||
|
- **THEN** 系统返回错误 "该套餐不在可购买范围内"
|
||||||
|
|
||||||
|
#### Scenario: 套餐已下架
|
||||||
|
- **WHEN** 买家尝试购买已下架的套餐
|
||||||
|
- **THEN** 系统返回错误 "该套餐已下架"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询订单列表
|
||||||
|
|
||||||
|
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、时间范围筛选。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户查询自己的订单
|
||||||
|
- **WHEN** 个人客户查询订单列表
|
||||||
|
- **THEN** 系统只返回该客户的订单
|
||||||
|
|
||||||
|
#### Scenario: 代理查询店铺订单
|
||||||
|
- **WHEN** 代理查询订单列表
|
||||||
|
- **THEN** 系统返回该店铺及下级店铺的订单
|
||||||
|
|
||||||
|
#### Scenario: 按支付状态筛选
|
||||||
|
- **WHEN** 指定支付状态筛选
|
||||||
|
- **THEN** 系统只返回匹配状态的订单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询订单详情
|
||||||
|
|
||||||
|
系统 SHALL 允许买家查询订单详情,包含订单明细。
|
||||||
|
|
||||||
|
#### Scenario: 查询订单详情
|
||||||
|
- **WHEN** 买家查询指定订单详情
|
||||||
|
- **THEN** 系统返回订单信息和订单明细列表
|
||||||
|
|
||||||
|
#### Scenario: 查询他人订单
|
||||||
|
- **WHEN** 买家尝试查询不属于自己的订单
|
||||||
|
- **THEN** 系统返回 "订单不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 取消订单
|
||||||
|
|
||||||
|
系统 SHALL 允许买家取消未支付的订单。
|
||||||
|
|
||||||
|
#### Scenario: 取消待支付订单
|
||||||
|
- **WHEN** 买家取消一个待支付的订单
|
||||||
|
- **THEN** 系统更新订单状态为已取消
|
||||||
|
|
||||||
|
#### Scenario: 取消已支付订单
|
||||||
|
- **WHEN** 买家尝试取消已支付的订单
|
||||||
|
- **THEN** 系统返回错误 "已支付订单无法取消"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 订单号生成
|
||||||
|
|
||||||
|
系统生成的订单号 MUST 全局唯一,格式为 ORD{YYYYMMDDHHMMSS}{6位随机数}。
|
||||||
|
|
||||||
|
#### Scenario: 订单号格式
|
||||||
|
- **WHEN** 创建新订单
|
||||||
|
- **THEN** 订单号格式为 ORD + 14位时间戳 + 6位随机数
|
||||||
|
|
||||||
|
#### Scenario: 订单号唯一
|
||||||
|
- **WHEN** 并发创建多个订单
|
||||||
|
- **THEN** 每个订单的订单号都唯一
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 钱包支付
|
||||||
|
|
||||||
|
系统 SHALL 支持使用钱包余额支付订单。支付成功后 MUST 扣减钱包余额并激活套餐。
|
||||||
|
|
||||||
|
#### Scenario: 钱包余额充足
|
||||||
|
- **WHEN** 买家使用钱包支付,余额充足
|
||||||
|
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,创建套餐使用记录
|
||||||
|
|
||||||
|
#### Scenario: 钱包余额不足
|
||||||
|
- **WHEN** 买家使用钱包支付,余额不足
|
||||||
|
- **THEN** 系统返回错误 "钱包余额不足"
|
||||||
|
|
||||||
|
#### Scenario: 订单已支付
|
||||||
|
- **WHEN** 买家尝试支付已支付的订单
|
||||||
|
- **THEN** 系统返回错误 "订单已支付"
|
||||||
|
|
||||||
|
#### Scenario: 订单已取消
|
||||||
|
- **WHEN** 买家尝试支付已取消的订单
|
||||||
|
- **THEN** 系统返回错误 "订单已取消"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 第三方支付回调
|
||||||
|
|
||||||
|
系统 SHALL 处理微信支付和支付宝的支付回调。回调处理 MUST 幂等。
|
||||||
|
|
||||||
|
#### Scenario: 微信支付成功回调
|
||||||
|
- **WHEN** 收到微信支付成功回调
|
||||||
|
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 支付宝成功回调
|
||||||
|
- **WHEN** 收到支付宝支付成功回调
|
||||||
|
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 重复回调
|
||||||
|
- **WHEN** 收到已处理订单的重复回调
|
||||||
|
- **THEN** 系统返回成功响应,不重复处理
|
||||||
|
|
||||||
|
#### Scenario: 签名验证失败
|
||||||
|
- **WHEN** 回调签名验证失败
|
||||||
|
- **THEN** 系统拒绝处理,返回失败响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 套餐激活
|
||||||
|
|
||||||
|
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。
|
||||||
|
|
||||||
|
#### Scenario: 单卡套餐激活
|
||||||
|
- **WHEN** 单卡订单支付成功
|
||||||
|
- **THEN** 系统创建 PackageUsage,usage_type 为 single_card,关联 iot_card_id
|
||||||
|
|
||||||
|
#### Scenario: 设备套餐激活
|
||||||
|
- **WHEN** 设备订单支付成功
|
||||||
|
- **THEN** 系统创建 PackageUsage,usage_type 为 device,关联 device_id
|
||||||
|
|
||||||
|
#### Scenario: 套餐有效期计算
|
||||||
|
- **WHEN** 套餐激活
|
||||||
|
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 支付事务保证
|
||||||
|
|
||||||
|
钱包支付 MUST 在事务中完成:余额扣减、订单状态更新、套餐激活。任一步骤失败则全部回滚。
|
||||||
|
|
||||||
|
#### Scenario: 事务成功
|
||||||
|
- **WHEN** 所有步骤成功
|
||||||
|
- **THEN** 事务提交,支付完成
|
||||||
|
|
||||||
|
#### Scenario: 余额扣减后套餐激活失败
|
||||||
|
- **WHEN** 余额扣减成功但套餐激活失败
|
||||||
|
- **THEN** 事务回滚,余额恢复,订单状态不变
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 验证卡/设备的套餐购买权限
|
||||||
|
|
||||||
|
创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。
|
||||||
|
|
||||||
|
#### Scenario: 卡有套餐系列关联
|
||||||
|
- **WHEN** 卡的 series_allocation_id 有值,且套餐属于该系列
|
||||||
|
- **THEN** 验证通过
|
||||||
|
|
||||||
|
#### Scenario: 卡无套餐系列关联
|
||||||
|
- **WHEN** 卡的 series_allocation_id 为空
|
||||||
|
- **THEN** 验证失败,返回 "该卡未关联套餐系列"
|
||||||
|
|
||||||
|
#### Scenario: 套餐不属于关联系列
|
||||||
|
- **WHEN** 套餐的 series_id 与卡关联的分配系列不匹配
|
||||||
|
- **THEN** 验证失败,返回 "该套餐不在可购买范围内"
|
||||||
|
|
||||||
|
#### Scenario: 系列分配已禁用
|
||||||
|
- **WHEN** 卡关联的系列分配状态为禁用
|
||||||
|
- **THEN** 验证失败,返回 "套餐系列已禁用"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 验证套餐状态
|
||||||
|
|
||||||
|
创建订单前系统 MUST 验证套餐处于可购买状态。
|
||||||
|
|
||||||
|
#### Scenario: 套餐启用且上架
|
||||||
|
- **WHEN** 套餐 status=1 且 shelf_status=1
|
||||||
|
- **THEN** 验证通过
|
||||||
|
|
||||||
|
#### Scenario: 套餐已禁用
|
||||||
|
- **WHEN** 套餐 status=2
|
||||||
|
- **THEN** 验证失败,返回 "套餐已禁用"
|
||||||
|
|
||||||
|
#### Scenario: 套餐已下架
|
||||||
|
- **WHEN** 套餐 shelf_status=2
|
||||||
|
- **THEN** 验证失败,返回 "套餐已下架"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 获取购买价格
|
||||||
|
|
||||||
|
系统 MUST 根据买家身份返回正确的购买价格。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户购买
|
||||||
|
- **WHEN** 个人客户购买套餐
|
||||||
|
- **THEN** 使用 Package.suggested_retail_price 作为支付金额
|
||||||
|
|
||||||
|
#### Scenario: 代理为店铺购买
|
||||||
|
- **WHEN** 代理为自己店铺购买套餐(囤货/测试)
|
||||||
|
- **THEN** 使用代理的成本价作为支付金额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 设备购买时的卡验证
|
||||||
|
|
||||||
|
设备购买套餐时 MUST 使用设备的 series_allocation_id 验证,不使用设备下单卡的关联。
|
||||||
|
|
||||||
|
#### Scenario: 设备有系列关联
|
||||||
|
- **WHEN** 设备的 series_allocation_id 有值
|
||||||
|
- **THEN** 使用设备的关联验证购买权限
|
||||||
|
|
||||||
|
#### Scenario: 设备无系列关联
|
||||||
|
- **WHEN** 设备的 series_allocation_id 为空
|
||||||
|
- **THEN** 验证失败,返回 "该设备未关联套餐系列"
|
||||||
105
openspec/changes/add-order-payment/tasks.md
Normal file
105
openspec/changes/add-order-payment/tasks.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
## 1. 新增模型
|
||||||
|
|
||||||
|
- [ ] 1.1 创建 `internal/model/order.go`,定义 Order 模型(order_no, order_type, buyer_type, buyer_id, iot_card_id, device_id, total_amount, payment_method, payment_status, paid_at, commission_status)
|
||||||
|
- [ ] 1.2 定义 OrderItem 模型(order_id, package_id, package_name, quantity, unit_price, amount)
|
||||||
|
|
||||||
|
## 2. 数据库迁移
|
||||||
|
|
||||||
|
- [ ] 2.1 创建迁移文件,创建 tb_order 表
|
||||||
|
- [ ] 2.2 创建 tb_order_item 表
|
||||||
|
- [ ] 2.3 添加索引(order_no 唯一索引, buyer_type+buyer_id, payment_status, iot_card_id, device_id)
|
||||||
|
- [ ] 2.4 本地执行迁移验证
|
||||||
|
|
||||||
|
## 3. 订单 DTO
|
||||||
|
|
||||||
|
- [ ] 3.1 创建 `internal/model/dto/order.go`,定义 CreateOrderRequest(order_type, iot_card_id/device_id, package_ids)
|
||||||
|
- [ ] 3.2 定义 OrderListRequest(payment_status, order_type, start_time, end_time, page, page_size)
|
||||||
|
- [ ] 3.3 定义 PayOrderRequest(payment_method)
|
||||||
|
- [ ] 3.4 定义 OrderResponse(包含订单信息和明细列表)
|
||||||
|
- [ ] 3.5 定义 OrderItemResponse
|
||||||
|
|
||||||
|
## 4. 订单 Store
|
||||||
|
|
||||||
|
- [ ] 4.1 创建 `internal/store/postgres/order_store.go`,实现 Create 方法(事务创建订单和明细)
|
||||||
|
- [ ] 4.2 实现 GetByID 方法(含明细)
|
||||||
|
- [ ] 4.3 实现 GetByOrderNo 方法
|
||||||
|
- [ ] 4.4 实现 Update 方法
|
||||||
|
- [ ] 4.5 实现 List 方法(支持分页和筛选)
|
||||||
|
- [ ] 4.6 实现 UpdatePaymentStatus 方法
|
||||||
|
- [ ] 4.7 实现 GenerateOrderNo 方法(ORD + 时间戳 + 随机数)
|
||||||
|
|
||||||
|
## 5. 订单明细 Store
|
||||||
|
|
||||||
|
- [ ] 5.1 创建 `internal/store/postgres/order_item_store.go`,实现 BatchCreate 方法
|
||||||
|
- [ ] 5.2 实现 ListByOrderID 方法
|
||||||
|
|
||||||
|
## 6. 购买验证 Service
|
||||||
|
|
||||||
|
- [ ] 6.1 创建 `internal/service/purchase_validation/service.go`,实现 ValidateCardPurchase 方法
|
||||||
|
- [ ] 6.2 实现 ValidateDevicePurchase 方法
|
||||||
|
- [ ] 6.3 实现 ValidatePackageStatus 方法
|
||||||
|
- [ ] 6.4 实现 GetPurchasePrice 方法(根据买家身份返回价格)
|
||||||
|
|
||||||
|
## 7. 订单 Service
|
||||||
|
|
||||||
|
- [ ] 7.1 创建 `internal/service/order/service.go`,实现 Create 方法(验证权限、创建订单和明细)
|
||||||
|
- [ ] 7.2 实现 Get 方法
|
||||||
|
- [ ] 7.3 实现 List 方法
|
||||||
|
- [ ] 7.4 实现 Cancel 方法(验证状态、更新为已取消)
|
||||||
|
- [ ] 7.5 实现 WalletPay 方法(事务:扣减余额、更新状态、激活套餐)
|
||||||
|
- [ ] 7.6 实现 HandlePaymentCallback 方法(验证签名、幂等处理、激活套餐)
|
||||||
|
- [ ] 7.7 实现 ActivatePackage 辅助方法(创建 PackageUsage 记录)
|
||||||
|
|
||||||
|
## 8. 订单 Handler(后台)
|
||||||
|
|
||||||
|
- [ ] 8.1 创建 `internal/handler/admin/order.go`,实现 Create 接口
|
||||||
|
- [ ] 8.2 实现 Get 接口
|
||||||
|
- [ ] 8.3 实现 List 接口
|
||||||
|
- [ ] 8.4 实现 Cancel 接口
|
||||||
|
|
||||||
|
## 9. 订单 Handler(H5)
|
||||||
|
|
||||||
|
- [ ] 9.1 创建 `internal/handler/h5/order.go`,实现 Create 接口
|
||||||
|
- [ ] 9.2 实现 Get 接口
|
||||||
|
- [ ] 9.3 实现 List 接口
|
||||||
|
- [ ] 9.4 实现 WalletPay 接口
|
||||||
|
|
||||||
|
## 10. 支付回调 Handler
|
||||||
|
|
||||||
|
- [ ] 10.1 创建 `internal/handler/callback/payment.go`,实现 WechatPayCallback 接口
|
||||||
|
- [ ] 10.2 实现 AlipayCallback 接口
|
||||||
|
|
||||||
|
## 11. Bootstrap 注册
|
||||||
|
|
||||||
|
- [ ] 11.1 在 stores.go 中注册 OrderStore, OrderItemStore
|
||||||
|
- [ ] 11.2 在 services.go 中注册 PurchaseValidationService, OrderService
|
||||||
|
- [ ] 11.3 在 handlers.go 中注册 AdminOrderHandler, H5OrderHandler, PaymentCallbackHandler
|
||||||
|
|
||||||
|
## 12. 路由注册
|
||||||
|
|
||||||
|
- [ ] 12.1 注册 `/api/admin/orders` 路由组(POST, GET, GET/:id, POST/:id/cancel)
|
||||||
|
- [ ] 12.2 注册 `/api/h5/orders` 路由组(POST, GET, GET/:id, POST/:id/pay)
|
||||||
|
- [ ] 12.3 注册 `/api/callback/wechat-pay` 回调路由
|
||||||
|
- [ ] 12.4 注册 `/api/callback/alipay` 回调路由
|
||||||
|
|
||||||
|
## 13. 文档生成器更新
|
||||||
|
|
||||||
|
- [ ] 13.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
|
||||||
|
- [ ] 13.2 执行文档生成验证
|
||||||
|
|
||||||
|
## 14. 测试
|
||||||
|
|
||||||
|
- [ ] 14.1 OrderStore 单元测试
|
||||||
|
- [ ] 14.2 PurchaseValidationService 单元测试(覆盖各种验证场景)
|
||||||
|
- [ ] 14.3 OrderService 单元测试(覆盖创建、支付、取消)
|
||||||
|
- [ ] 14.4 WalletPay 事务测试(覆盖成功和失败回滚)
|
||||||
|
- [ ] 14.5 订单创建 API 集成测试
|
||||||
|
- [ ] 14.6 钱包支付 API 集成测试
|
||||||
|
- [ ] 14.7 执行 `go test ./...` 确认通过
|
||||||
|
|
||||||
|
## 15. 最终验证
|
||||||
|
|
||||||
|
- [ ] 15.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [ ] 15.2 启动服务,手动测试订单创建流程
|
||||||
|
- [ ] 15.3 手动测试钱包支付流程
|
||||||
|
- [ ] 15.4 验证套餐激活(PackageUsage 记录创建)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-27
|
||||||
217
openspec/changes/add-shop-package-allocation/design.md
Normal file
217
openspec/changes/add-shop-package-allocation/design.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 1 完成了套餐系列和套餐的基础管理,但代理商还不能分销套餐。本期实现代理套餐分配机制,使上级代理能够:
|
||||||
|
1. 为下级店铺分配可销售的套餐系列
|
||||||
|
2. 通过加价模式设置下级的成本价
|
||||||
|
3. 配置梯度佣金(基于销量/销售额的阶梯奖励)
|
||||||
|
|
||||||
|
**当前代理层级结构**:
|
||||||
|
- 店铺通过 `Shop.parent_id` 维护层级关系
|
||||||
|
- 最多 7 级代理
|
||||||
|
- 数据权限通过 `GetSubordinateShopIDs()` 递归查询
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 实现套餐系列级别的分配机制
|
||||||
|
- 支持固定金额和百分比两种加价模式
|
||||||
|
- 支持梯度佣金配置(月度/季度/年度/自定义时间范围)
|
||||||
|
- 代理能查看自己被分配的套餐及成本价
|
||||||
|
- 可选的单套餐级别成本价覆盖
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不实现卡/设备的套餐系列关联(Phase 3)
|
||||||
|
- 不实现订单支付流程(Phase 4)
|
||||||
|
- 不实现佣金计算逻辑(Phase 5)
|
||||||
|
- 不支持跨级分配(只能分配给直属下级)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 分配模型设计
|
||||||
|
|
||||||
|
**决策**:三个独立模型
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ShopSeriesAllocation 店铺套餐系列分配
|
||||||
|
type ShopSeriesAllocation struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
ShopID uint // 被分配的店铺 ID
|
||||||
|
SeriesID uint // 套餐系列 ID
|
||||||
|
AllocatorShopID uint // 分配者店铺 ID(上级)
|
||||||
|
PricingMode string // 加价模式: fixed-固定金额 percent-百分比
|
||||||
|
PricingValue int64 // 加价值(分或千分比)
|
||||||
|
OneTimeCommissionTrigger string // 一次性佣金触发类型: one_time_recharge-单次充值 accumulated_recharge-累计充值
|
||||||
|
OneTimeCommissionThreshold int64 // 一次性佣金触发阈值(分)
|
||||||
|
OneTimeCommissionAmount int64 // 一次性佣金金额(分)
|
||||||
|
Status int // 状态 1-启用 2-禁用
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShopSeriesCommissionTier 梯度佣金配置
|
||||||
|
type ShopSeriesCommissionTier struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
AllocationID uint // 关联的分配 ID
|
||||||
|
TierType string // 梯度类型: sales_count-销量 sales_amount-销售额
|
||||||
|
PeriodType string // 周期类型: monthly-月度 quarterly-季度 yearly-年度 custom-自定义
|
||||||
|
PeriodStartDate *time.Time // 自定义周期开始日期
|
||||||
|
PeriodEndDate *time.Time // 自定义周期结束日期
|
||||||
|
ThresholdValue int64 // 阈值(销量或金额)
|
||||||
|
CommissionAmount int64 // 佣金金额(分)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShopPackageAllocation 店铺单套餐分配(可选覆盖)
|
||||||
|
type ShopPackageAllocation struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel
|
||||||
|
ShopID uint // 被分配的店铺 ID
|
||||||
|
PackageID uint // 套餐 ID
|
||||||
|
AllocationID uint // 关联的系列分配 ID
|
||||||
|
CostPrice int64 // 覆盖的成本价(分)
|
||||||
|
Status int // 状态 1-启用 2-禁用
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 系列级别分配是主要方式,减少配置工作量
|
||||||
|
- 单套餐分配用于特殊场景(如某个套餐给特定代理优惠价)
|
||||||
|
- 梯度佣金独立模型,支持多档配置
|
||||||
|
|
||||||
|
### 2. 加价模式与成本价计算
|
||||||
|
|
||||||
|
**决策**:成本价 = 上级成本价 + 加价值
|
||||||
|
|
||||||
|
```
|
||||||
|
# 固定金额加价
|
||||||
|
下级成本价 = 上级成本价 + pricing_value
|
||||||
|
|
||||||
|
# 百分比加价(pricing_value 为千分比,如 100 = 10%)
|
||||||
|
下级成本价 = 上级成本价 × (1 + pricing_value / 1000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 基于上级成本价加价,确保每级都有利润空间
|
||||||
|
- 千分比精度满足业务需求(0.1% 精度)
|
||||||
|
- 平台作为顶级,其成本价 = Package.suggested_cost_price
|
||||||
|
|
||||||
|
**约束**:
|
||||||
|
- 下级成本价 ≥ 上级成本价(禁止负加价)
|
||||||
|
- 验证时需递归获取上级成本价
|
||||||
|
|
||||||
|
### 3. 成本价获取逻辑
|
||||||
|
|
||||||
|
**决策**:递归查询 + 缓存
|
||||||
|
|
||||||
|
```go
|
||||||
|
func GetCostPrice(shopID, packageID uint) int64 {
|
||||||
|
// 1. 检查是否有单套餐覆盖
|
||||||
|
if override := GetPackageAllocation(shopID, packageID); override != nil {
|
||||||
|
return override.CostPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取系列分配
|
||||||
|
allocation := GetSeriesAllocation(shopID, package.SeriesID)
|
||||||
|
if allocation == nil {
|
||||||
|
return 0 // 未分配,不可购买
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取上级成本价
|
||||||
|
parentCostPrice := GetParentCostPrice(allocation.AllocatorShopID, packageID)
|
||||||
|
|
||||||
|
// 4. 计算当前成本价
|
||||||
|
return CalculatePrice(parentCostPrice, allocation.PricingMode, allocation.PricingValue)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 单套餐覆盖优先级最高
|
||||||
|
- 递归到平台级别时,使用 Package.suggested_cost_price
|
||||||
|
- 可考虑缓存热点套餐的成本价(后续优化)
|
||||||
|
|
||||||
|
### 4. 梯度佣金周期计算
|
||||||
|
|
||||||
|
**决策**:支持固定周期和自定义周期
|
||||||
|
|
||||||
|
| PeriodType | 计算方式 |
|
||||||
|
|------------|----------|
|
||||||
|
| monthly | 当月 1 日 00:00 至月末 23:59:59 |
|
||||||
|
| quarterly | 当季度第一天至最后一天 |
|
||||||
|
| yearly | 当年 1 月 1 日至 12 月 31 日 |
|
||||||
|
| custom | PeriodStartDate 至 PeriodEndDate |
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 固定周期覆盖常见场景
|
||||||
|
- 自定义周期支持促销活动等特殊需求
|
||||||
|
|
||||||
|
### 5. API 设计
|
||||||
|
|
||||||
|
**决策**:RESTful + 嵌套资源
|
||||||
|
|
||||||
|
```
|
||||||
|
# 套餐系列分配
|
||||||
|
POST /api/admin/shop-series-allocations 为下级分配系列
|
||||||
|
GET /api/admin/shop-series-allocations 查询分配列表
|
||||||
|
GET /api/admin/shop-series-allocations/:id 分配详情
|
||||||
|
PUT /api/admin/shop-series-allocations/:id 更新分配
|
||||||
|
DELETE /api/admin/shop-series-allocations/:id 删除分配
|
||||||
|
PATCH /api/admin/shop-series-allocations/:id/status 启用/禁用
|
||||||
|
|
||||||
|
# 梯度佣金(嵌套在分配下)
|
||||||
|
POST /api/admin/shop-series-allocations/:id/tiers 添加梯度
|
||||||
|
GET /api/admin/shop-series-allocations/:id/tiers 梯度列表
|
||||||
|
PUT /api/admin/shop-series-allocations/:id/tiers/:tierId 更新梯度
|
||||||
|
DELETE /api/admin/shop-series-allocations/:id/tiers/:tierId 删除梯度
|
||||||
|
|
||||||
|
# 单套餐分配
|
||||||
|
POST /api/admin/shop-package-allocations 分配单套餐
|
||||||
|
GET /api/admin/shop-package-allocations 查询列表
|
||||||
|
PUT /api/admin/shop-package-allocations/:id 更新
|
||||||
|
DELETE /api/admin/shop-package-allocations/:id 删除
|
||||||
|
|
||||||
|
# 代理可售套餐
|
||||||
|
GET /api/admin/my-packages 查询我的可售套餐
|
||||||
|
GET /api/admin/my-packages/:id 套餐详情(含成本价)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 风险 1:递归成本价计算性能
|
||||||
|
|
||||||
|
**风险**:多级代理场景下,递归查询成本价可能较慢
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 首期不做缓存,观察实际性能
|
||||||
|
- 如有问题,后续增加 Redis 缓存(按 shop_id + package_id 缓存)
|
||||||
|
- 缓存失效策略:分配变更时清除相关缓存
|
||||||
|
|
||||||
|
### 风险 2:分配一致性
|
||||||
|
|
||||||
|
**风险**:上级删除分配后,下级的分配关系如何处理
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 删除分配时检查是否有下级依赖
|
||||||
|
- 如有下级依赖,禁止删除或级联禁用
|
||||||
|
- 本期采用禁止删除策略,要求先清理下级分配
|
||||||
|
|
||||||
|
### 风险 3:梯度佣金统计复杂度
|
||||||
|
|
||||||
|
**风险**:统计周期内的销量/销售额可能涉及大量数据
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 佣金计算在 Phase 5 实现
|
||||||
|
- 可考虑定时任务预计算周期统计数据
|
||||||
|
- 本期只做配置,不做实际统计
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **是否支持批量分配?**
|
||||||
|
- 当前设计:单个分配
|
||||||
|
- 待确认:是否需要批量为多个下级分配同一系列?
|
||||||
|
|
||||||
|
2. **分配删除策略?**
|
||||||
|
- 当前设计:有下级依赖时禁止删除
|
||||||
|
- 待确认:是否需要级联删除或级联禁用?
|
||||||
|
|
||||||
|
3. **梯度佣金是否可叠加?**
|
||||||
|
- 当前设计:达到最高档位只拿最高档佣金
|
||||||
|
- 待确认:是否需要累加所有达标档位的佣金?
|
||||||
61
openspec/changes/add-shop-package-allocation/proposal.md
Normal file
61
openspec/changes/add-shop-package-allocation/proposal.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 1 完成了套餐基础模块,但代理商还不能分销套餐。需要实现代理套餐分配机制:上级代理为下级分配套餐系列,设置成本价(通过加价模式计算),并支持梯度佣金配置。代理只能看到和销售被分配的套餐。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**新增模型:**
|
||||||
|
- `ShopSeriesAllocation`:店铺套餐系列分配,记录哪个店铺被分配了哪个套餐系列、成本价加价模式、**一次性佣金触发配置**
|
||||||
|
- `ShopSeriesCommissionTier`:梯度佣金配置,基于销量/销售额设置不同的阶梯奖励金额
|
||||||
|
- `ShopPackageAllocation`:店铺单套餐分配(可选),用于覆盖系列级别的成本价设置
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
- 为下级店铺分配套餐系列(设置加价模式)
|
||||||
|
- 查询店铺的套餐系列分配列表
|
||||||
|
- 更新/删除套餐系列分配
|
||||||
|
- 配置梯度佣金(按系列)
|
||||||
|
- 为下级店铺分配单个套餐(覆盖成本价)
|
||||||
|
- 代理查看自己可销售的套餐列表(含成本价)
|
||||||
|
|
||||||
|
**业务规则:**
|
||||||
|
- 加价模式:固定金额加价 或 百分比加价(基于上级成本价)
|
||||||
|
- 代理给下级设置的成本价 ≥ 自己的成本价(不可亏本)
|
||||||
|
- 梯度佣金支持时间范围配置(月度/季度/年度/自定义)
|
||||||
|
- 套餐系列分配是主要方式,单套餐分配用于特殊覆盖
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `shop-series-allocation`: 店铺套餐系列分配 - 为下级店铺分配套餐系列,设置加价模式计算成本价
|
||||||
|
- `shop-commission-tier`: 梯度佣金配置 - 基于销量/销售额配置不同档位的一次性佣金
|
||||||
|
- `shop-package-allocation`: 店铺单套餐分配 - 可选的单套餐级别成本价覆盖
|
||||||
|
- `agent-available-packages`: 代理可售套餐查询 - 代理查看自己被分配的套餐及成本价
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- 无 -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**代码影响:**
|
||||||
|
- `internal/model/` - 新增 3 个模型文件
|
||||||
|
- `migrations/` - 创建 3 个新表
|
||||||
|
- `internal/handler/admin/` - 新增分配管理 Handler
|
||||||
|
- `internal/service/` - 新增分配管理 Service
|
||||||
|
- `internal/store/postgres/` - 新增 3 个 Store
|
||||||
|
- `internal/model/dto/` - 新增请求/响应 DTO
|
||||||
|
- `internal/bootstrap/` - 注册新组件
|
||||||
|
- `internal/router/` - 注册新路由
|
||||||
|
|
||||||
|
**API 影响:**
|
||||||
|
- 新增 `/api/admin/shop-series-allocations/*` 路由组
|
||||||
|
- 新增 `/api/admin/shop-package-allocations/*` 路由组
|
||||||
|
- 新增 `/api/admin/my-packages` 代理可售套餐查询
|
||||||
|
|
||||||
|
**数据库影响:**
|
||||||
|
- 新增表:`tb_shop_series_allocation`, `tb_shop_series_commission_tier`, `tb_shop_package_allocation`
|
||||||
|
|
||||||
|
**依赖关系:**
|
||||||
|
- 依赖 Phase 1(add-package-module)完成
|
||||||
|
- Phase 3(卡/设备关联)依赖本期
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 查询代理可售套餐列表
|
||||||
|
|
||||||
|
系统 SHALL 允许代理查询自己被分配的所有套餐。结果 MUST 包含套餐信息和代理的成本价。支持按套餐系列筛选、按套餐类型筛选。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有可售套餐
|
||||||
|
- **WHEN** 代理查询可售套餐列表
|
||||||
|
- **THEN** 系统返回该代理被分配的所有套餐系列下的启用且上架的套餐
|
||||||
|
|
||||||
|
#### Scenario: 响应包含成本价
|
||||||
|
- **WHEN** 代理查询可售套餐
|
||||||
|
- **THEN** 每个套餐包含:套餐信息、建议售价、代理成本价、利润空间
|
||||||
|
|
||||||
|
#### Scenario: 按系列筛选
|
||||||
|
- **WHEN** 代理指定套餐系列 ID 筛选
|
||||||
|
- **THEN** 系统只返回该系列下的套餐
|
||||||
|
|
||||||
|
#### Scenario: 只返回可售套餐
|
||||||
|
- **WHEN** 代理查询可售套餐
|
||||||
|
- **THEN** 系统只返回状态为启用(1)且上架状态为上架(1)的套餐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询代理可售套餐详情
|
||||||
|
|
||||||
|
系统 SHALL 允许代理查询单个套餐的详细信息,包含完整的价格信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询可售套餐详情
|
||||||
|
- **WHEN** 代理查询指定套餐的详情
|
||||||
|
- **THEN** 系统返回套餐完整信息,包含:成本价、建议售价、价格来源(系列加价/单套餐覆盖)
|
||||||
|
|
||||||
|
#### Scenario: 查询未分配的套餐
|
||||||
|
- **WHEN** 代理查询一个未被分配的套餐详情
|
||||||
|
- **THEN** 系统返回 "您没有该套餐的销售权限" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 成本价计算优先级
|
||||||
|
|
||||||
|
系统计算代理成本价时 MUST 遵循以下优先级:
|
||||||
|
1. 单套餐覆盖价(如果存在且启用)
|
||||||
|
2. 系列级别加价计算
|
||||||
|
|
||||||
|
#### Scenario: 存在单套餐覆盖
|
||||||
|
- **WHEN** 代理查询一个有覆盖价的套餐
|
||||||
|
- **THEN** 成本价使用覆盖价,价格来源标记为 "单套餐覆盖"
|
||||||
|
|
||||||
|
#### Scenario: 使用系列加价
|
||||||
|
- **WHEN** 代理查询一个无覆盖价的套餐
|
||||||
|
- **THEN** 成本价 = 上级成本价 + 加价值,价格来源标记为 "系列加价"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询代理被分配的套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许代理查询自己被分配的套餐系列列表。
|
||||||
|
|
||||||
|
#### Scenario: 查询被分配的系列
|
||||||
|
- **WHEN** 代理查询自己的套餐系列分配
|
||||||
|
- **THEN** 系统返回所有分配给该代理的套餐系列(启用状态的)
|
||||||
|
|
||||||
|
#### Scenario: 响应包含系列下套餐数量
|
||||||
|
- **WHEN** 代理查询被分配的系列
|
||||||
|
- **THEN** 每个系列包含:系列信息、可售套餐数量、加价模式信息
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 配置梯度佣金
|
||||||
|
|
||||||
|
系统 SHALL 允许代理为套餐系列分配配置梯度佣金。每个梯度包含:梯度类型(销量/销售额)、周期类型、阈值、佣金金额。
|
||||||
|
|
||||||
|
#### Scenario: 添加销量梯度佣金
|
||||||
|
- **WHEN** 代理为分配添加梯度:类型=销量,周期=月度,阈值=100,佣金=5000分
|
||||||
|
- **THEN** 系统创建梯度配置,当下级月销量达到 100 时可获得 50 元佣金
|
||||||
|
|
||||||
|
#### Scenario: 添加销售额梯度佣金
|
||||||
|
- **WHEN** 代理添加梯度:类型=销售额,周期=季度,阈值=100000分,佣金=10000分
|
||||||
|
- **THEN** 系统创建梯度配置,当下级季度销售额达到 1000 元时可获得 100 元佣金
|
||||||
|
|
||||||
|
#### Scenario: 配置自定义周期
|
||||||
|
- **WHEN** 代理添加梯度,周期类型=自定义,指定开始和结束日期
|
||||||
|
- **THEN** 系统创建梯度配置,统计指定日期范围内的数据
|
||||||
|
|
||||||
|
#### Scenario: 添加多个梯度档位
|
||||||
|
- **WHEN** 代理为同一分配添加多个梯度(如:100件=50元,200件=120元,500件=350元)
|
||||||
|
- **THEN** 系统创建多个梯度记录,支持阶梯奖励
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询梯度佣金配置
|
||||||
|
|
||||||
|
系统 SHALL 提供梯度佣金配置的查询功能,按分配 ID 查询。
|
||||||
|
|
||||||
|
#### Scenario: 查询分配的梯度配置
|
||||||
|
- **WHEN** 代理查询指定分配的梯度配置
|
||||||
|
- **THEN** 系统返回该分配下的所有梯度配置,按阈值升序排列
|
||||||
|
|
||||||
|
#### Scenario: 分配无梯度配置
|
||||||
|
- **WHEN** 代理查询一个没有配置梯度的分配
|
||||||
|
- **THEN** 系统返回空列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新梯度佣金配置
|
||||||
|
|
||||||
|
系统 SHALL 允许代理更新梯度配置的阈值和佣金金额。
|
||||||
|
|
||||||
|
#### Scenario: 更新梯度阈值
|
||||||
|
- **WHEN** 代理将梯度阈值从 100 改为 150
|
||||||
|
- **THEN** 系统更新梯度记录
|
||||||
|
|
||||||
|
#### Scenario: 更新梯度佣金金额
|
||||||
|
- **WHEN** 代理将佣金金额从 5000 改为 6000
|
||||||
|
- **THEN** 系统更新梯度记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除梯度佣金配置
|
||||||
|
|
||||||
|
系统 SHALL 允许代理删除梯度配置。
|
||||||
|
|
||||||
|
#### Scenario: 删除梯度配置
|
||||||
|
- **WHEN** 代理删除指定的梯度配置
|
||||||
|
- **THEN** 系统软删除该梯度记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 梯度佣金周期类型
|
||||||
|
|
||||||
|
系统 MUST 支持以下周期类型:
|
||||||
|
- monthly:月度(当月 1 日至月末)
|
||||||
|
- quarterly:季度(当季第一天至最后一天)
|
||||||
|
- yearly:年度(1 月 1 日至 12 月 31 日)
|
||||||
|
- custom:自定义(指定开始和结束日期)
|
||||||
|
|
||||||
|
#### Scenario: 月度周期
|
||||||
|
- **WHEN** 配置月度周期的梯度
|
||||||
|
- **THEN** 统计范围为当月 1 日 00:00:00 至月末 23:59:59
|
||||||
|
|
||||||
|
#### Scenario: 自定义周期必填日期
|
||||||
|
- **WHEN** 代理选择自定义周期但未提供开始或结束日期
|
||||||
|
- **THEN** 系统返回参数验证错误
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 为下级店铺分配单个套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许代理为下级店铺的特定套餐设置覆盖成本价。此功能用于对单个套餐给予特殊定价,优先级高于系列级别的加价计算。
|
||||||
|
|
||||||
|
#### Scenario: 成功分配单套餐覆盖价
|
||||||
|
- **WHEN** 代理为下级的某个套餐设置覆盖成本价 8000 分
|
||||||
|
- **THEN** 系统创建单套餐分配记录,该下级购买此套餐时成本价为 8000 分(不再使用系列加价计算)
|
||||||
|
|
||||||
|
#### Scenario: 覆盖价低于上级成本价
|
||||||
|
- **WHEN** 代理尝试设置的覆盖价低于自己的成本价
|
||||||
|
- **THEN** 系统返回错误 "覆盖价不能低于您的成本价"
|
||||||
|
|
||||||
|
#### Scenario: 套餐未在系列分配中
|
||||||
|
- **WHEN** 代理尝试为一个未分配系列下的套餐设置覆盖价
|
||||||
|
- **THEN** 系统返回错误 "该套餐的系列未分配给此店铺"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询单套餐分配列表
|
||||||
|
|
||||||
|
系统 SHALL 提供单套餐分配的查询功能,支持按店铺、套餐、状态筛选。
|
||||||
|
|
||||||
|
#### Scenario: 查询店铺的单套餐分配
|
||||||
|
- **WHEN** 代理查询指定店铺的单套餐分配列表
|
||||||
|
- **THEN** 系统返回该店铺的所有单套餐覆盖配置
|
||||||
|
|
||||||
|
#### Scenario: 查询结果包含套餐信息
|
||||||
|
- **WHEN** 代理查询单套餐分配列表
|
||||||
|
- **THEN** 响应包含套餐名称、套餐编码、原计算成本价、覆盖成本价
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新单套餐分配
|
||||||
|
|
||||||
|
系统 SHALL 允许代理更新单套餐分配的覆盖成本价。
|
||||||
|
|
||||||
|
#### Scenario: 更新覆盖成本价
|
||||||
|
- **WHEN** 代理将覆盖成本价从 8000 改为 7500
|
||||||
|
- **THEN** 系统更新记录,下级的该套餐成本价变为 7500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除单套餐分配
|
||||||
|
|
||||||
|
系统 SHALL 允许代理删除单套餐分配。删除后恢复使用系列级别的加价计算。
|
||||||
|
|
||||||
|
#### Scenario: 删除单套餐覆盖
|
||||||
|
- **WHEN** 代理删除单套餐分配记录
|
||||||
|
- **THEN** 系统软删除记录,下级的该套餐成本价恢复为系列加价计算值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 单套餐分配状态管理
|
||||||
|
|
||||||
|
系统 SHALL 允许代理启用/禁用单套餐分配。禁用后恢复使用系列级别价格。
|
||||||
|
|
||||||
|
#### Scenario: 禁用单套餐覆盖
|
||||||
|
- **WHEN** 代理禁用单套餐分配
|
||||||
|
- **THEN** 该套餐暂时使用系列级别的加价计算
|
||||||
|
|
||||||
|
#### Scenario: 启用单套餐覆盖
|
||||||
|
- **WHEN** 代理启用已禁用的单套餐分配
|
||||||
|
- **THEN** 该套餐恢复使用覆盖成本价
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 为下级店铺分配套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定加价模式(固定金额或百分比)和加价值。可选配置一次性佣金触发条件(触发类型、阈值、金额)。分配者只能分配自己已被分配的套餐系列。
|
||||||
|
|
||||||
|
#### Scenario: 成功分配套餐系列
|
||||||
|
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置固定金额加价 1000 分
|
||||||
|
- **THEN** 系统创建分配记录,下级成本价 = 上级成本价 + 1000
|
||||||
|
|
||||||
|
#### Scenario: 百分比加价分配
|
||||||
|
- **WHEN** 代理设置百分比加价模式,加价值为 100(10%)
|
||||||
|
- **THEN** 系统创建分配记录,下级成本价 = 上级成本价 × 1.1
|
||||||
|
|
||||||
|
#### Scenario: 尝试分配未拥有的系列
|
||||||
|
- **WHEN** 代理尝试分配自己未被分配的套餐系列
|
||||||
|
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
|
||||||
|
|
||||||
|
#### Scenario: 尝试分配给非直属下级
|
||||||
|
- **WHEN** 代理尝试分配给非直属下级店铺
|
||||||
|
- **THEN** 系统返回错误 "只能为直属下级分配套餐"
|
||||||
|
|
||||||
|
#### Scenario: 重复分配同一系列
|
||||||
|
- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列
|
||||||
|
- **THEN** 系统返回错误 "该店铺已分配此套餐系列"
|
||||||
|
|
||||||
|
#### Scenario: 配置一次性佣金触发条件
|
||||||
|
- **WHEN** 代理分配时设置一次性佣金触发类型为"单次充值",阈值 30000 分,金额 5000 分
|
||||||
|
- **THEN** 系统创建分配记录,下级的卡/设备在单次充值 ≥ 300 元时可获得 50 元一次性佣金
|
||||||
|
|
||||||
|
#### Scenario: 配置累计充值触发条件
|
||||||
|
- **WHEN** 代理分配时设置一次性佣金触发类型为"累计充值",阈值 50000 分,金额 8000 分
|
||||||
|
- **THEN** 系统创建分配记录,下级的卡/设备在累计充值 ≥ 500 元时可获得 80 元一次性佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐系列分配列表
|
||||||
|
|
||||||
|
系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。结果 MUST 包含计算后的成本价。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有分配
|
||||||
|
- **WHEN** 代理查询分配列表,不带筛选条件
|
||||||
|
- **THEN** 系统返回该代理创建的所有分配记录
|
||||||
|
|
||||||
|
#### Scenario: 按店铺筛选
|
||||||
|
- **WHEN** 代理指定下级店铺 ID 筛选
|
||||||
|
- **THEN** 系统只返回该店铺的分配记录
|
||||||
|
|
||||||
|
#### Scenario: 响应包含成本价
|
||||||
|
- **WHEN** 代理查询分配列表
|
||||||
|
- **THEN** 每条记录包含计算后的下级成本价
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许代理更新分配的加价模式和加价值。更新后下级的成本价 MUST 同步变化。
|
||||||
|
|
||||||
|
#### Scenario: 更新加价值
|
||||||
|
- **WHEN** 代理将加价值从 1000 改为 2000
|
||||||
|
- **THEN** 系统更新分配记录,下级成本价相应增加
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的分配
|
||||||
|
- **WHEN** 代理更新不存在的分配 ID
|
||||||
|
- **THEN** 系统返回 "分配记录不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许代理删除分配记录。如果有下级依赖此分配,MUST 禁止删除。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除无依赖的分配
|
||||||
|
- **WHEN** 代理删除一个没有下级依赖的分配记录
|
||||||
|
- **THEN** 系统软删除该记录
|
||||||
|
|
||||||
|
#### Scenario: 尝试删除有下级依赖的分配
|
||||||
|
- **WHEN** 代理尝试删除一个已被下级使用的分配(下级基于此分配又分配给了更下级)
|
||||||
|
- **THEN** 系统返回错误 "存在下级依赖,无法删除"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 启用/禁用套餐系列分配
|
||||||
|
|
||||||
|
系统 SHALL 允许代理切换分配的启用状态。禁用后下级 MUST NOT 能使用该分配购买套餐。
|
||||||
|
|
||||||
|
#### Scenario: 禁用分配
|
||||||
|
- **WHEN** 代理将分配状态设为禁用
|
||||||
|
- **THEN** 系统更新状态,下级无法基于此分配购买套餐
|
||||||
|
|
||||||
|
#### Scenario: 启用分配
|
||||||
|
- **WHEN** 代理将禁用的分配设为启用
|
||||||
|
- **THEN** 系统更新状态,下级可以继续使用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 平台分配套餐系列
|
||||||
|
|
||||||
|
平台管理员 SHALL 能够为一级代理分配套餐系列。平台的成本价基准为 Package.suggested_cost_price。
|
||||||
|
|
||||||
|
#### Scenario: 平台为一级代理分配
|
||||||
|
- **WHEN** 平台管理员为一级代理分配套餐系列
|
||||||
|
- **THEN** 系统创建分配记录,一级代理成本价 = suggested_cost_price + 加价值
|
||||||
167
openspec/changes/add-shop-package-allocation/tasks.md
Normal file
167
openspec/changes/add-shop-package-allocation/tasks.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
## 1. 新增模型
|
||||||
|
|
||||||
|
- [ ] 1.1 创建 `internal/model/shop_series_allocation.go`,定义 ShopSeriesAllocation 模型(shop_id, series_id, allocator_shop_id, pricing_mode, pricing_value, one_time_commission_trigger, one_time_commission_threshold, one_time_commission_amount, status)
|
||||||
|
- [ ] 1.2 创建 `internal/model/shop_series_commission_tier.go`,定义 ShopSeriesCommissionTier 模型(allocation_id, tier_type, period_type, period_start_date, period_end_date, threshold_value, commission_amount)
|
||||||
|
- [ ] 1.3 创建 `internal/model/shop_package_allocation.go`,定义 ShopPackageAllocation 模型(shop_id, package_id, allocation_id, cost_price, status)
|
||||||
|
|
||||||
|
## 2. 数据库迁移
|
||||||
|
|
||||||
|
- [ ] 2.1 创建迁移文件,创建 tb_shop_series_allocation 表
|
||||||
|
- [ ] 2.2 创建 tb_shop_series_commission_tier 表
|
||||||
|
- [ ] 2.3 创建 tb_shop_package_allocation 表
|
||||||
|
- [ ] 2.4 添加必要的索引(shop_id, series_id, allocation_id)
|
||||||
|
- [ ] 2.5 本地执行迁移验证
|
||||||
|
|
||||||
|
## 3. 套餐系列分配 DTO
|
||||||
|
|
||||||
|
- [ ] 3.1 创建 `internal/model/dto/shop_series_allocation.go`,定义 CreateShopSeriesAllocationRequest(含 one_time_commission_trigger, one_time_commission_threshold, one_time_commission_amount 可选字段)
|
||||||
|
- [ ] 3.2 定义 UpdateShopSeriesAllocationRequest
|
||||||
|
- [ ] 3.3 定义 ShopSeriesAllocationListRequest(支持 shop_id, series_id, status 筛选)
|
||||||
|
- [ ] 3.4 定义 UpdateStatusRequest
|
||||||
|
- [ ] 3.5 定义 ShopSeriesAllocationResponse(包含计算后的成本价)
|
||||||
|
|
||||||
|
## 4. 梯度佣金 DTO
|
||||||
|
|
||||||
|
- [ ] 4.1 定义 CreateCommissionTierRequest(tier_type, period_type, period_start_date, period_end_date, threshold_value, commission_amount)
|
||||||
|
- [ ] 4.2 定义 UpdateCommissionTierRequest
|
||||||
|
- [ ] 4.3 定义 CommissionTierResponse
|
||||||
|
|
||||||
|
## 5. 单套餐分配 DTO
|
||||||
|
|
||||||
|
- [ ] 5.1 创建 `internal/model/dto/shop_package_allocation.go`,定义 CreateShopPackageAllocationRequest
|
||||||
|
- [ ] 5.2 定义 UpdateShopPackageAllocationRequest
|
||||||
|
- [ ] 5.3 定义 ShopPackageAllocationListRequest
|
||||||
|
- [ ] 5.4 定义 ShopPackageAllocationResponse
|
||||||
|
|
||||||
|
## 6. 代理可售套餐 DTO
|
||||||
|
|
||||||
|
- [ ] 6.1 定义 MyPackageListRequest(series_id, package_type 筛选)
|
||||||
|
- [ ] 6.2 定义 MyPackageResponse(包含成本价、建议售价、价格来源)
|
||||||
|
- [ ] 6.3 定义 MySeriesAllocationResponse
|
||||||
|
|
||||||
|
## 7. 套餐系列分配 Store
|
||||||
|
|
||||||
|
- [ ] 7.1 创建 `internal/store/postgres/shop_series_allocation_store.go`,实现 Create 方法
|
||||||
|
- [ ] 7.2 实现 GetByID 方法
|
||||||
|
- [ ] 7.3 实现 GetByShopAndSeries 方法(检查重复分配)
|
||||||
|
- [ ] 7.4 实现 Update 方法
|
||||||
|
- [ ] 7.5 实现 Delete 方法
|
||||||
|
- [ ] 7.6 实现 List 方法(支持分页和筛选)
|
||||||
|
- [ ] 7.7 实现 UpdateStatus 方法
|
||||||
|
- [ ] 7.8 实现 HasDependentAllocations 方法(检查下级依赖)
|
||||||
|
- [ ] 7.9 实现 GetByShopID 方法(获取店铺的所有分配)
|
||||||
|
|
||||||
|
## 8. 梯度佣金 Store
|
||||||
|
|
||||||
|
- [ ] 8.1 创建 `internal/store/postgres/shop_series_commission_tier_store.go`,实现 Create 方法
|
||||||
|
- [ ] 8.2 实现 GetByID 方法
|
||||||
|
- [ ] 8.3 实现 Update 方法
|
||||||
|
- [ ] 8.4 实现 Delete 方法
|
||||||
|
- [ ] 8.5 实现 ListByAllocationID 方法
|
||||||
|
|
||||||
|
## 9. 单套餐分配 Store
|
||||||
|
|
||||||
|
- [ ] 9.1 创建 `internal/store/postgres/shop_package_allocation_store.go`,实现 Create 方法
|
||||||
|
- [ ] 9.2 实现 GetByID 方法
|
||||||
|
- [ ] 9.3 实现 GetByShopAndPackage 方法
|
||||||
|
- [ ] 9.4 实现 Update 方法
|
||||||
|
- [ ] 9.5 实现 Delete 方法
|
||||||
|
- [ ] 9.6 实现 List 方法
|
||||||
|
- [ ] 9.7 实现 UpdateStatus 方法
|
||||||
|
|
||||||
|
## 10. 套餐系列分配 Service
|
||||||
|
|
||||||
|
- [ ] 10.1 创建 `internal/service/shop_series_allocation/service.go`,实现 Create 方法(验证权限、检查重复、计算成本价)
|
||||||
|
- [ ] 10.2 实现 Get 方法
|
||||||
|
- [ ] 10.3 实现 Update 方法
|
||||||
|
- [ ] 10.4 实现 Delete 方法(检查下级依赖)
|
||||||
|
- [ ] 10.5 实现 List 方法
|
||||||
|
- [ ] 10.6 实现 UpdateStatus 方法
|
||||||
|
- [ ] 10.7 实现 GetParentCostPrice 辅助方法(递归获取上级成本价)
|
||||||
|
- [ ] 10.8 实现 CalculateCostPrice 辅助方法(根据加价模式计算)
|
||||||
|
|
||||||
|
## 11. 梯度佣金 Service
|
||||||
|
|
||||||
|
- [ ] 11.1 在 shop_series_allocation service 中实现 AddTier 方法
|
||||||
|
- [ ] 11.2 实现 UpdateTier 方法
|
||||||
|
- [ ] 11.3 实现 DeleteTier 方法
|
||||||
|
- [ ] 11.4 实现 ListTiers 方法
|
||||||
|
|
||||||
|
## 12. 单套餐分配 Service
|
||||||
|
|
||||||
|
- [ ] 12.1 创建 `internal/service/shop_package_allocation/service.go`,实现 Create 方法(验证系列已分配、验证成本价)
|
||||||
|
- [ ] 12.2 实现 Get 方法
|
||||||
|
- [ ] 12.3 实现 Update 方法
|
||||||
|
- [ ] 12.4 实现 Delete 方法
|
||||||
|
- [ ] 12.5 实现 List 方法
|
||||||
|
- [ ] 12.6 实现 UpdateStatus 方法
|
||||||
|
|
||||||
|
## 13. 代理可售套餐 Service
|
||||||
|
|
||||||
|
- [ ] 13.1 创建 `internal/service/my_package/service.go`,实现 ListMyPackages 方法(获取可售套餐列表)
|
||||||
|
- [ ] 13.2 实现 GetMyPackage 方法(获取单个套餐详情含成本价)
|
||||||
|
- [ ] 13.3 实现 ListMySeriesAllocations 方法(获取被分配的系列)
|
||||||
|
- [ ] 13.4 实现 GetCostPrice 核心方法(成本价计算,考虑优先级)
|
||||||
|
|
||||||
|
## 14. 套餐系列分配 Handler
|
||||||
|
|
||||||
|
- [ ] 14.1 创建 `internal/handler/admin/shop_series_allocation.go`,实现 Create 接口
|
||||||
|
- [ ] 14.2 实现 Get 接口
|
||||||
|
- [ ] 14.3 实现 Update 接口
|
||||||
|
- [ ] 14.4 实现 Delete 接口
|
||||||
|
- [ ] 14.5 实现 List 接口
|
||||||
|
- [ ] 14.6 实现 UpdateStatus 接口
|
||||||
|
- [ ] 14.7 实现 AddTier 接口
|
||||||
|
- [ ] 14.8 实现 UpdateTier 接口
|
||||||
|
- [ ] 14.9 实现 DeleteTier 接口
|
||||||
|
- [ ] 14.10 实现 ListTiers 接口
|
||||||
|
|
||||||
|
## 15. 单套餐分配 Handler
|
||||||
|
|
||||||
|
- [ ] 15.1 创建 `internal/handler/admin/shop_package_allocation.go`,实现 Create 接口
|
||||||
|
- [ ] 15.2 实现 Get 接口
|
||||||
|
- [ ] 15.3 实现 Update 接口
|
||||||
|
- [ ] 15.4 实现 Delete 接口
|
||||||
|
- [ ] 15.5 实现 List 接口
|
||||||
|
- [ ] 15.6 实现 UpdateStatus 接口
|
||||||
|
|
||||||
|
## 16. 代理可售套餐 Handler
|
||||||
|
|
||||||
|
- [ ] 16.1 创建 `internal/handler/admin/my_package.go`,实现 ListMyPackages 接口
|
||||||
|
- [ ] 16.2 实现 GetMyPackage 接口
|
||||||
|
- [ ] 16.3 实现 ListMySeriesAllocations 接口
|
||||||
|
|
||||||
|
## 17. Bootstrap 注册
|
||||||
|
|
||||||
|
- [ ] 17.1 在 stores.go 中注册 ShopSeriesAllocationStore, ShopSeriesCommissionTierStore, ShopPackageAllocationStore
|
||||||
|
- [ ] 17.2 在 services.go 中注册 ShopSeriesAllocationService, ShopPackageAllocationService, MyPackageService
|
||||||
|
- [ ] 17.3 在 handlers.go 中注册 ShopSeriesAllocationHandler, ShopPackageAllocationHandler, MyPackageHandler
|
||||||
|
|
||||||
|
## 18. 路由注册
|
||||||
|
|
||||||
|
- [ ] 18.1 注册 `/api/admin/shop-series-allocations` 路由组
|
||||||
|
- [ ] 18.2 注册 `/api/admin/shop-series-allocations/:id/tiers` 嵌套路由
|
||||||
|
- [ ] 18.3 注册 `/api/admin/shop-package-allocations` 路由组
|
||||||
|
- [ ] 18.4 注册 `/api/admin/my-packages` 路由
|
||||||
|
- [ ] 18.5 注册 `/api/admin/my-series-allocations` 路由
|
||||||
|
|
||||||
|
## 19. 文档生成器更新
|
||||||
|
|
||||||
|
- [ ] 19.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
|
||||||
|
- [ ] 19.2 执行文档生成验证
|
||||||
|
|
||||||
|
## 20. 测试
|
||||||
|
|
||||||
|
- [ ] 20.1 ShopSeriesAllocationStore 单元测试
|
||||||
|
- [ ] 20.2 ShopPackageAllocationStore 单元测试
|
||||||
|
- [ ] 20.3 ShopSeriesAllocationService 单元测试(覆盖权限验证、成本价计算)
|
||||||
|
- [ ] 20.4 MyPackageService 单元测试(覆盖成本价优先级)
|
||||||
|
- [ ] 20.5 套餐系列分配 API 集成测试
|
||||||
|
- [ ] 20.6 代理可售套餐 API 集成测试
|
||||||
|
- [ ] 20.7 执行 `go test ./...` 确认通过
|
||||||
|
|
||||||
|
## 21. 最终验证
|
||||||
|
|
||||||
|
- [ ] 21.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [ ] 21.2 启动服务,手动测试分配流程
|
||||||
|
- [ ] 21.3 验证成本价计算逻辑正确
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-01-27
|
||||||
199
openspec/changes/archive/2026-01-27-add-package-module/design.md
Normal file
199
openspec/changes/archive/2026-01-27-add-package-module/design.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
当前系统中存在大量为号卡业务设计的分佣模型(冻结/解冻、组合分佣、运营商结算等),但流量卡业务只需要简单的一次性佣金机制。这些模型增加了代码复杂度且从未使用。
|
||||||
|
|
||||||
|
现有套餐模型 `Package` 缺少建议价格字段和上架状态管理,无法支持后续的代理套餐分配功能。
|
||||||
|
|
||||||
|
**当前代码结构**:
|
||||||
|
- Handler 在 `internal/handler/admin/` 下,每个模块一个文件
|
||||||
|
- Service 在 `internal/service/{module}/service.go`,每个模块一个包
|
||||||
|
- Store 在 `internal/store/postgres/{module}_store.go`
|
||||||
|
- Bootstrap 在 `internal/bootstrap/` 负责组件注册
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 清理 8 个废弃模型,减少代码复杂度
|
||||||
|
- 扩展 Package 模型支持建议价格和上架状态
|
||||||
|
- 提供完整的套餐系列 CRUD API
|
||||||
|
- 提供完整的套餐 CRUD API(含双状态管理)
|
||||||
|
- 遵循现有代码架构风格
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不实现代理套餐分配(Phase 2)
|
||||||
|
- 不实现一次性佣金计算(Phase 5)
|
||||||
|
- 不迁移现有数据(表内无数据)
|
||||||
|
- 不修改 `CommissionRecord` 模型(后续 Phase 简化)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 模型文件处理策略
|
||||||
|
|
||||||
|
**决策**:直接删除废弃模型定义,不保留注释或空文件
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 这些模型从未在生产环境使用
|
||||||
|
- Git 历史可追溯
|
||||||
|
- 保留空定义增加维护负担
|
||||||
|
|
||||||
|
**替代方案**:
|
||||||
|
- ❌ 标记为 deprecated 保留:增加代码噪音
|
||||||
|
- ❌ 移到 archive 目录:过度设计
|
||||||
|
|
||||||
|
### 2. Package 模型字段设计
|
||||||
|
|
||||||
|
**决策**:新增三个字段
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Package struct {
|
||||||
|
// ... 现有字段 ...
|
||||||
|
SuggestedCostPrice int64 `gorm:"column:suggested_cost_price;type:bigint;default:0;comment:建议成本价(分为单位)" json:"suggested_cost_price"`
|
||||||
|
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||||
|
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `suggested_cost_price`:平台定义的建议成本价,代理分配时参考
|
||||||
|
- `suggested_retail_price`:平台定义的建议零售价,代理设置售价时参考
|
||||||
|
- `shelf_status`:与 `status`(启用/禁用)分离,支持独立的上架控制
|
||||||
|
- 默认 `shelf_status=2`(下架):新套餐需要显式上架
|
||||||
|
|
||||||
|
**替代方案**:
|
||||||
|
- ❌ 用 JSON 字段存储扩展属性:查询不便,类型不安全
|
||||||
|
- ❌ 合并 status 和 shelf_status:语义不同,分开更清晰
|
||||||
|
|
||||||
|
### 3. 双状态业务规则
|
||||||
|
|
||||||
|
**决策**:启用状态(status)和上架状态(shelf_status)独立但有约束
|
||||||
|
|
||||||
|
| status | shelf_status | 允许操作 |
|
||||||
|
|--------|--------------|----------|
|
||||||
|
| 启用(1) | 上架(1) | 可购买 |
|
||||||
|
| 启用(1) | 下架(2) | 不可购买,可上架 |
|
||||||
|
| 禁用(2) | 上架(1) | ❌ 禁止 - 禁用时强制下架 |
|
||||||
|
| 禁用(2) | 下架(2) | 不可购买,需先启用再上架 |
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 禁用套餐不应该可购买,强制下架保证数据一致性
|
||||||
|
- 启用但下架:允许平台配置套餐但暂不开放购买
|
||||||
|
|
||||||
|
### 4. API 路由设计
|
||||||
|
|
||||||
|
**决策**:使用 RESTful 风格,状态变更使用 PATCH
|
||||||
|
|
||||||
|
```
|
||||||
|
# 套餐系列
|
||||||
|
POST /api/admin/package-series 创建
|
||||||
|
GET /api/admin/package-series 列表
|
||||||
|
GET /api/admin/package-series/:id 详情
|
||||||
|
PUT /api/admin/package-series/:id 更新
|
||||||
|
DELETE /api/admin/package-series/:id 删除
|
||||||
|
PATCH /api/admin/package-series/:id/status 启用/禁用
|
||||||
|
|
||||||
|
# 套餐
|
||||||
|
POST /api/admin/packages 创建
|
||||||
|
GET /api/admin/packages 列表
|
||||||
|
GET /api/admin/packages/:id 详情
|
||||||
|
PUT /api/admin/packages/:id 更新
|
||||||
|
DELETE /api/admin/packages/:id 删除
|
||||||
|
PATCH /api/admin/packages/:id/status 启用/禁用
|
||||||
|
PATCH /api/admin/packages/:id/shelf 上架/下架
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 与现有 API 风格一致(参考 `/api/admin/carriers`)
|
||||||
|
- 状态变更使用 PATCH 符合 HTTP 语义
|
||||||
|
- 路径清晰,易于前端对接
|
||||||
|
|
||||||
|
### 5. Service 层设计
|
||||||
|
|
||||||
|
**决策**:每个模块独立 Service 包
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/service/package_series/service.go # 套餐系列 Service
|
||||||
|
internal/service/package/service.go # 套餐 Service
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 与现有架构一致(carrier, iot_card 等)
|
||||||
|
- 便于后续扩展(如套餐关联其他模块)
|
||||||
|
|
||||||
|
### 6. 数据库迁移策略
|
||||||
|
|
||||||
|
**决策**:单个迁移文件,先删后改
|
||||||
|
|
||||||
|
迁移顺序:
|
||||||
|
1. DROP 8 个废弃表
|
||||||
|
2. ALTER tb_package 添加 3 个新字段
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 这些表无生产数据,可直接删除
|
||||||
|
- 单文件便于回滚
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 风险 1:删除模型后发现有隐藏引用
|
||||||
|
|
||||||
|
**风险**:代码中可能有对废弃模型的隐藏引用导致编译失败
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 删除模型后执行 `go build ./...` 确认编译通过
|
||||||
|
- 使用 IDE 全局搜索确认无引用
|
||||||
|
|
||||||
|
### 风险 2:双状态逻辑复杂度
|
||||||
|
|
||||||
|
**风险**:禁用时强制下架的逻辑可能被遗漏
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 在 Service 层统一处理状态变更逻辑
|
||||||
|
- 添加单元测试覆盖所有状态组合
|
||||||
|
|
||||||
|
### 风险 3:API 命名与现有冲突
|
||||||
|
|
||||||
|
**风险**:`/packages` 路径可能与未来其他套餐类型冲突
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
- 当前只有流量卡套餐,命名合理
|
||||||
|
- 未来如有号卡套餐,可使用 `/number-card-packages`
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### 部署步骤
|
||||||
|
|
||||||
|
1. **代码部署前**:
|
||||||
|
- 确认生产环境废弃表无数据
|
||||||
|
- 备份数据库(预防措施)
|
||||||
|
|
||||||
|
2. **执行迁移**:
|
||||||
|
```bash
|
||||||
|
go run cmd/migrate/main.go up
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **验证**:
|
||||||
|
- 确认 8 个表已删除
|
||||||
|
- 确认 tb_package 新增 3 个字段
|
||||||
|
- API 健康检查
|
||||||
|
|
||||||
|
### 回滚策略
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/migrate/main.go down
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移 down 脚本:
|
||||||
|
- 重建 8 个废弃表(结构保留)
|
||||||
|
- 删除 tb_package 的 3 个新字段
|
||||||
|
|
||||||
|
**注意**:回滚不恢复数据,仅恢复表结构
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **套餐系列禁用是否级联影响套餐?**
|
||||||
|
- 当前设计:不级联,套餐系列禁用只影响系列本身
|
||||||
|
- 待确认:是否需要禁用系列时自动禁用下属套餐?
|
||||||
|
|
||||||
|
2. **删除套餐/套餐系列的约束?**
|
||||||
|
- 当前设计:物理删除(soft delete via GORM)
|
||||||
|
- 待确认:是否需要检查关联数据(如已分配给代理的套餐)?
|
||||||
|
- 建议:Phase 2 实现代理分配后再添加约束检查
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
当前分佣模型过于复杂(包含冻结/解冻审批、组合分佣、号卡结算等),而流量卡业务只需要简单的一次性佣金。现有的 `AgentPackageAllocation` 模型也不支持套餐系列级别的分配和梯度佣金配置。需要清理废弃模型,调整 Package 模型支持建议价格和上架状态,并提供完整的套餐/套餐系列 CRUD API。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
**模型清理(commission.go):**
|
||||||
|
- **BREAKING** 删除 `AgentHierarchy` - 代理层级通过 `Shop.parent_id` 维护
|
||||||
|
- **BREAKING** 删除 `CommissionRule` - 过于复杂,后续用新模型替代
|
||||||
|
- **BREAKING** 删除 `CommissionLadder` - 后续用 `ShopSeriesCommissionTier` 替代
|
||||||
|
- **BREAKING** 删除 `CommissionCombinedCondition` - 流量卡不需要组合分佣
|
||||||
|
- **BREAKING** 删除 `CommissionApproval` - 不需要冻结/解冻审批流程
|
||||||
|
- **BREAKING** 删除 `CommissionTemplate` - 简化后不需要模板
|
||||||
|
- **BREAKING** 删除 `CarrierSettlement` - 号卡专用,本期不做
|
||||||
|
|
||||||
|
**模型清理(package.go):**
|
||||||
|
- **BREAKING** 删除 `AgentPackageAllocation` - 用新的分配模型替代
|
||||||
|
|
||||||
|
**Package 模型调整:**
|
||||||
|
- 新增 `suggested_cost_price` 字段(建议成本价,分为单位)
|
||||||
|
- 新增 `suggested_retail_price` 字段(建议售价,分为单位)
|
||||||
|
- 新增 `shelf_status` 字段(上架状态:1-上架 2-下架)
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
- 套餐系列 CRUD(创建、更新、删除、列表、详情、启用/禁用)
|
||||||
|
- 套餐 CRUD(创建、更新、删除、列表、详情、启用/禁用、上架/下架)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `package-series-management`: 套餐系列管理 - 创建/更新/删除/列表/详情,支持启用/禁用状态切换
|
||||||
|
- `package-management`: 套餐管理 - 创建/更新/删除/列表/详情,支持启用/禁用和上架/下架双状态管理
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- 无需修改现有 capability,这是全新的套餐管理模块 -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
**代码影响:**
|
||||||
|
- `internal/model/commission.go` - 删除 7 个模型
|
||||||
|
- `internal/model/package.go` - 删除 1 个模型,修改 Package 模型
|
||||||
|
- `migrations/` - 需要创建迁移文件删除废弃表、修改 package 表
|
||||||
|
- `internal/handler/admin/` - 新增套餐系列和套餐管理 Handler
|
||||||
|
- `internal/service/` - 新增套餐系列和套餐管理 Service
|
||||||
|
- `internal/store/postgres/` - 新增套餐系列和套餐 Store
|
||||||
|
- `internal/model/dto/` - 新增请求/响应 DTO
|
||||||
|
- `internal/bootstrap/` - 注册新的 Store/Service/Handler
|
||||||
|
- `internal/router/` - 注册新的 API 路由
|
||||||
|
- `cmd/api/docs.go` 和 `cmd/gendocs/main.go` - 更新文档生成器
|
||||||
|
|
||||||
|
**API 影响:**
|
||||||
|
- 新增 `/api/admin/package-series/*` 路由组
|
||||||
|
- 新增 `/api/admin/packages/*` 路由组
|
||||||
|
|
||||||
|
**数据库影响:**
|
||||||
|
- 删除表:`tb_agent_hierarchy`, `tb_commission_rule`, `tb_commission_ladder`, `tb_commission_combined_condition`, `tb_commission_approval`, `tb_commission_template`, `tb_carrier_settlement`, `tb_agent_package_allocation`
|
||||||
|
- 修改表:`tb_package` 新增 3 个字段
|
||||||
|
|
||||||
|
**依赖关系:**
|
||||||
|
- 本期不涉及外部依赖变更
|
||||||
|
- 后续 Phase 2(代理套餐分配)依赖本期完成
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐
|
||||||
|
- **WHEN** 管理员提交有效的套餐信息
|
||||||
|
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
|
||||||
|
|
||||||
|
#### Scenario: 套餐编码重复
|
||||||
|
- **WHEN** 管理员提交的套餐编码已存在(未删除)
|
||||||
|
- **THEN** 系统返回错误 "套餐编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 关联不存在的套餐系列
|
||||||
|
- **WHEN** 管理员指定的系列 ID 不存在
|
||||||
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
|
#### Scenario: 缺少必填字段
|
||||||
|
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、价格)
|
||||||
|
- **THEN** 系统返回参数验证错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐列表
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐列表查询功能,支持按套餐名称模糊搜索、按系列 ID 筛选、按状态筛选、按上架状态筛选、按套餐类型筛选。结果 MUST 分页返回,按创建时间倒序排列。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐
|
||||||
|
- **WHEN** 管理员请求套餐列表,不带筛选条件
|
||||||
|
- **THEN** 系统返回所有未删除的套餐,分页显示
|
||||||
|
|
||||||
|
#### Scenario: 按系列筛选
|
||||||
|
- **WHEN** 管理员指定套餐系列 ID
|
||||||
|
- **THEN** 系统只返回属于该系列的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按名称搜索
|
||||||
|
- **WHEN** 管理员提供套餐名称关键字
|
||||||
|
- **THEN** 系统返回名称包含该关键字的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
- **WHEN** 管理员指定启用状态
|
||||||
|
- **THEN** 系统只返回匹配启用状态的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按上架状态筛选
|
||||||
|
- **WHEN** 管理员指定上架状态
|
||||||
|
- **THEN** 系统只返回匹配上架状态的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按套餐类型筛选
|
||||||
|
- **WHEN** 管理员指定套餐类型(formal/addon)
|
||||||
|
- **THEN** 系统只返回匹配类型的套餐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐详情
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员查询单个套餐的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐
|
||||||
|
- **WHEN** 管理员请求指定 ID 的套餐详情
|
||||||
|
- **THEN** 系统返回该套餐的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐
|
||||||
|
- **WHEN** 管理员请求不存在或已删除的套餐 ID
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐的基本信息。套餐编码创建后 MUST NOT 允许修改。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐
|
||||||
|
- **WHEN** 管理员提交有效的更新信息
|
||||||
|
- **THEN** 系统更新套餐记录,返回更新后的详情
|
||||||
|
|
||||||
|
#### Scenario: 尝试修改套餐编码
|
||||||
|
- **WHEN** 管理员尝试修改套餐编码
|
||||||
|
- **THEN** 系统忽略套餐编码字段,不进行修改
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的套餐
|
||||||
|
- **WHEN** 管理员更新不存在的套餐
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
#### Scenario: 关联不存在的套餐系列
|
||||||
|
- **WHEN** 管理员将套餐关联到不存在的系列
|
||||||
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐(软删除)。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐
|
||||||
|
- **WHEN** 管理员删除指定的套餐
|
||||||
|
- **THEN** 系统软删除该记录,后续查询不再返回
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的套餐
|
||||||
|
- **WHEN** 管理员删除不存在的套餐
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 启用/禁用套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐的启用状态。禁用套餐时 MUST 同时将上架状态设置为下架。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐
|
||||||
|
- **WHEN** 管理员将禁用的套餐设置为启用
|
||||||
|
- **THEN** 系统更新状态为启用(1),上架状态保持不变
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐
|
||||||
|
- **WHEN** 管理员将启用的套餐设置为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(2),同时将上架状态设置为下架(2)
|
||||||
|
|
||||||
|
#### Scenario: 禁用已上架的套餐
|
||||||
|
- **WHEN** 管理员禁用一个当前已上架的套餐
|
||||||
|
- **THEN** 系统更新状态为禁用(2),上架状态强制设置为下架(2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 上架/下架套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐的上架状态。只有启用状态的套餐才能上架。
|
||||||
|
|
||||||
|
#### Scenario: 上架启用的套餐
|
||||||
|
- **WHEN** 管理员将启用且下架的套餐设置为上架
|
||||||
|
- **THEN** 系统更新上架状态为上架(1)
|
||||||
|
|
||||||
|
#### Scenario: 尝试上架禁用的套餐
|
||||||
|
- **WHEN** 管理员尝试上架一个禁用的套餐
|
||||||
|
- **THEN** 系统返回错误 "禁用的套餐不能上架,请先启用"
|
||||||
|
|
||||||
|
#### Scenario: 下架套餐
|
||||||
|
- **WHEN** 管理员将上架的套餐设置为下架
|
||||||
|
- **THEN** 系统更新上架状态为下架(2)
|
||||||
|
|
||||||
|
#### Scenario: 状态未变化
|
||||||
|
- **WHEN** 管理员设置的上架状态与当前状态相同
|
||||||
|
- **THEN** 系统正常返回成功,不产生错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Package 模型新增字段
|
||||||
|
|
||||||
|
系统 MUST 在 Package 模型中新增以下字段:
|
||||||
|
- `suggested_cost_price`:建议成本价(分为单位),默认 0
|
||||||
|
- `suggested_retail_price`:建议售价(分为单位),默认 0
|
||||||
|
- `shelf_status`:上架状态,1-上架 2-下架,默认 2
|
||||||
|
|
||||||
|
#### Scenario: 创建套餐时设置建议价格
|
||||||
|
- **WHEN** 管理员创建套餐并设置建议成本价和建议售价
|
||||||
|
- **THEN** 系统保存这些价格信息
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐时返回建议价格
|
||||||
|
- **WHEN** 管理员查询套餐详情或列表
|
||||||
|
- **THEN** 响应中包含 suggested_cost_price、suggested_retail_price、shelf_status 字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 清理废弃模型
|
||||||
|
|
||||||
|
系统 MUST 删除以下废弃的分佣相关模型和对应的数据库表:
|
||||||
|
- `AgentHierarchy` (tb_agent_hierarchy)
|
||||||
|
- `CommissionRule` (tb_commission_rule)
|
||||||
|
- `CommissionLadder` (tb_commission_ladder)
|
||||||
|
- `CommissionCombinedCondition` (tb_commission_combined_condition)
|
||||||
|
- `CommissionApproval` (tb_commission_approval)
|
||||||
|
- `CommissionTemplate` (tb_commission_template)
|
||||||
|
- `CarrierSettlement` (tb_carrier_settlement)
|
||||||
|
- `AgentPackageAllocation` (tb_agent_package_allocation)
|
||||||
|
|
||||||
|
#### Scenario: 迁移后废弃表不存在
|
||||||
|
- **WHEN** 执行数据库迁移后
|
||||||
|
- **THEN** 上述 8 个表在数据库中不再存在
|
||||||
|
|
||||||
|
#### Scenario: 代码中无废弃模型引用
|
||||||
|
- **WHEN** 删除模型定义后
|
||||||
|
- **THEN** 项目能够正常编译,无编译错误
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许平台管理员创建套餐系列,包含系列编码、系列名称、描述信息。系列编码 MUST 全局唯一(排除已删除记录)。新创建的套餐系列默认为启用状态。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐系列
|
||||||
|
- **WHEN** 管理员提交有效的套餐系列信息(系列编码、系列名称)
|
||||||
|
- **THEN** 系统创建套餐系列记录,返回创建的套餐系列详情,状态为启用(1)
|
||||||
|
|
||||||
|
#### Scenario: 系列编码重复
|
||||||
|
- **WHEN** 管理员提交的系列编码已存在(未删除)
|
||||||
|
- **THEN** 系统返回错误 "系列编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 缺少必填字段
|
||||||
|
- **WHEN** 管理员未提供系列编码或系列名称
|
||||||
|
- **THEN** 系统返回参数验证错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐系列列表
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐系列列表查询功能,支持按系列名称模糊搜索、按状态筛选。结果 MUST 分页返回,按创建时间倒序排列。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐系列
|
||||||
|
- **WHEN** 管理员请求套餐系列列表,不带筛选条件
|
||||||
|
- **THEN** 系统返回所有未删除的套餐系列,分页显示
|
||||||
|
|
||||||
|
#### Scenario: 按名称搜索
|
||||||
|
- **WHEN** 管理员提供系列名称关键字
|
||||||
|
- **THEN** 系统返回名称包含该关键字的套餐系列
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
- **WHEN** 管理员指定状态筛选(启用/禁用)
|
||||||
|
- **THEN** 系统只返回匹配状态的套餐系列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐系列详情
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员查询单个套餐系列的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐系列
|
||||||
|
- **WHEN** 管理员请求指定 ID 的套餐系列详情
|
||||||
|
- **THEN** 系统返回该套餐系列的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐系列
|
||||||
|
- **WHEN** 管理员请求不存在或已删除的套餐系列 ID
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐系列的基本信息(系列名称、描述)。系列编码创建后 MUST NOT 允许修改。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐系列
|
||||||
|
- **WHEN** 管理员提交有效的更新信息
|
||||||
|
- **THEN** 系统更新套餐系列记录,返回更新后的详情
|
||||||
|
|
||||||
|
#### Scenario: 尝试修改系列编码
|
||||||
|
- **WHEN** 管理员尝试修改系列编码
|
||||||
|
- **THEN** 系统忽略系列编码字段,不进行修改
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的套餐系列
|
||||||
|
- **WHEN** 管理员更新不存在的套餐系列
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐系列(软删除)。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐系列
|
||||||
|
- **WHEN** 管理员删除指定的套餐系列
|
||||||
|
- **THEN** 系统软删除该记录,后续查询不再返回
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的套餐系列
|
||||||
|
- **WHEN** 管理员删除不存在的套餐系列
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 启用/禁用套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐系列的启用状态。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐系列
|
||||||
|
- **WHEN** 管理员将禁用的套餐系列设置为启用
|
||||||
|
- **THEN** 系统更新状态为启用(1)
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐系列
|
||||||
|
- **WHEN** 管理员将启用的套餐系列设置为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(2)
|
||||||
|
|
||||||
|
#### Scenario: 状态未变化
|
||||||
|
- **WHEN** 管理员设置的状态与当前状态相同
|
||||||
|
- **THEN** 系统正常返回成功,不产生错误
|
||||||
128
openspec/changes/archive/2026-01-27-add-package-module/tasks.md
Normal file
128
openspec/changes/archive/2026-01-27-add-package-module/tasks.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
## 1. 模型清理
|
||||||
|
|
||||||
|
- [x] 1.1 删除 `internal/model/commission.go` 中的废弃模型(AgentHierarchy, CommissionRule, CommissionLadder, CommissionCombinedCondition, CommissionApproval, CommissionTemplate, CarrierSettlement)
|
||||||
|
- [x] 1.2 删除 `internal/model/package.go` 中的 `AgentPackageAllocation` 模型
|
||||||
|
- [x] 1.3 执行 `go build ./...` 确认无编译错误,如有引用则同步清理
|
||||||
|
|
||||||
|
## 2. Package 模型调整
|
||||||
|
|
||||||
|
- [x] 2.1 在 `internal/model/package.go` 的 Package 结构体中新增 `suggested_cost_price` 字段(bigint, 默认 0, 注释:建议成本价)
|
||||||
|
- [x] 2.2 在 Package 结构体中新增 `suggested_retail_price` 字段(bigint, 默认 0, 注释:建议售价)
|
||||||
|
- [x] 2.3 在 Package 结构体中新增 `shelf_status` 字段(int, 默认 2, 注释:上架状态 1-上架 2-下架)
|
||||||
|
|
||||||
|
## 3. 数据库迁移
|
||||||
|
|
||||||
|
- [x] 3.1 创建迁移文件,UP 脚本删除 8 个废弃表:tb_agent_hierarchy, tb_commission_rule, tb_commission_ladder, tb_commission_combined_condition, tb_commission_approval, tb_commission_template, tb_carrier_settlement, tb_agent_package_allocation
|
||||||
|
- [x] 3.2 在迁移 UP 脚本中添加 tb_package 表的 3 个新字段
|
||||||
|
- [x] 3.3 编写迁移 DOWN 脚本(重建表结构、删除新字段)
|
||||||
|
- [x] 3.4 本地执行迁移验证
|
||||||
|
|
||||||
|
## 4. 套餐系列 DTO
|
||||||
|
|
||||||
|
- [x] 4.1 创建 `internal/model/dto/package_series.go`,定义 CreatePackageSeriesRequest(series_code 必填, series_name 必填, description 可选)
|
||||||
|
- [x] 4.2 定义 UpdatePackageSeriesRequest(series_name, description)
|
||||||
|
- [x] 4.3 定义 PackageSeriesListRequest(page, page_size, series_name 模糊, status 筛选)
|
||||||
|
- [x] 4.4 定义 UpdatePackageSeriesStatusRequest(status 必填)
|
||||||
|
- [x] 4.5 定义 PackageSeriesResponse 响应结构
|
||||||
|
|
||||||
|
## 5. 套餐系列 Store
|
||||||
|
|
||||||
|
- [x] 5.1 创建 `internal/store/postgres/package_series_store.go`,实现 Create 方法
|
||||||
|
- [x] 5.2 实现 GetByID 方法
|
||||||
|
- [x] 5.3 实现 GetByCode 方法(用于编码唯一性检查)
|
||||||
|
- [x] 5.4 实现 Update 方法
|
||||||
|
- [x] 5.5 实现 Delete 方法(软删除)
|
||||||
|
- [x] 5.6 实现 List 方法(支持分页、名称模糊搜索、状态筛选)
|
||||||
|
- [x] 5.7 实现 UpdateStatus 方法
|
||||||
|
|
||||||
|
## 6. 套餐系列 Service
|
||||||
|
|
||||||
|
- [x] 6.1 创建 `internal/service/package_series/service.go`,实现 Create 方法(检查编码唯一性)
|
||||||
|
- [x] 6.2 实现 Get 方法
|
||||||
|
- [x] 6.3 实现 Update 方法(忽略编码修改)
|
||||||
|
- [x] 6.4 实现 Delete 方法
|
||||||
|
- [x] 6.5 实现 List 方法
|
||||||
|
- [x] 6.6 实现 UpdateStatus 方法
|
||||||
|
|
||||||
|
## 7. 套餐系列 Handler
|
||||||
|
|
||||||
|
- [x] 7.1 创建 `internal/handler/admin/package_series.go`,实现 Create 接口
|
||||||
|
- [x] 7.2 实现 Get 接口
|
||||||
|
- [x] 7.3 实现 Update 接口
|
||||||
|
- [x] 7.4 实现 Delete 接口
|
||||||
|
- [x] 7.5 实现 List 接口
|
||||||
|
- [x] 7.6 实现 UpdateStatus 接口
|
||||||
|
|
||||||
|
## 8. 套餐 DTO
|
||||||
|
|
||||||
|
- [x] 8.1 创建 `internal/model/dto/package.go`,定义 CreatePackageRequest(package_code 必填, package_name 必填, series_id, package_type 必填, duration_months 必填, data_type, real_data_mb, virtual_data_mb, data_amount_mb, price 必填, suggested_cost_price, suggested_retail_price)
|
||||||
|
- [x] 8.2 定义 UpdatePackageRequest(除 package_code 外的字段)
|
||||||
|
- [x] 8.3 定义 PackageListRequest(page, page_size, package_name 模糊, series_id, status, shelf_status, package_type)
|
||||||
|
- [x] 8.4 定义 UpdatePackageStatusRequest(status 必填)
|
||||||
|
- [x] 8.5 定义 UpdatePackageShelfStatusRequest(shelf_status 必填)
|
||||||
|
- [x] 8.6 定义 PackageResponse 响应结构(包含新增的 3 个字段)
|
||||||
|
|
||||||
|
## 9. 套餐 Store
|
||||||
|
|
||||||
|
- [x] 9.1 创建 `internal/store/postgres/package_store.go`,实现 Create 方法
|
||||||
|
- [x] 9.2 实现 GetByID 方法
|
||||||
|
- [x] 9.3 实现 GetByCode 方法
|
||||||
|
- [x] 9.4 实现 Update 方法
|
||||||
|
- [x] 9.5 实现 Delete 方法
|
||||||
|
- [x] 9.6 实现 List 方法(支持分页、名称模糊、系列筛选、状态筛选、上架状态筛选、类型筛选)
|
||||||
|
- [x] 9.7 实现 UpdateStatus 方法
|
||||||
|
- [x] 9.8 实现 UpdateShelfStatus 方法
|
||||||
|
|
||||||
|
## 10. 套餐 Service
|
||||||
|
|
||||||
|
- [x] 10.1 创建 `internal/service/package/service.go`,实现 Create 方法(检查编码唯一性、验证系列存在)
|
||||||
|
- [x] 10.2 实现 Get 方法
|
||||||
|
- [x] 10.3 实现 Update 方法(忽略编码修改、验证系列存在)
|
||||||
|
- [x] 10.4 实现 Delete 方法
|
||||||
|
- [x] 10.5 实现 List 方法
|
||||||
|
- [x] 10.6 实现 UpdateStatus 方法(禁用时强制下架)
|
||||||
|
- [x] 10.7 实现 UpdateShelfStatus 方法(检查启用状态才能上架)
|
||||||
|
|
||||||
|
## 11. 套餐 Handler
|
||||||
|
|
||||||
|
- [x] 11.1 创建 `internal/handler/admin/package.go`,实现 Create 接口
|
||||||
|
- [x] 11.2 实现 Get 接口
|
||||||
|
- [x] 11.3 实现 Update 接口
|
||||||
|
- [x] 11.4 实现 Delete 接口
|
||||||
|
- [x] 11.5 实现 List 接口
|
||||||
|
- [x] 11.6 实现 UpdateStatus 接口
|
||||||
|
- [x] 11.7 实现 UpdateShelfStatus 接口
|
||||||
|
|
||||||
|
## 12. Bootstrap 注册
|
||||||
|
|
||||||
|
- [x] 12.1 在 `internal/bootstrap/stores.go` 中注册 PackageSeriesStore 和 PackageStore
|
||||||
|
- [x] 12.2 在 `internal/bootstrap/services.go` 中注册 PackageSeriesService 和 PackageService
|
||||||
|
- [x] 12.3 在 `internal/bootstrap/handlers.go` 中注册 PackageSeriesHandler 和 PackageHandler
|
||||||
|
|
||||||
|
## 13. 路由注册
|
||||||
|
|
||||||
|
- [x] 13.1 在 `internal/router/` 中注册套餐系列路由组 `/api/admin/package-series`(POST, GET, GET/:id, PUT/:id, DELETE/:id, PATCH/:id/status)
|
||||||
|
- [x] 13.2 注册套餐路由组 `/api/admin/packages`(POST, GET, GET/:id, PUT/:id, DELETE/:id, PATCH/:id/status, PATCH/:id/shelf)
|
||||||
|
|
||||||
|
## 14. 文档生成器更新
|
||||||
|
|
||||||
|
- [x] 14.1 在 `cmd/api/docs.go` 中添加 PackageSeriesHandler 和 PackageHandler
|
||||||
|
- [x] 14.2 在 `cmd/gendocs/main.go` 中添加 PackageSeriesHandler 和 PackageHandler
|
||||||
|
- [x] 14.3 执行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档
|
||||||
|
|
||||||
|
## 15. 测试
|
||||||
|
|
||||||
|
- [x] 15.1 为 PackageSeriesStore 编写单元测试
|
||||||
|
- [x] 15.2 为 PackageStore 编写单元测试
|
||||||
|
- [x] 15.3 为 PackageSeriesService 编写单元测试(覆盖编码唯一性检查)
|
||||||
|
- [x] 15.4 为 PackageService 编写单元测试(覆盖双状态逻辑)
|
||||||
|
- [x] 15.5 编写套餐系列 API 集成测试
|
||||||
|
- [x] 15.6 编写套餐 API 集成测试(覆盖禁用强制下架、禁用不能上架场景)
|
||||||
|
- [x] 15.7 执行 `go test ./...` 确认所有测试通过
|
||||||
|
|
||||||
|
## 16. 最终验证
|
||||||
|
|
||||||
|
- [x] 16.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [x] 16.2 执行 `go vet ./...` 检查代码质量
|
||||||
|
- [x] 16.3 启动服务,手动测试 API 接口
|
||||||
|
- [x] 16.4 确认 OpenAPI 文档正确生成
|
||||||
180
openspec/specs/package-management/spec.md
Normal file
180
openspec/specs/package-management/spec.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐
|
||||||
|
- **WHEN** 管理员提交有效的套餐信息
|
||||||
|
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
|
||||||
|
|
||||||
|
#### Scenario: 套餐编码重复
|
||||||
|
- **WHEN** 管理员提交的套餐编码已存在(未删除)
|
||||||
|
- **THEN** 系统返回错误 "套餐编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 关联不存在的套餐系列
|
||||||
|
- **WHEN** 管理员指定的系列 ID 不存在
|
||||||
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
|
#### Scenario: 缺少必填字段
|
||||||
|
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、价格)
|
||||||
|
- **THEN** 系统返回参数验证错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐列表
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐列表查询功能,支持按套餐名称模糊搜索、按系列 ID 筛选、按状态筛选、按上架状态筛选、按套餐类型筛选。结果 MUST 分页返回,按创建时间倒序排列。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐
|
||||||
|
- **WHEN** 管理员请求套餐列表,不带筛选条件
|
||||||
|
- **THEN** 系统返回所有未删除的套餐,分页显示
|
||||||
|
|
||||||
|
#### Scenario: 按系列筛选
|
||||||
|
- **WHEN** 管理员指定套餐系列 ID
|
||||||
|
- **THEN** 系统只返回属于该系列的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按名称搜索
|
||||||
|
- **WHEN** 管理员提供套餐名称关键字
|
||||||
|
- **THEN** 系统返回名称包含该关键字的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
- **WHEN** 管理员指定启用状态
|
||||||
|
- **THEN** 系统只返回匹配启用状态的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按上架状态筛选
|
||||||
|
- **WHEN** 管理员指定上架状态
|
||||||
|
- **THEN** 系统只返回匹配上架状态的套餐
|
||||||
|
|
||||||
|
#### Scenario: 按套餐类型筛选
|
||||||
|
- **WHEN** 管理员指定套餐类型(formal/addon)
|
||||||
|
- **THEN** 系统只返回匹配类型的套餐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐详情
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员查询单个套餐的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐
|
||||||
|
- **WHEN** 管理员请求指定 ID 的套餐详情
|
||||||
|
- **THEN** 系统返回该套餐的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐
|
||||||
|
- **WHEN** 管理员请求不存在或已删除的套餐 ID
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐的基本信息。套餐编码创建后 MUST NOT 允许修改。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐
|
||||||
|
- **WHEN** 管理员提交有效的更新信息
|
||||||
|
- **THEN** 系统更新套餐记录,返回更新后的详情
|
||||||
|
|
||||||
|
#### Scenario: 尝试修改套餐编码
|
||||||
|
- **WHEN** 管理员尝试修改套餐编码
|
||||||
|
- **THEN** 系统忽略套餐编码字段,不进行修改
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的套餐
|
||||||
|
- **WHEN** 管理员更新不存在的套餐
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
#### Scenario: 关联不存在的套餐系列
|
||||||
|
- **WHEN** 管理员将套餐关联到不存在的系列
|
||||||
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐(软删除)。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐
|
||||||
|
- **WHEN** 管理员删除指定的套餐
|
||||||
|
- **THEN** 系统软删除该记录,后续查询不再返回
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的套餐
|
||||||
|
- **WHEN** 管理员删除不存在的套餐
|
||||||
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 启用/禁用套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐的启用状态。禁用套餐时 MUST 同时将上架状态设置为下架。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐
|
||||||
|
- **WHEN** 管理员将禁用的套餐设置为启用
|
||||||
|
- **THEN** 系统更新状态为启用(1),上架状态保持不变
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐
|
||||||
|
- **WHEN** 管理员将启用的套餐设置为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(2),同时将上架状态设置为下架(2)
|
||||||
|
|
||||||
|
#### Scenario: 禁用已上架的套餐
|
||||||
|
- **WHEN** 管理员禁用一个当前已上架的套餐
|
||||||
|
- **THEN** 系统更新状态为禁用(2),上架状态强制设置为下架(2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 上架/下架套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐的上架状态。只有启用状态的套餐才能上架。
|
||||||
|
|
||||||
|
#### Scenario: 上架启用的套餐
|
||||||
|
- **WHEN** 管理员将启用且下架的套餐设置为上架
|
||||||
|
- **THEN** 系统更新上架状态为上架(1)
|
||||||
|
|
||||||
|
#### Scenario: 尝试上架禁用的套餐
|
||||||
|
- **WHEN** 管理员尝试上架一个禁用的套餐
|
||||||
|
- **THEN** 系统返回错误 "禁用的套餐不能上架,请先启用"
|
||||||
|
|
||||||
|
#### Scenario: 下架套餐
|
||||||
|
- **WHEN** 管理员将上架的套餐设置为下架
|
||||||
|
- **THEN** 系统更新上架状态为下架(2)
|
||||||
|
|
||||||
|
#### Scenario: 状态未变化
|
||||||
|
- **WHEN** 管理员设置的上架状态与当前状态相同
|
||||||
|
- **THEN** 系统正常返回成功,不产生错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Package 模型新增字段
|
||||||
|
|
||||||
|
系统 MUST 在 Package 模型中新增以下字段:
|
||||||
|
- `suggested_cost_price`:建议成本价(分为单位),默认 0
|
||||||
|
- `suggested_retail_price`:建议售价(分为单位),默认 0
|
||||||
|
- `shelf_status`:上架状态,1-上架 2-下架,默认 2
|
||||||
|
|
||||||
|
#### Scenario: 创建套餐时设置建议价格
|
||||||
|
- **WHEN** 管理员创建套餐并设置建议成本价和建议售价
|
||||||
|
- **THEN** 系统保存这些价格信息
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐时返回建议价格
|
||||||
|
- **WHEN** 管理员查询套餐详情或列表
|
||||||
|
- **THEN** 响应中包含 suggested_cost_price、suggested_retail_price、shelf_status 字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 清理废弃模型
|
||||||
|
|
||||||
|
系统 MUST 删除以下废弃的分佣相关模型和对应的数据库表:
|
||||||
|
- `AgentHierarchy` (tb_agent_hierarchy)
|
||||||
|
- `CommissionRule` (tb_commission_rule)
|
||||||
|
- `CommissionLadder` (tb_commission_ladder)
|
||||||
|
- `CommissionCombinedCondition` (tb_commission_combined_condition)
|
||||||
|
- `CommissionApproval` (tb_commission_approval)
|
||||||
|
- `CommissionTemplate` (tb_commission_template)
|
||||||
|
- `CarrierSettlement` (tb_carrier_settlement)
|
||||||
|
- `AgentPackageAllocation` (tb_agent_package_allocation)
|
||||||
|
|
||||||
|
#### Scenario: 迁移后废弃表不存在
|
||||||
|
- **WHEN** 执行数据库迁移后
|
||||||
|
- **THEN** 上述 8 个表在数据库中不再存在
|
||||||
|
|
||||||
|
#### Scenario: 代码中无废弃模型引用
|
||||||
|
- **WHEN** 删除模型定义后
|
||||||
|
- **THEN** 项目能够正常编译,无编译错误
|
||||||
99
openspec/specs/package-series-management/spec.md
Normal file
99
openspec/specs/package-series-management/spec.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许平台管理员创建套餐系列,包含系列编码、系列名称、描述信息。系列编码 MUST 全局唯一(排除已删除记录)。新创建的套餐系列默认为启用状态。
|
||||||
|
|
||||||
|
#### Scenario: 成功创建套餐系列
|
||||||
|
- **WHEN** 管理员提交有效的套餐系列信息(系列编码、系列名称)
|
||||||
|
- **THEN** 系统创建套餐系列记录,返回创建的套餐系列详情,状态为启用(1)
|
||||||
|
|
||||||
|
#### Scenario: 系列编码重复
|
||||||
|
- **WHEN** 管理员提交的系列编码已存在(未删除)
|
||||||
|
- **THEN** 系统返回错误 "系列编码已存在"
|
||||||
|
|
||||||
|
#### Scenario: 缺少必填字段
|
||||||
|
- **WHEN** 管理员未提供系列编码或系列名称
|
||||||
|
- **THEN** 系统返回参数验证错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐系列列表
|
||||||
|
|
||||||
|
系统 SHALL 提供套餐系列列表查询功能,支持按系列名称模糊搜索、按状态筛选。结果 MUST 分页返回,按创建时间倒序排列。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有套餐系列
|
||||||
|
- **WHEN** 管理员请求套餐系列列表,不带筛选条件
|
||||||
|
- **THEN** 系统返回所有未删除的套餐系列,分页显示
|
||||||
|
|
||||||
|
#### Scenario: 按名称搜索
|
||||||
|
- **WHEN** 管理员提供系列名称关键字
|
||||||
|
- **THEN** 系统返回名称包含该关键字的套餐系列
|
||||||
|
|
||||||
|
#### Scenario: 按状态筛选
|
||||||
|
- **WHEN** 管理员指定状态筛选(启用/禁用)
|
||||||
|
- **THEN** 系统只返回匹配状态的套餐系列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询套餐系列详情
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员查询单个套餐系列的详细信息。
|
||||||
|
|
||||||
|
#### Scenario: 查询存在的套餐系列
|
||||||
|
- **WHEN** 管理员请求指定 ID 的套餐系列详情
|
||||||
|
- **THEN** 系统返回该套餐系列的完整信息
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐系列
|
||||||
|
- **WHEN** 管理员请求不存在或已删除的套餐系列 ID
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 更新套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员更新套餐系列的基本信息(系列名称、描述)。系列编码创建后 MUST NOT 允许修改。
|
||||||
|
|
||||||
|
#### Scenario: 成功更新套餐系列
|
||||||
|
- **WHEN** 管理员提交有效的更新信息
|
||||||
|
- **THEN** 系统更新套餐系列记录,返回更新后的详情
|
||||||
|
|
||||||
|
#### Scenario: 尝试修改系列编码
|
||||||
|
- **WHEN** 管理员尝试修改系列编码
|
||||||
|
- **THEN** 系统忽略系列编码字段,不进行修改
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的套餐系列
|
||||||
|
- **WHEN** 管理员更新不存在的套餐系列
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 删除套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员删除套餐系列(软删除)。
|
||||||
|
|
||||||
|
#### Scenario: 成功删除套餐系列
|
||||||
|
- **WHEN** 管理员删除指定的套餐系列
|
||||||
|
- **THEN** 系统软删除该记录,后续查询不再返回
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的套餐系列
|
||||||
|
- **WHEN** 管理员删除不存在的套餐系列
|
||||||
|
- **THEN** 系统返回 "套餐系列不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 启用/禁用套餐系列
|
||||||
|
|
||||||
|
系统 SHALL 允许管理员切换套餐系列的启用状态。
|
||||||
|
|
||||||
|
#### Scenario: 启用套餐系列
|
||||||
|
- **WHEN** 管理员将禁用的套餐系列设置为启用
|
||||||
|
- **THEN** 系统更新状态为启用(1)
|
||||||
|
|
||||||
|
#### Scenario: 禁用套餐系列
|
||||||
|
- **WHEN** 管理员将启用的套餐系列设置为禁用
|
||||||
|
- **THEN** 系统更新状态为禁用(2)
|
||||||
|
|
||||||
|
#### Scenario: 状态未变化
|
||||||
|
- **WHEN** 管理员设置的状态与当前状态相同
|
||||||
|
- **THEN** 系统正常返回成功,不产生错误
|
||||||
752
tests/integration/package_test.go
Normal file
752
tests/integration/package_test.go
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||||
|
"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"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutil"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type packageTestEnv struct {
|
||||||
|
db *gorm.DB
|
||||||
|
rdb *redis.Client
|
||||||
|
tokenManager *auth.TokenManager
|
||||||
|
app *fiber.App
|
||||||
|
adminToken string
|
||||||
|
t *testing.T
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPackageTestEnv(t *testing.T) *packageTestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Setenv("JUNHONG_DATABASE_HOST", "cxd.whcxd.cn")
|
||||||
|
t.Setenv("JUNHONG_DATABASE_PORT", "16159")
|
||||||
|
t.Setenv("JUNHONG_DATABASE_USER", "erp_pgsql")
|
||||||
|
t.Setenv("JUNHONG_DATABASE_PASSWORD", "erp_2025")
|
||||||
|
t.Setenv("JUNHONG_DATABASE_DBNAME", "junhong_cmp_test")
|
||||||
|
t.Setenv("JUNHONG_REDIS_ADDRESS", "cxd.whcxd.cn")
|
||||||
|
t.Setenv("JUNHONG_REDIS_PORT", "16299")
|
||||||
|
t.Setenv("JUNHONG_REDIS_PASSWORD", "cpNbWtAaqgo1YJmbMp3h")
|
||||||
|
t.Setenv("JUNHONG_JWT_SECRET_KEY", "test_secret_key_for_integration_tests")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = config.Set(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
zapLogger, _ := zap.NewDevelopment()
|
||||||
|
|
||||||
|
dsn := "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai"
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: "cxd.whcxd.cn:16299",
|
||||||
|
Password: "cpNbWtAaqgo1YJmbMp3h",
|
||||||
|
DB: 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = rdb.Ping(ctx).Err()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testPrefix := fmt.Sprintf("test:%s:", t.Name())
|
||||||
|
keys, _ := rdb.Keys(ctx, testPrefix+"*").Result()
|
||||||
|
if len(keys) > 0 {
|
||||||
|
rdb.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour)
|
||||||
|
superAdmin := testutil.CreateSuperAdmin(t, db)
|
||||||
|
adminToken, _ := testutil.GenerateTestToken(t, rdb, superAdmin, "web")
|
||||||
|
|
||||||
|
queueClient := queue.NewClient(rdb, zapLogger)
|
||||||
|
|
||||||
|
deps := &bootstrap.Dependencies{
|
||||||
|
DB: db,
|
||||||
|
Redis: rdb,
|
||||||
|
Logger: zapLogger,
|
||||||
|
TokenManager: tokenManager,
|
||||||
|
QueueClient: queueClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := bootstrap.Bootstrap(deps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
ErrorHandler: internalMiddleware.ErrorHandler(zapLogger),
|
||||||
|
})
|
||||||
|
|
||||||
|
routes.RegisterRoutes(app, result.Handlers, result.Middlewares)
|
||||||
|
|
||||||
|
return &packageTestEnv{
|
||||||
|
db: db,
|
||||||
|
rdb: rdb,
|
||||||
|
tokenManager: tokenManager,
|
||||||
|
app: app,
|
||||||
|
adminToken: adminToken,
|
||||||
|
t: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *packageTestEnv) teardown() {
|
||||||
|
e.db.Exec("DELETE FROM tb_package WHERE package_code LIKE 'TEST%'")
|
||||||
|
e.db.Exec("DELETE FROM tb_package_series WHERE series_code LIKE 'TEST%'")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
testPrefix := fmt.Sprintf("test:%s:", e.t.Name())
|
||||||
|
keys, _ := e.rdb.Keys(ctx, testPrefix+"*").Result()
|
||||||
|
if len(keys) > 0 {
|
||||||
|
e.rdb.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.rdb.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Part 1: 套餐系列 API 测试 ====================
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_Create(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesCode := fmt.Sprintf("TEST_SERIES_%d", timestamp)
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"series_code": seriesCode,
|
||||||
|
"series_name": "测试套餐系列",
|
||||||
|
"description": "API集成测试创建的套餐系列",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/package-series", bytes.NewReader(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap, ok := result.Data.(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, seriesCode, dataMap["series_code"])
|
||||||
|
assert.Equal(t, "测试套餐系列", dataMap["series_name"])
|
||||||
|
assert.Equal(t, float64(constants.StatusEnabled), dataMap["status"])
|
||||||
|
|
||||||
|
t.Logf("创建的套餐系列 ID: %v", dataMap["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_Get(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesCode := fmt.Sprintf("TEST_SERIES_%d", timestamp)
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "测试套餐系列",
|
||||||
|
Description: "测试描述",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(series).Error)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/package-series/%d", series.ID)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, seriesCode, dataMap["series_code"])
|
||||||
|
assert.Equal(t, "测试套餐系列", dataMap["series_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_List(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesList := []*model.PackageSeries{
|
||||||
|
{
|
||||||
|
SeriesCode: fmt.Sprintf("TEST_LIST_%d_001", timestamp),
|
||||||
|
SeriesName: "列表测试系列1",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SeriesCode: fmt.Sprintf("TEST_LIST_%d_002", timestamp),
|
||||||
|
SeriesName: "列表测试系列2",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, s := range seriesList {
|
||||||
|
require.NoError(t, env.db.Create(s).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/package-series?page=1&page_size=20", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_Update(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesCode := fmt.Sprintf("TEST_SERIES_%d", timestamp)
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "原始系列名称",
|
||||||
|
Description: "原始描述",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(series).Error)
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"series_name": "更新后的系列名称",
|
||||||
|
"description": "更新后的描述",
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/package-series/%d", series.ID)
|
||||||
|
req := httptest.NewRequest("PUT", url, bytes.NewReader(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, "更新后的系列名称", dataMap["series_name"])
|
||||||
|
assert.Equal(t, "更新后的描述", dataMap["description"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_UpdateStatus(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesCode := fmt.Sprintf("TEST_SERIES_%d", timestamp)
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "测试系列",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(series).Error)
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"status": constants.StatusDisabled,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/package-series/%d/status", series.ID)
|
||||||
|
req := httptest.NewRequest("PATCH", url, bytes.NewReader(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
var updatedSeries model.PackageSeries
|
||||||
|
env.db.First(&updatedSeries, series.ID)
|
||||||
|
assert.Equal(t, constants.StatusDisabled, updatedSeries.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageSeriesAPI_Delete(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
seriesCode := fmt.Sprintf("TEST_SERIES_%d", timestamp)
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: seriesCode,
|
||||||
|
SeriesName: "测试系列",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(series).Error)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/package-series/%d", series.ID)
|
||||||
|
req := httptest.NewRequest("DELETE", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
var deletedSeries model.PackageSeries
|
||||||
|
err = env.db.First(&deletedSeries, series.ID).Error
|
||||||
|
assert.Error(t, err, "删除后应查不到套餐系列")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Part 2: 套餐 API 测试 ====================
|
||||||
|
|
||||||
|
func TestPackageAPI_Create(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"package_code": packageCode,
|
||||||
|
"package_name": "测试套餐",
|
||||||
|
"package_type": "formal",
|
||||||
|
"duration_months": 12,
|
||||||
|
"price": 99900,
|
||||||
|
"data_type": "real",
|
||||||
|
"real_data_mb": 10240,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/packages", bytes.NewReader(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap, ok := result.Data.(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, packageCode, dataMap["package_code"])
|
||||||
|
assert.Equal(t, "测试套餐", dataMap["package_name"])
|
||||||
|
assert.Equal(t, float64(constants.StatusEnabled), dataMap["status"])
|
||||||
|
assert.Equal(t, float64(2), dataMap["shelf_status"]) // 默认下架
|
||||||
|
|
||||||
|
t.Logf("创建的套餐 ID: %v", dataMap["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_UpdateStatus_DisableForceOffShelf(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
// 先创建套餐
|
||||||
|
createBody := map[string]interface{}{
|
||||||
|
"package_code": packageCode,
|
||||||
|
"package_name": "测试套餐",
|
||||||
|
"package_type": "formal",
|
||||||
|
"duration_months": 12,
|
||||||
|
"price": 99900,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(createBody)
|
||||||
|
|
||||||
|
createReq := httptest.NewRequest("POST", "/api/admin/packages", bytes.NewReader(jsonBody))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
createResp, err := env.app.Test(createReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
var createResult response.Response
|
||||||
|
err = json.NewDecoder(createResp.Body).Decode(&createResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, createResult.Code)
|
||||||
|
|
||||||
|
dataMap := createResult.Data.(map[string]interface{})
|
||||||
|
pkgID := uint(dataMap["id"].(float64))
|
||||||
|
|
||||||
|
// 先上架套餐
|
||||||
|
shelfBody := map[string]interface{}{
|
||||||
|
"shelf_status": 1,
|
||||||
|
}
|
||||||
|
shelfJsonBody, _ := json.Marshal(shelfBody)
|
||||||
|
|
||||||
|
shelfReq := httptest.NewRequest("PATCH", fmt.Sprintf("/api/admin/packages/%d/shelf", pkgID), bytes.NewReader(shelfJsonBody))
|
||||||
|
shelfReq.Header.Set("Content-Type", "application/json")
|
||||||
|
shelfReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
shelfResp, err := env.app.Test(shelfReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer shelfResp.Body.Close()
|
||||||
|
|
||||||
|
// 禁用套餐
|
||||||
|
disableBody := map[string]interface{}{
|
||||||
|
"status": constants.StatusDisabled,
|
||||||
|
}
|
||||||
|
disableJsonBody, _ := json.Marshal(disableBody)
|
||||||
|
|
||||||
|
disableReq := httptest.NewRequest("PATCH", fmt.Sprintf("/api/admin/packages/%d/status", pkgID), bytes.NewReader(disableJsonBody))
|
||||||
|
disableReq.Header.Set("Content-Type", "application/json")
|
||||||
|
disableReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
disableResp, err := env.app.Test(disableReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer disableResp.Body.Close()
|
||||||
|
|
||||||
|
var disableResult response.Response
|
||||||
|
err = json.NewDecoder(disableResp.Body).Decode(&disableResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("禁用响应: 状态码=%d, 错误码=%d, 消息=%s", disableResp.StatusCode, disableResult.Code, disableResult.Message)
|
||||||
|
require.Equal(t, 200, disableResp.StatusCode, "禁用套餐应该成功")
|
||||||
|
require.Equal(t, 0, disableResult.Code, "禁用套餐应该返回成功")
|
||||||
|
|
||||||
|
// 验证禁用后自动下架
|
||||||
|
var updatedPkg model.Package
|
||||||
|
ctx := pkgGorm.SkipDataPermission(context.Background())
|
||||||
|
require.NoError(t, env.db.WithContext(ctx).First(&updatedPkg, pkgID).Error)
|
||||||
|
assert.Equal(t, constants.StatusDisabled, updatedPkg.Status, "套餐应该被禁用")
|
||||||
|
assert.Equal(t, 2, updatedPkg.ShelfStatus, "禁用时应该强制下架")
|
||||||
|
|
||||||
|
t.Logf("禁用套餐后,状态: %d, 上架状态: %d", updatedPkg.Status, updatedPkg.ShelfStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_UpdateShelfStatus_DisabledCannotOnShelf(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
// 先创建套餐
|
||||||
|
createBody := map[string]interface{}{
|
||||||
|
"package_code": packageCode,
|
||||||
|
"package_name": "测试套餐",
|
||||||
|
"package_type": "formal",
|
||||||
|
"duration_months": 12,
|
||||||
|
"price": 99900,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(createBody)
|
||||||
|
|
||||||
|
createReq := httptest.NewRequest("POST", "/api/admin/packages", bytes.NewReader(jsonBody))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
createResp, err := env.app.Test(createReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
var createResult response.Response
|
||||||
|
err = json.NewDecoder(createResp.Body).Decode(&createResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, createResult.Code)
|
||||||
|
|
||||||
|
dataMap := createResult.Data.(map[string]interface{})
|
||||||
|
pkgID := uint(dataMap["id"].(float64))
|
||||||
|
|
||||||
|
// 禁用套餐
|
||||||
|
disableBody := map[string]interface{}{
|
||||||
|
"status": constants.StatusDisabled,
|
||||||
|
}
|
||||||
|
disableJsonBody, _ := json.Marshal(disableBody)
|
||||||
|
|
||||||
|
disableReq := httptest.NewRequest("PATCH", fmt.Sprintf("/api/admin/packages/%d/status", pkgID), bytes.NewReader(disableJsonBody))
|
||||||
|
disableReq.Header.Set("Content-Type", "application/json")
|
||||||
|
disableReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
disableResp, err := env.app.Test(disableReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer disableResp.Body.Close()
|
||||||
|
|
||||||
|
var disableResult response.Response
|
||||||
|
err = json.NewDecoder(disableResp.Body).Decode(&disableResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("禁用响应: 状态码=%d, 错误码=%d, 消息=%s", disableResp.StatusCode, disableResult.Code, disableResult.Message)
|
||||||
|
require.Equal(t, 200, disableResp.StatusCode, "禁用套餐应该成功")
|
||||||
|
require.Equal(t, 0, disableResult.Code, "禁用套餐应该返回成功")
|
||||||
|
|
||||||
|
// 尝试上架禁用的套餐
|
||||||
|
shelfBody := map[string]interface{}{
|
||||||
|
"shelf_status": 1,
|
||||||
|
}
|
||||||
|
shelfJsonBody, _ := json.Marshal(shelfBody)
|
||||||
|
|
||||||
|
shelfReq := httptest.NewRequest("PATCH", fmt.Sprintf("/api/admin/packages/%d/shelf", pkgID), bytes.NewReader(shelfJsonBody))
|
||||||
|
shelfReq.Header.Set("Content-Type", "application/json")
|
||||||
|
shelfReq.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
shelfResp, err := env.app.Test(shelfReq, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer shelfResp.Body.Close()
|
||||||
|
|
||||||
|
// 应该返回错误
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(shelfResp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, 0, result.Code, "禁用的套餐不能上架,应返回错误码")
|
||||||
|
|
||||||
|
// 验证套餐仍然是下架状态
|
||||||
|
var unchangedPkg model.Package
|
||||||
|
ctx := pkgGorm.SkipDataPermission(context.Background())
|
||||||
|
require.NoError(t, env.db.WithContext(ctx).First(&unchangedPkg, pkgID).Error)
|
||||||
|
assert.Equal(t, 2, unchangedPkg.ShelfStatus, "禁用的套餐应该保持下架状态")
|
||||||
|
|
||||||
|
t.Logf("尝试上架禁用套餐失败,错误码: %d, 消息: %s", result.Code, result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_Get(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: packageCode,
|
||||||
|
PackageName: "测试套餐",
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 12,
|
||||||
|
Price: 99900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(pkg).Error)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/packages/%d", pkg.ID)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, packageCode, dataMap["package_code"])
|
||||||
|
assert.Equal(t, "测试套餐", dataMap["package_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_List(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
pkgList := []*model.Package{
|
||||||
|
{
|
||||||
|
PackageCode: fmt.Sprintf("TEST_LIST_%d_001", timestamp),
|
||||||
|
PackageName: "列表测试套餐1",
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 12,
|
||||||
|
Price: 99900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 1,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PackageCode: fmt.Sprintf("TEST_LIST_%d_002", timestamp),
|
||||||
|
PackageName: "列表测试套餐2",
|
||||||
|
PackageType: "addon",
|
||||||
|
DurationMonths: 1,
|
||||||
|
Price: 9990,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, p := range pkgList {
|
||||||
|
require.NoError(t, env.db.Create(p).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/packages?page=1&page_size=20", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_Update(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: packageCode,
|
||||||
|
PackageName: "原始套餐名称",
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 12,
|
||||||
|
Price: 99900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(pkg).Error)
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"package_name": "更新后的套餐名称",
|
||||||
|
"price": 119900,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/packages/%d", pkg.ID)
|
||||||
|
req := httptest.NewRequest("PUT", url, bytes.NewReader(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
dataMap := result.Data.(map[string]interface{})
|
||||||
|
assert.Equal(t, "更新后的套餐名称", dataMap["package_name"])
|
||||||
|
assert.Equal(t, float64(119900), dataMap["price"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageAPI_Delete(t *testing.T) {
|
||||||
|
env := setupPackageTestEnv(t)
|
||||||
|
defer env.teardown()
|
||||||
|
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
packageCode := fmt.Sprintf("TEST_PKG_%d", timestamp)
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: packageCode,
|
||||||
|
PackageName: "测试套餐",
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 12,
|
||||||
|
Price: 99900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: 2,
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, env.db.Create(pkg).Error)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/admin/packages/%d", pkg.ID)
|
||||||
|
req := httptest.NewRequest("DELETE", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.adminToken)
|
||||||
|
|
||||||
|
resp, err := env.app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var result response.Response
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result.Code)
|
||||||
|
|
||||||
|
var deletedPkg model.Package
|
||||||
|
err = env.db.First(&deletedPkg, pkg.ID).Error
|
||||||
|
assert.Error(t, err, "删除后应查不到套餐")
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
"github.com/break/junhong_cmp_fiber/tests/testutil"
|
"github.com/break/junhong_cmp_fiber/tests/testutil"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -146,7 +147,7 @@ func TestShopManagement_CreateShop(t *testing.T) {
|
|||||||
ShopName: "测试商户",
|
ShopName: "测试商户",
|
||||||
ShopCode: "TEST001",
|
ShopCode: "TEST001",
|
||||||
InitUsername: "testuser",
|
InitUsername: "testuser",
|
||||||
InitPhone: "13800138000",
|
InitPhone: testutils.GenerateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +192,11 @@ func TestShopManagement_CreateShop_DuplicateCode(t *testing.T) {
|
|||||||
env := setupShopManagementTestEnv(t)
|
env := setupShopManagementTestEnv(t)
|
||||||
defer env.teardown()
|
defer env.teardown()
|
||||||
|
|
||||||
// 通过 API 创建第一个商户
|
|
||||||
firstReq := dto.CreateShopRequest{
|
firstReq := dto.CreateShopRequest{
|
||||||
ShopName: "商户1",
|
ShopName: "商户1",
|
||||||
ShopCode: "DUP001",
|
ShopCode: "DUP001",
|
||||||
InitUsername: "dupuser1",
|
InitUsername: "dupuser1",
|
||||||
InitPhone: "13800138101",
|
InitPhone: testutils.GenerateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
firstBody, _ := json.Marshal(firstReq)
|
firstBody, _ := json.Marshal(firstReq)
|
||||||
@@ -210,12 +210,11 @@ func TestShopManagement_CreateShop_DuplicateCode(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, 0, firstResult.Code, "第一个商户应该创建成功")
|
require.Equal(t, 0, firstResult.Code, "第一个商户应该创建成功")
|
||||||
|
|
||||||
// 尝试创建编码重复的商户
|
|
||||||
reqBody := dto.CreateShopRequest{
|
reqBody := dto.CreateShopRequest{
|
||||||
ShopName: "商户2",
|
ShopName: "商户2",
|
||||||
ShopCode: "DUP001", // 使用相同编码
|
ShopCode: "DUP001",
|
||||||
InitUsername: "dupuser2",
|
InitUsername: "dupuser2",
|
||||||
InitPhone: "13800138102",
|
InitPhone: testutils.GenerateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailPayload 邮件任务载荷(测试用)
|
|
||||||
type EmailPayload struct {
|
type EmailPayload struct {
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
@@ -23,21 +22,21 @@ type EmailPayload struct {
|
|||||||
CC []string `json:"cc,omitempty"`
|
CC []string `json:"cc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskSubmit 测试任务提交
|
|
||||||
func TestTaskSubmit(t *testing.T) {
|
func TestTaskSubmit(t *testing.T) {
|
||||||
// 创建 Redis 客户端
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
// 清理测试数据
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
// 创建 Asynq 客户端
|
|
||||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
@@ -66,20 +65,21 @@ func TestTaskSubmit(t *testing.T) {
|
|||||||
assert.Equal(t, constants.DefaultRetryMax, info.MaxRetry)
|
assert.Equal(t, constants.DefaultRetryMax, info.MaxRetry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskPriority 测试任务优先级
|
|
||||||
func TestTaskPriority(t *testing.T) {
|
func TestTaskPriority(t *testing.T) {
|
||||||
// 创建 Redis 客户端
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
// 创建 Asynq 客户端
|
|
||||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
@@ -113,18 +113,21 @@ func TestTaskPriority(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskRetry 测试任务重试机制
|
|
||||||
func TestTaskRetry(t *testing.T) {
|
func TestTaskRetry(t *testing.T) {
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
@@ -150,20 +153,22 @@ func TestTaskRetry(t *testing.T) {
|
|||||||
assert.Equal(t, 30*time.Second, info.Timeout)
|
assert.Equal(t, 30*time.Second, info.Timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskIdempotency 测试任务幂等性键
|
|
||||||
func TestTaskIdempotency(t *testing.T) {
|
func TestTaskIdempotency(t *testing.T) {
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
requestID := "idempotent-test-001"
|
requestID := "idempotent-test-" + time.Now().Format("20060102150405.000")
|
||||||
lockKey := constants.RedisTaskLockKey(requestID)
|
lockKey := constants.RedisTaskLockKey(requestID)
|
||||||
|
rdb.Del(ctx, lockKey)
|
||||||
|
t.Cleanup(func() { rdb.Del(ctx, lockKey) })
|
||||||
|
|
||||||
// 第一次设置锁(模拟任务开始执行)
|
|
||||||
result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, result, "第一次设置锁应该成功")
|
assert.True(t, result, "第一次设置锁应该成功")
|
||||||
@@ -185,15 +190,16 @@ func TestTaskIdempotency(t *testing.T) {
|
|||||||
assert.LessOrEqual(t, ttl.Hours(), 24.0)
|
assert.LessOrEqual(t, ttl.Hours(), 24.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskStatusTracking 测试任务状态跟踪
|
|
||||||
func TestTaskStatusTracking(t *testing.T) {
|
func TestTaskStatusTracking(t *testing.T) {
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
taskID := "task-123456"
|
taskID := "task-123456"
|
||||||
statusKey := constants.RedisTaskStatusKey(taskID)
|
statusKey := constants.RedisTaskStatusKey(taskID)
|
||||||
@@ -217,18 +223,21 @@ func TestTaskStatusTracking(t *testing.T) {
|
|||||||
assert.Greater(t, ttl.Hours(), 24.0*6)
|
assert.Greater(t, ttl.Hours(), 24.0*6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestQueueInspection 测试队列检查
|
|
||||||
func TestQueueInspection(t *testing.T) {
|
func TestQueueInspection(t *testing.T) {
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rdb.FlushDB(ctx)
|
cleanTestKeys(t, rdb, ctx)
|
||||||
|
|
||||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
@@ -249,9 +258,10 @@ func TestQueueInspection(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 Inspector 检查队列
|
|
||||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||||
Addr: "localhost:6379",
|
Addr: testRedisAddr,
|
||||||
|
Password: testRedisPasswd,
|
||||||
|
DB: testRedisDB,
|
||||||
})
|
})
|
||||||
defer func() { _ = inspector.Close() }()
|
defer func() { _ = inspector.Close() }()
|
||||||
|
|
||||||
@@ -262,7 +272,6 @@ func TestQueueInspection(t *testing.T) {
|
|||||||
assert.Equal(t, 0, info.Active)
|
assert.Equal(t, 0, info.Active)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTaskSerialization 测试任务序列化
|
|
||||||
func TestTaskSerialization(t *testing.T) {
|
func TestTaskSerialization(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -291,17 +300,14 @@ func TestTaskSerialization(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// 序列化
|
|
||||||
payloadBytes, err := sonic.Marshal(tt.payload)
|
payloadBytes, err := sonic.Marshal(tt.payload)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, payloadBytes)
|
assert.NotEmpty(t, payloadBytes)
|
||||||
|
|
||||||
// 反序列化
|
|
||||||
var decoded EmailPayload
|
var decoded EmailPayload
|
||||||
err = sonic.Unmarshal(payloadBytes, &decoded)
|
err = sonic.Unmarshal(payloadBytes, &decoded)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// 验证
|
|
||||||
assert.Equal(t, tt.payload.RequestID, decoded.RequestID)
|
assert.Equal(t, tt.payload.RequestID, decoded.RequestID)
|
||||||
assert.Equal(t, tt.payload.To, decoded.To)
|
assert.Equal(t, tt.payload.To, decoded.To)
|
||||||
assert.Equal(t, tt.payload.Subject, decoded.Subject)
|
assert.Equal(t, tt.payload.Subject, decoded.Subject)
|
||||||
@@ -310,3 +316,19 @@ func TestTaskSerialization(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanTestKeys(t *testing.T, rdb *redis.Client, ctx context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
prefix := "test:task:" + t.Name() + ":"
|
||||||
|
keys, err := rdb.Keys(ctx, prefix+"*").Result()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(keys) > 0 {
|
||||||
|
rdb.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
asynqKeys, _ := rdb.Keys(ctx, "asynq:*").Result()
|
||||||
|
if len(asynqKeys) > 0 {
|
||||||
|
rdb.Del(ctx, asynqKeys...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ func GeneratePhone(prefix string, index int) string {
|
|||||||
return fmt.Sprintf("%s%08d", prefix, index)
|
return fmt.Sprintf("%s%08d", prefix, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateUniquePhone 生成唯一手机号(基于时间戳)
|
||||||
|
func GenerateUniquePhone() string {
|
||||||
|
timestamp := time.Now().UnixNano()
|
||||||
|
suffix := timestamp % 100000000
|
||||||
|
return fmt.Sprintf("138%08d", suffix)
|
||||||
|
}
|
||||||
|
|
||||||
// Now 返回当前时间
|
// Now 返回当前时间
|
||||||
func Now() time.Time {
|
func Now() time.Time {
|
||||||
return time.Now()
|
return time.Now()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestShopService_Create(t *testing.T) {
|
|||||||
District: "朝阳区",
|
District: "朝阳区",
|
||||||
Address: "朝阳路100号",
|
Address: "朝阳路100号",
|
||||||
InitUsername: generateUniqueUsername("admin", t),
|
InitUsername: generateUniqueUsername("admin", t),
|
||||||
InitPhone: "13800138001",
|
InitPhone: generateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ func TestShopService_Create(t *testing.T) {
|
|||||||
ContactName: "王五",
|
ContactName: "王五",
|
||||||
ContactPhone: "13800000003",
|
ContactPhone: "13800000003",
|
||||||
InitUsername: generateUniqueUsername("agent", t),
|
InitUsername: generateUniqueUsername("agent", t),
|
||||||
InitPhone: "13800138002",
|
InitPhone: generateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ func TestShopService_Create(t *testing.T) {
|
|||||||
ContactName: "测试",
|
ContactName: "测试",
|
||||||
ContactPhone: "13800000008",
|
ContactPhone: "13800000008",
|
||||||
InitUsername: generateUniqueUsername("level8", t),
|
InitUsername: generateUniqueUsername("level8", t),
|
||||||
InitPhone: "13800138008",
|
InitPhone: generateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ func TestShopService_Create(t *testing.T) {
|
|||||||
ContactName: "测试",
|
ContactName: "测试",
|
||||||
ContactPhone: "13800000009",
|
ContactPhone: "13800000009",
|
||||||
InitUsername: generateUniqueUsername("invalid", t),
|
InitUsername: generateUniqueUsername("invalid", t),
|
||||||
InitPhone: "13800138009",
|
InitPhone: generateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ func TestShopService_Create(t *testing.T) {
|
|||||||
ContactName: "测试",
|
ContactName: "测试",
|
||||||
ContactPhone: "13800000010",
|
ContactPhone: "13800000010",
|
||||||
InitUsername: generateUniqueUsername("unauth", t),
|
InitUsername: generateUniqueUsername("unauth", t),
|
||||||
InitPhone: "13800138010",
|
InitPhone: generateUniquePhone(),
|
||||||
InitPassword: "password123",
|
InitPassword: "password123",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user