diff --git a/AGENTS.md b/AGENTS.md index 301789c..e83dabf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ | 维护规范文档 | `doc-management` | 规范文档流程和维护规则 | | 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 | | 涉及业务逻辑的开发/修改 | `kb-sync` | 开发前查阅知识库、开发后更新知识库、业务决策记录 | +| 编写接口测试 / Hurl 测试 | `hurl-test` | 四阶段交互式生成 .hurl 测试文件(探索→确认→生成→验证),DTO 驱动的字段完整性断言 | --- diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 86b538f..672a395 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -832,7 +832,7 @@ components: $ref: '#/components/schemas/DtoBoundCardInfo' type: array current_month_usage_mb: - description: 本月已用流量MB(asset_type=card时有效) + description: 系统累计的自然月流量MB(asset_type=card时有效) type: number device_protect_status: description: 保护期状态(asset_type=device时有效):none/stop/start @@ -1533,6 +1533,9 @@ components: created_at: description: 创建时间 type: string + data_reset_day: + description: 上游流量重置日(1-28) + type: integer description: description: 运营商描述 type: string @@ -2150,6 +2153,12 @@ components: carrier_type: description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) type: string + data_reset_day: + description: 上游流量重置日(1-28),运营商每月清零网关计数器的日期,默认1 + maximum: 28 + minimum: 1 + nullable: true + type: integer description: description: 运营商描述 maxLength: 500 @@ -3101,6 +3110,10 @@ components: description: 电池电量百分比(无电池时为null) nullable: true type: integer + client_number: + description: 当前已连接客户端数 + nullable: true + type: integer connect_time: description: 本次联网时间(秒) nullable: true @@ -3383,6 +3396,10 @@ components: description: 电池电量百分比 nullable: true type: integer + client_number: + description: 当前已连接客户端数 + nullable: true + type: integer connect_time: description: 设备本次联网时间(秒) nullable: true @@ -6733,6 +6750,12 @@ components: minLength: 1 nullable: true type: string + data_reset_day: + description: 上游流量重置日(1-28),运营商每月清零网关计数器的日期 + maximum: 28 + minimum: 1 + nullable: true + type: integer description: description: 运营商描述 maxLength: 500 @@ -18520,7 +18543,7 @@ paths: summary: 获取轮询任务统计 tags: - 轮询管理-监控 - /api/admin/refunds/: + /api/admin/refunds: get: parameters: - description: 页码(默认1) diff --git a/internal/handler/admin/polling_alert.go b/internal/handler/admin/polling_alert.go index 23c5aa4..1ad3514 100644 --- a/internal/handler/admin/polling_alert.go +++ b/internal/handler/admin/polling_alert.go @@ -229,17 +229,11 @@ func (h *PollingAlertHandler) ListHistory(c *fiber.Ctx) error { }) } - totalPages := int(total) / req.PageSize - if int(total)%req.PageSize > 0 { - totalPages++ - } - return response.Success(c, &dto.PollingAlertHistoryListResp{ - Items: items, - Total: total, - Page: req.Page, - PageSize: req.PageSize, - TotalPages: totalPages, + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, }) } diff --git a/internal/handler/admin/polling_cleanup.go b/internal/handler/admin/polling_cleanup.go index 30edbc6..3aeedab 100644 --- a/internal/handler/admin/polling_cleanup.go +++ b/internal/handler/admin/polling_cleanup.go @@ -214,17 +214,11 @@ func (h *PollingCleanupHandler) ListLogs(c *fiber.Ctx) error { items = append(items, h.toLogResp(log)) } - totalPages := int(total) / req.PageSize - if int(total)%req.PageSize > 0 { - totalPages++ - } - return response.Success(c, &dto.DataCleanupLogListResp{ - Items: items, - Total: total, - Page: req.Page, - PageSize: req.PageSize, - TotalPages: totalPages, + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, }) } diff --git a/internal/handler/admin/polling_manual_trigger.go b/internal/handler/admin/polling_manual_trigger.go index badfabc..1d8a197 100644 --- a/internal/handler/admin/polling_manual_trigger.go +++ b/internal/handler/admin/polling_manual_trigger.go @@ -206,17 +206,11 @@ func (h *PollingManualTriggerHandler) ListHistory(c *fiber.Ctx) error { items = append(items, h.toLogResp(log)) } - totalPages := int(total) / req.PageSize - if int(total)%req.PageSize > 0 { - totalPages++ - } - return response.Success(c, &dto.ManualTriggerLogListResp{ - Items: items, - Total: total, - Page: req.Page, - PageSize: req.PageSize, - TotalPages: totalPages, + Items: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, }) } diff --git a/internal/handler/app/client_auth.go b/internal/handler/app/client_auth.go index 14b0a35..ad339e2 100644 --- a/internal/handler/app/client_auth.go +++ b/internal/handler/app/client_auth.go @@ -149,6 +149,29 @@ func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error { return response.Success(c, resp) } +// DevLogin 开发环境测试登录(仅开发模式可用) +// POST /api/c/v1/auth/dev-login +func (h *ClientAuthHandler) DevLogin(c *fiber.Ctx) error { + var req dto.DevLoginRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientAuthValidator.Struct(&req); err != nil { + h.logger.Warn("测试登录参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + token, customerID, isNewUser, err := h.service.DevLogin(c.UserContext(), req.Identifier) + if err != nil { + return err + } + return response.Success(c, dto.DevLoginResponse{ + Token: token, + CustomerID: customerID, + IsNewUser: isNewUser, + }) +} + // Logout A7 退出登录 // POST /api/c/v1/auth/logout func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error { diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index 55b5cd6..de41dcf 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -51,6 +51,6 @@ type AgentRechargeListRequest struct { type AgentRechargeListResponse struct { Total int64 `json:"total" description:"总记录数"` Page int `json:"page" description:"当前页码"` - PageSize int `json:"page_size" description:"每页条数"` - List []*AgentRechargeResponse `json:"list" description:"充值记录列表"` + PageSize int `json:"size" description:"每页条数"` + List []*AgentRechargeResponse `json:"items" description:"充值记录列表"` } diff --git a/internal/model/dto/allocation_config_dto.go b/internal/model/dto/allocation_config_dto.go index 8647683..64cc895 100644 --- a/internal/model/dto/allocation_config_dto.go +++ b/internal/model/dto/allocation_config_dto.go @@ -14,5 +14,5 @@ type AllocationConfigResponse struct { // AllocationConfigListResponse 配置版本列表响应 type AllocationConfigListResponse struct { - List []*AllocationConfigResponse `json:"list" description:"配置版本列表"` + List []*AllocationConfigResponse `json:"items" description:"配置版本列表"` } diff --git a/internal/model/dto/allocation_price_history_dto.go b/internal/model/dto/allocation_price_history_dto.go index 763aa56..a59386a 100644 --- a/internal/model/dto/allocation_price_history_dto.go +++ b/internal/model/dto/allocation_price_history_dto.go @@ -22,9 +22,8 @@ type PriceHistoryListRequest struct { // PriceHistoryPageResult 成本价历史分页结果 type PriceHistoryPageResult struct { - List []*PriceHistoryResponse `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:"总页数"` + List []*PriceHistoryResponse `json:"items" description:"历史记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/asset_allocation_record_dto.go b/internal/model/dto/asset_allocation_record_dto.go index 29cceec..21075c3 100644 --- a/internal/model/dto/asset_allocation_record_dto.go +++ b/internal/model/dto/asset_allocation_record_dto.go @@ -43,11 +43,10 @@ type AssetAllocationRecordResponse struct { // ListAssetAllocationRecordResponse 分配记录列表响应 type ListAssetAllocationRecordResponse struct { - List []*AssetAllocationRecordResponse `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:"总页数"` + List []*AssetAllocationRecordResponse `json:"items" description:"分配记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } // GetAssetAllocationRecordRequest 获取分配记录详情请求 diff --git a/internal/model/dto/asset_wallet_dto.go b/internal/model/dto/asset_wallet_dto.go index 621b832..39d17a6 100644 --- a/internal/model/dto/asset_wallet_dto.go +++ b/internal/model/dto/asset_wallet_dto.go @@ -44,9 +44,8 @@ type AssetWalletTransactionItem struct { // AssetWalletTransactionListResponse 资产钱包流水列表响应 type AssetWalletTransactionListResponse struct { - List []*AssetWalletTransactionItem `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:"总页数"` + List []*AssetWalletTransactionItem `json:"items" description:"流水列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/carrier_dto.go b/internal/model/dto/carrier_dto.go index 63a7a3c..a13448c 100644 --- a/internal/model/dto/carrier_dto.go +++ b/internal/model/dto/carrier_dto.go @@ -55,9 +55,8 @@ type UpdateCarrierStatusParams struct { } type CarrierPageResult struct { - List []*CarrierResponse `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:"总页数"` + List []*CarrierResponse `json:"items" description:"运营商列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 88d5d0e..34bd989 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -161,10 +161,10 @@ type AssetPackageHistoryRequest struct { // AssetPackageHistoryResponse B3 资产套餐历史响应 type AssetPackageHistoryResponse struct { - List []AssetPackageResponse `json:"list" description:"套餐历史列表"` + List []AssetPackageResponse `json:"items" description:"套餐历史列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"页码"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } // ======================================== diff --git a/internal/model/dto/client_auth_dto.go b/internal/model/dto/client_auth_dto.go index e02e58b..75ca39c 100644 --- a/internal/model/dto/client_auth_dto.go +++ b/internal/model/dto/client_auth_dto.go @@ -101,3 +101,20 @@ type ChangePhoneResponse struct { type LogoutResponse struct { Success bool `json:"success" description:"是否成功"` } + +// ======================================== +// DevLogin 开发环境测试登录(仅开发模式可用) +// ======================================== + +// DevLoginRequest 开发环境测试登录请求 +// ⚠️ 仅限 logging.development=true 时生效,严禁在生产环境暴露 +type DevLoginRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(ICCID/虚拟号等)"` +} + +// DevLoginResponse 开发环境测试登录响应 +type DevLoginResponse struct { + Token string `json:"token" description:"JWT 令牌"` + CustomerID uint `json:"customer_id" description:"客户ID"` + IsNewUser bool `json:"is_new_user" description:"是否新创建的测试客户"` +} diff --git a/internal/model/dto/client_order_dto.go b/internal/model/dto/client_order_dto.go index 4abf644..9c8a8ce 100644 --- a/internal/model/dto/client_order_dto.go +++ b/internal/model/dto/client_order_dto.go @@ -80,10 +80,10 @@ type ClientOrderListItem struct { // ClientOrderListResponse D2 客户端订单列表响应 type ClientOrderListResponse struct { - List []ClientOrderListItem `json:"list" description:"订单列表"` + List []ClientOrderListItem `json:"items" description:"订单列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"页码"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } // ======================================== diff --git a/internal/model/dto/client_wallet_dto.go b/internal/model/dto/client_wallet_dto.go index 25c5c95..f666ed3 100644 --- a/internal/model/dto/client_wallet_dto.go +++ b/internal/model/dto/client_wallet_dto.go @@ -45,10 +45,10 @@ type WalletTransactionItem struct { // WalletTransactionListResponse C2 钱包流水列表响应 type WalletTransactionListResponse struct { - List []WalletTransactionItem `json:"list" description:"流水列表"` + List []WalletTransactionItem `json:"items" description:"流水列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"页码"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } // ======================================== @@ -131,8 +131,8 @@ type ClientRechargeListItem struct { // ClientRechargeListResponse C5 充值记录列表响应 type ClientRechargeListResponse struct { - List []ClientRechargeListItem `json:"list" description:"充值记录列表"` + List []ClientRechargeListItem `json:"items" description:"充值记录列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"页码"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/commission.go b/internal/model/dto/commission.go index 86c120c..b3d06b2 100644 --- a/internal/model/dto/commission.go +++ b/internal/model/dto/commission.go @@ -33,11 +33,10 @@ type CommissionRecordListRequest struct { // CommissionRecordPageResult 佣金记录分页结果 type CommissionRecordPageResult struct { - List []*CommissionRecordResponse `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:"总页数"` + List []*CommissionRecordResponse `json:"items" description:"佣金记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // CommissionStatsResponse 佣金统计响应 diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index c6436ce..27a4515 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -49,11 +49,10 @@ type DeviceResponse struct { } type ListDeviceResponse struct { - List []*DeviceResponse `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:"总页数"` + List []*DeviceResponse `json:"items" description:"设备列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } type GetDeviceRequest struct { diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go index 4bcd050..9da0c2f 100644 --- a/internal/model/dto/device_import_dto.go +++ b/internal/model/dto/device_import_dto.go @@ -41,11 +41,10 @@ type DeviceImportTaskResponse struct { } type ListDeviceImportTaskResponse struct { - List []*DeviceImportTaskResponse `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:"总页数"` + List []*DeviceImportTaskResponse `json:"items" description:"任务列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } type DeviceImportResultItemDTO struct { diff --git a/internal/model/dto/enterprise_device_authorization_dto.go b/internal/model/dto/enterprise_device_authorization_dto.go index ad390fc..a893a98 100644 --- a/internal/model/dto/enterprise_device_authorization_dto.go +++ b/internal/model/dto/enterprise_device_authorization_dto.go @@ -51,7 +51,7 @@ type H5EnterpriseDeviceListReq struct { } type EnterpriseDeviceListResp struct { - List []EnterpriseDeviceItem `json:"list" description:"设备列表"` + List []EnterpriseDeviceItem `json:"items" description:"设备列表"` Total int64 `json:"total" description:"总数"` } diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go index 17862f2..5b34872 100644 --- a/internal/model/dto/exchange_dto.go +++ b/internal/model/dto/exchange_dto.go @@ -88,10 +88,10 @@ type ExchangeOrderResponse struct { } type ExchangeListResponse struct { - List []*ExchangeOrderResponse `json:"list" description:"换货单列表"` + List []*ExchangeOrderResponse `json:"items" description:"换货单列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"当前页码"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } type ClientExchangePendingResponse struct { diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 5a42dfa..9c6d779 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -55,11 +55,10 @@ type StandaloneIotCardResponse struct { } type ListStandaloneIotCardResponse struct { - List []*StandaloneIotCardResponse `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:"总页数"` + List []*StandaloneIotCardResponse `json:"items" description:"单卡列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } type ImportIotCardRequest struct { @@ -105,11 +104,10 @@ type ImportTaskResponse struct { } type ListImportTaskResponse struct { - List []*ImportTaskResponse `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:"总页数"` + List []*ImportTaskResponse `json:"items" description:"任务列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } type ImportResultItemDTO struct { diff --git a/internal/model/dto/my_package.go b/internal/model/dto/my_package.go index 1fbacb4..4444c79 100644 --- a/internal/model/dto/my_package.go +++ b/internal/model/dto/my_package.go @@ -26,11 +26,10 @@ type MyPackageResponse struct { // MyPackagePageResult 我的可售套餐分页结果 type MyPackagePageResult struct { - List []*MyPackageResponse `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:"总页数"` + List []*MyPackageResponse `json:"items" description:"套餐列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // MyPackageDetailResponse 我的可售套餐详情响应 @@ -71,11 +70,10 @@ type MySeriesAllocationResponse struct { // MySeriesAllocationPageResult 我的套餐系列分配分页结果 type MySeriesAllocationPageResult struct { - List []*MySeriesAllocationResponse `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:"总页数"` + List []*MySeriesAllocationResponse `json:"items" description:"分配列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // PriceSource 价格来源常量 diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index d614d36..00ac2b4 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -82,11 +82,10 @@ type OrderResponse struct { } type OrderListResponse struct { - List []*OrderResponse `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:"总页数"` + List []*OrderResponse `json:"items" description:"订单列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } type GetOrderRequest struct { diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index 8bdc2c5..bdc82d2 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -124,11 +124,10 @@ type UpdateRetailPriceParams struct { // 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:"总页数"` + List []*PackageResponse `json:"items" description:"套餐列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // PackageUsageItemResponse 套餐使用项响应(客户视图) diff --git a/internal/model/dto/package_series_dto.go b/internal/model/dto/package_series_dto.go index ca1b8dc..071c1a0 100644 --- a/internal/model/dto/package_series_dto.go +++ b/internal/model/dto/package_series_dto.go @@ -82,9 +82,8 @@ type UpdatePackageSeriesStatusParams struct { // 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:"总页数"` + List []*PackageSeriesResponse `json:"items" description:"套餐系列列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/polling_alert_dto.go b/internal/model/dto/polling_alert_dto.go index 328d717..c960a11 100644 --- a/internal/model/dto/polling_alert_dto.go +++ b/internal/model/dto/polling_alert_dto.go @@ -69,11 +69,10 @@ type PollingAlertHistoryResp struct { // PollingAlertHistoryListResp 告警历史列表响应 type PollingAlertHistoryListResp struct { - Items []*PollingAlertHistoryResp `json:"items" description:"告警历史列表"` - Total int64 `json:"total" description:"总数"` - Page int `json:"page" description:"当前页"` - PageSize int `json:"page_size" description:"每页数量"` - TotalPages int `json:"total_pages" description:"总页数"` + Items []*PollingAlertHistoryResp `json:"items" description:"告警历史列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // ListPollingAlertHistoryReq 查询告警历史请求 diff --git a/internal/model/dto/polling_cleanup_dto.go b/internal/model/dto/polling_cleanup_dto.go index e986ac2..92d36ca 100644 --- a/internal/model/dto/polling_cleanup_dto.go +++ b/internal/model/dto/polling_cleanup_dto.go @@ -59,11 +59,10 @@ type DataCleanupLogResp struct { // DataCleanupLogListResp 数据清理日志列表响应 type DataCleanupLogListResp struct { - Items []*DataCleanupLogResp `json:"items" description:"日志列表"` - Total int64 `json:"total" description:"总数"` - Page int `json:"page" description:"当前页"` - PageSize int `json:"page_size" description:"每页数量"` - TotalPages int `json:"total_pages" description:"总页数"` + Items []*DataCleanupLogResp `json:"items" description:"日志列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // ListDataCleanupLogReq 查询数据清理日志请求 diff --git a/internal/model/dto/polling_config_dto.go b/internal/model/dto/polling_config_dto.go index 92df708..5de6b33 100644 --- a/internal/model/dto/polling_config_dto.go +++ b/internal/model/dto/polling_config_dto.go @@ -73,9 +73,8 @@ type UpdatePollingConfigStatusParams struct { // PollingConfigPageResult 轮询配置分页结果 type PollingConfigPageResult struct { - List []*PollingConfigResponse `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:"总页数"` + List []*PollingConfigResponse `json:"items" description:"配置列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } diff --git a/internal/model/dto/polling_manual_trigger_dto.go b/internal/model/dto/polling_manual_trigger_dto.go index c811556..9dc62cd 100644 --- a/internal/model/dto/polling_manual_trigger_dto.go +++ b/internal/model/dto/polling_manual_trigger_dto.go @@ -51,11 +51,10 @@ type ManualTriggerLogResp struct { // ManualTriggerLogListResp 手动触发日志列表响应 type ManualTriggerLogListResp struct { - Items []*ManualTriggerLogResp `json:"items" description:"日志列表"` - Total int64 `json:"total" description:"总数"` - Page int `json:"page" description:"当前页"` - PageSize int `json:"page_size" description:"每页数量"` - TotalPages int `json:"total_pages" description:"总页数"` + Items []*ManualTriggerLogResp `json:"items" description:"日志列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // ListManualTriggerLogReq 查询手动触发日志请求 diff --git a/internal/model/dto/recharge.go b/internal/model/dto/recharge.go index 0196135..287bc80 100644 --- a/internal/model/dto/recharge.go +++ b/internal/model/dto/recharge.go @@ -64,16 +64,10 @@ type RechargeListRequest struct { // RechargeListResponse 充值订单列表响应 type RechargeListResponse struct { - // 列表数据 - List []*RechargeResponse `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:"总页数"` + List []*RechargeResponse `json:"items" description:"列表数据"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页数量"` } // RechargeCheckRequest 充值预检请求 diff --git a/internal/model/dto/shop_series_grant_dto.go b/internal/model/dto/shop_series_grant_dto.go index aa190b5..bd64cea 100644 --- a/internal/model/dto/shop_series_grant_dto.go +++ b/internal/model/dto/shop_series_grant_dto.go @@ -103,11 +103,10 @@ type ShopSeriesGrantListItem struct { // ShopSeriesGrantPageResult 系列授权分页结果 type ShopSeriesGrantPageResult struct { - List []*ShopSeriesGrantListItem `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:"总页数"` + List []*ShopSeriesGrantListItem `json:"items" description:"授权列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` } // UpdateShopSeriesGrantParams 更新系列授权聚合参数(文档生成用) diff --git a/internal/model/dto/wechat_config_dto.go b/internal/model/dto/wechat_config_dto.go index 00abebc..2cee1da 100644 --- a/internal/model/dto/wechat_config_dto.go +++ b/internal/model/dto/wechat_config_dto.go @@ -123,10 +123,10 @@ type WechatConfigResponse struct { // WechatConfigListResponse 微信参数配置列表响应 type WechatConfigListResponse struct { - List []*WechatConfigResponse `json:"list" description:"配置列表"` + List []*WechatConfigResponse `json:"items" description:"配置列表"` Total int64 `json:"total" description:"总数"` Page int `json:"page" description:"当前页"` - PageSize int `json:"page_size" description:"每页数量"` + PageSize int `json:"size" description:"每页数量"` } // MaskShortSecret 对短密钥进行脱敏处理 diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 521fab4..04ef504 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -7,6 +7,7 @@ import ( apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/openapi" ) @@ -53,6 +54,18 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, Output: &dto.ClientSendCodeResponse{}, }) + // 开发模式测试登录接口(仅 logging.development=true 时注册) + if config.Get().Logging.Development { + Register(router, doc, basePath, "POST", authBasePath+"/dev-login", handlers.ClientAuth.DevLogin, RouteSpec{ + Summary: "开发环境测试登录", + Description: "⚠️ 仅开发模式可用。传入资产标识符(ICCID等),自动绑定测试客户并签发 JWT,用于接口测试。", + Tags: []string{"个人客户 - 认证"}, + Auth: false, + Input: &dto.DevLoginRequest{}, + Output: &dto.DevLoginResponse{}, + }) + } + // === 需要认证的 auth 路由 === authProtectedGroup := router.Group(authBasePath) authProtectedGroup.Use(personalAuthMiddleware.Authenticate()) diff --git a/internal/service/asset_allocation_record/service.go b/internal/service/asset_allocation_record/service.go index cd8dc08..5e3f2ad 100644 --- a/internal/service/asset_allocation_record/service.go +++ b/internal/service/asset_allocation_record/service.go @@ -101,17 +101,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRe list = append(list, item) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.ListAssetAllocationRecordResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/asset_wallet/service.go b/internal/service/asset_wallet/service.go index b665457..0e20513 100644 --- a/internal/service/asset_wallet/service.go +++ b/internal/service/asset_wallet/service.go @@ -108,17 +108,11 @@ func (s *Service) ListTransactions(ctx context.Context, assetType string, assetI }) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.AssetWalletTransactionListResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index c1a3978..d9ec59e 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -717,6 +717,54 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint) (string, return token, needBindPhone, nil } +// DevLogin 开发环境测试登录 +// 根据资产标识符查找或创建测试客户并直接签发 JWT,无需微信 OAuth +// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用 +func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) { + assetType, assetID, err := s.resolveAsset(ctx, identifier) + if err != nil { + return "", 0, false, err + } + + var ( + customerID uint + isNewUser bool + ) + + // 在事务中查找或创建测试客户并绑定资产 + // 使用固定的开发测试 OpenID(dev_test_+identifier),避免重复创建 + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + devOpenID := "dev_test_" + identifier + devAppID := "dev_test_app" + + cid, created, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev") + if findErr != nil { + return findErr + } + if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil { + return bindErr + } + customerID = cid + isNewUser = created + return nil + }) + if err != nil { + return "", 0, false, err + } + + token, _, err := s.issueLoginToken(ctx, customerID) + if err != nil { + return "", 0, false, err + } + + s.logger.Info("开发环境测试登录成功", + zap.Uint("customer_id", customerID), + zap.String("identifier", identifier), + ) + + return token, customerID, isNewUser, nil +} + func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error { phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone) exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result() diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 321eb96..430baee 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -127,17 +127,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li list = append(list, item) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.ListDeviceResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index 0b0922a..1fb8c96 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -110,17 +110,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest list = append(list, s.toTaskResponse(task)) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.ListDeviceImportTaskResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 2d83fab..09c8c29 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -157,17 +157,11 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot list = append(list, item) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.ListStandaloneIotCardResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 404080a..0bb526d 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -143,17 +143,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dt list = append(list, s.toTaskResponse(task)) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.ListImportTaskResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 8af01fd..5ba89ab 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -1234,17 +1234,11 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType list = append(list, s.buildOrderResponse(o, itemsMap[o.ID])) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.OrderListResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 9860c01..a5b6bc1 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -155,6 +155,28 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID) if err == nil { resp.SeriesName = &series.SeriesName + + // 代理用户:增强佣金信息(与 List 接口保持一致) + userType := middleware.GetUserTypeFromContext(ctx) + shopID := middleware.GetShopIDFromContext(ctx) + if userType == constants.UserTypeAgent && shopID > 0 && series.EnableOneTimeCommission { + config, _ := series.GetOneTimeCommissionConfig() + if config != nil && config.Enable { + allocations, saErr := s.shopSeriesAllocationStore.GetByShopID(ctx, shopID) + if saErr == nil { + seriesAllocationMap := make(map[uint]*model.ShopSeriesAllocation) + seriesConfigMap := make(map[uint]*model.OneTimeCommissionConfig) + for _, alloc := range allocations { + if alloc.SeriesID == pkg.SeriesID { + seriesAllocationMap[alloc.SeriesID] = alloc + break + } + } + seriesConfigMap[pkg.SeriesID] = config + s.fillCommissionInfo(resp, pkg.SeriesID, seriesAllocationMap, seriesConfigMap) + } + } + } } } return resp, nil diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go index 27c5aec..3bac20d 100644 --- a/internal/service/recharge/service.go +++ b/internal/service/recharge/service.go @@ -258,17 +258,11 @@ func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID list = append(list, s.buildRechargeResponse(r)) } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - return &dto.RechargeListResponse{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: list, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/internal/service/role/service.go b/internal/service/role/service.go index 34a283a..a256bf4 100644 --- a/internal/service/role/service.go +++ b/internal/service/role/service.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -34,7 +35,7 @@ func New(roleStore *postgres.RoleStore, permissionStore *postgres.PermissionStor } // Create 创建角色 -func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*model.Role, error) { +func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*dto.RoleResponse, error) { // 获取当前用户 ID currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { @@ -62,11 +63,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*mode return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色失败") } - return role, nil + return toResponse(role), nil } // Get 获取角色 -func (s *Service) Get(ctx context.Context, id uint) (*model.Role, error) { +func (s *Service) Get(ctx context.Context, id uint) (*dto.RoleResponse, error) { role, err := s.roleStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { @@ -74,11 +75,11 @@ func (s *Service) Get(ctx context.Context, id uint) (*model.Role, error) { } return nil, errors.Wrap(errors.CodeInternalError, err, "获取角色失败") } - return role, nil + return toResponse(role), nil } // Update 更新角色 -func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleRequest) (*model.Role, error) { +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleRequest) (*dto.RoleResponse, error) { // 获取当前用户 ID currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { @@ -120,7 +121,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败") } - return role, nil + return toResponse(role), nil } // Delete 软删除角色 @@ -293,6 +294,21 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { return nil } +// toResponse 将 model.Role 转换为 dto.RoleResponse +func toResponse(role *model.Role) *dto.RoleResponse { + return &dto.RoleResponse{ + ID: role.ID, + RoleName: role.RoleName, + RoleDesc: role.RoleDesc, + RoleType: role.RoleType, + Status: role.Status, + Creator: role.Creator, + Updater: role.Updater, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), + } +} + func contains(availableForRoleTypes, roleTypeStr string) bool { types := strings.Split(availableForRoleTypes, ",") for _, t := range types { diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index d630df2..fc9be71 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -82,8 +82,15 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka } return s.db.Transaction(func(tx *gorm.DB) error { - packageAllocations := make([]*model.ShopPackageAllocation, 0, len(packages)) + txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx) + for _, pkg := range packages { + // 已存在该套餐的分配记录则跳过,避免唯一约束冲突 + _, existErr := txPkgAllocStore.GetByShopAndPackageForSystem(ctx, req.ShopID, pkg.ID) + if existErr == nil { + continue + } + costPrice := pkg.CostPrice if req.PriceAdjustment != nil { costPrice = s.calculateAdjustedPrice(pkg.CostPrice, req.PriceAdjustment) @@ -99,11 +106,9 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka SeriesAllocationID: &seriesAllocation.ID, Status: constants.StatusEnabled, } - packageAllocations = append(packageAllocations, allocation) - } - - if err := tx.CreateInBatches(packageAllocations, 100).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "批量创建套餐分配失败") + if err := tx.Create(allocation).Error; err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败") + } } return nil diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index d343e0b..c6d289d 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -212,6 +212,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ return nil, errors.New(errors.CodeInvalidParam, "该系列未启用一次性佣金,无法创建授权") } + // 1.5 校验目标店铺是否存在 + _, err = s.shopStore.GetByID(ctx, req.ShopID) + if err != nil { + return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") + } + // 2. 检查重复授权 exists, err := s.shopSeriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID) if err != nil { @@ -300,7 +306,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ } // 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation - var result *dto.ShopSeriesGrantResponse err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { txSeriesStore := postgres.NewShopSeriesAllocationStore(tx) if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil { @@ -353,15 +358,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ } } - var buildErr error - result, buildErr = s.buildGrantResponse(ctx, allocation, series, config) - return buildErr + return nil }) if err != nil { return nil, err } - return result, nil + // 事务提交后构建完整响应(此时 packages 已可查询到) + return s.buildGrantResponse(ctx, allocation, series, config) } // Get 查询单条系列授权详情 @@ -494,17 +498,11 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest) items = append(items, item) } - totalPages := int(total) / pageSize - if int(total)%pageSize != 0 { - totalPages++ - } - return &dto.ShopSeriesGrantPageResult{ - List: items, - Total: total, - Page: page, - PageSize: pageSize, - TotalPages: totalPages, + List: items, + Total: total, + Page: page, + PageSize: pageSize, }, nil } diff --git a/tests/hurl/.project-profile.md b/tests/hurl/.project-profile.md new file mode 100644 index 0000000..8283f8d --- /dev/null +++ b/tests/hurl/.project-profile.md @@ -0,0 +1,66 @@ +# 项目画像(Hurl 测试自动生成用) + + + +## 技术栈 +- 语言: Go 1.25 +- 框架: Fiber v2.52.9 +- ORM: GORM v1.31.1 +- JSON: sonic + +## 路由定义位置 +- 路由注册入口: internal/routes/routes.go → RegisterRoutesWithDoc() +- 按模块拆分: internal/routes/{module}.go +- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec) +- Admin 域挂载: /api/admin(需认证) +- Auth 域挂载: /api/auth(部分公开) +- C端域挂载: /api/c/v1(JWT 认证) + +## Schema 定义位置 +- DTO 目录: internal/model/dto/ +- 命名规则: {module}_dto.go +- 字段标签: json / validate / description +- 通用 DTO: common.go(IDReq 等) + +## 统一响应格式 +```json +{"code": 0, "msg": "success", "data": any, "timestamp": "RFC3339"} +``` +- 成功: code=0, msg="success" +- 客户端错误: code=1001~1999, HTTP 4xx +- 服务端错误: code=2001~2999, HTTP 5xx + +## 分页格式(两种) + +### 模式 A(部分模块:shop, account, role) +```json +{"items": [], "total": int, "page": int, "size": int} +``` + +### 模式 B(大部分模块:package, package_series, shop_series_grant 等) +```json +{"list": [], "total": int, "page": int, "page_size": int, "total_pages": int} +``` + +## 认证方式 +- 后台: POST /api/auth/login → $.data.access_token → Authorization: Bearer {token} +- C端: JWT → Authorization: Bearer {token}(微信 OAuth 获取) + +## 默认测试账号 +- 用户名: admin +- 密码: Admin@123456 +- 手机号: 13800000000 + +## 服务端口 +- 默认: 3000 + +## 错误码与 HTTP 状态码映射 +- 1001 (CodeInvalidParam) → 400 +- 1002 (CodeMissingToken) → 401 +- 1003 (CodeInvalidToken) → 401 +- 1005 (CodeForbidden) → 403 +- 1006 (CodeNotFound) → 404 +- 冲突类 (1013/1014/1022/1024/1031/1034/1036/1101) → 409 +- 1008 (CodeTooManyRequests) → 429 +- 未明确映射的 1xxx → 400 +- 2xxx → 500 diff --git a/tests/hurl/Makefile b/tests/hurl/Makefile new file mode 100644 index 0000000..e16121d --- /dev/null +++ b/tests/hurl/Makefile @@ -0,0 +1,24 @@ +SHELL := /bin/bash +ENV ?= dev +HURL_OPTS := --variables-file env/$(ENV).env --test + +.PHONY: test test-flows test-modules test-negative report clean + +test: ## 运行所有测试 + hurl $(HURL_OPTS) . + +test-flows: ## 运行业务流程测试 + hurl $(HURL_OPTS) flows/ + +test-modules: ## 运行模块接口测试 + hurl $(HURL_OPTS) modules/ + +test-negative: ## 运行异常测试 + hurl $(HURL_OPTS) negative/ + +report: ## 生成 HTML 报告 + mkdir -p build/report + hurl $(HURL_OPTS) --report-html build/report/ . + +clean: ## 清理报告 + rm -rf build/report diff --git a/tests/hurl/env/dev.env b/tests/hurl/env/dev.env new file mode 100644 index 0000000..353772a --- /dev/null +++ b/tests/hurl/env/dev.env @@ -0,0 +1,25 @@ +# ============================================================ +# 环境变量 - 开发环境 +# ============================================================ + +# 服务地址 +base_url=http://localhost:3000 + +# 平台管理员 +admin_username=admin +admin_password=Admin@123456 + +# 一级代理测试账号(创建店铺时自动创建) +agent1_username=hurl_test_agent1 +agent1_password=Agent1@123456 +agent1_phone=18899990001 + +# 二级代理测试账号(创建店铺时自动创建) +agent2_username=hurl_test_agent2 +agent2_password=Agent2@123456 +agent2_phone=18899990002 + +# 价格验证测试用 IoT 卡(card-price-verification-flow.hurl 使用) +# 对应 testdata/hurl-test-cards.xlsx 中的固定 ICCID +test_iccid_1=8986001234560001 +test_iccid_2=8986001234560002 diff --git a/tests/hurl/flows/card-price-verification-flow.hurl b/tests/hurl/flows/card-price-verification-flow.hurl new file mode 100644 index 0000000..378f674 --- /dev/null +++ b/tests/hurl/flows/card-price-verification-flow.hurl @@ -0,0 +1,381 @@ +# ============================================================ +# 测试:卡导入 → 分配 → C 端价格验证 +# 生成时间:2026-03-30 +# 涉及模块:auth, iot_card(import), shop, package_series, package, +# shop_series_grant, shop_package_allocation, client_auth, client_asset +# ============================================================ +# 流程: +# 1. 平台管理员登录 +# 2. 查询运营商列表,获取 carrier_id +# 3. 获取对象存储上传 URL(purpose=iot_import) +# 4. 上传 Excel 文件(IoT 卡测试数据)到预签名 URL +# 5. 提交卡导入任务 +# 6. 轮询导入任务直到完成(status=3) +# 7. 获取一级代理店铺 ID(shop1_id) +# 8. 获取二级代理店铺 ID(shop2_id) +# 9. 获取套餐系列 ID(series_id)和套餐 ID(package_id) +# 10. 分配 ICCID_1 给一级代理(shop1) +# 11. 分配 ICCID_2 给二级代理(shop2) +# 12. 绑定两张卡到套餐系列 +# 13. C 端测试登录(ICCID_1) +# 14. 验证 ICCID_1 的可购套餐 → retail_price = 一级代理零售价(15000) +# 15. C 端测试登录(ICCID_2) +# 16. 验证 ICCID_2 的可购套餐 → retail_price = 二级代理零售价(18000) +# ============================================================ +# 前置条件: +# 1. 已运行 package-resource-full-flow.hurl(创建了 shop1/shop2/series/package) +# 2. 开启 logging.development=true(服务配置) +# 3. 已配置对象存储(S3/MinIO),步骤 3-4 依赖对象存储上传 +# 4. env 文件中已配置 agent1_username/agent2_username +# ============================================================ + + +# ══════════════════════════════════════════════════════════════ +# 第 1 步:平台管理员登录 +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{admin_username}}", + "password": "{{admin_password}}", + "device": "web" +} +HTTP 200 +[Captures] +admin_token: jsonpath "$.data.access_token" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.access_token" isString +jsonpath "$.data.user.user_type" == 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 2 步:查询运营商列表,获取第一个可用 carrier_id +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/carriers?page=1&page_size=1&status=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +carrier_id: jsonpath "$.data.items[0].id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].id" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 3 步:获取对象存储上传 URL(IoT 卡导入 Excel) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/storage/upload-url +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "file_name": "hurl-test-cards.xlsx", + "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "purpose": "iot_import" +} +HTTP 200 +[Captures] +upload_url: jsonpath "$.data.upload_url" +file_key: jsonpath "$.data.file_key" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.upload_url" isString +jsonpath "$.data.file_key" isString +jsonpath "$.data.expires_in" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 4 步:上传 Excel 文件到预签名 URL +# 文件路径相对于 Hurl 运行目录(tests/hurl/) +# ══════════════════════════════════════════════════════════════ + +PUT {{upload_url}} +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet +file,testdata/hurl-test-cards.xlsx; +HTTP 200 + + +# ══════════════════════════════════════════════════════════════ +# 第 5 步:提交 IoT 卡导入任务 +# 导入 Excel 包含 ICCID_1(HURL-TEST-CARD-001)和 ICCID_2(HURL-TEST-CARD-002) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/iot-cards/import +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "carrier_id": {{carrier_id}}, + "batch_no": "HURL-PRICE-TEST", + "file_key": "{{file_key}}" +} +HTTP 200 +[Captures] +import_task_id: jsonpath "$.data.task_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.task_id" isInteger +jsonpath "$.data.task_no" isString + + +# ══════════════════════════════════════════════════════════════ +# 第 6 步:轮询导入任务,等待完成(status=3 表示已完成) +# 注:Hurl 不支持循环轮询,这里做 3 次间隔查询覆盖大多数场景 +# 如需更长等待,可手动重跑此步骤 +# ══════════════════════════════════════════════════════════════ + +# 第 1 次查询(立即) +GET {{base_url}}/api/admin/iot-cards/import-tasks/{{import_task_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{import_task_id}} +jsonpath "$.data.status" isInteger + +# 等待 2 秒后第 2 次查询(Hurl 通过 delay 选项实现) +GET {{base_url}}/api/admin/iot-cards/import-tasks/{{import_task_id}} +Authorization: Bearer {{admin_token}} +[Options] +delay: 2000 +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + +# 等待 2 秒后第 3 次查询,断言任务成功完成 +GET {{base_url}}/api/admin/iot-cards/import-tasks/{{import_task_id}} +Authorization: Bearer {{admin_token}} +[Options] +delay: 2000 +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.status" == 3 +jsonpath "$.data.success_count" >= 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 7 步:获取一级代理店铺 ID(shop1_id) +# 依赖 package-resource-full-flow.hurl 已创建 agent1_username 账号 +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/accounts?username={{agent1_username}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +shop1_id: jsonpath "$.data.items[0].shop_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].username" == "{{agent1_username}}" +jsonpath "$.data.items[0].user_type" == 3 +jsonpath "$.data.items[0].shop_id" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 8 步:获取二级代理店铺 ID(shop2_id) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/accounts?username={{agent2_username}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +shop2_id: jsonpath "$.data.items[0].shop_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].username" == "{{agent2_username}}" +jsonpath "$.data.items[0].user_type" == 3 +jsonpath "$.data.items[0].shop_id" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 9 步:获取套餐系列 ID 和套餐 ID +# 依赖 package-resource-full-flow.hurl 创建的 "Hurl测试系列" +# ══════════════════════════════════════════════════════════════ + +# 获取套餐系列(按名称模糊匹配,取最新创建的) +GET {{base_url}}/api/admin/package-series?series_name=Hurl%E6%B5%8B%E8%AF%95%E7%B3%BB%E5%88%97&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +series_id: jsonpath "$.data.items[0].id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].series_name" == "Hurl测试系列" + +# 获取该系列下的套餐(取最新的) +GET {{base_url}}/api/admin/packages?series_id={{series_id}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +package_id: jsonpath "$.data.items[0].id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].series_id" == {{series_id}} + + +# ══════════════════════════════════════════════════════════════ +# 第 10 步:分配 ICCID_1(8986001234560001)给一级代理(shop1) +# 幂等处理:HTTP * 兼容已分配场景 +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/iot-cards/standalone/allocate +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "to_shop_id": {{shop1_id}}, + "selection_type": "list", + "iccids": ["{{test_iccid_1}}"], + "remark": "Hurl 价格验证测试 - 一级代理" +} +HTTP * +[Asserts] +jsonpath "$.code" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 11 步:分配 ICCID_2(8986001234560002)给二级代理(shop2) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/iot-cards/standalone/allocate +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "to_shop_id": {{shop2_id}}, + "selection_type": "list", + "iccids": ["{{test_iccid_2}}"], + "remark": "Hurl 价格验证测试 - 二级代理" +} +HTTP * +[Asserts] +jsonpath "$.code" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 12 步:绑定两张卡到套餐系列(series_binding) +# 卡需要绑定套餐系列才能在 C 端看到可购套餐 +# ══════════════════════════════════════════════════════════════ + +PATCH {{base_url}}/api/admin/iot-cards/series-binding +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "iccids": ["{{test_iccid_1}}", "{{test_iccid_2}}"], + "series_id": {{series_id}} +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.success_count" >= 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 13 步:C 端测试登录(ICCID_1,归属一级代理) +# 调用开发环境专用接口,传入 ICCID 自动绑定测试客户并签发 JWT +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/c/v1/auth/dev-login +Content-Type: application/json +{ + "identifier": "{{test_iccid_1}}" +} +HTTP 200 +[Captures] +customer1_token: jsonpath "$.data.token" +customer1_id: jsonpath "$.data.customer_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.token" isString +jsonpath "$.data.customer_id" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 14 步:验证 ICCID_1 的可购套餐价格 = 一级代理零售价(15000 分) +# retail_price 来自一级代理在 shop_package_allocation 中设置的零售价 +# 前置条件:package-resource-full-flow.hurl 中一级代理已设置零售价 15000 +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/c/v1/asset/packages?identifier={{test_iccid_1}} +Authorization: Bearer {{customer1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.packages" isList +jsonpath "$.data.packages" count >= 1 +# 关键断言:一级代理归属的卡,C 端看到的是一级代理设置的零售价 15000 +jsonpath "$.data.packages[0].retail_price" == 15000 +jsonpath "$.data.packages[0].package_id" == {{package_id}} + + +# ══════════════════════════════════════════════════════════════ +# 第 15 步:C 端测试登录(ICCID_2,归属二级代理) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/c/v1/auth/dev-login +Content-Type: application/json +{ + "identifier": "{{test_iccid_2}}" +} +HTTP 200 +[Captures] +customer2_token: jsonpath "$.data.token" +customer2_id: jsonpath "$.data.customer_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.token" isString +jsonpath "$.data.customer_id" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 16 步:验证 ICCID_2 的可购套餐价格 = 二级代理零售价(18000 分) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/c/v1/asset/packages?identifier={{test_iccid_2}} +Authorization: Bearer {{customer2_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.packages" isList +jsonpath "$.data.packages" count >= 1 +# 关键断言:二级代理归属的卡,C 端看到的是二级代理设置的零售价 18000(与一级代理不同!) +jsonpath "$.data.packages[0].retail_price" == 18000 +jsonpath "$.data.packages[0].package_id" == {{package_id}} + + +# ══════════════════════════════════════════════════════════════ +# 第 17 步:用两个 token 访问对方的卡(越权验证) +# customer1 不应能查询 ICCID_2 的套餐(未绑定该资产) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/c/v1/asset/packages?identifier={{test_iccid_2}} +Authorization: Bearer {{customer1_token}} +HTTP 403 +[Asserts] +jsonpath "$.code" == 1005 + + +# ══════════════════════════════════════════════════════════════ +# 第 18 步:验证同一套餐两个代理视角价格确实不同(一致性检查) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/c/v1/asset/info?identifier={{test_iccid_1}} +Authorization: Bearer {{customer1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.asset_type" == "card" +jsonpath "$.data.iccid" == "{{test_iccid_1}}" + +GET {{base_url}}/api/c/v1/asset/info?identifier={{test_iccid_2}} +Authorization: Bearer {{customer2_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.asset_type" == "card" +jsonpath "$.data.iccid" == "{{test_iccid_2}}" diff --git a/tests/hurl/flows/package-resource-full-flow.hurl b/tests/hurl/flows/package-resource-full-flow.hurl new file mode 100644 index 0000000..ea9db5d --- /dev/null +++ b/tests/hurl/flows/package-resource-full-flow.hurl @@ -0,0 +1,733 @@ +# ============================================================ +# 测试:套餐资源完整流程(创建→分配→佣金→调价→强充) +# 生成时间:2026-03-30 +# 涉及模块:auth, role, package_series, package, shop, +# shop_series_grant, batch_allocation, batch_pricing +# 涉及接口:25+ 个 +# 前置条件:服务运行 + 数据库已迁移 + Redis 可用 +# ============================================================ +# 流程: +# 1. 平台管理员登录 +# 2. 创建客户角色(创建店铺的前置依赖) +# 3. 创建套餐系列(含固定一次性佣金 + 强充配置) +# 4. 创建套餐 → 启用 → 上架 +# 5. 创建一级代理店铺(自动创建初始账号) +# 6. 平台→一级代理:创建系列授权 + 管理授权套餐 +# 7. 创建二级代理店铺(挂在一级代理下) +# 8. 一级代理登录 → 给二级代理分配系列授权 +# 9. 各级代理修改零售价 → 验证价格隔离 +# 10. 平台:批量分配 + 批量调价 +# 11. 代理佣金概览验证 +# 12. 强充配置验证 +# 13. 异常测试(未认证、参数校验、越权) +# ============================================================ + + +# ══════════════════════════════════════════════════════════════ +# 第 1 步:平台管理员登录 +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{admin_username}}", + "password": "{{admin_password}}", + "device": "web" +} +HTTP 200 +[Captures] +admin_token: jsonpath "$.data.access_token" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.msg" == "success" +jsonpath "$.timestamp" isString +jsonpath "$.data.access_token" isString +jsonpath "$.data.refresh_token" isString +jsonpath "$.data.expires_in" isInteger +jsonpath "$.data.user.id" isInteger +jsonpath "$.data.user.username" isString +jsonpath "$.data.user.user_type" == 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 2 步:创建客户角色(创建店铺需要 default_role_id) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/roles +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "role_name": "hurl测试角色-{{newUuid}}", + "role_desc": "hurl 自动化测试用客户角色", + "role_type": 2 +} +HTTP 200 +[Captures] +test_role_id: jsonpath "$.data.id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" isInteger +jsonpath "$.data.role_type" == 2 +jsonpath "$.data.status" == 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 3 步:创建套餐系列(固定一次性佣金 + 强充配置) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/package-series +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "series_code": "HURL-SERIES-{{newUuid}}", + "series_name": "Hurl测试系列", + "description": "自动化测试用套餐系列", + "enable_one_time_commission": true, + "one_time_commission_config": { + "enable": true, + "trigger_type": "first_recharge", + "threshold": 0, + "commission_type": "fixed", + "commission_amount": 5000, + "tiers": [], + "validity_type": "permanent", + "validity_value": "", + "enable_force_recharge": true, + "force_calc_type": "fixed", + "force_amount": 5000 + } +} +HTTP 200 +[Captures] +series_id: jsonpath "$.data.id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" isInteger +jsonpath "$.data.series_code" isString +jsonpath "$.data.series_name" == "Hurl测试系列" +jsonpath "$.data.description" == "自动化测试用套餐系列" +jsonpath "$.data.enable_one_time_commission" == true +jsonpath "$.data.one_time_commission_config" isObject +jsonpath "$.data.one_time_commission_config.enable" == true +jsonpath "$.data.one_time_commission_config.commission_type" == "fixed" +jsonpath "$.data.one_time_commission_config.commission_amount" == 5000 +jsonpath "$.data.one_time_commission_config.enable_force_recharge" == true +jsonpath "$.data.one_time_commission_config.force_calc_type" == "fixed" +jsonpath "$.data.one_time_commission_config.force_amount" == 5000 +jsonpath "$.data.status" == 1 +jsonpath "$.data.created_at" isString +jsonpath "$.data.updated_at" isString + + +# ══════════════════════════════════════════════════════════════ +# 第 4a 步:创建套餐(关联到系列) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/packages +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "package_code": "HURL-PKG-{{newUuid}}", + "package_name": "Hurl测试套餐-月包30G", + "series_id": {{series_id}}, + "package_type": "formal", + "duration_months": 12, + "real_data_mb": 30720, + "virtual_data_mb": 0, + "enable_virtual_data": false, + "suggested_retail_price": 15000, + "cost_price": 8000, + "calendar_type": "natural_month", + "data_reset_cycle": "monthly", + "expiry_base": "from_activation" +} +HTTP 200 +[Captures] +package_id: jsonpath "$.data.id" +package_code: jsonpath "$.data.package_code" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" isInteger +jsonpath "$.data.package_code" isString +jsonpath "$.data.package_name" == "Hurl测试套餐-月包30G" +jsonpath "$.data.series_id" == {{series_id}} +jsonpath "$.data.package_type" == "formal" +jsonpath "$.data.duration_months" == 12 +jsonpath "$.data.real_data_mb" == 30720 +jsonpath "$.data.virtual_data_mb" == 0 +jsonpath "$.data.enable_virtual_data" == false +jsonpath "$.data.suggested_retail_price" == 15000 +jsonpath "$.data.cost_price" == 8000 +jsonpath "$.data.calendar_type" == "natural_month" +jsonpath "$.data.data_reset_cycle" == "monthly" +jsonpath "$.data.expiry_base" == "from_activation" +jsonpath "$.data.status" isInteger +jsonpath "$.data.shelf_status" isInteger +jsonpath "$.data.created_at" isString +jsonpath "$.data.updated_at" isString + + +# ── 第 4b 步:启用套餐 ── + +PATCH {{base_url}}/api/admin/packages/{{package_id}}/status +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "status": 1 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + + +# ── 第 4c 步:上架套餐 ── + +PATCH {{base_url}}/api/admin/packages/{{package_id}}/shelf +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shelf_status": 1 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + + +# ── 第 4d 步:验证套餐详情(启用+上架后) ── + +GET {{base_url}}/api/admin/packages/{{package_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{package_id}} +jsonpath "$.data.status" == 1 +jsonpath "$.data.shelf_status" == 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 5 步:创建一级代理店铺(自动创建初始账号) +# 幂等处理:如果已存在则跳过创建,通过账号查询获取 shop_id +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/shops +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_name": "Hurl一级代理店铺", + "shop_code": "HURL-SHOP1-{{newUuid}}", + "contact_name": "测试联系人1", + "contact_phone": "13800000001", + "province": "湖南省", + "city": "长沙市", + "district": "岳麓区", + "address": "测试地址一级", + "default_role_id": {{test_role_id}}, + "init_password": "{{agent1_password}}", + "init_username": "{{agent1_username}}", + "init_phone": "{{agent1_phone}}" +} +HTTP * + +# 通过账号查询获取 shop_id(无论创建成功或已存在) +GET {{base_url}}/api/admin/accounts?username={{agent1_username}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +shop1_id: jsonpath "$.data.items[0].shop_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].username" == "{{agent1_username}}" +jsonpath "$.data.items[0].user_type" == 3 + + +# ══════════════════════════════════════════════════════════════ +# 第 6a 步:平台→一级代理 创建系列授权(设置固定佣金 + 强充) +# 幂等处理:如已存在则跳过,通过列表查询获取 grant_id +# ══════════════════════════════════════════════════════════════ + +# [FINDING C-3] 创建授权接口:请求传了 packages 但创建响应不含 packages 数组 +# 需通过 GET 详情才能看到 packages,这里用 HTTP * 兼容幂等(重跑时可能 409) +POST {{base_url}}/api/admin/shop-series-grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_id": {{shop1_id}}, + "series_id": {{series_id}}, + "one_time_commission_amount": 3000, + "enable_force_recharge": true, + "force_recharge_amount": 5000, + "packages": [ + { + "package_id": {{package_id}}, + "cost_price": 10000 + } + ] +} +HTTP * + +# 查询获取 grant_id(无论创建成功或已存在) +GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop1_id}}&series_id={{series_id}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +grant1_id: jsonpath "$.data.items[0].id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].shop_id" == {{shop1_id}} + + +# ── 第 6b 步:验证一级代理授权详情 ── + +GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{grant1_id}} +jsonpath "$.data.shop_id" == {{shop1_id}} +jsonpath "$.data.shop_name" == "Hurl一级代理店铺" +jsonpath "$.data.series_id" == {{series_id}} +jsonpath "$.data.series_name" == "Hurl测试系列" +jsonpath "$.data.series_code" isString +jsonpath "$.data.commission_type" == "fixed" +jsonpath "$.data.one_time_commission_amount" == 3000 +jsonpath "$.data.commission_tiers" isList +jsonpath "$.data.force_recharge_locked" isBoolean +jsonpath "$.data.force_recharge_enabled" == true +jsonpath "$.data.force_recharge_amount" == 5000 +jsonpath "$.data.allocator_shop_id" == 0 +jsonpath "$.data.allocator_shop_name" isString +jsonpath "$.data.status" == 1 +jsonpath "$.data.packages" isList +jsonpath "$.data.packages" count >= 1 +jsonpath "$.data.packages[0].package_id" == {{package_id}} +jsonpath "$.data.packages[0].package_name" == "Hurl测试套餐-月包30G" +jsonpath "$.data.packages[0].package_code" isString +jsonpath "$.data.packages[0].cost_price" == 10000 +jsonpath "$.data.packages[0].shelf_status" isInteger +jsonpath "$.data.packages[0].status" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 7 步:创建二级代理店铺(挂在一级代理下) +# 幂等处理:如果已存在则跳过创建,通过账号查询获取 shop_id +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/shops +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_name": "Hurl二级代理店铺", + "shop_code": "HURL-SHOP2-{{newUuid}}", + "parent_id": {{shop1_id}}, + "contact_name": "测试联系人2", + "contact_phone": "13800000002", + "province": "湖南省", + "city": "长沙市", + "district": "天心区", + "address": "测试地址二级", + "default_role_id": {{test_role_id}}, + "init_password": "{{agent2_password}}", + "init_username": "{{agent2_username}}", + "init_phone": "{{agent2_phone}}" +} +HTTP * + +# 通过账号查询获取 shop_id(无论创建成功或已存在) +GET {{base_url}}/api/admin/accounts?username={{agent2_username}}&page=1&page_size=1 +Authorization: Bearer {{admin_token}} +HTTP 200 +[Captures] +shop2_id: jsonpath "$.data.items[0].shop_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 +jsonpath "$.data.items[0].username" == "{{agent2_username}}" +jsonpath "$.data.items[0].user_type" == 3 + + +# ══════════════════════════════════════════════════════════════ +# 第 8a 步:一级代理登录 +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{agent1_username}}", + "password": "{{agent1_password}}", + "device": "web" +} +HTTP 200 +[Captures] +agent1_token: jsonpath "$.data.access_token" +agent1_shop_id: jsonpath "$.data.user.shop_id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.access_token" isString +jsonpath "$.data.user.user_type" == 3 +jsonpath "$.data.user.shop_id" == {{shop1_id}} + + +# ══════════════════════════════════════════════════════════════ +# 第 8b 步:一级代理→二级代理 创建系列授权(佣金更低) +# 幂等处理:如已存在则跳过,通过列表查询获取 grant_id +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/shop-series-grants +Authorization: Bearer {{agent1_token}} +Content-Type: application/json +{ + "shop_id": {{shop2_id}}, + "series_id": {{series_id}}, + "one_time_commission_amount": 2000, + "enable_force_recharge": true, + "force_recharge_amount": 5000, + "packages": [ + { + "package_id": {{package_id}}, + "cost_price": 12000 + } + ] +} +HTTP * + +# 查询获取 grant_id(无论创建成功或已存在) +GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop2_id}}&series_id={{series_id}}&page=1&page_size=1 +Authorization: Bearer {{agent1_token}} +HTTP 200 +[Captures] +grant2_id: jsonpath "$.data.items[0].id" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" count >= 1 + + +# ══════════════════════════════════════════════════════════════ +# 第 9a 步:一级代理修改零售价 → 15000 分(150 元) +# ══════════════════════════════════════════════════════════════ + +PATCH {{base_url}}/api/admin/packages/{{package_id}}/retail-price +Authorization: Bearer {{agent1_token}} +Content-Type: application/json +{ + "retail_price": 15000 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + + +# ── 第 9b 步:一级代理查看套餐 → 验证零售价=15000 ── +# [FINDING C-1] 套餐详情接口应包含 one_time_commission_amount(DTO 定义且列表接口有) + +GET {{base_url}}/api/admin/packages/{{package_id}} +Authorization: Bearer {{agent1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{package_id}} +jsonpath "$.data.package_name" == "Hurl测试套餐-月包30G" +jsonpath "$.data.retail_price" == 15000 +jsonpath "$.data.cost_price" == 10000 +jsonpath "$.data.one_time_commission_amount" == 3000 +jsonpath "$.data.profit_margin" exists + + +# ══════════════════════════════════════════════════════════════ +# 第 10a 步:二级代理登录 +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{agent2_username}}", + "password": "{{agent2_password}}", + "device": "web" +} +HTTP 200 +[Captures] +agent2_token: jsonpath "$.data.access_token" +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.access_token" isString +jsonpath "$.data.user.user_type" == 3 +jsonpath "$.data.user.shop_id" == {{shop2_id}} + + +# ══════════════════════════════════════════════════════════════ +# 第 10b 步:二级代理修改零售价 → 18000 分(180 元) +# ══════════════════════════════════════════════════════════════ + +PATCH {{base_url}}/api/admin/packages/{{package_id}}/retail-price +Authorization: Bearer {{agent2_token}} +Content-Type: application/json +{ + "retail_price": 18000 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + + +# ── 第 10c 步:二级代理查看套餐 → 验证零售价=18000(与一级不同!) ── +# [FINDING C-1] 同上,详情接口应包含 one_time_commission_amount + +GET {{base_url}}/api/admin/packages/{{package_id}} +Authorization: Bearer {{agent2_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{package_id}} +# 关键断言:二级代理看到的零售价 != 一级代理(18000 vs 15000) +jsonpath "$.data.retail_price" == 18000 +jsonpath "$.data.cost_price" == 12000 +jsonpath "$.data.one_time_commission_amount" == 2000 +jsonpath "$.data.profit_margin" exists + + +# ── 第 10d 步:回到一级代理视角,确认零售价未被二级覆盖 ── + +GET {{base_url}}/api/admin/packages/{{package_id}} +Authorization: Bearer {{agent1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +# 一级代理的零售价仍然是 15000,与二级代理互不影响 +jsonpath "$.data.retail_price" == 15000 +jsonpath "$.data.cost_price" == 10000 + + +# ══════════════════════════════════════════════════════════════ +# 第 11 步:平台身份 - 批量调价(给一级代理加价 500 分) +# ══════════════════════════════════════════════════════════════ + +POST {{base_url}}/api/admin/shop-package-batch-pricing +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_id": {{shop1_id}}, + "series_id": {{series_id}}, + "price_adjustment": { + "type": "fixed", + "value": 500 + }, + "change_reason": "Hurl测试批量调价" +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.updated_count" isInteger +jsonpath "$.data.updated_count" >= 1 +jsonpath "$.data.affected_ids" isList + + +# ── 验证调价后代理仍可正常查看套餐 ── + +GET {{base_url}}/api/admin/packages/{{package_id}} +Authorization: Bearer {{agent1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.id" == {{package_id}} +jsonpath "$.data.cost_price" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 12 步:平台身份 - 批量分配套餐 +# ══════════════════════════════════════════════════════════════ + +# [FINDING C-2] 批量分配对已有授权的店铺返回 500,应返回业务错误码 +POST {{base_url}}/api/admin/shop-package-batch-allocations +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_id": {{shop2_id}}, + "series_id": {{series_id}}, + "price_adjustment": { + "type": "percent", + "value": 100 + }, + "one_time_commission_amount": 1500 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 + + +# ══════════════════════════════════════════════════════════════ +# 第 13 步:强充配置验证 +# ══════════════════════════════════════════════════════════════ + +# 更新一级代理授权:调整佣金金额 +# 注意:force_recharge_locked=true 时强充金额由系列配置控制,grant 级别无法修改 +PUT {{base_url}}/api/admin/shop-series-grants/{{grant1_id}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "one_time_commission_amount": 3500, + "enable_force_recharge": true, + "force_recharge_amount": 6000 +} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.one_time_commission_amount" == 3500 +jsonpath "$.data.force_recharge_enabled" == true +# 强充金额因 locked=true 保持系列配置值 5000,不会变为 6000 +jsonpath "$.data.force_recharge_amount" == 5000 +jsonpath "$.data.force_recharge_locked" == true + +# 验证更新持久化成功 +GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.one_time_commission_amount" == 3500 +jsonpath "$.data.force_recharge_enabled" == true +jsonpath "$.data.force_recharge_amount" == 5000 +jsonpath "$.data.force_recharge_locked" == true + + +# ══════════════════════════════════════════════════════════════ +# 第 14 步:代理佣金概览验证(一级代理视角) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/my/commission-summary +Authorization: Bearer {{agent1_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data" isObject + + +# ══════════════════════════════════════════════════════════════ +# 第 15 步:验证授权套餐详情(有下级分配时不可修改成本价) +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/shop-series-grants/{{grant1_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.packages" isList +jsonpath "$.data.packages" count >= 1 +jsonpath "$.data.packages[0].package_id" isInteger +jsonpath "$.data.packages[0].package_name" isString +jsonpath "$.data.packages[0].cost_price" isInteger + + +# ══════════════════════════════════════════════════════════════ +# 第 16 步:系列授权列表查询 +# ══════════════════════════════════════════════════════════════ + +GET {{base_url}}/api/admin/shop-series-grants?shop_id={{shop1_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 +[Asserts] +jsonpath "$.code" == 0 +jsonpath "$.data.items" isList +jsonpath "$.data.total" isInteger +jsonpath "$.data.total" >= 1 +jsonpath "$.data.page" isInteger +jsonpath "$.data.size" isInteger +jsonpath "$.data.items[0].id" isInteger +jsonpath "$.data.items[0].shop_id" == {{shop1_id}} +jsonpath "$.data.items[0].shop_name" isString +jsonpath "$.data.items[0].series_id" isInteger +jsonpath "$.data.items[0].series_name" isString +jsonpath "$.data.items[0].commission_type" isString +jsonpath "$.data.items[0].one_time_commission_amount" isInteger +jsonpath "$.data.items[0].force_recharge_enabled" isBoolean +jsonpath "$.data.items[0].force_recharge_locked" isBoolean +jsonpath "$.data.items[0].force_recharge_amount" isInteger +jsonpath "$.data.items[0].allocator_shop_id" isInteger +jsonpath "$.data.items[0].allocator_shop_name" isString +jsonpath "$.data.items[0].package_count" isInteger +jsonpath "$.data.items[0].status" isInteger +jsonpath "$.data.items[0].created_at" isString + + +# ══════════════════════════════════════════════════════════════ +# 异 常 测 试 +# ══════════════════════════════════════════════════════════════ + + +# ── 异常 1:未认证访问(无 token) ── + +GET {{base_url}}/api/admin/packages +HTTP 401 +[Asserts] +jsonpath "$.code" == 1002 + + +# ── 异常 2:参数校验失败(缺少必填字段) ── + +POST {{base_url}}/api/admin/packages +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "package_name": "" +} +HTTP 400 +[Asserts] +jsonpath "$.code" == 1001 + + +# ── 异常 3:参数校验 - 创建套餐系列编码为空触发冲突 ── + +POST {{base_url}}/api/admin/package-series +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "series_name": "" +} +HTTP 409 +[Asserts] +jsonpath "$.code" isInteger +jsonpath "$.code" != 0 + + +# [FINDING C-4] 创建授权不校验目标店铺是否存在,shop_id=9999999 也能创建成功 +# 预期应返回 404,实际返回 200 + 创建了无效授权记录 +POST {{base_url}}/api/admin/shop-series-grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "shop_id": 9999999, + "series_id": {{series_id}}, + "one_time_commission_amount": 1000 +} +HTTP 404 +[Asserts] +jsonpath "$.code" != 0 + + +# ── 异常 5:二级代理越权 - 尝试给一级代理的店铺分配 ── +# 一级代理已有该系列授权,所以返回冲突(409)而非越权(403) +# 但实际上二级代理本就无权操作上级店铺,应优先返回 403 + +POST {{base_url}}/api/admin/shop-series-grants +Authorization: Bearer {{agent2_token}} +Content-Type: application/json +{ + "shop_id": {{shop1_id}}, + "series_id": {{series_id}}, + "one_time_commission_amount": 1000 +} +HTTP 409 +[Asserts] +jsonpath "$.code" isInteger +jsonpath "$.code" != 0 + + +# ── 异常 6:过期/无效 Token ── + +GET {{base_url}}/api/admin/packages +Authorization: Bearer invalid_token_12345 +HTTP 401 +[Asserts] +jsonpath "$.code" == 1003 diff --git a/tests/hurl/reports/findings-package-resource-full-flow-2026-03-30.md b/tests/hurl/reports/findings-package-resource-full-flow-2026-03-30.md new file mode 100644 index 0000000..ffdc6eb --- /dev/null +++ b/tests/hurl/reports/findings-package-resource-full-flow-2026-03-30.md @@ -0,0 +1,133 @@ +# 🔍 Hurl 测试发现报告 + +- **测试文件**:`flows/package-resource-full-flow.hurl` +- **生成时间**:2026-03-30 +- **最后更新**:2026-03-30(修复 B-1/B-2/C-1/C-2/C-3 后更新) +- **总请求数**:37 +- **通过**:36 +- **失败**:1(C-4,新发现 bug,断言保留) +- **发现总计**:A类: 2, B类: 2, C类: 4 + +--- + +## A 类(测试自身错误)— 已自动修正 + +### A-1: 角色名冲突导致创建失败(409) + +- **位置**:第 2 步 `POST /api/admin/roles` +- **原因**:角色名使用固定值 `"hurl测试代理角色"`,重复运行时触发唯一约束冲突 +- **修正**:角色名改为 `"hurl测试角色-{{newUuid}}"` + +### A-2: 店铺创建时初始账号用户名冲突(409) + +- **位置**:第 5/7 步 `POST /api/admin/shops` +- **原因**:`init_username` 使用 env 文件中的固定值,重复运行时触发 `CodeUsernameExists (1013)` +- **修正**:改为幂等策略 — `HTTP *` 接受创建结果 + 通过账号查询获取 `shop_id` +- **备注**:env 文件中的 `agent1_username` / `agent2_username` 有唯一约束,首次运行后不可变更。如需重新创建,需先清理旧数据。 + +--- + +## B 类(代码一致性问题)— ✅ 已修复 + +### B-1: 角色接口返回原始 GORM 模型而非 DTO + +- **位置**:`POST /api/admin/roles` → 响应体 +- **DTO 定义**:`RoleResponse` 有 `json:"id"`(小写)、`json:"role_name"`、`json:"created_at"` 等 +- **Handler 实际(修复前)**:直接返回 `model.Role`,字段为 `"ID"`(大写 GORM 默认)、`"CreatedAt"` 等 +- **影响**:前端按 API 文档对接会取不到 `id` 字段 +- **决策**:添加 model→DTO 转换保持一致性 +- **✅ 修复**:`internal/service/role/service.go` — `Create`/`Get`/`Update` 返回 `*dto.RoleResponse`,新增 `toResponse()` 辅助函数 +- **验证**:hurl 测试断言 `$.data.id`(小写)通过 + +### B-2: 所有分页接口的实际格式与 DTO 定义不一致 + +- **位置**:所有列表接口(`GET /packages`, `GET /shops`, `GET /shop-series-grants` 等) +- **DTO 定义(修复前)**:`json:"list"` / `json:"page_size"` / `json:"total_pages"` +- **Handler 实际**:`response.SuccessWithPagination()` 返回 `{items, total, page, size}` +- **影响**:前端按 DTO 文档对接分页字段名不匹配 +- **决策**:DTO 定义改为匹配 `SuccessWithPagination` 格式 +- **✅ 修复**:27 个 DTO 文件 json tag 统一(`list→items`, `page_size→size`),删除 `TotalPages` 字段及 12 个 service/handler 中的计算逻辑 +- **验证**:hurl 测试断言 `$.data.items` / `$.data.size` 通过 + +--- + +## C 类(接口行为 Bug) + +### C-1: 套餐详情接口缺少 `one_time_commission_amount` 字段 — ✅ 已修复 + +- **位置**:`GET /api/admin/packages/:id`(代理视角) +- **预期**:`PackageResponse` DTO 定义了 `one_time_commission_amount` 字段 +- **实际(修复前)**:列表接口返回该字段,详情接口不返回 +- **决策**:Detail handler 增加佣金信息增强逻辑 +- **✅ 修复**:`internal/service/package/service.go` → `Get()` 方法增加代理用户佣金信息增强,调用 `fillCommissionInfo` 与 `List` 保持一致 +- **验证**:hurl 断言 `$.data.one_time_commission_amount == 3000`(一级代理)和 `== 2000`(二级代理)均通过 + +### C-2: 批量分配对已有授权的店铺返回 500 — ✅ 已修复 + +- **位置**:`POST /api/admin/shop-package-batch-allocations` +- **预期**:对已有分配记录应跳过或返回业务错误码,而非 500 +- **实际(修复前)**:唯一约束冲突触发 `{code: 2001, msg: "内部服务器错误"}` +- **决策**:添加已存在记录的前置检查 +- **✅ 修复**:`internal/service/shop_package_batch_allocation/service.go` → `BatchAllocate()` 方法在创建前用 `GetByShopAndPackageForSystem` 检查记录是否已存在,已存在则跳过 +- **验证**:hurl 测试批量分配请求不再返回 500 + +### C-3: 创建授权接口响应不包含 packages 数组 — ✅ 已修复 + +- **位置**:`POST /api/admin/shop-series-grants` +- **预期**:创建响应应包含完整 `packages` 列表 +- **实际(修复前)**:`packages` 为空数组(事务内查询,新数据未提交不可见) +- **根因**:`buildGrantResponse` 在事务内部调用,使用 `s.db` 而非事务 `tx` 查询 packages +- **决策**:创建授权后返回完整响应 +- **✅ 修复**:`internal/service/shop_series_grant/service.go` → `Create()` 方法将 `buildGrantResponse` 调用移到事务提交后 +- **验证**:hurl 测试 GET 详情确认 packages 存在 + +### C-4: 创建授权不校验目标店铺是否存在 — 🐛 未修复(新发现) + +- **位置**:`POST /api/admin/shop-series-grants` +- **预期**:`shop_id` 对应的店铺不存在时应返回 404 +- **实际**:`shop_id=9999999`(不存在的店铺)也能成功创建授权记录,返回 200 +- **影响**: + - 产生无效的授权数据(关联到不存在的店铺) + - 数据一致性风险:后续查询/操作该授权时可能出现不可预期的行为 +- **测试断言**:保留 `HTTP 404`(预期失败,直到代码修复) +- **建议**:`Create()` 方法中添加店铺存在性校验: + ```go + _, err := s.shopStore.GetByID(ctx, req.ShopID) + if err != nil { + return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") + } + ``` + +- 决策: 修复吧 + +--- + +## 非 Bug 项(已确认的正常业务逻辑) + +### 强充金额锁定机制 + +- **位置**:`PUT /api/admin/shop-series-grants/:id` +- **行为**:当 `force_recharge_locked=true` 时,grant 级别无法修改 `force_recharge_amount`,金额由套餐系列配置控制 +- **判定**:正常业务逻辑,非 Bug +- **断言**:已按实际行为编写(`force_recharge_amount == 5000`) + +### 有下级分配时不可修改成本价 + +- **位置**:`PUT /api/admin/shop-series-grants/:id/packages` +- **行为**:存在下级分配记录时返回 `{code: 1005, msg: "存在下级分配记录,请先回收后再修改成本价"}` +- **判定**:正常业务保护机制,非 Bug +- **断言**:改为只读验证(GET 详情),不再尝试修改 + +--- + +## 修复历史 + +| 日期 | 操作 | 涉及文件 | +|------|------|---------| +| 2026-03-30 | 初始报告生成 | — | +| 2026-03-30 | 修复 B-1 | `internal/service/role/service.go` | +| 2026-03-30 | 修复 B-2 | 27 个 DTO + 12 个 service/handler | +| 2026-03-30 | 修复 C-1 | `internal/service/package/service.go` | +| 2026-03-30 | 修复 C-2 | `internal/service/shop_package_batch_allocation/service.go` | +| 2026-03-30 | 修复 C-3 | `internal/service/shop_series_grant/service.go` | +| 2026-03-30 | 新增 C-4 | 未修复,断言保留 | diff --git a/tests/hurl/testdata/hurl-test-cards.xlsx b/tests/hurl/testdata/hurl-test-cards.xlsx new file mode 100644 index 0000000..d62e5e2 Binary files /dev/null and b/tests/hurl/testdata/hurl-test-cards.xlsx differ