From 79c061b6fa35db35b78311113251cd10d280a448 Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 27 Jan 2026 19:55:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=8C=85=E5=90=AB?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E7=B3=BB=E5=88=97=E3=80=81=E5=8F=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=E3=80=81=E5=BA=9F=E5=BC=83=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增套餐系列管理 (CRUD + 状态切换) - 新增套餐管理 (CRUD + 启用/禁用 + 上架/下架双状态) - 清理 8 个废弃分佣模型及对应数据库表 - Package 模型新增建议成本价、建议售价、上架状态字段 - 完整的 Store/Service/Handler 三层实现 - 包含单元测试和集成测试 - 归档 add-package-module change - 新增多个 OpenSpec changes (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定) --- AGENTS.md | 9 + cmd/api/docs.go | 2 + cmd/gendocs/main.go | 2 + docs/admin-openapi.yaml | 963 ++++++++++++++++++ docs/testing/test-connection-guide.md | 42 + internal/bootstrap/handlers.go | 2 + internal/bootstrap/services.go | 6 + internal/bootstrap/stores.go | 4 + internal/bootstrap/types.go | 2 + internal/handler/admin/package.go | 130 +++ internal/handler/admin/package_series.go | 112 ++ internal/model/commission.go | 133 --- internal/model/dto/package_dto.go | 101 ++ internal/model/dto/package_series_dto.go | 59 ++ internal/model/package.go | 44 +- internal/routes/admin.go | 6 + internal/routes/package.go | 70 ++ internal/routes/package_series.go | 62 ++ internal/service/package/service.go | 305 ++++++ internal/service/package/service_test.go | 14 +- internal/service/package_series/service.go | 177 ++++ .../service/package_series/service_test.go | 313 ++++++ .../store/postgres/iot_card_store_test.go | 66 +- .../store/postgres/package_series_store.go | 84 ++ .../postgres/package_series_store_test.go | 191 ++++ internal/store/postgres/package_store.go | 97 ++ internal/store/postgres/package_store_test.go | 332 ++++++ ...ted_models_and_add_package_fields.down.sql | 162 +++ ...cated_models_and_add_package_fields.up.sql | 21 + .../.openspec.yaml | 2 + .../add-card-device-series-bindng/design.md | 123 +++ .../add-card-device-series-bindng/proposal.md | 58 ++ .../specs/card-series-bindng/spec.md | 70 ++ .../specs/device-series-bindng/spec.md | 84 ++ .../add-card-device-series-bindng/tasks.md | 85 ++ .../add-one-time-commission/.openspec.yaml | 2 + .../changes/add-one-time-commission/design.md | 252 +++++ .../add-one-time-commission/proposal.md | 77 ++ .../specs/commission-calculation/spec.md | 73 ++ .../specs/commission-record-query/spec.md | 67 ++ .../specs/one-time-commission-trigger/spec.md | 69 ++ .../changes/add-one-time-commission/tasks.md | 99 ++ .../changes/add-order-payment/.openspec.yaml | 2 + openspec/changes/add-order-payment/design.md | 237 +++++ .../changes/add-order-payment/proposal.md | 70 ++ .../specs/order-management/spec.md | 85 ++ .../specs/order-payment/spec.md | 75 ++ .../specs/package-purchase-validation/spec.md | 67 ++ openspec/changes/add-order-payment/tasks.md | 105 ++ .../.openspec.yaml | 2 + .../add-shop-package-allocation/design.md | 217 ++++ .../add-shop-package-allocation/proposal.md | 61 ++ .../specs/agent-available-packages/spec.md | 65 ++ .../specs/shop-commission-tier/spec.md | 77 ++ .../specs/shop-package-allocation/spec.md | 65 ++ .../specs/shop-series-allocation/spec.md | 103 ++ .../add-shop-package-allocation/tasks.md | 167 +++ .../.openspec.yaml | 2 + .../2026-01-27-add-package-module/design.md | 199 ++++ .../2026-01-27-add-package-module/proposal.md | 63 ++ .../specs/package-management/spec.md | 180 ++++ .../specs/package-series-management/spec.md | 99 ++ .../2026-01-27-add-package-module/tasks.md | 128 +++ openspec/specs/package-management/spec.md | 180 ++++ .../specs/package-series-management/spec.md | 99 ++ tests/integration/package_test.go | 752 ++++++++++++++ tests/integration/shop_management_test.go | 15 +- tests/integration/task_test.go | 94 +- tests/testutils/setup.go | 7 + tests/unit/shop_service_test.go | 10 +- 70 files changed, 7554 insertions(+), 244 deletions(-) create mode 100644 internal/handler/admin/package.go create mode 100644 internal/handler/admin/package_series.go create mode 100644 internal/model/dto/package_dto.go create mode 100644 internal/model/dto/package_series_dto.go create mode 100644 internal/routes/package.go create mode 100644 internal/routes/package_series.go create mode 100644 internal/service/package/service.go create mode 100644 internal/service/package_series/service.go create mode 100644 internal/service/package_series/service_test.go create mode 100644 internal/store/postgres/package_series_store.go create mode 100644 internal/store/postgres/package_series_store_test.go create mode 100644 internal/store/postgres/package_store.go create mode 100644 internal/store/postgres/package_store_test.go create mode 100644 migrations/000024_cleanup_deprecated_models_and_add_package_fields.down.sql create mode 100644 migrations/000024_cleanup_deprecated_models_and_add_package_fields.up.sql create mode 100644 openspec/changes/add-card-device-series-bindng/.openspec.yaml create mode 100644 openspec/changes/add-card-device-series-bindng/design.md create mode 100644 openspec/changes/add-card-device-series-bindng/proposal.md create mode 100644 openspec/changes/add-card-device-series-bindng/specs/card-series-bindng/spec.md create mode 100644 openspec/changes/add-card-device-series-bindng/specs/device-series-bindng/spec.md create mode 100644 openspec/changes/add-card-device-series-bindng/tasks.md create mode 100644 openspec/changes/add-one-time-commission/.openspec.yaml create mode 100644 openspec/changes/add-one-time-commission/design.md create mode 100644 openspec/changes/add-one-time-commission/proposal.md create mode 100644 openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md create mode 100644 openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md create mode 100644 openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md create mode 100644 openspec/changes/add-one-time-commission/tasks.md create mode 100644 openspec/changes/add-order-payment/.openspec.yaml create mode 100644 openspec/changes/add-order-payment/design.md create mode 100644 openspec/changes/add-order-payment/proposal.md create mode 100644 openspec/changes/add-order-payment/specs/order-management/spec.md create mode 100644 openspec/changes/add-order-payment/specs/order-payment/spec.md create mode 100644 openspec/changes/add-order-payment/specs/package-purchase-validation/spec.md create mode 100644 openspec/changes/add-order-payment/tasks.md create mode 100644 openspec/changes/add-shop-package-allocation/.openspec.yaml create mode 100644 openspec/changes/add-shop-package-allocation/design.md create mode 100644 openspec/changes/add-shop-package-allocation/proposal.md create mode 100644 openspec/changes/add-shop-package-allocation/specs/agent-available-packages/spec.md create mode 100644 openspec/changes/add-shop-package-allocation/specs/shop-commission-tier/spec.md create mode 100644 openspec/changes/add-shop-package-allocation/specs/shop-package-allocation/spec.md create mode 100644 openspec/changes/add-shop-package-allocation/specs/shop-series-allocation/spec.md create mode 100644 openspec/changes/add-shop-package-allocation/tasks.md create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/.openspec.yaml create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/design.md create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/proposal.md create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/specs/package-management/spec.md create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/specs/package-series-management/spec.md create mode 100644 openspec/changes/archive/2026-01-27-add-package-module/tasks.md create mode 100644 openspec/specs/package-management/spec.md create mode 100644 openspec/specs/package-series-management/spec.md create mode 100644 tests/integration/package_test.go diff --git a/AGENTS.md b/AGENTS.md index 14c519b..e8533f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,15 @@ handler.HandleIotCardImport(ctx, asynqTask) // 测试完整流程,验证真 **详细规范**: [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 func TestXxx(t *testing.T) { diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 07a4b48..289b542 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -46,6 +46,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), Carrier: admin.NewCarrierHandler(nil), + PackageSeries: admin.NewPackageSeriesHandler(nil), + Package: admin.NewPackageHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 21f10e2..5363612 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -55,6 +55,8 @@ func generateAdminDocs(outputPath string) error { AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), Carrier: admin.NewCarrierHandler(nil), + PackageSeries: admin.NewPackageSeriesHandler(nil), + Package: admin.NewPackageHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 5f1190f..1c2c458 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -821,6 +821,91 @@ components: description: 提现单号 type: string 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: properties: parent_id: @@ -1963,6 +2048,130 @@ components: description: 已提现佣金(分) type: integer 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: properties: items: @@ -2873,6 +3082,102 @@ components: required: - status 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: properties: new_password: @@ -7062,6 +7367,664 @@ paths: summary: 发起提现申请 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: put: requestBody: diff --git a/docs/testing/test-connection-guide.md b/docs/testing/test-connection-guide.md index 025f825..9ed241a 100644 --- a/docs/testing/test-connection-guide.md +++ b/docs/testing/test-connection-guide.md @@ -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 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 2095589..2d91400 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -34,5 +34,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), Storage: admin.NewStorageHandler(deps.StorageService), Carrier: admin.NewCarrierHandler(svc.Carrier), + PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries), + Package: admin.NewPackageHandler(svc.Package), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index efffc46..1232232 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -15,6 +15,8 @@ import ( iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import" 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" personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer" roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role" @@ -45,6 +47,8 @@ type services struct { DeviceImport *deviceImportSvc.Service AssetAllocationRecord *assetAllocationRecordSvc.Service Carrier *carrierSvc.Service + PackageSeries *packageSeriesSvc.Service + Package *packageSvc.Service } 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), AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), Carrier: carrierSvc.New(s.Carrier), + PackageSeries: packageSeriesSvc.New(s.PackageSeries), + Package: packageSvc.New(s.Package, s.PackageSeries), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 8817ee4..f0fce23 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -27,6 +27,8 @@ type stores struct { DeviceImportTask *postgres.DeviceImportTaskStore AssetAllocationRecord *postgres.AssetAllocationRecordStore Carrier *postgres.CarrierStore + PackageSeries *postgres.PackageSeriesStore + Package *postgres.PackageStore } func initStores(deps *Dependencies) *stores { @@ -53,5 +55,7 @@ func initStores(deps *Dependencies) *stores { DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), Carrier: postgres.NewCarrierStore(deps.DB), + PackageSeries: postgres.NewPackageSeriesStore(deps.DB), + Package: postgres.NewPackageStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 47e788a..e274508 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -32,6 +32,8 @@ type Handlers struct { AssetAllocationRecord *admin.AssetAllocationRecordHandler Storage *admin.StorageHandler Carrier *admin.CarrierHandler + PackageSeries *admin.PackageSeriesHandler + Package *admin.PackageHandler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/package.go b/internal/handler/admin/package.go new file mode 100644 index 0000000..b94d8df --- /dev/null +++ b/internal/handler/admin/package.go @@ -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) +} diff --git a/internal/handler/admin/package_series.go b/internal/handler/admin/package_series.go new file mode 100644 index 0000000..2e35d75 --- /dev/null +++ b/internal/handler/admin/package_series.go @@ -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) +} diff --git a/internal/model/commission.go b/internal/model/commission.go index d4dd306..6185a4c 100644 --- a/internal/model/commission.go +++ b/internal/model/commission.go @@ -6,85 +6,6 @@ import ( "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 分佣记录模型 // 记录分佣的冻结、解冻、发放状态 type CommissionRecord struct { @@ -106,57 +27,3 @@ type CommissionRecord struct { func (CommissionRecord) TableName() string { 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" -} diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go new file mode 100644 index 0000000..f9f0274 --- /dev/null +++ b/internal/model/dto/package_dto.go @@ -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:"总页数"` +} diff --git a/internal/model/dto/package_series_dto.go b/internal/model/dto/package_series_dto.go new file mode 100644 index 0000000..ee5a06a --- /dev/null +++ b/internal/model/dto/package_series_dto.go @@ -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:"总页数"` +} diff --git a/internal/model/package.go b/internal/model/package.go index 7745e0d..b200b0b 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -26,18 +26,21 @@ func (PackageSeries) TableName() string { // 只适用于 IoT 卡,支持真流量/虚流量共存机制 type Package struct { gorm.Model - BaseModel `gorm:"embedded"` - PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"` - PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"` - SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"` - PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"` - DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"` - DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"` - RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"` - VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"` - 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"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` + BaseModel `gorm:"embedded"` + PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"` + PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"` + SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"` + PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"` + DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"` + DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"` + RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"` + VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"` + 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"` + 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 指定表名 @@ -45,23 +48,6 @@ func (Package) TableName() string { 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 套餐使用情况模型 // 跟踪单卡套餐和设备级套餐的流量使用 type PackageUsage struct { diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 9dc7cd0..f10eef7 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -70,6 +70,12 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.Carrier != nil { 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) { diff --git a/internal/routes/package.go b/internal/routes/package.go new file mode 100644 index 0000000..d38879d --- /dev/null +++ b/internal/routes/package.go @@ -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, + }) +} diff --git a/internal/routes/package_series.go b/internal/routes/package_series.go new file mode 100644 index 0000000..1754d00 --- /dev/null +++ b/internal/routes/package_series.go @@ -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, + }) +} diff --git a/internal/service/package/service.go b/internal/service/package/service.go new file mode 100644 index 0000000..ee98fd6 --- /dev/null +++ b/internal/service/package/service.go @@ -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), + } +} diff --git a/internal/service/package/service_test.go b/internal/service/package/service_test.go index 4a80071..1b02c49 100644 --- a/internal/service/package/service_test.go +++ b/internal/service/package/service_test.go @@ -122,12 +122,12 @@ func TestPackageService_UpdateStatus(t *testing.T) { require.NoError(t, err) 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) pkg, err = svc.Get(ctx, created.ID) require.NoError(t, err) - assert.Equal(t, 2, pkg.Status) + assert.Equal(t, constants.StatusDisabled, pkg.Status) assert.Equal(t, 2, pkg.ShelfStatus) }) @@ -145,20 +145,20 @@ func TestPackageService_UpdateStatus(t *testing.T) { err = svc.UpdateShelfStatus(ctx, created2.ID, 1) require.NoError(t, err) - err = svc.UpdateStatus(ctx, created2.ID, 2) + err = svc.UpdateStatus(ctx, created2.ID, constants.StatusDisabled) require.NoError(t, err) pkg, err := svc.Get(ctx, created2.ID) require.NoError(t, err) - assert.Equal(t, 2, pkg.Status) + assert.Equal(t, constants.StatusDisabled, pkg.Status) 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) pkg, err = svc.Get(ctx, created2.ID) require.NoError(t, err) - assert.Equal(t, 1, pkg.Status) + assert.Equal(t, constants.StatusEnabled, pkg.Status) assert.Equal(t, 2, pkg.ShelfStatus) }) } @@ -209,7 +209,7 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) { created, err := svc.Create(ctx, req) require.NoError(t, err) - err = svc.UpdateStatus(ctx, created.ID, 2) + err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled) require.NoError(t, err) err = svc.UpdateShelfStatus(ctx, created.ID, 1) diff --git a/internal/service/package_series/service.go b/internal/service/package_series/service.go new file mode 100644 index 0000000..cf70522 --- /dev/null +++ b/internal/service/package_series/service.go @@ -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), + } +} diff --git a/internal/service/package_series/service_test.go b/internal/service/package_series/service_test.go new file mode 100644 index 0000000..7c1f063 --- /dev/null +++ b/internal/service/package_series/service_test.go @@ -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) + }) +} diff --git a/internal/store/postgres/iot_card_store_test.go b/internal/store/postgres/iot_card_store_test.go index 493816f..228c8dd 100644 --- a/internal/store/postgres/iot_card_store_test.go +++ b/internal/store/postgres/iot_card_store_test.go @@ -2,7 +2,9 @@ package postgres import ( "context" + "fmt" "testing" + "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store" @@ -11,6 +13,10 @@ import ( "github.com/stretchr/testify/require" ) +func uniqueICCIDPrefix() string { + return fmt.Sprintf("T%d", time.Now().UnixNano()%1000000000) +} + func TestIotCardStore_Create(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) @@ -94,15 +100,16 @@ func TestIotCardStore_ListStandalone(t *testing.T) { s := NewIotCardStore(tx, rdb) ctx := context.Background() + prefix := uniqueICCIDPrefix() standaloneCards := []*model.IotCard{ - {ICCID: "89860012345678903001", CardType: "data_card", CarrierID: 1, Status: 1}, - {ICCID: "89860012345678903002", CardType: "data_card", CarrierID: 1, Status: 1}, - {ICCID: "89860012345678903003", CardType: "data_card", CarrierID: 2, Status: 2}, + {ICCID: prefix + "0001", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: prefix + "0002", CardType: "data_card", CarrierID: 1, Status: 1}, + {ICCID: prefix + "0003", CardType: "data_card", CarrierID: 2, Status: 2}, } require.NoError(t, s.CreateBatch(ctx, standaloneCards)) boundCard := &model.IotCard{ - ICCID: "89860012345678903004", + ICCID: prefix + "0004", CardType: "data_card", CarrierID: 1, Status: 1, @@ -117,7 +124,8 @@ func TestIotCardStore_ListStandalone(t *testing.T) { require.NoError(t, tx.Create(binding).Error) 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) assert.Equal(t, int64(3), total) assert.Len(t, cards, 3) @@ -128,7 +136,7 @@ func TestIotCardStore_ListStandalone(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) require.NoError(t, err) assert.Equal(t, int64(2), total) @@ -138,7 +146,7 @@ func TestIotCardStore_ListStandalone(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) require.NoError(t, err) assert.Equal(t, int64(1), total) @@ -147,26 +155,28 @@ func TestIotCardStore_ListStandalone(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) require.NoError(t, err) 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) { - 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) assert.Equal(t, int64(3), total) 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) assert.Len(t, cards2, 1) }) 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) assert.Equal(t, int64(3), total) assert.Len(t, cards, 3) @@ -181,39 +191,43 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) { s := NewIotCardStore(tx, rdb) 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{ - {ICCID: "89860012345678904001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: "BATCH001", MSISDN: "13800000001"}, - {ICCID: "89860012345678904002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001", MSISDN: "13800000002"}, - {ICCID: "89860012345678904003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH002", MSISDN: "13800000003"}, + {ICCID: prefix + "A001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "01"}, + {ICCID: prefix + "A002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "02"}, + {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)) t.Run("按店铺ID过滤", func(t *testing.T) { - filters := map[string]interface{}{"shop_id": shopID} - cards, total, err := s.ListStandalone(ctx, nil, filters) + filters := map[string]interface{}{"shop_id": shopID, "iccid": prefix} + result, total, err := s.ListStandalone(ctx, nil, filters) require.NoError(t, err) 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) { - filters := map[string]interface{}{"batch_no": "BATCH001"} + filters := map[string]interface{}{"batch_no": batchPrefix + "01", "iccid": prefix} _, total, err := s.ListStandalone(ctx, nil, filters) require.NoError(t, err) assert.Equal(t, int64(2), total) }) 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) require.NoError(t, err) 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) { - filters := map[string]interface{}{"is_distributed": true} + filters := map[string]interface{}{"is_distributed": true, "iccid": prefix} result, total, err := s.ListStandalone(ctx, nil, filters) require.NoError(t, err) assert.Equal(t, int64(1), total) @@ -221,7 +235,7 @@ func TestIotCardStore_ListStandalone_Filters(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) require.NoError(t, err) assert.Equal(t, int64(2), total) @@ -232,8 +246,8 @@ func TestIotCardStore_ListStandalone_Filters(t *testing.T) { t.Run("ICCID范围查询", func(t *testing.T) { filters := map[string]interface{}{ - "iccid_start": "89860012345678904001", - "iccid_end": "89860012345678904002", + "iccid_start": prefix + "A001", + "iccid_end": prefix + "A002", } _, total, err := s.ListStandalone(ctx, nil, filters) require.NoError(t, err) diff --git a/internal/store/postgres/package_series_store.go b/internal/store/postgres/package_series_store.go new file mode 100644 index 0000000..bd81e9c --- /dev/null +++ b/internal/store/postgres/package_series_store.go @@ -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 +} diff --git a/internal/store/postgres/package_series_store_test.go b/internal/store/postgres/package_series_store_test.go new file mode 100644 index 0000000..622d2e7 --- /dev/null +++ b/internal/store/postgres/package_series_store_test.go @@ -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) +} diff --git a/internal/store/postgres/package_store.go b/internal/store/postgres/package_store.go new file mode 100644 index 0000000..4a0d9f7 --- /dev/null +++ b/internal/store/postgres/package_store.go @@ -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 +} diff --git a/internal/store/postgres/package_store_test.go b/internal/store/postgres/package_store_test.go new file mode 100644 index 0000000..69e2937 --- /dev/null +++ b/internal/store/postgres/package_store_test.go @@ -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) +} diff --git a/migrations/000024_cleanup_deprecated_models_and_add_package_fields.down.sql b/migrations/000024_cleanup_deprecated_models_and_add_package_fields.down.sql new file mode 100644 index 0000000..674f847 --- /dev/null +++ b/migrations/000024_cleanup_deprecated_models_and_add_package_fields.down.sql @@ -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 '代理套餐分配表'; diff --git a/migrations/000024_cleanup_deprecated_models_and_add_package_fields.up.sql b/migrations/000024_cleanup_deprecated_models_and_add_package_fields.up.sql new file mode 100644 index 0000000..35bc36e --- /dev/null +++ b/migrations/000024_cleanup_deprecated_models_and_add_package_fields.up.sql @@ -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-下架'; diff --git a/openspec/changes/add-card-device-series-bindng/.openspec.yaml b/openspec/changes/add-card-device-series-bindng/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/add-card-device-series-bindng/design.md b/openspec/changes/add-card-device-series-bindng/design.md new file mode 100644 index 0000000..fc33d22 --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/design.md @@ -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 + - 待确认:是否需要双向同步? diff --git a/openspec/changes/add-card-device-series-bindng/proposal.md b/openspec/changes/add-card-device-series-bindng/proposal.md new file mode 100644 index 0000000..b6e8297 --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/proposal.md @@ -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(订单与支付)依赖本期 diff --git a/openspec/changes/add-card-device-series-bindng/specs/card-series-bindng/spec.md b/openspec/changes/add-card-device-series-bindng/specs/card-series-bindng/spec.md new file mode 100644 index 0000000..37aaf20 --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/specs/card-series-bindng/spec.md @@ -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** 响应包含这三个新字段 diff --git a/openspec/changes/add-card-device-series-bindng/specs/device-series-bindng/spec.md b/openspec/changes/add-card-device-series-bindng/specs/device-series-bindng/spec.md new file mode 100644 index 0000000..50bcf39 --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/specs/device-series-bindng/spec.md @@ -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** 该设备无法购买设备级套餐 diff --git a/openspec/changes/add-card-device-series-bindng/tasks.md b/openspec/changes/add-card-device-series-bindng/tasks.md new file mode 100644 index 0000000..9e747b0 --- /dev/null +++ b/openspec/changes/add-card-device-series-bindng/tasks.md @@ -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 验证列表筛选功能正常 diff --git a/openspec/changes/add-one-time-commission/.openspec.yaml b/openspec/changes/add-one-time-commission/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/add-one-time-commission/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/add-one-time-commission/design.md b/openspec/changes/add-one-time-commission/design.md new file mode 100644 index 0000000..b64ca5f --- /dev/null +++ b/openspec/changes/add-one-time-commission/design.md @@ -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. **一次性佣金发放给谁?** + - 当前设计:发放给卡/设备的直接归属店铺 + - 待确认:是否需要多级分佣? diff --git a/openspec/changes/add-one-time-commission/proposal.md b/openspec/changes/add-one-time-commission/proposal.md new file mode 100644 index 0000000..c24dd62 --- /dev/null +++ b/openspec/changes/add-one-time-commission/proposal.md @@ -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 + + + +## 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 的卡/设备佣金状态字段 diff --git a/openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md b/openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md new file mode 100644 index 0000000..f3fd69c --- /dev/null +++ b/openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md @@ -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(梯度奖励) diff --git a/openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md b/openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md new file mode 100644 index 0000000..5467370 --- /dev/null +++ b/openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md @@ -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 天的数据 diff --git a/openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md b/openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md new file mode 100644 index 0000000..31cd706 --- /dev/null +++ b/openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md @@ -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 的钱包 diff --git a/openspec/changes/add-one-time-commission/tasks.md b/openspec/changes/add-one-time-commission/tasks.md new file mode 100644 index 0000000..0505f9c --- /dev/null +++ b/openspec/changes/add-one-time-commission/tasks.md @@ -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 验证佣金统计数据正确 diff --git a/openspec/changes/add-order-payment/.openspec.yaml b/openspec/changes/add-order-payment/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/add-order-payment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/add-order-payment/design.md b/openspec/changes/add-order-payment/design.md new file mode 100644 index 0000000..daaa998 --- /dev/null +++ b/openspec/changes/add-order-payment/design.md @@ -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. **代理为终端用户购买?** + - 当前设计:代理只能为自己店铺购买 + - 待确认:是否需要代理帮终端用户购买的场景? diff --git a/openspec/changes/add-order-payment/proposal.md b/openspec/changes/add-order-payment/proposal.md new file mode 100644 index 0000000..b53b604 --- /dev/null +++ b/openspec/changes/add-order-payment/proposal.md @@ -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 模型 diff --git a/openspec/changes/add-order-payment/specs/order-management/spec.md b/openspec/changes/add-order-payment/specs/order-management/spec.md new file mode 100644 index 0000000..43e7e3c --- /dev/null +++ b/openspec/changes/add-order-payment/specs/order-management/spec.md @@ -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** 每个订单的订单号都唯一 diff --git a/openspec/changes/add-order-payment/specs/order-payment/spec.md b/openspec/changes/add-order-payment/specs/order-payment/spec.md new file mode 100644 index 0000000..beb80b3 --- /dev/null +++ b/openspec/changes/add-order-payment/specs/order-payment/spec.md @@ -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** 事务回滚,余额恢复,订单状态不变 diff --git a/openspec/changes/add-order-payment/specs/package-purchase-validation/spec.md b/openspec/changes/add-order-payment/specs/package-purchase-validation/spec.md new file mode 100644 index 0000000..477a87c --- /dev/null +++ b/openspec/changes/add-order-payment/specs/package-purchase-validation/spec.md @@ -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** 验证失败,返回 "该设备未关联套餐系列" diff --git a/openspec/changes/add-order-payment/tasks.md b/openspec/changes/add-order-payment/tasks.md new file mode 100644 index 0000000..55ed250 --- /dev/null +++ b/openspec/changes/add-order-payment/tasks.md @@ -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 记录创建) diff --git a/openspec/changes/add-shop-package-allocation/.openspec.yaml b/openspec/changes/add-shop-package-allocation/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/add-shop-package-allocation/design.md b/openspec/changes/add-shop-package-allocation/design.md new file mode 100644 index 0000000..8354f4a --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/design.md @@ -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. **梯度佣金是否可叠加?** + - 当前设计:达到最高档位只拿最高档佣金 + - 待确认:是否需要累加所有达标档位的佣金? diff --git a/openspec/changes/add-shop-package-allocation/proposal.md b/openspec/changes/add-shop-package-allocation/proposal.md new file mode 100644 index 0000000..c3a88c8 --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/proposal.md @@ -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(卡/设备关联)依赖本期 diff --git a/openspec/changes/add-shop-package-allocation/specs/agent-available-packages/spec.md b/openspec/changes/add-shop-package-allocation/specs/agent-available-packages/spec.md new file mode 100644 index 0000000..328ed3c --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/specs/agent-available-packages/spec.md @@ -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** 每个系列包含:系列信息、可售套餐数量、加价模式信息 diff --git a/openspec/changes/add-shop-package-allocation/specs/shop-commission-tier/spec.md b/openspec/changes/add-shop-package-allocation/specs/shop-commission-tier/spec.md new file mode 100644 index 0000000..5971a50 --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/specs/shop-commission-tier/spec.md @@ -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** 系统返回参数验证错误 diff --git a/openspec/changes/add-shop-package-allocation/specs/shop-package-allocation/spec.md b/openspec/changes/add-shop-package-allocation/specs/shop-package-allocation/spec.md new file mode 100644 index 0000000..df83cb8 --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/specs/shop-package-allocation/spec.md @@ -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** 该套餐恢复使用覆盖成本价 diff --git a/openspec/changes/add-shop-package-allocation/specs/shop-series-allocation/spec.md b/openspec/changes/add-shop-package-allocation/specs/shop-series-allocation/spec.md new file mode 100644 index 0000000..3c6c409 --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/specs/shop-series-allocation/spec.md @@ -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 + 加价值 diff --git a/openspec/changes/add-shop-package-allocation/tasks.md b/openspec/changes/add-shop-package-allocation/tasks.md new file mode 100644 index 0000000..bb273b7 --- /dev/null +++ b/openspec/changes/add-shop-package-allocation/tasks.md @@ -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 验证成本价计算逻辑正确 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/.openspec.yaml b/openspec/changes/archive/2026-01-27-add-package-module/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/design.md b/openspec/changes/archive/2026-01-27-add-package-module/design.md new file mode 100644 index 0000000..c464a09 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/design.md @@ -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 实现代理分配后再添加约束检查 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/proposal.md b/openspec/changes/archive/2026-01-27-add-package-module/proposal.md new file mode 100644 index 0000000..9e1f920 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/proposal.md @@ -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 + + + +## 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(代理套餐分配)依赖本期完成 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/specs/package-management/spec.md b/openspec/changes/archive/2026-01-27-add-package-module/specs/package-management/spec.md new file mode 100644 index 0000000..df97867 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/specs/package-management/spec.md @@ -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** 项目能够正常编译,无编译错误 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/specs/package-series-management/spec.md b/openspec/changes/archive/2026-01-27-add-package-module/specs/package-series-management/spec.md new file mode 100644 index 0000000..d335218 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/specs/package-series-management/spec.md @@ -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** 系统正常返回成功,不产生错误 diff --git a/openspec/changes/archive/2026-01-27-add-package-module/tasks.md b/openspec/changes/archive/2026-01-27-add-package-module/tasks.md new file mode 100644 index 0000000..bd35199 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-add-package-module/tasks.md @@ -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 文档正确生成 diff --git a/openspec/specs/package-management/spec.md b/openspec/specs/package-management/spec.md new file mode 100644 index 0000000..df97867 --- /dev/null +++ b/openspec/specs/package-management/spec.md @@ -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** 项目能够正常编译,无编译错误 diff --git a/openspec/specs/package-series-management/spec.md b/openspec/specs/package-series-management/spec.md new file mode 100644 index 0000000..d335218 --- /dev/null +++ b/openspec/specs/package-series-management/spec.md @@ -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** 系统正常返回成功,不产生错误 diff --git a/tests/integration/package_test.go b/tests/integration/package_test.go new file mode 100644 index 0000000..67cd8bf --- /dev/null +++ b/tests/integration/package_test.go @@ -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, "删除后应查不到套餐") +} diff --git a/tests/integration/shop_management_test.go b/tests/integration/shop_management_test.go index 3a128ca..34aa7cb 100644 --- a/tests/integration/shop_management_test.go +++ b/tests/integration/shop_management_test.go @@ -17,6 +17,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/tests/testutil" + "github.com/break/junhong_cmp_fiber/tests/testutils" "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -30,7 +31,7 @@ import ( // shopManagementTestEnv 商户管理测试环境 type shopManagementTestEnv struct { tx *gorm.DB - rdb *redis.Client + rdb *redis.Client tokenManager *auth.TokenManager app *fiber.App adminToken string @@ -113,7 +114,7 @@ func setupShopManagementTestEnv(t *testing.T) *shopManagementTestEnv { return &shopManagementTestEnv{ tx: tx, - rdb: rdb, + rdb: rdb, tokenManager: tokenManager, app: app, adminToken: adminToken, @@ -146,7 +147,7 @@ func TestShopManagement_CreateShop(t *testing.T) { ShopName: "测试商户", ShopCode: "TEST001", InitUsername: "testuser", - InitPhone: "13800138000", + InitPhone: testutils.GenerateUniquePhone(), InitPassword: "password123", } @@ -191,12 +192,11 @@ func TestShopManagement_CreateShop_DuplicateCode(t *testing.T) { env := setupShopManagementTestEnv(t) defer env.teardown() - // 通过 API 创建第一个商户 firstReq := dto.CreateShopRequest{ ShopName: "商户1", ShopCode: "DUP001", InitUsername: "dupuser1", - InitPhone: "13800138101", + InitPhone: testutils.GenerateUniquePhone(), InitPassword: "password123", } firstBody, _ := json.Marshal(firstReq) @@ -210,12 +210,11 @@ func TestShopManagement_CreateShop_DuplicateCode(t *testing.T) { require.Equal(t, 0, firstResult.Code, "第一个商户应该创建成功") - // 尝试创建编码重复的商户 reqBody := dto.CreateShopRequest{ ShopName: "商户2", - ShopCode: "DUP001", // 使用相同编码 + ShopCode: "DUP001", InitUsername: "dupuser2", - InitPhone: "13800138102", + InitPhone: testutils.GenerateUniquePhone(), InitPassword: "password123", } diff --git a/tests/integration/task_test.go b/tests/integration/task_test.go index 1185db6..de84b89 100644 --- a/tests/integration/task_test.go +++ b/tests/integration/task_test.go @@ -14,7 +14,6 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" ) -// EmailPayload 邮件任务载荷(测试用) type EmailPayload struct { RequestID string `json:"request_id"` To string `json:"to"` @@ -23,21 +22,21 @@ type EmailPayload struct { CC []string `json:"cc,omitempty"` } -// TestTaskSubmit 测试任务提交 func TestTaskSubmit(t *testing.T) { - // 创建 Redis 客户端 rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() - // 清理测试数据 ctx := context.Background() - rdb.FlushDB(ctx) + cleanTestKeys(t, rdb, ctx) - // 创建 Asynq 客户端 client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = client.Close() }() @@ -66,20 +65,21 @@ func TestTaskSubmit(t *testing.T) { assert.Equal(t, constants.DefaultRetryMax, info.MaxRetry) } -// TestTaskPriority 测试任务优先级 func TestTaskPriority(t *testing.T) { - // 创建 Redis 客户端 rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() ctx := context.Background() - rdb.FlushDB(ctx) + cleanTestKeys(t, rdb, ctx) - // 创建 Asynq 客户端 client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = client.Close() }() @@ -113,18 +113,21 @@ func TestTaskPriority(t *testing.T) { } } -// TestTaskRetry 测试任务重试机制 func TestTaskRetry(t *testing.T) { rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() ctx := context.Background() - rdb.FlushDB(ctx) + cleanTestKeys(t, rdb, ctx) client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = client.Close() }() @@ -150,20 +153,22 @@ func TestTaskRetry(t *testing.T) { assert.Equal(t, 30*time.Second, info.Timeout) } -// TestTaskIdempotency 测试任务幂等性键 func TestTaskIdempotency(t *testing.T) { rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() 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) + rdb.Del(ctx, lockKey) + t.Cleanup(func() { rdb.Del(ctx, lockKey) }) - // 第一次设置锁(模拟任务开始执行) result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result() require.NoError(t, err) assert.True(t, result, "第一次设置锁应该成功") @@ -185,15 +190,16 @@ func TestTaskIdempotency(t *testing.T) { assert.LessOrEqual(t, ttl.Hours(), 24.0) } -// TestTaskStatusTracking 测试任务状态跟踪 func TestTaskStatusTracking(t *testing.T) { rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() ctx := context.Background() - rdb.FlushDB(ctx) + cleanTestKeys(t, rdb, ctx) taskID := "task-123456" statusKey := constants.RedisTaskStatusKey(taskID) @@ -217,18 +223,21 @@ func TestTaskStatusTracking(t *testing.T) { assert.Greater(t, ttl.Hours(), 24.0*6) } -// TestQueueInspection 测试队列检查 func TestQueueInspection(t *testing.T) { rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = rdb.Close() }() ctx := context.Background() - rdb.FlushDB(ctx) + cleanTestKeys(t, rdb, ctx) client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = client.Close() }() @@ -249,9 +258,10 @@ func TestQueueInspection(t *testing.T) { require.NoError(t, err) } - // 创建 Inspector 检查队列 inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", + Addr: testRedisAddr, + Password: testRedisPasswd, + DB: testRedisDB, }) defer func() { _ = inspector.Close() }() @@ -262,7 +272,6 @@ func TestQueueInspection(t *testing.T) { assert.Equal(t, 0, info.Active) } -// TestTaskSerialization 测试任务序列化 func TestTaskSerialization(t *testing.T) { tests := []struct { name string @@ -291,17 +300,14 @@ func TestTaskSerialization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // 序列化 payloadBytes, err := sonic.Marshal(tt.payload) require.NoError(t, err) assert.NotEmpty(t, payloadBytes) - // 反序列化 var decoded EmailPayload err = sonic.Unmarshal(payloadBytes, &decoded) require.NoError(t, err) - // 验证 assert.Equal(t, tt.payload.RequestID, decoded.RequestID) assert.Equal(t, tt.payload.To, decoded.To) 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...) + } +} diff --git a/tests/testutils/setup.go b/tests/testutils/setup.go index ef98298..b47c4a3 100644 --- a/tests/testutils/setup.go +++ b/tests/testutils/setup.go @@ -15,6 +15,13 @@ func GeneratePhone(prefix string, index int) string { 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 返回当前时间 func Now() time.Time { return time.Now() diff --git a/tests/unit/shop_service_test.go b/tests/unit/shop_service_test.go index c4c6d04..6b33294 100644 --- a/tests/unit/shop_service_test.go +++ b/tests/unit/shop_service_test.go @@ -39,7 +39,7 @@ func TestShopService_Create(t *testing.T) { District: "朝阳区", Address: "朝阳路100号", InitUsername: generateUniqueUsername("admin", t), - InitPhone: "13800138001", + InitPhone: generateUniquePhone(), InitPassword: "password123", } @@ -80,7 +80,7 @@ func TestShopService_Create(t *testing.T) { ContactName: "王五", ContactPhone: "13800000003", InitUsername: generateUniqueUsername("agent", t), - InitPhone: "13800138002", + InitPhone: generateUniquePhone(), InitPassword: "password123", } @@ -132,7 +132,7 @@ func TestShopService_Create(t *testing.T) { ContactName: "测试", ContactPhone: "13800000008", InitUsername: generateUniqueUsername("level8", t), - InitPhone: "13800138008", + InitPhone: generateUniquePhone(), InitPassword: "password123", } @@ -195,7 +195,7 @@ func TestShopService_Create(t *testing.T) { ContactName: "测试", ContactPhone: "13800000009", InitUsername: generateUniqueUsername("invalid", t), - InitPhone: "13800138009", + InitPhone: generateUniquePhone(), InitPassword: "password123", } @@ -219,7 +219,7 @@ func TestShopService_Create(t *testing.T) { ContactName: "测试", ContactPhone: "13800000010", InitUsername: generateUniqueUsername("unauth", t), - InitPhone: "13800138010", + InitPhone: generateUniquePhone(), InitPassword: "password123", }