1138 lines
48 KiB
Go
1138 lines
48 KiB
Go
package identity
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
"hyapp/services/user-service/internal/storage/mysql/shared"
|
||
userstorage "hyapp/services/user-service/internal/storage/mysql/user"
|
||
)
|
||
|
||
type prettyAssignment struct {
|
||
// PrettyID 只在靓号池申请和后台发放时存在;旧付费临时靓号没有池表记录,返回身份时只返回展示号。
|
||
PrettyID string
|
||
LeaseID string
|
||
DisplayUserID string
|
||
ExpiresAtMs int64
|
||
PaymentReceiptID string
|
||
// Source 决定释放后的池表状态:pool 号码回到 available,admin_grant 号码进入 released 留审计。
|
||
Source string
|
||
ChangeType string
|
||
Reason string
|
||
OperatorUserID int64
|
||
RequestID string
|
||
NowMs int64
|
||
}
|
||
|
||
type prettyReleaseCommand struct {
|
||
// LeaseID 为空时按用户当前 active lease 释放;非空时必须匹配当前 active lease,防止旧请求误释放新靓号。
|
||
LeaseID string
|
||
// Reason 写入 lease、展示号、靓号池记录和变更日志,用于区分到期、替换和后台回收。
|
||
Reason string
|
||
// ChangeType 是 display_user_id_change_logs 的业务类型。
|
||
ChangeType string
|
||
// LeaseStatus 表示租约释放后的生命周期状态。
|
||
LeaseStatus userdomain.PrettyLeaseStatus
|
||
// DisplayStatus 表示当前展示号占用释放后的状态,到期用 expired,后台回收用 released。
|
||
DisplayStatus userdomain.DisplayUserIDStatus
|
||
// OperatorAdminID 只有后台回收这类管理动作存在;到期任务为 0。
|
||
OperatorAdminID int64
|
||
RequestID string
|
||
NowMs int64
|
||
}
|
||
|
||
// ApplyPrettyDisplayUserID 兼容旧付费靓号接口,仍要求租期和支付回执,但允许覆盖旧靓号。
|
||
func (r *Repository) ApplyPrettyDisplayUserID(ctx context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error) {
|
||
// 旧接口按展示号申请,没有 pretty_id;先懒过期同名到期靓号,避免已过期记录继续占用唯一性。
|
||
if err := r.expirePrettyByDisplayUserID(ctx, command.PrettyDisplayID, command.NowMs, command.RequestID); err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, "", xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
// 用户锁在全局唯一性校验前拿到,保证同一用户的展示号替换和其他申请不会交叉写入。
|
||
if err := r.assertDisplayUserIDGloballyAvailable(ctx, tx, command.PrettyDisplayID); err != nil {
|
||
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
|
||
return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display_user_id is not available")
|
||
}
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
|
||
identity, err := r.replacePrettyInTx(ctx, tx, user, prettyAssignment{
|
||
LeaseID: command.LeaseID,
|
||
DisplayUserID: command.PrettyDisplayID,
|
||
ExpiresAtMs: command.NowMs + command.LeaseDurationMs,
|
||
PaymentReceiptID: command.PaymentReceiptID,
|
||
Source: command.Source,
|
||
ChangeType: "pretty_apply",
|
||
Reason: "pretty_apply",
|
||
OperatorUserID: command.OperatorUserID,
|
||
RequestID: command.RequestID,
|
||
NowMs: command.NowMs,
|
||
})
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
|
||
return identity, command.LeaseID, nil
|
||
}
|
||
|
||
// ListAvailablePrettyDisplayIDs 返回用户当前等级可申请的靓号池号码。
|
||
func (r *Repository) ListAvailablePrettyDisplayIDs(ctx context.Context, query userdomain.AvailablePrettyDisplayIDQuery) ([]userdomain.PrettyDisplayID, int64, error) {
|
||
appCode := appcode.Normalize(query.AppCode)
|
||
page, pageSize := normalizeIdentityPage(query.Page, query.PageSize)
|
||
offset := (page - 1) * pageSize
|
||
// 可申请列表不接受客户端自报轨道等级;服务层先读取用户真实等级,这里按财富、游戏、魅力三条轨道做 OR 匹配。
|
||
levelWhere := `
|
||
AND (
|
||
(pool.level_track = ? AND pool.min_level <= ? AND pool.max_level >= ?)
|
||
OR (pool.level_track = ? AND pool.min_level <= ? AND pool.max_level >= ?)
|
||
OR (pool.level_track = ? AND pool.min_level <= ? AND pool.max_level >= ?)
|
||
)`
|
||
levelArgs := []any{
|
||
userdomain.LevelTrackWealth, query.Levels.WealthLevel, query.Levels.WealthLevel,
|
||
userdomain.LevelTrackGame, query.Levels.GameLevel, query.Levels.GameLevel,
|
||
userdomain.LevelTrackCharm, query.Levels.CharmLevel, query.Levels.CharmLevel,
|
||
}
|
||
|
||
var total int64
|
||
countArgs := append([]any{appCode, userdomain.PrettyDisplayIDStatusAvailable, userdomain.PrettyDisplayIDSourcePool, userdomain.PrettyDisplayIDPoolStatusActive}, levelArgs...)
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM pretty_display_ids pdi
|
||
JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
WHERE pdi.app_code = ?
|
||
AND pdi.status = ?
|
||
AND pdi.source = ?
|
||
AND pool.status = ?
|
||
`+levelWhere, countArgs...).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
selectArgs := append(countArgs, int(pageSize), int(offset))
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT `+prettyDisplayIDSelectColumns()+`
|
||
FROM pretty_display_ids pdi
|
||
JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
WHERE pdi.app_code = ?
|
||
AND pdi.status = ?
|
||
AND pdi.source = ?
|
||
AND pool.status = ?
|
||
`+levelWhere+`
|
||
ORDER BY pool.sort_order ASC, pdi.created_at_ms ASC, pdi.pretty_id ASC
|
||
LIMIT ? OFFSET ?
|
||
`, selectArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items, err := scanPrettyDisplayIDRows(rows)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
// ApplyPrettyDisplayIDFromPool 申请靓号池号码,号码本身、用户当前等级和用户展示号在同一事务中校验。
|
||
func (r *Repository) ApplyPrettyDisplayIDFromPool(ctx context.Context, command userdomain.PrettyDisplayIDPoolApplyCommand) (userdomain.Identity, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
appCode := appcode.Normalize(command.AppCode)
|
||
// 先锁定 pretty_display_ids 行,保证同一个 pretty_id 只能被一个申请事务从 available 改为 assigned。
|
||
item, err := queryPrettyDisplayIDForUpdate(ctx, tx, appCode, command.PrettyID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, xerr.New(xerr.NotFound, "pretty display id not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if item.Source != userdomain.PrettyDisplayIDSourcePool || item.Status != userdomain.PrettyDisplayIDStatusAvailable {
|
||
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display id is not available")
|
||
}
|
||
|
||
// 池配置也加锁读取,避免管理员同时禁用池或调整等级区间时出现旧规则通过的申请。
|
||
pool, err := queryPrettyDisplayIDPoolForUpdate(ctx, tx, appCode, item.PoolID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, xerr.New(xerr.NotFound, "pretty display id pool not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if pool.Status != userdomain.PrettyDisplayIDPoolStatusActive {
|
||
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display id pool is disabled")
|
||
}
|
||
// 只校验该池声明的等级轨道;用户在其他轨道等级足够也不能跨池申请。
|
||
level, ok := command.Levels.LevelForTrack(pool.LevelTrack)
|
||
if !ok || level < pool.MinLevel || level > pool.MaxLevel {
|
||
return userdomain.Identity{}, xerr.New(xerr.PermissionDenied, "user level is not allowed for pretty display id pool")
|
||
}
|
||
|
||
// 用户行最后锁定,后续替换展示号时以 users 当前快照为准释放旧靓号或挂起默认短号。
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
// 池申请默认长期持有,expires_at_ms=0;替换当前靓号和写入租约先完成,再把池号码标记为 assigned。
|
||
identity, err := r.replacePrettyInTx(ctx, tx, user, prettyAssignment{
|
||
PrettyID: item.PrettyID,
|
||
LeaseID: command.LeaseID,
|
||
DisplayUserID: item.DisplayUserID,
|
||
ExpiresAtMs: 0,
|
||
Source: userdomain.PrettyDisplayIDSourcePool,
|
||
ChangeType: "pretty_pool_apply",
|
||
Reason: "pretty_pool_apply",
|
||
OperatorUserID: command.UserID,
|
||
RequestID: command.RequestID,
|
||
NowMs: command.NowMs,
|
||
})
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
// WHERE status=available 是最后一道并发保护,确保只有锁住并仍可用的池号会被分配。
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE pretty_display_ids
|
||
SET status = ?, assigned_user_id = ?, assigned_lease_id = ?, assigned_at_ms = ?,
|
||
released_at_ms = 0, release_reason = '', updated_at_ms = ?
|
||
WHERE app_code = ? AND pretty_id = ? AND status = ?
|
||
`, userdomain.PrettyDisplayIDStatusAssigned, command.UserID, command.LeaseID, command.NowMs, command.NowMs, appCode, command.PrettyID, userdomain.PrettyDisplayIDStatusAvailable)
|
||
if err != nil {
|
||
return userdomain.Identity{}, shared.MapPrettyDuplicateError(err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
return identity, nil
|
||
}
|
||
|
||
// ExpirePrettyDisplayUserID 把到期靓号恢复成默认短号;该方法是定时和懒过期的共同事务。
|
||
func (r *Repository) ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
identity, err := r.expirePrettyInTx(ctx, tx, user, command.LeaseID, command.NowMs, command.RequestID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
return identity, nil
|
||
}
|
||
|
||
func (r *Repository) expirePrettyByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64, requestID string) error {
|
||
// 申请同名靓号前只处理已经到期的占用;未到期的冲突仍交给唯一性校验返回不可用。
|
||
var userID int64
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT user_id
|
||
FROM users
|
||
WHERE app_code = ?
|
||
AND current_display_user_id_kind = ?
|
||
AND current_display_user_id_expires_at_ms IS NOT NULL
|
||
AND current_display_user_id_expires_at_ms > 0
|
||
AND current_display_user_id_expires_at_ms <= ?
|
||
AND (current_display_user_id = ? OR default_display_user_id = ?)
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), string(userdomain.DisplayUserIDKindPretty), nowMs, displayUserID, displayUserID).Scan(&userID)
|
||
if err == sql.ErrNoRows {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = r.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
|
||
UserID: userID,
|
||
RequestID: requestID,
|
||
NowMs: nowMs,
|
||
})
|
||
return err
|
||
}
|
||
|
||
// ExpirePrettyByDisplayUserID 给认证存储复用短号懒过期事务。
|
||
func ExpirePrettyByDisplayUserID(ctx context.Context, db *sql.DB, displayUserID string, nowMs int64, requestID string) error {
|
||
return New(db).expirePrettyByDisplayUserID(ctx, displayUserID, nowMs, requestID)
|
||
}
|
||
|
||
func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, leaseID string, nowMs int64, requestID string) (userdomain.Identity, error) {
|
||
if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
|
||
// 当前已经是默认短号时,过期恢复是幂等操作,直接返回现有身份。
|
||
return user.EffectiveIdentity(), nil
|
||
}
|
||
if user.CurrentDisplayUserIDExpiresAtMs == 0 || user.CurrentDisplayUserIDExpiresAtMs > nowMs {
|
||
// expires_at_ms=0 表示长期靓号,正未来时间表示尚未到期,这两类都不能被过期任务释放。
|
||
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not expired")
|
||
}
|
||
|
||
return r.releaseCurrentPrettyInTx(ctx, tx, user, prettyReleaseCommand{
|
||
LeaseID: leaseID,
|
||
Reason: "pretty_expire",
|
||
ChangeType: "pretty_expire",
|
||
LeaseStatus: userdomain.PrettyLeaseStatusExpired,
|
||
DisplayStatus: userdomain.DisplayUserIDStatusExpired,
|
||
RequestID: requestID,
|
||
NowMs: nowMs,
|
||
})
|
||
}
|
||
|
||
// RecyclePrettyDisplayID 强制回收后台列表中仍处于 assigned 的靓号,并把用户恢复到默认短号。
|
||
func (r *Repository) RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error) {
|
||
appCode := appcode.Normalize(command.AppCode)
|
||
reason := strings.TrimSpace(command.Reason)
|
||
if reason == "" {
|
||
// 默认原因必须和到期恢复区分,方便审计和后续排查运营手动动作。
|
||
reason = "admin_recycle_pretty"
|
||
}
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
item, err := queryPrettyDisplayIDForUpdate(ctx, tx, appCode, strings.TrimSpace(command.PrettyID))
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.PrettyDisplayID{}, xerr.New(xerr.NotFound, "pretty display id not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
if item.Status != userdomain.PrettyDisplayIDStatusAssigned || item.AssignedUserID <= 0 || item.AssignedLeaseID == "" {
|
||
// 只有真实占用中的号码才能回收;可用、禁用、预留和已释放状态仍走普通状态管理。
|
||
return userdomain.PrettyDisplayID{}, xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display id is not assigned")
|
||
}
|
||
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", item.AssignedUserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.PrettyDisplayID{}, xerr.New(xerr.NotFound, "assigned user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty || user.CurrentDisplayUserID != item.DisplayUserID {
|
||
// 列表记录和 users 当前展示号不一致时不能盲目清表,交给后续数据修复处理,避免回收错用户的新靓号。
|
||
return userdomain.PrettyDisplayID{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display id lease is not active")
|
||
}
|
||
|
||
if _, err := r.releaseCurrentPrettyInTx(ctx, tx, user, prettyReleaseCommand{
|
||
LeaseID: item.AssignedLeaseID,
|
||
Reason: reason,
|
||
ChangeType: "admin_recycle_pretty",
|
||
LeaseStatus: userdomain.PrettyLeaseStatusCancelled,
|
||
DisplayStatus: userdomain.DisplayUserIDStatusReleased,
|
||
OperatorAdminID: command.OperatorAdminID,
|
||
RequestID: command.RequestID,
|
||
NowMs: command.NowMs,
|
||
}); err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
|
||
return queryPrettyDisplayID(ctx, r.db, appCode, item.PrettyID)
|
||
}
|
||
|
||
func (r *Repository) releaseCurrentPrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, command prettyReleaseCommand) (userdomain.Identity, error) {
|
||
appCode := appcode.FromContext(ctx)
|
||
// 先锁 active lease,再按调用方传入的 lease_id 做精确校验;后台回收必须命中列表行里的 lease,过期任务可以空 lease 幂等恢复。
|
||
activeLeaseID, _, err := lockActivePrettyLease(ctx, tx, appCode, user.UserID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if command.LeaseID != "" && activeLeaseID != command.LeaseID {
|
||
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not active")
|
||
}
|
||
|
||
if command.LeaseID != "" {
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE pretty_display_user_id_leases
|
||
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND lease_id = ? AND status = ?
|
||
`, string(command.LeaseStatus), command.NowMs, command.Reason, command.NowMs, appCode, command.LeaseID, string(userdomain.PrettyLeaseStatusActive))
|
||
} else {
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE pretty_display_user_id_leases
|
||
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND status = ?
|
||
`, string(command.LeaseStatus), command.NowMs, command.Reason, command.NowMs, appCode, user.UserID, string(userdomain.PrettyLeaseStatusActive))
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE user_display_user_ids
|
||
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
|
||
`, string(command.DisplayStatus), command.NowMs, command.Reason, command.NowMs, appCode, user.UserID, user.CurrentDisplayUserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive))
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if activeLeaseID != "" {
|
||
// 池号和后台发放号释放后的状态不同,统一通过 lease 反查池表行处理。
|
||
if err := releasePrettyDisplayIDByLeaseInTx(ctx, tx, appCode, activeLeaseID, command.NowMs, command.Reason); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
}
|
||
|
||
// 默认短号在用户使用靓号期间保持 held;释放时只把原默认号重新激活,不重新生成短号。
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE user_display_user_ids
|
||
SET status = ?, activated_at_ms = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
|
||
`, string(userdomain.DisplayUserIDStatusActive), command.NowMs, command.NowMs, appCode, user.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusHeld))
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET current_display_user_id = default_display_user_id, current_display_user_id_kind = ?, current_display_user_id_expires_at_ms = NULL, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, string(userdomain.DisplayUserIDKindDefault), command.NowMs, appCode, user.UserID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
|
||
AppCode: appCode,
|
||
UserID: user.UserID,
|
||
OldID: user.CurrentDisplayUserID,
|
||
NewID: user.DefaultDisplayUserID,
|
||
OldKind: userdomain.DisplayUserIDKindPretty,
|
||
NewKind: userdomain.DisplayUserIDKindDefault,
|
||
ChangeType: command.ChangeType,
|
||
LeaseID: activeLeaseID,
|
||
Reason: command.Reason,
|
||
OperatorID: command.OperatorAdminID,
|
||
RequestID: command.RequestID,
|
||
CreatedAtMs: command.NowMs,
|
||
}); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
return userdomain.Identity{
|
||
AppCode: appCode,
|
||
UserID: user.UserID,
|
||
DisplayUserID: user.DefaultDisplayUserID,
|
||
DefaultDisplayUserID: user.DefaultDisplayUserID,
|
||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||
Status: userdomain.DisplayUserIDStatusActive,
|
||
}, nil
|
||
}
|
||
|
||
func (r *Repository) replacePrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, assignment prettyAssignment) (userdomain.Identity, error) {
|
||
appCode := appcode.FromContext(ctx)
|
||
if user.DisplayUserIDExpired(assignment.NowMs) {
|
||
// 替换前先在同一事务内清理已过期靓号,把 users 快照恢复到默认号,后续日志的 old_id 才准确。
|
||
identity, err := r.expirePrettyInTx(ctx, tx, user, "", assignment.NowMs, assignment.RequestID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
user.CurrentDisplayUserID = identity.DisplayUserID
|
||
user.CurrentDisplayUserIDKind = identity.DisplayUserIDKind
|
||
user.CurrentDisplayUserIDExpiresAtMs = 0
|
||
user.PrettyID = ""
|
||
user.PrettyDisplayUserID = ""
|
||
}
|
||
|
||
if user.CurrentDisplayUserIDKind == userdomain.DisplayUserIDKindPretty {
|
||
// 用户当前已有 active 靓号时,新靓号直接覆盖旧靓号,旧 lease 必须先取消并记录释放原因。
|
||
activeLeaseID, _, err := lockActivePrettyLease(ctx, tx, appCode, user.UserID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
if activeLeaseID == "" {
|
||
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not active")
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE pretty_display_user_id_leases
|
||
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND lease_id = ? AND status = ?
|
||
`, string(userdomain.PrettyLeaseStatusCancelled), assignment.NowMs, "replaced_by_new_pretty", assignment.NowMs, appCode, activeLeaseID, string(userdomain.PrettyLeaseStatusActive))
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
// active 的 user_display_user_ids 代表当前展示号占用,覆盖时释放该占用,避免同一用户出现两个 active 靓号。
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE user_display_user_ids
|
||
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
|
||
`, string(userdomain.DisplayUserIDStatusReleased), assignment.NowMs, "replaced_by_new_pretty", assignment.NowMs, appCode, user.UserID, user.CurrentDisplayUserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive))
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
// 旧靓号对应的池表记录按来源释放:池号回可申请,后台发放号保留 released 审计态。
|
||
if err := releasePrettyDisplayIDByLeaseInTx(ctx, tx, appCode, activeLeaseID, assignment.NowMs, "replaced_by_new_pretty"); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
} else {
|
||
// 用户从默认短号切到靓号时,不删除默认短号,只把它挂起,后续过期或释放可以原样恢复。
|
||
_, err := tx.ExecContext(ctx, `
|
||
UPDATE user_display_user_ids
|
||
SET status = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
|
||
`, string(userdomain.DisplayUserIDStatusHeld), assignment.NowMs, appCode, user.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive))
|
||
if err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
}
|
||
|
||
// 租约是靓号生命周期的主记录;长期靓号 expires_at_ms=0,限时靓号写入明确到期时间。
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO pretty_display_user_id_leases (
|
||
app_code, lease_id, display_user_id, user_id, status, starts_at_ms, expires_at_ms,
|
||
released_at_ms, release_reason, payment_receipt_id, source, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?)
|
||
`, appCode, assignment.LeaseID, assignment.DisplayUserID, user.UserID, string(userdomain.PrettyLeaseStatusActive), assignment.NowMs, assignment.ExpiresAtMs, shared.NullableString(assignment.PaymentReceiptID), assignment.Source, assignment.NowMs, assignment.NowMs)
|
||
if err != nil {
|
||
return userdomain.Identity{}, shared.MapPrettyDuplicateError(err)
|
||
}
|
||
|
||
// 当前展示号占用单独写入 user_display_user_ids,和 users 快照互相校验,便于全局唯一性检查。
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO user_display_user_ids (
|
||
app_code, display_user_id, user_id, display_user_id_kind, status,
|
||
assigned_at_ms, activated_at_ms, expires_at_ms, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appCode, assignment.DisplayUserID, user.UserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive), assignment.NowMs, assignment.NowMs, assignment.ExpiresAtMs, assignment.NowMs, assignment.NowMs)
|
||
if err != nil {
|
||
return userdomain.Identity{}, shared.MapPrettyDuplicateError(err)
|
||
}
|
||
|
||
// users 表保存高频读取快照,资料接口不用每次 join 租约表也能返回当前展示号和到期时间。
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET current_display_user_id = ?, current_display_user_id_kind = ?, current_display_user_id_expires_at_ms = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, assignment.DisplayUserID, string(userdomain.DisplayUserIDKindPretty), assignment.ExpiresAtMs, assignment.NowMs, appCode, user.UserID)
|
||
if err != nil {
|
||
return userdomain.Identity{}, shared.MapPrettyDuplicateError(err)
|
||
}
|
||
|
||
// 展示号变更日志记录 old/new、操作者、请求 ID,覆盖、池申请、后台发放和旧付费接口都复用同一审计结构。
|
||
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
|
||
AppCode: appCode,
|
||
UserID: user.UserID,
|
||
OldID: user.CurrentDisplayUserID,
|
||
NewID: assignment.DisplayUserID,
|
||
OldKind: user.CurrentDisplayUserIDKind,
|
||
NewKind: userdomain.DisplayUserIDKindPretty,
|
||
ChangeType: assignment.ChangeType,
|
||
LeaseID: assignment.LeaseID,
|
||
Reason: assignment.Reason,
|
||
OperatorID: assignment.OperatorUserID,
|
||
RequestID: assignment.RequestID,
|
||
CreatedAtMs: assignment.NowMs,
|
||
}); err != nil {
|
||
return userdomain.Identity{}, err
|
||
}
|
||
|
||
return userdomain.Identity{
|
||
AppCode: appCode,
|
||
UserID: user.UserID,
|
||
DisplayUserID: assignment.DisplayUserID,
|
||
DefaultDisplayUserID: user.DefaultDisplayUserID,
|
||
DisplayUserIDKind: userdomain.DisplayUserIDKindPretty,
|
||
DisplayUserIDExpiresAtMs: assignment.ExpiresAtMs,
|
||
PrettyID: assignment.PrettyID,
|
||
PrettyDisplayUserID: assignment.DisplayUserID,
|
||
Status: userdomain.DisplayUserIDStatusActive,
|
||
}, nil
|
||
}
|
||
|
||
func lockActivePrettyLease(ctx context.Context, tx *sql.Tx, appCode string, userID int64) (string, string, error) {
|
||
var leaseID string
|
||
var displayUserID string
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT lease_id, display_user_id
|
||
FROM pretty_display_user_id_leases
|
||
WHERE app_code = ? AND user_id = ? AND status = ?
|
||
FOR UPDATE
|
||
`, appcode.Normalize(appCode), userID, string(userdomain.PrettyLeaseStatusActive)).Scan(&leaseID, &displayUserID)
|
||
if err == sql.ErrNoRows {
|
||
return "", "", nil
|
||
}
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return leaseID, displayUserID, nil
|
||
}
|
||
|
||
func releasePrettyDisplayIDByLeaseInTx(ctx context.Context, tx *sql.Tx, appCode string, leaseID string, nowMs int64, reason string) error {
|
||
// source=pool 的号码释放后重新进入池;后台手动发放的自由靓号没有池归属,释放后进入 released 防止自动复用。
|
||
_, err := tx.ExecContext(ctx, `
|
||
UPDATE pretty_display_ids
|
||
SET status = CASE
|
||
WHEN source = ? THEN ?
|
||
ELSE ?
|
||
END,
|
||
assigned_user_id = 0,
|
||
assigned_lease_id = '',
|
||
assigned_at_ms = 0,
|
||
released_at_ms = ?,
|
||
release_reason = ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND assigned_lease_id = ? AND status = ?
|
||
`, userdomain.PrettyDisplayIDSourcePool, userdomain.PrettyDisplayIDStatusAvailable, userdomain.PrettyDisplayIDStatusReleased, nowMs, reason, nowMs, appcode.Normalize(appCode), leaseID, userdomain.PrettyDisplayIDStatusAssigned)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) assertDisplayUserIDGloballyAvailable(ctx context.Context, tx *sql.Tx, displayUserID string) error {
|
||
// users 表同时覆盖真实 user_id、默认短号和当前展示号,避免管理员发放成任何用户已使用的 ID。
|
||
var exists int
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT 1
|
||
FROM users
|
||
WHERE app_code = ?
|
||
AND (CAST(user_id AS CHAR) = ? OR default_display_user_id = ? OR current_display_user_id = ?)
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), displayUserID, displayUserID, displayUserID).Scan(&exists)
|
||
if err != nil && err != sql.ErrNoRows {
|
||
return err
|
||
}
|
||
if exists == 1 {
|
||
return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists")
|
||
}
|
||
|
||
// user_display_user_ids 覆盖 active/held/reserved/blocked 历史占用,默认短号 held 时也不能被靓号抢占。
|
||
err = tx.QueryRowContext(ctx, `
|
||
SELECT 1
|
||
FROM user_display_user_ids
|
||
WHERE app_code = ? AND display_user_id = ? AND status IN (?, ?, ?, ?)
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), displayUserID, string(userdomain.DisplayUserIDStatusActive), string(userdomain.DisplayUserIDStatusHeld), string(userdomain.DisplayUserIDStatusReserved), string(userdomain.DisplayUserIDStatusBlocked)).Scan(&exists)
|
||
if err != nil && err != sql.ErrNoRows {
|
||
return err
|
||
}
|
||
if exists == 1 {
|
||
return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists")
|
||
}
|
||
|
||
// pretty_display_ids 覆盖池中未发放、已发放、禁用和预留号码,防止批量生成和后台发放互相撞号。
|
||
err = tx.QueryRowContext(ctx, `
|
||
SELECT 1
|
||
FROM pretty_display_ids
|
||
WHERE app_code = ? AND display_user_id = ? AND status IN (?, ?, ?, ?)
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), displayUserID, userdomain.PrettyDisplayIDStatusAvailable, userdomain.PrettyDisplayIDStatusAssigned, userdomain.PrettyDisplayIDStatusDisabled, userdomain.PrettyDisplayIDStatusReserved).Scan(&exists)
|
||
if err != nil && err != sql.ErrNoRows {
|
||
return err
|
||
}
|
||
if exists == 1 {
|
||
return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListPrettyDisplayIDPools 分页读取后台靓号池配置。
|
||
func (r *Repository) ListPrettyDisplayIDPools(ctx context.Context, query userdomain.PrettyDisplayIDPoolQuery) ([]userdomain.PrettyDisplayIDPool, int64, error) {
|
||
appCode := appcode.Normalize(query.AppCode)
|
||
page, pageSize := normalizeIdentityPage(query.Page, query.PageSize)
|
||
where := []string{"app_code = ?"}
|
||
args := []any{appCode}
|
||
if query.Status != "" {
|
||
where = append(where, "status = ?")
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.LevelTrack != "" {
|
||
where = append(where, "level_track = ?")
|
||
args = append(args, query.LevelTrack)
|
||
}
|
||
whereSQL := "WHERE " + strings.Join(where, " AND ")
|
||
|
||
var total int64
|
||
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pretty_display_id_pools "+whereSQL, args...).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT app_code, pool_id, name, level_track, min_level, max_level, rule_type,
|
||
COALESCE(CAST(rule_config_json AS CHAR), ''), status, sort_order,
|
||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||
FROM pretty_display_id_pools
|
||
`+whereSQL+`
|
||
ORDER BY sort_order ASC, created_at_ms DESC, pool_id ASC
|
||
LIMIT ? OFFSET ?
|
||
`, append(args, int(pageSize), int((page-1)*pageSize))...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
pools := make([]userdomain.PrettyDisplayIDPool, 0)
|
||
for rows.Next() {
|
||
pool, err := scanPrettyDisplayIDPool(rows)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
pools = append(pools, pool)
|
||
}
|
||
return pools, total, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) CreatePrettyDisplayIDPool(ctx context.Context, command userdomain.PrettyDisplayIDPoolCommand) (userdomain.PrettyDisplayIDPool, error) {
|
||
_, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO pretty_display_id_pools (
|
||
app_code, pool_id, name, level_track, min_level, max_level, rule_type,
|
||
rule_config_json, status, sort_order, created_by_admin_id, updated_by_admin_id,
|
||
created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.Normalize(command.AppCode), command.PoolID, command.Name, command.LevelTrack, command.MinLevel, command.MaxLevel, command.RuleType, nullableJSON(command.RuleConfigJSON), command.Status, command.SortOrder, command.OperatorAdminID, command.OperatorAdminID, command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDPool{}, err
|
||
}
|
||
return queryPrettyDisplayIDPool(ctx, r.db, appcode.Normalize(command.AppCode), command.PoolID)
|
||
}
|
||
|
||
func (r *Repository) UpdatePrettyDisplayIDPool(ctx context.Context, command userdomain.PrettyDisplayIDPoolCommand) (userdomain.PrettyDisplayIDPool, error) {
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE pretty_display_id_pools
|
||
SET name = ?, level_track = ?, min_level = ?, max_level = ?, rule_type = ?,
|
||
rule_config_json = ?, status = ?, sort_order = ?, updated_by_admin_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND pool_id = ?
|
||
`, command.Name, command.LevelTrack, command.MinLevel, command.MaxLevel, command.RuleType, nullableJSON(command.RuleConfigJSON), command.Status, command.SortOrder, command.OperatorAdminID, command.NowMs, appcode.Normalize(command.AppCode), command.PoolID)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDPool{}, err
|
||
}
|
||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||
return userdomain.PrettyDisplayIDPool{}, xerr.New(xerr.NotFound, "pretty display id pool not found")
|
||
}
|
||
return queryPrettyDisplayIDPool(ctx, r.db, appcode.Normalize(command.AppCode), command.PoolID)
|
||
}
|
||
|
||
func (r *Repository) GeneratePrettyDisplayIDs(ctx context.Context, command userdomain.PrettyDisplayIDGenerateCommand) (userdomain.PrettyDisplayIDGenerationBatch, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
appCode := appcode.Normalize(command.AppCode)
|
||
pool, err := queryPrettyDisplayIDPoolForUpdate(ctx, tx, appCode, command.PoolID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, xerr.New(xerr.NotFound, "pretty display id pool not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
// 生成必须锁池配置,确保管理员同时修改规则或禁用池时,本批次仍以一个确定的池状态落库。
|
||
ruleType := command.RuleType
|
||
if ruleType == "" {
|
||
ruleType = pool.RuleType
|
||
}
|
||
|
||
generated := int32(0)
|
||
skipped := int32(0)
|
||
if command.RuleConfigJSON == "" {
|
||
// 生成弹窗没有单独填写规则配置时,沿用靓号池保存的配置,保证“按池生成”不会丢掉排除数字等配置。
|
||
command.RuleConfigJSON = pool.RuleConfigJSON
|
||
}
|
||
candidates, err := generatePrettyDisplayIDCandidates(ruleType, command.RuleConfigJSON)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
|
||
// 候选按规则确定顺序扫描,遇到已有用户 ID、默认短号、当前靓号或池内号码就跳过,不随机重试。
|
||
for _, candidate := range candidates {
|
||
if generated >= command.Count {
|
||
break
|
||
}
|
||
if err := r.assertDisplayUserIDGloballyAvailable(ctx, tx, candidate); err != nil {
|
||
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
|
||
skipped++
|
||
continue
|
||
}
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO pretty_display_ids (
|
||
app_code, pretty_id, pool_id, source, display_user_id, status,
|
||
generated_batch_id, created_by_admin_id, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appCode, idgen.New("pretty"), command.PoolID, userdomain.PrettyDisplayIDSourcePool, candidate, userdomain.PrettyDisplayIDStatusAvailable, command.BatchID, command.OperatorAdminID, command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
// 唯一键冲突和显式查重一样计入 skipped,保证并发生成时不会因为个别撞号中断整个批次。
|
||
if xerr.IsCode(shared.MapPrettyDuplicateError(err), xerr.DisplayUserIDPrettyNotAvailable) {
|
||
skipped++
|
||
continue
|
||
}
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
generated++
|
||
}
|
||
if generated < command.Count {
|
||
// 候选空间被耗尽时也要记录缺口,便于后台看到“请求 1000 个但实际生成不足”的原因。
|
||
shortfall := command.Count - generated
|
||
if shortfall > skipped {
|
||
skipped = shortfall
|
||
}
|
||
}
|
||
|
||
// 批次记录和号码记录同事务提交,后台列表能用 batch_id 追溯一次生成实际落了哪些号码。
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO pretty_display_id_generation_batches (
|
||
app_code, batch_id, pool_id, rule_type, rule_config_json, requested_count,
|
||
generated_count, skipped_conflict_count, status, operator_admin_id,
|
||
request_id, created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appCode, command.BatchID, command.PoolID, ruleType, nullableJSON(command.RuleConfigJSON), command.Count, generated, skipped, userdomain.PrettyDisplayIDBatchStatusCompleted, command.OperatorAdminID, command.RequestID, command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.PrettyDisplayIDGenerationBatch{}, err
|
||
}
|
||
return userdomain.PrettyDisplayIDGenerationBatch{
|
||
AppCode: appCode,
|
||
BatchID: command.BatchID,
|
||
PoolID: command.PoolID,
|
||
RuleType: ruleType,
|
||
RuleConfigJSON: command.RuleConfigJSON,
|
||
RequestedCount: command.Count,
|
||
GeneratedCount: generated,
|
||
SkippedConflictCount: skipped,
|
||
Status: userdomain.PrettyDisplayIDBatchStatusCompleted,
|
||
OperatorAdminID: command.OperatorAdminID,
|
||
RequestID: command.RequestID,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}, nil
|
||
}
|
||
|
||
func (r *Repository) ListPrettyDisplayIDs(ctx context.Context, query userdomain.PrettyDisplayIDQuery) ([]userdomain.PrettyDisplayID, int64, error) {
|
||
appCode := appcode.Normalize(query.AppCode)
|
||
page, pageSize := normalizeIdentityPage(query.Page, query.PageSize)
|
||
where := []string{"pdi.app_code = ?"}
|
||
args := []any{appCode}
|
||
if query.PoolID != "" {
|
||
where = append(where, "pdi.pool_id = ?")
|
||
args = append(args, query.PoolID)
|
||
}
|
||
if query.Source != "" {
|
||
where = append(where, "pdi.source = ?")
|
||
args = append(args, query.Source)
|
||
}
|
||
if query.Status != "" {
|
||
where = append(where, "pdi.status = ?")
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.AssignedUserID > 0 {
|
||
where = append(where, "pdi.assigned_user_id = ?")
|
||
args = append(args, query.AssignedUserID)
|
||
}
|
||
if query.Keyword != "" {
|
||
where = append(where, "(pdi.pretty_id LIKE ? OR pdi.display_user_id LIKE ?)")
|
||
keyword := "%" + query.Keyword + "%"
|
||
args = append(args, keyword, keyword)
|
||
}
|
||
whereSQL := "WHERE " + strings.Join(where, " AND ")
|
||
|
||
var total int64
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM pretty_display_ids pdi
|
||
LEFT JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
`+whereSQL, args...).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT `+prettyDisplayIDSelectColumns()+`
|
||
FROM pretty_display_ids pdi
|
||
LEFT JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
`+whereSQL+`
|
||
ORDER BY pdi.created_at_ms DESC, pdi.pretty_id DESC
|
||
LIMIT ? OFFSET ?
|
||
`, append(args, int(pageSize), int((page-1)*pageSize))...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
items, err := scanPrettyDisplayIDRows(rows)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return items, total, rows.Err()
|
||
}
|
||
|
||
func (r *Repository) SetPrettyDisplayIDStatus(ctx context.Context, command userdomain.PrettyDisplayIDStatusCommand) (userdomain.PrettyDisplayID, error) {
|
||
// 状态切换只允许未占用号码,assigned 号码必须通过覆盖、过期或释放链路改变,避免后台直接改坏用户当前展示号。
|
||
result, err := r.db.ExecContext(ctx, `
|
||
UPDATE pretty_display_ids
|
||
SET status = ?, release_reason = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND pretty_id = ?
|
||
AND assigned_user_id = 0
|
||
AND assigned_lease_id = ''
|
||
AND status IN (?, ?, ?)
|
||
`, command.Status, command.Reason, command.NowMs, appcode.Normalize(command.AppCode), command.PrettyID, userdomain.PrettyDisplayIDStatusAvailable, userdomain.PrettyDisplayIDStatusDisabled, userdomain.PrettyDisplayIDStatusReserved)
|
||
if err != nil {
|
||
return userdomain.PrettyDisplayID{}, err
|
||
}
|
||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||
return userdomain.PrettyDisplayID{}, xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display id status can not be changed")
|
||
}
|
||
return queryPrettyDisplayID(ctx, r.db, appcode.Normalize(command.AppCode), command.PrettyID)
|
||
}
|
||
|
||
func (r *Repository) AdminGrantPrettyDisplayID(ctx context.Context, command userdomain.AdminGrantPrettyDisplayIDCommand) (userdomain.Identity, string, error) {
|
||
// 后台发放可以输入任意合规内容;如果同名旧限时靓号已到期,先懒过期释放后再做全局唯一性校验。
|
||
if err := r.expirePrettyByDisplayUserID(ctx, command.DisplayUserID, command.NowMs, command.RequestID); err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 后台发放不经过号码池,但仍要和用户真实 ID、默认短号、当前展示号、池号和保留号做全局冲突检查。
|
||
if err := r.assertDisplayUserIDGloballyAvailable(ctx, tx, command.DisplayUserID); err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
// 锁目标用户后再插入租约和更新 users,确保同一用户同时被发放或申请池号时只有一个事务成功替换。
|
||
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.TargetUserID)
|
||
if err == sql.ErrNoRows {
|
||
return userdomain.Identity{}, "", xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
|
||
expiresAtMs := int64(0)
|
||
if command.DurationMs > 0 {
|
||
// duration_ms<=0 在服务层表示长期;只有正数才转换成明确过期时间,供懒过期和定时恢复使用。
|
||
expiresAtMs = command.NowMs + command.DurationMs
|
||
}
|
||
// 自由输入的后台靓号也先写入 pretty_display_ids,并直接标记 assigned,便于列表、审计和后续释放统一处理。
|
||
_, err = tx.ExecContext(ctx, `
|
||
INSERT INTO pretty_display_ids (
|
||
app_code, pretty_id, pool_id, source, display_user_id, status,
|
||
assigned_user_id, assigned_lease_id, assigned_at_ms, created_by_admin_id,
|
||
created_at_ms, updated_at_ms
|
||
)
|
||
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.Normalize(command.AppCode), command.PrettyID, userdomain.PrettyDisplayIDSourceAdminGrant, command.DisplayUserID, userdomain.PrettyDisplayIDStatusAssigned, command.TargetUserID, command.LeaseID, command.NowMs, command.OperatorAdminID, command.NowMs, command.NowMs)
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", shared.MapPrettyDuplicateError(err)
|
||
}
|
||
|
||
// 真正覆盖用户当前展示号仍走统一替换事务,确保旧池号释放、旧后台号 released 和默认号 held 行为完全一致。
|
||
identity, err := r.replacePrettyInTx(ctx, tx, user, prettyAssignment{
|
||
PrettyID: command.PrettyID,
|
||
LeaseID: command.LeaseID,
|
||
DisplayUserID: command.DisplayUserID,
|
||
ExpiresAtMs: expiresAtMs,
|
||
Source: userdomain.PrettyDisplayIDSourceAdminGrant,
|
||
ChangeType: "pretty_admin_grant",
|
||
Reason: command.Reason,
|
||
OperatorUserID: command.OperatorAdminID,
|
||
RequestID: command.RequestID,
|
||
NowMs: command.NowMs,
|
||
})
|
||
if err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userdomain.Identity{}, "", err
|
||
}
|
||
return identity, command.LeaseID, nil
|
||
}
|
||
|
||
func normalizeIdentityPage(page int32, pageSize int32) (int32, int32) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 100 {
|
||
pageSize = 100
|
||
}
|
||
return page, pageSize
|
||
}
|
||
|
||
func nullableJSON(value string) any {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil
|
||
}
|
||
return value
|
||
}
|
||
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
func scanPrettyDisplayIDPool(scanner rowScanner) (userdomain.PrettyDisplayIDPool, error) {
|
||
var pool userdomain.PrettyDisplayIDPool
|
||
err := scanner.Scan(
|
||
&pool.AppCode,
|
||
&pool.PoolID,
|
||
&pool.Name,
|
||
&pool.LevelTrack,
|
||
&pool.MinLevel,
|
||
&pool.MaxLevel,
|
||
&pool.RuleType,
|
||
&pool.RuleConfigJSON,
|
||
&pool.Status,
|
||
&pool.SortOrder,
|
||
&pool.CreatedByAdminID,
|
||
&pool.UpdatedByAdminID,
|
||
&pool.CreatedAtMs,
|
||
&pool.UpdatedAtMs,
|
||
)
|
||
return pool, err
|
||
}
|
||
|
||
func queryPrettyDisplayIDPool(ctx context.Context, q shared.Queryer, appCode string, poolID string) (userdomain.PrettyDisplayIDPool, error) {
|
||
return scanPrettyDisplayIDPool(q.QueryRowContext(ctx, `
|
||
SELECT app_code, pool_id, name, level_track, min_level, max_level, rule_type,
|
||
COALESCE(CAST(rule_config_json AS CHAR), ''), status, sort_order,
|
||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||
FROM pretty_display_id_pools
|
||
WHERE app_code = ? AND pool_id = ?
|
||
`, appCode, poolID))
|
||
}
|
||
|
||
func queryPrettyDisplayIDPoolForUpdate(ctx context.Context, tx *sql.Tx, appCode string, poolID string) (userdomain.PrettyDisplayIDPool, error) {
|
||
return scanPrettyDisplayIDPool(tx.QueryRowContext(ctx, `
|
||
SELECT app_code, pool_id, name, level_track, min_level, max_level, rule_type,
|
||
COALESCE(CAST(rule_config_json AS CHAR), ''), status, sort_order,
|
||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||
FROM pretty_display_id_pools
|
||
WHERE app_code = ? AND pool_id = ?
|
||
FOR UPDATE
|
||
`, appCode, poolID))
|
||
}
|
||
|
||
func prettyDisplayIDSelectColumns() string {
|
||
return `
|
||
pdi.app_code, pdi.pretty_id, pdi.pool_id, pdi.source, pdi.display_user_id,
|
||
pdi.status, pdi.assigned_user_id, pdi.assigned_lease_id, pdi.assigned_at_ms,
|
||
pdi.released_at_ms, pdi.release_reason, pdi.generated_batch_id,
|
||
pdi.created_by_admin_id, pdi.created_at_ms, pdi.updated_at_ms,
|
||
COALESCE(pool.app_code, ''), COALESCE(pool.pool_id, ''), COALESCE(pool.name, ''),
|
||
COALESCE(pool.level_track, ''), COALESCE(pool.min_level, 0), COALESCE(pool.max_level, 0),
|
||
COALESCE(pool.rule_type, ''), COALESCE(CAST(pool.rule_config_json AS CHAR), ''),
|
||
COALESCE(pool.status, ''), COALESCE(pool.sort_order, 0),
|
||
COALESCE(pool.created_by_admin_id, 0), COALESCE(pool.updated_by_admin_id, 0),
|
||
COALESCE(pool.created_at_ms, 0), COALESCE(pool.updated_at_ms, 0)`
|
||
}
|
||
|
||
func scanPrettyDisplayIDRows(rows *sql.Rows) ([]userdomain.PrettyDisplayID, error) {
|
||
items := make([]userdomain.PrettyDisplayID, 0)
|
||
for rows.Next() {
|
||
item, err := scanPrettyDisplayID(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, rows.Err()
|
||
}
|
||
|
||
func scanPrettyDisplayID(scanner rowScanner) (userdomain.PrettyDisplayID, error) {
|
||
var item userdomain.PrettyDisplayID
|
||
err := scanner.Scan(
|
||
&item.AppCode,
|
||
&item.PrettyID,
|
||
&item.PoolID,
|
||
&item.Source,
|
||
&item.DisplayUserID,
|
||
&item.Status,
|
||
&item.AssignedUserID,
|
||
&item.AssignedLeaseID,
|
||
&item.AssignedAtMs,
|
||
&item.ReleasedAtMs,
|
||
&item.ReleaseReason,
|
||
&item.GeneratedBatchID,
|
||
&item.CreatedByAdminID,
|
||
&item.CreatedAtMs,
|
||
&item.UpdatedAtMs,
|
||
&item.Pool.AppCode,
|
||
&item.Pool.PoolID,
|
||
&item.Pool.Name,
|
||
&item.Pool.LevelTrack,
|
||
&item.Pool.MinLevel,
|
||
&item.Pool.MaxLevel,
|
||
&item.Pool.RuleType,
|
||
&item.Pool.RuleConfigJSON,
|
||
&item.Pool.Status,
|
||
&item.Pool.SortOrder,
|
||
&item.Pool.CreatedByAdminID,
|
||
&item.Pool.UpdatedByAdminID,
|
||
&item.Pool.CreatedAtMs,
|
||
&item.Pool.UpdatedAtMs,
|
||
)
|
||
return item, err
|
||
}
|
||
|
||
func queryPrettyDisplayID(ctx context.Context, q shared.Queryer, appCode string, prettyID string) (userdomain.PrettyDisplayID, error) {
|
||
return scanPrettyDisplayID(q.QueryRowContext(ctx, `
|
||
SELECT `+prettyDisplayIDSelectColumns()+`
|
||
FROM pretty_display_ids pdi
|
||
LEFT JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
WHERE pdi.app_code = ? AND pdi.pretty_id = ?
|
||
`, appCode, prettyID))
|
||
}
|
||
|
||
func queryPrettyDisplayIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, prettyID string) (userdomain.PrettyDisplayID, error) {
|
||
return scanPrettyDisplayID(tx.QueryRowContext(ctx, `
|
||
SELECT `+prettyDisplayIDSelectColumns()+`
|
||
FROM pretty_display_ids pdi
|
||
LEFT JOIN pretty_display_id_pools pool
|
||
ON pool.app_code = pdi.app_code AND pool.pool_id = pdi.pool_id
|
||
WHERE pdi.app_code = ? AND pdi.pretty_id = ?
|
||
FOR UPDATE
|
||
`, appCode, prettyID))
|
||
}
|