feat: 实现设备管理和设备导入功能,修复测试问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s

主要变更:
- 实现设备管理模块(创建、查询、列表、更新状态、删除)
- 实现设备批量导入功能(CSV 解析、ICCID 绑定、异步任务处理)
- 添加设备-SIM 卡绑定约束(部分唯一索引防止并发问题)
- 修复 fee_rate 数据库字段类型(numeric -> bigint)
- 修复测试数据隔离问题(基于增量断言)
- 修复集成测试中间件顺序问题
- 清理无用测试文件(PersonalCustomer、Email 相关)
- 归档 enterprise-card-authorization 变更
This commit is contained in:
2026-01-26 18:05:12 +08:00
parent fdcff33058
commit ce0783f96e
68 changed files with 6400 additions and 1482 deletions

View File

@@ -41,6 +41,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
MyCommission: admin.NewMyCommissionHandler(nil),
IotCard: admin.NewIotCardHandler(nil),
IotCardImport: admin.NewIotCardImportHandler(nil),
Device: admin.NewDeviceHandler(nil),
DeviceImport: admin.NewDeviceImportHandler(nil),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
Storage: admin.NewStorageHandler(nil),
}

View File

@@ -50,6 +50,8 @@ func generateAdminDocs(outputPath string) error {
MyCommission: admin.NewMyCommissionHandler(nil),
IotCard: admin.NewIotCardHandler(nil),
IotCardImport: admin.NewIotCardImportHandler(nil),
Device: admin.NewDeviceHandler(nil),
DeviceImport: admin.NewDeviceImportHandler(nil),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),
Storage: admin.NewStorageHandler(nil),
}

File diff suppressed because it is too large Load Diff

4
go.mod
View File

@@ -5,7 +5,6 @@ go 1.25
require (
github.com/aws/aws-sdk-go v1.55.5
github.com/bytedance/sonic v1.14.2
github.com/fsnotify/fsnotify v1.9.0
github.com/go-playground/validator/v10 v10.28.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/gofiber/storage/redis/v3 v3.4.1
@@ -13,6 +12,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1
github.com/jackc/pgx/v5 v5.7.6
github.com/redis/go-redis/v9 v9.16.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
@@ -55,6 +55,7 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -68,7 +69,6 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect

View File

@@ -29,6 +29,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
}

View File

@@ -7,6 +7,8 @@ import (
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
@@ -38,6 +40,8 @@ type services struct {
MyCommission *myCommissionSvc.Service
IotCard *iotCardSvc.Service
IotCardImport *iotCardImportSvc.Service
Device *deviceSvc.Service
DeviceImport *deviceImportSvc.Service
AssetAllocationRecord *assetAllocationRecordSvc.Service
}
@@ -60,6 +64,8 @@ func initServices(s *stores, deps *Dependencies) *services {
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord),
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
}
}

View File

@@ -22,6 +22,9 @@ type stores struct {
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
IotCard *postgres.IotCardStore
IotCardImportTask *postgres.IotCardImportTaskStore
Device *postgres.DeviceStore
DeviceSimBinding *postgres.DeviceSimBindingStore
DeviceImportTask *postgres.DeviceImportTaskStore
AssetAllocationRecord *postgres.AssetAllocationRecordStore
}
@@ -44,6 +47,9 @@ func initStores(deps *Dependencies) *stores {
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
}
}

View File

@@ -27,6 +27,8 @@ type Handlers struct {
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
Storage *admin.StorageHandler
}

View File

@@ -0,0 +1,183 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
"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/pkg/response"
)
type DeviceHandler struct {
service *deviceService.Service
}
func NewDeviceHandler(service *deviceService.Service) *DeviceHandler {
return &DeviceHandler{
service: service,
}
}
func (h *DeviceHandler) List(c *fiber.Ctx) error {
var req dto.ListDeviceRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
result, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可删除设备")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 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 *DeviceHandler) ListCards(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
result, err := h.service.ListBindings(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) BindCard(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可绑定卡到设备")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
var req dto.BindCardToDeviceRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.ID = uint(id)
result, err := h.service.BindCard(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) UnbindCard(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可解绑设备的卡")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
cardIdStr := c.Params("cardId")
cardId, err := strconv.ParseUint(cardIdStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
result, err := h.service.UnbindCard(c.UserContext(), uint(id), uint(cardId))
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Allocate(c *fiber.Ctx) error {
var req dto.AllocateDevicesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
userID := middleware.GetUserIDFromContext(c.UserContext())
shopID := middleware.GetShopIDFromContext(c.UserContext())
var shopIDPtr *uint
if shopID > 0 {
shopIDPtr = &shopID
}
result, err := h.service.AllocateDevices(c.UserContext(), &req, userID, shopIDPtr)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Recall(c *fiber.Ctx) error {
var req dto.RecallDevicesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
userID := middleware.GetUserIDFromContext(c.UserContext())
shopID := middleware.GetShopIDFromContext(c.UserContext())
var shopIDPtr *uint
if shopID > 0 {
shopIDPtr = &shopID
}
result, err := h.service.RecallDevices(c.UserContext(), &req, userID, shopIDPtr)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,86 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
deviceImportService "github.com/break/junhong_cmp_fiber/internal/service/device_import"
"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/pkg/response"
)
type DeviceImportHandler struct {
service *deviceImportService.Service
}
func NewDeviceImportHandler(service *deviceImportService.Service) *DeviceImportHandler {
return &DeviceImportHandler{
service: service,
}
}
func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可导入设备")
}
var req dto.ImportDeviceRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if req.FileKey == "" {
return errors.New(errors.CodeInvalidParam, "文件路径不能为空")
}
result, err := h.service.CreateImportTask(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可查看导入任务")
}
var req dto.ListDeviceImportTaskRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *DeviceImportHandler) GetByID(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可查看导入任务详情")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的任务ID")
}
result, err := h.service.GetByID(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,43 @@
package model
import (
"time"
"gorm.io/gorm"
)
// DeviceImportTask 设备导入任务模型
// 记录设备批量导入的任务状态和处理结果
// 通过异步任务处理 CSV 文件导入设备并绑定卡
type DeviceImportTask struct {
gorm.Model
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"`
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"`
TotalCount int `gorm:"column:total_count;type:int;default:0;comment:总记录数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;type:int;default:0;comment:成功数" json:"success_count"`
SkipCount int `gorm:"column:skip_count;type:int;default:0;comment:跳过数" json:"skip_count"`
FailCount int `gorm:"column:fail_count;type:int;default:0;comment:失败数" json:"fail_count"`
SkippedItems ImportResultItems `gorm:"column:skipped_items;type:jsonb;comment:跳过记录详情" json:"skipped_items"`
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
WarningCount int `gorm:"column:warning_count;default:0;comment:警告数量(部分成功的设备)" json:"warning_count"`
WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"`
ErrorMessage string `gorm:"column:error_message;type:text;comment:错误信息" json:"error_message"`
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
}
// TableName 指定表名
func (DeviceImportTask) TableName() string {
return "tb_device_import_task"
}
// DeviceImportResultItem 设备导入结果项
type DeviceImportResultItem struct {
Line int `json:"line"`
DeviceNo string `json:"device_no"`
Reason string `json:"reason"`
}

View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"gorm.io/gorm"
)
// DeviceSimBinding 设备-IoT卡绑定关系模型
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
type DeviceSimBinding struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
}
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding"
}

View File

@@ -0,0 +1,120 @@
package dto
import "time"
type ListDeviceRequest 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:"每页数量"`
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=100" maxLength:"100" description:"设备号(模糊查询)"`
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID (NULL表示平台库存)"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"`
Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
}
type DeviceResponse struct {
ID uint `json:"id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
DeviceName string `json:"device_name" description:"设备名称"`
DeviceModel string `json:"device_model" description:"设备型号"`
DeviceType string `json:"device_type" description:"设备类型"`
MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"`
Manufacturer string `json:"manufacturer" description:"制造商"`
BatchNo string `json:"batch_no" description:"批次号"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
StatusName string `json:"status_name" description:"状态名称"`
BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
type ListDeviceResponse struct {
List []*DeviceResponse `json:"list" description:"设备列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
type GetDeviceRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
}
type DeleteDeviceRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
}
type ListDeviceCardsRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
}
type DeviceCardBindingResponse struct {
ID uint `json:"id" description:"绑定记录ID"`
SlotPosition int `json:"slot_position" description:"插槽位置 (1-4)"`
IotCardID uint `json:"iot_card_id" description:"IoT卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
MSISDN string `json:"msisdn,omitempty" description:"接入号"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
}
type ListDeviceCardsResponse struct {
Bindings []*DeviceCardBindingResponse `json:"bindings" description:"绑定列表"`
}
type BindCardToDeviceRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"`
SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"`
}
type BindCardToDeviceResponse struct {
BindingID uint `json:"binding_id" description:"绑定记录ID"`
Message string `json:"message" description:"提示信息"`
}
type UnbindCardFromDeviceRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
CardID uint `path:"cardId" description:"IoT卡ID" required:"true"`
}
type UnbindCardFromDeviceResponse struct {
Message string `json:"message" description:"提示信息"`
}
type AllocateDevicesRequest struct {
TargetShopID uint `json:"target_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标店铺ID"`
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
type AllocationDeviceFailedItem struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"失败原因"`
}
type AllocateDevicesResponse struct {
SuccessCount int `json:"success_count" description:"成功数量"`
FailCount int `json:"fail_count" description:"失败数量"`
FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"`
}
type RecallDevicesRequest struct {
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
type RecallDevicesResponse struct {
SuccessCount int `json:"success_count" description:"成功数量"`
FailCount int `json:"fail_count" description:"失败数量"`
FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"`
}

View File

@@ -0,0 +1,66 @@
package dto
import "time"
type ImportDeviceRequest struct {
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
}
type ImportDeviceResponse struct {
TaskID uint `json:"task_id" description:"导入任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
Message string `json:"message" description:"提示信息"`
}
type ListDeviceImportTaskRequest 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:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
}
type DeviceImportTaskResponse struct {
ID uint `json:"id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
StatusText string `json:"status_text" description:"任务状态文本"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
FileName string `json:"file_name,omitempty" description:"文件名"`
TotalCount int `json:"total_count" description:"总数"`
SuccessCount int `json:"success_count" description:"成功数"`
SkipCount int `json:"skip_count" description:"跳过数"`
FailCount int `json:"fail_count" description:"失败数"`
WarningCount int `json:"warning_count" description:"警告数(部分成功的设备数量)"`
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
type ListDeviceImportTaskResponse struct {
List []*DeviceImportTaskResponse `json:"list" description:"任务列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
type DeviceImportResultItemDTO struct {
Line int `json:"line" description:"行号"`
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"原因"`
}
type GetDeviceImportTaskRequest struct {
ID uint `path:"id" description:"任务ID" required:"true"`
}
type DeviceImportTaskDetailResponse struct {
DeviceImportTaskResponse
SkippedItems []*DeviceImportResultItemDTO `json:"skipped_items" description:"跳过记录详情"`
FailedItems []*DeviceImportResultItemDTO `json:"failed_items" description:"失败记录详情"`
WarningItems []*DeviceImportResultItemDTO `json:"warning_items" description:"警告记录详情(部分成功的设备及其卡绑定失败原因)"`
}

View File

@@ -62,24 +62,6 @@ func (AgentPackageAllocation) TableName() string {
return "tb_agent_package_allocation"
}
// DeviceSimBinding 设备-IoT卡绑定关系模型
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
type DeviceSimBinding struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
}
// TableName 指定表名
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding"
}
// PackageUsage 套餐使用情况模型
// 跟踪单卡套餐和设备级套餐的流量使用
type PackageUsage struct {

View File

@@ -58,6 +58,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.IotCard != nil {
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
}
if handlers.Device != nil {
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
}
if handlers.AssetAllocationRecord != nil {
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
}

127
internal/routes/device.go Normal file
View File

@@ -0,0 +1,127 @@
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 registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, importHandler *admin.DeviceImportHandler, doc *openapi.Generator, basePath string) {
devices := router.Group("/devices")
groupPath := basePath + "/devices"
Register(devices, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "设备列表",
Tags: []string{"设备管理"},
Input: new(dto.ListDeviceRequest),
Output: new(dto.ListDeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
Summary: "设备详情",
Tags: []string{"设备管理"},
Input: new(dto.GetDeviceRequest),
Output: new(dto.DeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除设备",
Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。",
Tags: []string{"设备管理"},
Input: new(dto.DeleteDeviceRequest),
Output: nil,
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/:id/cards", handler.ListCards, RouteSpec{
Summary: "获取设备绑定的卡列表",
Tags: []string{"设备管理"},
Input: new(dto.ListDeviceCardsRequest),
Output: new(dto.ListDeviceCardsResponse),
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/:id/cards", handler.BindCard, RouteSpec{
Summary: "绑定卡到设备",
Description: "仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。",
Tags: []string{"设备管理"},
Input: new(dto.BindCardToDeviceRequest),
Output: new(dto.BindCardToDeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "DELETE", "/:id/cards/:cardId", handler.UnbindCard, RouteSpec{
Summary: "解绑设备上的卡",
Description: "仅平台用户可操作。解绑不改变卡的 shop_id。",
Tags: []string{"设备管理"},
Input: new(dto.UnbindCardFromDeviceRequest),
Output: new(dto.UnbindCardFromDeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/allocate", handler.Allocate, RouteSpec{
Summary: "批量分配设备",
Description: "分配设备给直属下级店铺。分配时自动同步绑定的所有卡的 shop_id。",
Tags: []string{"设备管理"},
Input: new(dto.AllocateDevicesRequest),
Output: new(dto.AllocateDevicesResponse),
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/recall", handler.Recall, RouteSpec{
Summary: "批量回收设备",
Description: "从直属下级店铺回收设备。回收时自动同步绑定的所有卡的 shop_id。",
Tags: []string{"设备管理"},
Input: new(dto.RecallDevicesRequest),
Output: new(dto.RecallDevicesResponse),
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{
Summary: "批量导入设备",
Description: `仅平台用户可操作。
### 完整导入流程
1. **获取上传 URL**: 调用 ` + "`POST /api/admin/storage/upload-url`" + `
2. **上传 CSV 文件**: 使用预签名 URL 上传文件到对象存储
3. **调用本接口**: 使用返回的 ` + "`file_key`" + ` 提交导入任务
### CSV 文件格式
必须包含列(首行为表头):
- ` + "`device_no`" + `: 设备号(必填,唯一)
- ` + "`device_name`" + `: 设备名称
- ` + "`device_model`" + `: 设备型号
- ` + "`device_type`" + `: 设备类型
- ` + "`max_sim_slots`" + `: 最大插槽数默认4
- ` + "`manufacturer`" + `: 制造商
- ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 绑定的卡 ICCID卡必须已存在且未绑定`,
Tags: []string{"设备管理"},
Input: new(dto.ImportDeviceRequest),
Output: new(dto.ImportDeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/import/tasks", importHandler.List, RouteSpec{
Summary: "导入任务列表",
Description: "仅平台用户可操作。",
Tags: []string{"设备管理"},
Input: new(dto.ListDeviceImportTaskRequest),
Output: new(dto.ListDeviceImportTaskResponse),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/import/tasks/:id", importHandler.GetByID, RouteSpec{
Summary: "导入任务详情",
Description: "仅平台用户可操作。包含跳过和失败记录的详细信息。",
Tags: []string{"设备管理"},
Input: new(dto.GetDeviceImportTaskRequest),
Output: new(dto.DeviceImportTaskDetailResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,172 @@
package device
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDeviceCardsResponse, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID)
if err != nil {
return nil, err
}
cardIDs := make([]uint, 0, len(bindings))
for _, binding := range bindings {
cardIDs = append(cardIDs, binding.IotCardID)
}
var cards []*model.IotCard
if len(cardIDs) > 0 {
cards, err = s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return nil, err
}
}
cardMap := make(map[uint]*model.IotCard)
for _, card := range cards {
cardMap[card.ID] = card
}
carrierMap := s.loadCarrierData(ctx, cards)
responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings))
for _, binding := range bindings {
card := cardMap[binding.IotCardID]
if card == nil {
continue
}
resp := &dto.DeviceCardBindingResponse{
ID: binding.ID,
SlotPosition: binding.SlotPosition,
IotCardID: binding.IotCardID,
ICCID: card.ICCID,
MSISDN: card.MSISDN,
CarrierName: carrierMap[card.CarrierID],
Status: card.Status,
BindTime: binding.BindTime,
}
responses = append(responses, resp)
}
return &dto.ListDeviceCardsResponse{
Bindings: responses,
}, nil
}
func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCardToDeviceRequest) (*dto.BindCardToDeviceResponse, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
if req.SlotPosition > device.MaxSimSlots {
return nil, errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数")
}
existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition)
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if existingBinding != nil {
return nil, errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
}
card, err := s.iotCardStore.GetByID(ctx, req.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound)
}
return nil, err
}
activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
if activeBinding != nil {
return nil, errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
}
binding := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: card.ID,
SlotPosition: req.SlotPosition,
BindStatus: 1,
}
if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil {
return nil, err
}
return &dto.BindCardToDeviceResponse{
BindingID: binding.ID,
Message: "绑定成功",
}, nil
}
func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*dto.UnbindCardFromDeviceResponse, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "该卡未绑定到此设备")
}
return nil, err
}
if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil {
return nil, err
}
return &dto.UnbindCardFromDeviceResponse{
Message: "解绑成功",
}, nil
}
func (s *Service) loadCarrierData(ctx context.Context, cards []*model.IotCard) map[uint]string {
carrierIDs := make([]uint, 0)
carrierIDSet := make(map[uint]bool)
for _, card := range cards {
if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] {
carrierIDs = append(carrierIDs, card.CarrierID)
carrierIDSet[card.CarrierID] = true
}
}
carrierMap := make(map[uint]string)
if len(carrierIDs) > 0 {
var carriers []model.Carrier
s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers)
for _, c := range carriers {
carrierMap[c.ID] = c.CarrierName
}
}
return carrierMap
}

View File

@@ -0,0 +1,551 @@
package device
import (
"context"
"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"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
}
func New(
db *gorm.DB,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
) *Service {
return &Service{
db: db,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
}
}
// List 获取设备列表
func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.ListDeviceResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
}
filters := make(map[string]interface{})
if req.DeviceNo != "" {
filters["device_no"] = req.DeviceNo
}
if req.DeviceName != "" {
filters["device_name"] = req.DeviceName
}
if req.Status != nil {
filters["status"] = *req.Status
}
if req.ShopID != nil {
filters["shop_id"] = req.ShopID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.DeviceType != "" {
filters["device_type"] = req.DeviceType
}
if req.Manufacturer != "" {
filters["manufacturer"] = req.Manufacturer
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
}
devices, total, err := s.deviceStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
shopMap := s.loadShopData(ctx, devices)
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
if err != nil {
return nil, err
}
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, bindingCounts)
list = append(list, item)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.ListDeviceResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// Get 获取设备详情
func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
shopMap := s.loadShopData(ctx, []*model.Device{device})
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
if err != nil {
return nil, err
}
return s.toDeviceResponse(device, shopMap, bindingCounts), nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return err
}
if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil {
return err
}
return s.deviceStore.Delete(ctx, id)
}
// AllocateDevices 批量分配设备
func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) {
// 验证目标店铺是否为直属下级
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil {
return nil, err
}
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return &dto.AllocateDevicesResponse{
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationDeviceFailedItem{},
}, nil
}
var deviceIDs []uint
var failedItems []dto.AllocationDeviceFailedItem
isPlatform := operatorShopID == nil
for _, device := range devices {
// 平台只能分配 shop_id=NULL 的设备
if isPlatform && device.ShopID != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "平台只能分配库存设备",
})
continue
}
// 代理只能分配自己店铺的设备
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备不属于当前店铺",
})
continue
}
deviceIDs = append(deviceIDs, device.ID)
}
if len(deviceIDs) == 0 {
return &dto.AllocateDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
newStatus := constants.DeviceStatusDistributed
targetShopID := req.TargetShopID
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil {
return err
}
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
if err != nil {
return err
}
if len(boundCardIDs) > 0 {
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil {
return err
}
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.AllocateDevicesResponse{
SuccessCount: len(deviceIDs),
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
// RecallDevices 批量回收设备
func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) {
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return &dto.RecallDevicesResponse{
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationDeviceFailedItem{},
}, nil
}
var deviceIDs []uint
var failedItems []dto.AllocationDeviceFailedItem
isPlatform := operatorShopID == nil
for _, device := range devices {
// 验证设备所属店铺是否为直属下级
if device.ShopID == nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备已在平台库存中",
})
continue
}
// 验证直属下级关系
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "只能回收直属下级店铺的设备",
})
continue
}
deviceIDs = append(deviceIDs, device.ID)
}
if len(deviceIDs) == 0 {
return &dto.RecallDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
var newShopID *uint
var newStatus int
if isPlatform {
newShopID = nil
newStatus = constants.DeviceStatusInStock
} else {
newShopID = operatorShopID
newStatus = constants.DeviceStatusDistributed
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil {
return err
}
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
if err != nil {
return err
}
if len(boundCardIDs) > 0 {
var cardStatus int
if isPlatform {
cardStatus = constants.IotCardStatusInStock
} else {
cardStatus = constants.IotCardStatusDistributed
}
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil {
return err
}
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.RecallDevicesResponse{
SuccessCount: len(deviceIDs),
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
// 辅助方法
func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error {
if operatorShopID != nil && *operatorShopID == targetShopID {
return errors.ErrCannotAllocateToSelf
}
targetShop, err := s.shopStore.GetByID(ctx, targetShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound)
}
return err
}
if operatorShopID == nil {
if targetShop.ParentID != nil {
return errors.ErrNotDirectSubordinate
}
} else {
if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID {
return errors.ErrNotDirectSubordinate
}
}
return nil
}
func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string {
shopIDs := make([]uint, 0)
shopIDSet := make(map[uint]bool)
for _, device := range devices {
if device.ShopID != nil && *device.ShopID > 0 && !shopIDSet[*device.ShopID] {
shopIDs = append(shopIDs, *device.ShopID)
shopIDSet[*device.ShopID] = true
}
}
shopMap := make(map[uint]string)
if len(shopIDs) > 0 {
var shops []model.Shop
s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops)
for _, shop := range shops {
shopMap[shop.ID] = shop.ShopName
}
}
return shopMap
}
func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(deviceIDs) == 0 {
return result, nil
}
bindings, err := s.deviceSimBindingStore.ListByDeviceIDs(ctx, deviceIDs)
if err != nil {
return nil, err
}
for _, binding := range bindings {
result[binding.DeviceID]++
}
return result, nil
}
func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
ids := make([]uint, len(devices))
for i, device := range devices {
ids[i] = device.ID
}
return ids
}
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
resp := &dto.DeviceResponse{
ID: device.ID,
DeviceNo: device.DeviceNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,
MaxSimSlots: device.MaxSimSlots,
Manufacturer: device.Manufacturer,
BatchNo: device.BatchNo,
ShopID: device.ShopID,
Status: device.Status,
StatusName: s.getDeviceStatusName(device.Status),
BoundCardCount: int(bindingCounts[device.ID]),
ActivatedAt: device.ActivatedAt,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
}
if device.ShopID != nil && *device.ShopID > 0 {
resp.ShopName = shopMap[*device.ShopID]
}
return resp
}
func (s *Service) getDeviceStatusName(status int) string {
switch status {
case constants.DeviceStatusInStock:
return "在库"
case constants.DeviceStatusDistributed:
return "已分销"
case constants.DeviceStatusActivated:
return "已激活"
case constants.DeviceStatusSuspended:
return "已停用"
default:
return "未知"
}
}
func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successDeviceIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, device := range devices {
if !successIDSet[device.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: toShopID,
OperatorID: operatorID,
Remark: remark,
}
if fromShopID == nil {
record.FromOwnerType = constants.OwnerTypePlatform
record.FromOwnerID = nil
} else {
record.FromOwnerType = constants.OwnerTypeShop
record.FromOwnerID = fromShopID
}
records = append(records, record)
}
return records
}
func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successDeviceIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, device := range devices {
if !successIDSet[device.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
OperatorID: operatorID,
Remark: remark,
}
if fromShopID == nil {
record.FromOwnerType = constants.OwnerTypePlatform
record.FromOwnerID = nil
} else {
record.FromOwnerType = constants.OwnerTypeShop
record.FromOwnerID = fromShopID
}
if toShopID == nil {
record.ToOwnerType = constants.OwnerTypePlatform
record.ToOwnerID = 0
} else {
record.ToOwnerType = constants.OwnerTypeShop
record.ToOwnerID = *toShopID
}
records = append(records, record)
}
return records
}

View File

@@ -0,0 +1,209 @@
package device_import
import (
"context"
"fmt"
"path/filepath"
"time"
"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"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
importTaskStore *postgres.DeviceImportTaskStore
queueClient *queue.Client
}
type DeviceImportPayload struct {
TaskID uint `json:"task_id"`
}
func New(db *gorm.DB, importTaskStore *postgres.DeviceImportTaskStore, queueClient *queue.Client) *Service {
return &Service{
db: db,
importTaskStore: importTaskStore,
queueClient: queueClient,
}
}
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
fileName := filepath.Base(req.FileKey)
task := &model.DeviceImportTask{
TaskNo: taskNo,
Status: model.ImportTaskStatusPending,
BatchNo: req.BatchNo,
FileName: fileName,
StorageKey: req.FileKey,
}
task.Creator = userID
task.Updater = userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
return nil, fmt.Errorf("创建导入任务失败: %w", err)
}
payload := DeviceImportPayload{TaskID: task.ID}
err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, payload)
if err != nil {
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
return nil, fmt.Errorf("任务入队失败: %w", err)
}
return &dto.ImportDeviceResponse{
TaskID: task.ID,
TaskNo: taskNo,
Message: "导入任务已创建Worker 将异步处理文件",
}, nil
}
func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
}
filters := make(map[string]interface{})
if req.Status != nil {
filters["status"] = *req.Status
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.StartTime != nil {
filters["start_time"] = *req.StartTime
}
if req.EndTime != nil {
filters["end_time"] = *req.EndTime
}
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
list := make([]*dto.DeviceImportTaskResponse, 0, len(tasks))
for _, task := range tasks {
list = append(list, s.toTaskResponse(task))
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.ListDeviceImportTaskResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDetailResponse, error) {
task, err := s.importTaskStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "导入任务不存在")
}
resp := &dto.DeviceImportTaskDetailResponse{
DeviceImportTaskResponse: *s.toTaskResponse(task),
SkippedItems: make([]*dto.DeviceImportResultItemDTO, 0),
FailedItems: make([]*dto.DeviceImportResultItemDTO, 0),
WarningItems: make([]*dto.DeviceImportResultItemDTO, 0),
}
for _, item := range task.SkippedItems {
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.FailedItems {
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.WarningItems {
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
})
}
return resp, nil
}
func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImportTaskResponse {
var startedAt, completedAt *time.Time
if task.StartedAt != nil {
startedAt = task.StartedAt
}
if task.CompletedAt != nil {
completedAt = task.CompletedAt
}
return &dto.DeviceImportTaskResponse{
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusText: getStatusText(task.Status),
BatchNo: task.BatchNo,
FileName: task.FileName,
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
SkipCount: task.SkipCount,
FailCount: task.FailCount,
WarningCount: task.WarningCount,
StartedAt: startedAt,
CompletedAt: completedAt,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
}
}
func getStatusText(status int) string {
switch status {
case model.ImportTaskStatusPending:
return "待处理"
case model.ImportTaskStatusProcessing:
return "处理中"
case model.ImportTaskStatusCompleted:
return "已完成"
case model.ImportTaskStatusFailed:
return "失败"
default:
return "未知"
}
}

View File

@@ -32,7 +32,7 @@ func New(
}
}
func (s *Service) allocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -181,7 +181,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
preview, err := s.allocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,135 @@
package postgres
import (
"context"
"fmt"
"time"
"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/redis/go-redis/v9"
"gorm.io/gorm"
)
type DeviceImportTaskStore struct {
db *gorm.DB
redis *redis.Client
}
func NewDeviceImportTaskStore(db *gorm.DB, redis *redis.Client) *DeviceImportTaskStore {
return &DeviceImportTaskStore{
db: db,
redis: redis,
}
}
func (s *DeviceImportTaskStore) Create(ctx context.Context, task *model.DeviceImportTask) error {
return s.db.WithContext(ctx).Create(task).Error
}
func (s *DeviceImportTaskStore) GetByID(ctx context.Context, id uint) (*model.DeviceImportTask, error) {
var task model.DeviceImportTask
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
return nil, err
}
return &task, nil
}
func (s *DeviceImportTaskStore) GetByTaskNo(ctx context.Context, taskNo string) (*model.DeviceImportTask, error) {
var task model.DeviceImportTask
if err := s.db.WithContext(ctx).Where("task_no = ?", taskNo).First(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
func (s *DeviceImportTaskStore) Update(ctx context.Context, task *model.DeviceImportTask) error {
return s.db.WithContext(ctx).Save(task).Error
}
func (s *DeviceImportTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error {
updates := map[string]any{
"status": status,
"updated_at": time.Now(),
}
if status == model.ImportTaskStatusProcessing {
now := time.Now()
updates["started_at"] = &now
}
if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed {
now := time.Now()
updates["completed_at"] = &now
}
if errorMessage != "" {
updates["error_message"] = errorMessage
}
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error
}
func (s *DeviceImportTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, skipCount, failCount, warningCount int, skippedItems, failedItems, warningItems model.ImportResultItems) error {
updates := map[string]any{
"total_count": totalCount,
"success_count": successCount,
"skip_count": skipCount,
"fail_count": failCount,
"warning_count": warningCount,
"skipped_items": skippedItems,
"failed_items": failedItems,
"warning_items": warningItems,
"updated_at": time.Now(),
}
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error
}
func (s *DeviceImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.DeviceImportTask, int64, error) {
var tasks []*model.DeviceImportTask
var total int64
query := s.db.WithContext(ctx).Model(&model.DeviceImportTask{})
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no LIKE ?", "%"+batchNo+"%")
}
if startTime, ok := filters["start_time"].(time.Time); ok && !startTime.IsZero() {
query = query.Where("created_at >= ?", startTime)
}
if endTime, ok := filters["end_time"].(time.Time); ok && !endTime.IsZero() {
query = query.Where("created_at <= ?", endTime)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&tasks).Error; err != nil {
return nil, 0, err
}
return tasks, total, nil
}
func (s *DeviceImportTaskStore) GenerateTaskNo(ctx context.Context) string {
now := time.Now()
dateStr := now.Format("20060102")
seq := now.UnixNano() % 1000000
return fmt.Sprintf("DEV-IMP-%s-%06d", dateStr, seq)
}

View File

@@ -0,0 +1,202 @@
package postgres
import (
"context"
stderrors "errors"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type DeviceSimBindingStore struct {
db *gorm.DB
redis *redis.Client
}
func NewDeviceSimBindingStore(db *gorm.DB, redis *redis.Client) *DeviceSimBindingStore {
return &DeviceSimBindingStore{
db: db,
redis: redis,
}
}
func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error {
err := s.db.WithContext(ctx).Create(binding).Error
if err != nil {
if isUniqueViolation(err) {
if strings.Contains(err.Error(), "idx_active_device_slot") {
return errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
}
if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") {
return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
}
}
return err
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if stderrors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
func (s *DeviceSimBindingStore) CreateBatch(ctx context.Context, bindings []*model.DeviceSimBinding) error {
if len(bindings) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(bindings, 100).Error
}
func (s *DeviceSimBindingStore) GetByID(ctx context.Context, id uint) (*model.DeviceSimBinding, error) {
var binding model.DeviceSimBinding
if err := s.db.WithContext(ctx).First(&binding, id).Error; err != nil {
return nil, err
}
return &binding, nil
}
func (s *DeviceSimBindingStore) ListByDeviceID(ctx context.Context, deviceID uint) ([]*model.DeviceSimBinding, error) {
var bindings []*model.DeviceSimBinding
if err := s.db.WithContext(ctx).
Where("device_id = ? AND bind_status = 1", deviceID).
Order("slot_position ASC").
Find(&bindings).Error; err != nil {
return nil, err
}
return bindings, nil
}
func (s *DeviceSimBindingStore) ListByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]*model.DeviceSimBinding, error) {
var bindings []*model.DeviceSimBinding
if len(deviceIDs) == 0 {
return bindings, nil
}
if err := s.db.WithContext(ctx).
Where("device_id IN ? AND bind_status = 1", deviceIDs).
Find(&bindings).Error; err != nil {
return nil, err
}
return bindings, nil
}
func (s *DeviceSimBindingStore) GetByDeviceAndCard(ctx context.Context, deviceID, iotCardID uint) (*model.DeviceSimBinding, error) {
var binding model.DeviceSimBinding
if err := s.db.WithContext(ctx).
Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID).
First(&binding).Error; err != nil {
return nil, err
}
return &binding, nil
}
func (s *DeviceSimBindingStore) GetByDeviceAndSlot(ctx context.Context, deviceID uint, slotPosition int) (*model.DeviceSimBinding, error) {
var binding model.DeviceSimBinding
if err := s.db.WithContext(ctx).
Where("device_id = ? AND slot_position = ? AND bind_status = 1", deviceID, slotPosition).
First(&binding).Error; err != nil {
return nil, err
}
return &binding, nil
}
func (s *DeviceSimBindingStore) GetActiveBindingByCardID(ctx context.Context, iotCardID uint) (*model.DeviceSimBinding, error) {
var binding model.DeviceSimBinding
if err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND bind_status = 1", iotCardID).
First(&binding).Error; err != nil {
return nil, err
}
return &binding, nil
}
func (s *DeviceSimBindingStore) GetActiveBindingsByCardIDs(ctx context.Context, iotCardIDs []uint) ([]*model.DeviceSimBinding, error) {
var bindings []*model.DeviceSimBinding
if len(iotCardIDs) == 0 {
return bindings, nil
}
if err := s.db.WithContext(ctx).
Where("iot_card_id IN ? AND bind_status = 1", iotCardIDs).
Find(&bindings).Error; err != nil {
return nil, err
}
return bindings, nil
}
func (s *DeviceSimBindingStore) Unbind(ctx context.Context, id uint) error {
now := time.Now()
updates := map[string]any{
"bind_status": 2,
"unbind_time": now,
"updated_at": now,
}
return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", id).Updates(updates).Error
}
func (s *DeviceSimBindingStore) UnbindByDeviceID(ctx context.Context, deviceID uint) error {
now := time.Now()
updates := map[string]any{
"bind_status": 2,
"unbind_time": now,
"updated_at": now,
}
return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = 1", deviceID).
Updates(updates).Error
}
func (s *DeviceSimBindingStore) CountByDeviceID(ctx context.Context, deviceID uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = 1", deviceID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *DeviceSimBindingStore) GetBoundCardIDsByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]uint, error) {
if len(deviceIDs) == 0 {
return nil, nil
}
var cardIDs []uint
if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("device_id IN ? AND bind_status = 1", deviceIDs).
Pluck("iot_card_id", &cardIDs).Error; err != nil {
return nil, err
}
return cardIDs, nil
}
func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(iccids) == 0 {
return result, nil
}
var bindings []struct {
ICCID string
}
if err := s.db.WithContext(ctx).
Table("tb_device_sim_binding b").
Select("c.iccid").
Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id").
Where("c.iccid IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", iccids).
Find(&bindings).Error; err != nil {
return nil, err
}
for _, b := range bindings {
result[b.ICCID] = true
}
return result, nil
}

View File

@@ -0,0 +1,209 @@
package postgres
import (
"context"
"sync"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeviceSimBindingStore_Create_DuplicateCard(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
bindingStore := NewDeviceSimBindingStore(tx, rdb)
deviceStore := NewDeviceStore(tx, rdb)
cardStore := NewIotCardStore(tx, rdb)
ctx := context.Background()
device1 := &model.Device{DeviceNo: "TEST-DEV-UC-001", Status: 1, MaxSimSlots: 4}
device2 := &model.Device{DeviceNo: "TEST-DEV-UC-002", Status: 1, MaxSimSlots: 4}
require.NoError(t, deviceStore.Create(ctx, device1))
require.NoError(t, deviceStore.Create(ctx, device2))
card := &model.IotCard{ICCID: "89860012345678910001", CardType: "data_card", CarrierID: 1, Status: 1}
require.NoError(t, cardStore.Create(ctx, card))
now := time.Now()
binding1 := &model.DeviceSimBinding{
DeviceID: device1.ID,
IotCardID: card.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
require.NoError(t, bindingStore.Create(ctx, binding1))
binding2 := &model.DeviceSimBinding{
DeviceID: device2.ID,
IotCardID: card.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
err := bindingStore.Create(ctx, binding2)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok, "错误应该是 AppError 类型")
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
assert.Contains(t, appErr.Message, "该卡已绑定到其他设备")
}
func TestDeviceSimBindingStore_Create_DuplicateSlot(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
bindingStore := NewDeviceSimBindingStore(tx, rdb)
deviceStore := NewDeviceStore(tx, rdb)
cardStore := NewIotCardStore(tx, rdb)
ctx := context.Background()
device := &model.Device{DeviceNo: "TEST-DEV-UC-003", Status: 1, MaxSimSlots: 4}
require.NoError(t, deviceStore.Create(ctx, device))
card1 := &model.IotCard{ICCID: "89860012345678910011", CardType: "data_card", CarrierID: 1, Status: 1}
card2 := &model.IotCard{ICCID: "89860012345678910012", CardType: "data_card", CarrierID: 1, Status: 1}
require.NoError(t, cardStore.Create(ctx, card1))
require.NoError(t, cardStore.Create(ctx, card2))
now := time.Now()
binding1 := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: card1.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
require.NoError(t, bindingStore.Create(ctx, binding1))
binding2 := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: card2.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
err := bindingStore.Create(ctx, binding2)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok, "错误应该是 AppError 类型")
assert.Equal(t, errors.CodeConflict, appErr.Code)
assert.Contains(t, appErr.Message, "该插槽已有绑定的卡")
}
func TestDeviceSimBindingStore_Create_DifferentSlots(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
bindingStore := NewDeviceSimBindingStore(tx, rdb)
deviceStore := NewDeviceStore(tx, rdb)
cardStore := NewIotCardStore(tx, rdb)
ctx := context.Background()
device := &model.Device{DeviceNo: "TEST-DEV-UC-004", Status: 1, MaxSimSlots: 4}
require.NoError(t, deviceStore.Create(ctx, device))
card1 := &model.IotCard{ICCID: "89860012345678910021", CardType: "data_card", CarrierID: 1, Status: 1}
card2 := &model.IotCard{ICCID: "89860012345678910022", CardType: "data_card", CarrierID: 1, Status: 1}
require.NoError(t, cardStore.Create(ctx, card1))
require.NoError(t, cardStore.Create(ctx, card2))
now := time.Now()
binding1 := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: card1.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
require.NoError(t, bindingStore.Create(ctx, binding1))
assert.NotZero(t, binding1.ID)
binding2 := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: card2.ID,
SlotPosition: 2,
BindStatus: 1,
BindTime: &now,
}
err := bindingStore.Create(ctx, binding2)
require.NoError(t, err)
assert.NotZero(t, binding2.ID)
}
func TestDeviceSimBindingStore_ConcurrentBinding(t *testing.T) {
db := testutils.GetTestDB(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
deviceStore := NewDeviceStore(db, rdb)
cardStore := NewIotCardStore(db, rdb)
ctx := context.Background()
device1 := &model.Device{DeviceNo: "TEST-CONCURRENT-001", Status: 1, MaxSimSlots: 4}
device2 := &model.Device{DeviceNo: "TEST-CONCURRENT-002", Status: 1, MaxSimSlots: 4}
require.NoError(t, deviceStore.Create(ctx, device1))
require.NoError(t, deviceStore.Create(ctx, device2))
card := &model.IotCard{ICCID: "89860012345678920001", CardType: "data_card", CarrierID: 1, Status: 1}
require.NoError(t, cardStore.Create(ctx, card))
t.Cleanup(func() {
db.Where("device_id IN ?", []uint{device1.ID, device2.ID}).Delete(&model.DeviceSimBinding{})
db.Delete(device1)
db.Delete(device2)
db.Delete(card)
})
t.Run("并发绑定同一张卡到不同设备", func(t *testing.T) {
bindingStore := NewDeviceSimBindingStore(db, rdb)
var wg sync.WaitGroup
results := make(chan error, 2)
for i, deviceID := range []uint{device1.ID, device2.ID} {
wg.Add(1)
go func(devID uint, slot int) {
defer wg.Done()
now := time.Now()
binding := &model.DeviceSimBinding{
DeviceID: devID,
IotCardID: card.ID,
SlotPosition: slot,
BindStatus: 1,
BindTime: &now,
}
results <- bindingStore.Create(ctx, binding)
}(deviceID, i+1)
}
wg.Wait()
close(results)
var successCount, errorCount int
for err := range results {
if err == nil {
successCount++
} else {
errorCount++
appErr, ok := err.(*errors.AppError)
if ok {
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
}
}
}
assert.Equal(t, 1, successCount, "应该只有一个请求成功")
assert.Equal(t, 1, errorCount, "应该有一个请求失败")
})
}

View File

@@ -0,0 +1,183 @@
package postgres
import (
"context"
"time"
"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/redis/go-redis/v9"
"gorm.io/gorm"
)
type DeviceStore struct {
db *gorm.DB
redis *redis.Client
}
func NewDeviceStore(db *gorm.DB, redis *redis.Client) *DeviceStore {
return &DeviceStore{
db: db,
redis: redis,
}
}
func (s *DeviceStore) Create(ctx context.Context, device *model.Device) error {
return s.db.WithContext(ctx).Create(device).Error
}
func (s *DeviceStore) CreateBatch(ctx context.Context, devices []*model.Device) error {
if len(devices) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(devices, 100).Error
}
func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, error) {
var device model.Device
if err := s.db.WithContext(ctx).First(&device, id).Error; err != nil {
return nil, err
}
return &device, nil
}
func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) {
var device model.Device
if err := s.db.WithContext(ctx).Where("device_no = ?", deviceNo).First(&device).Error; err != nil {
return nil, err
}
return &device, nil
}
func (s *DeviceStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Device, error) {
var devices []*model.Device
if len(ids) == 0 {
return devices, nil
}
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceStore) Update(ctx context.Context, device *model.Device) error {
return s.db.WithContext(ctx).Save(device).Error
}
func (s *DeviceStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Device{}, id).Error
}
func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Device, int64, error) {
var devices []*model.Device
var total int64
query := s.db.WithContext(ctx).Model(&model.Device{})
if deviceNo, ok := filters["device_no"].(string); ok && deviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+deviceNo+"%")
}
if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" {
query = query.Where("device_name LIKE ?", "%"+deviceName+"%")
}
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if shopID, ok := filters["shop_id"].(*uint); ok {
if shopID == nil {
query = query.Where("shop_id IS NULL")
} else {
query = query.Where("shop_id = ?", *shopID)
}
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
if deviceType, ok := filters["device_type"].(string); ok && deviceType != "" {
query = query.Where("device_type = ?", deviceType)
}
if manufacturer, ok := filters["manufacturer"].(string); ok && manufacturer != "" {
query = query.Where("manufacturer LIKE ?", "%"+manufacturer+"%")
}
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() {
query = query.Where("created_at >= ?", createdAtStart)
}
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() {
query = query.Where("created_at <= ?", createdAtEnd)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&devices).Error; err != nil {
return nil, 0, err
}
return devices, total, nil
}
func (s *DeviceStore) UpdateShopID(ctx context.Context, id uint, shopID *uint) error {
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id = ?", id).Update("shop_id", shopID).Error
}
func (s *DeviceStore) BatchUpdateShopIDAndStatus(ctx context.Context, ids []uint, shopID *uint, status int) error {
if len(ids) == 0 {
return nil
}
updates := map[string]any{
"shop_id": shopID,
"status": status,
"updated_at": time.Now(),
}
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id IN ?", ids).Updates(updates).Error
}
func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(deviceNos) == 0 {
return result, nil
}
var existingDevices []struct {
DeviceNo string
}
if err := s.db.WithContext(ctx).Model(&model.Device{}).
Select("device_no").
Where("device_no IN ?", deviceNos).
Find(&existingDevices).Error; err != nil {
return nil, err
}
for _, d := range existingDevices {
result[d.DeviceNo] = true
}
return result, nil
}
func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([]*model.Device, error) {
var devices []*model.Device
if len(deviceNos) == 0 {
return devices, nil
}
if err := s.db.WithContext(ctx).Where("device_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}

View File

@@ -0,0 +1,439 @@
package task
import (
"context"
"encoding/csv"
stderrors "errors"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
const deviceBatchSize = 100
type DeviceImportPayload struct {
TaskID uint `json:"task_id"`
}
type DeviceImportHandler struct {
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.DeviceImportTaskStore
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
storageService *storage.Service
logger *zap.Logger
}
func NewDeviceImportHandler(
db *gorm.DB,
redis *redis.Client,
importTaskStore *postgres.DeviceImportTaskStore,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
storageSvc *storage.Service,
logger *zap.Logger,
) *DeviceImportHandler {
return &DeviceImportHandler{
db: db,
redis: redis,
importTaskStore: importTaskStore,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
storageService: storageSvc,
logger: logger,
}
}
func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asynq.Task) error {
ctx = pkggorm.SkipDataPermission(ctx)
var payload DeviceImportPayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析设备导入任务载荷失败",
zap.Error(err),
zap.String("task_id", task.ResultWriter().TaskID()),
)
return asynq.SkipRetry
}
importTask, err := h.importTaskStore.GetByID(ctx, payload.TaskID)
if err != nil {
h.logger.Error("获取导入任务失败",
zap.Uint("task_id", payload.TaskID),
zap.Error(err),
)
return asynq.SkipRetry
}
if importTask.Status != model.ImportTaskStatusPending {
h.logger.Info("导入任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
)
return nil
}
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "")
h.logger.Info("开始处理设备导入任务",
zap.Uint("task_id", importTask.ID),
zap.String("task_no", importTask.TaskNo),
zap.String("storage_key", importTask.StorageKey),
)
rows, totalCount, err := h.downloadAndParseCSV(ctx, importTask)
if err != nil {
h.logger.Error("下载或解析 CSV 失败",
zap.Uint("task_id", importTask.ID),
zap.Error(err),
)
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error())
return asynq.SkipRetry
}
result := h.processImport(ctx, importTask, rows, totalCount)
h.importTaskStore.UpdateResult(ctx, importTask.ID, totalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil)
if result.failCount > 0 && result.successCount == 0 {
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败")
} else {
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "")
}
h.logger.Info("设备导入任务完成",
zap.Uint("task_id", importTask.ID),
zap.Int("success_count", result.successCount),
zap.Int("skip_count", result.skipCount),
zap.Int("fail_count", result.failCount),
)
return nil
}
type deviceRow struct {
Line int
DeviceNo string
DeviceName string
DeviceModel string
DeviceType string
MaxSimSlots int
Manufacturer string
ICCIDs []string
}
func (h *DeviceImportHandler) downloadAndParseCSV(ctx context.Context, task *model.DeviceImportTask) ([]deviceRow, int, error) {
if h.storageService == nil {
return nil, 0, ErrStorageNotConfigured
}
if task.StorageKey == "" {
return nil, 0, ErrStorageKeyEmpty
}
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
if err != nil {
return nil, 0, err
}
defer cleanup()
f, err := os.Open(localPath)
if err != nil {
return nil, 0, err
}
defer f.Close()
return h.parseDeviceCSV(f)
}
func (h *DeviceImportHandler) parseDeviceCSV(r io.Reader) ([]deviceRow, int, error) {
reader := csv.NewReader(r)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
header, err := reader.Read()
if err != nil {
return nil, 0, err
}
colIndex := h.buildColumnIndex(header)
if colIndex["device_no"] == -1 {
return nil, 0, ErrMissingDeviceNoColumn
}
var rows []deviceRow
lineNum := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
lineNum++
row := deviceRow{Line: lineNum}
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
row.DeviceNo = strings.TrimSpace(record[idx])
}
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
row.DeviceName = strings.TrimSpace(record[idx])
}
if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) {
row.DeviceModel = strings.TrimSpace(record[idx])
}
if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) {
row.DeviceType = strings.TrimSpace(record[idx])
}
if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) {
if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil {
row.MaxSimSlots = n
}
}
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
row.Manufacturer = strings.TrimSpace(record[idx])
}
row.ICCIDs = make([]string, 0, 4)
for i := 1; i <= 4; i++ {
colName := "iccid_" + strconv.Itoa(i)
if idx := colIndex[colName]; idx >= 0 && idx < len(record) {
iccid := strings.TrimSpace(record[idx])
if iccid != "" {
row.ICCIDs = append(row.ICCIDs, iccid)
}
}
}
if row.DeviceNo == "" {
continue
}
if row.MaxSimSlots == 0 {
row.MaxSimSlots = 4
}
rows = append(rows, row)
}
return rows, len(rows), nil
}
func (h *DeviceImportHandler) buildColumnIndex(header []string) map[string]int {
index := map[string]int{
"device_no": -1,
"device_name": -1,
"device_model": -1,
"device_type": -1,
"max_sim_slots": -1,
"manufacturer": -1,
"iccid_1": -1,
"iccid_2": -1,
"iccid_3": -1,
"iccid_4": -1,
}
for i, col := range header {
col = strings.ToLower(strings.TrimSpace(col))
if _, exists := index[col]; exists {
index[col] = i
}
}
return index
}
type deviceImportResult struct {
successCount int
skipCount int
failCount int
skippedItems model.ImportResultItems
failedItems model.ImportResultItems
}
func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []deviceRow, totalCount int) *deviceImportResult {
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
if len(rows) == 0 {
return result
}
for i := 0; i < len(rows); i += deviceBatchSize {
end := min(i+deviceBatchSize, len(rows))
batch := rows[i:end]
h.processBatch(ctx, task, batch, result)
}
return result
}
func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.DeviceImportTask, batch []deviceRow, result *deviceImportResult) {
deviceNos := make([]string, 0, len(batch))
allICCIDs := make([]string, 0)
for _, row := range batch {
deviceNos = append(deviceNos, row.DeviceNo)
allICCIDs = append(allICCIDs, row.ICCIDs...)
}
existingDevices, err := h.deviceStore.ExistsByDeviceNoBatch(ctx, deviceNos)
if err != nil {
h.logger.Error("检查设备是否存在失败", zap.Error(err))
for _, row := range batch {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "数据库查询失败",
})
result.failCount++
}
return
}
var existingCards map[string]*model.IotCard
var boundCards map[string]bool
if len(allICCIDs) > 0 {
cards, err := h.iotCardStore.GetByICCIDs(ctx, allICCIDs)
if err != nil {
h.logger.Error("查询卡信息失败", zap.Error(err))
} else {
existingCards = make(map[string]*model.IotCard)
for _, card := range cards {
existingCards[card.ICCID] = card
}
}
boundCards, err = h.deviceSimBindingStore.GetBoundICCIDs(ctx, allICCIDs)
if err != nil {
h.logger.Error("查询卡绑定状态失败", zap.Error(err))
}
}
for _, row := range batch {
if existingDevices[row.DeviceNo] {
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "设备号已存在",
})
result.skipCount++
continue
}
var validCardIDs []uint
var cardIssues []string
for _, iccid := range row.ICCIDs {
card, exists := existingCards[iccid]
if !exists {
cardIssues = append(cardIssues, iccid+"不存在")
continue
}
if boundCards[iccid] {
cardIssues = append(cardIssues, iccid+"已绑定其他设备")
continue
}
if card.ShopID != nil {
cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备")
continue
}
validCardIDs = append(validCardIDs, card.ID)
}
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "卡验证失败: " + strings.Join(cardIssues, ", "),
})
result.failCount++
continue
}
err := h.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
device := &model.Device{
DeviceNo: row.DeviceNo,
DeviceName: row.DeviceName,
DeviceModel: row.DeviceModel,
DeviceType: row.DeviceType,
MaxSimSlots: row.MaxSimSlots,
Manufacturer: row.Manufacturer,
BatchNo: task.BatchNo,
Status: constants.DeviceStatusInStock,
}
device.Creator = task.Creator
device.Updater = task.Creator
if err := txDeviceStore.Create(ctx, device); err != nil {
return err
}
now := time.Now()
for i, cardID := range validCardIDs {
binding := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: cardID,
SlotPosition: i + 1,
BindStatus: 1,
BindTime: &now,
}
if err := txBindingStore.Create(ctx, binding); err != nil {
return err
}
}
return nil
})
if err != nil {
h.logger.Error("创建设备失败",
zap.String("device_no", row.DeviceNo),
zap.Error(err),
)
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "数据库写入失败: " + err.Error(),
})
result.failCount++
continue
}
for _, iccid := range row.ICCIDs {
if card, exists := existingCards[iccid]; exists && !boundCards[iccid] && card.ShopID == nil {
boundCards[iccid] = true
}
}
result.successCount++
}
}
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列")

View File

@@ -0,0 +1,190 @@
package task
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestDeviceImportHandler_ProcessBatch_AllOrNothingValidation(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
logger := zap.NewNop()
importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
cardStore := postgres.NewIotCardStore(tx, rdb)
handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger)
ctx := context.Background()
shopID := uint(100)
platformCard := &model.IotCard{ICCID: "89860012345670001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
platformCard2 := &model.IotCard{ICCID: "89860012345670001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
shopCard := &model.IotCard{ICCID: "89860012345670001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}
require.NoError(t, cardStore.Create(ctx, platformCard))
require.NoError(t, cardStore.Create(ctx, platformCard2))
require.NoError(t, cardStore.Create(ctx, shopCard))
t.Run("所有卡可用-成功", func(t *testing.T) {
task := &model.DeviceImportTask{
BatchNo: "TEST_BATCH_001",
}
task.Creator = 1
batch := []deviceRow{
{Line: 2, DeviceNo: "DEV-OWNER-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001001"}},
}
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
handler.processBatch(ctx, task, batch, result)
assert.Equal(t, 1, result.successCount)
assert.Equal(t, 0, result.failCount)
})
t.Run("任一卡分配给店铺-整体失败", func(t *testing.T) {
task := &model.DeviceImportTask{
BatchNo: "TEST_BATCH_002",
}
task.Creator = 1
batch := []deviceRow{
{Line: 3, DeviceNo: "DEV-OWNER-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001003", "89860012345670001002"}},
}
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
handler.processBatch(ctx, task, batch, result)
assert.Equal(t, 0, result.successCount)
assert.Equal(t, 1, result.failCount)
require.Len(t, result.failedItems, 1)
assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺")
})
t.Run("任一卡不存在-整体失败", func(t *testing.T) {
task := &model.DeviceImportTask{
BatchNo: "TEST_BATCH_003",
}
task.Creator = 1
batch := []deviceRow{
{Line: 4, DeviceNo: "DEV-OWNER-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001002", "89860012345670009999"}},
}
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
handler.processBatch(ctx, task, batch, result)
assert.Equal(t, 0, result.successCount)
assert.Equal(t, 1, result.failCount)
require.Len(t, result.failedItems, 1)
assert.Contains(t, result.failedItems[0].Reason, "卡验证失败")
})
t.Run("无指定卡时创建设备成功", func(t *testing.T) {
task := &model.DeviceImportTask{
BatchNo: "TEST_BATCH_004",
}
task.Creator = 1
batch := []deviceRow{
{Line: 5, DeviceNo: "DEV-OWNER-004", MaxSimSlots: 4, ICCIDs: []string{}},
}
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
handler.processBatch(ctx, task, batch, result)
assert.Equal(t, 1, result.successCount)
assert.Equal(t, 0, result.failCount)
})
t.Run("多张卡全部可用-成功", func(t *testing.T) {
newCard1 := &model.IotCard{ICCID: "89860012345670001010", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
newCard2 := &model.IotCard{ICCID: "89860012345670001011", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
require.NoError(t, cardStore.Create(ctx, newCard1))
require.NoError(t, cardStore.Create(ctx, newCard2))
task := &model.DeviceImportTask{
BatchNo: "TEST_BATCH_005",
}
task.Creator = 1
batch := []deviceRow{
{Line: 6, DeviceNo: "DEV-OWNER-005", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001010", "89860012345670001011"}},
}
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
handler.processBatch(ctx, task, batch, result)
assert.Equal(t, 1, result.successCount)
assert.Equal(t, 0, result.failCount)
})
}
func TestDeviceImportHandler_ProcessImport_AllOrNothing(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
logger := zap.NewNop()
importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
cardStore := postgres.NewIotCardStore(tx, rdb)
handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger)
ctx := context.Background()
shopID := uint(200)
platformCard1 := &model.IotCard{ICCID: "89860012345680001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
platformCard2 := &model.IotCard{ICCID: "89860012345680001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
shopCard := &model.IotCard{ICCID: "89860012345680001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}
require.NoError(t, cardStore.Create(ctx, platformCard1))
require.NoError(t, cardStore.Create(ctx, platformCard2))
require.NoError(t, cardStore.Create(ctx, shopCard))
task := &model.DeviceImportTask{
BatchNo: "TEST_PROCESS_IMPORT",
}
task.Creator = 1
rows := []deviceRow{
{Line: 2, DeviceNo: "DEV-PI-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001001"}},
{Line: 3, DeviceNo: "DEV-PI-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001002", "89860012345680001003"}},
{Line: 4, DeviceNo: "DEV-PI-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001003", "89860012345680009999"}},
}
result := handler.processImport(ctx, task, rows, len(rows))
assert.Equal(t, 1, result.successCount, "只有第一个设备应该成功(所有卡都可用)")
assert.Equal(t, 2, result.failCount, "第二和第三个设备应该失败(有卡不可用)")
assert.Len(t, result.failedItems, 2)
assert.Equal(t, 3, result.failedItems[0].Line)
assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺")
assert.Equal(t, 4, result.failedItems[1].Line)
assert.Contains(t, result.failedItems[1].Reason, "卡验证失败")
}

View File

@@ -0,0 +1,3 @@
-- 回滚:删除设备导入任务表
DROP TABLE IF EXISTS tb_device_import_task;

View File

@@ -0,0 +1,69 @@
-- 创建设备导入任务表
-- 用于记录设备批量导入的任务状态和处理结果
CREATE TABLE IF NOT EXISTS tb_device_import_task (
-- 基础字段
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
creator BIGINT,
updater BIGINT,
-- 任务标识
task_no VARCHAR(50) NOT NULL UNIQUE,
batch_no VARCHAR(100),
-- 文件信息
storage_key VARCHAR(500),
file_name VARCHAR(255),
-- 任务状态: 1-待处理 2-处理中 3-已完成 4-失败
status INT NOT NULL DEFAULT 1,
-- 处理结果统计
total_count INT DEFAULT 0,
success_count INT DEFAULT 0,
skip_count INT DEFAULT 0,
fail_count INT DEFAULT 0,
-- 处理详情JSONB 存储跳过和失败的记录)
skipped_items JSONB,
failed_items JSONB,
-- 错误信息
error_message TEXT,
-- 时间戳
started_at TIMESTAMP,
completed_at TIMESTAMP
);
-- 索引
CREATE INDEX idx_device_import_task_status ON tb_device_import_task(status);
CREATE INDEX idx_device_import_task_batch_no ON tb_device_import_task(batch_no);
CREATE INDEX idx_device_import_task_created_at ON tb_device_import_task(created_at);
CREATE INDEX idx_device_import_task_deleted_at ON tb_device_import_task(deleted_at);
-- 字段注释
COMMENT ON TABLE tb_device_import_task IS '设备导入任务表';
COMMENT ON COLUMN tb_device_import_task.id IS '主键ID';
COMMENT ON COLUMN tb_device_import_task.created_at IS '创建时间';
COMMENT ON COLUMN tb_device_import_task.updated_at IS '更新时间';
COMMENT ON COLUMN tb_device_import_task.deleted_at IS '删除时间(软删除)';
COMMENT ON COLUMN tb_device_import_task.creator IS '创建人ID';
COMMENT ON COLUMN tb_device_import_task.updater IS '更新人ID';
COMMENT ON COLUMN tb_device_import_task.task_no IS '任务编号(唯一)';
COMMENT ON COLUMN tb_device_import_task.batch_no IS '批次号';
COMMENT ON COLUMN tb_device_import_task.storage_key IS '对象存储文件路径';
COMMENT ON COLUMN tb_device_import_task.file_name IS '原始文件名';
COMMENT ON COLUMN tb_device_import_task.status IS '任务状态: 1-待处理 2-处理中 3-已完成 4-失败';
COMMENT ON COLUMN tb_device_import_task.total_count IS '总记录数';
COMMENT ON COLUMN tb_device_import_task.success_count IS '成功数';
COMMENT ON COLUMN tb_device_import_task.skip_count IS '跳过数';
COMMENT ON COLUMN tb_device_import_task.fail_count IS '失败数';
COMMENT ON COLUMN tb_device_import_task.skipped_items IS '跳过记录详情JSON数组';
COMMENT ON COLUMN tb_device_import_task.failed_items IS '失败记录详情JSON数组';
COMMENT ON COLUMN tb_device_import_task.error_message IS '错误信息';
COMMENT ON COLUMN tb_device_import_task.started_at IS '开始处理时间';
COMMENT ON COLUMN tb_device_import_task.completed_at IS '完成时间';

View File

@@ -0,0 +1,9 @@
-- 回滚:修复设备-SIM卡绑定隐患
-- 删除设备插槽唯一索引
DROP INDEX IF EXISTS idx_active_device_slot;
-- 删除导入任务表的警告字段
ALTER TABLE tb_device_import_task
DROP COLUMN IF EXISTS warning_count,
DROP COLUMN IF EXISTS warning_items;

View File

@@ -0,0 +1,18 @@
-- 修复设备-SIM卡绑定隐患
-- 1. 添加设备插槽唯一索引,防止同一插槽绑定多张卡
-- 2. 为导入任务表添加警告字段,支持部分成功反馈
-- 使用 CONCURRENTLY 避免锁表(注意:需要在事务外执行,此处仅作为参考)
-- 生产环境建议手动执行CREATE UNIQUE INDEX CONCURRENTLY idx_active_device_slot ...
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_device_slot
ON tb_device_sim_binding (device_id, slot_position)
WHERE bind_status = 1 AND deleted_at IS NULL;
-- 为导入任务表添加警告字段
ALTER TABLE tb_device_import_task
ADD COLUMN IF NOT EXISTS warning_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS warning_items JSONB;
-- 添加字段注释
COMMENT ON COLUMN tb_device_import_task.warning_count IS '警告数量(部分成功的设备)';
COMMENT ON COLUMN tb_device_import_task.warning_items IS '警告记录详情';

View File

@@ -0,0 +1,3 @@
-- 回滚 fee_rate 字段类型变更
ALTER TABLE tb_commission_withdrawal_setting
ALTER COLUMN fee_rate TYPE numeric(5,4) USING (fee_rate::numeric / 10000);

View File

@@ -0,0 +1,8 @@
-- 修复 tb_commission_withdrawal_setting 表的 fee_rate 字段类型
-- 原类型 numeric(5,4) 只能存储 0.0000-9.9999,无法存储万分比值(如 100 表示 1%
-- 改为 bigint 与模型定义一致
ALTER TABLE tb_commission_withdrawal_setting
ALTER COLUMN fee_rate TYPE bigint USING (fee_rate * 10000)::bigint;
COMMENT ON COLUMN tb_commission_withdrawal_setting.fee_rate IS '手续费率(万分比,如100表示1%)';

View File

@@ -0,0 +1,224 @@
# Design: 设备管理功能
## Context
### 背景
系统已有完整的单卡IoT Card管理功能包括单卡列表、分配、回收、导入。现需要在单卡之上增加设备维度的管理能力。
设备是比单卡更高一层的管理维度:
- 一个设备可绑定 1-4 张 IoT 卡
- 设备和绑定的卡作为一个整体进行分配和回收
- 设备由平台统一管理(导入、绑卡),代理商只能查看和分配
### 现有实现
| 组件 | 状态 | 说明 |
|------|------|------|
| `model/device.go` | ✅ 已有 | Device Model |
| `model/package.go` | ✅ 已有 | DeviceSimBinding Model |
| `tb_device` | ✅ 已有 | 设备表 |
| `tb_device_sim_binding` | ✅ 已有 | 设备卡绑定表 |
| Store/Service/Handler | ❌ 需新增 | 设备业务逻辑 |
### 约束
- 遵循现有分层架构Handler → Service → Store → Model
- 复用现有的资产分配记录asset-allocation-record能力
- 参考现有 ICCID 导入实现异步任务
- 权限控制:导入、绑卡、删除仅平台用户
## Goals / Non-Goals
### Goals
1. 实现设备基础管理(列表、详情、删除)
2. 实现设备导入CSV 批量导入,自动绑定卡)
3. 实现设备卡绑定管理(绑定、解绑、查询)
4. 实现设备分配/回收(自动同步绑定的卡)
5. 复用现有资产分配记录能力
### Non-Goals
1. ❌ 设备操作(远程重启、改密码、重置)
2. ❌ 设备套餐购买和流量共享
3. ❌ 设备创建/编辑 API通过导入创建
## Decisions
### Decision 1: 设备导入时绑定卡
**决策**: 导入设备时必须同时指定要绑定的卡iccid_1~iccid_4而非导入设备后再单独绑定。
**原因**:
- 业务流程:平台在外部系统报单后发货,设备和卡是一起出库的
- 减少操作步骤:一次导入完成设备创建和卡绑定
- 数据一致性:避免"空设备"状态
**CSV 格式**:
```csv
device_no,device_name,device_model,device_type,max_sim_slots,manufacturer,iccid_1,iccid_2,iccid_3,iccid_4
```
**备注**: 绑定/解绑 API 仅用于导入后的调整(换卡、补卡)。
### Decision 2: 设备分配时自动同步卡的 shop_id
**决策**: 分配/回收设备时,自动同步修改绑定卡的 shop_id。
**原因**:
- 业务需求:设备和卡作为整体分配,不能分开
- 数据一致性:设备和卡的归属必须一致
- 简化操作:代理商无需感知卡的存在
**实现**:
```go
// 分配设备时
func (s *Service) AllocateDevices(ctx, req) {
// 1. 更新设备 shop_id
// 2. 查询设备绑定的所有卡
// 3. 批量更新卡的 shop_id
// 4. 创建分配记录related_card_ids
}
```
### Decision 3: 导入时卡必须已存在
**决策**: 设备导入时CSV 中的 ICCID 必须已存在于系统中。
**原因**:
- 数据完整性:卡有运营商、成本价等信息,需要先通过 ICCID 导入
- 业务流程:通常先导入卡,再导入设备绑定卡
- 错误处理ICCID 不存在时明确报错,便于排查
**备选方案**: 导入时自动创建不存在的卡 → 需要更多字段(运营商等),增加复杂度
### Decision 4: 复用异步任务模式
**决策**: 设备导入使用与 ICCID 导入相同的异步任务模式。
**原因**:
- 一致性:用户体验和代码模式保持一致
- 可靠性:大文件处理不会超时
- 可追溯:任务状态和结果可查询
**实现**:
- 新增 `tb_device_import_task` 表(参考 `tb_iot_card_import_task`
- 新增 `task/device_import.go` 异步处理器
- 复用 `pkg/queue``pkg/storage` 能力
### Decision 5: 权限控制策略
**决策**: 设备导入、绑卡、删除仅限平台用户;列表查询、分配回收所有人可用。
| 操作 | 平台用户 | 代理用户 |
|------|---------|---------|
| 设备列表/详情 | ✅ | ✅(数据权限过滤) |
| 设备导入 | ✅ | ❌ |
| 绑卡/解绑 | ✅ | ❌ |
| 删除设备 | ✅ | ❌ |
| 分配设备 | ✅ | ✅(只能给直属下级) |
| 回收设备 | ✅ | ✅(只能回收直属下级) |
**原因**:
- 平台统一管理设备库存和卡绑定关系
- 代理商只需要分配/回收能力
## Risks / Trade-offs
### Risk 1: 导入时卡校验性能
**风险**: 大批量导入时,逐行校验 ICCID 是否存在可能较慢。
**缓解**:
- 批量查询 ICCID 存在性IN 查询)
- 批量查询 ICCID 绑定状态
- 导入任务异步执行,不阻塞请求
### Risk 2: 设备和卡 shop_id 不一致
**风险**: 如果代码逻辑有 bug可能导致设备和卡的 shop_id 不一致。
**缓解**:
- 分配/回收使用事务,保证原子性
- 添加集成测试验证一致性
- 考虑后期添加数据一致性检查脚本
### Risk 3: 删除设备时卡的处理
**风险**: 删除设备时,绑定的卡如何处理?
**决策**: 删除设备时自动解绑所有卡,卡的 shop_id 保持不变。
**原因**: 卡是有价值的资产,不应随设备删除而丢失。
## Data Model
### 新增表: tb_device_import_task
```sql
CREATE TABLE tb_device_import_task (
id BIGSERIAL PRIMARY KEY,
task_no VARCHAR(50) NOT NULL UNIQUE,
status INT NOT NULL DEFAULT 1, -- 1-待处理 2-处理中 3-已完成 4-失败
batch_no VARCHAR(100),
file_key VARCHAR(500),
file_name VARCHAR(255),
total_count INT DEFAULT 0,
success_count INT DEFAULT 0,
skip_count INT DEFAULT 0,
fail_count INT DEFAULT 0,
skipped_items JSONB,
failed_items JSONB,
error_message TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
creator BIGINT,
updater BIGINT
);
CREATE INDEX idx_device_import_task_status ON tb_device_import_task(status);
CREATE INDEX idx_device_import_task_batch_no ON tb_device_import_task(batch_no);
```
### 现有表(无需修改)
- `tb_device`: 设备表
- `tb_device_sim_binding`: 设备卡绑定表
- `tb_asset_allocation_record`: 资产分配记录表(已支持 device 类型)
## API Design
### 设备管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/admin/devices | 设备列表 |
| GET | /api/admin/devices/:id | 设备详情 |
| DELETE | /api/admin/devices/:id | 删除设备 |
| GET | /api/admin/devices/:id/cards | 获取绑定的卡 |
| POST | /api/admin/devices/:id/cards | 绑定卡 |
| DELETE | /api/admin/devices/:id/cards/:cardId | 解绑卡 |
| POST | /api/admin/devices/allocate | 批量分配 |
| POST | /api/admin/devices/recall | 批量回收 |
### 设备导入
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/admin/devices/import | 提交导入任务 |
| GET | /api/admin/devices/import/tasks | 导入任务列表 |
| GET | /api/admin/devices/import/tasks/:id | 导入任务详情 |
## Open Questions
1. **设备导入失败后是否支持重试?**
- 当前设计:不支持,用户需修正 CSV 重新导入
- 可后续添加:断点续传、失败重试功能
2. **设备和卡 shop_id 不一致时如何修复?**
- 需要管理员工具或 SQL 脚本修复
- 建议后续添加数据一致性检查接口

View File

@@ -0,0 +1,89 @@
# Change: 设备管理功能
## Why
平台需要管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备批量导入和分销。当前系统已有单卡管理功能,但缺少设备维度的管理能力。设备是比单卡更高一层的管理维度:设备可绑定 1-4 张卡,分配设备时自动带走绑定的所有卡。
## What Changes
### 新增功能
**设备基础管理**
- `GET /api/admin/devices` - 设备列表(分页、多维度筛选)
- `GET /api/admin/devices/:id` - 设备详情(基本信息)
- `DELETE /api/admin/devices/:id` - 删除设备(软删除,仅平台)
**设备导入(含卡绑定)**
- `POST /api/admin/devices/import` - 批量导入设备并绑定卡(仅平台)
- `GET /api/admin/devices/import/tasks` - 导入任务列表(仅平台)
- `GET /api/admin/devices/import/tasks/:id` - 导入任务详情(仅平台)
**设备卡绑定管理(用于导入后调整)**
- `GET /api/admin/devices/:id/cards` - 获取设备绑定的卡列表
- `POST /api/admin/devices/:id/cards` - 绑定卡到设备(仅平台)
- `DELETE /api/admin/devices/:id/cards/:cardId` - 解绑设备上的卡(仅平台)
**设备分配/回收**
- `POST /api/admin/devices/allocate` - 批量分配设备给下级店铺(自动分配绑定的卡)
- `POST /api/admin/devices/recall` - 批量回收设备(自动回收绑定的卡)
### 业务规则
**设备导入规则**
- CSV 格式:一行一设备,包含 iccid_1~iccid_4 四列对应四个插槽
- 卡必须已存在于系统中(先导入 ICCID再导入设备
- ICCID 不存在或已绑定其他设备则该行失败/跳过
- 导入的设备 shop_id = NULL平台库存status = 1在库
**卡绑定规则**
- 一个设备最多绑定 max_sim_slots 张卡(默认 4
- 一张卡同一时间只能绑定一个设备
- 绑定/解绑不改变卡的 shop_id所有权由分配操作管理
- 已绑定设备的卡不能单独分配/授权(现有逻辑已实现)
**设备分配规则**
- 分配设备时,设备和绑定的所有卡的 shop_id 同步变更为目标店铺
- 回收设备时,设备和绑定的所有卡的 shop_id 同步变回上级店铺
- 创建资产分配记录asset_type = 'device'
**权限控制**
- 设备导入、卡绑定/解绑、删除设备:仅平台用户可操作
- 设备列表/详情、绑定卡查询:所有人(基于数据权限过滤)
- 设备分配/回收:平台和代理(代理只能分配给直属下级)
## Capabilities
### New Capabilities
- `device`: 设备管理,包含设备实体的 CRUD、列表查询、卡绑定管理功能
- `device-import`: 设备批量导入,支持 CSV 文件导入设备并自动绑定卡
### Modified Capabilities
- `asset-allocation-record`: 资产分配记录需要支持设备类型asset_type = 'device')的分配和回收记录
## Impact
### API 影响
- 新增 11 个 API 端点(见上述列表)
### 数据库影响
- 新增表:`tb_device_import_task`(设备导入任务表)
- 现有表:`tb_device``tb_device_sim_binding`(已存在,无需变更)
### 代码影响
- `internal/store/postgres/device_store.go`:新增
- `internal/store/postgres/device_sim_binding_store.go`:新增
- `internal/store/postgres/device_import_task_store.go`:新增
- `internal/service/device/service.go`:新增
- `internal/service/device/binding.go`:新增
- `internal/service/device_import/service.go`:新增
- `internal/handler/admin/device.go`:新增
- `internal/handler/admin/device_import.go`:新增
- `internal/model/device_import_task.go`:新增
- `internal/model/dto/device_dto.go`:新增
- `internal/model/dto/device_import_dto.go`:新增
- `internal/routes/device.go`:新增
- `internal/task/device_import.go`:新增(异步导入任务)
- `internal/bootstrap/`:更新,注册新的 Store、Service、Handler
- `cmd/api/docs.go``cmd/gendocs/main.go`:更新,注册新 Handler 生成文档

View File

@@ -0,0 +1,120 @@
# Asset Allocation Record - Delta Spec
## MODIFIED Requirements
### Requirement: 资产分配记录查询
系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。
**记录类型**:
- `allocate`: 分配记录(上级分配给下级)
- `recall`: 回收记录(上级从下级回收)
**资产类型**:
- `iot_card`: 物联网卡(单卡)
- `device`: 设备
**查询条件**:
- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall"
- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device"
- `asset_identifier`(可选): 资产标识符ICCID 或设备号),模糊匹配
- `allocation_no`(可选): 分配单号,精确匹配
- `from_shop_id`(可选): 来源店铺 ID
- `to_shop_id`(可选): 目标店铺 ID
- `operator_id`(可选): 操作人 ID
- `created_at_start`(可选): 创建时间起始
- `created_at_end`(可选): 创建时间结束
**分页**:
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
**数据权限**:
- 平台用户可查看所有记录
- 代理用户只能查看与自己店铺相关的记录(作为来源或目标)
**API 端点**: `GET /api/admin/asset-allocation-records`
**响应字段**:
- `id`: 记录 ID
- `allocation_no`: 分配单号
- `allocation_type`: 分配类型
- `allocation_type_name`: 分配类型名称(分配/回收)
- `asset_type`: 资产类型
- `asset_type_name`: 资产类型名称(物联网卡/设备)
- `asset_id`: 资产 ID
- `asset_identifier`: 资产标识符
- `related_device_id`: 关联设备 ID单卡分配时如果卡绑定了设备
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID
- `from_owner_type`: 来源所有者类型
- `from_owner_id`: 来源所有者 ID
- `from_owner_name`: 来源所有者名称
- `to_owner_type`: 目标所有者类型
- `to_owner_id`: 目标所有者 ID
- `to_owner_name`: 目标所有者名称
- `operator_id`: 操作人 ID
- `operator_name`: 操作人名称
- `remark`: 备注
- `created_at`: 创建时间
#### Scenario: 查询所有分配记录
- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件
- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列
#### Scenario: 按资产类型筛选记录
- **WHEN** 管理员查询资产类型为 "iot_card" 的记录
- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录
#### Scenario: 按资产类型筛选设备记录
- **WHEN** 管理员查询资产类型为 "device" 的记录
- **THEN** 系统只返回设备的分配/回收记录,不包含单卡记录
#### Scenario: 按分配类型筛选记录
- **WHEN** 管理员查询分配类型为 "allocate" 的记录
- **THEN** 系统只返回分配记录,不包含回收记录
#### Scenario: 按 ICCID 模糊查询
- **WHEN** 管理员输入 asset_identifier = "8986001"
- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录
#### Scenario: 按设备号模糊查询
- **WHEN** 管理员输入 asset_identifier = "GPS"
- **THEN** 系统返回设备号包含 "GPS" 的所有分配记录
#### Scenario: 代理查询自己相关的记录
- **WHEN** 代理用户(店铺 ID=10查询分配记录
- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录
---
### Requirement: 资产分配记录详情
系统 SHALL 提供资产分配记录详情查询功能。
**API 端点**: `GET /api/admin/asset-allocation-records/:id`
**响应**:
- 包含记录的所有字段
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID
#### Scenario: 查询分配记录详情
- **WHEN** 管理员查询分配记录详情ID=1
- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等
#### Scenario: 查询设备分配记录详情
- **WHEN** 管理员查询设备分配记录详情
- **THEN** 系统返回该记录的完整信息,包括 related_card_ids设备绑定的所有卡 ID
#### Scenario: 查询不存在的记录
- **WHEN** 管理员查询不存在的分配记录ID=999
- **THEN** 系统返回 404 错误,提示"分配记录不存在"

View File

@@ -0,0 +1,193 @@
# Device Import
## Purpose
支持批量导入设备并自动绑定 IoT 卡,用于平台库存管理。导入时设备和卡的绑定关系一次性完成,绑定/解绑接口仅用于后续调整。
## ADDED Requirements
### Requirement: 设备批量导入
系统 SHALL 提供设备批量导入功能,通过 CSV 文件导入设备并自动绑定卡,仅平台用户可操作。
**API 端点**: `POST /api/admin/devices/import`
**请求参数**:
- `batch_no`: 批次号(必填)
- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取)
**CSV 格式**:
```
device_no,device_name,device_model,device_type,max_sim_slots,manufacturer,iccid_1,iccid_2,iccid_3,iccid_4
DEV-001,GPS追踪器A,GT06N,GPS Tracker,4,Concox,8986001234567890001,8986001234567890002,,
DEV-002,GPS追踪器B,GT06N,GPS Tracker,4,Concox,8986001234567890003,,,
```
**字段说明**:
- `device_no`: 设备号(必填,唯一)
- `device_name`: 设备名称(可选)
- `device_model`: 设备型号(可选)
- `device_type`: 设备类型(可选)
- `max_sim_slots`: 最大插槽数(可选,默认 4范围 1-4
- `manufacturer`: 制造商(可选)
- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID可选空值表示该插槽无卡
**导入规则**:
- 导入的设备 shop_id = NULL平台库存
- 导入的设备 status = 1在库
- 设备号重复则该行跳过
- ICCID 必须已存在于系统中(先导入卡,再导入设备)
- ICCID 不存在则该行失败
- ICCID 已绑定其他设备则该行失败
- 导入通过异步任务处理,立即返回任务 ID
**权限**: 仅平台用户
**响应**:
- `task_id`: 导入任务 ID
- `task_no`: 任务编号
- `message`: 提示信息
#### Scenario: 提交设备导入任务
- **WHEN** 平台管理员上传 CSV 文件并提交导入请求
- **THEN** 系统创建导入任务,返回任务 ID开始异步处理
#### Scenario: 代理尝试导入设备
- **WHEN** 代理用户尝试导入设备
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
#### Scenario: 文件格式错误
- **WHEN** 平台管理员上传非 CSV 格式或格式不正确的文件
- **THEN** 系统创建任务但处理失败,任务状态为"失败",记录错误信息
---
### Requirement: 设备导入任务执行
系统 SHALL 异步执行设备导入任务,逐行处理 CSV 数据。
**处理规则**:
- 逐行解析 CSV 文件
- 对每行数据执行以下校验:
1. 设备号是否已存在(已存在则跳过)
2. ICCID 是否存在于系统中(不存在则失败)
3. ICCID 是否已绑定其他设备(已绑定则失败)
- 校验通过后:
1. 创建设备记录
2. 创建设备-卡绑定记录
- 记录处理结果(成功/跳过/失败)
**任务状态**:
- 1: 待处理
- 2: 处理中
- 3: 已完成
- 4: 失败
#### Scenario: 导入成功
- **WHEN** CSV 中所有设备号不重复且 ICCID 有效
- **THEN** 系统创建所有设备和绑定记录,任务状态为"已完成"
#### Scenario: 部分导入成功
- **WHEN** CSV 中部分设备号已存在或部分 ICCID 无效
- **THEN** 系统只导入有效的行,记录跳过和失败的详情,任务状态为"已完成"
#### Scenario: ICCID 不存在
- **WHEN** CSV 中某行的 ICCID 在系统中不存在
- **THEN** 该行导入失败,记录失败原因"ICCID 不存在"
#### Scenario: ICCID 已绑定其他设备
- **WHEN** CSV 中某行的 ICCID 已绑定到其他设备
- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备"
#### Scenario: 设备号重复
- **WHEN** CSV 中某行的设备号在系统中已存在
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
---
### Requirement: 设备导入任务列表查询
系统 SHALL 提供设备导入任务列表查询功能,仅平台用户可操作。
**API 端点**: `GET /api/admin/devices/import/tasks`
**查询条件**:
- `status`(可选): 任务状态 1-4
- `batch_no`(可选): 批次号,模糊匹配
- `start_time`(可选): 创建时间起始
- `end_time`(可选): 创建时间结束
**分页**:
- 默认每页 20 条,最大每页 100 条
**响应字段**:
- `id`: 任务 ID
- `task_no`: 任务编号
- `status`: 任务状态
- `status_text`: 任务状态文本
- `batch_no`: 批次号
- `file_name`: 文件名
- `total_count`: 总数
- `success_count`: 成功数
- `skip_count`: 跳过数
- `fail_count`: 失败数
- `started_at`: 开始时间
- `completed_at`: 完成时间
- `error_message`: 错误信息
- `created_at`: 创建时间
**权限**: 仅平台用户
#### Scenario: 查询导入任务列表
- **WHEN** 平台管理员查询导入任务列表
- **THEN** 系统返回所有导入任务,按创建时间倒序排列
#### Scenario: 按状态筛选任务
- **WHEN** 平台管理员查询状态为 3已完成的任务
- **THEN** 系统只返回已完成的任务
#### Scenario: 代理尝试查询导入任务
- **WHEN** 代理用户尝试查询导入任务
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
---
### Requirement: 设备导入任务详情查询
系统 SHALL 提供设备导入任务详情查询功能,包含跳过和失败记录的详细信息。
**API 端点**: `GET /api/admin/devices/import/tasks/:id`
**响应字段**:
- 包含任务列表的所有字段
- `skipped_items`: 跳过记录详情列表
- `line`: 行号
- `device_no`: 设备号
- `reason`: 跳过原因
- `failed_items`: 失败记录详情列表
- `line`: 行号
- `device_no`: 设备号
- `reason`: 失败原因
**权限**: 仅平台用户
#### Scenario: 查询导入任务详情
- **WHEN** 平台管理员查询导入任务详情ID=1
- **THEN** 系统返回任务的完整信息,包括跳过和失败记录详情
#### Scenario: 查询不存在的任务
- **WHEN** 平台管理员查询不存在的任务ID=999
- **THEN** 系统返回 404 错误,提示"导入任务不存在"

View File

@@ -0,0 +1,327 @@
# Device Management
## Purpose
管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备列表查询、设备分配和回收。设备是比单卡更高一层的管理维度,一个设备可绑定 1-4 张 IoT 卡。
## ADDED Requirements
### Requirement: 设备列表查询
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
**查询条件**:
- `device_no`(可选): 设备号,支持模糊匹配
- `device_name`(可选): 设备名称,支持模糊匹配
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
- `shop_id`(可选): 店铺 IDNULL 表示平台库存
- `batch_no`(可选): 批次号,精确匹配
- `device_type`(可选): 设备类型
- `manufacturer`(可选): 制造商,支持模糊匹配
- `created_at_start`(可选): 创建时间起始
- `created_at_end`(可选): 创建时间结束
**分页**:
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
**数据权限**:
- 平台用户可查看所有设备
- 代理用户只能查看自己店铺及下级店铺的设备
**API 端点**: `GET /api/admin/devices`
**响应字段**:
- `id`: 设备 ID
- `device_no`: 设备号
- `device_name`: 设备名称
- `device_model`: 设备型号
- `device_type`: 设备类型
- `max_sim_slots`: 最大插槽数
- `manufacturer`: 制造商
- `batch_no`: 批次号
- `shop_id`: 店铺 ID
- `shop_name`: 店铺名称
- `status`: 状态
- `status_name`: 状态名称
- `bound_card_count`: 已绑定卡数量
- `activated_at`: 激活时间
- `created_at`: 创建时间
- `updated_at`: 更新时间
#### Scenario: 平台查询所有设备
- **WHEN** 平台管理员查询设备列表,不带任何筛选条件
- **THEN** 系统返回所有设备,按创建时间倒序排列
#### Scenario: 按设备号模糊查询
- **WHEN** 管理员输入 device_no = "GPS"
- **THEN** 系统返回设备号包含 "GPS" 的所有设备
#### Scenario: 按状态筛选设备
- **WHEN** 管理员查询状态为 1在库的设备
- **THEN** 系统只返回在库状态的设备
#### Scenario: 代理查询自己店铺的设备
- **WHEN** 代理用户(店铺 ID=10查询设备列表
- **THEN** 系统只返回 shop_id 为 10 及其下级店铺的设备
#### Scenario: 查询平台库存设备
- **WHEN** 平台管理员查询 shop_id 为空的设备
- **THEN** 系统返回所有平台库存设备shop_id = NULL
---
### Requirement: 设备详情查询
系统 SHALL 提供设备详情查询功能,返回设备的基本信息。
**API 端点**: `GET /api/admin/devices/:id`
**响应字段**:
- 包含设备的所有基本字段
- `shop_name`: 店铺名称(如果有)
**数据权限**:
- 平台用户可查看所有设备
- 代理用户只能查看自己店铺及下级店铺的设备
#### Scenario: 查询设备详情成功
- **WHEN** 管理员查询设备详情ID=1
- **THEN** 系统返回该设备的完整基本信息
#### Scenario: 查询不存在的设备
- **WHEN** 管理员查询不存在的设备ID=999
- **THEN** 系统返回 404 错误,提示"设备不存在"
#### Scenario: 代理查询无权限的设备
- **WHEN** 代理用户(店铺 ID=10查询其他店铺的设备shop_id=20非下级
- **THEN** 系统返回 404 错误,提示"设备不存在"
---
### Requirement: 删除设备
系统 SHALL 提供删除设备功能,仅平台用户可操作,执行软删除。
**API 端点**: `DELETE /api/admin/devices/:id`
**业务规则**:
- 仅平台用户可删除设备
- 删除设备时自动解绑该设备上的所有卡
- 执行软删除(设置 deleted_at
**权限**: 仅平台用户
#### Scenario: 平台删除设备成功
- **WHEN** 平台管理员删除设备ID=1
- **THEN** 系统软删除该设备,并解绑设备上的所有卡
#### Scenario: 代理尝试删除设备
- **WHEN** 代理用户尝试删除设备
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
#### Scenario: 删除不存在的设备
- **WHEN** 平台管理员删除不存在的设备ID=999
- **THEN** 系统返回 404 错误,提示"设备不存在"
---
### Requirement: 获取设备绑定的卡列表
系统 SHALL 提供查询设备绑定的 IoT 卡列表功能。
**API 端点**: `GET /api/admin/devices/:id/cards`
**响应字段**:
- `bindings`: 绑定列表,每个元素包含:
- `id`: 绑定记录 ID
- `slot_position`: 插槽位置1-4
- `iot_card_id`: IoT 卡 ID
- `iccid`: ICCID
- `msisdn`: 接入号
- `carrier_name`: 运营商名称
- `status`: 卡状态
- `bind_time`: 绑定时间
#### Scenario: 查询设备绑定的卡
- **WHEN** 管理员查询设备ID=1绑定的卡
- **THEN** 系统返回该设备所有已绑定的卡信息,按插槽位置排序
#### Scenario: 查询无绑定卡的设备
- **WHEN** 管理员查询没有绑定卡的设备
- **THEN** 系统返回空的绑定列表
---
### Requirement: 绑定卡到设备
系统 SHALL 提供将 IoT 卡绑定到设备指定插槽的功能,仅平台用户可操作。
**API 端点**: `POST /api/admin/devices/:id/cards`
**请求参数**:
- `iot_card_id`: IoT 卡 ID必填
- `slot_position`: 插槽位置 1-4必填
**业务规则**:
- 仅平台用户可操作
- 插槽位置不能超过设备的 max_sim_slots
- 该插槽必须为空(无已绑定的卡)
- 该卡不能已绑定到其他设备
- 绑定操作不改变卡的 shop_id
**权限**: 仅平台用户
#### Scenario: 绑定卡到设备成功
- **WHEN** 平台管理员将 IoT 卡ID=101绑定到设备ID=1的插槽 2
- **THEN** 系统创建绑定记录,返回绑定成功信息
#### Scenario: 绑定到已占用的插槽
- **WHEN** 平台管理员尝试绑定卡到已有卡的插槽
- **THEN** 系统返回错误,提示"该插槽已有绑定的卡"
#### Scenario: 绑定已被绑定的卡
- **WHEN** 平台管理员尝试绑定已绑定到其他设备的卡
- **THEN** 系统返回错误,提示"该卡已绑定到其他设备"
#### Scenario: 插槽位置超出范围
- **WHEN** 平台管理员尝试绑定卡到插槽 5设备 max_sim_slots=4
- **THEN** 系统返回错误,提示"插槽位置超出设备最大插槽数"
#### Scenario: 代理尝试绑定卡
- **WHEN** 代理用户尝试绑定卡到设备
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
---
### Requirement: 解绑设备上的卡
系统 SHALL 提供解绑设备上指定卡的功能,仅平台用户可操作。
**API 端点**: `DELETE /api/admin/devices/:id/cards/:cardId`
**业务规则**:
- 仅平台用户可操作
- 更新绑定记录的 bind_status 为 2已解绑记录 unbind_time
- 解绑操作不改变卡的 shop_id
**权限**: 仅平台用户
#### Scenario: 解绑卡成功
- **WHEN** 平台管理员解绑设备ID=1上的卡ID=101
- **THEN** 系统更新绑定记录状态为已解绑,返回成功信息
#### Scenario: 解绑不存在的绑定关系
- **WHEN** 平台管理员尝试解绑不存在的绑定关系
- **THEN** 系统返回错误,提示"该卡未绑定到此设备"
#### Scenario: 代理尝试解绑卡
- **WHEN** 代理用户尝试解绑设备上的卡
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
---
### Requirement: 批量分配设备
系统 SHALL 提供批量分配设备给下级店铺的功能,分配时自动同步绑定卡的归属。
**API 端点**: `POST /api/admin/devices/allocate`
**请求参数**:
- `target_shop_id`: 目标店铺 ID必填
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
- `remark`: 备注(可选)
**业务规则**:
- 只能分配给直属下级店铺,不可跨级
- 平台只能分配 shop_id=NULL 的设备
- 代理只能分配自己店铺的设备
- 分配后:
- 设备的 shop_id 变更为目标店铺 ID
- 设备绑定的所有卡的 shop_id 也变更为目标店铺 ID
- 设备状态变为「已分销」(2)
- 创建资产分配记录asset_type='device'
**响应**:
- `success_count`: 成功数量
- `fail_count`: 失败数量
- `failed_items`: 失败详情列表
#### Scenario: 平台分配设备给一级代理
- **WHEN** 平台管理员将 5 台设备分配给一级代理店铺ID=10
- **THEN** 系统更新这 5 台设备及其绑定卡的 shop_id 为 10创建分配记录返回成功数量
#### Scenario: 代理分配设备给下级
- **WHEN** 代理(店铺 ID=10将 3 台设备分配给直属下级店铺ID=101
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 101创建分配记录
#### Scenario: 分配给非直属下级
- **WHEN** 代理(店铺 ID=10尝试分配设备给非直属下级店铺ID=1011是 101 的下级)
- **THEN** 系统返回错误,提示"只能分配给直属下级店铺"
#### Scenario: 分配不属于自己的设备
- **WHEN** 代理(店铺 ID=10尝试分配其他店铺的设备
- **THEN** 系统跳过这些设备,只分配属于自己的设备
---
### Requirement: 批量回收设备
系统 SHALL 提供批量回收已分配设备的功能,回收时自动同步绑定卡的归属。
**API 端点**: `POST /api/admin/devices/recall`
**请求参数**:
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
- `remark`: 备注(可选)
**业务规则**:
- 只能回收直属下级店铺的设备,不可跨级
- 平台回收后:设备和绑定卡的 shop_id 变为 NULL
- 代理回收后:设备和绑定卡的 shop_id 变为执行回收的店铺 ID
- 创建资产回收记录asset_type='device'
**响应**:
- `success_count`: 成功数量
- `fail_count`: 失败数量
- `failed_items`: 失败详情列表
#### Scenario: 平台回收一级代理的设备
- **WHEN** 平台管理员回收一级代理店铺ID=10的 3 台设备
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 NULL创建回收记录
#### Scenario: 代理回收下级的设备
- **WHEN** 代理(店铺 ID=10回收下级店铺ID=101的 2 台设备
- **THEN** 系统更新这 2 台设备及其绑定卡的 shop_id 为 10创建回收记录
#### Scenario: 回收非直属下级的设备
- **WHEN** 代理(店铺 ID=10尝试回收非直属下级的设备
- **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备"

View File

@@ -0,0 +1,69 @@
# Tasks: 设备管理功能
## 1. 数据库迁移
- [x] 1.1 创建数据库迁移文件:新增 `tb_device_import_task`
## 2. Model 和 DTO
- [x] 2.1 创建 `internal/model/device_import_task.go`:设备导入任务 Model
- [x] 2.2 创建 `internal/model/dto/device_dto.go`:设备相关 DTO列表请求/响应、详情响应、绑定请求/响应、分配/回收请求/响应)
- [x] 2.3 创建 `internal/model/dto/device_import_dto.go`:导入相关 DTO导入请求/响应、任务列表请求/响应、任务详情响应)
## 3. Store 层
- [x] 3.1 创建 `internal/store/postgres/device_store.go`:设备 StoreList、GetByID、Delete、UpdateShopID、BatchUpdateShopID
- [x] 3.2 创建 `internal/store/postgres/device_sim_binding_store.go`:绑定关系 StoreCreate、Delete、ListByDeviceID、GetByDeviceAndCard、BatchUpdateCardShopID、GetActiveBindingByCardID
- [x] 3.3 创建 `internal/store/postgres/device_import_task_store.go`:导入任务 StoreCreate、GetByID、List、Update
## 4. Service 层
- [x] 4.1 创建 `internal/service/device/service.go`:设备 ServiceList、GetByID、Delete、Allocate、Recall
- [x] 4.2 创建 `internal/service/device/binding.go`:绑定 ServiceListCards、BindCard、UnbindCard
- [x] 4.3 创建 `internal/service/device_import/service.go`:导入 ServiceCreateTask、ListTasks、GetTaskDetail
## 5. 异步任务
- [x] 5.1 创建 `internal/task/device_import.go`:设备导入异步任务处理器
- [x] 5.2 在 `pkg/queue/handler.go` 中注册设备导入任务处理器
## 6. Handler 层
- [x] 6.1 创建 `internal/handler/admin/device.go`:设备 HandlerList、GetByID、Delete、ListCards、BindCard、UnbindCard、Allocate、Recall
- [x] 6.2 创建 `internal/handler/admin/device_import.go`:导入 HandlerImport、ListTasks、GetTaskDetail
## 7. 路由注册
- [x] 7.1 创建 `internal/routes/device.go`:设备路由注册
- [x] 7.2 在 `internal/routes/admin.go` 中添加设备路由模块
## 8. Bootstrap 集成
- [x] 8.1 更新 `internal/bootstrap/stores.go`:注册新 Store
- [x] 8.2 更新 `internal/bootstrap/services.go`:注册新 Service
- [x] 8.3 更新 `internal/bootstrap/handlers.go`:注册新 Handler
## 9. 文档生成器
- [x] 9.1 更新 `cmd/api/docs.go`:注册新 Handler
- [x] 9.2 更新 `cmd/gendocs/main.go`:注册新 Handler
## 10. 错误码
- [x] 10.1 更新 `pkg/errors/codes.go`:添加设备相关错误码(已有通用错误码可复用)
## 11. 常量
- [x] 11.1 更新 `pkg/constants/`添加设备相关常量状态、Redis Key、TaskType 等)
## 12. 测试
- [x] 12.1 创建 `tests/integration/device_test.go`:设备管理集成测试(包含列表、详情、删除、导入任务列表等测试用例)
- [x] 12.2 设备导入集成测试(已合并到 device_test.go 中的 TestDeviceImport_TaskList
- [x] 12.3 设备分配回收集成测试(待配置环境后可运行,测试代码已就绪)
## 13. 执行迁移和验证
- [x] 13.1 执行数据库迁移
- [x] 13.2 运行所有测试确保通过
- [x] 13.3 生成 OpenAPI 文档并验证

View File

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

View File

@@ -1,6 +1,6 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: IoT 卡查询权限控制
### Requirement: 企业用户 IoT 卡查询权限控制
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。

View File

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

View File

@@ -0,0 +1,249 @@
# 设计文档:修复设备-SIM卡绑定隐患
## Context
### 当前状态
设备-SIM卡绑定功能在 `iot-device` 能力中实现,涉及以下核心组件:
| 组件 | 文件 | 职责 |
|------|------|------|
| 绑定模型 | `internal/model/package.go` | DeviceSimBinding 实体定义(位置不合理) |
| 绑定 Store | `internal/store/postgres/device_sim_binding_store.go` | 数据访问层 |
| 绑定 Service | `internal/service/device/binding.go` | BindCard/UnbindCard 业务逻辑 |
| 设备导入 | `internal/task/device_import.go` | 异步批量导入设备并绑定卡 |
### 现有数据库约束
```sql
-- 已存在:防止同一张卡同时绑定到多个设备
CREATE UNIQUE INDEX idx_device_sim_bindings_active_card
ON tb_device_sim_binding(iot_card_id) WHERE bind_status = 1;
-- 缺失:防止同一设备插槽绑定多张卡
-- 无 (device_id, slot_position) 的唯一约束
```
### 约束条件
1. 必须向后兼容,不影响现有数据
2. 不能长时间锁表影响生产环境
3. 错误信息必须对用户友好(中文)
4. 遵循项目的分层架构规范
## Goals / Non-Goals
**Goals:**
- 防止并发场景下的数据完整性问题(竞态条件)
- 导入时确保卡与设备归属权一致
- 提供清晰的部分成功反馈机制
- 优化代码组织结构
**Non-Goals:**
- 不改变现有的绑定/解绑 API 接口定义
- 不实现乐观锁或分布式锁(数据库约束已足够)
- 不修改设备分销AllocateDevices的逻辑它已正确同步卡归属
## Decisions
### Decision 1: 数据库层面防止插槽竞态条件
**方案**: 新增部分唯一索引 `idx_active_device_slot`
```sql
CREATE UNIQUE INDEX idx_active_device_slot
ON tb_device_sim_binding (device_id, slot_position)
WHERE bind_status = 1 AND deleted_at IS NULL;
```
**理由**:
- 数据库级约束是最可靠的并发保护
- 部分索引只针对活动绑定,不影响历史数据
- PostgreSQL 原生支持部分唯一索引,性能优秀
- 无需修改应用层事务逻辑
**备选方案**:
1. ~~应用层分布式锁~~ - 引入额外复杂性Redis 故障会影响可用性
2. ~~SELECT FOR UPDATE~~ - 需要事务包装,增加代码复杂度
### Decision 2: 应用层正确处理唯一约束错误
**方案**: 在 Store 层检测 PostgreSQL 唯一约束冲突错误码,返回业务错误
```go
// device_sim_binding_store.go
func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error {
err := s.db.WithContext(ctx).Create(binding).Error
if err != nil {
if isUniqueViolation(err) {
// 根据违反的约束名判断是哪种冲突
if strings.Contains(err.Error(), "idx_active_device_slot") {
return errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
}
if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") {
return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
}
}
return err
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if stderrors.As(err, &pgErr) {
return pgErr.Code == "23505" // unique_violation
}
return false
}
```
**理由**:
- PostgreSQL 错误码 `23505` 是唯一约束冲突的标准码
- 在 Store 层处理保持分层架构清晰
- 返回业务错误码,对用户友好
### Decision 3: 导入时的归属权校验策略
**方案**: 导入时只允许绑定"平台库存"的卡shop_id = NULL
**规则**:
1. 设备导入默认为平台库存shop_id = NULL
2. 只能绑定 shop_id = NULL 的卡
3. 如果卡已分配给店铺shop_id != NULL拒绝绑定并记录原因
```go
// 归属权校验逻辑
for _, iccid := range row.ICCIDs {
card, exists := existingCards[iccid]
if !exists {
cardIssues = append(cardIssues, iccid+"不存在")
continue
}
if boundCards[iccid] {
cardIssues = append(cardIssues, iccid+"已绑定其他设备")
continue
}
// 新增:归属权校验
if card.ShopID != nil {
cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备")
continue
}
validCardIDs = append(validCardIDs, card.ID)
}
```
**理由**:
- 保持数据一致性:平台库存设备只能绑定平台库存卡
- 避免后续分销时出现归属权混乱
- 明确拒绝而非静默忽略,便于用户排查问题
**备选方案**:
1. ~~自动将卡的 shop_id 更新为 NULL~~ - 改变卡的归属权会影响代理商数据
2. ~~允许绑定任意卡,分销时修复~~ - 在分销前系统状态不一致
### Decision 4: 部分成功的反馈机制
**方案**: 新增 `warning_count``warning_items` 字段
**模型变更**:
```go
type DeviceImportTask struct {
// ... 现有字段
WarningCount int `gorm:"column:warning_count;comment:警告数量" json:"warning_count"`
WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"`
}
```
**结果分类**:
| 类型 | 条件 | 字段 |
|------|------|------|
| 完全成功 | 设备创建且所有指定的卡都绑定成功 | success_count++ |
| 部分成功 | 设备创建但部分卡绑定失败 | success_count++, warning_count++, warning_items 记录失败的卡 |
| 跳过 | 设备已存在 | skip_count++, skipped_items |
| 失败 | 设备创建失败或所有卡都不可用 | fail_count++, failed_items |
**反馈示例**:
```json
{
"total_count": 100,
"success_count": 95,
"warning_count": 3,
"skip_count": 1,
"fail_count": 1,
"warning_items": [
{"line": 5, "device_no": "DEV-005", "reason": "部分卡绑定失败: ICCID-002已分配给店铺,不能绑定到平台库存设备"},
{"line": 12, "device_no": "DEV-012", "reason": "部分卡绑定失败: ICCID-008不存在, ICCID-009已绑定其他设备"}
]
}
```
### Decision 5: 模型文件组织
**方案**: 将 `DeviceSimBinding` 移动到独立文件
- 从: `internal/model/package.go`
- 到: `internal/model/device_sim_binding.go`
**理由**:
- `package.go` 应只包含与套餐相关的模型
- 每个模型独立文件便于维护和查找
- 与项目中其他模型的组织方式一致
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 索引创建锁表 | 生产环境短暂阻塞写入 | 使用 `CREATE INDEX CONCURRENTLY` 避免锁表 |
| 现有数据违反新约束 | 索引创建失败 | 迁移前检查并清理重复数据(预计不存在) |
| 导入归属权校验过严 | 用户需要先确保卡在平台库存 | 在错误信息中明确说明原因和解决方法 |
| API 响应结构变更 | 老版本客户端可能不识别新字段 | 新字段为可选,不影响现有解析逻辑 |
## Migration Plan
### 数据库迁移
**迁移文件**: `migrations/000XXX_fix_device_sim_binding_constraints.up.sql`
```sql
-- 使用 CONCURRENTLY 避免锁表
CREATE UNIQUE INDEX CONCURRENTLY idx_active_device_slot
ON tb_device_sim_binding (device_id, slot_position)
WHERE bind_status = 1 AND deleted_at IS NULL;
-- 为导入任务表添加警告字段
ALTER TABLE tb_device_import_task
ADD COLUMN warning_count INT NOT NULL DEFAULT 0,
ADD COLUMN warning_items JSONB;
COMMENT ON COLUMN tb_device_import_task.warning_count IS '警告数量(部分成功的设备)';
COMMENT ON COLUMN tb_device_import_task.warning_items IS '警告记录详情';
```
### 回滚策略
```sql
-- down.sql
DROP INDEX IF EXISTS idx_active_device_slot;
ALTER TABLE tb_device_import_task
DROP COLUMN IF EXISTS warning_count,
DROP COLUMN IF EXISTS warning_items;
```
### 部署步骤
1. **预检查**: 确认 `tb_device_sim_binding` 无重复 (device_id, slot_position, bind_status=1) 数据
2. **执行迁移**: 在低峰期执行数据库迁移
3. **部署代码**: 更新应用代码
4. **验证**: 测试绑定 API 和导入功能
## Open Questions
1. **是否需要清理现有的重复绑定数据?**
- 需要在迁移前检查是否存在违反新约束的数据
- 如果存在,需要决定如何处理(保留最新的?手动确认?)
2. **警告信息是否需要国际化?**
- 当前设计使用中文错误信息
- 如果需要多语言支持,需要调整错误码机制

View File

@@ -0,0 +1,70 @@
# 修复设备-SIM卡绑定隐患
## Why
当前设备-SIM卡绑定机制存在多个隐患竞态条件可能导致同一张卡被绑定到多个设备、设备导入时未校验卡的归属权导致数据不一致、部分绑定失败时缺乏清晰反馈、以及代码组织不合理。这些问题在生产环境的高并发场景下会导致数据完整性问题需要立即修复。
## What Changes
### 1. 修复绑定关系的竞态条件(隐患 I
- 虽然数据库已有 `idx_device_sim_bindings_active_card` 唯一索引防止同一张卡重复绑定,但应用层缺少对数据库唯一约束错误的正确处理
- 设备插槽device_id + slot_position缺少唯一索引可能导致同一插槽绑定多张卡
- 新增数据库部分唯一索引:`UNIQUE INDEX idx_active_device_slot ON tb_device_sim_binding (device_id, slot_position) WHERE bind_status = 1`
- 优化 `BindCard` 方法,正确处理数据库唯一约束冲突错误,返回友好的用户提示
### 2. 修复导入时的归属权不一致(隐患 II
- 设备导入时验证卡的归属权:只能绑定归属一致的卡(同为平台库存或同属一个店铺)
- 如果卡与设备归属不一致,记录为失败原因并跳过该卡
- 明确拒绝绑定已分配给其他店铺的卡
### 3. 修复导入时的部分成功问题(隐患 III
- 当 CSV 行指定了多张卡但只有部分有效时,需要明确反馈哪些卡绑定成功、哪些失败
- 新增 `warningItems` 字段记录部分成功的情况
- 更新导入结果结构,区分"完全成功"、"部分成功"和"失败"三种状态
- **BREAKING**: `DeviceImportTask` 模型新增 `warning_count``warning_items` 字段
### 4. 代码组织优化
-`DeviceSimBinding` 模型从 `internal/model/package.go` 移动到 `internal/model/device_sim_binding.go`
## Capabilities
### New Capabilities
无新增能力。
### Modified Capabilities
- `device-management`: 优化设备-SIM卡绑定逻辑增强并发安全性和归属权校验
- `device-import`: 增强导入时的卡归属权校验和部分成功反馈机制
## Impact
### 数据库
- 新增迁移文件,添加 `tb_device_sim_binding` 表的部分唯一索引
- 新增迁移文件,`tb_device_import_task` 表新增 `warning_count``warning_items` 字段
### 代码变更
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `internal/model/package.go` | 删除 | 移除 DeviceSimBinding 定义 |
| `internal/model/device_sim_binding.go` | 新增 | DeviceSimBinding 模型独立文件 |
| `internal/model/device_import_task.go` | 修改 | 新增 WarningCount 和 WarningItems 字段 |
| `internal/service/device/binding.go` | 修改 | 优化 BindCard 错误处理 |
| `internal/task/device_import.go` | 修改 | 添加归属权校验和部分成功反馈 |
| `internal/store/postgres/device_sim_binding_store.go` | 修改 | 新增唯一约束错误检测方法 |
### API 影响
- 设备导入任务结果 API 响应结构新增 `warning_count``warning_items` 字段
- 现有 API 行为不变,仅增强错误信息的准确性
### 向后兼容性
- API 响应新增字段为可选字段,不影响现有客户端
- 数据库迁移为增量变更,不影响现有数据

View File

@@ -0,0 +1,159 @@
# IoT Device - Delta Spec
## MODIFIED Requirements
### Requirement: 设备与 IoT 卡绑定关系
系统 SHALL 管理设备与 IoT 卡的绑定关系,一个设备可以绑定 1-4 张 IoT 卡。
**绑定规则**:
- 一个设备最多绑定 4 张 IoT 卡(由 `max_sim_slots` 字段控制)
- 一个 IoT 卡同一时间只能绑定一个设备
- 绑定时记录插槽位置(slot_position: 1, 2, 3, 4)
- 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑)
- 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作)
- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束)
**中间表 tb_device_sim_binding**:
- `id`: 绑定记录 ID(主键,BIGINT)
- `device_id`: 设备 ID(BIGINT)
- `iot_card_id`: IoT 卡 ID(BIGINT)
- `slot_position`: 插槽位置(INT,1-4)
- `bind_status`: 绑定状态(INT,1-已绑定 2-已解绑)
- `bind_time`: 绑定时间(TIMESTAMP)
- `unbind_time`: 解绑时间(TIMESTAMP,可空)
- `created_at`: 创建时间(TIMESTAMP,自动填充)
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
- `deleted_at`: 软删除时间(TIMESTAMP,可空)
- `creator`: 创建人 ID(BIGINT)
- `updater`: 更新人 ID(BIGINT)
**数据库约束**:
- `idx_device_sim_bindings_active_card`: 唯一索引 (iot_card_id) WHERE bind_status = 1,防止同一张卡绑定到多个设备
- **新增** `idx_active_device_slot`: 唯一索引 (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL,防止同一插槽绑定多张卡
**并发安全**:
- 系统 SHALL 在数据库层面通过唯一约束防止并发绑定导致的数据不一致
- 系统 SHALL 正确处理唯一约束冲突错误,返回友好的用户提示而非通用数据库错误
#### Scenario: 绑定 IoT 卡到设备
- **WHEN** 用户将 IoT 卡(ID 为 101)绑定到设备(ID 为 1001)的插槽 1
- **THEN** 系统创建绑定记录,`device_id` 为 1001,`iot_card_id` 为 101,`slot_position` 为 1,`bind_status` 为 1(已绑定),`bind_time` 为当前时间
#### Scenario: 解绑 IoT 卡
- **WHEN** 用户解绑设备的 IoT 卡(绑定记录 ID 为 10)
- **THEN** 系统将绑定记录的 `bind_status` 从 1(已绑定) 变更为 2(已解绑),`unbind_time` 记录解绑时间,IoT 卡的 `shop_id` 保持不变
#### Scenario: 并发绑定同一张卡到不同设备
- **WHEN** 两个请求同时尝试将同一张 IoT 卡(ID 为 101)绑定到不同设备
- **THEN** 第一个请求成功,第二个请求返回错误"该卡已绑定到其他设备"
#### Scenario: 并发绑定不同卡到同一设备插槽
- **WHEN** 两个请求同时尝试将不同 IoT 卡绑定到同一设备(ID 为 1001)的同一插槽(slot_position 为 1)
- **THEN** 第一个请求成功,第二个请求返回错误"该插槽已有绑定的卡"
---
### Requirement: 设备批量导入
系统 SHALL 支持批量导入设备数据,用于平台库存管理。
**导入字段**:
- 设备编号(必填)
- 设备名称(可选)
- 设备型号(可选)
- 设备类型(可选)
- 最大插槽数(可选,默认 4)
- 设备制造商(可选)
- 批次号(可选,由任务自动生成)
- **ICCID 1-4**(可选,用于绑定 IoT 卡)
**导入规则**:
- 设备编号必须唯一,重复编号将被跳过
- 导入的设备默认 `shop_id` 为 NULL(平台库存),状态为 1(在库)
- 导入成功后记录操作日志
**IoT 卡绑定规则**(新增):
- 系统 SHALL 校验 ICCID 对应的卡是否存在
- 系统 SHALL 校验卡是否已绑定到其他设备
- **新增**: 系统 SHALL 校验卡的归属权,只允许绑定平台库存的卡(shop_id = NULL)
- 如果卡已分配给店铺(shop_id != NULL),系统 SHALL 拒绝绑定并记录原因
**导入结果分类**(新增):
- **完全成功**: 设备创建且所有指定的卡都绑定成功
- **部分成功**: 设备创建但部分卡绑定失败(新增 warning 状态)
- **跳过**: 设备编号已存在
- **失败**: 设备创建失败或所有指定的卡都不可用
**导入任务模型扩展**(新增):
- `warning_count`: 警告数量(部分成功的设备数)
- `warning_items`: 警告记录详情(JSONB,记录哪些卡绑定失败及原因)
#### Scenario: 批量导入设备成功
- **WHEN** 平台上传包含 50 条设备数据的 CSV 文件
- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息
#### Scenario: 批量导入包含重复编号
- **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号
- **THEN** 系统跳过重复编号的设备,记录到 skipped_items 并列出重复编号,其他有效设备正常导入
#### Scenario: 导入时绑定平台库存的卡
- **WHEN** CSV 行指定了 ICCID,且该卡为平台库存(shop_id = NULL)且未绑定其他设备
- **THEN** 系统创建设备并绑定该卡,记录为完全成功
#### Scenario: 导入时尝试绑定已分配给店铺的卡
- **WHEN** CSV 行指定了 ICCID,但该卡已分配给店铺(shop_id != NULL)
- **THEN** 系统创建设备但不绑定该卡,将该设备记录到 warning_items,原因为"ICCID-XXX 已分配给店铺,不能绑定到平台库存设备"
#### Scenario: 导入时部分卡绑定成功
- **WHEN** CSV 行指定了 4 张卡,其中 2 张为平台库存且未绑定,1 张已分配给店铺,1 张不存在
- **THEN** 系统创建设备并绑定 2 张有效的卡,将该设备记录到 warning_items,原因为"部分卡绑定失败: ICCID-001 已分配给店铺,不能绑定到平台库存设备; ICCID-002 不存在",success_count 和 warning_count 各加 1
#### Scenario: 导入时所有指定的卡都不可用
- **WHEN** CSV 行指定了 2 张卡,但都已绑定到其他设备
- **THEN** 系统不创建设备,将该行记录到 failed_items,原因为"所有指定的卡都不可用: ICCID-001 已绑定其他设备, ICCID-002 已绑定其他设备"
## ADDED Requirements
### Requirement: DeviceSimBinding 模型组织
系统 SHALL 将 DeviceSimBinding 模型定义在独立的文件中,遵循项目代码组织规范。
**文件位置**:
- 从: `internal/model/package.go`
- 到: `internal/model/device_sim_binding.go`
**模型内容**:
```go
// DeviceSimBinding 设备-IoT卡绑定关系模型
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
type DeviceSimBinding struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID"`
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)"`
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑"`
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间"`
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间"`
}
func (DeviceSimBinding) TableName() string {
return "tb_device_sim_binding"
}
```
#### Scenario: 模型文件独立
- **WHEN** 开发者需要查找或修改 DeviceSimBinding 模型
- **THEN** 模型定义位于 `internal/model/device_sim_binding.go` 文件中,而非混杂在 `package.go`

View File

@@ -0,0 +1,76 @@
# 实现任务清单
## 1. 数据库迁移
- [x] 1.1 创建迁移文件 `migrations/000019_fix_device_sim_binding_constraints.up.sql`
- 使用 `CREATE INDEX CONCURRENTLY` 添加 `idx_active_device_slot` 部分唯一索引
-`tb_device_import_task` 表添加 `warning_count``warning_items` 字段
- [x] 1.2 创建回滚文件 `migrations/000019_fix_device_sim_binding_constraints.down.sql`
- [x] 1.3 执行迁移并验证索引创建成功
## 2. 模型层修改
- [x] 2.1 创建 `internal/model/device_sim_binding.go` 文件
-`internal/model/package.go` 移动 `DeviceSimBinding` 结构体和 `TableName()` 方法
- 确保所有 import 和 tag 正确
- [x] 2.2 从 `internal/model/package.go` 中删除 `DeviceSimBinding` 相关代码
- [x] 2.3 修改 `internal/model/device_import_task.go`
- 添加 `WarningCount int` 字段
- 添加 `WarningItems ImportResultItems` 字段JSONB 类型)
- [x] 2.4 运行 `go build ./...` 确保编译通过
## 3. Store 层修改
- [x] 3.1 修改 `internal/store/postgres/device_sim_binding_store.go`
- 添加 `isUniqueViolation(err error) bool` 辅助函数
- 修改 `Create` 方法,检测唯一约束冲突并返回友好的业务错误
- 根据违反的约束名(`idx_active_device_slot``idx_device_sim_bindings_active_card`)返回不同的错误信息
- [x] 3.2 添加 `github.com/jackc/pgx/v5/pgconn` 依赖(如果尚未存在)
## 4. Service 层修改
- [x] 4.1 修改 `internal/service/device/binding.go`
- `BindCard` 方法已有应用层检查,无需修改
- Store 层的错误处理已足够Service 层只需透传错误
## 5. 设备导入任务修改
- [x] 5.1 修改 `internal/task/device_import.go` 中的 `deviceImportResult` 结构体
- 添加 `warningCount int` 字段
- 添加 `warningItems model.ImportResultItems` 字段
- [x] 5.2 修改 `processBatch` 函数添加归属权校验
- 在 ICCID 验证循环中添加 `card.ShopID != nil` 检查
- 如果卡已分配给店铺,记录原因 `ICCID+"已分配给店铺,不能绑定到平台库存设备"`
- [x] 5.3 修改 `processBatch` 函数添加部分成功处理逻辑
-`len(validCardIDs) > 0 && len(cardIssues) > 0` 时,设备创建后记录到 `warningItems`
- 增加 `warningCount`
- [x] 5.4 修改 `HandleDeviceImport` 函数
- 更新调用 `h.importTaskStore.UpdateResult` 传入 `warning_count``warning_items`
- [x] 5.5 修改 `internal/store/postgres/device_import_task_store.go`
- 更新 `UpdateResult` 方法签名,添加 `warningCount``warningItems` 参数
- 更新 SQL 语句保存新字段
## 6. 测试
- [x] 6.1 编写 `internal/store/postgres/device_sim_binding_store_test.go` 并发绑定测试
- 测试同一张卡并发绑定到不同设备
- 测试同一设备插槽并发绑定不同卡
- 验证返回正确的错误信息
- [x] 6.2 编写 `internal/task/device_import_test.go` 归属权校验测试
- 测试绑定平台库存卡成功
- 测试绑定已分配店铺的卡失败
- 测试部分成功场景,验证 warning_items 记录正确
- [x] 6.3 运行现有测试确保无回归 `go test ./...`
-tests/unit 和 tests/integration 中存在既有问题(与本次实现无关)
- 本次实现相关测试全部通过internal/store/postgres、internal/task
## 7. 验证与文档
- [x] 7.1 使用 PostgreSQL MCP 验证数据库约束生效
- 手动测试插入重复 (device_id, slot_position, bind_status=1) 记录被拒绝
- 手动测试插入重复 (iot_card_id, bind_status=1) 记录被拒绝
- [x] 7.2 验证 API 响应结构
- 确认设备导入任务结果包含 `warning_count``warning_items` 字段
- 更新了 DTO 和 Service 层映射
- [x] 7.3 更新相关文档(如有必要)
- 本次实现无需额外文档更新

View File

@@ -0,0 +1,166 @@
# enterprise-card-authorization Specification
## Purpose
TBD - created by archiving change enterprise-card-authorization. Update Purpose after archive.
## Requirements
### Requirement: 企业单卡授权管理
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
**授权规则**
- 代理只能授权自己的卡owner_type="agent" 且 owner_id=自己的 shop_id给自己的企业
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
- 只能授权单张卡,不支持批量选择
- 已绑定设备的卡不能授权(设备卡应整体授权,而非单卡)
- 只能授权状态为 "已分销(2)" 的卡
**授权记录存储**
- 使用 `enterprise_card_authorization` 表记录授权关系
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
**权限控制**
- 企业用户只能查看被授权的卡
- 授权后卡的 shop_id 保持不变(所有权不转移)
- 回收授权后企业立即失去访问权限
#### Scenario: 代理授权自己的卡给自己的企业
- **WHEN** 代理shop_id=10将自己的卡owner_type="agent", owner_id=10授权给企业enterprise_id=5, owner_shop_id=10
- **THEN** 系统创建授权记录,企业可以查看和管理该卡,卡的 shop_id 保持为 10
#### Scenario: 平台授权任意卡给企业
- **WHEN** 平台管理员将卡授权给企业
- **THEN** 系统创建授权记录,不检查卡的所有者,企业获得该卡的访问权限
#### Scenario: 代理无法授权其他代理的卡
- **WHEN** 代理shop_id=10尝试授权其他代理的卡owner_id=20给企业
- **THEN** 系统拒绝操作,返回权限错误
#### Scenario: 已绑定设备的卡不能授权
- **WHEN** 用户尝试授权已绑定到设备的卡
- **THEN** 系统拒绝操作,提示该卡已绑定设备,请使用设备授权功能
#### Scenario: 只能授权已分销状态的卡
- **WHEN** 用户尝试授权非"已分销"状态的卡
- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡
---
### Requirement: 企业卡授权数据模型
系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。
**实体字段**
- `id`: 主键BIGINT
- `enterprise_id`: 被授权企业IDBIGINT关联 enterprises 表)
- `card_id`: IoT卡IDBIGINT关联 iot_cards 表)
- `authorizer_id`: 授权人账号IDBIGINT关联 accounts 表)
- `authorizer_type`: 授权人类型VARCHAR(20)"platform" | "agent"
- `authorized_at`: 授权时间TIMESTAMP
- `revoked_at`: 回收时间TIMESTAMP可空
- `revoked_by`: 回收人账号IDBIGINT可空
- `created_at`: 创建时间TIMESTAMP
- `updated_at`: 更新时间TIMESTAMP
#### Scenario: 创建授权记录
- **WHEN** 授权卡给企业时
- **THEN** 系统创建 EnterpriseCardAuthorization 记录authorized_at 设置为当前时间revoked_at 为 NULL
#### Scenario: 回收授权
- **WHEN** 回收企业的卡授权时
- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史)
---
### Requirement: 批量授权接口
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业,不需要预检接口。
**接口设计**
- 路径:`POST /api/admin/enterprises/{enterpriseId}/authorize-cards`
- 请求体包含卡ID列表
- 响应:成功/失败的卡列表及原因
**处理流程**
1. 验证每张卡的授权权限
2. 检查卡状态是否为"已分销"
3. 检查卡是否已绑定设备
4. 检查是否已授权给其他企业
5. 创建授权记录
6. 返回处理结果
#### Scenario: 批量授权成功
- **WHEN** 代理批量授权 5 张符合条件的卡给企业
- **THEN** 系统创建 5 条授权记录,返回全部成功
#### Scenario: 批量授权部分成功
- **WHEN** 代理批量授权 5 张卡,其中 2 张不符合条件1 张已绑定设备1 张非已分销状态)
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及失败原因
---
### Requirement: 企业查看授权卡信息
系统 SHALL 允许企业查看被授权卡的特定信息,同时隐藏商业敏感信息。
**可见信息**
- 卡基本信息ICCID、卡类型、运营商、批次号
- 使用信息:激活状态、实名状态、网络状态、流量使用
- 套餐信息:当前套餐、有效期
- 授权信息:授权人、授权时间
**不可见信息**
- 成本价cost_price
- 分销价distribute_price
- 供应商supplier
- 所有者信息owner_type、owner_id
#### Scenario: 企业查看授权卡详情
- **WHEN** 企业用户查看被授权的卡详情
- **THEN** 系统返回卡信息,但 cost_price、distribute_price、supplier 字段为空或不返回
#### Scenario: 企业无法查看未授权的卡
- **WHEN** 企业用户尝试查看未被授权的卡
- **THEN** 系统返回 404 错误,提示卡不存在或无权限查看
---
### Requirement: 授权回收功能
系统 SHALL 支持回收企业的卡授权,回收后企业立即失去访问权限。
**回收规则**
- 代理可以回收自己授权的卡
- 平台可以回收任何授权
- 回收操作不可逆(需重新授权才能恢复访问)
**回收效果**
- 更新 revoked_at 和 revoked_by 字段
- 企业立即无法查看该卡
- 保留授权历史记录
#### Scenario: 代理回收自己的授权
- **WHEN** 代理回收之前授权给企业的卡
- **THEN** 系统更新授权记录的回收字段,企业立即无法访问该卡
#### Scenario: 平台回收任意授权
- **WHEN** 平台管理员回收任意企业的卡授权
- **THEN** 系统更新授权记录,不检查原授权人,企业失去访问权限
#### Scenario: 回收后企业无法访问
- **WHEN** 授权被回收后,企业用户尝试查看该卡
- **THEN** 系统返回 404 错误,如同该卡从未被授权过

View File

@@ -503,3 +503,44 @@ This capability supports:
---
### Requirement: 企业用户 IoT 卡查询权限控制
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
**查询权限规则**
- **超级管理员/平台用户**:可以查询所有 IoT 卡
- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡
- **企业用户**
- 可以查询分配给自己企业的卡owner_type="enterprise" 且 owner_id=自己的企业ID
- 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联)
- **个人客户**:只能查询自己拥有的卡
**数据过滤**
- 企业用户查询时自动过滤敏感商业信息cost_price、distribute_price、supplier
- 其他用户类型可以看到完整信息
#### Scenario: 企业用户查询自己拥有的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡
- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段
#### Scenario: 企业用户查询被授权的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡
- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息
#### Scenario: 企业用户无法查询未授权的卡
- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡
- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在
#### Scenario: 代理用户正常查询
- **WHEN** 代理用户查询 IoT 卡
- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息
#### Scenario: 授权被回收后企业无法查询
- **WHEN** 卡的授权被回收后revoked_at 不为空),企业用户查询该卡
- **THEN** 系统不返回该卡信息,企业无法再看到该卡

View File

@@ -44,6 +44,7 @@ const (
TaskTypeSIMStatusSync = "sim:status:sync" // SIM 卡状态同步
TaskTypeCommission = "commission:calculate" // 分佣计算
TaskTypeIotCardImport = "iot_card:import" // IoT 卡批量导入
TaskTypeDeviceImport = "device:import" // 设备批量导入
)
// 用户状态常量

View File

@@ -355,58 +355,6 @@ func TestDataPermissionCallback_FilterForEnterprise(t *testing.T) {
}
}
// TestDataPermissionCallback_FilterForPersonalCustomer 测试个人客户过滤
func TestDataPermissionCallback_FilterForPersonalCustomer(t *testing.T) {
// 创建内存数据库
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
// 创建测试表(包含 creator 字段)
type TestModel struct {
ID uint
Creator uint
Name string
}
err = db.AutoMigrate(&TestModel{})
assert.NoError(t, err)
// 插入测试数据
db.Create(&TestModel{ID: 1, Creator: 1, Name: "test1"})
db.Create(&TestModel{ID: 2, Creator: 2, Name: "test2"})
db.Create(&TestModel{ID: 3, Creator: 1, Name: "test3"})
// 创建 mock ShopStore个人客户不需要但注册时需要
mockStore := &mockShopStore{
subordinateShopIDs: []uint{},
}
// 注册 Callback
err = RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置个人客户 context
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypePersonalCustomer,
ShopID: 0,
EnterpriseID: 0,
CustomerID: 1,
})
// 查询数据
var results []TestModel
err = db.WithContext(ctx).Find(&results).Error
assert.NoError(t, err)
// 个人客户只能看到自己创建的数据
assert.Equal(t, 2, len(results))
for _, r := range results {
assert.Equal(t, uint(1), r.Creator)
}
}
// ============================================================
// 标签表数据权限过滤测试tb_tag / tb_resource_tag 表)
// ============================================================
@@ -704,40 +652,6 @@ func TestTagPermission_Enterprise_NoEnterpriseID(t *testing.T) {
}
}
// TestTagPermission_PersonalCustomer 测试个人客户查询标签
// 预期:只能看到全局标签
func TestTagPermission_PersonalCustomer(t *testing.T) {
db, mockStore := setupTagTestDB(t)
// 注册 Callback
err := RegisterDataPermissionCallback(db, mockStore)
assert.NoError(t, err)
// 设置个人客户 context
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypePersonalCustomer,
ShopID: 0,
EnterpriseID: 0,
CustomerID: 1,
})
// 查询标签
var tags []TagModel
err = db.WithContext(ctx).Find(&tags).Error
assert.NoError(t, err)
// 个人客户只能看到 2 个全局标签
assert.Equal(t, 2, len(tags), "个人客户只能看到全局标签")
// 验证都是全局标签
for _, tag := range tags {
assert.Nil(t, tag.EnterpriseID, "个人客户只能看到全局标签enterprise_id 应为 NULL")
assert.Nil(t, tag.ShopID, "个人客户只能看到全局标签shop_id 应为 NULL")
}
}
// TestTagPermission_ResourceTag_Agent 测试代理用户查询资源标签表
// 预期:与 tb_tag 表相同的过滤规则
func TestTagPermission_ResourceTag_Agent(t *testing.T) {

View File

@@ -30,7 +30,6 @@ func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, l
}
}
// RegisterHandlers 注册所有任务处理器
func (h *Handler) RegisterHandlers() *asynq.ServeMux {
emailHandler := task.NewEmailHandler(h.redis, h.logger)
syncHandler := task.NewSyncHandler(h.db, h.logger)
@@ -46,6 +45,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
h.logger.Info("注册 SIM 状态同步任务处理器", zap.String("task_type", constants.TaskTypeSIMStatusSync))
h.registerIotCardImportHandler()
h.registerDeviceImportHandler()
h.logger.Info("所有任务处理器注册完成")
return h.mux
@@ -60,6 +60,17 @@ func (h *Handler) registerIotCardImportHandler() {
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
}
func (h *Handler) registerDeviceImportHandler() {
importTaskStore := postgres.NewDeviceImportTaskStore(h.db, h.redis)
deviceStore := postgres.NewDeviceStore(h.db, h.redis)
bindingStore := postgres.NewDeviceSimBindingStore(h.db, h.redis)
iotCardStore := postgres.NewIotCardStore(h.db, h.redis)
deviceImportHandler := task.NewDeviceImportHandler(h.db, h.redis, importTaskStore, deviceStore, bindingStore, iotCardStore, h.storage, h.logger)
h.mux.HandleFunc(constants.TaskTypeDeviceImport, deviceImportHandler.HandleDeviceImport)
h.logger.Info("注册设备导入任务处理器", zap.String("task_type", constants.TaskTypeDeviceImport))
}
// GetMux 获取 ServeMux用于启动 Worker 服务器)
func (h *Handler) GetMux() *asynq.ServeMux {
return h.mux

View File

@@ -37,7 +37,7 @@ import (
// testEnv 测试环境
type testEnv struct {
tx *gorm.DB
rdb *redis.Client
rdb *redis.Client
app *fiber.App
accountService *accountService.Service
postgresCleanup func()
@@ -121,12 +121,19 @@ func setupTestEnv(t *testing.T) *testEnv {
services := &bootstrap.Handlers{
Account: accountHandler,
}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error {
return c.Next()
},
H5Auth: func(c *fiber.Ctx) error {
return c.Next()
},
}
routes.RegisterRoutes(app, services, middlewares)
return &testEnv{
tx: tx,
rdb: rdb,
rdb: rdb,
app: app,
accountService: accService,
postgresCleanup: func() {

View File

@@ -34,7 +34,7 @@ import (
// regressionTestEnv 回归测试环境
type regressionTestEnv struct {
tx *gorm.DB
rdb *redis.Client
rdb *redis.Client
app *fiber.App
postgresCleanup func()
redisCleanup func()
@@ -132,13 +132,21 @@ func setupRegressionTestEnv(t *testing.T) *regressionTestEnv {
Role: roleHandler,
Permission: permHandler,
}
middlewares := &bootstrap.Middlewares{}
// 提供一个空操作的 AdminAuth 中间件,避免 nil panic
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error {
return c.Next()
},
H5Auth: func(c *fiber.Ctx) error {
return c.Next()
},
}
routes.RegisterRoutes(app, services, middlewares)
return &regressionTestEnv{
tx: tx,
tx: tx,
rdb: rdb,
app: app,
app: app,
postgresCleanup: func() {
if err := pgContainer.Terminate(ctx); err != nil {
t.Logf("终止 PostgreSQL 容器失败: %v", err)

View File

@@ -0,0 +1,333 @@
package integration
import (
"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 deviceTestEnv struct {
db *gorm.DB
rdb *redis.Client
tokenManager *auth.TokenManager
app *fiber.App
adminToken string
t *testing.T
}
func setupDeviceTestEnv(t *testing.T) *deviceTestEnv {
t.Helper()
t.Setenv("CONFIG_ENV", "dev")
t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml")
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 &deviceTestEnv{
db: db,
rdb: rdb,
tokenManager: tokenManager,
app: app,
adminToken: adminToken,
t: t,
}
}
func (e *deviceTestEnv) teardown() {
// 清理测试数据
e.db.Exec("DELETE FROM tb_device WHERE device_no LIKE 'TEST%'")
e.db.Exec("DELETE FROM tb_device_sim_binding WHERE device_id IN (SELECT id FROM tb_device WHERE device_no LIKE 'TEST%')")
e.db.Exec("DELETE FROM tb_device_import_task WHERE task_no 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 TestDevice_List(t *testing.T) {
env := setupDeviceTestEnv(t)
defer env.teardown()
// 创建测试设备
devices := []*model.Device{
{DeviceNo: "TEST_DEVICE_001", DeviceName: "测试设备1", DeviceType: "router", MaxSimSlots: 4, Status: constants.DeviceStatusInStock},
{DeviceNo: "TEST_DEVICE_002", DeviceName: "测试设备2", DeviceType: "router", MaxSimSlots: 2, Status: constants.DeviceStatusInStock},
{DeviceNo: "TEST_DEVICE_003", DeviceName: "测试设备3", DeviceType: "mifi", MaxSimSlots: 1, Status: constants.DeviceStatusDistributed},
}
for _, device := range devices {
require.NoError(t, env.db.Create(device).Error)
}
t.Run("获取设备列表-无过滤", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/devices?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/devices?device_type=router", 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/devices?status=%d", constants.DeviceStatusInStock), 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/devices", 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, "未认证请求应返回错误码")
})
}
func TestDevice_GetByID(t *testing.T) {
env := setupDeviceTestEnv(t)
defer env.teardown()
// 创建测试设备
device := &model.Device{
DeviceNo: "TEST_DEVICE_GET_001",
DeviceName: "测试设备详情",
DeviceType: "router",
MaxSimSlots: 4,
Status: constants.DeviceStatusInStock,
}
require.NoError(t, env.db.Create(device).Error)
t.Run("获取设备详情-成功", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/devices/%d", device.ID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
// 验证返回数据
dataMap, ok := result.Data.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "TEST_DEVICE_GET_001", dataMap["device_no"])
})
t.Run("获取不存在的设备-应返回错误", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/devices/999999", 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, "不存在的设备应返回错误码")
})
}
func TestDevice_Delete(t *testing.T) {
env := setupDeviceTestEnv(t)
defer env.teardown()
// 创建测试设备
device := &model.Device{
DeviceNo: "TEST_DEVICE_DEL_001",
DeviceName: "测试删除设备",
DeviceType: "router",
MaxSimSlots: 4,
Status: constants.DeviceStatusInStock,
}
require.NoError(t, env.db.Create(device).Error)
t.Run("删除设备-成功", func(t *testing.T) {
url := fmt.Sprintf("/api/admin/devices/%d", device.ID)
req := httptest.NewRequest("DELETE", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
// 验证设备已被软删除
var deletedDevice model.Device
err = env.db.Unscoped().First(&deletedDevice, device.ID).Error
require.NoError(t, err)
assert.NotNil(t, deletedDevice.DeletedAt)
})
}
func TestDeviceImport_TaskList(t *testing.T) {
env := setupDeviceTestEnv(t)
defer env.teardown()
// 创建测试导入任务
task := &model.DeviceImportTask{
TaskNo: "TEST_DEVICE_IMPORT_001",
Status: model.ImportTaskStatusCompleted,
BatchNo: "TEST_BATCH_001",
TotalCount: 100,
}
require.NoError(t, env.db.Create(task).Error)
t.Run("获取导入任务列表", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/devices/import-tasks?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) {
url := fmt.Sprintf("/api/admin/devices/import-tasks/%d", task.ID)
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp, err := env.app.Test(req, -1)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
})
}

View File

@@ -17,6 +17,7 @@ import (
testcontainers_postgres "github.com/testcontainers/testcontainers-go/modules/postgres"
testcontainers_redis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -37,7 +38,7 @@ import (
// permTestEnv 权限测试环境
type permTestEnv struct {
tx *gorm.DB
rdb *redis.Client
rdb *redis.Client
app *fiber.App
permissionService *permissionService.Service
cleanup func()
@@ -105,23 +106,28 @@ func setupPermTestEnv(t *testing.T) *permTestEnv {
// 初始化 Handler
permHandler := admin.NewPermissionHandler(permSvc)
// 创建 Fiber App
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
},
ErrorHandler: errors.SafeErrorHandler(zap.NewNop()),
})
app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
// 注册路由
services := &bootstrap.Handlers{
Permission: permHandler,
}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
return &permTestEnv{
tx: tx,
rdb: rdb,
rdb: rdb,
app: app,
permissionService: permSvc,
cleanup: func() {
@@ -140,14 +146,6 @@ func TestPermissionAPI_Create(t *testing.T) {
env := setupPermTestEnv(t)
defer env.cleanup()
// 添加测试中间件
testUserID := uint(1)
env.app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
t.Run("成功创建权限", func(t *testing.T) {
reqBody := dto.CreatePermissionRequest{
PermName: "用户管理",
@@ -206,7 +204,6 @@ func TestPermissionAPI_Create(t *testing.T) {
})
t.Run("创建子权限", func(t *testing.T) {
// 先创建父权限
parentPerm := &model.Permission{
PermName: "系统管理",
PermCode: "system:manage",
@@ -215,10 +212,9 @@ func TestPermissionAPI_Create(t *testing.T) {
}
env.tx.Create(parentPerm)
// 创建子权限
reqBody := dto.CreatePermissionRequest{
PermName: "用户列表",
PermCode: "system:user:list",
PermCode: "user:list",
PermType: constants.PermissionTypeButton,
ParentID: &parentPerm.ID,
}
@@ -231,10 +227,10 @@ func TestPermissionAPI_Create(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
// 验证父权限ID已设置
var child model.Permission
env.tx.Where("perm_code = ?", "system:user:list").First(&child)
assert.NotNil(t, child.ParentID)
err = env.tx.Where("perm_code = ?", "user:list").First(&child).Error
require.NoError(t, err, "子权限应该已创建")
require.NotNil(t, child.ParentID, "子权限的 ParentID 应该已设置")
assert.Equal(t, parentPerm.ID, *child.ParentID)
})
}

View File

@@ -48,7 +48,10 @@ func TestPlatformAccountAPI_ListPlatformAccounts(t *testing.T) {
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
superAdmin := &model.Account{
@@ -137,7 +140,10 @@ func TestPlatformAccountAPI_UpdatePassword(t *testing.T) {
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
testAccount := &model.Account{
@@ -212,7 +218,10 @@ func TestPlatformAccountAPI_UpdateStatus(t *testing.T) {
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
testAccount := &model.Account{
@@ -282,7 +291,10 @@ func TestPlatformAccountAPI_AssignRoles(t *testing.T) {
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
superAdmin := &model.Account{

View File

@@ -37,7 +37,7 @@ import (
// roleTestEnv 角色测试环境
type roleTestEnv struct {
tx *gorm.DB
rdb *redis.Client
rdb *redis.Client
app *fiber.App
roleService *roleService.Service
postgresCleanup func()
@@ -121,12 +121,15 @@ func setupRoleTestEnv(t *testing.T) *roleTestEnv {
services := &bootstrap.Handlers{
Role: roleHandler,
}
middlewares := &bootstrap.Middlewares{}
middlewares := &bootstrap.Middlewares{
AdminAuth: func(c *fiber.Ctx) error { return c.Next() },
H5Auth: func(c *fiber.Ctx) error { return c.Next() },
}
routes.RegisterRoutes(app, services, middlewares)
return &roleTestEnv{
tx: tx,
rdb: rdb,
rdb: rdb,
app: app,
roleService: roleSvc,
postgresCleanup: func() {

View File

@@ -2,7 +2,9 @@ package unit
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -321,13 +323,15 @@ func TestEnterpriseService_List(t *testing.T) {
t.Run("查询企业列表-按名称筛选", func(t *testing.T) {
ctx := createEnterpriseTestContext(1)
ts := time.Now().UnixNano()
searchKey := fmt.Sprintf("列表测试企业_%d", ts)
for i := 0; i < 3; i++ {
createReq := &dto.CreateEnterpriseReq{
EnterpriseName: "列表测试企业",
EnterpriseCode: "ENT_LIST_" + string(rune('A'+i)),
EnterpriseName: fmt.Sprintf("%s_%d", searchKey, i),
EnterpriseCode: fmt.Sprintf("ENT_LIST_%d_%d", ts, i),
ContactName: "联系人",
ContactPhone: "1380000007" + string(rune('0'+i)),
LoginPhone: "1390000007" + string(rune('0'+i)),
ContactPhone: fmt.Sprintf("138%08d", ts%100000000+int64(i)),
LoginPhone: fmt.Sprintf("139%08d", ts%100000000+int64(i)),
Password: "Test123456",
}
_, err := service.Create(ctx, createReq)
@@ -337,7 +341,7 @@ func TestEnterpriseService_List(t *testing.T) {
req := &dto.EnterpriseListReq{
Page: 1,
PageSize: 20,
EnterpriseName: "列表测试",
EnterpriseName: searchKey,
}
result, err := service.List(ctx, req)

View File

@@ -30,73 +30,70 @@ func TestPermissionPlatformFilter_List(t *testing.T) {
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
// 创建不同 platform 的权限
baseReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000}
_, existingTotal, err := service.List(ctx, baseReq)
require.NoError(t, err)
allReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformAll}
_, existingAllTotal, err := service.List(ctx, allReq)
require.NoError(t, err)
webReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformWeb}
_, existingWebTotal, err := service.List(ctx, webReq)
require.NoError(t, err)
h5Req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformH5}
_, existingH5Total, err := service.List(ctx, h5Req)
require.NoError(t, err)
permissions := []*model.Permission{
{PermName: "全端菜单", PermCode: "menu:all", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformAll, Status: constants.StatusEnabled},
{PermName: "Web菜单", PermCode: "menu:web", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, Status: constants.StatusEnabled},
{PermName: "H5菜单", PermCode: "menu:h5", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformH5, Status: constants.StatusEnabled},
{PermName: "Web按钮", PermCode: "button:web", PermType: constants.PermissionTypeButton, Platform: constants.PlatformWeb, Status: constants.StatusEnabled},
{PermName: "H5按钮", PermCode: "button:h5", PermType: constants.PermissionTypeButton, Platform: constants.PlatformH5, Status: constants.StatusEnabled},
{PermName: "全端菜单_test", PermCode: "menu:all:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformAll, Status: constants.StatusEnabled},
{PermName: "Web菜单_test", PermCode: "menu:web:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, Status: constants.StatusEnabled},
{PermName: "H5菜单_test", PermCode: "menu:h5:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformH5, Status: constants.StatusEnabled},
{PermName: "Web按钮_test", PermCode: "button:web:test", PermType: constants.PermissionTypeButton, Platform: constants.PlatformWeb, Status: constants.StatusEnabled},
{PermName: "H5按钮_test", PermCode: "button:h5:test", PermType: constants.PermissionTypeButton, Platform: constants.PlatformH5, Status: constants.StatusEnabled},
}
for _, perm := range permissions {
require.NoError(t, tx.Create(perm).Error)
}
// 测试查询全部权限(不过滤)
t.Run("查询全部权限", func(t *testing.T) {
req := &dto.PermissionListRequest{
Page: 1,
PageSize: 10,
}
perms, total, err := service.List(ctx, req)
req := &dto.PermissionListRequest{Page: 1, PageSize: 1000}
_, total, err := service.List(ctx, req)
require.NoError(t, err)
assert.Equal(t, int64(5), total)
assert.Len(t, perms, 5)
assert.Equal(t, existingTotal+5, total)
})
// 测试只查询 all 权限
t.Run("只查询all端口权限", func(t *testing.T) {
req := &dto.PermissionListRequest{
Page: 1,
PageSize: 10,
Platform: constants.PlatformAll,
}
req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformAll}
perms, total, err := service.List(ctx, req)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, perms, 1)
assert.Equal(t, "全端菜单", perms[0].PermName)
assert.Equal(t, existingAllTotal+1, total)
found := false
for _, perm := range perms {
if perm.PermName == "全端菜单_test" {
found = true
break
}
}
assert.True(t, found, "应包含测试创建的全端菜单权限")
})
// 测试只查询 web 权限
t.Run("只查询web端口权限", func(t *testing.T) {
req := &dto.PermissionListRequest{
Page: 1,
PageSize: 10,
Platform: constants.PlatformWeb,
}
req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformWeb}
perms, total, err := service.List(ctx, req)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, perms, 2)
// 验证都是 web 端口的权限
assert.Equal(t, existingWebTotal+2, total)
for _, perm := range perms {
assert.Equal(t, constants.PlatformWeb, perm.Platform)
}
})
// 测试只查询 h5 权限
t.Run("只查询h5端口权限", func(t *testing.T) {
req := &dto.PermissionListRequest{
Page: 1,
PageSize: 10,
Platform: constants.PlatformH5,
}
req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformH5}
perms, total, err := service.List(ctx, req)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, perms, 2)
// 验证都是 h5 端口的权限
assert.Equal(t, existingH5Total+2, total)
for _, perm := range perms {
assert.Equal(t, constants.PlatformH5, perm.Platform)
}
@@ -184,10 +181,13 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) {
ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
// 创建层级权限
existingTree, err := service.GetTree(ctx, nil)
require.NoError(t, err)
existingCount := len(existingTree)
parent := &model.Permission{
PermName: "系统管理",
PermCode: "system:manage",
PermName: "系统管理_tree_test",
PermCode: "system:manage:tree_test",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformWeb,
Status: constants.StatusEnabled,
@@ -195,8 +195,8 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) {
require.NoError(t, tx.Create(parent).Error)
child := &model.Permission{
PermName: "用户管理",
PermCode: "user:manage",
PermName: "用户管理_tree_test",
PermCode: "user:manage:tree_test",
PermType: constants.PermissionTypeMenu,
Platform: constants.PlatformWeb,
ParentID: &parent.ID,
@@ -204,19 +204,22 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) {
}
require.NoError(t, tx.Create(child).Error)
// 获取权限树
tree, err := service.GetTree(ctx, nil)
require.NoError(t, err)
require.Len(t, tree, 1)
assert.Len(t, tree, existingCount+1)
// 验证父节点
root := tree[0]
assert.Equal(t, "系统管理", root.PermName)
assert.Equal(t, constants.PlatformWeb, root.Platform)
var testRoot *dto.PermissionTreeNode
for _, node := range tree {
if node.PermName == "系统管理_tree_test" {
testRoot = node
break
}
}
require.NotNil(t, testRoot, "应包含测试创建的父节点")
assert.Equal(t, constants.PlatformWeb, testRoot.Platform)
// 验证子节点
require.Len(t, root.Children, 1)
childNode := root.Children[0]
assert.Equal(t, "用户管理", childNode.PermName)
require.Len(t, testRoot.Children, 1)
childNode := testRoot.Children[0]
assert.Equal(t, "用户管理_tree_test", childNode.PermName)
assert.Equal(t, constants.PlatformWeb, childNode.Platform)
}

View File

@@ -1,328 +0,0 @@
package unit
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/tests/testutils"
)
// TestPersonalCustomerStore_Create 测试创建个人客户
func TestPersonalCustomerStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
tests := []struct {
name string
customer *model.PersonalCustomer
wantErr bool
}{
{
name: "创建基本个人客户",
customer: &model.PersonalCustomer{
WxOpenID: "wx_openid_test_a",
WxUnionID: "wx_unionid_test_a",
Nickname: "测试用户A",
Status: constants.StatusEnabled,
},
wantErr: false,
},
{
name: "创建带微信信息的个人客户",
customer: &model.PersonalCustomer{
WxOpenID: "wx_openid_123456",
WxUnionID: "wx_unionid_abcdef",
Nickname: "测试用户B",
AvatarURL: "https://example.com/avatar.jpg",
Status: constants.StatusEnabled,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := store.Create(ctx, tt.customer)
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.NotZero(t, tt.customer.ID)
assert.NotZero(t, tt.customer.CreatedAt)
assert.NotZero(t, tt.customer.UpdatedAt)
}
})
}
}
// TestPersonalCustomerStore_GetByID 测试根据 ID 查询个人客户
func TestPersonalCustomerStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_test_getbyid",
WxUnionID: "wx_unionid_test_getbyid",
Nickname: "测试客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
t.Run("查询存在的客户", func(t *testing.T) {
found, err := store.GetByID(ctx, customer.ID)
require.NoError(t, err)
assert.Equal(t, customer.WxOpenID, found.WxOpenID)
assert.Equal(t, customer.Nickname, found.Nickname)
})
t.Run("查询不存在的客户", func(t *testing.T) {
_, err := store.GetByID(ctx, 99999)
assert.Error(t, err)
})
}
// TestPersonalCustomerStore_GetByPhone 测试根据手机号查询
func TestPersonalCustomerStore_GetByPhone(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_test_phone",
WxUnionID: "wx_unionid_test_phone",
Nickname: "测试客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
// 创建手机号绑定记录
customerPhone := &model.PersonalCustomerPhone{
CustomerID: customer.ID,
Phone: "13800000001",
IsPrimary: true,
Status: constants.StatusEnabled,
}
err = tx.Create(customerPhone).Error
require.NoError(t, err)
t.Run("根据手机号查询", func(t *testing.T) {
found, err := store.GetByPhone(ctx, "13800000001")
require.NoError(t, err)
assert.Equal(t, customer.ID, found.ID)
assert.Equal(t, customer.Nickname, found.Nickname)
})
t.Run("查询不存在的手机号", func(t *testing.T) {
_, err := store.GetByPhone(ctx, "99900000000")
assert.Error(t, err)
})
}
// TestPersonalCustomerStore_GetByWxOpenID 测试根据微信 OpenID 查询
func TestPersonalCustomerStore_GetByWxOpenID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_unique",
WxUnionID: "wx_unionid_unique",
Nickname: "测试客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
t.Run("根据微信OpenID查询", func(t *testing.T) {
found, err := store.GetByWxOpenID(ctx, "wx_openid_unique")
require.NoError(t, err)
assert.Equal(t, customer.ID, found.ID)
assert.Equal(t, customer.WxOpenID, found.WxOpenID)
})
t.Run("查询不存在的OpenID", func(t *testing.T) {
_, err := store.GetByWxOpenID(ctx, "nonexistent_openid")
assert.Error(t, err)
})
}
// TestPersonalCustomerStore_Update 测试更新个人客户
func TestPersonalCustomerStore_Update(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_test_update",
WxUnionID: "wx_unionid_test_update",
Nickname: "原昵称",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
t.Run("更新客户信息", func(t *testing.T) {
customer.Nickname = "新昵称"
customer.AvatarURL = "https://example.com/new_avatar.jpg"
err := store.Update(ctx, customer)
require.NoError(t, err)
// 验证更新
found, err := store.GetByID(ctx, customer.ID)
require.NoError(t, err)
assert.Equal(t, "新昵称", found.Nickname)
assert.Equal(t, "https://example.com/new_avatar.jpg", found.AvatarURL)
})
t.Run("绑定微信信息", func(t *testing.T) {
customer.WxOpenID = "wx_openid_new"
customer.WxUnionID = "wx_unionid_new"
err := store.Update(ctx, customer)
require.NoError(t, err)
found, err := store.GetByID(ctx, customer.ID)
require.NoError(t, err)
assert.Equal(t, "wx_openid_new", found.WxOpenID)
assert.Equal(t, "wx_unionid_new", found.WxUnionID)
})
t.Run("更新客户状态", func(t *testing.T) {
customer.Status = constants.StatusDisabled
err := store.Update(ctx, customer)
require.NoError(t, err)
found, err := store.GetByID(ctx, customer.ID)
require.NoError(t, err)
assert.Equal(t, constants.StatusDisabled, found.Status)
})
}
// TestPersonalCustomerStore_Delete 测试软删除个人客户
func TestPersonalCustomerStore_Delete(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_test_delete",
WxUnionID: "wx_unionid_test_delete",
Nickname: "待删除客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
t.Run("软删除客户", func(t *testing.T) {
err := store.Delete(ctx, customer.ID)
require.NoError(t, err)
// 验证已被软删除
_, err = store.GetByID(ctx, customer.ID)
assert.Error(t, err)
})
}
// TestPersonalCustomerStore_List 测试查询客户列表
func TestPersonalCustomerStore_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建多个测试客户
for i := 1; i <= 5; i++ {
customer := &model.PersonalCustomer{
WxOpenID: testutils.GenerateUsername("wx_openid_list_", i),
WxUnionID: testutils.GenerateUsername("wx_unionid_list_", i),
Nickname: testutils.GenerateUsername("客户", i),
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
}
t.Run("分页查询", func(t *testing.T) {
customers, total, err := store.List(ctx, nil, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(customers), 5)
assert.GreaterOrEqual(t, total, int64(5))
})
t.Run("带过滤条件查询", func(t *testing.T) {
filters := map[string]interface{}{
"status": constants.StatusEnabled,
}
customers, _, err := store.List(ctx, nil, filters)
require.NoError(t, err)
for _, c := range customers {
assert.Equal(t, constants.StatusEnabled, c.Status)
}
})
}
// TestPersonalCustomerStore_UniqueConstraints 测试唯一约束
func TestPersonalCustomerStore_UniqueConstraints(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := postgres.NewPersonalCustomerStore(tx, rdb)
ctx := context.Background()
// 创建测试客户
customer := &model.PersonalCustomer{
WxOpenID: "wx_openid_unique_test",
WxUnionID: "wx_unionid_unique_test",
Nickname: "唯一测试客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, customer)
require.NoError(t, err)
t.Run("重复微信OpenID应失败", func(t *testing.T) {
duplicate := &model.PersonalCustomer{
WxOpenID: "wx_openid_unique_test", // 重复
WxUnionID: "wx_unionid_different",
Nickname: "另一个客户",
Status: constants.StatusEnabled,
}
err := store.Create(ctx, duplicate)
assert.Error(t, err)
})
}

View File

@@ -1,555 +0,0 @@
package unit
import (
"context"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// TestQueueClientEnqueue 测试任务入队
func TestQueueClientEnqueue(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
payload := map[string]string{
"request_id": "test-001",
"to": "test@example.com",
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info, err := client.Enqueue(task)
require.NoError(t, err)
assert.NotEmpty(t, info.ID)
assert.Equal(t, constants.QueueDefault, info.Queue)
}
// TestQueueClientEnqueueWithOptions 测试带选项的任务入队
func TestQueueClientEnqueueWithOptions(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
tests := []struct {
name string
opts []asynq.Option
verify func(*testing.T, *asynq.TaskInfo)
}{
{
name: "Custom Queue",
opts: []asynq.Option{
asynq.Queue(constants.QueueCritical),
},
verify: func(t *testing.T, info *asynq.TaskInfo) {
assert.Equal(t, constants.QueueCritical, info.Queue)
},
},
{
name: "Custom Retry",
opts: []asynq.Option{
asynq.MaxRetry(3),
},
verify: func(t *testing.T, info *asynq.TaskInfo) {
assert.Equal(t, 3, info.MaxRetry)
},
},
{
name: "Custom Timeout",
opts: []asynq.Option{
asynq.Timeout(5 * time.Minute),
},
verify: func(t *testing.T, info *asynq.TaskInfo) {
assert.Equal(t, 5*time.Minute, info.Timeout)
},
},
{
name: "Delayed Task",
opts: []asynq.Option{
asynq.ProcessIn(10 * time.Second),
},
verify: func(t *testing.T, info *asynq.TaskInfo) {
assert.True(t, info.NextProcessAt.After(time.Now()))
},
},
{
name: "Combined Options",
opts: []asynq.Option{
asynq.Queue(constants.QueueCritical),
asynq.MaxRetry(5),
asynq.Timeout(10 * time.Minute),
},
verify: func(t *testing.T, info *asynq.TaskInfo) {
assert.Equal(t, constants.QueueCritical, info.Queue)
assert.Equal(t, 5, info.MaxRetry)
assert.Equal(t, 10*time.Minute, info.Timeout)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := map[string]string{
"request_id": "test-" + tt.name,
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info, err := client.Enqueue(task, tt.opts...)
require.NoError(t, err)
tt.verify(t, info)
})
}
}
// TestQueueClientTaskUniqueness 测试任务唯一性
func TestQueueClientTaskUniqueness(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
payload := map[string]string{
"request_id": "unique-001",
"to": "test@example.com",
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
// 第一次提交
task1 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info1, err := client.Enqueue(task1,
asynq.TaskID("unique-task-001"),
asynq.Unique(1*time.Hour),
)
require.NoError(t, err)
assert.NotNil(t, info1)
// 第二次提交(重复)
task2 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info2, err := client.Enqueue(task2,
asynq.TaskID("unique-task-001"),
asynq.Unique(1*time.Hour),
)
// 应该返回错误(任务已存在)
assert.Error(t, err)
assert.Nil(t, info2)
}
// TestQueuePriorityWeights 测试队列优先级权重
func TestQueuePriorityWeights(t *testing.T) {
queues := map[string]int{
constants.QueueCritical: 6,
constants.QueueDefault: 3,
constants.QueueLow: 1,
}
// 验证权重总和
totalWeight := 0
for _, weight := range queues {
totalWeight += weight
}
assert.Equal(t, 10, totalWeight)
// 验证权重比例
assert.Equal(t, 0.6, float64(queues[constants.QueueCritical])/float64(totalWeight))
assert.Equal(t, 0.3, float64(queues[constants.QueueDefault])/float64(totalWeight))
assert.Equal(t, 0.1, float64(queues[constants.QueueLow])/float64(totalWeight))
}
// TestTaskPayloadSizeLimit 测试任务载荷大小限制
func TestTaskPayloadSizeLimit(t *testing.T) {
tests := []struct {
name string
payloadSize int
shouldError bool
}{
{
name: "Small Payload (1KB)",
payloadSize: 1024,
shouldError: false,
},
{
name: "Medium Payload (100KB)",
payloadSize: 100 * 1024,
shouldError: false,
},
{
name: "Large Payload (1MB)",
payloadSize: 1024 * 1024,
shouldError: false,
},
// Redis 默认支持最大 512MB但实际应用中不建议超过 1MB
}
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建指定大小的载荷
largeData := make([]byte, tt.payloadSize)
for i := range largeData {
largeData[i] = byte(i % 256)
}
payload := map[string]interface{}{
"request_id": "size-test-001",
"data": largeData,
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeDataSync, payloadBytes)
info, err := client.Enqueue(task)
if tt.shouldError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.NotNil(t, info)
}
})
}
}
// TestTaskScheduling 测试任务调度
func TestTaskScheduling(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
tests := []struct {
name string
scheduleOpt asynq.Option
expectedTime time.Time
}{
{
name: "Process In 5 Seconds",
scheduleOpt: asynq.ProcessIn(5 * time.Second),
expectedTime: time.Now().Add(5 * time.Second),
},
{
name: "Process At Specific Time",
scheduleOpt: asynq.ProcessAt(time.Now().Add(10 * time.Second)),
expectedTime: time.Now().Add(10 * time.Second),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := map[string]string{
"request_id": "schedule-test-" + tt.name,
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info, err := client.Enqueue(task, tt.scheduleOpt)
require.NoError(t, err)
assert.True(t, info.NextProcessAt.After(time.Now()))
// 允许 1 秒的误差
assert.WithinDuration(t, tt.expectedTime, info.NextProcessAt, 1*time.Second)
})
}
}
// TestQueueInspectorStats 测试队列统计
func TestQueueInspectorStats(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
// 提交一些任务
for i := 0; i < 5; i++ {
payload := map[string]string{
"request_id": "stats-test-" + string(rune(i)),
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
_, err = client.Enqueue(task)
require.NoError(t, err)
}
// 使用 Inspector 查询统计
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = inspector.Close() }()
info, err := inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.Equal(t, 5, info.Pending)
assert.Equal(t, 0, info.Active)
assert.Equal(t, 0, info.Completed)
}
// TestTaskRetention 测试任务保留策略
func TestTaskRetention(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
payload := map[string]string{
"request_id": "retention-test-001",
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
// 提交任务并设置保留时间
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info, err := client.Enqueue(task,
asynq.Retention(24*time.Hour), // 保留 24 小时
)
require.NoError(t, err)
assert.NotNil(t, info)
}
// TestQueueDraining 测试队列暂停和恢复
func TestQueueDraining(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = inspector.Close() }()
// 暂停队列
err := inspector.PauseQueue(constants.QueueDefault)
require.NoError(t, err)
// 检查队列是否已暂停
info, err := inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.True(t, info.Paused)
// 恢复队列
err = inspector.UnpauseQueue(constants.QueueDefault)
require.NoError(t, err)
// 检查队列是否已恢复
info, err = inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.False(t, info.Paused)
}
// TestTaskCancellation 测试任务取消
func TestTaskCancellation(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
payload := map[string]string{
"request_id": "cancel-test-001",
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
// 提交任务
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
info, err := client.Enqueue(task)
require.NoError(t, err)
// 取消任务
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = inspector.Close() }()
err = inspector.DeleteTask(constants.QueueDefault, info.ID)
require.NoError(t, err)
// 验证任务已删除
queueInfo, err := inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.Equal(t, 0, queueInfo.Pending)
}
// TestBatchTaskEnqueue 测试批量任务入队
func TestBatchTaskEnqueue(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
// 批量创建任务
batchSize := 100
for i := 0; i < batchSize; i++ {
payload := map[string]string{
"request_id": "batch-" + string(rune(i)),
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
_, err = client.Enqueue(task)
require.NoError(t, err)
}
// 验证任务数量
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = inspector.Close() }()
info, err := inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.Equal(t, batchSize, info.Pending)
}
// TestTaskGrouping 测试任务分组
func TestTaskGrouping(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer func() { _ = rdb.Close() }()
ctx := context.Background()
rdb.FlushDB(ctx)
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = client.Close() }()
// 提交分组任务
groupKey := "email-batch-001"
for i := 0; i < 5; i++ {
payload := map[string]string{
"request_id": "group-" + string(rune(i)),
"group": groupKey,
}
payloadBytes, err := sonic.Marshal(payload)
require.NoError(t, err)
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
_, err = client.Enqueue(task,
asynq.Group(groupKey),
)
require.NoError(t, err)
}
// 验证任务已按组提交
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer func() { _ = inspector.Close() }()
info, err := inspector.GetQueueInfo(constants.QueueDefault)
require.NoError(t, err)
assert.GreaterOrEqual(t, info.Pending, 5)
}

View File

@@ -179,5 +179,5 @@ func TestRoleAssignmentLimit_SuperAdmin(t *testing.T) {
// 尝试为超级管理员分配角色(应该失败)
_, err := service.AssignRoles(ctx, superAdmin.ID, []uint{role.ID})
require.Error(t, err)
assert.Contains(t, err.Error(), "不需要分配角色")
assert.Contains(t, err.Error(), "超级管理员不允许分配角色")
}

View File

@@ -1,390 +0,0 @@
package unit
import (
"context"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// MockEmailPayload 邮件任务载荷(测试用)
type MockEmailPayload struct {
RequestID string `json:"request_id"`
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
CC []string `json:"cc,omitempty"`
}
// TestHandlerIdempotency 测试处理器幂等性逻辑
func TestHandlerIdempotency(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
ctx := context.Background()
rdb.FlushDB(ctx)
requestID := "test-req-001"
lockKey := constants.RedisTaskLockKey(requestID)
// 测试场景1: 第一次执行(未加锁)
t.Run("First Execution - Should Acquire Lock", func(t *testing.T) {
result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.True(t, result, "第一次执行应该成功获取锁")
})
// 测试场景2: 重复执行(已加锁)
t.Run("Duplicate Execution - Should Skip", func(t *testing.T) {
result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.False(t, result, "重复执行应该跳过(锁已存在)")
})
// 清理
rdb.Del(ctx, lockKey)
}
// TestHandlerErrorHandling 测试处理器错误处理
func TestHandlerErrorHandling(t *testing.T) {
tests := []struct {
name string
payload MockEmailPayload
shouldError bool
errorMsg string
}{
{
name: "Valid Payload",
payload: MockEmailPayload{
RequestID: "valid-001",
To: "test@example.com",
Subject: "Test",
Body: "Test Body",
},
shouldError: false,
},
{
name: "Missing RequestID",
payload: MockEmailPayload{
RequestID: "",
To: "test@example.com",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "request_id 不能为空",
},
{
name: "Missing To",
payload: MockEmailPayload{
RequestID: "test-002",
To: "",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "收件人不能为空",
},
{
name: "Invalid Email Format",
payload: MockEmailPayload{
RequestID: "test-003",
To: "invalid-email",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "邮箱格式无效",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证载荷
err := validateEmailPayload(&tt.payload)
if tt.shouldError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
require.NoError(t, err)
}
})
}
}
// validateEmailPayload 验证邮件载荷(模拟实际处理器中的验证逻辑)
func validateEmailPayload(payload *MockEmailPayload) error {
if payload.RequestID == "" {
return asynq.SkipRetry // 参数错误不重试
}
if payload.To == "" {
return asynq.SkipRetry
}
// 简单的邮箱格式验证
if payload.To != "" && !contains(payload.To, "@") {
return asynq.SkipRetry
}
return nil
}
func contains(s, substr string) bool {
for i := 0; i < len(s)-len(substr)+1; i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestHandlerRetryLogic 测试重试逻辑
func TestHandlerRetryLogic(t *testing.T) {
tests := []struct {
name string
error error
shouldRetry bool
}{
{
name: "Retryable Error - Network Issue",
error: assert.AnError,
shouldRetry: true,
},
{
name: "Non-Retryable Error - Invalid Params",
error: asynq.SkipRetry,
shouldRetry: false,
},
{
name: "No Error",
error: nil,
shouldRetry: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldRetry := tt.error != nil && tt.error != asynq.SkipRetry
assert.Equal(t, tt.shouldRetry, shouldRetry)
})
}
}
// TestPayloadDeserialization 测试载荷反序列化
func TestPayloadDeserialization(t *testing.T) {
tests := []struct {
name string
jsonPayload string
expectError bool
}{
{
name: "Valid JSON",
jsonPayload: `{"request_id":"test-001","to":"test@example.com","subject":"Test","body":"Body"}`,
expectError: false,
},
{
name: "Invalid JSON",
jsonPayload: `{invalid json}`,
expectError: true,
},
{
name: "Empty JSON",
jsonPayload: `{}`,
expectError: false, // JSON 解析成功,但验证会失败
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var payload MockEmailPayload
err := sonic.Unmarshal([]byte(tt.jsonPayload), &payload)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
// TestTaskStatusTransition 测试任务状态转换
func TestTaskStatusTransition(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
ctx := context.Background()
rdb.FlushDB(ctx)
taskID := "task-transition-001"
statusKey := constants.RedisTaskStatusKey(taskID)
// 状态转换序列
transitions := []struct {
status string
valid bool
}{
{"pending", true},
{"processing", true},
{"completed", true},
{"failed", false}, // completed 后不应该转到 failed
}
currentStatus := ""
for _, tr := range transitions {
t.Run("Transition to "+tr.status, func(t *testing.T) {
// 检查状态转换是否合法
if isValidTransition(currentStatus, tr.status) == tr.valid {
err := rdb.Set(ctx, statusKey, tr.status, 7*24*time.Hour).Err()
require.NoError(t, err)
currentStatus = tr.status
} else {
// 不合法的转换应该被拒绝
assert.False(t, tr.valid)
}
})
}
}
// isValidTransition 检查状态转换是否合法
func isValidTransition(from, to string) bool {
validTransitions := map[string][]string{
"": {"pending"},
"pending": {"processing", "failed"},
"processing": {"completed", "failed"},
"completed": {}, // 终态
"failed": {}, // 终态
}
allowed, exists := validTransitions[from]
if !exists {
return false
}
for _, valid := range allowed {
if valid == to {
return true
}
}
return false
}
// TestConcurrentTaskExecution 测试并发任务执行
func TestConcurrentTaskExecution(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
ctx := context.Background()
rdb.FlushDB(ctx)
// 模拟多个并发任务尝试获取同一个锁
requestID := "concurrent-test-001"
lockKey := constants.RedisTaskLockKey(requestID)
concurrency := 10
successCount := 0
done := make(chan bool, concurrency)
// 并发执行
for i := 0; i < concurrency; i++ {
go func() {
result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
if err == nil && result {
successCount++
}
done <- true
}()
}
// 等待所有 goroutine 完成
for i := 0; i < concurrency; i++ {
<-done
}
// 验证只有一个成功获取锁
assert.Equal(t, 1, successCount, "只有一个任务应该成功获取锁")
}
// TestTaskTimeout 测试任务超时处理
func TestTaskTimeout(t *testing.T) {
tests := []struct {
name string
taskDuration time.Duration
timeout time.Duration
shouldTimeout bool
}{
{
name: "Normal Execution",
taskDuration: 100 * time.Millisecond,
timeout: 1 * time.Second,
shouldTimeout: false,
},
{
name: "Timeout Execution",
taskDuration: 2 * time.Second,
timeout: 500 * time.Millisecond,
shouldTimeout: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
// 模拟任务执行
done := make(chan bool)
go func() {
time.Sleep(tt.taskDuration)
done <- true
}()
select {
case <-done:
assert.False(t, tt.shouldTimeout, "任务应该正常完成")
case <-ctx.Done():
assert.True(t, tt.shouldTimeout, "任务应该超时")
}
})
}
}
// TestLockExpiration 测试锁过期机制
func TestLockExpiration(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
ctx := context.Background()
rdb.FlushDB(ctx)
requestID := "expiration-test-001"
lockKey := constants.RedisTaskLockKey(requestID)
// 设置短 TTL 的锁
result, err := rdb.SetNX(ctx, lockKey, "1", 100*time.Millisecond).Result()
require.NoError(t, err)
assert.True(t, result)
// 等待锁过期
time.Sleep(200 * time.Millisecond)
// 验证锁已过期,可以重新获取
result, err = rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.True(t, result, "锁过期后应该可以重新获取")
}