253 lines
9.3 KiB
Go
253 lines
9.3 KiB
Go
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
|
||
}
|