353 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package identity
import (
"context"
"database/sql"
"strconv"
"strings"
"hyapp/pkg/appcode"
"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"
)
func (r *Repository) GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error) {
identity, err := r.queryActiveIdentity(ctx, "du.user_id = ?", userID)
if err == nil {
return identity, nil
}
if err != sql.ErrNoRows {
return userdomain.Identity{}, err
}
// 旧数据如果缺少 user_display_user_ids active 行,仍回退 users 快照,避免身份表补数据前直接阻断用户。
user, err := userstorage.New(r.db).GetUser(ctx, userID)
if err != nil {
return userdomain.Identity{}, err
}
return user.EffectiveIdentity(), nil
}
// ResolveDisplayUserID 只解析当前有效展示号。
// 查询前先按输入短号触发懒过期,保证过期靓号不继续解析,默认短号能恢复解析。
func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error) {
// 解析前按输入短号触发懒过期,避免过期靓号继续被解析。
if err := r.expirePrettyByDisplayUserID(ctx, displayUserID, nowMs, "lazy_resolve"); err != nil {
return userdomain.Identity{}, err
}
identity, err := r.queryActiveIdentity(ctx, "du.display_user_id = ? AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?)", displayUserID, nowMs)
if err == sql.ErrNoRows {
// held 默认号、释放号和过期靓号都不能作为当前展示号解析。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found")
}
if err != nil {
return userdomain.Identity{}, err
}
if err := r.repairUserCurrentIdentity(ctx, identity, nowMs); err != nil {
return userdomain.Identity{}, err
}
return identity, nil
}
// ResolveAdminUserIdentifier 是后台精确查人的 owner 入口。它刻意不复用 ResolveDisplayUserID
// 客户端只能解析当前 active 展示号,而运营搜索还必须能用被有效靓号覆盖的永久默认短号定位同一用户。
func (r *Repository) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) {
userIdentifier = strings.TrimSpace(userIdentifier)
appCode := appcode.FromContext(ctx)
// 输入本身是已过期靓号时先执行既有懒过期流程;这样过期靓号不会继续作为后台搜索别名,
// 同时用户的默认短号会恢复 active后续 GetUserIdentity 返回的始终是当前身份。
if err := r.expirePrettyByDisplayUserID(ctx, userIdentifier, nowMs, "lazy_admin_resolve"); err != nil {
return 0, err
}
var userID int64
// live_display_user_id 是 active/held 事实的生成列并有 app 级唯一索引。active 表示当前有效展示号;
// held 只接受仍等于 users.default_display_user_id 的永久默认号,避免 reserved/blocked 或历史释放号被解析。
err := r.db.QueryRowContext(ctx, `
SELECT du.user_id
FROM user_display_user_ids du FORCE INDEX (uk_user_display_user_ids_live)
INNER JOIN users u
ON u.app_code = du.app_code
AND u.user_id = du.user_id
WHERE du.app_code = ?
AND du.live_display_user_id = ?
AND (
(du.status = ? AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?))
OR (du.status = ? AND du.display_user_id = u.default_display_user_id)
)
LIMIT 1
`, appCode, userIdentifier, string(userdomain.DisplayUserIDStatusActive), nowMs, string(userdomain.DisplayUserIDStatusHeld)).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
// 兼容 identity 事实表补齐前的旧用户:两次独立等值查询分别命中 current/default 唯一索引,
// 不使用 OR 扫描 app 用户集合。过期 pretty 快照不能作为当前展示号命中。
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (uk_users_current_display_user_id)
WHERE app_code = ?
AND current_display_user_id = ?
AND (
current_display_user_id_kind <> ?
OR current_display_user_id_expires_at_ms IS NULL
OR current_display_user_id_expires_at_ms = 0
OR current_display_user_id_expires_at_ms > ?
)
LIMIT 1
`, appCode, userIdentifier, string(userdomain.DisplayUserIDKindPretty), nowMs).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (uk_users_default_display_user_id)
WHERE app_code = ? AND default_display_user_id = ?
LIMIT 1
`, appCode, userIdentifier).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
// 展示号优先于长 user_id保持既有后台搜索的碰撞决策只有所有展示别名都未命中时才查主键。
parsedUserID, parseErr := strconv.ParseInt(userIdentifier, 10, 64)
if parseErr == nil && parsedUserID > 0 {
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (PRIMARY)
WHERE user_id = ? AND app_code = ?
LIMIT 1
`, parsedUserID, appCode).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
}
return 0, xerr.New(xerr.DisplayUserIDNotFound, "user identifier not found")
}
func (r *Repository) queryActiveIdentity(ctx context.Context, clause string, args ...any) (userdomain.Identity, error) {
// user_display_user_ids 的 active 行是展示号归属事实users.current_display_user_id 只是高频读取快照。
// 解析入口优先读取事实表,可以容忍历史脚本或异常中断导致 users 快照暂时没有同步到最新靓号。
appCode := appcode.FromContext(ctx)
queryArgs := []any{
string(userdomain.DisplayUserIDKindPretty),
appCode,
string(userdomain.DisplayUserIDStatusActive),
}
queryArgs = append(queryArgs, args...)
row := r.db.QueryRowContext(ctx, `
SELECT
du.app_code,
du.user_id,
du.display_user_id,
u.default_display_user_id,
du.display_user_id_kind,
COALESCE(du.expires_at_ms, 0),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases pdl
LEFT JOIN pretty_display_ids pdi
ON pdi.app_code = pdl.app_code
AND pdi.assigned_lease_id = pdl.lease_id
AND pdi.assigned_user_id = pdl.user_id
WHERE pdl.app_code = du.app_code
AND pdl.user_id = du.user_id
AND pdl.display_user_id = du.display_user_id
AND pdl.status = 'active'
ORDER BY pdl.starts_at_ms DESC
LIMIT 1
), ''),
CASE WHEN du.display_user_id_kind = ? THEN du.display_user_id ELSE '' END,
du.status
FROM user_display_user_ids du
INNER JOIN users u
ON u.app_code = du.app_code
AND u.user_id = du.user_id
WHERE du.app_code = ?
AND du.status = ?
AND `+clause+`
LIMIT 1
`, queryArgs...)
var identity userdomain.Identity
var kind string
var status string
if err := row.Scan(
&identity.AppCode,
&identity.UserID,
&identity.DisplayUserID,
&identity.DefaultDisplayUserID,
&kind,
&identity.DisplayUserIDExpiresAtMs,
&identity.PrettyID,
&identity.PrettyDisplayUserID,
&status,
); err != nil {
return userdomain.Identity{}, err
}
identity.DisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
identity.Status = userdomain.DisplayUserIDStatus(status)
return identity, nil
}
func (r *Repository) repairUserCurrentIdentity(ctx context.Context, identity userdomain.Identity, nowMs int64) error {
// users.current_display_user_id 是读取快照,真正归属以 user_display_user_ids active 行为准。
// 读路径发现快照滞后时做幂等同步,保证后续 GetUser、token 和 H5 账号确认都返回同一个当前展示号。
_, err := r.db.ExecContext(ctx, `
UPDATE users
SET current_display_user_id = ?,
current_display_user_id_kind = ?,
current_display_user_id_expires_at_ms = ?,
updated_at_ms = GREATEST(updated_at_ms, ?)
WHERE app_code = ?
AND user_id = ?
AND (
current_display_user_id <> ?
OR current_display_user_id_kind <> ?
OR COALESCE(current_display_user_id_expires_at_ms, 0) <> ?
)
`, identity.DisplayUserID, string(identity.DisplayUserIDKind), nullableExpiresAt(identity.DisplayUserIDExpiresAtMs), nowMs,
appcode.Normalize(identity.AppCode), identity.UserID, identity.DisplayUserID, string(identity.DisplayUserIDKind), identity.DisplayUserIDExpiresAtMs)
return err
}
func nullableExpiresAt(value int64) any {
if value <= 0 {
return nil
}
return value
}
// ChangeDisplayUserID 修改默认短号;有 active 靓号时拒绝,避免过期恢复目标不清晰。
func (r *Repository) ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error) {
// 默认短号修改涉及 users、短号记录和变更日志必须在一个事务内完成。
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 {
// FOR UPDATE 锁住用户行,避免并发修改默认短号或申请靓号。
return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found")
}
if err != nil {
return userdomain.Identity{}, err
}
if user.DisplayUserIDExpired(command.ChangedAtMs) {
// 修改前先恢复过期靓号,避免默认短号仍处于 held。
identity, err := r.expirePrettyInTx(ctx, tx, user, "", command.ChangedAtMs, command.RequestID)
if err != nil {
return userdomain.Identity{}, err
}
user = userdomain.User{
AppCode: user.AppCode,
UserID: identity.UserID,
DefaultDisplayUserID: identity.DefaultDisplayUserID,
CurrentDisplayUserID: identity.DisplayUserID,
CurrentDisplayUserIDKind: identity.DisplayUserIDKind,
Status: user.Status,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: command.ChangedAtMs,
}
}
if user.CurrentDisplayUserIDKind == userdomain.DisplayUserIDKindPretty {
// active 靓号期间拒绝改默认短号,保证过期恢复目标稳定。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDPrettyActive, "pretty display_user_id is active")
}
if user.DefaultDisplayUserID == command.NewDisplayUserID {
// 修改为相同默认短号按幂等成功处理,不写日志。
return user.EffectiveIdentity(), nil
}
if err := r.assertDisplayUserIDAvailable(ctx, tx, command.NewDisplayUserID, command.UserID, true); err != nil {
return userdomain.Identity{}, err
}
if command.CooldownMs > 0 {
// 冷却期检查必须在事务内读取变更日志,避免并发绕过。
if err := r.assertChangeCooldown(ctx, tx, command); err != nil {
return userdomain.Identity{}, err
}
}
// 旧默认短号释放,新默认短号插入为 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), command.ChangedAtMs, command.Reason, command.ChangedAtMs, appcode.Normalize(command.AppCode), command.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive))
if err != nil {
return userdomain.Identity{}, err
}
_, 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, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.Normalize(command.AppCode), command.NewDisplayUserID, command.UserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive), command.ChangedAtMs, command.ChangedAtMs, command.ChangedAtMs, command.ChangedAtMs)
if err != nil {
return userdomain.Identity{}, shared.MapDisplayDuplicateError(err)
}
_, err = tx.ExecContext(ctx, `
UPDATE users
SET default_display_user_id = ?, current_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 = ?
`, command.NewDisplayUserID, command.NewDisplayUserID, string(userdomain.DisplayUserIDKindDefault), command.ChangedAtMs, appcode.Normalize(command.AppCode), command.UserID)
if err != nil {
return userdomain.Identity{}, shared.MapDisplayDuplicateError(err)
}
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
AppCode: appcode.Normalize(command.AppCode),
UserID: command.UserID,
OldID: user.DefaultDisplayUserID,
NewID: command.NewDisplayUserID,
OldKind: userdomain.DisplayUserIDKindDefault,
NewKind: userdomain.DisplayUserIDKindDefault,
ChangeType: "default_change",
Reason: command.Reason,
OperatorID: command.OperatorUserID,
RequestID: command.RequestID,
CreatedAtMs: command.ChangedAtMs,
}); err != nil {
return userdomain.Identity{}, err
}
if err := tx.Commit(); err != nil {
// commit 失败时调用方不能使用返回 identity。
return userdomain.Identity{}, err
}
return userdomain.Identity{
AppCode: appcode.Normalize(command.AppCode),
UserID: command.UserID,
DisplayUserID: command.NewDisplayUserID,
DefaultDisplayUserID: command.NewDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}, nil
}
// ApplyPrettyDisplayUserID 创建临时靓号租约,并把默认短号置为 held。