371 lines
13 KiB
Go
371 lines
13 KiB
Go
package user
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/xerr"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
userservice "hyapp/services/user-service/internal/service/user"
|
||
)
|
||
|
||
// SetUserStatus 原子更新用户主状态,并在非 active 状态下吊销该用户所有 active refresh session。
|
||
func (r *Repository) SetUserStatus(ctx context.Context, command userservice.UserStatusCommand) (userservice.UserStatusPersistenceResult, error) {
|
||
if r == nil || r.db == nil {
|
||
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
appCode := appcode.Normalize(command.AppCode)
|
||
if appCode == "" {
|
||
appCode = appcode.FromContext(ctx)
|
||
}
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
var oldStatus string
|
||
if err := tx.QueryRowContext(ctx, `
|
||
SELECT status
|
||
FROM users
|
||
WHERE app_code = ? AND user_id = ?
|
||
FOR UPDATE
|
||
`, appCode, command.TargetUserID).Scan(&oldStatus); err != nil {
|
||
if err == sql.ErrNoRows {
|
||
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.NotFound, "user not found")
|
||
}
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE users
|
||
SET status = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, string(command.Status), command.NowMs, appCode, command.TargetUserID)
|
||
if err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO user_status_change_logs (
|
||
app_code, target_user_id, old_status, new_status, operator_type, operator_user_id, reason, request_id, created_at_ms
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appCode, command.TargetUserID, oldStatus, string(command.Status), command.OperatorType, command.OperatorUserID, command.Reason, command.RequestID, command.NowMs); err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
|
||
sessionIDs := make([]string, 0)
|
||
if command.Status != userdomain.StatusActive {
|
||
rows, err := tx.QueryContext(ctx, `
|
||
SELECT session_id
|
||
FROM auth_sessions
|
||
WHERE app_code = ? AND user_id = ? AND expires_at_ms > ?
|
||
FOR UPDATE
|
||
`, appCode, command.TargetUserID, command.NowMs)
|
||
if err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
for rows.Next() {
|
||
var sessionID string
|
||
if err := rows.Scan(&sessionID); err != nil {
|
||
_ = rows.Close()
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
sessionIDs = append(sessionIDs, sessionID)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE auth_sessions
|
||
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL
|
||
`, command.NowMs, userStatusRevokeReason(command.Status), command.RequestID, revokedBy(command.OperatorUserID), command.NowMs, appCode, command.TargetUserID); err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
|
||
user, err := r.GetUser(appcode.WithContext(ctx, appCode), command.TargetUserID)
|
||
if err != nil {
|
||
return userservice.UserStatusPersistenceResult{}, err
|
||
}
|
||
return userservice.UserStatusPersistenceResult{User: user, RevokedSessionIDs: sessionIDs}, nil
|
||
}
|
||
|
||
func userStatusRevokeReason(status userdomain.Status) string {
|
||
switch status {
|
||
case userdomain.StatusBanned:
|
||
return "USER_BANNED"
|
||
case userdomain.StatusDisabled:
|
||
return "USER_DISABLED"
|
||
default:
|
||
return "USER_STATUS_CHANGED"
|
||
}
|
||
}
|
||
|
||
func revokedBy(operatorUserID int64) string {
|
||
if operatorUserID > 0 {
|
||
return "user_status_operator"
|
||
}
|
||
return "user_status"
|
||
}
|
||
|
||
// CreateManagerUserBlock 写入经理封禁记录;用户主状态修改由 service 层随后调用 SetUserStatus 完成。
|
||
func (r *Repository) CreateManagerUserBlock(ctx context.Context, command userservice.ManagerUserBlockCommand, target userdomain.User) (userservice.ManagerUserBlock, error) {
|
||
if r == nil || r.db == nil {
|
||
return userservice.ManagerUserBlock{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
appCode := appcode.FromContext(ctx)
|
||
blockID := idgen.New("mblock")
|
||
_, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO manager_user_blocks (
|
||
app_code, block_id, command_id, manager_user_id, target_user_id,
|
||
target_display_user_id, target_username, target_avatar, country, target_old_status,
|
||
blocked_until_ms, status, reason, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
|
||
appCode, blockID, command.CommandID, command.ManagerUserID, command.TargetUserID,
|
||
target.CurrentDisplayUserID, target.Username, target.Avatar, target.Country, string(target.Status),
|
||
command.BlockedUntilMS, command.Reason, command.NowMS, command.NowMS,
|
||
)
|
||
if err != nil {
|
||
if managerBlockDuplicate(err) {
|
||
return r.getManagerUserBlockByCommand(ctx, command.CommandID)
|
||
}
|
||
return userservice.ManagerUserBlock{}, err
|
||
}
|
||
return r.getManagerUserBlock(ctx, blockID)
|
||
}
|
||
|
||
func (r *Repository) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]userservice.ManagerUserBlock, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, managerBlockSelectSQL()+`
|
||
WHERE app_code = ? AND manager_user_id = ? AND status = ?
|
||
ORDER BY created_at_ms DESC, block_id DESC
|
||
LIMIT ?`,
|
||
appcode.FromContext(ctx), managerUserID, status, pageSize,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanManagerUserBlocks(rows)
|
||
}
|
||
|
||
func (r *Repository) ReleaseManagerUserBlock(ctx context.Context, command userservice.ReleaseManagerUserBlockCommand) (userservice.ManagerUserBlockRelease, error) {
|
||
return r.releaseManagerUserBlock(ctx, command.BlockID, command.ManagerUserID, command.Status, command.Reason, command.NowMS)
|
||
}
|
||
|
||
func (r *Repository) ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]userservice.ManagerUserBlockRelease, error) {
|
||
if r == nil || r.db == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT block_id, manager_user_id
|
||
FROM manager_user_blocks
|
||
WHERE app_code = ? AND status = 'active' AND blocked_until_ms <= ?
|
||
ORDER BY blocked_until_ms ASC, block_id ASC
|
||
LIMIT ?`,
|
||
appcode.FromContext(ctx), nowMS, limit,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
pending := make([]struct {
|
||
blockID string
|
||
managerUserID int64
|
||
}, 0)
|
||
for rows.Next() {
|
||
var item struct {
|
||
blockID string
|
||
managerUserID int64
|
||
}
|
||
if err := rows.Scan(&item.blockID, &item.managerUserID); err != nil {
|
||
_ = rows.Close()
|
||
return nil, err
|
||
}
|
||
pending = append(pending, item)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
releases := make([]userservice.ManagerUserBlockRelease, 0, len(pending))
|
||
for _, item := range pending {
|
||
// 逐条释放而不是批量 UPDATE,是为了给每个目标用户单独计算是否还存在其他 active 经理封禁。
|
||
release, err := r.releaseManagerUserBlock(ctx, item.blockID, item.managerUserID, userservice.ManagerUserBlockStatusExpired, "manager_center_block_expired", nowMS)
|
||
if err != nil {
|
||
return releases, err
|
||
}
|
||
releases = append(releases, release)
|
||
}
|
||
return releases, nil
|
||
}
|
||
|
||
func (r *Repository) releaseManagerUserBlock(ctx context.Context, blockID string, managerUserID int64, status string, reason string, nowMS int64) (userservice.ManagerUserBlockRelease, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
block, err := r.getManagerUserBlockForUpdate(ctx, tx, blockID, managerUserID)
|
||
if err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
if block.Status != userservice.ManagerUserBlockStatusActive {
|
||
if err := tx.Commit(); err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
return userservice.ManagerUserBlockRelease{Block: block}, nil
|
||
}
|
||
_, err = tx.ExecContext(ctx, `
|
||
UPDATE manager_user_blocks
|
||
SET status = ?, released_reason = ?, released_at_ms = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND block_id = ?`,
|
||
status, reason, nowMS, nowMS, appcode.FromContext(ctx), blockID,
|
||
)
|
||
if err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
block.Status = status
|
||
block.UpdatedAtMS = nowMS
|
||
activeCount, err := r.countActiveManagerBlocks(ctx, tx, block.TargetUserID)
|
||
if err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
// 只在“没有其他经理封禁 + 原状态是 active + 最近一次 ban 由经理封禁写入”时恢复,避免误解后台封禁。
|
||
latestManagerBan, err := r.latestBanBelongsToManagerBlock(ctx, tx, block.TargetUserID)
|
||
if err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return userservice.ManagerUserBlockRelease{}, err
|
||
}
|
||
return userservice.ManagerUserBlockRelease{
|
||
Block: block,
|
||
ShouldRestore: activeCount == 0 && block.TargetOldStatus == userdomain.StatusActive && latestManagerBan,
|
||
}, nil
|
||
}
|
||
|
||
func (r *Repository) getManagerUserBlock(ctx context.Context, blockID string) (userservice.ManagerUserBlock, error) {
|
||
row := r.db.QueryRowContext(ctx, managerBlockSelectSQL()+`WHERE app_code = ? AND block_id = ?`, appcode.FromContext(ctx), blockID)
|
||
return scanManagerUserBlock(row)
|
||
}
|
||
|
||
func (r *Repository) getManagerUserBlockByCommand(ctx context.Context, commandID string) (userservice.ManagerUserBlock, error) {
|
||
row := r.db.QueryRowContext(ctx, managerBlockSelectSQL()+`WHERE app_code = ? AND command_id = ?`, appcode.FromContext(ctx), commandID)
|
||
return scanManagerUserBlock(row)
|
||
}
|
||
|
||
func (r *Repository) getManagerUserBlockForUpdate(ctx context.Context, tx *sql.Tx, blockID string, managerUserID int64) (userservice.ManagerUserBlock, error) {
|
||
row := tx.QueryRowContext(ctx, managerBlockSelectSQL()+`
|
||
WHERE app_code = ? AND block_id = ? AND manager_user_id = ?
|
||
FOR UPDATE`,
|
||
appcode.FromContext(ctx), blockID, managerUserID,
|
||
)
|
||
return scanManagerUserBlock(row)
|
||
}
|
||
|
||
func (r *Repository) countActiveManagerBlocks(ctx context.Context, tx *sql.Tx, targetUserID int64) (int64, error) {
|
||
var count int64
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM manager_user_blocks
|
||
WHERE app_code = ? AND target_user_id = ? AND status = 'active'`,
|
||
appcode.FromContext(ctx), targetUserID,
|
||
).Scan(&count)
|
||
return count, err
|
||
}
|
||
|
||
func (r *Repository) latestBanBelongsToManagerBlock(ctx context.Context, tx *sql.Tx, targetUserID int64) (bool, error) {
|
||
var reason string
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT reason
|
||
FROM user_status_change_logs
|
||
WHERE app_code = ? AND target_user_id = ? AND new_status = 'banned'
|
||
ORDER BY created_at_ms DESC, id DESC
|
||
LIMIT 1`,
|
||
appcode.FromContext(ctx), targetUserID,
|
||
).Scan(&reason)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return strings.HasPrefix(reason, "manager_center_block:"), nil
|
||
}
|
||
|
||
func managerBlockSelectSQL() string {
|
||
return `
|
||
SELECT block_id, manager_user_id, target_user_id, target_display_user_id,
|
||
target_username, target_avatar, country, target_old_status, blocked_until_ms,
|
||
status, reason, created_at_ms, updated_at_ms
|
||
FROM manager_user_blocks
|
||
`
|
||
}
|
||
|
||
func scanManagerUserBlocks(rows *sql.Rows) ([]userservice.ManagerUserBlock, error) {
|
||
items := make([]userservice.ManagerUserBlock, 0)
|
||
for rows.Next() {
|
||
item, err := scanManagerUserBlockRows(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, rows.Err()
|
||
}
|
||
|
||
type managerBlockScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
func scanManagerUserBlock(row managerBlockScanner) (userservice.ManagerUserBlock, error) {
|
||
item, err := scanManagerUserBlockRows(row)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return userservice.ManagerUserBlock{}, xerr.New(xerr.NotFound, "manager block not found")
|
||
}
|
||
return item, err
|
||
}
|
||
|
||
func scanManagerUserBlockRows(row managerBlockScanner) (userservice.ManagerUserBlock, error) {
|
||
var item userservice.ManagerUserBlock
|
||
var oldStatus string
|
||
if err := row.Scan(
|
||
&item.BlockID,
|
||
&item.ManagerUserID,
|
||
&item.TargetUserID,
|
||
&item.TargetDisplayUserID,
|
||
&item.TargetUsername,
|
||
&item.TargetAvatar,
|
||
&item.Country,
|
||
&oldStatus,
|
||
&item.BlockedUntilMS,
|
||
&item.Status,
|
||
&item.Reason,
|
||
&item.CreatedAtMS,
|
||
&item.UpdatedAtMS,
|
||
); err != nil {
|
||
return userservice.ManagerUserBlock{}, err
|
||
}
|
||
item.TargetOldStatus = userdomain.Status(oldStatus)
|
||
return item, nil
|
||
}
|
||
|
||
func managerBlockDuplicate(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "Error 1062")
|
||
}
|