feat: 实现运营商模块重构,添加冗余字段优化查询性能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m16s

主要变更:
- 新增 Carrier CRUD API(创建、列表、详情、更新、删除、状态更新)
- IotCard/IotCardImportTask 添加 carrier_type/carrier_name 冗余字段
- 移除 Carrier 表的 channel_name/channel_code 字段
- 查询时直接使用冗余字段,避免 JOIN Carrier 表
- 添加数据库迁移脚本(000021-000023)
- 添加单元测试和集成测试
- 同步更新 OpenAPI 文档和 specs
This commit is contained in:
2026-01-27 12:18:19 +08:00
parent 5a179ba16b
commit d104d297ca
42 changed files with 2431 additions and 122 deletions

View File

@@ -45,6 +45,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
DeviceImport: admin.NewDeviceImportHandler(nil), DeviceImport: admin.NewDeviceImportHandler(nil),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
Storage: admin.NewStorageHandler(nil), Storage: admin.NewStorageHandler(nil),
Carrier: admin.NewCarrierHandler(nil),
} }
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器

View File

@@ -54,6 +54,7 @@ func generateAdminDocs(outputPath string) error {
DeviceImport: admin.NewDeviceImportHandler(nil), DeviceImport: admin.NewDeviceImportHandler(nil),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
Storage: admin.NewStorageHandler(nil), Storage: admin.NewStorageHandler(nil),
Carrier: admin.NewCarrierHandler(nil),
} }
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器

View File

@@ -550,6 +550,55 @@ components:
description: 提示信息 description: 提示信息
type: string type: string
type: object 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: DtoChangePasswordRequest:
properties: properties:
new_password: new_password:
@@ -595,6 +644,30 @@ components:
- password - password
- user_type - user_type
type: object 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: DtoCreateCustomerAccountReq:
properties: properties:
password: password:
@@ -1473,6 +1546,9 @@ components:
carrier_name: carrier_name:
description: 运营商名称 description: 运营商名称
type: string type: string
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
completed_at: completed_at:
description: 完成时间 description: 完成时间
format: date-time format: date-time
@@ -1543,6 +1619,9 @@ components:
carrier_name: carrier_name:
description: 运营商名称 description: 运营商名称
type: string type: string
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
completed_at: completed_at:
description: 完成时间 description: 完成时间
format: date-time format: date-time
@@ -1615,6 +1694,9 @@ components:
carrier_name: carrier_name:
description: 运营商名称 description: 运营商名称
type: string type: string
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
cost_price: cost_price:
description: 成本价(分) description: 成本价(分)
type: integer type: integer
@@ -2563,6 +2645,9 @@ components:
carrier_name: carrier_name:
description: 运营商名称 description: 运营商名称
type: string type: string
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
cost_price: cost_price:
description: 成本价(分) description: 成本价(分)
type: integer type: integer
@@ -2653,6 +2738,28 @@ components:
description: 备注最多500字 description: 备注最多500字
type: string type: string
type: object 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: DtoUpdateCustomerAccountPasswordReq:
properties: properties:
password: password:
@@ -3975,6 +4082,308 @@ paths:
summary: 修改授权备注 summary: 修改授权备注
tags: 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: /api/admin/commission/withdrawal-requests:
get: get:
parameters: parameters:

View File

@@ -33,5 +33,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService), Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
} }
} }

View File

@@ -4,6 +4,7 @@ import (
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" 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" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account" customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account"
@@ -43,6 +44,7 @@ type services struct {
Device *deviceSvc.Service Device *deviceSvc.Service
DeviceImport *deviceImportSvc.Service DeviceImport *deviceImportSvc.Service
AssetAllocationRecord *assetAllocationRecordSvc.Service AssetAllocationRecord *assetAllocationRecordSvc.Service
Carrier *carrierSvc.Service
} }
func initServices(s *stores, deps *Dependencies) *services { 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), Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient), DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
} }
} }

View File

@@ -26,6 +26,7 @@ type stores struct {
DeviceSimBinding *postgres.DeviceSimBindingStore DeviceSimBinding *postgres.DeviceSimBindingStore
DeviceImportTask *postgres.DeviceImportTaskStore DeviceImportTask *postgres.DeviceImportTaskStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore AssetAllocationRecord *postgres.AssetAllocationRecordStore
Carrier *postgres.CarrierStore
} }
func initStores(deps *Dependencies) *stores { func initStores(deps *Dependencies) *stores {
@@ -51,5 +52,6 @@ func initStores(deps *Dependencies) *stores {
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
Carrier: postgres.NewCarrierStore(deps.DB),
} }
} }

View File

@@ -31,6 +31,7 @@ type Handlers struct {
DeviceImport *admin.DeviceImportHandler DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler AssetAllocationRecord *admin.AssetAllocationRecordHandler
Storage *admin.StorageHandler Storage *admin.StorageHandler
Carrier *admin.CarrierHandler
} }
// Middlewares 封装所有中间件 // Middlewares 封装所有中间件

View File

@@ -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)
}

View File

@@ -7,13 +7,11 @@ import (
type Carrier struct { type Carrier struct {
gorm.Model gorm.Model
BaseModel `gorm:"embedded"` 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"` 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"` 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"` CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
ChannelName *string `gorm:"column:channel_name;type:varchar(100);comment:渠道名称" json:"channel_name,omitempty"` Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
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"` Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 2-禁用" json:"status"`
} }
// TableName 指定表名 // TableName 指定表名

View File

@@ -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:"总页数"`
}

View File

@@ -24,6 +24,7 @@ type StandaloneIotCardResponse struct {
CardType string `json:"card_type" description:"卡类型"` CardType string `json:"card_type" description:"卡类型"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID uint `json:"carrier_id" description:"运营商ID"` 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:"运营商名称"` CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
IMSI string `json:"imsi,omitempty" description:"IMSI"` IMSI string `json:"imsi,omitempty" description:"IMSI"`
MSISDN string `json:"msisdn,omitempty" description:"卡接入号"` MSISDN string `json:"msisdn,omitempty" description:"卡接入号"`
@@ -79,6 +80,7 @@ type ImportTaskResponse struct {
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
StatusText string `json:"status_text" description:"任务状态文本"` StatusText string `json:"status_text" description:"任务状态文本"`
CarrierID uint `json:"carrier_id" description:"运营商ID"` 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:"运营商名称"` CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"` BatchNo string `json:"batch_no,omitempty" description:"批次号"`
FileName string `json:"file_name,omitempty" description:"文件名"` FileName string `json:"file_name,omitempty" description:"文件名"`

View File

@@ -16,6 +16,8 @@ type IotCard struct {
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"` CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"` CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"` 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"` IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"`
MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"` MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`

View File

@@ -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"` 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"` 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"` 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"` 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"` 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"` TotalCount int `gorm:"column:total_count;type:int;default:0;not null;comment:总数" json:"total_count"`

View File

@@ -67,6 +67,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Storage != nil { if handlers.Storage != nil {
registerStorageRoutes(authGroup, handlers.Storage, doc, basePath) 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) { func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {

View File

@@ -0,0 +1,62 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func 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,
})
}

View File

@@ -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),
}
}

View File

@@ -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)
})
}

View File

@@ -41,8 +41,6 @@ func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDev
cardMap[card.ID] = card cardMap[card.ID] = card
} }
carrierMap := s.loadCarrierData(ctx, cards)
responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings)) responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings))
for _, binding := range bindings { for _, binding := range bindings {
card := cardMap[binding.IotCardID] card := cardMap[binding.IotCardID]
@@ -56,7 +54,7 @@ func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDev
IotCardID: binding.IotCardID, IotCardID: binding.IotCardID,
ICCID: card.ICCID, ICCID: card.ICCID,
MSISDN: card.MSISDN, MSISDN: card.MSISDN,
CarrierName: carrierMap[card.CarrierID], CarrierName: card.CarrierName, // 直接使用 IotCard 的冗余字段
Status: card.Status, Status: card.Status,
BindTime: binding.BindTime, BindTime: binding.BindTime,
} }
@@ -147,26 +145,3 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
Message: "解绑成功", Message: "解绑成功",
}, nil }, 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
}

View File

@@ -88,11 +88,11 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
return nil, err return nil, err
} }
carrierMap, shopMap := s.loadRelatedData(ctx, cards) shopMap := s.loadShopNames(ctx, cards)
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards { for _, card := range cards {
item := s.toStandaloneResponse(card, carrierMap, shopMap) item := s.toStandaloneResponse(card, shopMap)
list = append(list, item) list = append(list, item)
} }
@@ -120,40 +120,25 @@ func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDet
return nil, err return nil, err
} }
carrierMap, shopMap := s.loadRelatedData(ctx, []*model.IotCard{card}) shopMap := s.loadShopNames(ctx, []*model.IotCard{card})
standaloneResp := s.toStandaloneResponse(card, carrierMap, shopMap) standaloneResp := s.toStandaloneResponse(card, shopMap)
return &dto.IotCardDetailResponse{ return &dto.IotCardDetailResponse{
StandaloneIotCardResponse: *standaloneResp, StandaloneIotCardResponse: *standaloneResp,
}, nil }, nil
} }
func (s *Service) loadRelatedData(ctx context.Context, cards []*model.IotCard) (map[uint]string, map[uint]string) { func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map[uint]string {
carrierIDs := make([]uint, 0)
shopIDs := make([]uint, 0) shopIDs := make([]uint, 0)
carrierIDSet := make(map[uint]bool)
shopIDSet := make(map[uint]bool) shopIDSet := make(map[uint]bool)
for _, card := range cards { 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] { if card.ShopID != nil && *card.ShopID > 0 && !shopIDSet[*card.ShopID] {
shopIDs = append(shopIDs, *card.ShopID) shopIDs = append(shopIDs, *card.ShopID)
shopIDSet[*card.ShopID] = true 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) shopMap := make(map[uint]string)
if len(shopIDs) > 0 { if len(shopIDs) > 0 {
var shops []model.Shop 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{ resp := &dto.StandaloneIotCardResponse{
ID: card.ID, ID: card.ID,
ICCID: card.ICCID, ICCID: card.ICCID,
CardType: card.CardType, CardType: card.CardType,
CardCategory: card.CardCategory, CardCategory: card.CardCategory,
CarrierID: card.CarrierID, CarrierID: card.CarrierID,
CarrierName: carrierMap[card.CarrierID], CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
IMSI: card.IMSI, IMSI: card.IMSI,
MSISDN: card.MSISDN, MSISDN: card.MSISDN,
BatchNo: card.BatchNo, BatchNo: card.BatchNo,

View File

@@ -76,6 +76,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
Status: model.ImportTaskStatusPending, Status: model.ImportTaskStatusPending,
CarrierID: req.CarrierID, CarrierID: req.CarrierID,
CarrierType: carrier.CarrierType, CarrierType: carrier.CarrierType,
CarrierName: carrier.CarrierName,
BatchNo: req.BatchNo, BatchNo: req.BatchNo,
FileName: fileName, FileName: fileName,
StorageKey: req.FileKey, StorageKey: req.FileKey,
@@ -138,11 +139,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dt
return nil, err return nil, err
} }
carrierMap := s.loadCarriers(ctx, tasks)
list := make([]*dto.ImportTaskResponse, 0, len(tasks)) list := make([]*dto.ImportTaskResponse, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
list = append(list, s.toTaskResponse(task, carrierMap)) list = append(list, s.toTaskResponse(task))
} }
totalPages := int(total) / pageSize 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, "导入任务不存在") 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{ resp := &dto.ImportTaskDetailResponse{
ImportTaskResponse: *s.toTaskResponse(task, carrierMap), ImportTaskResponse: *s.toTaskResponse(task),
SkippedItems: make([]*dto.ImportResultItemDTO, 0), SkippedItems: make([]*dto.ImportResultItemDTO, 0),
FailedItems: 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 return resp, nil
} }
func (s *Service) loadCarriers(ctx context.Context, tasks []*model.IotCardImportTask) map[uint]string { func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskResponse {
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 {
var startedAt, completedAt *time.Time var startedAt, completedAt *time.Time
if task.StartedAt != nil { if task.StartedAt != nil {
startedAt = task.StartedAt startedAt = task.StartedAt
@@ -234,7 +206,8 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask, carrierMap map[u
Status: task.Status, Status: task.Status,
StatusText: getStatusText(task.Status), StatusText: getStatusText(task.Status),
CarrierID: task.CarrierID, CarrierID: task.CarrierID,
CarrierName: carrierMap[task.CarrierID], CarrierType: task.CarrierType,
CarrierName: task.CarrierName,
BatchNo: task.BatchNo, BatchNo: task.BatchNo,
FileName: task.FileName, FileName: task.FileName,
TotalCount: task.TotalCount, TotalCount: task.TotalCount,

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE tb_iot_card_import_task DROP COLUMN IF EXISTS carrier_name;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -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 管理 APICRUD + 状态管理)
- 简化 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
- **风险**: 如果有外部系统依赖这些字段
- **缓解**: 已确认这些字段未被使用,可安全移除

View File

@@ -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` 路由组

View File

@@ -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** 系统返回参数校验错误

View File

@@ -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 写入导入任务记录

View File

@@ -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 记录的冗余字段

View File

@@ -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 文档

View File

@@ -1,11 +1,13 @@
# carrier Specification # carrier Specification
## Purpose ## Purpose
TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose after archive. 管理运营商(Carrier)实体,支持四大固定运营商(中国移动、中国联通、中国电信、广电)的 CRUD 操作。
## Requirements ## Requirements
### Requirement: 运营商实体定义 ### Requirement: 运营商实体定义
系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)的渠道信息 系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)
**四大运营商固定枚举** **四大运营商固定枚举**
- **CMCC**:中国移动 - **CMCC**:中国移动
@@ -15,44 +17,114 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
**实体字段** **实体字段**
- `id`:运营商 ID主键BIGINT - `id`:运营商 ID主键BIGINT
- `carrier_type`:运营商类型VARCHAR(20)枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN"**【新增】** - `carrier_code`:运营商编码VARCHAR(50)唯一约束)
- `carrier_name`运营商名称VARCHAR(100),如"中国移动" - `carrier_name`运营商名称VARCHAR(100),如"中国移动"
- `carrier_code`:运营商编码VARCHAR(50)保留字段,建议填充与 carrier_type 相同 - `carrier_type`:运营商类型VARCHAR(20)枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN"
- `channel_name`:渠道名称VARCHAR(100),可自定义,如"北京渠道1"**【新增】** - `description`:运营商描述VARCHAR(500),可选)
- `channel_code`渠道编码VARCHAR(50),可自定义,如"BJ001"**【新增】** - `status`状态INT1-启用 0-禁用)
- `status`状态INT1-启用 2-禁用)
- `creator`:创建人 IDBIGINT - `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT - `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充 - `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充 - `updated_at`更新时间TIMESTAMP自动填充
- `deleted_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" ### Requirement: 创建运营商
- **THEN** 系统创建运营商记录,`carrier_type` 为 "CMCC"`channel_name` 为 "北京渠道1"`channel_code` 为 "BJ001"
#### Scenario: 同一运营商创建多个渠道 系统 SHALL 允许管理员创建新的运营商记录。创建时必须指定 carrier_code唯一编码、carrier_name显示名称、carrier_type运营商类型枚举值。description 为选填字段。创建成功后默认状态为启用status=1
- **WHEN** 平台为中国移动创建两个渠道北京渠道BJ001和上海渠道SH001 #### Scenario: 成功创建运营商
- **THEN** 系统创建两条运营商记录,`carrier_type` 都为 "CMCC",但 `channel_code` 不同 - **WHEN** 管理员提交有效的创建请求carrier_code 不重复carrier_type 为有效枚举值
- **THEN** 系统创建运营商记录,返回完整的运营商信息
#### Scenario: 渠道编码重复 #### Scenario: carrier_code 重复
- **WHEN** 管理员提交的 carrier_code 已存在
- **THEN** 系统返回错误"运营商编码已存在"
- **WHEN** 平台创建中国移动的渠道,`carrier_type` 为 "CMCC"`channel_code` 为已存在的 "BJ001" #### Scenario: carrier_type 无效
- **THEN** 系统拒绝创建,返回错误信息"该运营商的渠道编码已存在" - **WHEN** 管理员提交的 carrier_type 不是 CMCC/CUCC/CTCC/CBN 之一
- **THEN** 系统返回参数校验错误
#### Scenario: 不同运营商可以使用相同渠道编码 ---
- **WHEN** 平台为中国移动创建渠道carrier_type=CMCC, channel_code=BJ001然后为中国联通创建渠道carrier_type=CUCC, channel_code=BJ001 ### Requirement: 查询运营商列表
- **THEN** 系统允许创建,因为 `carrier_type` 不同
#### Scenario: 运营商类型枚举限制 系统 SHALL 提供分页查询运营商列表的接口,支持按 carrier_type、status、carrier_name模糊搜索筛选。
- **WHEN** 平台创建运营商,`carrier_type` 为 "OTHER"(不在枚举中) #### Scenario: 无筛选条件查询
- **THEN** 系统拒绝创建,返回错误信息"运营商类型必须是 CMCC/CUCC/CTCC/CBN 之一" - **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_type`:必填,枚举值 "CMCC" | "CUCC" | "CTCC" | "CBN"
- `carrier_name`:必填,长度 1-100 字符 - `carrier_name`:必填,长度 1-100 字符
- `carrier_code`:必填,长度 1-50 字符 - `carrier_code`:必填,长度 1-50 字符
- `channel_name`:可选,长度 1-100 字符 - `description`:可选,长度 0-500 字符
- `channel_code`:可选,长度 1-50 字符 - `status`:必填,枚举值 0 或 1
- `status`:必填,枚举值 1-2
#### Scenario: 创建运营商时 carrier_type 无效 #### Scenario: 创建运营商时 carrier_type 无效

View File

@@ -237,3 +237,27 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
- **WHEN** Worker 处理导入任务创建卡记录 - **WHEN** Worker 处理导入任务创建卡记录
- **THEN** 创建的 `IotCard` 记录 `iccid` 为 "898600...",`msisdn` 为 "13800000001" - **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 写入导入任务记录

View File

@@ -544,3 +544,37 @@ This capability supports:
- **WHEN** 卡的授权被回收后revoked_at 不为空),企业用户查询该卡 - **WHEN** 卡的授权被回收后revoked_at 不为空),企业用户查询该卡
- **THEN** 系统不返回该卡信息,企业无法再看到该卡 - **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 记录的冗余字段

View File

@@ -88,6 +88,14 @@ const (
StatusEnabled = 1 // 启用 StatusEnabled = 1 // 启用
) )
// 运营商类型常量
const (
CarrierTypeCMCC = "CMCC" // 中国移动
CarrierTypeCUCC = "CUCC" // 中国联通
CarrierTypeCTCC = "CTCC" // 中国电信
CarrierTypeCBN = "CBN" // 中国广电
)
// 订单状态常量 // 订单状态常量
const ( const (
OrderStatusPending = "pending" // 待支付 OrderStatusPending = "pending" // 待支付

View File

@@ -82,6 +82,10 @@ const (
CodeStorageInvalidPurpose = 1094 // 不支持的文件用途 CodeStorageInvalidPurpose = 1094 // 不支持的文件用途
CodeStorageInvalidFileType = 1095 // 不支持的文件类型 CodeStorageInvalidFileType = 1095 // 不支持的文件类型
// 运营商相关错误 (1100-1109)
CodeCarrierNotFound = 1100 // 运营商不存在
CodeCarrierCodeExists = 1101 // 运营商编码已存在
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误 CodeInternalError = 2001 // 内部服务器错误
CodeDatabaseError = 2002 // 数据库错误 CodeDatabaseError = 2002 // 数据库错误
@@ -156,6 +160,8 @@ var allErrorCodes = []int{
CodeStorageFileNotFound, CodeStorageFileNotFound,
CodeStorageInvalidPurpose, CodeStorageInvalidPurpose,
CodeStorageInvalidFileType, CodeStorageInvalidFileType,
CodeCarrierNotFound,
CodeCarrierCodeExists,
CodeInternalError, CodeInternalError,
CodeDatabaseError, CodeDatabaseError,
CodeRedisError, CodeRedisError,
@@ -232,6 +238,8 @@ var errorMessages = map[int]string{
CodeStorageFileNotFound: "文件不存在", CodeStorageFileNotFound: "文件不存在",
CodeStorageInvalidPurpose: "不支持的文件用途", CodeStorageInvalidPurpose: "不支持的文件用途",
CodeStorageInvalidFileType: "不支持的文件类型", CodeStorageInvalidFileType: "不支持的文件类型",
CodeCarrierNotFound: "运营商不存在",
CodeCarrierCodeExists: "运营商编码已存在",
CodeInvalidCredentials: "用户名或密码错误", CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定", CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期", CodePasswordExpired: "密码已过期",

View File

@@ -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, "未认证请求应返回错误码")
})
}

View File

@@ -274,6 +274,7 @@ func TestIotCard_ImportTaskList(t *testing.T) {
Status: model.ImportTaskStatusCompleted, Status: model.ImportTaskStatusCompleted,
CarrierID: 1, CarrierID: 1,
CarrierType: "CMCC", CarrierType: "CMCC",
CarrierName: "中国移动",
TotalCount: 100, TotalCount: 100,
} }
require.NoError(t, env.db.Create(task).Error) require.NoError(t, env.db.Create(task).Error)
@@ -294,7 +295,7 @@ func TestIotCard_ImportTaskList(t *testing.T) {
assert.Equal(t, 0, result.Code) 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) url := fmt.Sprintf("/api/admin/iot-cards/import-tasks/%d", task.ID)
req := httptest.NewRequest("GET", url, nil) req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken) req.Header.Set("Authorization", "Bearer "+env.adminToken)
@@ -309,6 +310,10 @@ func TestIotCard_ImportTaskList(t *testing.T) {
err = json.NewDecoder(resp.Body).Decode(&result) err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 0, result.Code) 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 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) { func TestIotCard_GetByICCID(t *testing.T) {
env := setupIotCardTestEnv(t) env := setupIotCardTestEnv(t)
defer env.teardown() defer env.teardown()
// 创建测试运营商 carrierCode := fmt.Sprintf("ICCID_%d", time.Now().UnixNano())
carrier := &model.Carrier{ carrier := &model.Carrier{
CarrierCode: "TEST001", CarrierCode: carrierCode,
CarrierName: "测试运营商", CarrierName: "测试运营商",
CarrierType: "CMCC", CarrierType: "CMCC",
Status: 1, Status: 1,
} }
require.NoError(t, env.db.Create(carrier).Error) require.NoError(t, env.db.Create(carrier).Error)
// 创建测试 IoT 卡 testICCID := fmt.Sprintf("8986%016d", time.Now().UnixNano()%10000000000000000)
card := &model.IotCard{ card := &model.IotCard{
ICCID: "TEST_ICCID_001", ICCID: testICCID,
CarrierID: carrier.ID, CarrierID: carrier.ID,
CarrierType: carrier.CarrierType,
CarrierName: carrier.CarrierName,
MSISDN: "13800000001", MSISDN: "13800000001",
CardType: "physical", CardType: "physical",
CardCategory: "normal", CardCategory: "normal",
@@ -617,11 +696,12 @@ func TestIotCard_GetByICCID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 0, result.Code) assert.Equal(t, 0, result.Code)
// 验证返回数据
dataMap, ok := result.Data.(map[string]interface{}) dataMap, ok := result.Data.(map[string]interface{})
require.True(t, ok) 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, "13800000001", dataMap["msisdn"])
assert.Equal(t, "CMCC", dataMap["carrier_type"], "应返回冗余的运营商类型")
assert.Equal(t, "测试运营商", dataMap["carrier_name"], "应返回冗余的运营商名称")
}) })
t.Run("通过不存在的ICCID查询-应返回错误", func(t *testing.T) { t.Run("通过不存在的ICCID查询-应返回错误", func(t *testing.T) {