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

@@ -524,6 +524,18 @@ const (
AuditActionExportTaskCreated = "export_task.create"
// AuditActionExportTaskCancelled 表示取消业务导出任务或提交取消请求。
AuditActionExportTaskCancelled = "export_task.cancel"
// AuditActionPhoneAssetAssociationCreated 表示 H5 短信验证通过后建立手机号—资产关联。
AuditActionPhoneAssetAssociationCreated = "phone_asset_association.create"
// AuditActionPhoneAssetAssociationMigrated 表示换绑手机号时把旧号全部有效关联迁移到新号。
AuditActionPhoneAssetAssociationMigrated = "phone_asset_association.migrate"
// AuditActionPhoneAssetAssociationUnbound 表示后台单项或按资产批量解除手机号—资产关联。
AuditActionPhoneAssetAssociationUnbound = "phone_asset_association.unbind"
// AuditActionPhoneAssetAssociationImported 表示 CSV 导入单行解除手机号—资产关联。
AuditActionPhoneAssetAssociationImported = "phone_asset_association.import_unbind"
// AuditActionPhoneAssetUnbindImportTaskCreated 表示创建 CSV 解绑导入任务。
AuditActionPhoneAssetUnbindImportTaskCreated = "phone_asset_unbind_import_task.create"
// AuditActionPhoneAssetUnbindImportTaskCompleted 表示 CSV 解绑导入任务完成。
AuditActionPhoneAssetUnbindImportTaskCompleted = "phone_asset_unbind_import_task.complete"
// AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。
AuditActionNotificationDelivered = "notification.deliver"
// AuditActionNotificationRead 表示单条通知首次标记已读。
@@ -667,6 +679,10 @@ const (
AuditResourceOrderPackageInvalidateTask = "order_package_invalidate_task"
// AuditResourceExportTask 表示业务导出任务资源。
AuditResourceExportTask = "export_task"
// AuditResourcePhoneAssetAssociation 表示手机号—资产关联资源。
AuditResourcePhoneAssetAssociation = "phone_asset_association"
// AuditResourcePhoneAssetUnbindImportTask 表示手机号—资产 CSV 解绑导入任务资源。
AuditResourcePhoneAssetUnbindImportTask = "phone_asset_unbind_import_task"
// AuditResourceNotification 表示站内通知资源。
AuditResourceNotification = "notification"
// AuditResourceNotificationReadBatch 表示通知批量已读资源。
@@ -863,6 +879,10 @@ const (
AuditResourceRoleEnterpriseDeviceBinding = "enterprise_device_binding"
// AuditResourceRoleEnterpriseDeviceAuthorization 表示企业设备授权记录。
AuditResourceRoleEnterpriseDeviceAuthorization = "enterprise_device_authorization"
// AuditResourceRolePhoneAssetAssociationTarget 表示手机号—资产关联的主要目标。
AuditResourceRolePhoneAssetAssociationTarget = "phone_asset_association_target"
// AuditResourceRolePhoneAssetAssociationAsset 表示关联指向的资产。
AuditResourceRolePhoneAssetAssociationAsset = "phone_asset_association_asset"
// AuditResourceRolePersonalCustomerTarget 表示个人客户操作的主体目标。
AuditResourceRolePersonalCustomerTarget = "personal_customer_target"
// AuditResourceRolePersonalCustomerPhone 表示个人客户手机号关系。

View File

@@ -77,6 +77,7 @@ const (
TaskTypeAssetPackageBatchOrder = "asset:package:batch_order" // 资产套餐批量订购
// 店铺负责人 CSV 导入任务类型,独立成队列以隔离逐行执行的长时任务。
TaskTypeShopBusinessOwnerImport = "shop_business_owner:import"
TaskTypePhoneAssetUnbindImport = "phone_asset_unbind:import" // 手机号—资产关联 CSV 解绑导入
// 订单超时任务类型
TaskTypeOrderExpire = "order:expire" // 订单超时自动取消
@@ -232,6 +233,7 @@ const (
QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列
QueueAssetPackageBatchOrder = TaskTypeAssetPackageBatchOrder // 资产套餐批量订购任务队列
QueueShopBusinessOwnerImport = TaskTypeShopBusinessOwnerImport // 店铺负责人 CSV 导入任务队列
QueuePhoneAssetUnbindImport = TaskTypePhoneAssetUnbindImport // 手机号—资产关联 CSV 解绑导入任务队列
QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列
QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列
QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列
@@ -291,6 +293,8 @@ func QueueForTaskType(taskType string) string {
return QueueAssetPackageBatchOrder
case TaskTypeShopBusinessOwnerImport:
return QueueShopBusinessOwnerImport
case TaskTypePhoneAssetUnbindImport:
return QueuePhoneAssetUnbindImport
case TaskTypeOrderExpire:
return QueueOrderExpire
case TaskTypeAutoPurchaseAfterRecharge:
@@ -332,6 +336,7 @@ func DefaultTaskQueueWeights() map[string]int {
QueueOrderPackageInvalidate: 5,
QueueAssetPackageBatchOrder: 5,
QueueShopBusinessOwnerImport: 4,
QueuePhoneAssetUnbindImport: 4,
QueueOrderExpire: 4,
QueueEmailSend: 4,
QueueExportDispatch: 4,

View File

@@ -0,0 +1,142 @@
package constants
import "time"
// 手机号—资产关联的状态取值。
const (
// PhoneAssetAssociationStatusInvalid 表示关联已失效,不再占用十项额度。
PhoneAssetAssociationStatusInvalid = 0
// PhoneAssetAssociationStatusValid 表示关联当前有效。
PhoneAssetAssociationStatusValid = 1
)
// 手机号—资产关联的建立来源;关联只能由 H5 短信验证建立,后台无创建或补录入口。
const (
// PhoneAssetAssociationSourceH5SMSVerification 表示关联由 H5 短信验证码校验通过后建立。
PhoneAssetAssociationSourceH5SMSVerification = "h5_sms_verification"
)
// 手机号—资产关联的失效方式,用于审计与事后核对解除入口。
const (
// PhoneAssetAssociationInvalidateMethodBackendSingle 表示后台单项解除。
PhoneAssetAssociationInvalidateMethodBackendSingle = "backend_single"
// PhoneAssetAssociationInvalidateMethodBackendBatch 表示后台勾选批量解除。
PhoneAssetAssociationInvalidateMethodBackendBatch = "backend_batch"
// PhoneAssetAssociationInvalidateMethodCSVImport 表示 CSV 导入解除。
PhoneAssetAssociationInvalidateMethodCSVImport = "csv_import"
)
// 手机号—资产关联的业务口径常量。
const (
// PhoneAssetAssociationMaxValidPerPhone 表示单手机号可同时有效的关联资产项数上限。
PhoneAssetAssociationMaxValidPerPhone = 10
// PhoneAssetAssociationLimitMessage 表示超过十项上限时的统一失败文案。
PhoneAssetAssociationLimitMessage = "该手机号最多关联10项有效资产"
)
// 手机号—资产关联解绑入口的角色说明。
const (
// PhoneAssetAssociationAccessDescription 表示关联查看与解除入口的角色说明。
PhoneAssetAssociationAccessDescription = "仅超级管理员和平台账号可访问,代理与企业账号返回 403。"
)
// 手机号—资产关联解除的入参口径。
const (
// PhoneAssetAssociationUnbindReasonMaxLength 表示解除原因的最大长度。
PhoneAssetAssociationUnbindReasonMaxLength = 500
)
// 手机号—资产关联 CSV 解绑导入的场景常量。
const (
// StoragePurposePhoneAssetUnbindImport 表示手机号资产解绑 CSV 导入上传用途。
StoragePurposePhoneAssetUnbindImport = "phone_unbind_import"
// PhoneAssetUnbindImportStoragePrefix 表示解绑导入文件的对象存储目录。
PhoneAssetUnbindImportStoragePrefix = "phone-unbind-imports"
// PhoneAssetUnbindImportTaskTimeout 表示单个解绑导入任务的最长执行时间。
PhoneAssetUnbindImportTaskTimeout = 2 * time.Hour
// PhoneAssetUnbindImportProgressBatchSize 表示导入按批更新进度计数的批大小。
PhoneAssetUnbindImportProgressBatchSize = 100
// PhoneAssetUnbindImportItemStatusSuccess 表示导入单行已成功解除关联。
PhoneAssetUnbindImportItemStatusSuccess = 3
// PhoneAssetUnbindImportItemStatusFailed 表示导入单行校验或执行失败并保留原状。
PhoneAssetUnbindImportItemStatusFailed = 4
)
// PhoneAssetUnbindImportColumns 表示导入文件首行必须完全一致的固定列序;解绑原因由任务级必填字段提供。
var PhoneAssetUnbindImportColumns = []string{"资产标识", "备注"}
// 手机号—资产关联解绑导入的任务级失败原因,与行级失败原因分开记录。
const (
// PhoneAssetUnbindImportErrorFileFormat 表示文件格式或表头与固定列序不符。
PhoneAssetUnbindImportErrorFileFormat = "文件格式或表头与固定列序不符"
// PhoneAssetUnbindImportErrorEncoding 表示文件既非合法 UTF-8 也无法按 GBK 解码。
PhoneAssetUnbindImportErrorEncoding = "文件编码无法按 UTF-8 或 GBK 解码"
// PhoneAssetUnbindImportErrorNoDataRow 表示文件没有数据行。
PhoneAssetUnbindImportErrorNoDataRow = "文件没有数据行"
)
// 手机号—资产关联解绑导入的行级失败原因,取值集合固定。
const (
// PhoneAssetUnbindImportRowErrorIdentifier 表示资产标识为空。
PhoneAssetUnbindImportRowErrorIdentifier = "资产标识不能为空"
// PhoneAssetUnbindImportRowErrorAssetMissing 表示资产标识无法定位到资产。
PhoneAssetUnbindImportRowErrorAssetMissing = "资产标识不存在或已删除"
// PhoneAssetUnbindImportRowErrorNoAssociation 表示该资产已无有效关联。
PhoneAssetUnbindImportRowErrorNoAssociation = "该资产已无有效关联手机号"
// PhoneAssetUnbindImportRowErrorFormat 表示该行列数与固定列序不一致。
PhoneAssetUnbindImportRowErrorFormat = "行格式错误"
// PhoneAssetUnbindImportRowErrorRemarkTooLong 表示备注超过长度上限。
PhoneAssetUnbindImportRowErrorRemarkTooLong = "备注超过500字符"
)
// 手机号—资产关联解绑导入的备注长度上限。
const (
// PhoneAssetUnbindImportRemarkMaxLength 表示行内备注的最大长度。
PhoneAssetUnbindImportRemarkMaxLength = 500
)
// 手机号—资产关联的三态统一失败文案。
// 越权、资产不存在与已无有效关系三种情况必须返回同一文案避免形成可枚举差异ENG-AUTHZ-001
// 本常量只供新增用例使用;既有内联字面量按 As-Is 保留,不在本 Change 统一。
const (
// PhoneAssetAssociationDeniedMessage 表示越权、资产不存在或已无有效关系时的统一失败文案。
PhoneAssetAssociationDeniedMessage = "无权限操作该资源或资源不存在"
)
// GetPhoneAssetAssociationImportItemStatusName 返回解绑导入行状态中文名称。
func GetPhoneAssetAssociationImportItemStatusName(status int) string {
switch status {
case PhoneAssetUnbindImportItemStatusSuccess:
return "成功"
case PhoneAssetUnbindImportItemStatusFailed:
return "失败"
default:
return "未知"
}
}
// GetPhoneAssetAssociationStatusName 返回关联状态中文名称。
func GetPhoneAssetAssociationStatusName(status int) string {
switch status {
case PhoneAssetAssociationStatusValid:
return "有效"
case PhoneAssetAssociationStatusInvalid:
return "已失效"
default:
return "未知"
}
}
// PhoneAssetAssociationInvalidateMethodName 返回失效方式中文名称。
func PhoneAssetAssociationInvalidateMethodName(method string) string {
switch method {
case PhoneAssetAssociationInvalidateMethodBackendSingle:
return "后台单项解除"
case PhoneAssetAssociationInvalidateMethodBackendBatch:
return "后台批量解除"
case PhoneAssetAssociationInvalidateMethodCSVImport:
return "CSV导入解除"
default:
return "未知"
}
}

View File

@@ -5,7 +5,9 @@ import (
"crypto/sha256"
"encoding/hex"
"net/url"
"strings"
"time"
"unicode"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -52,6 +54,38 @@ func truncateBody(body []byte, maxSize int) (string, bool) {
return string(body[:maxSize]), true
}
// phoneFieldNames 是访问日志中属于手机号类的字段名集合。
// 手机号不在 sanitizer 的 forbidden 字段清单内,仅靠字段名通用清理无法脱敏;
// 这里单独识别并输出前 3 位 + **** + 后 4 位,禁止完整号码进入 query、请求正文或响应正文。
// 只覆盖手机号类字段forbidden 字段仍按原语义标记为已脱敏,两者处理互不影响。
var phoneFieldNames = map[string]struct{}{
"phone": {},
"mobile": {},
"associated_phones": {},
}
// normalizeLogFieldName 归一化字段名用于敏感字段判断。
func normalizeLogFieldName(key string) string {
var builder strings.Builder
for index, char := range key {
if unicode.IsUpper(char) && index > 0 {
builder.WriteByte('_')
}
if char == '-' || char == '.' {
builder.WriteByte('_')
continue
}
builder.WriteRune(unicode.ToLower(char))
}
return builder.String()
}
// isPhoneField 判断字段名是否属于手机号类字段(与 forbidden 字段分开处理)。
func isPhoneField(key string) bool {
_, ok := phoneFieldNames[normalizeLogFieldName(key)]
return ok
}
// maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值
func maskSensitiveValue(key, value string) string {
if value == "" {
@@ -60,6 +94,9 @@ func maskSensitiveValue(key, value string) string {
if shouldMaskField(key) {
return redactedValue
}
if isPhoneField(key) {
return sanitizer.MaskPhone(value)
}
return value
}
@@ -125,6 +162,25 @@ func sanitizeJSONValue(value any) {
typed[key] = redactedValue
continue
}
if isPhoneField(key) {
// 手机号类数组逐元素脱敏;非字符串元素不保留原值。
list, ok := item.([]any)
if !ok {
if item != nil {
typed[key] = redactedValue
}
continue
}
for index, element := range list {
if text, ok := element.(string); ok {
list[index] = sanitizer.MaskPhone(text)
continue
}
list[index] = redactedValue
}
typed[key] = list
continue
}
sanitizeJSONValue(item)
}
case []any:

View File

@@ -78,6 +78,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil),
BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil),
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil),
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil),
ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
SuperAdmin: admin.NewSuperAdminHandler(nil),
SystemConfig: admin.NewSystemConfigHandler(nil, nil),

View File

@@ -75,6 +75,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
h.registerOrderPackageInvalidateHandler()
h.registerAssetPackageBatchOrderHandler()
h.registerShopBusinessOwnerImportHandler()
h.registerPhoneAssetUnbindImportHandler()
h.registerExportHandlers()
h.registerCommissionStatsHandlers()
h.registerCommissionCalculationHandler()
@@ -154,6 +155,22 @@ func (h *Handler) registerShopBusinessOwnerImportHandler() {
h.logger.Info("注册店铺负责人导入任务处理器", zap.String("task_type", constants.TaskTypeShopBusinessOwnerImport))
}
func (h *Handler) registerPhoneAssetUnbindImportHandler() {
handler := task.NewPhoneAssetUnbindImportHandler(
h.db,
h.workerResult.Stores.PhoneAssetUnbindImportTask,
h.workerResult.Stores.PhoneAssetAssociation,
h.workerResult.Stores.AssetIdentifier,
h.workerResult.Stores.IotCard,
h.workerResult.Stores.Device,
h.storage,
h.logger,
audit.NewWriter(audit.NewRegistry(), nil),
)
h.mux.HandleFunc(constants.TaskTypePhoneAssetUnbindImport, handler.Handle)
h.logger.Info("注册手机号资产解绑导入任务处理器", zap.String("task_type", constants.TaskTypePhoneAssetUnbindImport))
}
func (h *Handler) registerDeviceImportHandler() {
deviceImportHandler := task.NewDeviceImportHandler(
h.db,

View File

@@ -51,9 +51,11 @@ type WorkerStores struct {
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
}
// WorkerServices Worker 侧所有 Service 的集合

View File

@@ -105,6 +105,23 @@ func SanitizeText(value string) string {
return TextSummary(value)
}
// 手机号脱敏保留位数:前 3 位与后 4 位。
const (
phoneMaskedKeepPrefix = 3
phoneMaskedKeepSuffix = 4
)
// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。
// 审计、日志与错误文案一律使用本函数的结果;字段名脱敏清单不含 phone
// 因此不能依赖 RemoveForbiddenFields 自动脱敏,必须显式调用。
func MaskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix {
return ""
}
return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:]
}
// TextSummary 将不可信外部文本转换为不可逆大小和哈希摘要。
func TextSummary(value string) string {
if value == "" {

View File

@@ -36,7 +36,7 @@ func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) {
if ext == "" {
ext = ".bin"
}
if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport) && !strings.EqualFold(ext, ".csv") {
if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport || purpose == constants.StoragePurposePhoneAssetUnbindImport) && !strings.EqualFold(ext, ".csv") {
return "", errors.New(errors.CodeInvalidParam, "批量业务文件必须为CSV格式")
}

View File

@@ -22,4 +22,7 @@ var PurposeMappings = map[string]PurposeMapping{
constants.StoragePurposeShopBusinessOwnerImport: {
Prefix: constants.ShopBusinessOwnerImportStoragePrefix, ContentType: "text/csv",
},
constants.StoragePurposePhoneAssetUnbindImport: {
Prefix: constants.PhoneAssetUnbindImportStoragePrefix, ContentType: "text/csv",
},
}