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

@@ -0,0 +1,265 @@
package postgres
import (
"context"
"fmt"
"sort"
"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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PhoneAssetAssociationStore 手机号—资产关联数据访问层。
// 关联只能由 H5 短信验证建立:本 Store 不提供后台创建或补录方法。
type PhoneAssetAssociationStore struct {
db *gorm.DB
}
// NewPhoneAssetAssociationStore 创建手机号—资产关联 Store。
func NewPhoneAssetAssociationStore(db *gorm.DB) *PhoneAssetAssociationStore {
return &PhoneAssetAssociationStore{db: db}
}
// WithTx 返回绑定指定事务的关联 Store。
func (s *PhoneAssetAssociationStore) WithTx(tx *gorm.DB) *PhoneAssetAssociationStore {
return &PhoneAssetAssociationStore{db: tx}
}
// validScope 收敛「当前有效关系」的口径status = 1 且未删除。
func (s *PhoneAssetAssociationStore) validScope(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).
Where("status = ?", constants.PhoneAssetAssociationStatusValid)
}
// ExistsValid 判断手机号与指定资产之间是否已存在有效关系,供登录提示判定使用。
func (s *PhoneAssetAssociationStore) ExistsValid(ctx context.Context, phone, assetType string, assetID uint) (bool, error) {
var count int64
if err := s.validScope(ctx).
Where("phone = ? AND asset_type = ? AND asset_id = ?", phone, assetType, assetID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// CountValidByPhone 统计手机号当前有效关系数,用于十项上限判定;已失效关系不占用额度。
func (s *PhoneAssetAssociationStore) CountValidByPhone(ctx context.Context, phone string) (int64, error) {
var count int64
if err := s.validScope(ctx).Where("phone = ?", phone).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// PhoneAssetAssociationListFilter 后台关联列表筛选条件。
// ScopedShopIDs 非空时只返回这些店铺名下资产的关联;关联表没有 shop_id
// 数据范围必须经资产归属店铺判断ENG-AUTHZ-001
type PhoneAssetAssociationListFilter struct {
AssetType string
AssetID uint
Phone string
Status *int
CreatedAtStart *time.Time
CreatedAtEnd *time.Time
ScopedShopIDs []uint
}
// List 分页查询关联,返回当前页记录与总数。
// 资产范围按 (asset_type, asset_id) 分别落到卡与设备表的归属店铺上,
// 不允许在关联表上直接套用 shop_id 过滤(该表没有该列)。
func (s *PhoneAssetAssociationStore) List(ctx context.Context, opts *store.QueryOptions, filter PhoneAssetAssociationListFilter) ([]*model.PhoneAssetAssociation, int64, error) {
query := s.db.WithContext(ctx).Model(&model.PhoneAssetAssociation{})
if filter.AssetType != "" {
query = query.Where("asset_type = ?", filter.AssetType)
}
if filter.AssetID != 0 {
query = query.Where("asset_id = ?", filter.AssetID)
}
if filter.Phone != "" {
query = query.Where("phone = ?", filter.Phone)
}
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
if filter.CreatedAtStart != nil {
query = query.Where("created_at >= ?", *filter.CreatedAtStart)
}
if filter.CreatedAtEnd != nil {
query = query.Where("created_at <= ?", *filter.CreatedAtEnd)
}
if len(filter.ScopedShopIDs) > 0 {
query = query.Where(`(
(asset_type = ? AND asset_id IN (SELECT id FROM tb_iot_card WHERE deleted_at IS NULL AND shop_id IN ?))
OR (asset_type = ? AND asset_id IN (SELECT id FROM tb_device WHERE deleted_at IS NULL AND shop_id IN ?))
)`, constants.AssetTypeIotCard, filter.ScopedShopIDs, constants.AssetTypeDevice, filter.ScopedShopIDs)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
var rows []*model.PhoneAssetAssociation
if err := query.Order("id DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
// LockPhoneScopes 在事务内为「手机号」这一逻辑实体取稳定串行化点:按号码字符串升序取事务级 advisory lock。
// 新号可能既没有 tb_personal_customer_phone 行也没有关联行,仅靠行锁无法串行化计数与写入;
// 本方法必须在任何行锁之前调用且三条写入路径的次序一致advisory 按号码升序 → 行锁按 id 升序),
// 从而既保证十项上限不被并发突破,也不形成 A→B / B→A 死锁环。同一号码重复获取是同事务内的可重入 no-op。
func (s *PhoneAssetAssociationStore) LockPhoneScopes(ctx context.Context, phones ...string) error {
ordered := make([]string, 0, len(phones))
seen := make(map[string]struct{}, len(phones))
for _, phone := range phones {
if phone == "" {
continue
}
if _, ok := seen[phone]; ok {
continue
}
seen[phone] = struct{}{}
ordered = append(ordered, phone)
}
sort.Strings(ordered)
for _, phone := range ordered {
if err := s.db.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", phone).Error; err != nil {
return err
}
}
return nil
}
// LockValidByPhones 在事务内按 id ASC 一次性锁定指定手机号的全部有效关系行并返回。
// 计数与写入必须在同一临界区内完成,否则并发建联可以越过十项上限;
// 单条 FOR UPDATE 语句按 id 升序返回,加锁顺序对 bind-phone 与 change-phone 一致,不形成死锁环。
func (s *PhoneAssetAssociationStore) LockValidByPhones(ctx context.Context, phones ...string) ([]*model.PhoneAssetAssociation, error) {
if len(phones) == 0 {
return nil, nil
}
var rows []*model.PhoneAssetAssociation
if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("phone IN ? AND status = ?", phones, constants.PhoneAssetAssociationStatusValid).
Order("id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// ListValidByAsset 读取单项资产的全部有效关联手机号,供资产详情展示。
func (s *PhoneAssetAssociationStore) ListValidByAssets(ctx context.Context, assetType string, assetIDs []uint) (map[uint][]string, error) {
phones := make(map[uint][]string, len(assetIDs))
if len(assetIDs) == 0 {
return phones, nil
}
var rows []struct {
AssetID uint
Phone string
}
if err := s.validScope(ctx).Select("asset_id", "phone").
Where("asset_type = ? AND asset_id IN ?", assetType, assetIDs).
Order("asset_id ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
phones[row.AssetID] = append(phones[row.AssetID], row.Phone)
}
return phones, nil
}
// LockValidByID 在事务内按主键锁定一条有效关系,供单项解绑使用。
// 锁定后复核资产数据范围,避免「先查后改」之间关系被并发改写。
func (s *PhoneAssetAssociationStore) LockValidByID(ctx context.Context, tx *gorm.DB, id uint) (*model.PhoneAssetAssociation, error) {
var association model.PhoneAssetAssociation
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ?", id, constants.PhoneAssetAssociationStatusValid).
First(&association).Error; err != nil {
return nil, err
}
return &association, nil
}
// LockValidByAsset 在事务内按 id ASC 锁定某项资产的全部有效关系,供按资产解除使用。
func (s *PhoneAssetAssociationStore) LockValidByAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) ([]*model.PhoneAssetAssociation, error) {
var rows []*model.PhoneAssetAssociation
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("asset_type = ? AND asset_id = ? AND status = ?", assetType, assetID, constants.PhoneAssetAssociationStatusValid).
Order("id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// InvalidateByIDs 以 expected-status 条件把有效关系置为失效,返回实际失效行数。
// 状态流转必须带 status = 有效 条件并核对影响行数ENG-CONC-001否则并发解除会覆盖已成事实的失效信息。
func (s *PhoneAssetAssociationStore) InvalidateByIDs(
ctx context.Context,
tx *gorm.DB,
ids []uint,
method, reason string,
invalidator uint,
now time.Time,
) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).
Where("id IN ? AND status = ?", ids, constants.PhoneAssetAssociationStatusValid).
Updates(map[string]any{
"status": constants.PhoneAssetAssociationStatusInvalid,
"invalidated_at": now,
"invalidation_method": method,
"invalidation_reason": reason,
"invalidator": invalidator,
"updated_at": now,
})
return result.RowsAffected, result.Error
}
// ListValidByAsset 读取单项资产的全部有效关联手机号,供资产详情展示。
func (s *PhoneAssetAssociationStore) ListValidByAsset(ctx context.Context, assetType string, assetID uint) ([]string, error) {
var phones []string
if err := s.validScope(ctx).Order("id ASC").
Where("asset_type = ? AND asset_id = ?", assetType, assetID).
Pluck("phone", &phones).Error; err != nil {
return nil, err
}
return phones, nil
}
// CreateIfAbsent 幂等建立关联:同 (手机号, 资产类型, 资产ID) 已存在有效关系时不再插入。
// 并发同资产建联由 part 唯一索引 uq_phone_asset_association_valid 兜底,冲突即视为已存在。
// 返回 true 表示本次确实新建了关系。
func (s *PhoneAssetAssociationStore) CreateIfAbsent(ctx context.Context, tx *gorm.DB, association *model.PhoneAssetAssociation) (bool, error) {
query := s.db
if tx != nil {
query = tx
}
// RowsAffected 用于区分「本次新建」与「已存在」:命中冲突时 PostgreSQL 不返回行,
// GORM 的 ScanOnConflictDoNothing 不会因此报错,只把影响行数留在 0。
// ON CONFLICT 的推断谓词必须与部分唯一索引谓词逐字一致,且不能使用绑定参数:
// PostgreSQL 无法用 Param 匹配索引谓词中的常量,参数化会直接报 42P10。
result := query.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "phone"}, {Name: "asset_type"}, {Name: "asset_id"},
},
TargetWhere: clause.Where{Exprs: []clause.Expression{
clause.Expr{SQL: fmt.Sprintf("status = %d AND deleted_at IS NULL", constants.PhoneAssetAssociationStatusValid)},
}},
DoNothing: true,
}).Create(association)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected == 1, nil
}

View File

@@ -0,0 +1,121 @@
package postgres
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
)
// PhoneAssetUnbindImportTaskStore 手机号—资产关联解绑导入任务数据访问层。
type PhoneAssetUnbindImportTaskStore struct {
db *gorm.DB
}
// NewPhoneAssetUnbindImportTaskStore 创建解绑导入任务 Store。
func NewPhoneAssetUnbindImportTaskStore(db *gorm.DB) *PhoneAssetUnbindImportTaskStore {
return &PhoneAssetUnbindImportTaskStore{db: db}
}
// WithTx 返回绑定指定事务的任务 Store。
func (s *PhoneAssetUnbindImportTaskStore) WithTx(tx *gorm.DB) *PhoneAssetUnbindImportTaskStore {
return &PhoneAssetUnbindImportTaskStore{db: tx}
}
// Create 创建解绑导入任务。
func (s *PhoneAssetUnbindImportTaskStore) Create(ctx context.Context, task *model.PhoneAssetUnbindImportTask) error {
return s.db.WithContext(ctx).Create(task).Error
}
// GetByID 按 ID 查询解绑导入任务。
func (s *PhoneAssetUnbindImportTaskStore) GetByID(ctx context.Context, id uint) (*model.PhoneAssetUnbindImportTask, error) {
var task model.PhoneAssetUnbindImportTask
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
return nil, err
}
return &task, nil
}
// List 分页查询解绑导入任务。
func (s *PhoneAssetUnbindImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, status *int) ([]*model.PhoneAssetUnbindImportTask, int64, error) {
query := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{})
if status != nil {
query = query.Where("status = ?", *status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
var tasks []*model.PhoneAssetUnbindImportTask
if err := query.Order("created_at DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&tasks).Error; err != nil {
return nil, 0, err
}
return tasks, total, nil
}
// ResetForProcessing 把待处理任务或上次中断的处理中任务置为处理中,并重置进度计数与行明细。
// 返回 false 表示任务已到达终态,重复消费直接跳过;处理中一律视为中断重跑,
// 重跑前清空计数与明细,避免逐行结果重复追加。
func (s *PhoneAssetUnbindImportTaskStore) ResetForProcessing(ctx context.Context, id uint) (bool, error) {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}).
Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}).
Updates(map[string]any{
"status": model.ImportTaskStatusProcessing, "started_at": now, "success_count": 0,
"fail_count": 0, "total_count": 0, "result_items": model.PhoneAssetUnbindImportResults{},
"error_message": "", "updated_at": now,
})
return result.RowsAffected == 1, result.Error
}
// UpdateProgress 按批更新进度计数,不触碰逐行明细,失败不回滚已提交行。
// 必须同时写入本任务已知的行总数:表约束要求 success_count + fail_count <= total_count
// 只写计数会让处理中的中间态违反该约束,导致进度更新静默失败。
func (s *PhoneAssetUnbindImportTaskStore) UpdateProgress(ctx context.Context, id uint, totalCount, successCount, failCount int) error {
return s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}).Where("id = ?", id).
Updates(map[string]any{
"total_count": totalCount, "success_count": successCount, "fail_count": failCount,
"updated_at": time.Now(),
}).Error
}
// MarkFailed 将任务标记为任务级失败,并返回是否确实命中非终态。
// 命中返回 true返回 false 表示任务已到终态(例如 Enqueue 实际已投递成功且 Worker 抢先跑完),
// 此时调用方必须按库内真实状态对外呈现,不得把响应与审计置为失败。
func (s *PhoneAssetUnbindImportTaskStore) MarkFailed(ctx context.Context, id uint, message string) (bool, error) {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}).
Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}).
Updates(map[string]any{
"status": model.ImportTaskStatusFailed, "error_message": message,
"total_count": 0, "success_count": 0, "fail_count": 0,
"result_items": model.PhoneAssetUnbindImportResults{},
"completed_at": now, "updated_at": now,
})
return result.RowsAffected == 1, result.Error
}
// Complete 保存逐行结果与汇总并完成任务。
func (s *PhoneAssetUnbindImportTaskStore) Complete(ctx context.Context, id uint, totalCount, successCount, failCount int, items model.PhoneAssetUnbindImportResults) error {
now := time.Now()
return s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}).
Where("id = ? AND status = ?", id, model.ImportTaskStatusProcessing).
Updates(map[string]any{
"status": model.ImportTaskStatusCompleted, "total_count": totalCount,
"success_count": successCount, "fail_count": failCount,
"result_items": items, "completed_at": now, "updated_at": now,
}).Error
}
// GenerateTaskNo 生成解绑导入任务编号。
func (s *PhoneAssetUnbindImportTaskStore) GenerateTaskNo() string {
now := time.Now()
return fmt.Sprintf("PAU-%s-%06d", now.Format("20060102"), now.UnixNano()%1000000)
}