diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 3cf2de0..07a4b48 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -45,6 +45,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), + Carrier: admin.NewCarrierHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index aca8a10..21f10e2 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -54,6 +54,7 @@ func generateAdminDocs(outputPath string) error { DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), + Carrier: admin.NewCarrierHandler(nil), } // 4. 注册所有路由到文档生成器 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 1683190..5f1190f 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -550,6 +550,55 @@ components: description: 提示信息 type: string type: object + DtoCarrierPageResult: + properties: + list: + description: 运营商列表 + items: + $ref: '#/components/schemas/DtoCarrierResponse' + nullable: true + type: array + page: + description: 当前页 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + total_pages: + description: 总页数 + type: integer + type: object + DtoCarrierResponse: + properties: + carrier_code: + description: 运营商编码 + type: string + carrier_name: + description: 运营商名称 + type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string + created_at: + description: 创建时间 + type: string + description: + description: 运营商描述 + type: string + id: + description: 运营商ID + minimum: 0 + type: integer + status: + description: 状态 (1:启用, 0:禁用) + type: integer + updated_at: + description: 更新时间 + type: string + type: object DtoChangePasswordRequest: properties: new_password: @@ -595,6 +644,30 @@ components: - password - user_type type: object + DtoCreateCarrierRequest: + properties: + carrier_code: + description: 运营商编码 + maxLength: 50 + minLength: 1 + type: string + carrier_name: + description: 运营商名称 + maxLength: 100 + minLength: 1 + type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string + description: + description: 运营商描述 + maxLength: 500 + type: string + required: + - carrier_code + - carrier_name + - carrier_type + type: object DtoCreateCustomerAccountReq: properties: password: @@ -1473,6 +1546,9 @@ components: carrier_name: description: 运营商名称 type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string completed_at: description: 完成时间 format: date-time @@ -1543,6 +1619,9 @@ components: carrier_name: description: 运营商名称 type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string completed_at: description: 完成时间 format: date-time @@ -1615,6 +1694,9 @@ components: carrier_name: description: 运营商名称 type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string cost_price: description: 成本价(分) type: integer @@ -2563,6 +2645,9 @@ components: carrier_name: description: 运营商名称 type: string + carrier_type: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + type: string cost_price: description: 成本价(分) type: integer @@ -2653,6 +2738,28 @@ components: description: 备注(最多500字) type: string type: object + DtoUpdateCarrierParams: + properties: + carrier_name: + description: 运营商名称 + maxLength: 100 + minLength: 1 + nullable: true + type: string + description: + description: 运营商描述 + maxLength: 500 + nullable: true + type: string + type: object + DtoUpdateCarrierStatusParams: + properties: + status: + description: 状态 (1:启用, 0:禁用) + type: integer + required: + - status + type: object DtoUpdateCustomerAccountPasswordReq: properties: password: @@ -3975,6 +4082,308 @@ paths: summary: 修改授权备注 tags: - 授权记录管理 + /api/admin/carriers: + 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: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + in: query + name: carrier_type + schema: + description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电) + nullable: true + type: string + - description: 运营商名称(模糊搜索) + in: query + name: carrier_name + schema: + description: 运营商名称(模糊搜索) + maxLength: 100 + nullable: true + type: string + - description: 状态 (1:启用, 0:禁用) + in: query + name: status + schema: + description: 状态 (1:启用, 0:禁用) + nullable: true + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCarrierPageResult' + 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/DtoCreateCarrierRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCarrierResponse' + 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/carriers/{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/DtoCarrierResponse' + 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/DtoUpdateCarrierParams' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCarrierResponse' + 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/carriers/{id}/status: + 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/DtoUpdateCarrierStatusParams' + 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/commission/withdrawal-requests: get: parameters: diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index a4d36d7..2095589 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -33,5 +33,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), Storage: admin.NewStorageHandler(deps.StorageService), + Carrier: admin.NewCarrierHandler(svc.Carrier), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 75c5c81..efffc46 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -4,6 +4,7 @@ import ( accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" + carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account" @@ -43,6 +44,7 @@ type services struct { Device *deviceSvc.Service DeviceImport *deviceImportSvc.Service AssetAllocationRecord *assetAllocationRecordSvc.Service + Carrier *carrierSvc.Service } func initServices(s *stores, deps *Dependencies) *services { @@ -67,5 +69,6 @@ func initServices(s *stores, deps *Dependencies) *services { Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord), 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), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 9e733e4..8817ee4 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -26,6 +26,7 @@ type stores struct { DeviceSimBinding *postgres.DeviceSimBindingStore DeviceImportTask *postgres.DeviceImportTaskStore AssetAllocationRecord *postgres.AssetAllocationRecordStore + Carrier *postgres.CarrierStore } func initStores(deps *Dependencies) *stores { @@ -51,5 +52,6 @@ func initStores(deps *Dependencies) *stores { DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), + Carrier: postgres.NewCarrierStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index d198f25..47e788a 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -31,6 +31,7 @@ type Handlers struct { DeviceImport *admin.DeviceImportHandler AssetAllocationRecord *admin.AssetAllocationRecordHandler Storage *admin.StorageHandler + Carrier *admin.CarrierHandler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/carrier.go b/internal/handler/admin/carrier.go new file mode 100644 index 0000000..6a2251e --- /dev/null +++ b/internal/handler/admin/carrier.go @@ -0,0 +1,112 @@ +package admin + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + carrierService "github.com/break/junhong_cmp_fiber/internal/service/carrier" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +type CarrierHandler struct { + service *carrierService.Service +} + +func NewCarrierHandler(service *carrierService.Service) *CarrierHandler { + return &CarrierHandler{service: service} +} + +func (h *CarrierHandler) List(c *fiber.Ctx) error { + var req dto.CarrierListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + carriers, total, err := h.service.List(c.UserContext(), &req) + if err != nil { + return err + } + + return response.SuccessWithPagination(c, carriers, total, req.Page, req.PageSize) +} + +func (h *CarrierHandler) Create(c *fiber.Ctx) error { + var req dto.CreateCarrierRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + carrier, err := h.service.Create(c.UserContext(), &req) + if err != nil { + return err + } + + return response.Success(c, carrier) +} + +func (h *CarrierHandler) Get(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam, "无效的运营商 ID") + } + + carrier, err := h.service.Get(c.UserContext(), uint(id)) + if err != nil { + return err + } + + return response.Success(c, carrier) +} + +func (h *CarrierHandler) 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.UpdateCarrierRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + carrier, err := h.service.Update(c.UserContext(), uint(id), &req) + if err != nil { + return err + } + + return response.Success(c, carrier) +} + +func (h *CarrierHandler) 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 *CarrierHandler) 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.UpdateCarrierStatusRequest + 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/carrier.go b/internal/model/carrier.go index ef3158e..8452968 100644 --- a/internal/model/carrier.go +++ b/internal/model/carrier.go @@ -7,13 +7,11 @@ import ( type Carrier struct { gorm.Model BaseModel `gorm:"embedded"` - CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码(CMCC/CUCC/CTCC)" json:"carrier_code"` - CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称(中国移动/中国联通/中国电信)" json:"carrier_name"` - CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';uniqueIndex:idx_carrier_type_channel,priority:1,where:deleted_at IS NULL;comment:运营商类型" json:"carrier_type"` - ChannelName *string `gorm:"column:channel_name;type:varchar(100);comment:渠道名称" json:"channel_name,omitempty"` - ChannelCode *string `gorm:"column:channel_code;type:varchar(50);uniqueIndex:idx_carrier_type_channel,priority:2,where:deleted_at IS NULL;comment:渠道编码" json:"channel_code,omitempty"` - Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"` - Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"` + CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码" json:"carrier_code"` + CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称" json:"carrier_name"` + CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"` + Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"` + Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"` } // TableName 指定表名 diff --git a/internal/model/dto/carrier_dto.go b/internal/model/dto/carrier_dto.go new file mode 100644 index 0000000..d302897 --- /dev/null +++ b/internal/model/dto/carrier_dto.go @@ -0,0 +1,54 @@ +package dto + +type CreateCarrierRequest struct { + CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"` + CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"` + CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` + Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"` +} + +type UpdateCarrierRequest struct { + CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"` + Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"` +} + +type CarrierListRequest 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:"每页数量"` + CarrierType *string `json:"carrier_type" query:"carrier_type" validate:"omitempty,oneof=CMCC CUCC CTCC CBN" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` + CarrierName *string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊搜索)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (1:启用, 0:禁用)"` +} + +type UpdateCarrierStatusRequest struct { + Status int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (1:启用, 0:禁用)"` +} + +type CarrierResponse struct { + ID uint `json:"id" description:"运营商ID"` + CarrierCode string `json:"carrier_code" description:"运营商编码"` + CarrierName string `json:"carrier_name" description:"运营商名称"` + CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` + Description string `json:"description" description:"运营商描述"` + Status int `json:"status" description:"状态 (1:启用, 0:禁用)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +type UpdateCarrierParams struct { + IDReq + UpdateCarrierRequest +} + +type UpdateCarrierStatusParams struct { + IDReq + UpdateCarrierStatusRequest +} + +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:"总页数"` +} diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index e6bc3ea..5e234d9 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -24,6 +24,7 @@ type StandaloneIotCardResponse struct { CardType string `json:"card_type" description:"卡类型"` CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` CarrierID uint `json:"carrier_id" description:"运营商ID"` + CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` IMSI string `json:"imsi,omitempty" description:"IMSI"` MSISDN string `json:"msisdn,omitempty" description:"卡接入号"` @@ -79,6 +80,7 @@ type ImportTaskResponse struct { Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` StatusText string `json:"status_text" description:"任务状态文本"` CarrierID uint `json:"carrier_id" description:"运营商ID"` + CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"` CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` BatchNo string `json:"batch_no,omitempty" description:"批次号"` FileName string `json:"file_name,omitempty" description:"文件名"` diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index d96a3d6..9c0ecf8 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -16,6 +16,8 @@ type IotCard struct { CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"` CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"` CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"` + CarrierType string `gorm:"column:carrier_type;type:varchar(20);comment:运营商类型(CMCC/CUCC/CTCC/CBN),导入时快照" json:"carrier_type"` + CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,导入时快照" json:"carrier_name"` IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"` MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` diff --git a/internal/model/iot_card_import_task.go b/internal/model/iot_card_import_task.go index 7b42ddd..f097516 100644 --- a/internal/model/iot_card_import_task.go +++ b/internal/model/iot_card_import_task.go @@ -15,6 +15,7 @@ type IotCardImportTask struct { Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"` CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"` CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"` + CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,创建任务时快照" json:"carrier_name"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"` TotalCount int `gorm:"column:total_count;type:int;default:0;not null;comment:总数" json:"total_count"` diff --git a/internal/routes/admin.go b/internal/routes/admin.go index ae58aef..9dc7cd0 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -67,6 +67,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.Storage != nil { registerStorageRoutes(authGroup, handlers.Storage, doc, basePath) } + if handlers.Carrier != nil { + registerCarrierRoutes(authGroup, handlers.Carrier, doc, basePath) + } } func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) { diff --git a/internal/routes/carrier.go b/internal/routes/carrier.go new file mode 100644 index 0000000..6dd4fab --- /dev/null +++ b/internal/routes/carrier.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 registerCarrierRoutes(router fiber.Router, handler *admin.CarrierHandler, doc *openapi.Generator, basePath string) { + carriers := router.Group("/carriers") + groupPath := basePath + "/carriers" + + Register(carriers, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "运营商列表", + Tags: []string{"运营商管理"}, + Input: new(dto.CarrierListRequest), + Output: new(dto.CarrierPageResult), + Auth: true, + }) + + Register(carriers, doc, groupPath, "POST", "", handler.Create, RouteSpec{ + Summary: "创建运营商", + Tags: []string{"运营商管理"}, + Input: new(dto.CreateCarrierRequest), + Output: new(dto.CarrierResponse), + Auth: true, + }) + + Register(carriers, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{ + Summary: "获取运营商详情", + Tags: []string{"运营商管理"}, + Input: new(dto.IDReq), + Output: new(dto.CarrierResponse), + Auth: true, + }) + + Register(carriers, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{ + Summary: "更新运营商", + Tags: []string{"运营商管理"}, + Input: new(dto.UpdateCarrierParams), + Output: new(dto.CarrierResponse), + Auth: true, + }) + + Register(carriers, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ + Summary: "删除运营商", + Tags: []string{"运营商管理"}, + Input: new(dto.IDReq), + Output: nil, + Auth: true, + }) + + Register(carriers, doc, groupPath, "PUT", "/:id/status", handler.UpdateStatus, RouteSpec{ + Summary: "更新运营商状态", + Tags: []string{"运营商管理"}, + Input: new(dto.UpdateCarrierStatusParams), + Output: nil, + Auth: true, + }) +} diff --git a/internal/service/carrier/service.go b/internal/service/carrier/service.go new file mode 100644 index 0000000..a1471fc --- /dev/null +++ b/internal/service/carrier/service.go @@ -0,0 +1,182 @@ +package carrier + +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 { + carrierStore *postgres.CarrierStore +} + +func New(carrierStore *postgres.CarrierStore) *Service { + return &Service{carrierStore: carrierStore} +} + +func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) { + currentUserID := middleware.GetUserIDFromContext(ctx) + if currentUserID == 0 { + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + } + + existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode) + if existing != nil { + return nil, errors.New(errors.CodeCarrierCodeExists, "运营商编码已存在") + } + + carrier := &model.Carrier{ + CarrierCode: req.CarrierCode, + CarrierName: req.CarrierName, + CarrierType: req.CarrierType, + Description: req.Description, + Status: constants.StatusEnabled, + } + carrier.Creator = currentUserID + + if err := s.carrierStore.Create(ctx, carrier); err != nil { + return nil, fmt.Errorf("创建运营商失败: %w", err) + } + + return s.toResponse(carrier), nil +} + +func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error) { + carrier, err := s.carrierStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeCarrierNotFound, "运营商不存在") + } + return nil, fmt.Errorf("获取运营商失败: %w", err) + } + return s.toResponse(carrier), nil +} + +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierRequest) (*dto.CarrierResponse, error) { + currentUserID := middleware.GetUserIDFromContext(ctx) + if currentUserID == 0 { + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + } + + carrier, err := s.carrierStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeCarrierNotFound, "运营商不存在") + } + return nil, fmt.Errorf("获取运营商失败: %w", err) + } + + if req.CarrierName != nil { + carrier.CarrierName = *req.CarrierName + } + if req.Description != nil { + carrier.Description = *req.Description + } + carrier.Updater = currentUserID + + if err := s.carrierStore.Update(ctx, carrier); err != nil { + return nil, fmt.Errorf("更新运营商失败: %w", err) + } + + return s.toResponse(carrier), nil +} + +func (s *Service) Delete(ctx context.Context, id uint) error { + _, err := s.carrierStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeCarrierNotFound, "运营商不存在") + } + return fmt.Errorf("获取运营商失败: %w", err) + } + + if err := s.carrierStore.Delete(ctx, id); err != nil { + return fmt.Errorf("删除运营商失败: %w", err) + } + + return nil +} + +func (s *Service) List(ctx context.Context, req *dto.CarrierListRequest) ([]*dto.CarrierResponse, 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.CarrierType != nil { + filters["carrier_type"] = *req.CarrierType + } + if req.CarrierName != nil { + filters["carrier_name"] = *req.CarrierName + } + if req.Status != nil { + filters["status"] = *req.Status + } + + carriers, total, err := s.carrierStore.List(ctx, opts, filters) + if err != nil { + return nil, 0, fmt.Errorf("查询运营商列表失败: %w", err) + } + + responses := make([]*dto.CarrierResponse, len(carriers)) + for i, c := range carriers { + responses[i] = s.toResponse(c) + } + + 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, "未授权访问") + } + + carrier, err := s.carrierStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeCarrierNotFound, "运营商不存在") + } + return fmt.Errorf("获取运营商失败: %w", err) + } + + carrier.Status = status + carrier.Updater = currentUserID + + if err := s.carrierStore.Update(ctx, carrier); err != nil { + return fmt.Errorf("更新运营商状态失败: %w", err) + } + + return nil +} + +func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse { + return &dto.CarrierResponse{ + ID: c.ID, + CarrierCode: c.CarrierCode, + CarrierName: c.CarrierName, + CarrierType: c.CarrierType, + Description: c.Description, + Status: c.Status, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/internal/service/carrier/service_test.go b/internal/service/carrier/service_test.go new file mode 100644 index 0000000..d4169b3 --- /dev/null +++ b/internal/service/carrier/service_test.go @@ -0,0 +1,268 @@ +package carrier + +import ( + "context" + "testing" + + "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 TestCarrierService_Create(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + t.Run("创建成功", func(t *testing.T) { + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_CMCC_001", + CarrierName: "中国移动-服务测试", + CarrierType: constants.CarrierTypeCMCC, + Description: "服务层测试", + } + + resp, err := svc.Create(ctx, req) + require.NoError(t, err) + assert.NotZero(t, resp.ID) + assert.Equal(t, req.CarrierCode, resp.CarrierCode) + assert.Equal(t, req.CarrierName, resp.CarrierName) + assert.Equal(t, constants.StatusEnabled, resp.Status) + }) + + t.Run("编码重复失败", func(t *testing.T) { + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_CMCC_001", + CarrierName: "中国移动-重复", + CarrierType: constants.CarrierTypeCMCC, + } + + _, err := svc.Create(ctx, req) + require.Error(t, err) + appErr, ok := err.(*errors.AppError) + require.True(t, ok) + assert.Equal(t, errors.CodeCarrierCodeExists, appErr.Code) + }) + + t.Run("未授权失败", func(t *testing.T) { + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_CMCC_002", + CarrierName: "未授权测试", + CarrierType: constants.CarrierTypeCMCC, + } + + _, err := svc.Create(context.Background(), req) + require.Error(t, err) + }) +} + +func TestCarrierService_Get(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_GET_001", + CarrierName: "查询测试", + CarrierType: constants.CarrierTypeCUCC, + } + 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.CarrierCode, resp.CarrierCode) + }) + + 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.CodeCarrierNotFound, appErr.Code) + }) +} + +func TestCarrierService_Update(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_UPD_001", + CarrierName: "更新测试", + CarrierType: constants.CarrierTypeCTCC, + } + created, err := svc.Create(ctx, req) + require.NoError(t, err) + + t.Run("更新成功", func(t *testing.T) { + newName := "更新后的名称" + newDesc := "更新后的描述" + updateReq := &dto.UpdateCarrierRequest{ + CarrierName: &newName, + Description: &newDesc, + } + + resp, err := svc.Update(ctx, created.ID, updateReq) + require.NoError(t, err) + assert.Equal(t, newName, resp.CarrierName) + assert.Equal(t, newDesc, resp.Description) + }) + + t.Run("更新不存在的运营商", func(t *testing.T) { + newName := "test" + updateReq := &dto.UpdateCarrierRequest{ + CarrierName: &newName, + } + + _, err := svc.Update(ctx, 99999, updateReq) + require.Error(t, err) + appErr, ok := err.(*errors.AppError) + require.True(t, ok) + assert.Equal(t, errors.CodeCarrierNotFound, appErr.Code) + }) +} + +func TestCarrierService_Delete(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_DEL_001", + CarrierName: "删除测试", + CarrierType: constants.CarrierTypeCBN, + } + 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 TestCarrierService_List(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + carriers := []dto.CreateCarrierRequest{ + {CarrierCode: "SVC_LIST_001", CarrierName: "移动列表", CarrierType: constants.CarrierTypeCMCC}, + {CarrierCode: "SVC_LIST_002", CarrierName: "联通列表", CarrierType: constants.CarrierTypeCUCC}, + {CarrierCode: "SVC_LIST_003", CarrierName: "电信列表", CarrierType: constants.CarrierTypeCTCC}, + } + for _, c := range carriers { + _, err := svc.Create(ctx, &c) + require.NoError(t, err) + } + + t.Run("查询列表", func(t *testing.T) { + req := &dto.CarrierListRequest{ + 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) { + carrierType := constants.CarrierTypeCMCC + req := &dto.CarrierListRequest{ + Page: 1, + PageSize: 20, + CarrierType: &carrierType, + } + result, total, err := svc.List(ctx, req) + require.NoError(t, err) + assert.GreaterOrEqual(t, total, int64(1)) + for _, c := range result { + assert.Equal(t, constants.CarrierTypeCMCC, c.CarrierType) + } + }) +} + +func TestCarrierService_UpdateStatus(t *testing.T) { + tx := testutils.NewTestTransaction(t) + store := postgres.NewCarrierStore(tx) + svc := New(store) + + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + req := &dto.CreateCarrierRequest{ + CarrierCode: "SVC_STATUS_001", + CarrierName: "状态测试", + CarrierType: constants.CarrierTypeCMCC, + } + 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, 1) + require.Error(t, err) + }) +} diff --git a/internal/service/device/binding.go b/internal/service/device/binding.go index 2587b68..a632cc5 100644 --- a/internal/service/device/binding.go +++ b/internal/service/device/binding.go @@ -41,8 +41,6 @@ func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDev cardMap[card.ID] = card } - carrierMap := s.loadCarrierData(ctx, cards) - responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings)) for _, binding := range bindings { card := cardMap[binding.IotCardID] @@ -56,7 +54,7 @@ func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDev IotCardID: binding.IotCardID, ICCID: card.ICCID, MSISDN: card.MSISDN, - CarrierName: carrierMap[card.CarrierID], + CarrierName: card.CarrierName, // 直接使用 IotCard 的冗余字段 Status: card.Status, BindTime: binding.BindTime, } @@ -147,26 +145,3 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (* Message: "解绑成功", }, nil } - -func (s *Service) loadCarrierData(ctx context.Context, cards []*model.IotCard) map[uint]string { - carrierIDs := make([]uint, 0) - carrierIDSet := make(map[uint]bool) - - for _, card := range cards { - if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] { - carrierIDs = append(carrierIDs, card.CarrierID) - carrierIDSet[card.CarrierID] = true - } - } - - carrierMap := make(map[uint]string) - if len(carrierIDs) > 0 { - var carriers []model.Carrier - s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers) - for _, c := range carriers { - carrierMap[c.ID] = c.CarrierName - } - } - - return carrierMap -} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index cd49521..f80d146 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -88,11 +88,11 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot return nil, err } - carrierMap, shopMap := s.loadRelatedData(ctx, cards) + shopMap := s.loadShopNames(ctx, cards) list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) for _, card := range cards { - item := s.toStandaloneResponse(card, carrierMap, shopMap) + item := s.toStandaloneResponse(card, shopMap) list = append(list, item) } @@ -120,40 +120,25 @@ func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDet return nil, err } - carrierMap, shopMap := s.loadRelatedData(ctx, []*model.IotCard{card}) - standaloneResp := s.toStandaloneResponse(card, carrierMap, shopMap) + shopMap := s.loadShopNames(ctx, []*model.IotCard{card}) + standaloneResp := s.toStandaloneResponse(card, shopMap) return &dto.IotCardDetailResponse{ StandaloneIotCardResponse: *standaloneResp, }, nil } -func (s *Service) loadRelatedData(ctx context.Context, cards []*model.IotCard) (map[uint]string, map[uint]string) { - carrierIDs := make([]uint, 0) +func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map[uint]string { shopIDs := make([]uint, 0) - carrierIDSet := make(map[uint]bool) shopIDSet := make(map[uint]bool) for _, card := range cards { - if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] { - carrierIDs = append(carrierIDs, card.CarrierID) - carrierIDSet[card.CarrierID] = true - } if card.ShopID != nil && *card.ShopID > 0 && !shopIDSet[*card.ShopID] { shopIDs = append(shopIDs, *card.ShopID) shopIDSet[*card.ShopID] = true } } - carrierMap := make(map[uint]string) - if len(carrierIDs) > 0 { - var carriers []model.Carrier - s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers) - for _, c := range carriers { - carrierMap[c.ID] = c.CarrierName - } - } - shopMap := make(map[uint]string) if len(shopIDs) > 0 { var shops []model.Shop @@ -163,17 +148,18 @@ func (s *Service) loadRelatedData(ctx context.Context, cards []*model.IotCard) ( } } - return carrierMap, shopMap + return shopMap } -func (s *Service) toStandaloneResponse(card *model.IotCard, carrierMap map[uint]string, shopMap map[uint]string) *dto.StandaloneIotCardResponse { +func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string) *dto.StandaloneIotCardResponse { resp := &dto.StandaloneIotCardResponse{ ID: card.ID, ICCID: card.ICCID, CardType: card.CardType, CardCategory: card.CardCategory, CarrierID: card.CarrierID, - CarrierName: carrierMap[card.CarrierID], + CarrierType: card.CarrierType, + CarrierName: card.CarrierName, IMSI: card.IMSI, MSISDN: card.MSISDN, BatchNo: card.BatchNo, diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 02f4a50..380fd6d 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -76,6 +76,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe Status: model.ImportTaskStatusPending, CarrierID: req.CarrierID, CarrierType: carrier.CarrierType, + CarrierName: carrier.CarrierName, BatchNo: req.BatchNo, FileName: fileName, StorageKey: req.FileKey, @@ -138,11 +139,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dt return nil, err } - carrierMap := s.loadCarriers(ctx, tasks) - list := make([]*dto.ImportTaskResponse, 0, len(tasks)) for _, task := range tasks { - list = append(list, s.toTaskResponse(task, carrierMap)) + list = append(list, s.toTaskResponse(task)) } totalPages := int(total) / pageSize @@ -165,14 +164,8 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ImportTaskDetailRe return nil, errors.New(errors.CodeNotFound, "导入任务不存在") } - carrierMap := make(map[uint]string) - var carrier model.Carrier - if s.db.WithContext(ctx).First(&carrier, task.CarrierID).Error == nil { - carrierMap[carrier.ID] = carrier.CarrierName - } - resp := &dto.ImportTaskDetailResponse{ - ImportTaskResponse: *s.toTaskResponse(task, carrierMap), + ImportTaskResponse: *s.toTaskResponse(task), SkippedItems: make([]*dto.ImportResultItemDTO, 0), FailedItems: make([]*dto.ImportResultItemDTO, 0), } @@ -198,28 +191,7 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ImportTaskDetailRe return resp, nil } -func (s *Service) loadCarriers(ctx context.Context, tasks []*model.IotCardImportTask) map[uint]string { - carrierIDs := make([]uint, 0) - carrierIDSet := make(map[uint]bool) - for _, task := range tasks { - if task.CarrierID > 0 && !carrierIDSet[task.CarrierID] { - carrierIDs = append(carrierIDs, task.CarrierID) - carrierIDSet[task.CarrierID] = true - } - } - - carrierMap := make(map[uint]string) - if len(carrierIDs) > 0 { - var carriers []model.Carrier - s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers) - for _, c := range carriers { - carrierMap[c.ID] = c.CarrierName - } - } - return carrierMap -} - -func (s *Service) toTaskResponse(task *model.IotCardImportTask, carrierMap map[uint]string) *dto.ImportTaskResponse { +func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskResponse { var startedAt, completedAt *time.Time if task.StartedAt != nil { startedAt = task.StartedAt @@ -234,7 +206,8 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask, carrierMap map[u Status: task.Status, StatusText: getStatusText(task.Status), CarrierID: task.CarrierID, - CarrierName: carrierMap[task.CarrierID], + CarrierType: task.CarrierType, + CarrierName: task.CarrierName, BatchNo: task.BatchNo, FileName: task.FileName, TotalCount: task.TotalCount, diff --git a/internal/store/postgres/carrier_store.go b/internal/store/postgres/carrier_store.go new file mode 100644 index 0000000..ec60dc6 --- /dev/null +++ b/internal/store/postgres/carrier_store.go @@ -0,0 +1,83 @@ +package postgres + +import ( + "context" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" +) + +type CarrierStore struct { + db *gorm.DB +} + +func NewCarrierStore(db *gorm.DB) *CarrierStore { + return &CarrierStore{db: db} +} + +func (s *CarrierStore) Create(ctx context.Context, carrier *model.Carrier) error { + return s.db.WithContext(ctx).Create(carrier).Error +} + +func (s *CarrierStore) GetByID(ctx context.Context, id uint) (*model.Carrier, error) { + var carrier model.Carrier + if err := s.db.WithContext(ctx).First(&carrier, id).Error; err != nil { + return nil, err + } + return &carrier, nil +} + +func (s *CarrierStore) GetByCode(ctx context.Context, code string) (*model.Carrier, error) { + var carrier model.Carrier + if err := s.db.WithContext(ctx).Where("carrier_code = ?", code).First(&carrier).Error; err != nil { + return nil, err + } + return &carrier, nil +} + +func (s *CarrierStore) Update(ctx context.Context, carrier *model.Carrier) error { + return s.db.WithContext(ctx).Save(carrier).Error +} + +func (s *CarrierStore) Delete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.Carrier{}, id).Error +} + +func (s *CarrierStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Carrier, int64, error) { + var carriers []*model.Carrier + var total int64 + + query := s.db.WithContext(ctx).Model(&model.Carrier{}) + + if carrierType, ok := filters["carrier_type"].(string); ok && carrierType != "" { + query = query.Where("carrier_type = ?", carrierType) + } + if carrierName, ok := filters["carrier_name"].(string); ok && carrierName != "" { + query = query.Where("carrier_name LIKE ?", "%"+carrierName+"%") + } + 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(&carriers).Error; err != nil { + return nil, 0, err + } + + return carriers, total, nil +} diff --git a/internal/store/postgres/carrier_store_test.go b/internal/store/postgres/carrier_store_test.go new file mode 100644 index 0000000..1cd9331 --- /dev/null +++ b/internal/store/postgres/carrier_store_test.go @@ -0,0 +1,204 @@ +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 TestCarrierStore_Create(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "CMCC_TEST_001", + CarrierName: "中国移动测试", + CarrierType: constants.CarrierTypeCMCC, + Description: "测试运营商", + Status: constants.StatusEnabled, + } + + err := s.Create(ctx, carrier) + require.NoError(t, err) + assert.NotZero(t, carrier.ID) +} + +func TestCarrierStore_GetByID(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "CUCC_TEST_001", + CarrierName: "中国联通测试", + CarrierType: constants.CarrierTypeCUCC, + Status: constants.StatusEnabled, + } + require.NoError(t, s.Create(ctx, carrier)) + + t.Run("查询存在的运营商", func(t *testing.T) { + result, err := s.GetByID(ctx, carrier.ID) + require.NoError(t, err) + assert.Equal(t, carrier.CarrierCode, result.CarrierCode) + assert.Equal(t, carrier.CarrierName, result.CarrierName) + }) + + t.Run("查询不存在的运营商", func(t *testing.T) { + _, err := s.GetByID(ctx, 99999) + require.Error(t, err) + }) +} + +func TestCarrierStore_GetByCode(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "CTCC_TEST_001", + CarrierName: "中国电信测试", + CarrierType: constants.CarrierTypeCTCC, + Status: constants.StatusEnabled, + } + require.NoError(t, s.Create(ctx, carrier)) + + t.Run("查询存在的编码", func(t *testing.T) { + result, err := s.GetByCode(ctx, "CTCC_TEST_001") + require.NoError(t, err) + assert.Equal(t, carrier.ID, result.ID) + }) + + t.Run("查询不存在的编码", func(t *testing.T) { + _, err := s.GetByCode(ctx, "NOT_EXISTS") + require.Error(t, err) + }) +} + +func TestCarrierStore_Update(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "CBN_TEST_001", + CarrierName: "中国广电测试", + CarrierType: constants.CarrierTypeCBN, + Status: constants.StatusEnabled, + } + require.NoError(t, s.Create(ctx, carrier)) + + carrier.CarrierName = "中国广电测试-更新" + carrier.Description = "更新后的描述" + err := s.Update(ctx, carrier) + require.NoError(t, err) + + updated, err := s.GetByID(ctx, carrier.ID) + require.NoError(t, err) + assert.Equal(t, "中国广电测试-更新", updated.CarrierName) + assert.Equal(t, "更新后的描述", updated.Description) +} + +func TestCarrierStore_Delete(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "DEL_TEST_001", + CarrierName: "待删除运营商", + CarrierType: constants.CarrierTypeCMCC, + Status: constants.StatusEnabled, + } + require.NoError(t, s.Create(ctx, carrier)) + + err := s.Delete(ctx, carrier.ID) + require.NoError(t, err) + + _, err = s.GetByID(ctx, carrier.ID) + require.Error(t, err) +} + +func TestCarrierStore_List(t *testing.T) { + tx := testutils.NewTestTransaction(t) + s := NewCarrierStore(tx) + ctx := context.Background() + + carriers := []*model.Carrier{ + {CarrierCode: "LIST_001", CarrierName: "移动1", CarrierType: constants.CarrierTypeCMCC, Status: constants.StatusEnabled}, + {CarrierCode: "LIST_002", CarrierName: "联通1", CarrierType: constants.CarrierTypeCUCC, Status: constants.StatusEnabled}, + {CarrierCode: "LIST_003", CarrierName: "电信1", CarrierType: constants.CarrierTypeCTCC, Status: constants.StatusEnabled}, + } + for _, c := range carriers { + require.NoError(t, s.Create(ctx, c)) + } + // 显式更新第三个 carrier 为禁用状态(GORM 不会写入零值) + carriers[2].Status = constants.StatusDisabled + require.NoError(t, s.Update(ctx, carriers[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{}{"carrier_type": constants.CarrierTypeCMCC} + result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters) + require.NoError(t, err) + assert.GreaterOrEqual(t, total, int64(1)) + for _, c := range result { + assert.Equal(t, constants.CarrierTypeCMCC, c.CarrierType) + } + }) + + t.Run("按名称模糊搜索", func(t *testing.T) { + filters := map[string]interface{}{"carrier_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 _, c := range result { + assert.Contains(t, c.CarrierName, "联通") + } + }) + + 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 _, c := range result { + assert.Equal(t, constants.StatusDisabled, c.Status) + } + }) + + t.Run("按状态过滤-启用", func(t *testing.T) { + filters := map[string]interface{}{"status": constants.StatusEnabled} + result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters) + require.NoError(t, err) + assert.GreaterOrEqual(t, total, int64(2)) + for _, c := range result { + assert.Equal(t, constants.StatusEnabled, c.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) + }) + + t.Run("默认分页选项", func(t *testing.T) { + result, _, err := s.List(ctx, nil, nil) + require.NoError(t, err) + assert.NotNil(t, result) + }) +} diff --git a/migrations/000021_carrier_remove_channel_fields.down.sql b/migrations/000021_carrier_remove_channel_fields.down.sql new file mode 100644 index 0000000..260f86e --- /dev/null +++ b/migrations/000021_carrier_remove_channel_fields.down.sql @@ -0,0 +1,12 @@ +-- 回滚:恢复 Carrier 表的 channel_name、channel_code 字段 + +-- 添加 channel_name 字段 +ALTER TABLE tb_carrier ADD COLUMN channel_name VARCHAR(100); +COMMENT ON COLUMN tb_carrier.channel_name IS '渠道名称'; + +-- 添加 channel_code 字段 +ALTER TABLE tb_carrier ADD COLUMN channel_code VARCHAR(50); +COMMENT ON COLUMN tb_carrier.channel_code IS '渠道编码'; + +-- 重建联合唯一索引(carrier_type + channel_code,排除已删除记录) +CREATE UNIQUE INDEX idx_carrier_type_channel ON tb_carrier(carrier_type, channel_code) WHERE deleted_at IS NULL; diff --git a/migrations/000021_carrier_remove_channel_fields.up.sql b/migrations/000021_carrier_remove_channel_fields.up.sql new file mode 100644 index 0000000..a575b24 --- /dev/null +++ b/migrations/000021_carrier_remove_channel_fields.up.sql @@ -0,0 +1,11 @@ +-- 移除 Carrier 表的 channel_name、channel_code 字段及相关索引 +-- 这些字段未被任何地方使用,属于冗余设计 + +-- 先删除联合唯一索引 +DROP INDEX IF EXISTS idx_carrier_type_channel; + +-- 删除 channel_name 字段 +ALTER TABLE tb_carrier DROP COLUMN IF EXISTS channel_name; + +-- 删除 channel_code 字段 +ALTER TABLE tb_carrier DROP COLUMN IF EXISTS channel_code; diff --git a/migrations/000022_iot_card_add_carrier_redundant_fields.down.sql b/migrations/000022_iot_card_add_carrier_redundant_fields.down.sql new file mode 100644 index 0000000..d3e6d36 --- /dev/null +++ b/migrations/000022_iot_card_add_carrier_redundant_fields.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS carrier_type; +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS carrier_name; diff --git a/migrations/000022_iot_card_add_carrier_redundant_fields.up.sql b/migrations/000022_iot_card_add_carrier_redundant_fields.up.sql new file mode 100644 index 0000000..eecdfa5 --- /dev/null +++ b/migrations/000022_iot_card_add_carrier_redundant_fields.up.sql @@ -0,0 +1,14 @@ +-- IotCard 表添加 carrier_type、carrier_name 冗余字段 +-- 导入时从 Carrier 表快照,后续查询无需 JOIN + +ALTER TABLE tb_iot_card ADD COLUMN carrier_type VARCHAR(20); +ALTER TABLE tb_iot_card ADD COLUMN carrier_name VARCHAR(100); + +COMMENT ON COLUMN tb_iot_card.carrier_type IS '运营商类型(CMCC/CUCC/CTCC/CBN),导入时快照'; +COMMENT ON COLUMN tb_iot_card.carrier_name IS '运营商名称,导入时快照'; + +-- 从 Carrier 表填充现有数据 +UPDATE tb_iot_card +SET carrier_type = c.carrier_type, carrier_name = c.carrier_name +FROM tb_carrier c +WHERE tb_iot_card.carrier_id = c.id; diff --git a/migrations/000023_import_task_add_carrier_name.down.sql b/migrations/000023_import_task_add_carrier_name.down.sql new file mode 100644 index 0000000..f1f23d9 --- /dev/null +++ b/migrations/000023_import_task_add_carrier_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_iot_card_import_task DROP COLUMN IF EXISTS carrier_name; diff --git a/migrations/000023_import_task_add_carrier_name.up.sql b/migrations/000023_import_task_add_carrier_name.up.sql new file mode 100644 index 0000000..3d72f41 --- /dev/null +++ b/migrations/000023_import_task_add_carrier_name.up.sql @@ -0,0 +1,11 @@ +-- IotCardImportTask 表添加 carrier_name 冗余字段(已有 carrier_type) + +ALTER TABLE tb_iot_card_import_task ADD COLUMN carrier_name VARCHAR(100); + +COMMENT ON COLUMN tb_iot_card_import_task.carrier_name IS '运营商名称,创建任务时快照'; + +-- 从 Carrier 表填充现有数据 +UPDATE tb_iot_card_import_task +SET carrier_name = c.carrier_name +FROM tb_carrier c +WHERE tb_iot_card_import_task.carrier_id = c.id; diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/.openspec.yaml b/openspec/changes/archive/2026-01-27-carrier-module-refactor/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/design.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/design.md new file mode 100644 index 0000000..d6ce1e5 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/design.md @@ -0,0 +1,96 @@ +## Context + +Carrier(运营商)模块当前状态: +- Model 已定义于 `internal/model/carrier.go`,表名 `tb_carrier` +- 被 IotCard、IotCardImportTask、PollingConfig 通过 `carrier_id` 引用 +- 缺少管理接口,数据通过其他方式手动管理 +- `channel_name`、`channel_code` 字段未被任何地方使用 +- 查询 IotCard 时需要 JOIN Carrier 表获取 carrier_name + +CarrierType 固定为 4 种:CMCC(中国移动)、CUCC(中国联通)、CTCC(中国电信)、CBN(中国广电) + +## Goals / Non-Goals + +**Goals:** +- 提供完整的 Carrier 管理 API(CRUD + 状态管理) +- 简化 Carrier Model,移除冗余字段 +- IotCard/ImportTask 存储冗余快照,实现数据自包含 +- 优化查询性能,减少不必要的 JOIN + +**Non-Goals:** +- PollingConfig 保持 carrier_id 引用方式(配置语义,NULL 表示所有运营商) +- 不修改 Carrier 的业务逻辑(仅提供管理接口) +- 不支持 CarrierType 动态扩展(固定 4 种) + +## Decisions + +### D1: 冗余快照 vs 保持引用 + +**决策**: IotCard/ImportTask 采用冗余快照存储 carrier_type、carrier_name + +**理由**: +- 查询时无需 JOIN,性能更好 +- Carrier 软删除后历史数据完整 +- 符合"赋予时刻快照"的业务语义——卡分配后运营商信息不应变化 + +**备选方案**: +- 保持纯引用:需要限制 Carrier 删除,查询需要 JOIN +- 软引用 + 视图:复杂度高,维护成本大 + +### D2: PollingConfig 不添加冗余字段 + +**决策**: PollingConfig 保持 carrier_id 引用 + +**理由**: +- PollingConfig 是配置表,`carrier_id = NULL` 有特殊含义(所有运营商) +- 配置应该跟随 Carrier 变化,不需要历史快照 +- 场景不同于数据记录 + +### D3: CarrierType 枚举校验 + +**决策**: 创建时 carrier_type 必须是 CMCC/CUCC/CTCC/CBN 之一,使用 validator 枚举校验 + +**理由**: +- 运营商类型固定,不会动态扩展 +- 前端下拉选择,后端强校验 +- 避免脏数据 + +### D4: 删除策略 + +**决策**: Carrier 可以软删除,不检查关联数据 + +**理由**: +- IotCard 已有冗余字段,不依赖 Carrier 表 +- ImportTask 同理 +- PollingConfig 的 carrier_id 可能变成悬空引用,但配置场景可接受(管理员负责) + +### D5: API 路由设计 + +**决策**: 遵循项目现有 RESTful 风格 + +``` +POST /api/admin/carriers 创建 +GET /api/admin/carriers 列表(分页+筛选) +GET /api/admin/carriers/:id 详情 +PUT /api/admin/carriers/:id 更新 +DELETE /api/admin/carriers/:id 软删除 +PUT /api/admin/carriers/:id/status 启用/禁用 +``` + +## Risks / Trade-offs + +### R1: 数据冗余导致存储增加 +- **风险**: 每条 IotCard 多存 carrier_type(20B) + carrier_name(100B) +- **缓解**: 可接受的存储开销,换取查询性能和数据独立性 + +### R2: 迁移时需要填充历史数据 +- **风险**: 现有 IotCard/ImportTask 记录需要通过 UPDATE 填充冗余字段 +- **缓解**: 迁移脚本从 Carrier 表 JOIN 填充,一次性操作 + +### R3: carrier_code/carrier_type 创建后不可修改 +- **风险**: 如果创建错误,只能删除重建 +- **缓解**: 前端选择 + 后端校验,降低出错概率;错误情况下软删除后重建 + +### R4: 移除 channel 字段是 BREAKING CHANGE +- **风险**: 如果有外部系统依赖这些字段 +- **缓解**: 已确认这些字段未被使用,可安全移除 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/proposal.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/proposal.md new file mode 100644 index 0000000..e56833c --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/proposal.md @@ -0,0 +1,32 @@ +## Why + +Carrier(运营商)模块目前只有 Model 定义,缺少管理接口。系统需要一套完整的 CRUD + 状态管理接口来创建和管理上游运营商渠道。同时,现有的 IotCard 等表通过 carrier_id 引用 Carrier,每次查询都需要 JOIN,且 Carrier 删除后历史数据会缺失。需要通过冗余字段实现"赋予时刻快照",让数据自包含。 + +## What Changes + +- 新增 Carrier 管理 API(增删改查 + 启用/禁用) +- 简化 Carrier Model,移除未使用的 `channel_name`、`channel_code` 字段 +- IotCard 新增冗余字段 `carrier_type`、`carrier_name`,导入时填充快照 +- IotCardImportTask 新增冗余字段 `carrier_name`(已有 `carrier_type`) +- 优化查询逻辑,移除不必要的 JOIN 操作 +- **BREAKING**: 移除 Carrier 表的 `channel_name`、`channel_code` 字段及相关索引 + +## Capabilities + +### New Capabilities + +- `carrier-management`: 运营商管理功能,包含 CRUD 接口、状态管理、列表筛选 + +### Modified Capabilities + +- `iot-card-import`: 导入时填充 carrier_type、carrier_name 冗余字段 +- `iot-card-query`: 查询响应直接使用冗余字段,无需 JOIN Carrier 表 + +## Impact + +- **Model 层**: `carrier.go`(移除字段)、`iot_card.go`(新增字段)、`iot_card_import_task.go`(新增字段) +- **新增文件**: `carrier_dto.go`、`carrier_store.go`、`carrier/service.go`、`carrier.go`(handler)、`carrier.go`(routes) +- **修改文件**: `iot_card/service.go`、`iot_card_import/service.go`、`device/binding.go`(移除 JOIN 逻辑) +- **Bootstrap**: 注册新的 Store、Service、Handler +- **数据库**: 3 个迁移文件(Carrier 简化、IotCard 冗余、ImportTask 冗余) +- **API**: 新增 `/api/admin/carriers` 路由组 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/carrier-management/spec.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/carrier-management/spec.md new file mode 100644 index 0000000..54464ca --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/carrier-management/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: 创建运营商 +系统 SHALL 允许管理员创建新的运营商记录。创建时必须指定 carrier_code(唯一编码)、carrier_name(显示名称)、carrier_type(运营商类型,枚举值)。description 为选填字段。创建成功后默认状态为启用(status=1)。 + +#### Scenario: 成功创建运营商 +- **WHEN** 管理员提交有效的创建请求,carrier_code 不重复,carrier_type 为有效枚举值 +- **THEN** 系统创建运营商记录,返回完整的运营商信息 + +#### Scenario: carrier_code 重复 +- **WHEN** 管理员提交的 carrier_code 已存在 +- **THEN** 系统返回错误"运营商编码已存在" + +#### Scenario: carrier_type 无效 +- **WHEN** 管理员提交的 carrier_type 不是 CMCC/CUCC/CTCC/CBN 之一 +- **THEN** 系统返回参数校验错误 + +### Requirement: 查询运营商列表 +系统 SHALL 提供分页查询运营商列表的接口,支持按 carrier_type、status、carrier_name(模糊搜索)筛选。 + +#### Scenario: 无筛选条件查询 +- **WHEN** 管理员请求列表,不带筛选条件 +- **THEN** 系统返回所有运营商的分页列表,按 ID 降序排列 + +#### Scenario: 按运营商类型筛选 +- **WHEN** 管理员指定 carrier_type=CMCC +- **THEN** 系统仅返回 carrier_type 为 CMCC 的记录 + +#### Scenario: 按名称模糊搜索 +- **WHEN** 管理员指定 carrier_name=移动 +- **THEN** 系统返回 carrier_name 包含"移动"的记录 + +### Requirement: 获取运营商详情 +系统 SHALL 允许管理员通过 ID 获取单个运营商的详细信息。 + +#### Scenario: 成功获取详情 +- **WHEN** 管理员请求存在的运营商 ID +- **THEN** 系统返回该运营商的完整信息 + +#### Scenario: 运营商不存在 +- **WHEN** 管理员请求不存在的运营商 ID +- **THEN** 系统返回错误"运营商不存在" + +### Requirement: 更新运营商 +系统 SHALL 允许管理员更新运营商的 carrier_name 和 description 字段。carrier_code 和 carrier_type 创建后不可修改。 + +#### Scenario: 成功更新运营商 +- **WHEN** 管理员提交有效的更新请求 +- **THEN** 系统更新运营商信息,返回更新后的完整信息 + +#### Scenario: 尝试修改 carrier_code +- **WHEN** 管理员尝试修改 carrier_code +- **THEN** 系统忽略该字段(不报错,但不修改) + +### Requirement: 删除运营商 +系统 SHALL 允许管理员软删除运营商记录。 + +#### Scenario: 成功删除运营商 +- **WHEN** 管理员请求删除存在的运营商 +- **THEN** 系统软删除该记录(设置 deleted_at) + +#### Scenario: 删除不存在的运营商 +- **WHEN** 管理员请求删除不存在的运营商 ID +- **THEN** 系统返回错误"运营商不存在" + +### Requirement: 更新运营商状态 +系统 SHALL 允许管理员启用或禁用运营商。状态值:1=启用,2=禁用。 + +#### Scenario: 启用运营商 +- **WHEN** 管理员将状态设置为 1 +- **THEN** 系统更新运营商状态为启用 + +#### Scenario: 禁用运营商 +- **WHEN** 管理员将状态设置为 2 +- **THEN** 系统更新运营商状态为禁用 + +#### Scenario: 无效状态值 +- **WHEN** 管理员提交的状态值不是 1 或 2 +- **THEN** 系统返回参数校验错误 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-import/spec.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-import/spec.md new file mode 100644 index 0000000..4749896 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-import/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: 导入物联网卡时记录运营商信息 +系统 SHALL 在导入物联网卡时,将运营商的 carrier_type 和 carrier_name 作为冗余字段存储到 IotCard 记录中。这些字段在导入时从 Carrier 表查询并写入,后续不再依赖 Carrier 表。 + +#### Scenario: 导入时填充冗余字段 +- **WHEN** 系统处理物联网卡导入任务 +- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_type 和 carrier_name 写入每条 IotCard 记录 + +#### Scenario: Carrier 不存在 +- **WHEN** 导入任务指定的 carrier_id 对应的 Carrier 不存在或已删除 +- **THEN** 系统拒绝导入,返回错误"运营商不存在" + +### Requirement: 导入任务记录运营商名称 +系统 SHALL 在创建导入任务时,将 carrier_name 作为冗余字段存储到 IotCardImportTask 记录中(已有 carrier_type)。 + +#### Scenario: 创建导入任务时填充 carrier_name +- **WHEN** 管理员创建物联网卡导入任务 +- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_name 写入导入任务记录 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-query/spec.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-query/spec.md new file mode 100644 index 0000000..3ab9af4 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/specs/iot-card-query/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: 查询物联网卡时返回运营商信息 +系统 SHALL 在查询物联网卡列表/详情时,直接从 IotCard 记录的冗余字段返回 carrier_type 和 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 列表查询返回运营商信息 +- **WHEN** 管理员查询物联网卡列表 +- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段 + +#### Scenario: 详情查询返回运营商信息 +- **WHEN** 管理员查询单张物联网卡详情 +- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段 + +### Requirement: 查询导入任务时返回运营商名称 +系统 SHALL 在查询导入任务列表/详情时,直接从 IotCardImportTask 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 导入任务列表返回运营商名称 +- **WHEN** 管理员查询导入任务列表 +- **THEN** 响应中的 carrier_name 直接来自 IotCardImportTask 记录的冗余字段 + +### Requirement: 设备绑定卡查询返回运营商信息 +系统 SHALL 在查询设备绑定的物联网卡时,直接从 IotCard 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 设备绑定卡列表返回运营商名称 +- **WHEN** 管理员查询设备绑定的物联网卡列表 +- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段 diff --git a/openspec/changes/archive/2026-01-27-carrier-module-refactor/tasks.md b/openspec/changes/archive/2026-01-27-carrier-module-refactor/tasks.md new file mode 100644 index 0000000..2b1f372 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-carrier-module-refactor/tasks.md @@ -0,0 +1,62 @@ +## 1. 数据库迁移 + +- [x] 1.1 创建迁移文件:Carrier 表移除 channel_name、channel_code 字段及 idx_carrier_type_channel 索引 +- [x] 1.2 创建迁移文件:IotCard 表添加 carrier_type、carrier_name 冗余字段,并从 Carrier 表填充现有数据 +- [x] 1.3 创建迁移文件:IotCardImportTask 表添加 carrier_name 冗余字段,并从 Carrier 表填充现有数据 +- [x] 1.4 执行迁移,验证数据完整性 + +## 2. Model 层修改 + +- [x] 2.1 修改 `internal/model/carrier.go`:移除 ChannelName、ChannelCode 字段 +- [x] 2.2 修改 `internal/model/iot_card.go`:添加 CarrierType、CarrierName 字段 +- [x] 2.3 修改 `internal/model/iot_card_import_task.go`:添加 CarrierName 字段 + +## 3. DTO 层 + +- [x] 3.1 创建 `internal/model/dto/carrier_dto.go`:定义 CreateCarrierRequest、UpdateCarrierRequest、CarrierListRequest、UpdateCarrierStatusRequest、CarrierResponse +- [x] 3.2 修改 `internal/model/dto/iot_card_dto.go`:响应结构添加 carrier_type 字段(如果缺失) +- [x] 3.3 修改 `internal/model/dto/iot_card_import_dto.go`:响应结构确认包含 carrier_name 字段 + +## 4. Store 层 + +- [x] 4.1 创建 `internal/store/postgres/carrier_store.go`:实现 Create、GetByID、Update、Delete、List、GetByCode 方法 + +## 5. Service 层 + +- [x] 5.1 创建 `internal/service/carrier/service.go`:实现 Create、Get、Update、Delete、List、UpdateStatus 业务逻辑 +- [x] 5.2 修改 `internal/service/iot_card_import/service.go`:创建导入任务时填充 carrier_name;处理卡片时填充 carrier_type、carrier_name +- [x] 5.3 修改 `internal/service/iot_card/service.go`:移除 loadCarrierData / loadRelatedData 中的 Carrier JOIN 逻辑,直接使用 IotCard 自身字段 +- [x] 5.4 修改 `internal/service/device/binding.go`:移除 loadCarrierData 方法,直接使用 IotCard 的 carrier_name 字段 + +## 6. Handler 层 + +- [x] 6.1 创建 `internal/handler/admin/carrier.go`:实现 Create、Get、Update、Delete、List、UpdateStatus 接口 + +## 7. 路由注册 + +- [x] 7.1 创建 `internal/routes/carrier.go`:注册 /api/admin/carriers 路由组 +- [x] 7.2 更新 `internal/routes/admin.go`:调用 Carrier 路由注册 + +## 8. Bootstrap 注册 + +- [x] 8.1 修改 `internal/bootstrap/stores.go`:注册 CarrierStore +- [x] 8.2 修改 `internal/bootstrap/services.go`:注册 CarrierService +- [x] 8.3 修改 `internal/bootstrap/handlers.go`:注册 CarrierHandler + +## 9. 常量定义 + +- [x] 9.1 在 `pkg/constants/` 中添加 CarrierType 枚举常量 +- [x] 9.2 在 `pkg/errors/codes.go` 中添加 Carrier 相关错误码(CodeCarrierNotFound、CodeCarrierCodeExists 等) + +## 10. 测试 + +- [x] 10.1 编写 CarrierStore 单元测试 +- [x] 10.2 编写 CarrierService 单元测试 +- [x] 10.3 编写 Carrier API 集成测试 +- [x] 10.4 验证 IotCard 导入流程正确填充冗余字段(通过 TestIotCard_CarrierRedundantFields 验证) +- [x] 10.5 验证 IotCard 查询响应正确返回冗余字段(通过 TestIotCard_GetByICCID 验证) + +## 11. 文档更新 + +- [x] 11.1 更新 API 文档生成器(docs.go / gendocs/main.go)注册 CarrierHandler +- [x] 11.2 运行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档 diff --git a/openspec/specs/carrier/spec.md b/openspec/specs/carrier/spec.md index b5b6392..af7c8e8 100644 --- a/openspec/specs/carrier/spec.md +++ b/openspec/specs/carrier/spec.md @@ -1,11 +1,13 @@ # carrier Specification ## Purpose -TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose after archive. +管理运营商(Carrier)实体,支持四大固定运营商(中国移动、中国联通、中国电信、广电)的 CRUD 操作。 + ## Requirements + ### Requirement: 运营商实体定义 -系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)的渠道信息 +系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)。 **四大运营商固定枚举**: - **CMCC**:中国移动 @@ -15,44 +17,114 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose **实体字段**: - `id`:运营商 ID(主键,BIGINT) -- `carrier_type`:运营商类型(VARCHAR(20),枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN")**【新增】** +- `carrier_code`:运营商编码(VARCHAR(50),唯一约束) - `carrier_name`:运营商名称(VARCHAR(100),如"中国移动") -- `carrier_code`:运营商编码(VARCHAR(50),保留字段,建议填充与 carrier_type 相同) -- `channel_name`:渠道名称(VARCHAR(100),可自定义,如"北京渠道1")**【新增】** -- `channel_code`:渠道编码(VARCHAR(50),可自定义,如"BJ001")**【新增】** -- `status`:状态(INT,1-启用 2-禁用) +- `carrier_type`:运营商类型(VARCHAR(20),枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN") +- `description`:运营商描述(VARCHAR(500),可选) +- `status`:状态(INT,1-启用 0-禁用) - `creator`:创建人 ID(BIGINT) - `updater`:更新人 ID(BIGINT) - `created_at`:创建时间(TIMESTAMP,自动填充) - `updated_at`:更新时间(TIMESTAMP,自动填充) - `deleted_at`:删除时间(TIMESTAMP,可空,软删除) -**唯一约束**:`(carrier_type, channel_code)` 在 `deleted_at IS NULL` 条件下唯一 +**唯一约束**:`carrier_code` 在 `deleted_at IS NULL` 条件下唯一 -#### Scenario: 创建中国移动的渠道 +--- -- **WHEN** 平台创建中国移动的北京渠道,`carrier_type` 为 "CMCC",`carrier_name` 为 "中国移动",`channel_name` 为 "北京渠道1",`channel_code` 为 "BJ001" -- **THEN** 系统创建运营商记录,`carrier_type` 为 "CMCC",`channel_name` 为 "北京渠道1",`channel_code` 为 "BJ001" +### Requirement: 创建运营商 -#### Scenario: 同一运营商创建多个渠道 +系统 SHALL 允许管理员创建新的运营商记录。创建时必须指定 carrier_code(唯一编码)、carrier_name(显示名称)、carrier_type(运营商类型,枚举值)。description 为选填字段。创建成功后默认状态为启用(status=1)。 -- **WHEN** 平台为中国移动创建两个渠道:北京渠道(BJ001)和上海渠道(SH001) -- **THEN** 系统创建两条运营商记录,`carrier_type` 都为 "CMCC",但 `channel_code` 不同 +#### Scenario: 成功创建运营商 +- **WHEN** 管理员提交有效的创建请求,carrier_code 不重复,carrier_type 为有效枚举值 +- **THEN** 系统创建运营商记录,返回完整的运营商信息 -#### Scenario: 渠道编码重复 +#### Scenario: carrier_code 重复 +- **WHEN** 管理员提交的 carrier_code 已存在 +- **THEN** 系统返回错误"运营商编码已存在" -- **WHEN** 平台创建中国移动的渠道,`carrier_type` 为 "CMCC",`channel_code` 为已存在的 "BJ001" -- **THEN** 系统拒绝创建,返回错误信息"该运营商的渠道编码已存在" +#### Scenario: carrier_type 无效 +- **WHEN** 管理员提交的 carrier_type 不是 CMCC/CUCC/CTCC/CBN 之一 +- **THEN** 系统返回参数校验错误 -#### Scenario: 不同运营商可以使用相同渠道编码 +--- -- **WHEN** 平台为中国移动创建渠道(carrier_type=CMCC, channel_code=BJ001),然后为中国联通创建渠道(carrier_type=CUCC, channel_code=BJ001) -- **THEN** 系统允许创建,因为 `carrier_type` 不同 +### Requirement: 查询运营商列表 -#### Scenario: 运营商类型枚举限制 +系统 SHALL 提供分页查询运营商列表的接口,支持按 carrier_type、status、carrier_name(模糊搜索)筛选。 -- **WHEN** 平台创建运营商,`carrier_type` 为 "OTHER"(不在枚举中) -- **THEN** 系统拒绝创建,返回错误信息"运营商类型必须是 CMCC/CUCC/CTCC/CBN 之一" +#### Scenario: 无筛选条件查询 +- **WHEN** 管理员请求列表,不带筛选条件 +- **THEN** 系统返回所有运营商的分页列表,按 ID 降序排列 + +#### Scenario: 按运营商类型筛选 +- **WHEN** 管理员指定 carrier_type=CMCC +- **THEN** 系统仅返回 carrier_type 为 CMCC 的记录 + +#### Scenario: 按名称模糊搜索 +- **WHEN** 管理员指定 carrier_name=移动 +- **THEN** 系统返回 carrier_name 包含"移动"的记录 + +--- + +### Requirement: 获取运营商详情 + +系统 SHALL 允许管理员通过 ID 获取单个运营商的详细信息。 + +#### Scenario: 成功获取详情 +- **WHEN** 管理员请求存在的运营商 ID +- **THEN** 系统返回该运营商的完整信息 + +#### Scenario: 运营商不存在 +- **WHEN** 管理员请求不存在的运营商 ID +- **THEN** 系统返回错误"运营商不存在" + +--- + +### Requirement: 更新运营商 + +系统 SHALL 允许管理员更新运营商的 carrier_name 和 description 字段。carrier_code 和 carrier_type 创建后不可修改。 + +#### Scenario: 成功更新运营商 +- **WHEN** 管理员提交有效的更新请求 +- **THEN** 系统更新运营商信息,返回更新后的完整信息 + +#### Scenario: 尝试修改 carrier_code +- **WHEN** 管理员尝试修改 carrier_code +- **THEN** 系统忽略该字段(不报错,但不修改) + +--- + +### Requirement: 删除运营商 + +系统 SHALL 允许管理员软删除运营商记录。 + +#### Scenario: 成功删除运营商 +- **WHEN** 管理员请求删除存在的运营商 +- **THEN** 系统软删除该记录(设置 deleted_at) + +#### Scenario: 删除不存在的运营商 +- **WHEN** 管理员请求删除不存在的运营商 ID +- **THEN** 系统返回错误"运营商不存在" + +--- + +### Requirement: 更新运营商状态 + +系统 SHALL 允许管理员启用或禁用运营商。状态值:1=启用,0=禁用。 + +#### Scenario: 启用运营商 +- **WHEN** 管理员将状态设置为 1 +- **THEN** 系统更新运营商状态为启用 + +#### Scenario: 禁用运营商 +- **WHEN** 管理员将状态设置为 0 +- **THEN** 系统更新运营商状态为禁用 + +#### Scenario: 无效状态值 +- **WHEN** 管理员提交的状态值不是 0 或 1 +- **THEN** 系统返回参数校验错误 --- @@ -64,9 +136,8 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose - `carrier_type`:必填,枚举值 "CMCC" | "CUCC" | "CTCC" | "CBN" - `carrier_name`:必填,长度 1-100 字符 - `carrier_code`:必填,长度 1-50 字符 -- `channel_name`:可选,长度 1-100 字符 -- `channel_code`:可选,长度 1-50 字符 -- `status`:必填,枚举值 1-2 +- `description`:可选,长度 0-500 字符 +- `status`:必填,枚举值 0 或 1 #### Scenario: 创建运营商时 carrier_type 无效 diff --git a/openspec/specs/iot-card-import-task/spec.md b/openspec/specs/iot-card-import-task/spec.md index 082b895..d2386f1 100644 --- a/openspec/specs/iot-card-import-task/spec.md +++ b/openspec/specs/iot-card-import-task/spec.md @@ -237,3 +237,27 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - **WHEN** Worker 处理导入任务创建卡记录 - **THEN** 创建的 `IotCard` 记录 `iccid` 为 "898600...",`msisdn` 为 "13800000001" +--- + +### Requirement: 导入物联网卡时记录运营商信息 + +系统 SHALL 在导入物联网卡时,将运营商的 carrier_type 和 carrier_name 作为冗余字段存储到 IotCard 记录中。这些字段在导入时从 Carrier 表查询并写入,后续不再依赖 Carrier 表。 + +#### Scenario: 导入时填充冗余字段 +- **WHEN** 系统处理物联网卡导入任务 +- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_type 和 carrier_name 写入每条 IotCard 记录 + +#### Scenario: Carrier 不存在 +- **WHEN** 导入任务指定的 carrier_id 对应的 Carrier 不存在或已删除 +- **THEN** 系统拒绝导入,返回错误"运营商不存在" + +--- + +### Requirement: 导入任务记录运营商名称 + +系统 SHALL 在创建导入任务时,将 carrier_name 作为冗余字段存储到 IotCardImportTask 记录中(已有 carrier_type)。 + +#### Scenario: 创建导入任务时填充 carrier_name +- **WHEN** 管理员创建物联网卡导入任务 +- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_name 写入导入任务记录 + diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index 38d62ab..c78be2c 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -544,3 +544,37 @@ This capability supports: - **WHEN** 卡的授权被回收后(revoked_at 不为空),企业用户查询该卡 - **THEN** 系统不返回该卡信息,企业无法再看到该卡 +--- + +### Requirement: 查询物联网卡时返回运营商信息 + +系统 SHALL 在查询物联网卡列表/详情时,直接从 IotCard 记录的冗余字段返回 carrier_type 和 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 列表查询返回运营商信息 +- **WHEN** 管理员查询物联网卡列表 +- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段 + +#### Scenario: 详情查询返回运营商信息 +- **WHEN** 管理员查询单张物联网卡详情 +- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段 + +--- + +### Requirement: 查询导入任务时返回运营商名称 + +系统 SHALL 在查询导入任务列表/详情时,直接从 IotCardImportTask 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 导入任务列表返回运营商名称 +- **WHEN** 管理员查询导入任务列表 +- **THEN** 响应中的 carrier_name 直接来自 IotCardImportTask 记录的冗余字段 + +--- + +### Requirement: 设备绑定卡查询返回运营商信息 + +系统 SHALL 在查询设备绑定的物联网卡时,直接从 IotCard 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。 + +#### Scenario: 设备绑定卡列表返回运营商名称 +- **WHEN** 管理员查询设备绑定的物联网卡列表 +- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段 + diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 553deb0..562aa95 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -88,6 +88,14 @@ const ( StatusEnabled = 1 // 启用 ) +// 运营商类型常量 +const ( + CarrierTypeCMCC = "CMCC" // 中国移动 + CarrierTypeCUCC = "CUCC" // 中国联通 + CarrierTypeCTCC = "CTCC" // 中国电信 + CarrierTypeCBN = "CBN" // 中国广电 +) + // 订单状态常量 const ( OrderStatusPending = "pending" // 待支付 diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 586ad09..83572d2 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -82,6 +82,10 @@ const ( CodeStorageInvalidPurpose = 1094 // 不支持的文件用途 CodeStorageInvalidFileType = 1095 // 不支持的文件类型 + // 运营商相关错误 (1100-1109) + CodeCarrierNotFound = 1100 // 运营商不存在 + CodeCarrierCodeExists = 1101 // 运营商编码已存在 + // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 CodeDatabaseError = 2002 // 数据库错误 @@ -156,6 +160,8 @@ var allErrorCodes = []int{ CodeStorageFileNotFound, CodeStorageInvalidPurpose, CodeStorageInvalidFileType, + CodeCarrierNotFound, + CodeCarrierCodeExists, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -232,6 +238,8 @@ var errorMessages = map[int]string{ CodeStorageFileNotFound: "文件不存在", CodeStorageInvalidPurpose: "不支持的文件用途", CodeStorageInvalidFileType: "不支持的文件类型", + CodeCarrierNotFound: "运营商不存在", + CodeCarrierCodeExists: "运营商编码已存在", CodeInvalidCredentials: "用户名或密码错误", CodeAccountLocked: "账号已锁定", CodePasswordExpired: "密码已过期", diff --git a/tests/integration/carrier_test.go b/tests/integration/carrier_test.go new file mode 100644 index 0000000..b1250ee --- /dev/null +++ b/tests/integration/carrier_test.go @@ -0,0 +1,394 @@ +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" + "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 carrierTestEnv struct { + db *gorm.DB + rdb *redis.Client + tokenManager *auth.TokenManager + app *fiber.App + adminToken string + t *testing.T +} + +func setupCarrierTestEnv(t *testing.T) *carrierTestEnv { + 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 &carrierTestEnv{ + db: db, + rdb: rdb, + tokenManager: tokenManager, + app: app, + adminToken: adminToken, + t: t, + } +} + +func (e *carrierTestEnv) teardown() { + e.db.Exec("DELETE FROM tb_carrier WHERE carrier_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() +} + +func TestCarrier_CRUD(t *testing.T) { + env := setupCarrierTestEnv(t) + defer env.teardown() + + var createdCarrierID uint + + t.Run("创建运营商", func(t *testing.T) { + body := map[string]interface{}{ + "carrier_code": "TEST_CMCC_001", + "carrier_name": "测试中国移动", + "carrier_type": constants.CarrierTypeCMCC, + "description": "API集成测试创建的运营商", + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest("POST", "/api/admin/carriers", 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, "TEST_CMCC_001", dataMap["carrier_code"]) + assert.Equal(t, "测试中国移动", dataMap["carrier_name"]) + assert.Equal(t, constants.CarrierTypeCMCC, dataMap["carrier_type"]) + assert.Equal(t, float64(constants.StatusEnabled), dataMap["status"]) + + createdCarrierID = uint(dataMap["id"].(float64)) + t.Logf("创建的运营商 ID: %d", createdCarrierID) + }) + + t.Run("创建运营商-编码重复应失败", func(t *testing.T) { + body := map[string]interface{}{ + "carrier_code": "TEST_CMCC_001", + "carrier_name": "重复编码测试", + "carrier_type": constants.CarrierTypeCMCC, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest("POST", "/api/admin/carriers", 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() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "编码重复应返回错误") + }) + + t.Run("获取运营商详情", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/carriers/%d", createdCarrierID) + 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, "TEST_CMCC_001", dataMap["carrier_code"]) + }) + + t.Run("获取不存在的运营商", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/carriers/99999", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "不存在的运营商应返回错误") + }) + + t.Run("更新运营商", func(t *testing.T) { + body := map[string]interface{}{ + "carrier_name": "测试中国移动-更新", + "description": "更新后的描述", + } + jsonBody, _ := json.Marshal(body) + + url := fmt.Sprintf("/api/admin/carriers/%d", createdCarrierID) + 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["carrier_name"]) + assert.Equal(t, "更新后的描述", dataMap["description"]) + }) + + t.Run("更新运营商状态-禁用", func(t *testing.T) { + body := map[string]interface{}{ + "status": constants.StatusDisabled, + } + jsonBody, _ := json.Marshal(body) + + url := fmt.Sprintf("/api/admin/carriers/%d/status", createdCarrierID) + 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) + + var carrier model.Carrier + env.db.First(&carrier, createdCarrierID) + assert.Equal(t, constants.StatusDisabled, carrier.Status) + }) + + t.Run("删除运营商", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/carriers/%d", createdCarrierID) + 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 carrier model.Carrier + err = env.db.First(&carrier, createdCarrierID).Error + assert.Error(t, err, "删除后应查不到运营商") + }) +} + +func TestCarrier_List(t *testing.T) { + env := setupCarrierTestEnv(t) + defer env.teardown() + + carriers := []*model.Carrier{ + {CarrierCode: "TEST_LIST_001", CarrierName: "移动列表测试1", CarrierType: constants.CarrierTypeCMCC, Status: constants.StatusEnabled}, + {CarrierCode: "TEST_LIST_002", CarrierName: "联通列表测试", CarrierType: constants.CarrierTypeCUCC, Status: constants.StatusEnabled}, + {CarrierCode: "TEST_LIST_003", CarrierName: "电信列表测试", CarrierType: constants.CarrierTypeCTCC, Status: constants.StatusEnabled}, + } + for _, c := range carriers { + require.NoError(t, env.db.Create(c).Error) + } + carriers[2].Status = constants.StatusDisabled + require.NoError(t, env.db.Save(carriers[2]).Error) + + t.Run("获取运营商列表-无过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/carriers?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) + }) + + t.Run("获取运营商列表-按类型过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/carriers?carrier_type=CMCC", 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) + }) + + t.Run("获取运营商列表-按名称模糊搜索", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/carriers?carrier_name=联通", 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) + }) + + t.Run("获取运营商列表-按状态过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", fmt.Sprintf("/api/admin/carriers?status=%d", constants.StatusDisabled), 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) + }) + + t.Run("未认证请求应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/carriers", nil) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码") + }) +} diff --git a/tests/integration/iot_card_test.go b/tests/integration/iot_card_test.go index 9492542..c58ce5f 100644 --- a/tests/integration/iot_card_test.go +++ b/tests/integration/iot_card_test.go @@ -274,6 +274,7 @@ func TestIotCard_ImportTaskList(t *testing.T) { Status: model.ImportTaskStatusCompleted, CarrierID: 1, CarrierType: "CMCC", + CarrierName: "中国移动", TotalCount: 100, } require.NoError(t, env.db.Create(task).Error) @@ -294,7 +295,7 @@ func TestIotCard_ImportTaskList(t *testing.T) { assert.Equal(t, 0, result.Code) }) - t.Run("获取导入任务详情", func(t *testing.T) { + t.Run("获取导入任务详情-应包含冗余字段", func(t *testing.T) { url := fmt.Sprintf("/api/admin/iot-cards/import-tasks/%d", task.ID) req := httptest.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+env.adminToken) @@ -309,6 +310,10 @@ func TestIotCard_ImportTaskList(t *testing.T) { 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, "CMCC", dataMap["carrier_type"], "任务详情应返回冗余的运营商类型") + assert.Equal(t, "中国移动", dataMap["carrier_name"], "任务详情应返回冗余的运营商名称") }) } @@ -575,23 +580,97 @@ func startTestWorker(t *testing.T, db *gorm.DB, rdb *redis.Client, logger *zap.L return workerServer } +func TestIotCard_CarrierRedundantFields(t *testing.T) { + env := setupIotCardTestEnv(t) + defer env.teardown() + + carrierCode := fmt.Sprintf("REDUND_%d", time.Now().UnixNano()) + carrier := &model.Carrier{ + CarrierCode: carrierCode, + CarrierName: "冗余字段测试运营商", + CarrierType: "CUCC", + Status: 1, + } + require.NoError(t, env.db.Create(carrier).Error) + + testICCID := fmt.Sprintf("8986%016d", time.Now().UnixNano()%10000000000000000) + card := &model.IotCard{ + ICCID: testICCID, + CarrierID: carrier.ID, + CarrierType: carrier.CarrierType, + CarrierName: carrier.CarrierName, + CardType: "data_card", + Status: 1, + } + require.NoError(t, env.db.Create(card).Error) + + t.Run("单卡列表应返回冗余字段", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/iot-cards/standalone?iccid="+testICCID, nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "API应返回成功,实际: %v", result.Message) + + dataMap, ok := result.Data.(map[string]interface{}) + require.True(t, ok, "Data应为map类型,实际: %T", result.Data) + items, ok := dataMap["items"].([]interface{}) + require.True(t, ok, "items字段应存在且为数组,dataMap: %+v", dataMap) + require.GreaterOrEqual(t, len(items), 1, "列表应至少有1条记录,ICCID: %s", testICCID) + + cardData := items[0].(map[string]interface{}) + assert.Equal(t, "CUCC", cardData["carrier_type"], "列表应返回冗余的运营商类型") + assert.Equal(t, "冗余字段测试运营商", cardData["carrier_name"], "列表应返回冗余的运营商名称") + }) + + t.Run("单卡详情应返回冗余字段", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/iot-cards/by-iccid/%s", card.ICCID) + 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, "CUCC", dataMap["carrier_type"], "详情应返回冗余的运营商类型") + assert.Equal(t, "冗余字段测试运营商", dataMap["carrier_name"], "详情应返回冗余的运营商名称") + }) +} + func TestIotCard_GetByICCID(t *testing.T) { env := setupIotCardTestEnv(t) defer env.teardown() - // 创建测试运营商 + carrierCode := fmt.Sprintf("ICCID_%d", time.Now().UnixNano()) carrier := &model.Carrier{ - CarrierCode: "TEST001", + CarrierCode: carrierCode, CarrierName: "测试运营商", CarrierType: "CMCC", Status: 1, } require.NoError(t, env.db.Create(carrier).Error) - // 创建测试 IoT 卡 + testICCID := fmt.Sprintf("8986%016d", time.Now().UnixNano()%10000000000000000) card := &model.IotCard{ - ICCID: "TEST_ICCID_001", + ICCID: testICCID, CarrierID: carrier.ID, + CarrierType: carrier.CarrierType, + CarrierName: carrier.CarrierName, MSISDN: "13800000001", CardType: "physical", CardCategory: "normal", @@ -617,11 +696,12 @@ func TestIotCard_GetByICCID(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, result.Code) - // 验证返回数据 dataMap, ok := result.Data.(map[string]interface{}) require.True(t, ok) - assert.Equal(t, "TEST_ICCID_001", dataMap["iccid"]) + assert.Equal(t, testICCID, dataMap["iccid"]) assert.Equal(t, "13800000001", dataMap["msisdn"]) + assert.Equal(t, "CMCC", dataMap["carrier_type"], "应返回冗余的运营商类型") + assert.Equal(t, "测试运营商", dataMap["carrier_name"], "应返回冗余的运营商名称") }) t.Run("通过不存在的ICCID查询-应返回错误", func(t *testing.T) {