feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s

- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
This commit is contained in:
2026-09-15 11:54:56 +08:00
parent 93e072e1e2
commit 70e680eb0a
67 changed files with 3951 additions and 147 deletions

View File

@@ -37,12 +37,13 @@ type AssetResolveResponse struct {
EnableVirtualData bool `json:"enable_virtual_data" description:"当前主套餐是否启用虚流量(按套餐使用记录快照返回)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态none/stop/start仅asset_type=device时有效"`
// 绑定关系字段
ICCID string `json:"iccid,omitempty" description:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_type=card时有效"`
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号asset_type=card时有效"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称asset_type=card时有效"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表asset_type=device时有效"`
AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组"`
ICCID string `json:"iccid,omitempty" description:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_type=card时有效"`
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号asset_type=card时有效"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称asset_type=card时有效)"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量asset_type=device时有效"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表asset_type=device时有效"`
// 设备专属字段card类型时为零值
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
IMEI string `json:"imei,omitempty" description:"设备IMEI"`

View File

@@ -58,11 +58,12 @@ type DeviceResponse struct {
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at" description:"最后 sync-info 同步时间"`
// asset_status=3 表示该资产已换货且尚未执行"旧资产转新",不再流通
// generation>1 表示该资产历史上曾执行过换货后转新,目前仍在使用
AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"`
AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"`
Generation int `json:"generation" description:"资产世代编号初始值1每次换货转新后+1"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID未授权时为 null"`
AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"`
AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"`
AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"`
Generation int `json:"generation" description:"资产世代编号初始值1每次换货转新后+1"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID未授权时为 null"`
AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"`
AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"`
}
type ListDeviceResponse struct {

View File

@@ -74,6 +74,7 @@ type StandaloneIotCardResponse struct {
Generation int `json:"generation" description:"资产世代编号初始值1每次换货转新后+1"`
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID未授权时为 null"`
AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"`
AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}

View File

@@ -0,0 +1,152 @@
package dto
import "time"
// ListPhoneAssetAssociationRequest 后台查询手机号—资产关联列表请求。
// 仅超级管理员与平台用户可调用,范围外资产不进入结果集,越权与不存在不形成可枚举差异。
type ListPhoneAssetAssociationRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识ICCID、虚拟号、IMEI、SN 或接入号,精确匹配)"`
Phone string `json:"phone" query:"phone" validate:"omitempty,max=20" maxLength:"20" description:"手机号(完整值精确匹配)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"关联状态 (0:已失效, 1:有效)"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"关联创建时间起(含)"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"关联创建时间止(含)"`
}
// PhoneAssetAssociationResponse 手机号—资产关联列表项。
// 手机号为读取范围内的完整值(读侧不脱敏);审计与日志仍只写脱敏值。
type PhoneAssetAssociationResponse struct {
ID uint `json:"id" description:"关联关系ID"`
Phone string `json:"phone" description:"关联手机号(完整值)"`
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
AssetIdentifier string `json:"asset_identifier" description:"资产当前标识(卡为 ICCID设备为虚拟号"`
Status int `json:"status" description:"关联状态 (0:已失效, 1:有效)"`
StatusName string `json:"status_name" description:"关联状态中文名称"`
Source string `json:"source" description:"建立来源,固定为 h5_sms_verification"`
EstablishedAt string `json:"established_at" description:"建立时间(短信验证通过时间)"`
InvalidatedAt string `json:"invalidated_at" description:"失效时间;有效关系为空"`
InvalidationMethod string `json:"invalidation_method" description:"失效方式 (backend_single:后台单项, backend_batch:后台批量, csv_import:CSV导入);有效关系为空"`
InvalidationMethodName string `json:"invalidation_method_name" description:"失效方式中文名称;有效关系为空"`
InvalidationReason string `json:"invalidation_reason" description:"失效原因;有效关系为空"`
}
// PhoneAssetAssociationPageResult 手机号—资产关联列表分页响应。
type PhoneAssetAssociationPageResult struct {
Items []*PhoneAssetAssociationResponse `json:"items" description:"关联列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// UnbindPhoneAssetAssociationRequest 单项解除手机号—资产关联请求。
// 路径主键即指定一条关系;解除必须二次确认并填写原因,缺一即拒绝。
type UnbindPhoneAssetAssociationRequest struct {
ID uint `json:"-" path:"id" description:"关联关系ID" required:"true" minimum:"1"`
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"解除原因1500 字符)"`
Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"`
}
// UnbindPhoneAssetAssociationResponse 单项解除结果。
type UnbindPhoneAssetAssociationResponse struct {
ID uint `json:"id" description:"关联关系ID"`
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
UnboundCount int `json:"unbound_count" description:"本次解除的有效关系数,成功时为 1"`
InvalidatedAt string `json:"invalidated_at" description:"失效时间"`
}
// BatchUnbindAssetItem 批量解除中的单项资产。
type BatchUnbindAssetItem struct {
AssetType string `json:"asset_type" validate:"required,oneof=iot_card device" required:"true" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" validate:"required,min=1" required:"true" minimum:"1" description:"资产ID"`
}
// BatchUnbindPhoneAssetAssociationRequest 按资产批量解除手机号关联请求。
// 每项资产解除其全部当前有效关系,逐资产独立执行,部分成功不回滚成功项。
type BatchUnbindPhoneAssetAssociationRequest struct {
Assets []BatchUnbindAssetItem `json:"assets" validate:"required,min=1,max=200,dive" required:"true" description:"待解除的资产集合,按 (资产类型, 资产ID) 去重后执行"`
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"解除原因1500 字符)"`
Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"`
}
// BatchUnbindAssetResult 批量解除的逐项结果。
type BatchUnbindAssetResult struct {
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
Success bool `json:"success" description:"该项是否解除成功"`
UnboundCount int `json:"unbound_count" description:"该项解除的有效关系数"`
Reason string `json:"reason,omitempty" description:"该项失败原因,成功时为空"`
}
// CreatePhoneAssetUnbindImportRequest 创建手机号—资产关联 CSV 解绑导入任务请求。
type CreatePhoneAssetUnbindImportRequest struct {
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"CSV 对象存储 Key必须以 phone-unbind-imports/ 开头且扩展名为 .csv通过 POST /api/admin/storage/upload-url 获取)"`
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"任务级解绑原因1500 字符),写入每次解除的失效原因"`
Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"`
}
// ListPhoneAssetUnbindImportRequest 查询解绑导入任务列表请求。
type ListPhoneAssetUnbindImportRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
}
// GetPhoneAssetUnbindImportRequest 解绑导入任务详情路径参数。
type GetPhoneAssetUnbindImportRequest struct {
ID uint `path:"id" required:"true" description:"导入任务ID"`
}
// PhoneAssetUnbindImportTaskResponse 解绑导入任务响应。
type PhoneAssetUnbindImportTaskResponse struct {
ID uint `json:"id" description:"导入任务ID"`
TaskNo string `json:"task_no" description:"导入任务编号"`
FileName string `json:"file_name" description:"上传的源 CSV 文件名"`
UnbindReason string `json:"unbind_reason" description:"任务级解绑原因"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
StatusName string `json:"status_name" description:"任务状态中文名称"`
TotalCount int `json:"total_count" description:"数据行总数;任务级失败时为 0"`
SuccessCount int `json:"success_count" description:"成功行数"`
FailCount int `json:"fail_count" description:"失败行数"`
ErrorMessage string `json:"error_message" description:"任务级失败原因;与行级失败原因分开记录"`
CreatorName string `json:"creator_name" description:"任务创建人名称快照"`
CreatedAt string `json:"created_at" description:"创建时间"`
StartedAt string `json:"started_at" description:"开始处理时间"`
CompletedAt string `json:"completed_at" description:"完成时间"`
}
// PhoneAssetUnbindImportTaskPageResult 解绑导入任务分页响应。
type PhoneAssetUnbindImportTaskPageResult struct {
Items []*PhoneAssetUnbindImportTaskResponse `json:"items" description:"导入任务列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// PhoneAssetUnbindImportItemResponse 解绑导入逐行结果。
// AssociatedPhones 是解绑当时的完整手机号快照:关系已失效,只有快照能事后展示被解绑的手机号。
type PhoneAssetUnbindImportItemResponse struct {
Line int `json:"line" description:"行号,自数据首行起计(表头不计入)"`
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备);未定位到资产时为空"`
AssetIdentifier string `json:"asset_identifier" description:"导入文件中的资产标识原文"`
AssetID uint `json:"asset_id" description:"资产ID未定位到资产时为 0"`
UnboundCount int `json:"unbound_count" description:"该行解除的有效关系数"`
AssociatedPhones []string `json:"associated_phones" description:"解绑当时的完整关联手机号快照;失败行为空数组"`
Status int `json:"status" description:"行状态 (3:成功, 4:失败)"`
StatusName string `json:"status_name" description:"行状态中文名称"`
Reason string `json:"reason" description:"失败原因;成功行为空"`
}
// PhoneAssetUnbindImportTaskDetailResponse 解绑导入任务详情响应。
type PhoneAssetUnbindImportTaskDetailResponse struct {
PhoneAssetUnbindImportTaskResponse
Items []PhoneAssetUnbindImportItemResponse `json:"items" description:"逐行结果明细;任务级失败时为空数组"`
}
type BatchUnbindPhoneAssetAssociationResponse struct {
SuccessCount int `json:"success_count" description:"解除成功的资产项数"`
FailCount int `json:"fail_count" description:"解除失败的资产项数"`
Items []BatchUnbindAssetResult `json:"items" description:"逐项结果,顺序与请求去重后的资产集合一致"`
}

View File

@@ -3,7 +3,7 @@ package dto
type GetUploadURLRequest struct {
FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名cards.csv"`
ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型text/csv留空则自动推断"`
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV)"`
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import phone_unbind_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import,phone_unbind_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV, phone_unbind_import:手机号资产解绑导入CSV)"`
}
type GetUploadURLResponse struct {