2026-07-12 14:17:45 +08:00

380 lines
12 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 appuser
import (
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Handler struct {
service *Service
cfg config.Config
audit shared.OperationLogger
}
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger, opts ...ServiceOption) *Handler {
return &Handler{
service: NewService(userClient, activityClient, adminDB, userDB, walletDB, opts...),
cfg: cfg,
audit: audit,
}
}
func (h *Handler) ListUsers(c *gin.Context) {
query := parseListQuery(c)
items, total, err := h.service.ListUsers(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取 App 用户列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListLoginLogs(c *gin.Context) {
query := parseLoginLogQuery(c)
items, total, err := h.service.ListLoginLogs(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取登录日志失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListBannedUsers(c *gin.Context) {
query := parseBanListQuery(c)
items, total, err := h.service.ListBannedUsers(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取封禁列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListUserLoginLogs(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
query := parseLoginLogQuery(c)
query.UserID = userID
items, total, err := h.service.ListLoginLogs(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取用户登录日志失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) GetUser(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
user, err := h.service.GetUser(c.Request.Context(), userID)
if err != nil {
writeReadError(c, err, "获取 App 用户失败")
return
}
response.OK(c, user)
}
func (h *Handler) UpdateUser(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "用户参数不正确")
return
}
user, err := h.service.UpdateUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "更新 App 用户失败")
return
}
detail := "update profile"
if req.Country != nil {
detail = fmt.Sprintf("update country=%s region_id=%d", user.Country, user.RegionID)
}
writeAppUserAuditLog(c, h.audit, "update-app-user", "app_users", userID, "success", detail)
response.OK(c, user)
}
func (h *Handler) BanUser(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req banUserRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "封禁参数不正确")
return
}
result, err := h.service.AdminBanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "封禁 App 用户失败")
return
}
detail := "permanent"
if req.ExpiresAtMs > 0 {
detail = fmt.Sprintf("expires_at_ms=%d", req.ExpiresAtMs)
}
writeAppUserAuditLog(c, h.audit, "ban-app-user", "app_users", userID, "success", detail)
response.OK(c, result)
}
func (h *Handler) UnbanUser(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req unbanUserRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "解封参数不正确")
return
}
result, err := h.service.AdminUnbanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "解封 App 用户失败")
return
}
writeAppUserAuditLog(c, h.audit, "unban-app-user", "app_users", userID, "success", strings.TrimSpace(req.Reason))
response.OK(c, result)
}
func (h *Handler) AdjustTemporaryLevels(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req adjustTemporaryLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "等级调整参数不正确")
return
}
user, err := h.service.AdjustTemporaryLevels(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "调整用户等级失败")
return
}
writeAppUserAuditLog(c, h.audit, "adjust-app-user-level", "app_users", userID, "success", fmt.Sprintf("tracks=%d", len(req.Adjustments)))
response.OK(c, user)
}
func (h *Handler) SetPassword(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req setPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "密码参数不正确")
return
}
if err := h.service.SetPassword(c.Request.Context(), userID, req.Password); err != nil {
writeMutationError(c, err, "设置 App 用户密码失败")
return
}
writeAppUserAuditLog(c, h.audit, "set-app-user-password", "app_users", userID, "success", "set password")
response.OK(c, gin.H{"passwordSet": true})
}
func (h *Handler) IssueAccessToken(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
result, err := h.service.IssueAccessToken(c.Request.Context(), userID)
if err != nil {
if grpcStatus, ok := status.FromError(err); ok && grpcStatus.Code() == codes.NotFound {
// 无活跃会话是明确业务事实token 是无状态 JWT没有 active session 就没有可重签对象。
response.NotFound(c, "该用户当前没有活跃会话")
return
}
writeMutationError(c, err, "获取用户 Token 失败")
return
}
// 重签出的 token 等价于账号接管能力,且 user-service 侧刻意不写 login_audit——
// 后台操作日志是唯一审计痕迹,所以这里 fail-closed审计落库失败就不返回 token。
if err := shared.OperationLogWithResourceIDStrict(c, h.audit, "issue-app-user-token", "app_users", fmt.Sprintf("%d", userID), "success", fmt.Sprintf("session_id=%s", result.SessionID)); err != nil {
response.ServerError(c, "操作日志写入失败,未返回 Token")
return
}
response.OK(c, result)
}
func (h *Handler) setUserStatus(c *gin.Context, status string, action string, successMessage string) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
result, err := h.service.SetUserStatus(c.Request.Context(), userID, status, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c))
if err != nil {
writeMutationError(c, err, successMessage+"失败")
return
}
writeAppUserAuditLog(c, h.audit, action, "app_users", userID, "success", fmt.Sprintf("status=%s", status))
response.OK(c, result)
}
func parseListQuery(c *gin.Context) listQuery {
options := shared.ListOptions(c)
regionID, regionIDSet := queryOptionalInt64(c, "region_id", "regionId")
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Country: firstQuery(c, "country", "country_code", "countryCode"),
Keyword: options.Keyword,
UserFilter: shared.UserIdentityFilterFromQuery(c, ""),
RegionID: regionID,
RegionIDSet: regionIDSet,
Status: options.Status,
SortBy: firstQuery(c, "sort_by", "sortBy"),
SortDirection: firstQuery(c, "sort_direction", "sortDirection", "order"),
StartMs: queryInt64(c, "start_ms", "startMs", "start_at_ms", "startAtMs"),
EndMs: queryInt64(c, "end_ms", "endMs", "end_at_ms", "endAtMs"),
})
}
func parseLoginLogQuery(c *gin.Context) loginLogQuery {
options := shared.ListOptions(c)
regionID, regionIDSet := queryOptionalInt64(c, "region_id", "regionId")
return normalizeLoginLogQuery(loginLogQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
UserFilter: shared.UserIdentityFilterFromQuery(c, ""),
UserID: queryInt64(c, "user_id", "userId"),
DisplayUserID: firstQuery(c, "display_user_id", "displayUserId"),
IP: firstQuery(c, "ip"),
IPCountryCode: firstQuery(c, "ip_country_code", "ipCountryCode"),
Channel: firstQuery(c, "channel"),
Platform: firstQuery(c, "platform"),
Result: firstQuery(c, "result"),
Blocked: firstQuery(c, "blocked"),
RegionID: regionID,
RegionIDSet: regionIDSet,
StartMs: queryInt64(c, "start_ms", "startMs"),
EndMs: queryInt64(c, "end_ms", "endMs"),
})
}
func parseBanListQuery(c *gin.Context) banListQuery {
options := shared.ListOptions(c)
return normalizeBanListQuery(banListQuery{
Page: options.Page,
PageSize: options.PageSize,
TargetKeyword: firstQuery(c, "target_keyword", "targetKeyword", "keyword"),
TargetFilter: shared.UserIdentityFilterFromQuery(c, "target"),
OperatorKeyword: firstQuery(c, "operator_keyword", "operatorKeyword"),
OperatorFilter: shared.UserIdentityFilterFromQuery(c, "operator"),
OperatorAdminKeyword: firstQuery(c, "operator_admin_keyword", "operatorAdminKeyword"),
StartMs: queryInt64(c, "start_ms", "startMs"),
EndMs: queryInt64(c, "end_ms", "endMs"),
})
}
func firstQuery(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {
return value
}
}
return ""
}
func queryInt64(c *gin.Context, names ...string) int64 {
value := firstQuery(c, names...)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func queryOptionalInt64(c *gin.Context, names ...string) (int64, bool) {
value := firstQuery(c, names...)
if value == "" {
return 0, false
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0, false
}
return parsed, true
}
func parseInt64ID(c *gin.Context, name string) (int64, bool) {
raw := strings.TrimSpace(c.Param(name))
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "ID 参数不正确")
return 0, false
}
return id, true
}
func writeReadError(c *gin.Context, err error, fallback string) {
if errors.Is(err, ErrNotFound) {
response.NotFound(c, "App 用户不存在")
return
}
// 读路径对外只回兜底文案、不透出内部细节;原始错误必须落日志,否则详情页 500 无从排查。
slog.ErrorContext(c.Request.Context(), "admin_app_user_read_failed",
slog.String("path", c.Request.URL.Path),
slog.String("error", err.Error()),
)
response.ServerError(c, fallback)
}
func writeMutationError(c *gin.Context, err error, fallback string) {
if errors.Is(err, ErrNotFound) {
response.NotFound(c, "App 用户不存在")
return
}
if errors.Is(err, ErrInvalidArgument) {
response.BadRequest(c, err.Error())
return
}
if grpcStatus, ok := status.FromError(err); ok {
message := strings.TrimSpace(grpcStatus.Message())
if message == "" {
message = fallback
}
switch grpcStatus.Code() {
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
// 等级目标不高于真实等级、规则缺失或仍有其他封禁都属于可修正的业务拒绝,不能伪装成 500。
response.BadRequest(c, message)
case codes.NotFound:
response.NotFound(c, message)
default:
response.ServerError(c, fallback)
}
return
}
response.ServerError(c, fallback)
}
func writeAppUserAuditLog(c *gin.Context, logger shared.OperationLogger, action string, resource string, resourceID int64, status string, detail string) {
shared.OperationLogWithResourceID(c, logger, action, resource, fmt.Sprintf("%d", resourceID), status, detail)
}