feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
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:
265
internal/store/postgres/phone_asset_association_store.go
Normal file
265
internal/store/postgres/phone_asset_association_store.go
Normal 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
|
||||
}
|
||||
121
internal/store/postgres/phone_asset_unbind_import_task_store.go
Normal file
121
internal/store/postgres/phone_asset_unbind_import_task_store.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user