package appuser import ( "context" "database/sql" "fmt" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/modules/shared" ) type AppUserBanRecord struct { ID int64 `json:"id"` Target AppUserBrief `json:"target"` Operator BanOperator `json:"operator"` OldStatus string `json:"oldStatus"` NewStatus string `json:"newStatus"` Reason string `json:"reason"` RequestID string `json:"requestId"` CreatedAtMs int64 `json:"createdAtMs"` } type AppUserBrief struct { Avatar string `json:"avatar"` DisplayUserID string `json:"displayUserId"` UserID string `json:"userId"` Username string `json:"username"` } type BanOperator struct { Type string `json:"type"` AdminID string `json:"adminId,omitempty"` Account string `json:"account,omitempty"` Avatar string `json:"avatar,omitempty"` DisplayUserID string `json:"displayUserId,omitempty"` Name string `json:"name,omitempty"` UserID string `json:"userId,omitempty"` } type adminOperatorProfile struct { Account string Name string } func (s *Service) ListBannedUsers(ctx context.Context, query banListQuery) ([]AppUserBanRecord, int64, error) { if s.userDB == nil { return nil, 0, fmt.Errorf("user mysql is not configured") } query = normalizeBanListQuery(query) adminIDs, err := s.lookupAdminOperatorIDs(ctx, query.operatorAdminLookupKeyword()) if err != nil { return nil, 0, err } whereSQL, args := banListWhereSQL(ctx, query, adminIDs) total, err := countRows(ctx, s.userDB, whereSQL, args...) if err != nil { return nil, 0, err } rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT l.id, l.target_user_id, target.current_display_user_id, COALESCE(target.username, ''), COALESCE(target.avatar, ''), l.old_status, l.new_status, l.operator_type, l.operator_user_id, COALESCE(operator_user.current_display_user_id, ''), COALESCE(operator_user.username, ''), COALESCE(operator_user.avatar, ''), l.reason, l.request_id, l.created_at_ms %s ORDER BY l.created_at_ms DESC, l.id DESC LIMIT ? OFFSET ? `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]AppUserBanRecord, 0, query.PageSize) adminOperatorIDs := make([]int64, 0) for rows.Next() { item, adminOperatorID, err := scanBanRecord(rows) if err != nil { return nil, 0, err } if adminOperatorID > 0 { adminOperatorIDs = append(adminOperatorIDs, adminOperatorID) } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } if err := s.fillAdminOperators(ctx, items, adminOperatorIDs); err != nil { return nil, 0, err } return items, total, nil } func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64) (string, []any) { appCode := appctx.FromContext(ctx) whereSQL := ` FROM user_status_change_logs l INNER JOIN ( SELECT app_code, target_user_id, MAX(id) AS latest_id FROM user_status_change_logs WHERE app_code = ? AND new_status IN ('banned', 'disabled') GROUP BY app_code, target_user_id ) latest ON latest.app_code = l.app_code AND latest.latest_id = l.id INNER JOIN users target ON target.app_code = l.app_code AND target.user_id = l.target_user_id LEFT JOIN users operator_user ON l.operator_type = 'app_user' AND operator_user.app_code = l.app_code AND operator_user.user_id = l.operator_user_id WHERE l.app_code = ? AND target.status IN ('banned', 'disabled')` args := []any{appCode, appCode} nowMs := nowMillis() if !query.TargetFilter.IsEmpty() { matchSQL, matchArgs := shared.UserIdentityFieldsSQL("target", "target.user_id", query.TargetFilter, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } else if query.TargetKeyword != "" { matchSQL, matchArgs := shared.UserIdentityKeywordSQL("target", "target.user_id", query.TargetKeyword, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } if !query.OperatorFilter.IsEmpty() { matchSQL, matchArgs := shared.UserIdentityFieldsSQL("operator_user", "operator_user.user_id", query.OperatorFilter, nowMs) whereSQL += " AND (l.operator_type = 'app_user' AND " + matchSQL + ")" args = append(args, matchArgs...) } else if query.OperatorKeyword != "" { matchSQL, matchArgs := shared.UserIdentityKeywordSQL("operator_user", "operator_user.user_id", query.OperatorKeyword, nowMs) operatorClauses := []string{"(l.operator_type = 'app_user' AND " + matchSQL + ")"} args = append(args, matchArgs...) if len(adminIDs) > 0 { operatorClauses = append(operatorClauses, "(l.operator_type = 'admin' AND l.operator_user_id IN ("+placeholders(len(adminIDs))+"))") for _, id := range adminIDs { args = append(args, id) } } whereSQL += " AND (" + strings.Join(operatorClauses, " OR ") + ")" } else if query.OperatorAdminKeyword != "" { if len(adminIDs) == 0 { whereSQL += " AND 1 = 0" } else { whereSQL += " AND (l.operator_type = 'admin' AND l.operator_user_id IN (" + placeholders(len(adminIDs)) + "))" for _, id := range adminIDs { args = append(args, id) } } } if query.StartMs > 0 { whereSQL += " AND l.created_at_ms >= ?" args = append(args, query.StartMs) } if query.EndMs > 0 { whereSQL += " AND l.created_at_ms < ?" args = append(args, query.EndMs) } return whereSQL, args } func scanBanRecord(rows *sql.Rows) (AppUserBanRecord, int64, error) { var item AppUserBanRecord var targetUserID int64 var operatorType string var operatorUserID int64 var operatorDisplayUserID string var operatorUsername string var operatorAvatar string if err := rows.Scan( &item.ID, &targetUserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar, &item.OldStatus, &item.NewStatus, &operatorType, &operatorUserID, &operatorDisplayUserID, &operatorUsername, &operatorAvatar, &item.Reason, &item.RequestID, &item.CreatedAtMs, ); err != nil { return AppUserBanRecord{}, 0, err } item.Target.UserID = strconv.FormatInt(targetUserID, 10) item.Operator.Type = operatorType if operatorType == "admin" { item.Operator.AdminID = strconv.FormatInt(operatorUserID, 10) item.Operator.Account = item.Operator.AdminID return item, operatorUserID, nil } if operatorType == "app_user" && operatorUserID > 0 { item.Operator.UserID = strconv.FormatInt(operatorUserID, 10) item.Operator.DisplayUserID = operatorDisplayUserID item.Operator.Name = operatorUsername item.Operator.Avatar = operatorAvatar } return item, 0, nil } func (s *Service) lookupAdminOperatorIDs(ctx context.Context, keyword string) ([]int64, error) { if s.adminDB == nil || strings.TrimSpace(keyword) == "" { return nil, nil } like := "%" + strings.TrimSpace(keyword) + "%" rows, err := s.adminDB.QueryContext(ctx, ` SELECT id FROM admin_users WHERE CAST(id AS CHAR) LIKE ? OR username LIKE ? OR name LIKE ? LIMIT 200 `, like, like, like) if err != nil { return nil, err } defer rows.Close() ids := make([]int64, 0) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func (s *Service) fillAdminOperators(ctx context.Context, items []AppUserBanRecord, adminIDs []int64) error { if s.adminDB == nil || len(adminIDs) == 0 { return nil } ids := uniquePositiveInt64s(adminIDs) if len(ids) == 0 { return nil } args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(` SELECT id, username, name FROM admin_users WHERE id IN (%s) `, placeholders(len(ids))), args...) if err != nil { return err } defer rows.Close() profiles := make(map[int64]adminOperatorProfile, len(ids)) for rows.Next() { var id int64 var profile adminOperatorProfile if err := rows.Scan(&id, &profile.Account, &profile.Name); err != nil { return err } profiles[id] = profile } if err := rows.Err(); err != nil { return err } for index := range items { if items[index].Operator.Type != "admin" { continue } adminID, _ := strconv.ParseInt(items[index].Operator.AdminID, 10, 64) profile, ok := profiles[adminID] if !ok { continue } items[index].Operator.Account = profile.Account items[index].Operator.Name = profile.Name } return nil } func normalizeBanListQuery(query banListQuery) banListQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } query.TargetKeyword = strings.TrimSpace(query.TargetKeyword) query.OperatorKeyword = strings.TrimSpace(query.OperatorKeyword) query.OperatorAdminKeyword = strings.TrimSpace(query.OperatorAdminKeyword) query.TargetFilter.UserID = strings.TrimSpace(query.TargetFilter.UserID) query.TargetFilter.DisplayUserID = strings.TrimSpace(query.TargetFilter.DisplayUserID) query.TargetFilter.Username = strings.TrimSpace(query.TargetFilter.Username) query.OperatorFilter.UserID = strings.TrimSpace(query.OperatorFilter.UserID) query.OperatorFilter.DisplayUserID = strings.TrimSpace(query.OperatorFilter.DisplayUserID) query.OperatorFilter.Username = strings.TrimSpace(query.OperatorFilter.Username) return query } func (query banListQuery) operatorAdminLookupKeyword() string { if keyword := strings.TrimSpace(query.OperatorAdminKeyword); keyword != "" { return keyword } return strings.TrimSpace(query.OperatorKeyword) } func uniquePositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func placeholders(count int) string { if count <= 0 { return "" } return strings.TrimRight(strings.Repeat("?,", count), ",") }