2026-04-29 23:07:50 +08:00

480 lines
20 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 mysql 实现 user-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// GetUserIdentity 按 user_id 返回当前有效展示号快照;过期恢复由 service 层统一触发。
func (r *Repository) GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error) {
// 直接读取 users 当前快照;是否懒过期由 service 层统一判断。
user, err := r.GetUser(ctx, userID)
if err != nil {
return userdomain.Identity{}, err
}
if user.CurrentDisplayUserID == "" {
// 理论上用户创建事务会保证短号存在,该分支保护脏数据。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found")
}
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
}
user, err := r.queryUser(ctx, r.db, "WHERE current_display_user_id = ?", displayUserID)
if err == sql.ErrNoRows {
// held 默认号和过期靓号都不会匹配 current_display_user_id。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found")
}
if err != nil {
return userdomain.Identity{}, err
}
return user.EffectiveIdentity(), nil
}
// 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 := r.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{
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 user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusReleased), command.ChangedAtMs, command.Reason, command.ChangedAtMs, 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 (display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, command.NewDisplayUserID, command.UserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive), command.ChangedAtMs, command.ChangedAtMs, command.ChangedAtMs, command.ChangedAtMs)
if err != nil {
return userdomain.Identity{}, mapDuplicateError(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 user_id = ?
`, command.NewDisplayUserID, command.NewDisplayUserID, string(userdomain.DisplayUserIDKindDefault), command.ChangedAtMs, command.UserID)
if err != nil {
return userdomain.Identity{}, mapDuplicateError(err)
}
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
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{
UserID: command.UserID,
DisplayUserID: command.NewDisplayUserID,
DefaultDisplayUserID: command.NewDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}, nil
}
// ApplyPrettyDisplayUserID 创建临时靓号租约,并把默认短号置为 held。
func (r *Repository) ApplyPrettyDisplayUserID(ctx context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error) {
// 靓号申请涉及租约、默认短号 held、当前展示号覆盖和变更日志必须事务化。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.Identity{}, "", err
}
defer tx.Rollback()
user, err := r.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 user.DisplayUserIDExpired(command.NowMs) {
// 如果旧靓号已过期,先恢复默认短号再申请新靓号。
identity, err := r.expirePrettyInTx(ctx, tx, user, "", command.NowMs, command.RequestID)
if err != nil {
return userdomain.Identity{}, "", err
}
user.CurrentDisplayUserID = identity.DisplayUserID
user.CurrentDisplayUserIDKind = identity.DisplayUserIDKind
user.CurrentDisplayUserIDExpiresAtMs = 0
}
if user.CurrentDisplayUserIDKind == userdomain.DisplayUserIDKindPretty {
// 同一用户不能叠加 active 靓号。
return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPrettyActive, "pretty display_user_id is active")
}
if err := r.assertDisplayUserIDAvailable(ctx, tx, command.PrettyDisplayID, command.UserID, false); err != nil {
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
// 靓号冲突使用专用 reason便于前端展示购买失败原因。
return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display_user_id is not available")
}
return userdomain.Identity{}, "", err
}
// 默认短号从 active 改为 held过期时会重新激活。
expiresAtMs := command.NowMs + command.LeaseDurationMs
_, err = tx.ExecContext(ctx, `
UPDATE user_display_user_ids
SET status = ?, updated_at_ms = ?
WHERE user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusHeld), command.NowMs, command.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusActive))
if err != nil {
return userdomain.Identity{}, "", err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO pretty_display_user_id_leases (lease_id, display_user_id, user_id, status, starts_at_ms, expires_at_ms, payment_receipt_id, source, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, command.LeaseID, command.PrettyDisplayID, command.UserID, string(userdomain.PrettyLeaseStatusActive), command.NowMs, expiresAtMs, nullableString(command.PaymentReceiptID), command.Source, command.NowMs, command.NowMs)
if err != nil {
return userdomain.Identity{}, "", mapPrettyDuplicateError(err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO user_display_user_ids (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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, command.PrettyDisplayID, command.UserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive), command.NowMs, command.NowMs, expiresAtMs, command.NowMs, command.NowMs)
if err != nil {
return userdomain.Identity{}, "", mapPrettyDuplicateError(err)
}
_, 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 user_id = ?
`, command.PrettyDisplayID, string(userdomain.DisplayUserIDKindPretty), expiresAtMs, command.NowMs, command.UserID)
if err != nil {
return userdomain.Identity{}, "", mapPrettyDuplicateError(err)
}
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
UserID: command.UserID,
OldID: user.CurrentDisplayUserID,
NewID: command.PrettyDisplayID,
OldKind: user.CurrentDisplayUserIDKind,
NewKind: userdomain.DisplayUserIDKindPretty,
ChangeType: "pretty_apply",
LeaseID: command.LeaseID,
Reason: "pretty_apply",
OperatorID: command.OperatorUserID,
RequestID: command.RequestID,
CreatedAtMs: command.NowMs,
}); err != nil {
return userdomain.Identity{}, "", err
}
if err := tx.Commit(); err != nil {
// commit 失败时租约和展示号覆盖都不能视为成功。
return userdomain.Identity{}, "", err
}
return userdomain.Identity{
UserID: command.UserID,
DisplayUserID: command.PrettyDisplayID,
DefaultDisplayUserID: user.DefaultDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindPretty,
DisplayUserIDExpiresAtMs: expiresAtMs,
Status: userdomain.DisplayUserIDStatusActive,
}, command.LeaseID, 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 := r.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 {
// 只找可能受输入短号影响的过期 pretty 用户,避免每次解析全表扫描。
var userID int64
err := r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users
WHERE current_display_user_id_kind = ?
AND current_display_user_id_expires_at_ms IS NOT NULL
AND current_display_user_id_expires_at_ms <= ?
AND (current_display_user_id = ? OR default_display_user_id = ?)
LIMIT 1
`, 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
}
func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, leaseID string, nowMs int64, requestID string) (userdomain.Identity, error) {
// 调用方必须已经锁住 users 行;本函数继续锁 active lease 行。
if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
// 非 pretty 状态重复过期按幂等成功返回当前身份。
return user.EffectiveIdentity(), nil
}
if user.CurrentDisplayUserIDExpiresAtMs > nowMs {
// 未到期时拒绝恢复,防止提前释放已购买靓号。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not expired")
}
var activeLeaseID string
err := tx.QueryRowContext(ctx, `
SELECT lease_id
FROM pretty_display_user_id_leases
WHERE user_id = ? AND status = ?
FOR UPDATE
`, user.UserID, string(userdomain.PrettyLeaseStatusActive)).Scan(&activeLeaseID)
if err != nil && err != sql.ErrNoRows {
return userdomain.Identity{}, err
}
if leaseID != "" && activeLeaseID != "" && leaseID != activeLeaseID {
// 指定 lease_id 不匹配 active lease 时拒绝,防止旧任务误释放新租约。
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not active")
}
// 租约、pretty 短号记录、默认短号记录和 users 快照一起恢复。
_, err = tx.ExecContext(ctx, `
UPDATE pretty_display_user_id_leases
SET status = ?, updated_at_ms = ?
WHERE user_id = ? AND status = ?
`, string(userdomain.PrettyLeaseStatusExpired), nowMs, 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 user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusExpired), nowMs, "pretty_expire", nowMs, user.UserID, user.CurrentDisplayUserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive))
if err != nil {
return userdomain.Identity{}, err
}
_, err = tx.ExecContext(ctx, `
UPDATE user_display_user_ids
SET status = ?, activated_at_ms = ?, updated_at_ms = ?
WHERE user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusActive), nowMs, nowMs, 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 user_id = ?
`, string(userdomain.DisplayUserIDKindDefault), nowMs, user.UserID)
if err != nil {
return userdomain.Identity{}, err
}
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
UserID: user.UserID,
OldID: user.CurrentDisplayUserID,
NewID: user.DefaultDisplayUserID,
OldKind: userdomain.DisplayUserIDKindPretty,
NewKind: userdomain.DisplayUserIDKindDefault,
ChangeType: "pretty_expire",
LeaseID: activeLeaseID,
Reason: "pretty_expire",
RequestID: requestID,
CreatedAtMs: nowMs,
}); err != nil {
return userdomain.Identity{}, err
}
return userdomain.Identity{
UserID: user.UserID,
DisplayUserID: user.DefaultDisplayUserID,
DefaultDisplayUserID: user.DefaultDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}, nil
}
func (r *Repository) assertDisplayUserIDAvailable(ctx context.Context, tx *sql.Tx, displayUserID string, userID int64, allowSameOwner bool) error {
// FOR UPDATE 锁住目标短号记录,避免并发抢占同一个 display_user_id。
var existingUserID int64
err := tx.QueryRowContext(ctx, `
SELECT user_id
FROM user_display_user_ids
WHERE display_user_id = ? AND status IN (?, ?, ?, ?)
FOR UPDATE
`, displayUserID, string(userdomain.DisplayUserIDStatusActive), string(userdomain.DisplayUserIDStatusHeld), string(userdomain.DisplayUserIDStatusReserved), string(userdomain.DisplayUserIDStatusBlocked)).Scan(&existingUserID)
if err == sql.ErrNoRows {
// 没有 active/held/reserved/blocked 记录时可用。
return nil
}
if err != nil {
return err
}
if allowSameOwner && existingUserID == userID {
// 默认短号修改允许同用户幂等检查通过。
return nil
}
return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists")
}
func (r *Repository) assertChangeCooldown(ctx context.Context, tx *sql.Tx, command userdomain.DisplayUserIDChangeCommand) error {
// 冷却期只统计默认短号修改,不统计靓号 apply/expire。
var lastChangedAt sql.NullInt64
err := tx.QueryRowContext(ctx, `
SELECT MAX(created_at_ms)
FROM display_user_id_change_logs
WHERE user_id = ? AND change_type = ?
`, command.UserID, "default_change").Scan(&lastChangedAt)
if err != nil {
return err
}
if lastChangedAt.Valid && command.ChangedAtMs-lastChangedAt.Int64 < command.CooldownMs {
// 仍在冷却期内时拒绝修改默认短号。
return xerr.New(xerr.DisplayUserIDCooldown, "display_user_id change is cooling down")
}
return nil
}
type displayUserIDLog struct {
// UserID 是短号变更所属用户。
UserID int64
// OldID 是变更前短号。
OldID string
// NewID 是变更后短号。
NewID string
// OldKind 是变更前来源。
OldKind userdomain.DisplayUserIDKind
// NewKind 是变更后来源。
NewKind userdomain.DisplayUserIDKind
// ChangeType 区分 default_change、pretty_apply、pretty_expire。
ChangeType string
// LeaseID 是靓号相关变更的租约 ID。
LeaseID string
// Reason 是操作原因。
Reason string
// OperatorID 是操作者,系统任务可为空。
OperatorID int64
// RequestID 是入口请求或任务 ID。
RequestID string
// CreatedAtMs 是日志创建时间。
CreatedAtMs int64
}
func insertDisplayUserIDChangeLog(ctx context.Context, tx *sql.Tx, item displayUserIDLog) error {
// 所有短号变更都写审计日志,便于追踪默认号和靓号覆盖历史。
_, err := tx.ExecContext(ctx, `
INSERT INTO display_user_id_change_logs (user_id, old_display_user_id, new_display_user_id, old_display_user_id_kind, new_display_user_id_kind, change_type, lease_id, reason, operator_user_id, request_id, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, item.UserID, item.OldID, item.NewID, string(item.OldKind), string(item.NewKind), item.ChangeType, nullableString(item.LeaseID), item.Reason, nullableOperator(item.OperatorID), item.RequestID, item.CreatedAtMs)
return err
}