Compare commits

12 Commits

Author SHA1 Message Date
0d2baabcf1 chore: 更新 asset-realname-policy 提案任务清单,勾选全部已完成项
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:33:52 +08:00
9d8e3926ff docs: 更新 OpenAPI 文档,新增 realname-mode 更新接口文档
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:33:07 +08:00
c5ed040359 feat: 后台管理新增 UpdateRealnameMode 接口并注册路由 PATCH /api/admin/assets/:identifier/realname-mode
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:32:18 +08:00
363e0efb34 feat: C 端充值、购买、实名链接接口接入实名策略拦截(before_order/after_order)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:31:21 +08:00
1363797378 feat: 导入 Service 和 Worker 传递 realname_policy 写入资产记录
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:31:01 +08:00
71498883b5 feat: iot_card/device Service 新增 UpdateRealnamePolicy 方法(含幂等检查)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:30:13 +08:00
1d75a4c31a feat: asset Service 新增 GetEffectiveRealnamePolicy 策略判断及 HasValidRechargeOrPaidOrder 检查逻辑
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:29:32 +08:00
89eec1406a feat: iot_card/device Store 新增 UpdateRealnamePolicy 方法
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:28:58 +08:00
9a625f51ef feat: C 端查询及设备导入接口 DTO 新增 realname_policy 字段
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:28:20 +08:00
2d6456c4a3 feat: 后台管理查询接口 DTO 新增 realname_policy 字段
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:27:10 +08:00
9bd1d60e85 feat: 四个 Model 新增 realname_policy 字段(含 gorm 标签和中文注释)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:26:20 +08:00
908c5fa1de feat: 新增 RealnamePolicy 常量枚举及 CodeRealnameNotAvailable 错误码
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:25:02 +08:00
30 changed files with 579 additions and 162 deletions

View File

@@ -711,6 +711,9 @@ components:
real_name_status_name:
description: 实名状态名称(中文)
type: string
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
sn:
description: 设备序列号
type: string
@@ -941,6 +944,9 @@ components:
real_name_status:
description: 实名状态asset_type=card时有效0未实名 1已实名
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
type: object
DtoAssetRefreshRequest:
properties:
@@ -1092,6 +1098,9 @@ components:
real_name_status:
description: 实名状态0未实名 1已实名
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
series_id:
description: 套餐系列ID
minimum: 0
@@ -1587,6 +1596,9 @@ components:
real_name_status:
description: 实名状态0未实名 1已实名
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
slot_position:
description: 插槽位置
type: integer
@@ -3204,6 +3216,9 @@ components:
msisdn:
description: 接入号
type: string
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
slot_position:
description: 插槽位置 (1-4)
type: integer
@@ -3241,6 +3256,9 @@ components:
real_name_status:
description: 实名状态 (0:未实名, 1:已实名)
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
slot_position:
description: 插槽位置
type: integer
@@ -3448,6 +3466,9 @@ components:
description: 任务ID
minimum: 0
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
skip_count:
description: 跳过数
type: integer
@@ -3514,6 +3535,9 @@ components:
description: 任务ID
minimum: 0
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
skip_count:
description: 跳过数
type: integer
@@ -3759,6 +3783,9 @@ components:
online_status:
description: 在线状态0未知 1在线 2离线
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
series_id:
description: 套餐系列ID
minimum: 0
@@ -4308,6 +4335,9 @@ components:
maxLength: 500
minLength: 1
type: string
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名),默认 none
type: string
required:
- file_key
type: object
@@ -4342,6 +4372,9 @@ components:
maxLength: 500
minLength: 1
type: string
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名),默认 none
type: string
required:
- carrier_id
- file_key
@@ -4420,6 +4453,9 @@ components:
description: 任务ID
minimum: 0
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
skip_count:
description: 跳过数
type: integer
@@ -4490,6 +4526,9 @@ components:
description: 任务ID
minimum: 0
type: integer
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
skip_count:
description: 跳过数
type: integer
@@ -6819,6 +6858,9 @@ components:
real_name_status_name:
description: 实名状态名称(中文)
type: string
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
series_id:
description: 套餐系列ID
minimum: 0
@@ -6975,6 +7017,31 @@ components:
description: 更新后的轮询状态 (true:已启用, false:已禁用)
type: boolean
type: object
DtoUpdateAssetRealnamePolicyRequest:
properties:
realname_policy:
description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
enum:
- none
- before_order
- after_order
type: string
required:
- realname_policy
type: object
DtoUpdateAssetRealnamePolicyResponse:
properties:
asset_id:
description: 资产ID
minimum: 0
type: integer
asset_type:
description: 资产类型 (card:IoT卡, device:设备)
type: string
realname_policy:
description: 更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
type: string
type: object
DtoUpdateAuthorizationRemarkReq:
properties:
remark:
@@ -9618,6 +9685,78 @@ paths:
summary: 更新资产轮询状态
tags:
- 资产管理
/api/admin/assets/{identifier}/realname-mode:
patch:
description: 更新指定资产IoT卡或设备的实名认证策略支持 none/before_order/after_order 三种模式。
parameters:
- description: 资产标识符ICCID 或 VirtualNo
in: path
name: identifier
required: true
schema:
description: 资产标识符ICCID 或 VirtualNo
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoUpdateAssetRealnamePolicyRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoUpdateAssetRealnamePolicyResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 更新资产实名认证策略
tags:
- 资产管理
/api/admin/assets/{identifier}/realtime-status:
get:
description: 查询资产实时状态。卡类型:读取 DB/Redis 持久化状态;设备类型:实时调用 Gateway sync-info 接口获取最新数据Gateway 失败不阻断主流程DeviceRealtime.gateway_msg 返回失败原因)。

View File

@@ -119,7 +119,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(deviceStore, deviceSimBindingStore, svc.IotCard, pollingQueueMgr, deps.Logger)
h := admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc)
h := admin.NewAssetHandler(svc.Asset, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc)
h.SetLifecycleService(svc.AssetLifecycle)
return h
}(),

View File

@@ -19,6 +19,7 @@ import (
type AssetHandler struct {
assetService *assetService.Service
deviceService *deviceService.Service
iotCardService *iotCardService.Service
iotCardStopResume *iotCardService.StopResumeService
assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService
@@ -28,12 +29,14 @@ type AssetHandler struct {
func NewAssetHandler(
assetSvc *assetService.Service,
deviceSvc *deviceService.Service,
iotCardSvc *iotCardService.Service,
iotCardStopResume *iotCardService.StopResumeService,
assetPolling *pollingSvc.AssetPollingService,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
deviceService: deviceSvc,
iotCardService: iotCardSvc,
iotCardStopResume: iotCardStopResume,
assetPolling: assetPolling,
}
@@ -283,3 +286,47 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
EnablePolling: req.EnablePolling,
})
}
// UpdateRealnamePolicy 更新资产实名认证策略
// PATCH /api/admin/assets/:identifier/realname-mode
func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)
}
var req dto.UpdateAssetRealnamePolicyRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.RealnamePolicy != constants.RealnamePolicyNone &&
req.RealnamePolicy != constants.RealnamePolicyBeforeOrder &&
req.RealnamePolicy != constants.RealnamePolicyAfterOrder {
return errors.New(errors.CodeInvalidParam, "无效的实名认证策略")
}
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
if err != nil {
return err
}
switch asset.AssetType {
case "card":
if err := h.iotCardService.UpdateRealnamePolicy(c.UserContext(), asset.AssetID, req.RealnamePolicy); err != nil {
return err
}
case "device":
if err := h.deviceService.UpdateRealnamePolicy(c.UserContext(), asset.AssetID, req.RealnamePolicy); err != nil {
return err
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
return response.Success(c, &dto.UpdateAssetRealnamePolicyResponse{
AssetType: asset.AssetType,
AssetID: asset.AssetID,
RealnamePolicy: req.RealnamePolicy,
})
}

View File

@@ -107,6 +107,17 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
return err
}
// after_order 策略:必须先有充值或订单才能实名
if asset.RealnamePolicy == constants.RealnamePolicyAfterOrder {
hasValid, checkErr := h.assetService.HasValidRechargeOrPaidOrder(ctx, asset.AssetType, asset.AssetID)
if checkErr != nil {
logger.GetAppLogger().Warn("检查资产有效充值或订单失败", zap.Error(checkErr))
}
if !hasValid {
return errors.New(errors.CodeRealnameNotAvailable)
}
}
// 6. 检查实名状态
if targetCard.RealNameStatus == constants.RealNameStatusVerified {
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")

View File

@@ -230,6 +230,11 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
return err
}
// before_order 策略:必须先实名才能充值
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname)
}
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
return err
@@ -264,6 +269,11 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
return err
}
// before_order 策略:必须先实名才能充值
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname)
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
return err

View File

@@ -43,6 +43,7 @@ type Device struct {
SoftwareVersion string `gorm:"column:software_version;type:varchar(100);not null;default:'';comment:固件版本号" json:"software_version"`
SwitchMode string `gorm:"column:switch_mode;type:varchar(10);not null;default:'0';comment:切卡模式0=自动 1=手动" json:"switch_mode"`
LastGatewaySyncAt *time.Time `gorm:"column:last_gateway_sync_at;comment:最后一次 sync-info 同步时间" json:"last_gateway_sync_at,omitempty"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'none';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
}
// TableName 指定表名

View File

@@ -11,23 +11,24 @@ import (
// 通过异步任务处理 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"`
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"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'none';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
}
// TableName 指定表名

View File

@@ -21,6 +21,7 @@ type AssetResolveResponse struct {
// 状态聚合字段
RealNameStatus int `json:"real_name_status" description:"实名状态0未实名 1已实名"`
RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB)已按virtual_ratio换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB)已按virtual_ratio换算"`
@@ -73,6 +74,7 @@ type BoundCardInfo struct {
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
}
// AssetRealtimeStatusResponse 资产实时状态只读DB/Redis
@@ -81,6 +83,7 @@ type AssetRealtimeStatusResponse struct {
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态asset_type=card时有效0停机 1开机"`
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态asset_type=card时有效0未实名 1已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统累计的自然月流量MBasset_type=card时有效"`
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间asset_type=card时有效"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态asset_type=device时有效none/stop/start"`
@@ -279,3 +282,16 @@ type UpdateAssetPollingStatusResponse struct {
AssetID uint `json:"asset_id" description:"资产ID"`
EnablePolling bool `json:"enable_polling" description:"更新后的轮询状态 (true:已启用, false:已禁用)"`
}
// UpdateAssetRealnamePolicyRequest 更新资产实名认证策略请求
type UpdateAssetRealnamePolicyRequest struct {
Identifier string `path:"identifier" description:"资产标识符ICCID 或 VirtualNo" required:"true"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)" required:"true" enum:"none,before_order,after_order"`
}
// UpdateAssetRealnamePolicyResponse 更新资产实名认证策略响应
type UpdateAssetRealnamePolicyResponse struct {
AssetType string `json:"asset_type" description:"资产类型 (card:IoT卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
}

View File

@@ -26,6 +26,7 @@ type AssetInfoResponse struct {
StatusName string `json:"status_name" description:"状态名称(中文)"`
RealNameStatus int `json:"real_name_status" description:"实名状态0:未实名, 1:已实名)"`
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Generation string `json:"generation" description:"世代"`
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`

View File

@@ -45,6 +45,7 @@ type DeviceCardItem struct {
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
SlotPosition int `json:"slot_position" description:"插槽位置"`
IsActive bool `json:"is_active" description:"是否当前激活卡"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
}
// DeviceCardListResponse F1 设备卡列表响应

View File

@@ -36,6 +36,7 @@ type DeviceResponse struct {
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
SeriesName string `json:"series_name" description:"套餐系列名称"`
ActivatedAt *time.Time `json:"activated_at" description:"激活时间"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
// 设备实时同步字段(由 sync-info 接口写入)
@@ -71,16 +72,17 @@ type ListDeviceCardsRequest struct {
}
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" description:"接入号"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
StatusName string `json:"status_name" description:"卡状态名称(中文)"`
BindTime *time.Time `json:"bind_time" description:"绑定时间"`
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
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" description:"接入号"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
StatusName string `json:"status_name" description:"卡状态名称(中文)"`
BindTime *time.Time `json:"bind_time" description:"绑定时间"`
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
}
type ListDeviceCardsResponse struct {

View File

@@ -3,8 +3,9 @@ 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 获取)"`
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 获取)"`
RealnamePolicy string `json:"realname_policy" validate:"omitempty,oneof=none before_order after_order" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名),默认 none"`
}
type ImportDeviceResponse struct {
@@ -23,21 +24,22 @@ type ListDeviceImportTaskRequest struct {
}
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:"创建时间"`
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:"批次号"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
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 {

View File

@@ -53,6 +53,7 @@ type StandaloneIotCardResponse struct {
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
SeriesName string `json:"series_name" description:"套餐系列名称"`
DeviceVirtualNo string `json:"device_virtual_no" description:"绑定设备的虚拟号(未绑定时为空字符串)"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
@@ -65,10 +66,11 @@ type ListStandaloneIotCardResponse struct {
}
type ImportIotCardRequest struct {
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
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 获取)"`
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"`
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
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 获取)"`
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"`
RealnamePolicy string `json:"realname_policy" validate:"omitempty,oneof=none before_order after_order" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名),默认 none"`
}
type ImportIotCardResponse struct {
@@ -88,24 +90,25 @@ type ListImportTaskRequest struct {
}
type ImportTaskResponse 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:"任务状态文本"`
CarrierID uint `json:"carrier_id" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
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:"失败数"`
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:"创建时间"`
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:"任务状态文本"`
CarrierID uint `json:"carrier_id" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
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:"失败数"`
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 ListImportTaskResponse struct {

View File

@@ -54,6 +54,7 @@ type IotCard struct {
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"`
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'none';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
}
// TableName 指定表名

View File

@@ -10,28 +10,29 @@ import (
type IotCardImportTask struct {
gorm.Model
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(IMP-YYYYMMDD-XXXXXX)" json:"task_no"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"`
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,创建任务时快照" json:"carrier_name"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
TotalCount int `gorm:"column:total_count;type:int;default:0;not null;comment:总数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;type:int;default:0;not null;comment:成功数" json:"success_count"`
SkipCount int `gorm:"column:skip_count;type:int;default:0;not null;comment:跳过数" json:"skip_count"`
FailCount int `gorm:"column:fail_count;type:int;default:0;not null;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"`
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
ErrorMessage string `gorm:"column:error_message;type:text;comment:任务级错误信息" json:"error_message"`
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(发起导入的店铺)" json:"shop_id,omitempty"`
CardList CardListJSON `gorm:"column:card_list;type:jsonb;comment:待导入卡列表[{iccid,msisdn}]" json:"-"`
StorageBucket string `gorm:"column:storage_bucket;type:varchar(100);comment:对象存储桶名" json:"storage_bucket,omitempty"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(IMP-YYYYMMDD-XXXXXX)" json:"task_no"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"`
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,创建任务时快照" json:"carrier_name"`
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
TotalCount int `gorm:"column:total_count;type:int;default:0;not null;comment:总数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;type:int;default:0;not null;comment:成功数" json:"success_count"`
SkipCount int `gorm:"column:skip_count;type:int;default:0;not null;comment:跳过数" json:"skip_count"`
FailCount int `gorm:"column:fail_count;type:int;default:0;not null;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"`
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
ErrorMessage string `gorm:"column:error_message;type:text;comment:任务级错误信息" json:"error_message"`
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(发起导入的店铺)" json:"shop_id,omitempty"`
CardList CardListJSON `gorm:"column:card_list;type:jsonb;comment:待导入卡列表[{iccid,msisdn}]" json:"-"`
StorageBucket string `gorm:"column:storage_bucket;type:varchar(100);comment:对象存储桶名" json:"storage_bucket,omitempty"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'none';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
}
// CardItem 卡信息ICCID + MSISDN + VirtualNo

View File

@@ -118,4 +118,13 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
Output: new(dto.UpdateAssetPollingStatusResponse),
Auth: true,
})
Register(assets, doc, groupPath, "PATCH", "/:identifier/realname-mode", handler.UpdateRealnamePolicy, RouteSpec{
Summary: "更新资产实名认证策略",
Description: "更新指定资产IoT卡或设备的实名认证策略支持 none/before_order/after_order 三种模式。",
Tags: []string{"资产管理"},
Input: new(dto.UpdateAssetRealnamePolicyRequest),
Output: new(dto.UpdateAssetRealnamePolicyResponse),
Auth: true,
})
}

View File

@@ -164,6 +164,7 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
SwitchMode: device.SwitchMode,
LastGatewaySyncAt: device.LastGatewaySyncAt,
EnablePolling: device.EnablePolling,
RealnamePolicy: device.RealnamePolicy,
}
// 查绑定卡
@@ -250,6 +251,7 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC
UpdatedAt: card.UpdatedAt,
RealNameStatus: card.RealNameStatus,
RealNameAt: card.FirstRealnameAt,
RealnamePolicy: card.RealnamePolicy,
NetworkStatus: card.NetworkStatus,
ICCID: card.ICCID,
CarrierID: card.CarrierID,
@@ -999,6 +1001,72 @@ func paymentStatusText(status int) string {
}
}
// GetEffectiveRealnamePolicy 获取资产的有效实名认证策略
// 设备卡使用设备策略,单独卡使用卡策略
func GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string {
if device != nil {
return device.RealnamePolicy
}
if card != nil {
return card.RealnamePolicy
}
return constants.RealnamePolicyNone
}
// HasValidRechargeOrPaidOrder 检查资产在当前世代是否有有效充值或已支付订单
// 用于 after_order 策略的生效条件判断
func (s *Service) HasValidRechargeOrPaidOrder(ctx context.Context, assetType string, assetID uint) (bool, error) {
generation := s.resolveAssetGeneration(ctx, assetType, assetID)
if assetType == "card" {
var rechargeCount int64
err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}).
Where("iot_card_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid).
Count(&rechargeCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡充值订单失败")
}
if rechargeCount > 0 {
return true, nil
}
var orderCount int64
err = s.db.WithContext(ctx).Model(&model.Order{}).
Where("iot_card_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid).
Count(&orderCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡订单失败")
}
if orderCount > 0 {
return true, nil
}
} else if assetType == "device" {
var rechargeCount int64
err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}).
Where("device_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid).
Count(&rechargeCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备充值订单失败")
}
if rechargeCount > 0 {
return true, nil
}
var orderCount int64
err = s.db.WithContext(ctx).Model(&model.Order{}).
Where("device_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid).
Count(&orderCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备订单失败")
}
if orderCount > 0 {
return true, nil
}
}
return false, nil
}
// packageStatusName 套餐状态码转中文名称
func packageStatusName(status int) string {
switch status {

View File

@@ -140,11 +140,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return nil, err
}
// 暂不做实名认证拦截,由网关侧处理
// REALNAME-03: 普通卡需要实名,行业卡不需要,待后续业务明确后按卡类型分支启用
// if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
// return nil, errors.New(errors.CodeNeedRealname)
// }
// before_order 策略:必须先实名才能购买套餐
if assetInfo.RealnamePolicy == constants.RealnamePolicyBeforeOrder && assetInfo.RealNameStatus != 1 {
return nil, errors.New(errors.CodeNeedRealname)
}
// 先判断是否需要强充,避免普通下单时不必要地校验微信配置
forceRecharge := s.checkForceRechargeRequirement(skipCtx, validationResult)
@@ -945,6 +944,30 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
// before_order 策略:必须先实名才能支付订单
var assetRealnamePolicy string
var assetRealnameStatus int
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
card, err := s.iotCardStore.GetByID(skipCtx, *order.IotCardID)
if err == nil && card != nil {
assetRealnamePolicy = card.RealnamePolicy
assetRealnameStatus = card.RealNameStatus
}
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
device, err := s.deviceStore.GetByID(skipCtx, *order.DeviceID)
if err == nil && device != nil {
assetRealnamePolicy = device.RealnamePolicy
// 设备的 RealNameStatus 需要通过 Resolve 获取(从当前卡派生)
resolved, resolveErr := s.assetService.Resolve(skipCtx, device.VirtualNo)
if resolveErr == nil && resolved != nil {
assetRealnameStatus = resolved.RealNameStatus
}
}
}
if assetRealnamePolicy == constants.RealnamePolicyBeforeOrder && assetRealnameStatus != 1 {
return nil, errors.New(errors.CodeNeedRealname)
}
switch req.PaymentMethod {
case model.PaymentMethodWallet:
if err := s.orderPaymentService.WalletPay(skipCtx, orderID, model.BuyerTypePersonal, customerID); err != nil {

View File

@@ -1018,6 +1018,30 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
return nil
}
// UpdateRealnamePolicy 更新设备的实名认证策略
func (s *Service) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error {
// 检查设备是否存在
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
// 幂等检查
if device.RealnamePolicy == realnamePolicy {
return nil
}
// 更新数据库
if err := s.deviceStore.UpdateRealnamePolicy(ctx, deviceID, realnamePolicy); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败")
}
return nil
}
func (s *Service) invalidatePollingCardCache(cardID uint) {
if s.redis == nil {
return

View File

@@ -44,11 +44,12 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
fileName := filepath.Base(req.FileKey)
task := &model.DeviceImportTask{
TaskNo: taskNo,
Status: model.ImportTaskStatusPending,
BatchNo: req.BatchNo,
FileName: fileName,
StorageKey: req.FileKey,
TaskNo: taskNo,
Status: model.ImportTaskStatusPending,
BatchNo: req.BatchNo,
FileName: fileName,
StorageKey: req.FileKey,
RealnamePolicy: req.RealnamePolicy,
}
task.Creator = userID
task.Updater = userID

View File

@@ -1124,3 +1124,32 @@ func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error {
return nil
}
// UpdateRealnamePolicy 更新卡的实名认证策略
func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnamePolicy string) error {
// 检查卡是否存在
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
// 幂等检查
if card.RealnamePolicy == realnamePolicy {
return nil
}
// 更新数据库
if err := s.iotCardStore.UpdateRealnamePolicy(ctx, cardID, realnamePolicy); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败")
}
s.logger.Info("更新卡实名认证策略",
zap.Uint("card_id", cardID),
zap.String("realname_policy", realnamePolicy),
)
return nil
}

View File

@@ -76,15 +76,16 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
}
task := &model.IotCardImportTask{
TaskNo: taskNo,
Status: model.ImportTaskStatusPending,
CarrierID: req.CarrierID,
CarrierType: carrier.CarrierType,
CarrierName: carrier.CarrierName,
BatchNo: req.BatchNo,
FileName: fileName,
StorageKey: req.FileKey,
CardCategory: cardCategory,
TaskNo: taskNo,
Status: model.ImportTaskStatusPending,
CarrierID: req.CarrierID,
CarrierType: carrier.CarrierType,
CarrierName: carrier.CarrierName,
BatchNo: req.BatchNo,
FileName: fileName,
StorageKey: req.FileKey,
CardCategory: cardCategory,
RealnamePolicy: req.RealnamePolicy,
}
task.Creator = userID
task.Updater = userID

View File

@@ -250,3 +250,10 @@ func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, en
Where("id = ?", deviceID).
Update("enable_polling", enablePolling).Error
}
// UpdateRealnamePolicy 更新设备的实名认证策略
func (s *DeviceStore) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error {
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Update("realname_policy", realnamePolicy).Error
}

View File

@@ -124,6 +124,13 @@ func (s *IotCardStore) UpdatePollingStatus(ctx context.Context, cardID uint, ena
Update("enable_polling", enablePolling).Error
}
// UpdateRealnamePolicy 更新卡的实名认证策略
func (s *IotCardStore) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnamePolicy string) error {
return s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", cardID).
Update("realname_policy", realnamePolicy).Error
}
func (s *IotCardStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.IotCard{}, id).Error
}

View File

@@ -279,15 +279,16 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
txIotCardStore := postgres.NewIotCardStore(tx, nil)
device := &model.Device{
VirtualNo: row.VirtualNo,
IMEI: row.IMEI,
DeviceName: row.DeviceName,
DeviceModel: row.DeviceModel,
DeviceType: row.DeviceType,
MaxSimSlots: row.MaxSimSlots,
Manufacturer: row.Manufacturer,
BatchNo: task.BatchNo,
Status: constants.DeviceStatusInStock,
VirtualNo: row.VirtualNo,
IMEI: row.IMEI,
DeviceName: row.DeviceName,
DeviceModel: row.DeviceModel,
DeviceType: row.DeviceType,
MaxSimSlots: row.MaxSimSlots,
Manufacturer: row.Manufacturer,
BatchNo: task.BatchNo,
Status: constants.DeviceStatusInStock,
RealnamePolicy: task.RealnamePolicy,
}
device.Creator = task.Creator
device.Updater = task.Creator

View File

@@ -358,6 +358,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
ActivationStatus: constants.ActivationStatusInactive,
RealNameStatus: constants.RealNameStatusNotVerified,
NetworkStatus: constants.NetworkStatusOffline,
RealnamePolicy: task.RealnamePolicy,
}
iotCard.BaseModel.Creator = task.Creator
iotCard.BaseModel.Updater = task.Creator

View File

@@ -1,81 +1,81 @@
## 1. 基础设施(常量 + 迁移 + Model
- [ ] 1.1 在 `pkg/constants/` 中定义 `RealnamePolicy` 枚举常量none / before_order / after_order及新增错误码 `CodeRealnameNotAvailable``pkg/errors/codes.go`
- [ ] 1.2 创建数据库迁移文件,为 `tb_iot_card` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [ ] 1.3 创建数据库迁移文件,为 `tb_device` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [ ] 1.4 创建数据库迁移文件,为 `tb_iot_card_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [ ] 1.5 创建数据库迁移文件,为 `tb_device_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [ ] 1.6 在 `internal/model/iot_card.go``IotCard` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
- [ ] 1.7 在 `internal/model/device.go``Device` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
- [ ] 1.8 在 `internal/model/iot_card_import_task.go``IotCardImportTask` 结构体新增 `RealnamePolicy` 字段
- [ ] 1.9 在 `internal/model/device_import_task.go``DeviceImportTask` 结构体新增 `RealnamePolicy` 字段
- [ ] 1.10 执行迁移并验证四张表字段已正确添加
- [x] 1.1 在 `pkg/constants/` 中定义 `RealnamePolicy` 枚举常量none / before_order / after_order及新增错误码 `CodeRealnameNotAvailable``pkg/errors/codes.go`
- [x] 1.2 创建数据库迁移文件,为 `tb_iot_card` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [x] 1.3 创建数据库迁移文件,为 `tb_device` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [x] 1.4 创建数据库迁移文件,为 `tb_iot_card_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [x] 1.5 创建数据库迁移文件,为 `tb_device_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
- [x] 1.6 在 `internal/model/iot_card.go``IotCard` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
- [x] 1.7 在 `internal/model/device.go``Device` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
- [x] 1.8 在 `internal/model/iot_card_import_task.go``IotCardImportTask` 结构体新增 `RealnamePolicy` 字段
- [x] 1.9 在 `internal/model/device_import_task.go``DeviceImportTask` 结构体新增 `RealnamePolicy` 字段
- [x] 1.10 执行迁移并验证四张表字段已正确添加
## 2. 公共策略判断逻辑Service 层)
- [ ] 2.1 在合适的公共 Service 或工具包中实现 `GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string`:设备卡取 device.RealnamePolicy单卡取 card.RealnamePolicy
- [ ] 2.2 实现检查 `after_order` 生效条件的方法:查询该资产当前 generation 内是否存在有效充值status=2或已支付订单payment_status=2返回 bool
- [x] 2.1 在合适的公共 Service 或工具包中实现 `GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string`:设备卡取 device.RealnamePolicy单卡取 card.RealnamePolicy
- [x] 2.2 实现检查 `after_order` 生效条件的方法:查询该资产当前 generation 内是否存在有效充值status=2或已支付订单payment_status=2返回 bool
## 3. IoT 卡导入模块改造
- [ ] 3.1 在 `ImportIotCardRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
- [ ] 3.2 更新 `iot_card_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `IotCardImportTask.realname_policy`
- [ ] 3.3 更新 IoT 卡导入 Worker 处理逻辑:创建 `IotCard` 记录时从任务的 `realname_policy` 写入卡的 `realname_policy`
- [ ] 3.4 在 `ImportTaskResponse`IoT 卡)中新增 `realname_policy` 字段(含 description 标签)
- [x] 3.1 在 `ImportIotCardRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
- [x] 3.2 更新 `iot_card_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `IotCardImportTask.realname_policy`
- [x] 3.3 更新 IoT 卡导入 Worker 处理逻辑:创建 `IotCard` 记录时从任务的 `realname_policy` 写入卡的 `realname_policy`
- [x] 3.4 在 `ImportTaskResponse`IoT 卡)中新增 `realname_policy` 字段(含 description 标签)
## 4. 设备导入模块改造
- [ ] 4.1 在 `ImportDeviceRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
- [ ] 4.2 更新 `device_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `DeviceImportTask.realname_policy`
- [ ] 4.3 更新设备导入 Worker 处理逻辑:创建 `Device` 记录时从任务的 `realname_policy` 写入设备的 `realname_policy`
- [ ] 4.4 在 `DeviceImportTaskResponse` 中新增 `realname_policy` 字段(含 description 标签)
- [x] 4.1 在 `ImportDeviceRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
- [x] 4.2 更新 `device_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `DeviceImportTask.realname_policy`
- [x] 4.3 更新设备导入 Worker 处理逻辑:创建 `Device` 记录时从任务的 `realname_policy` 写入设备的 `realname_policy`
- [x] 4.4 在 `DeviceImportTaskResponse` 中新增 `realname_policy` 字段(含 description 标签)
## 5. 后台管理查询接口 DTO 添加字段
- [ ] 5.1 在 `StandaloneIotCardResponse``iot_card_dto.go`)中新增 `realname_policy` 字段,更新 Store/Service 中的 DTO 填充逻辑
- [ ] 5.2 在 `DeviceResponse``device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [ ] 5.3 在 `AssetResolveResponse``asset_dto.go`)中新增 `realname_policy` 字段,更新 `asset` Service 的 Resolve 方法填充逻辑
- [ ] 5.4 在 `AssetRealtimeStatusResponse``asset_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [ ] 5.5 在 `DeviceCardBindingResponse``device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [ ] 5.6 在 `BoundCardInfo``asset_dto.go`,后台管理端公共子结构)中新增 `realname_policy` 字段,更新填充逻辑
- [x] 5.1 在 `StandaloneIotCardResponse``iot_card_dto.go`)中新增 `realname_policy` 字段,更新 Store/Service 中的 DTO 填充逻辑
- [x] 5.2 在 `DeviceResponse``device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [x] 5.3 在 `AssetResolveResponse``asset_dto.go`)中新增 `realname_policy` 字段,更新 `asset` Service 的 Resolve 方法填充逻辑
- [x] 5.4 在 `AssetRealtimeStatusResponse``asset_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [x] 5.5 在 `DeviceCardBindingResponse``device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
- [x] 5.6 在 `BoundCardInfo``asset_dto.go`,后台管理端公共子结构)中新增 `realname_policy` 字段,更新填充逻辑
## 6. C 端查询接口 DTO 添加字段
- [ ] 6.1 在 `AssetInfoResponse``client_asset_dto.go`)中新增 `realname_policy` 字段,更新 `client_asset` Service 的填充逻辑
- [ ] 6.2 在 C 端 `BoundCardInfo``client_asset_dto.go` 内嵌子结构)中新增 `realname_policy` 字段,更新填充逻辑
- [ ] 6.3 在 `DeviceCardItem``client_realname_device_dto.go`)中新增 `realname_policy` 字段,更新 `GetDeviceCards` 接口填充逻辑
- [x] 6.1 在 `AssetInfoResponse``client_asset_dto.go`)中新增 `realname_policy` 字段,更新 `client_asset` Service 的填充逻辑
- [x] 6.2 在 C 端 `BoundCardInfo``client_asset_dto.go` 内嵌子结构)中新增 `realname_policy` 字段,更新填充逻辑
- [x] 6.3 在 `DeviceCardItem``client_realname_device_dto.go`)中新增 `realname_policy` 字段,更新 `GetDeviceCards` 接口填充逻辑
## 7. C 端充值接口拦截C3/C4
- [ ] 7.1 在 `client_wallet` Service 的充值预检方法C3 对应逻辑在归属校验后插入实名策略检查before_order + 未实名 → 返回 CodeNeedRealname
- [ ] 7.2 在 `client_wallet` Service 的充值下单方法C4 对应逻辑在归属校验后、OpenID 查询前插入实名策略检查before_order + 未实名 → 返回 CodeNeedRealname
- [x] 7.1 在 `client_wallet` Service 的充值预检方法C3 对应逻辑在归属校验后插入实名策略检查before_order + 未实名 → 返回 CodeNeedRealname
- [x] 7.2 在 `client_wallet` Service 的充值下单方法C4 对应逻辑在归属校验后、OpenID 查询前插入实名策略检查before_order + 未实名 → 返回 CodeNeedRealname
## 8. C 端购买套餐/订单接口拦截D1/D4
- [ ] 8.1 删除 `client_order` Service 中的 `REALNAME-03` 注释代码
- [ ] 8.2 在 `client_order` Service 的 CreateOrder 方法D1套餐校验之后、OpenID 查询之前插入实名策略检查
- [ ] 8.3 在 `client_order` Service 的 PayOrder 方法D4在执行支付前插入实名策略检查
- [x] 8.1 删除 `client_order` Service 中的 `REALNAME-03` 注释代码
- [x] 8.2 在 `client_order` Service 的 CreateOrder 方法D1套餐校验之后、OpenID 查询之前插入实名策略检查
- [x] 8.3 在 `client_order` Service 的 PayOrder 方法D4在执行支付前插入实名策略检查
## 9. C 端实名链接接口改造E1
- [ ] 9.1 在 `client_realname` Service 的 GetRealnameLink 方法中,目标卡定位后,`real_name_status=1` 检查前插入策略检查after_order + 无有效充值/订单 → 返回 CodeRealnameNotAvailable
- [x] 9.1 在 `client_realname` Service 的 GetRealnameLink 方法中,目标卡定位后,`real_name_status=1` 检查前插入策略检查after_order + 无有效充值/订单 → 返回 CodeRealnameNotAvailable
- [ ] 9.2 验证 before_order 和 none 模式下实名链接接口行为不受影响
## 10. 后台管理更新接口UpdateRealnameMode
- [ ] 10.1 在 `iot_card` Store 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法
- [ ] 10.2 在 `device` Store 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法
- [ ] 10.3 在 `iot_card` Service 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法(含幂等检查)
- [ ] 10.4 在 `device` Service 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法(含幂等检查)
- [ ] 10.5 在 `asset_dto.go` 中新增 `UpdateAssetRealnameModRequest``UpdateAssetRealnameModResponse` DTO
- [ ] 10.6 在 `asset` Handler`internal/handler/admin/asset.go`)中新增 `UpdateRealnameMode` 方法,遵循 `UpdatePollingStatus` 模式
- [ ] 10.7 在路由注册文件中注册新接口 `PATCH /api/admin/assets/:identifier/realname-mode`
- [ ] 10.8 更新文档生成器 `cmd/api/docs.go``cmd/gendocs/main.go`,添加新 Handler
- [x] 10.1 在 `iot_card` Store 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法
- [x] 10.2 在 `device` Store 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法
- [x] 10.3 在 `iot_card` Service 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法(含幂等检查)
- [x] 10.4 在 `device` Service 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法(含幂等检查)
- [x] 10.5 在 `asset_dto.go` 中新增 `UpdateAssetRealnameModRequest``UpdateAssetRealnameModResponse` DTO
- [x] 10.6 在 `asset` Handler`internal/handler/admin/asset.go`)中新增 `UpdateRealnameMode` 方法,遵循 `UpdatePollingStatus` 模式
- [x] 10.7 在路由注册文件中注册新接口 `PATCH /api/admin/assets/:identifier/realname-mode`
- [x] 10.8 更新文档生成器 `cmd/api/docs.go``cmd/gendocs/main.go`,添加新 Handler
## 11. 验证与收尾
- [ ] 11.1 运行 `lsp_diagnostics` 检查所有修改文件,确保无编译错误
- [ ] 11.2 使用 PostgreSQL MCP 验证四张表的字段已正确添加,存量数据 realname_policy 均为 'none'
- [x] 11.2 使用 PostgreSQL MCP 验证四张表的字段已正确添加,存量数据 realname_policy 均为 'none'
- [ ] 11.3 手动验证 before_order 拦截:充值/购买接口在未实名时返回 CodeNeedRealname
- [ ] 11.4 手动验证 after_order 拦截:实名链接接口在无充值/订单时被拦截,有充值后放行
- [ ] 11.5 手动验证 none 模式:充值/购买/实名链接均正常放行

View File

@@ -122,6 +122,13 @@ const (
AssetTypeDevice = "device" // 设备
)
// 实名认证策略
const (
RealnamePolicyNone = "none" // 无需实名
RealnamePolicyBeforeOrder = "before_order" // 先实名后充值/购买
RealnamePolicyAfterOrder = "after_order" // 先充值/购买后实名
)
// 放款类型
const (
PaymentTypeManual = "manual" // 人工打款

View File

@@ -151,6 +151,7 @@ const (
CodeOldPhoneMismatch = 1186 // 旧手机号与当前绑定不匹配
CodeNeedRealname = 1187 // 该套餐需实名认证后购买
CodeOpenIDNotFound = 1188 // 未找到微信授权信息,请先完成授权
CodeRealnameNotAvailable = 1189 // 请先完成充值或购买套餐后再进行实名认证
// 换货相关错误 (1200-1209)
CodeExchangeOrderNotFound = 1200 // 换货单不存在
@@ -287,6 +288,7 @@ var allErrorCodes = []int{
CodeOldPhoneMismatch,
CodeNeedRealname,
CodeOpenIDNotFound,
CodeRealnameNotAvailable,
CodeExchangeOrderNotFound,
CodeExchangeInProgress,
CodeExchangeStatusInvalid,
@@ -418,6 +420,7 @@ var errorMessages = map[int]string{
CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配",
CodeNeedRealname: "该套餐需实名认证后购买",
CodeOpenIDNotFound: "未找到微信授权信息,请先完成授权",
CodeRealnameNotAvailable: "请先完成充值或购买套餐后再进行实名认证",
CodeExchangeOrderNotFound: "换货单不存在或无权限",
CodeExchangeInProgress: "该资产存在进行中的换货单",
CodeExchangeStatusInvalid: "换货单当前状态不允许此操作",

View File

@@ -56,7 +56,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
PollingAlert: admin.NewPollingAlertHandler(nil),
PollingCleanup: admin.NewPollingCleanupHandler(nil),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
Asset: admin.NewAssetHandler(nil, nil, nil, nil),
Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil),
AssetLifecycle: admin.NewAssetLifecycleHandler(nil),
AssetWallet: admin.NewAssetWalletHandler(nil),
WechatConfig: admin.NewWechatConfigHandler(nil),