253 lines
9.3 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 user
import (
"context"
"fmt"
"strings"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// BatchGetUserAdminProfiles 聚合 user-service 同库内的用户、登录、角色和封禁事实。
// 该查询不读取 wallet/activity 等 owner 数据admin-server 会分别批量读取并组装最终 DTO。
func (r *Repository) BatchGetUserAdminProfiles(ctx context.Context, userIDs []int64, _ int64) (map[int64]userdomain.AdminProfile, error) {
users, err := r.BatchGetUsers(ctx, userIDs)
if err != nil {
return nil, err
}
profiles := make(map[int64]userdomain.AdminProfile, len(users))
for userID, item := range users {
profiles[userID] = userdomain.AdminProfile{
User: item,
BirthDate: item.BirthDate,
RegisterDevice: item.RegisterDevice,
Roles: make([]string, 0, 6),
}
}
if len(profiles) == 0 {
return profiles, nil
}
placeholders, idArgs := adminProfileIDArgs(userIDs)
if err := r.hydrateAdminLastSuccessLogin(ctx, profiles, placeholders, idArgs); err != nil {
return nil, err
}
if err := r.hydrateAdminRoles(ctx, profiles, placeholders, idArgs); err != nil {
return nil, err
}
if err := r.hydrateAdminLastOperations(ctx, profiles, placeholders, idArgs); err != nil {
return nil, err
}
if err := r.hydrateAdminBanSummaries(ctx, profiles, placeholders, idArgs); err != nil {
return nil, err
}
return profiles, nil
}
func (r *Repository) hydrateAdminLastSuccessLogin(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error {
args := append([]any{appcode.FromContext(ctx)}, idArgs...)
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, MAX(created_at_ms)
FROM login_audit
WHERE app_code = ? AND result = 'success' AND user_id IN (%s)
GROUP BY user_id`, placeholders), args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID, loggedInAtMs int64
if err := rows.Scan(&userID, &loggedInAtMs); err != nil {
return err
}
profile, ok := profiles[userID]
if ok {
profile.LastSuccessLoginAtMs = loggedInAtMs
profiles[userID] = profile
}
}
return rows.Err()
}
func (r *Repository) hydrateAdminRoles(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error {
args := append([]any{appcode.FromContext(ctx)}, idArgs...)
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
EXISTS(SELECT 1 FROM host_profiles h WHERE h.app_code = u.app_code AND h.user_id = u.user_id AND h.status = 'active'),
EXISTS(SELECT 1 FROM agencies a WHERE a.app_code = u.app_code AND a.active_owner_user_id = u.user_id),
EXISTS(SELECT 1 FROM bd_profiles b WHERE b.app_code = u.app_code AND b.user_id = u.user_id AND b.status = 'active'),
EXISTS(SELECT 1 FROM bd_leader_profiles bl WHERE bl.app_code = u.app_code AND bl.user_id = u.user_id AND bl.status = 'active'),
EXISTS(SELECT 1 FROM coin_seller_profiles c WHERE c.app_code = u.app_code AND c.user_id = u.user_id AND c.status = 'active'),
EXISTS(SELECT 1 FROM manager_profiles m WHERE m.app_code = u.app_code AND m.user_id = u.user_id AND m.status = 'active')
FROM users u
WHERE u.app_code = ? AND u.user_id IN (%s)`, placeholders), args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID int64
var host, agency, bd, bdLeader, coinSeller, manager bool
if err := rows.Scan(&userID, &host, &agency, &bd, &bdLeader, &coinSeller, &manager); err != nil {
return err
}
profile, ok := profiles[userID]
if !ok {
continue
}
// 固定顺序让 CSV、列表和详情在相同事实下始终产生一致展示避免 map/set 顺序漂移。
profile.Roles = appendAdminRole(profile.Roles, host, userdomain.AdminRoleHost)
profile.Roles = appendAdminRole(profile.Roles, agency, userdomain.AdminRoleAgency)
profile.Roles = appendAdminRole(profile.Roles, bd, userdomain.AdminRoleBD)
profile.Roles = appendAdminRole(profile.Roles, bdLeader, userdomain.AdminRoleBDLeader)
profile.Roles = appendAdminRole(profile.Roles, coinSeller, userdomain.AdminRoleCoinSeller)
profile.Roles = appendAdminRole(profile.Roles, manager, userdomain.AdminRoleManager)
profiles[userID] = profile
}
return rows.Err()
}
func (r *Repository) hydrateAdminLastOperations(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error {
args := make([]any, 0, len(idArgs)+2)
args = append(args, appcode.FromContext(ctx))
args = append(args, idArgs...)
args = append(args, appcode.FromContext(ctx))
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT log.target_user_id, log.operator_type, log.operator_user_id, log.reason, log.created_at_ms
FROM user_status_change_logs log
INNER JOIN (
SELECT target_user_id, MAX(id) AS latest_id
FROM user_status_change_logs
WHERE app_code = ? AND target_user_id IN (%s)
GROUP BY target_user_id
) latest ON latest.latest_id = log.id
WHERE log.app_code = ?`, placeholders), args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID, operatorUserID, operatedAtMs int64
var operatorType, reason string
if err := rows.Scan(&userID, &operatorType, &operatorUserID, &reason, &operatedAtMs); err != nil {
return err
}
profile, ok := profiles[userID]
if ok {
profile.LastOperatorType = operatorType
profile.LastOperatorUserID = operatorUserID
profile.LastOperationReason = reason
profile.LastOperationAtMs = operatedAtMs
profiles[userID] = profile
}
}
return rows.Err()
}
func (r *Repository) hydrateAdminBanSummaries(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error {
args := make([]any, 0, len(idArgs)*2+2)
args = append(args, appcode.FromContext(ctx))
args = append(args, idArgs...)
args = append(args, appcode.FromContext(ctx))
args = append(args, idArgs...)
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT 'admin', ban_id, target_user_id, expires_at_ms, operator_admin_id, reason, created_at_ms
FROM admin_user_bans
WHERE app_code = ? AND status = 'active' AND target_user_id IN (%s)
UNION ALL
SELECT 'manager', block_id, target_user_id, blocked_until_ms, manager_user_id, reason, created_at_ms
FROM manager_user_blocks
WHERE app_code = ? AND status = 'active' AND target_user_id IN (%s)`, placeholders, placeholders), args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var source, banID, reason string
var targetUserID, expiresAtMs, operatorUserID, createdAtMs int64
if err := rows.Scan(&source, &banID, &targetUserID, &expiresAtMs, &operatorUserID, &reason, &createdAtMs); err != nil {
return err
}
profile, ok := profiles[targetUserID]
if !ok {
continue
}
profile.Ban = mergeActiveBan(profile.Ban, userdomain.ActiveBanSummary{
Active: true,
Source: source,
BanID: banID,
Permanent: expiresAtMs == 0,
ExpiresAtMs: expiresAtMs,
UserStatus: profile.User.Status,
OperatorType: source,
OperatorUserID: operatorUserID,
Reason: reason,
CreatedAtMs: createdAtMs,
ActiveBanCount: 1,
})
profiles[targetUserID] = profile
}
if err := rows.Err(); err != nil {
return err
}
for userID, profile := range profiles {
if profile.Ban.Active || (profile.User.Status != userdomain.StatusBanned && profile.User.Status != userdomain.StatusDisabled) {
continue
}
// 历史 banned/disabled 没有新事实表记录时一律视为永久cron 绝不能仅凭时间猜测并恢复这些账号。
profile.Ban = userdomain.ActiveBanSummary{
Active: true,
Source: userdomain.BanSourceLegacy,
Permanent: true,
UserStatus: profile.User.Status,
OperatorType: profile.LastOperatorType,
OperatorUserID: profile.LastOperatorUserID,
Reason: profile.LastOperationReason,
CreatedAtMs: profile.LastOperationAtMs,
ActiveBanCount: 1,
}
profiles[userID] = profile
}
return nil
}
func adminProfileIDArgs(userIDs []int64) (string, []any) {
placeholders := make([]string, 0, len(userIDs))
args := make([]any, 0, len(userIDs))
for _, userID := range userIDs {
placeholders = append(placeholders, "?")
args = append(args, userID)
}
return strings.Join(placeholders, ","), args
}
func appendAdminRole(roles []string, active bool, role string) []string {
if active {
return append(roles, role)
}
return roles
}
func mergeActiveBan(current userdomain.ActiveBanSummary, candidate userdomain.ActiveBanSummary) userdomain.ActiveBanSummary {
if !current.Active {
return candidate
}
current.ActiveBanCount += candidate.ActiveBanCount
// 任一永久封禁都会让聚合视图永久;多个永久事实取最新一条作为可操作 ban_id 和审计展示。
if candidate.Permanent && (!current.Permanent || candidate.CreatedAtMs > current.CreatedAtMs) {
candidate.ActiveBanCount = current.ActiveBanCount
return candidate
}
if current.Permanent {
return current
}
// 全部是限时事实时,账号只有在最晚一条也到期后才能恢复,因此摘要展示最晚截止时间。
if candidate.ExpiresAtMs > current.ExpiresAtMs || (candidate.ExpiresAtMs == current.ExpiresAtMs && candidate.CreatedAtMs > current.CreatedAtMs) {
candidate.ActiveBanCount = current.ActiveBanCount
return candidate
}
return current
}