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

@@ -28,6 +28,10 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.ShopBusinessOwnerImport != nil {
registerShopBusinessOwnerImportRoutes(authGroup, handlers.ShopBusinessOwnerImport, doc, basePath)
}
// 手机号资产关联的静态路径必须先于 DELETE /:id 注册,避免导入任务路径被动态主键吞掉。
if handlers.PhoneAssetAssociation != nil {
registerPhoneAssetAssociationRoutes(authGroup, handlers.PhoneAssetAssociation, doc, basePath)
}
if handlers.Shop != nil {
registerShopRoutes(authGroup, handlers.Shop, doc, basePath)
}

View File

@@ -0,0 +1,107 @@
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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// phoneAssetUnbindImportDoc 说明手机号资产解绑 CSV 的模板要求。
// 模板由前端提供,后端只描述固定列序、取值与编码要求,不提供模板下载端点与模板资源。
const phoneAssetUnbindImportDoc = `仅超级管理员和平台账号可操作,代理与企业账号返回 403。
### 完整导入流程
1. **获取上传 URL**:调用 ` + "`POST /api/admin/storage/upload-url`" + `purpose 传 ` + "`phone_unbind_import`" + `
2. **上传 CSV**:使用返回的预签名 URL 上传文件到对象存储
3. **调用本接口**:使用返回的 ` + "`file_key`" + ` 创建导入任务(必须以 ` + "`phone-unbind-imports/`" + ` 开头且扩展名为 ` + "`.csv`" + `
### 模板列序(首行表头必须完全一致)
` + "`资产标识`" + `` + "`备注`" + `
- 资产标识:以 ICCID、虚拟号、IMEI、SN 或接入号唯一定位资产;不存在或已删除时该行失败
- 备注:可选,填写时写入该行审计
### 编码要求
文件编码为 UTF-8可带 BOM非 UTF-8 时按 GBK 尝试解码,仍失败时按任务级失败并给出明确原因。
### 执行语义
- 逐行独立事务:成功行提交,失败行保留原状,不设行数硬上限
- 每行解除该资产全部当前有效关联,解绑原因取任务级必填字段
- 行号自数据首行起计(表头不计入);任务级失败不产生行明细
- 逐行结果保存解绑当时的完整手机号快照,供任务详情与结果展示`
// registerPhoneAssetAssociationRoutes 注册手机号—资产关联查看与解除路由。
// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403。
func registerPhoneAssetAssociationRoutes(router fiber.Router, handler *admin.PhoneAssetAssociationHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/phone-asset-associations", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return c.Next()
})
groupPath := basePath + "/phone-asset-associations"
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询手机号资产关联列表",
Description: constants.PhoneAssetAssociationAccessDescription + "按资产数据范围过滤后分页返回资产(类型/ID/标识)、完整手机号、建立时间、建立来源与状态;支持资产标识、手机号、关联状态与创建时间区间筛选。越权与不存在不形成可枚举差异。",
Tags: []string{"手机号资产关联"},
Input: new(dto.ListPhoneAssetAssociationRequest),
Output: new(dto.PhoneAssetAssociationPageResult),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/batch-unbind", handler.BatchUnbind, RouteSpec{
Summary: "按资产批量解除手机号关联",
Description: constants.PhoneAssetAssociationAccessDescription + "资产集合按 (资产类型, 资产ID) 去重后逐项独立执行:每项解除该资产全部当前有效关联,返回成功数、失败数与逐项结果,部分成功不回滚成功项。越权、资产不存在与已无有效关系返回同一失败文案。" + constants.PhoneAssetAssociationDeniedMessage + "。",
Tags: []string{"手机号资产关联"},
Input: new(dto.BatchUnbindPhoneAssetAssociationRequest),
Output: new(dto.BatchUnbindPhoneAssetAssociationResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/unbind-imports", handler.CreateImportTask, RouteSpec{
Summary: "创建手机号资产解绑导入任务",
Description: phoneAssetUnbindImportDoc,
Tags: []string{"手机号资产关联"},
Input: new(dto.CreatePhoneAssetUnbindImportRequest),
Output: new(dto.PhoneAssetUnbindImportTaskResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/unbind-imports", handler.ListImportTasks, RouteSpec{
Summary: "查询手机号资产解绑导入任务列表",
Description: constants.PhoneAssetAssociationAccessDescription,
Tags: []string{"手机号资产关联"},
Input: new(dto.ListPhoneAssetUnbindImportRequest),
Output: new(dto.PhoneAssetUnbindImportTaskPageResult),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/unbind-imports/:id", handler.ImportTaskDetail, RouteSpec{
Summary: "查询手机号资产解绑导入任务详情",
Description: constants.PhoneAssetAssociationAccessDescription + "返回成功数、失败数、逐行失败原因与解绑当时的完整手机号快照,并单独返回任务级错误原因。",
Tags: []string{"手机号资产关联"},
Input: new(dto.GetPhoneAssetUnbindImportRequest),
Output: new(dto.PhoneAssetUnbindImportTaskDetailResponse),
Auth: true,
})
Register(group, doc, groupPath, "DELETE", "/:id", handler.Unbind, RouteSpec{
Summary: "解除单条手机号资产关联",
Description: constants.PhoneAssetAssociationAccessDescription + "路径主键即指定一条资产—手机号关系必须指定必须二次确认并填写原因1500 字符),缺一即拒绝且不解除任何关系。越权、资产不存在与已无有效关系返回同一失败文案。" + constants.PhoneAssetAssociationDeniedMessage + "。",
Tags: []string{"手机号资产关联"},
Input: new(dto.UnbindPhoneAssetAssociationRequest),
Output: new(dto.UnbindPhoneAssetAssociationResponse),
Auth: true,
})
}

View File

@@ -137,6 +137,7 @@ await api.post('/iot-cards/import', {
| batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv |
| device_batch_allocation | 设备批量分配、设置套餐系列或回收 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv |
| shop_import | 店铺负责人导入 (CSV) | shop-imports/YYYY/MM/DD/uuid.csv |
| phone_unbind_import | 手机号资产解绑导入 (CSV) | phone-unbind-imports/YYYY/MM/DD/uuid.csv |
| export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx |
| attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext |