380 lines
12 KiB
Go
380 lines
12 KiB
Go
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)
|
||
}
|