bindbox-game/internal/api/admin/users_admin.go
2026-02-18 23:23:34 +08:00

2058 lines
68 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 admin
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/user"
)
type listUsersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"`
InviteCode string `form:"inviteCode"`
StartDate string `form:"startDate"`
EndDate string `form:"endDate"`
ID string `form:"id"`
DouyinID string `form:"douyin_id"`
DouyinUserID string `form:"douyin_user_id"`
}
type listUsersResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserItem `json:"list"`
}
// ListAppUsers 管理端用户列表
// @Summary 管理端用户列表
// @Description 查看APP端用户分页列表
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Param nickname query string false "用户昵称"
// @Param inviteCode query string false "邀请码"
// @Param startDate query string false "开始日期(YYYY-MM-DD)"
// @Param endDate query string false "结束日期(YYYY-MM-DD)"
// @Success 200 {object} listUsersResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users [get]
// @Security LoginVerifyToken
func (h *handler) ListAppUsers() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listUsersRequest)
rsp := new(listUsersResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
u := h.readDB.Users
c := h.readDB.Channels
q := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(c, c.ID.EqCol(u.ChannelID)).
Select(
u.ALL,
c.Name.As("channel_name"),
c.Code.As("channel_code"),
)
// 应用搜索条件
if req.ID != "" {
if id, err := strconv.ParseInt(req.ID, 10, 64); err == nil {
q = q.Where(h.readDB.Users.ID.Eq(id))
}
}
if req.Nickname != "" {
q = q.Where(h.readDB.Users.Nickname.Like("%" + req.Nickname + "%"))
}
if req.InviteCode != "" {
q = q.Where(h.readDB.Users.InviteCode.Eq(req.InviteCode))
}
if req.StartDate != "" {
if startTime, err := time.Parse("2006-01-02", req.StartDate); err == nil {
q = q.Where(h.readDB.Users.CreatedAt.Gte(startTime))
}
}
if req.EndDate != "" {
if endTime, err := time.Parse("2006-01-02", req.EndDate); err == nil {
// 设置结束时间为当天的23:59:59
endTime = endTime.Add(24 * time.Hour).Add(-time.Second)
q = q.Where(h.readDB.Users.CreatedAt.Lte(endTime))
}
}
if req.DouyinID != "" {
q = q.Where(h.readDB.Users.DouyinID.Like("%" + req.DouyinID + "%"))
}
if req.DouyinUserID != "" {
q = q.Where(h.readDB.Users.DouyinUserID.Like("%" + req.DouyinUserID + "%"))
}
total, err := q.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20101, err.Error()))
return
}
type result struct {
model.Users
ChannelName string
ChannelCode string
}
var rows []result
if err := q.Order(h.readDB.Users.ID.Desc()).Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Scan(&rows); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20102, err.Error()))
return
}
// 获取用户ID列表以批量查询资产
userIDs := make([]int64, len(rows))
for i, v := range rows {
userIDs[i] = v.ID
}
// 批量查询优惠券数量(未使用的)
couponCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.ID.Count().As("count")).
Where(h.readDB.UserCoupons.UserID.In(userIDs...)).
Where(h.readDB.UserCoupons.Status.Eq(1)). // 1=未使用
Group(h.readDB.UserCoupons.UserID).
Scan(&counts)
for _, c := range counts {
couponCounts[c.UserID] = c.Count
}
}
// 批量查询积分余额
pointBalances := make(map[int64]int64)
if len(userIDs) > 0 {
type balResult struct {
UserID int64
Points int64
}
var bRes []balResult
h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserPoints.UserID, h.readDB.UserPoints.Points.Sum().As("points")).
Where(h.readDB.UserPoints.UserID.In(userIDs...)).
Group(h.readDB.UserPoints.UserID).
Scan(&bRes)
for _, b := range bRes {
pointBalances[b.UserID] = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), b.Points))
}
}
// 批量查询优惠券数量(未使用的)
cardCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.ID.Count().As("count")).
Where(h.readDB.UserItemCards.UserID.In(userIDs...)).
Where(h.readDB.UserItemCards.Status.Eq(1)). // 1=未使用
Group(h.readDB.UserItemCards.UserID).
Scan(&counts)
for _, c := range counts {
cardCounts[c.UserID] = c.Count
}
}
// 批量查询消费统计
todayConsume := make(map[int64]int64)
sevenDayConsume := make(map[int64]int64)
thirtyDayConsume := make(map[int64]int64)
totalConsume := make(map[int64]int64)
if len(userIDs) > 0 {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
sevenDayStart := todayStart.AddDate(0, 0, -6)
thirtyDayStart := todayStart.AddDate(0, 0, -29)
type consumeResult struct {
UserID int64
Amount int64
}
// 当日消费
var todayRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(todayStart)).
Group(h.readDB.Orders.UserID).
Scan(&todayRes)
for _, r := range todayRes {
todayConsume[r.UserID] = r.Amount
}
// 近7天消费
var sevenRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)).
Group(h.readDB.Orders.UserID).
Scan(&sevenRes)
for _, r := range sevenRes {
sevenDayConsume[r.UserID] = r.Amount
}
// 近30天消费
var thirtyRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)).
Group(h.readDB.Orders.UserID).
Scan(&thirtyRes)
for _, r := range thirtyRes {
thirtyDayConsume[r.UserID] = r.Amount
}
// 累计消费
var totalRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Group(h.readDB.Orders.UserID).
Scan(&totalRes)
for _, r := range totalRes {
totalConsume[r.UserID] = r.Amount
}
}
// 批量查询邀请人数
inviteCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
InviterID int64
Count int64
}
var counts []countResult
h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Users.InviterID, h.readDB.Users.ID.Count().As("count")).
Where(h.readDB.Users.InviterID.In(userIDs...)).
Group(h.readDB.Users.InviterID).
Scan(&counts)
for _, c := range counts {
inviteCounts[c.InviterID] = c.Count
}
}
// 批量查询下线(被邀请人)累计消费金额
// 从 user_invites.accumulated_amount 聚合,该字段在被邀请人支付订单时自动更新
inviteeTotalConsumes := make(map[int64]int64)
if len(userIDs) > 0 {
type consumeResult struct {
InviterID int64
Total int64
}
var consumes []consumeResult
h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserInvites.InviterID, h.readDB.UserInvites.AccumulatedAmount.Sum().As("total")).
Where(h.readDB.UserInvites.InviterID.In(userIDs...)).
Group(h.readDB.UserInvites.InviterID).
Scan(&consumes)
for _, c := range consumes {
inviteeTotalConsumes[c.InviterID] = c.Total
}
}
// 批量查询次数卡数量
gamePassCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
now := time.Now()
h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserGamePasses.UserID, h.readDB.UserGamePasses.Remaining.Sum().As("count")).
Where(h.readDB.UserGamePasses.UserID.In(userIDs...)).
Where(h.readDB.UserGamePasses.Remaining.Gt(0)).
Where(h.readDB.UserGamePasses.Where(h.readDB.UserGamePasses.ExpiredAt.Gt(now)).Or(h.readDB.UserGamePasses.ExpiredAt.IsNull())).
Group(h.readDB.UserGamePasses.UserID).
Scan(&counts)
for _, c := range counts {
gamePassCounts[c.UserID] = c.Count
}
}
// 批量查询游戏资格数量
gameTicketCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
h.readDB.UserGameTickets.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserGameTickets.UserID, h.readDB.UserGameTickets.Available.Sum().As("count")).
Where(h.readDB.UserGameTickets.UserID.In(userIDs...)).
Group(h.readDB.UserGameTickets.UserID).
Scan(&counts)
for _, c := range counts {
gameTicketCounts[c.UserID] = c.Count
}
}
// 批量查询持有商品价值
inventoryValues := make(map[int64]int64)
if len(userIDs) > 0 {
type invResult struct {
UserID int64
Value int64
}
var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.In(userIDs...)).
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
Group(h.readDB.UserInventory.UserID).
Scan(&invRes)
for _, r := range invRes {
inventoryValues[r.UserID] = r.Value
}
}
// 批量查询优惠券价值(余额之和)
couponValues := make(map[int64]int64)
if len(userIDs) > 0 {
type valResult struct {
UserID int64
Value int64
}
var vRes []valResult
h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.BalanceAmount.Sum().As("value")).
Where(h.readDB.UserCoupons.UserID.In(userIDs...)).
Where(h.readDB.UserCoupons.Status.Eq(1)).
Group(h.readDB.UserCoupons.UserID).
Scan(&vRes)
for _, v := range vRes {
couponValues[v.UserID] = v.Value
}
}
// 批量查询道具卡价值
itemCardValues := make(map[int64]int64)
if len(userIDs) > 0 {
type valResult struct {
UserID int64
Value int64
}
var vRes []valResult
h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)).
Select(h.readDB.UserItemCards.UserID, h.readDB.SystemItemCards.Price.Sum().As("value")).
Where(h.readDB.UserItemCards.UserID.In(userIDs...)).
Where(h.readDB.UserItemCards.Status.Eq(1)).
Group(h.readDB.UserItemCards.UserID).
Scan(&vRes)
for _, v := range vRes {
itemCardValues[v.UserID] = v.Value
}
}
// 批量查询所有用户的邀请人昵称
inviterNicknames := make(map[int64]string)
inviterIDs := make([]int64, 0)
for _, v := range rows {
if v.InviterID > 0 {
inviterIDs = append(inviterIDs, v.InviterID)
}
}
if len(inviterIDs) > 0 {
inviters, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.In(inviterIDs...)).Find()
for _, inv := range inviters {
inviterNicknames[inv.ID] = inv.Nickname
}
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = make([]adminUserItem, len(rows))
for i, v := range rows {
pointsBal := pointBalances[v.ID]
invVal := inventoryValues[v.ID]
cpVal := couponValues[v.ID]
icVal := itemCardValues[v.ID]
gpCount := gamePassCounts[v.ID]
gtCount := gameTicketCounts[v.ID]
// 总资产估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次)
// 游戏资格不计入估值(购买其他商品赠送,无实际价值)
assetVal := pointsBal*100 + invVal + cpVal + icVal + gpCount*200
rsp.List[i] = adminUserItem{
ID: v.ID,
Nickname: v.Nickname,
Avatar: v.Avatar,
InviteCode: v.InviteCode,
InviterID: v.InviterID,
InviterNickname: inviterNicknames[v.InviterID],
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
DouyinID: v.DouyinID,
DouyinUserID: v.DouyinUserID,
Mobile: v.Mobile,
Remark: v.Remark,
ChannelName: v.ChannelName,
ChannelCode: v.ChannelCode,
PointsBalance: pointsBal,
CouponsCount: couponCounts[v.ID],
ItemCardsCount: cardCounts[v.ID],
TodayConsume: todayConsume[v.ID],
SevenDayConsume: sevenDayConsume[v.ID],
ThirtyDayConsume: thirtyDayConsume[v.ID],
TotalConsume: totalConsume[v.ID],
InviteCount: inviteCounts[v.ID],
InviteeTotalConsume: inviteeTotalConsumes[v.ID],
GamePassCount: gpCount,
GameTicketCount: gtCount,
InventoryValue: invVal,
TotalAssetValue: assetVal,
Status: v.Status,
}
}
ctx.Payload(rsp)
}
}
type listInvitesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listInvitesResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserItem `json:"list"`
Summary struct {
TotalConsume int64 `json:"total_consume"` // 所有下线累计消费
TotalAsset int64 `json:"total_asset"` // 所有下线资产价值
TotalProfit int64 `json:"total_profit"` // 所有下线总盈亏
} `json:"summary"`
}
// ListUserInvites 查看用户邀请列表
// @Summary 查看用户邀请列表
// @Description 查看指定用户邀请的用户列表,包含每个被邀请人的累计消费金额
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listInvitesResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/invites [get]
// @Security LoginVerifyToken
func (h *handler) ListUserInvites() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listInvitesRequest)
rsp := new(listInvitesResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
rows, total, err := h.userSvc.ListInvites(ctx.RequestContext(), userID, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20103, err.Error()))
return
}
// 获取被邀请人ID列表批量查询消费金额
inviteeIDs := make([]int64, len(rows))
for i, v := range rows {
inviteeIDs[i] = v.ID
}
// 从 user_invites 表查询每个被邀请人的累计消费
inviteeConsumes := make(map[int64]int64)
if len(inviteeIDs) > 0 {
type consumeResult struct {
InviteeID int64
Amount int64
}
var consumes []consumeResult
h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserInvites.InviteeID, h.readDB.UserInvites.AccumulatedAmount.As("amount")).
Where(h.readDB.UserInvites.InviterID.Eq(userID)).
Where(h.readDB.UserInvites.InviteeID.In(inviteeIDs...)).
Scan(&consumes)
for _, c := range consumes {
inviteeConsumes[c.InviteeID] = c.Amount
}
}
// 批量查询被邀请人的累计获得商品价值
// 真实盈亏 = 商品价值(持有+发货,排除兑换)- 累计消费
// 注:已兑换(status=2)的商品不计入,因为兑换=转成积分,积分是用户的现金等价物
inviteeAssets := make(map[int64]int64)
if len(inviteeIDs) > 0 {
type assetResult struct {
UserID int64
Value int64
}
// 商品价值:排除已兑换(status=2)
var invRes []assetResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.In(inviteeIDs...)).
Where(h.readDB.UserInventory.Status.Neq(2)). // 排除已兑换
Group(h.readDB.UserInventory.UserID).
Scan(&invRes)
for _, r := range invRes {
inviteeAssets[r.UserID] = r.Value
}
}
// 查询所有下线的汇总数据(不分页)
var summaryConsume, summaryAsset int64
// 累计消费汇总
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(accumulated_amount), 0)
FROM user_invites
WHERE inviter_id = ?
`, userID).Scan(&summaryConsume).Error
// 资产价值汇总(不包含已兑换的商品)
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(p.price), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
AND ui.status != 2
`, userID).Scan(&summaryAsset).Error
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = make([]adminUserItem, len(rows))
for i, v := range rows {
consume := inviteeConsumes[v.ID]
asset := inviteeAssets[v.ID]
rsp.List[i] = adminUserItem{
ID: v.ID,
Nickname: v.Nickname,
Avatar: v.Avatar,
InviteCode: v.InviteCode,
InviterID: v.InviterID,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
TotalConsume: consume, // 被邀请人的累计消费
TotalAssetValue: asset, // 被邀请人的资产价值
}
}
// 填充汇总数据
rsp.Summary.TotalConsume = summaryConsume
rsp.Summary.TotalAsset = summaryAsset
rsp.Summary.TotalProfit = summaryAsset - summaryConsume
ctx.Payload(rsp)
}
}
type listOrdersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listOrdersResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []*user.OrderWithItems `json:"list"`
}
type listInventoryRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"` // 搜索关键词(商品名称)
Status int32 `form:"status"` // 状态筛选0=全部, 1=持有, 2=作废, 3=已使用
}
type listInventoryResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []*user.InventoryWithProduct `json:"list"`
}
// ListUserOrders 查看用户订单列表
// @Summary 查看用户订单列表
// @Description 查看指定用户的订单记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listOrdersResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/orders [get]
// @Security LoginVerifyToken
func (h *handler) ListUserOrders() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listOrdersRequest)
rsp := new(listOrdersResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
items, total, err := h.userSvc.ListOrdersWithItems(ctx.RequestContext(), userID, 0, nil, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20104, err.Error()))
return
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
ctx.Payload(rsp)
}
}
// 查看用户资产列表
// @Summary 查看用户资产列表
// @Description 查看指定用户的资产记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Param keyword query string false "搜索关键词"
// @Param status query int false "状态筛选: 0=全部, 1=持有, 2=作废, 3=已使用"
// @Success 200 {object} listInventoryResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/inventory [get]
// @Security LoginVerifyToken
func (h *handler) ListUserInventory() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listInventoryRequest)
rsp := new(listInventoryResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
// 处理分页参数
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
// 如果有搜索关键词,使用带搜索的查询
if req.Keyword != "" {
// 联表查询以支持按商品名称搜索
ui := h.readDB.UserInventory
p := h.readDB.Products
// Check if keyword is numeric
numKeyword, errNum := strconv.ParseInt(req.Keyword, 10, 64)
// Count query logic
countQ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(p, p.ID.EqCol(ui.ProductID)).
Where(ui.UserID.Eq(userID))
// 应用状态筛选
if req.Status > 0 {
countQ = countQ.Where(ui.Status.Eq(req.Status))
} else {
// 默认只过滤掉已软删除的记录如果有的话status=2是作废通常后台要能看到作废的所以这里如果不传status默认查所有非删除的
// 既然是管理端如果不传status应该显示所有状态的记录
}
if errNum == nil {
// Keyword is numeric, search by name OR ID OR OrderID
countQ = countQ.Where(
ui.Where(p.Name.Like("%" + req.Keyword + "%")).
Or(ui.ID.Eq(numKeyword)).
Or(ui.OrderID.Eq(numKeyword)),
)
} else {
// Keyword is not numeric, search by name only
countQ = countQ.Where(p.Name.Like("%" + req.Keyword + "%"))
}
total, err := countQ.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
// 查询资产数据
type inventoryRow struct {
ID int64
UserID int64
ProductID int64
OrderID int64
ActivityID int64
RewardID int64
Status int32
Remark string
CreatedAt string
UpdatedAt string
ProductName string
ProductImages string
ProductPrice int64
}
var rows []inventoryRow
sql := `
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
ui.status, ui.remark, ui.created_at, ui.updated_at,
p.name as product_name, p.images_json as product_images, p.price as product_price
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ?
`
var args []interface{}
args = append(args, userID)
if req.Status > 0 {
sql += " AND ui.status = ?"
args = append(args, req.Status)
}
if errNum == nil {
sql += " AND (p.name LIKE ? OR ui.id = ? OR ui.order_id = ?)"
args = append(args, "%"+req.Keyword+"%", numKeyword, numKeyword)
} else {
sql += " AND p.name LIKE ?"
args = append(args, "%"+req.Keyword+"%")
}
sql += " ORDER BY ui.id DESC LIMIT ? OFFSET ?"
args = append(args, req.PageSize, (req.Page-1)*req.PageSize)
err = h.repo.GetDbR().Raw(sql, args...).Scan(&rows).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
// 转换结果
items := make([]*user.InventoryWithProduct, len(rows))
for i, r := range rows {
items[i] = &user.InventoryWithProduct{
UserInventory: &model.UserInventory{
ID: r.ID,
UserID: r.UserID,
ProductID: r.ProductID,
OrderID: r.OrderID,
ActivityID: r.ActivityID,
RewardID: r.RewardID,
Status: r.Status,
Remark: r.Remark,
},
ProductName: r.ProductName,
ProductImages: r.ProductImages,
ProductPrice: r.ProductPrice,
}
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
ctx.Payload(rsp)
return
}
// 无搜索关键词时使用原有逻辑
rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize, req.Status)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = rows
ctx.Payload(rsp)
}
}
type listUserItemCardsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listUserItemCardsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []*user.ItemCardWithTemplate `json:"list"`
}
// ListUserItemCards 查看用户道具卡列表
// @Summary 查看用户道具卡列表
// @Description 查看指定用户的道具卡持有记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listUserItemCardsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/item_cards [get]
// @Security LoginVerifyToken
func (h *handler) ListUserItemCards() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listUserItemCardsRequest)
rsp := new(listUserItemCardsResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
items, total, err := h.userSvc.ListUserItemCardsWithTemplate(ctx.RequestContext(), userID, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20106, err.Error()))
return
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
ctx.Payload(rsp)
}
}
type listCouponsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type adminUserCouponItem struct {
ID int64 `json:"id"`
CouponID int64 `json:"coupon_id"`
Status int32 `json:"status"`
UsedOrderID int64 `json:"used_order_id"`
UsedAt string `json:"used_at"`
ValidStart string `json:"valid_start"`
ValidEnd string `json:"valid_end"`
Name string `json:"name"`
ScopeType int32 `json:"scope_type"`
DiscountType int32 `json:"discount_type"`
DiscountValue int64 `json:"discount_value"`
MinSpend int64 `json:"min_spend"`
BalanceAmount int64 `json:"balance_amount"`
}
type listCouponsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserCouponItem `json:"list"`
}
// ListUserCoupons 查看用户优惠券列表
// @Summary 查看用户优惠券列表
// @Description 查看指定用户持有的优惠券列表
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listCouponsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/coupons [get]
// @Security LoginVerifyToken
func (h *handler) ListUserCoupons() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listCouponsRequest)
rsp := new(listCouponsResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
// 统计总数
base := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserCoupons.UserID.Eq(userID))
total, err := base.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
// 联表查询 system_coupons 获取优惠券模板信息
type row struct {
ID int64
CouponID int64
Status int32
UsedOrderID int64
UsedAt *string
ValidStart *string
ValidEnd *string
Name string
ScopeType int32
DiscountType int32
DiscountValue int64
MinSpend int64
BalanceAmount int64
}
q := base.
Select(
h.readDB.UserCoupons.ID, h.readDB.UserCoupons.CouponID, h.readDB.UserCoupons.Status,
h.readDB.UserCoupons.UsedOrderID, h.readDB.UserCoupons.UsedAt,
h.readDB.UserCoupons.ValidStart, h.readDB.UserCoupons.ValidEnd,
h.readDB.SystemCoupons.Name, h.readDB.SystemCoupons.ScopeType,
h.readDB.SystemCoupons.DiscountType, h.readDB.SystemCoupons.DiscountValue,
h.readDB.SystemCoupons.MinSpend,
).
LeftJoin(h.readDB.SystemCoupons, h.readDB.SystemCoupons.ID.EqCol(h.readDB.UserCoupons.CouponID)).
Order(h.readDB.UserCoupons.ID.Desc()).
Limit(req.PageSize).Offset((req.Page - 1) * req.PageSize)
var rows []row
if err := q.Scan(&rows); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = make([]adminUserCouponItem, len(rows))
for i, v := range rows {
rsp.List[i] = adminUserCouponItem{
ID: v.ID,
CouponID: v.CouponID,
Status: v.Status,
UsedOrderID: v.UsedOrderID,
UsedAt: nullableToString(v.UsedAt),
ValidStart: nullableToString(v.ValidStart),
ValidEnd: nullableToString(v.ValidEnd),
Name: v.Name,
ScopeType: v.ScopeType,
DiscountType: v.DiscountType,
DiscountValue: v.DiscountValue,
MinSpend: v.MinSpend,
BalanceAmount: v.BalanceAmount,
}
}
ctx.Payload(rsp)
}
}
type AuditLogItem struct {
CreatedAt string `json:"created_at"` // 时间
Category string `json:"category"` // 大类: points/order/shipping/draw
SubType string `json:"sub_type"` // 子类: action/status
AmountStr string `json:"amount_str"` // 金额/数值变化 (带符号字符串)
RefInfo string `json:"ref_info"` // 关联信息 (RefID/OrderNo/ExpressNo)
DetailInfo string `json:"detail_info"` // 详细描述 (Remark/PrizeName)
}
type listAuditLogsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"` // 由于UNION ALL分页较难精确Count Total这里可能返回估算值或分步Count为简化MVP先只做翻页不用Total或者Total设为0
List []AuditLogItem `json:"list"`
}
// ListUserAuditLogs 查看用户行为审计日志
// @Summary 查看用户行为审计日志
// @Description 聚合查看用户的积分、订单、发货、抽奖等行为记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量" default(20)
// @Success 200 {object} listAuditLogsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/audit [get]
// @Security LoginVerifyToken
func (h *handler) ListUserAuditLogs() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listInvitesRequest) // 复用分页参数结构
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
offset := (req.Page - 1) * req.PageSize
limit := req.PageSize
var logs []AuditLogItem
// 构建 UNION ALL 查询
// 1. 积分流水
// 2. 订单记录
// 3. 发货记录
// 4. 抽奖记录 (只看中奖的? 或者全部? 这里先只看中奖 IsWinner=1 避免数据量太大)
sql := `
SELECT * FROM (
-- 1. Points Ledger
SELECT
created_at,
'points' as category,
CONVERT(action USING utf8mb4) as sub_type,
CAST(points AS CHAR) as amount_str,
CONCAT(CONVERT(ref_table USING utf8mb4), ':', CONVERT(ref_id USING utf8mb4)) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM user_points_ledger
WHERE user_id = ?
UNION ALL
-- 2. Orders
SELECT
created_at,
'order' as category,
'paid' as sub_type,
CAST(actual_amount AS CHAR) as amount_str,
CONVERT(order_no USING utf8mb4) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM orders
WHERE user_id = ? AND status = 2
UNION ALL
-- 3. Shipping Records
SELECT
created_at,
'shipping' as category,
CAST(status AS CHAR) as sub_type,
CAST(quantity AS CHAR) as amount_str,
CONCAT(IFNULL(CONVERT(express_code USING utf8mb4),''), ':', IFNULL(CONVERT(express_no USING utf8mb4),'')) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM shipping_records
WHERE user_id = ?
UNION ALL
-- 4. Draw Logs (Winners)
SELECT
l.created_at,
'draw' as category,
IF(l.is_winner=1, 'win', 'lose') as sub_type,
CAST(1 AS CHAR) as amount_str,
CAST(l.order_id AS CHAR) as ref_info,
CONCAT(
'游戏: ', IFNULL(CONVERT(act.name USING utf8mb4), '未知'),
' | 奖品: ', IFNULL(CONVERT(prod.name USING utf8mb4), '未知'),
' | 级别: ', CASE l.level WHEN 1 THEN 'S' WHEN 2 THEN 'A' WHEN 3 THEN 'B' WHEN 4 THEN 'C' ELSE CAST(l.level AS CHAR) END
) as detail_info
FROM activity_draw_logs l
LEFT JOIN activity_issues issue ON l.issue_id = issue.id
LEFT JOIN activities act ON issue.activity_id = act.id
LEFT JOIN activity_reward_settings reward ON l.reward_id = reward.id
LEFT JOIN products prod ON reward.product_id = prod.id
WHERE l.user_id = ? AND l.is_winner = 1
) as combined_logs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`
if err := h.repo.GetDbR().Raw(sql, userID, userID, userID, userID, limit, offset).Scan(&logs).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error()))
return
}
// 格式化处理 (Optional)
for i := range logs {
// 将时间标准化
if t, err := time.Parse(time.RFC3339, logs[i].CreatedAt); err == nil {
logs[i].CreatedAt = t.Format("2006-01-02 15:04:05")
}
// 处理金额显示 (分 -> 元)
if logs[i].Category == "points" || logs[i].Category == "order" {
if amount, err := strconv.ParseFloat(logs[i].AmountStr, 64); err == nil {
val := amount / 100.0
if logs[i].Category == "order" {
val = -math.Abs(val) // 订单固定为支出
}
// 积分正数加+号
if logs[i].Category == "points" && val > 0 {
logs[i].AmountStr = fmt.Sprintf("+%.2f", val)
} else {
logs[i].AmountStr = fmt.Sprintf("%.2f", val)
}
}
}
// [Fix] 针对直播间订单(金额为0),尝试从备注中提取关联的抖店订单号并查询实际支付金额
// 备注格式示例: "直播间抽奖: xxx (关联抖店订单: 69505...)"
// 收集需要查询的 ShopOrderID
shopOrderIDs := make([]string, 0)
logIndicesMap := make(map[string][]int) // shopOrderID -> []logIndex
for i := range logs {
// 仅处理 order 类型且金额为 0 的记录 (通常直播间订单 actual_amount=0)
// 或者 category=order 且 sub_type=paid
if logs[i].Category == "order" && logs[i].SubType == "paid" {
// 简单判断金额是否为 0.00 (前面已经格式化过)
if logs[i].AmountStr == "-0.00" || logs[i].AmountStr == "0.00" {
// 尝试提取关联抖店订单
if strings.Contains(logs[i].DetailInfo, "关联抖店订单:") {
// 提取 ID: last part after "关联抖店订单:" and trim ")"
parts := strings.Split(logs[i].DetailInfo, "关联抖店订单:")
if len(parts) > 1 {
orderIDPart := strings.TrimSpace(parts[1])
orderIDPart = strings.TrimRight(orderIDPart, ")")
if orderIDPart != "" {
shopOrderIDs = append(shopOrderIDs, orderIDPart)
logIndicesMap[orderIDPart] = append(logIndicesMap[orderIDPart], i)
}
}
}
}
}
}
// 批量查询 douyin_orders
if len(shopOrderIDs) > 0 {
var dyOrders []struct {
ShopOrderID string `gorm:"column:shop_order_id"`
ActualPayAmount int64 `gorm:"column:actual_pay_amount"`
}
// 只需要查 shop_order_id 和 actual_pay_amount
// 使用 raw sql 或者 model find
// 这里为了简单直接用 h.readDB.DouyinOrders (Gen生成的) 或者 Raw SQL
// 假设 h.readDB.DouyinOrders 可用,或者直接用 h.repo.GetDbR()
if err := h.repo.GetDbR().Table("douyin_orders").Select("shop_order_id, actual_pay_amount").
Where("shop_order_id IN ?", shopOrderIDs).Scan(&dyOrders).Error; err == nil {
// 更新日志金额
for _, dy := range dyOrders {
if indices, ok := logIndicesMap[dy.ShopOrderID]; ok {
for _, idx := range indices {
// 转换为浮点数 (元)
val := float64(dy.ActualPayAmount) / 100.0
// 订单固定为支出 (负数)
val = -math.Abs(val)
logs[idx].AmountStr = fmt.Sprintf("%.2f", val)
}
}
}
}
}
// 翻译 Shipping Status 等 (可选项,也可以前端做)
// 翻译 Shipping Status 等 (可选项,也可以前端做)
if logs[i].Category == "shipping" {
switch logs[i].SubType {
case "1":
logs[i].SubType = "待发货"
case "2":
logs[i].SubType = "已发货"
case "3":
logs[i].SubType = "已签收"
case "4":
logs[i].SubType = "异常"
}
}
// 翻译积分变动
if logs[i].Category == "points" {
switch logs[i].SubType {
case "redeem_coupon":
logs[i].SubType = "兑换优惠券"
case "redeem_product":
logs[i].SubType = "兑换商品"
case "redeem_item_card":
logs[i].SubType = "兑换道具卡"
case "minesweeper_settle":
logs[i].SubType = "扫雷游戏奖励"
case "game_reward":
logs[i].SubType = "游戏奖励"
case "redeem_reward":
logs[i].SubType = "奖品分解"
case "pay_reward":
logs[i].SubType = "支付奖励"
case "manual", "manual_add":
logs[i].SubType = "管理员调整"
case "task_reward":
logs[i].SubType = "任务中心奖励"
case "order_reward":
logs[i].SubType = "订单奖励"
case "douyin_product_reward":
logs[i].SubType = "抖店商品奖励"
case "signin":
logs[i].SubType = "签到奖励"
case "draw_cost":
logs[i].SubType = "抽奖消耗"
}
// 翻译 RefInfo
if strings.HasPrefix(logs[i].RefInfo, "system_coupons:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "system_coupons:", "系统优惠券ID: ", 1)
}
if strings.HasPrefix(logs[i].RefInfo, "user_inventory:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "user_inventory:", "用户背包ID: ", 1)
}
if strings.HasPrefix(logs[i].RefInfo, "orders:") {
logs[i].RefInfo = strings.Replace(logs[i].RefInfo, "orders:", "关联订单号: ", 1)
}
}
// 翻译订单状态
if logs[i].Category == "order" && logs[i].SubType == "paid" {
logs[i].SubType = "支付成功"
}
if logs[i].Category == "draw" && logs[i].SubType == "win" {
logs[i].SubType = "中奖"
}
}
ctx.Payload(&listAuditLogsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: 0, // 为了性能暂时忽略
List: logs,
})
}
}
func nullableToString(s *string) string {
if s == nil {
return ""
}
return *s
}
type listPointsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type adminUserPointsLedgerItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
Points float64 `json:"points"` // 改为 float64 支持小数积分
RefTable string `json:"ref_table"`
RefID string `json:"ref_id"`
Remark string `json:"remark"`
CreatedAt string `json:"created_at"`
}
type listPointsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserPointsLedgerItem `json:"list"`
}
// ListUserPoints 查看用户积分记录
// @Summary 查看用户积分记录
// @Description 查看指定用户的积分流水记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listPointsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/points [get]
// @Security LoginVerifyToken
func (h *handler) ListUserPoints() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listPointsRequest)
rsp := new(listPointsResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
items, total, err := h.userSvc.ListPointsLedger(ctx.RequestContext(), userID, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20106, err.Error()))
return
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
// Convert ledger items
rsp.List = make([]adminUserPointsLedgerItem, len(items))
for i, v := range items {
rsp.List[i] = adminUserPointsLedgerItem{
ID: v.ID,
UserID: v.UserID,
Action: v.Action,
Points: h.userSvc.CentsToPointsFloat(ctx.RequestContext(), v.Points),
RefTable: v.RefTable,
RefID: v.RefID,
Remark: v.Remark,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(rsp)
}
}
type pointsBalanceResponse struct {
Balance int64 `json:"balance"`
}
type adminUserItem struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID
Mobile string `json:"mobile"` // 手机号
Remark string `json:"remark"` // 备注
ChannelName string `json:"channel_name"`
ChannelCode string `json:"channel_code"`
PointsBalance int64 `json:"points_balance"`
CouponsCount int64 `json:"coupons_count"`
ItemCardsCount int64 `json:"item_cards_count"`
TodayConsume int64 `json:"today_consume"`
SevenDayConsume int64 `json:"seven_day_consume"`
ThirtyDayConsume int64 `json:"thirty_day_consume"` // 近30天消费
TotalConsume int64 `json:"total_consume"` // 累计消费
InviteCount int64 `json:"invite_count"` // 邀请人数
InviteeTotalConsume int64 `json:"invitee_total_consume"` // 下线(被邀请人)累计消费
GamePassCount int64 `json:"game_pass_count"` // 次数卡数量
GameTicketCount int64 `json:"game_ticket_count"` // 游戏资格数量
InventoryValue int64 `json:"inventory_value"` // 持有商品总价值
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
Status int32 `json:"status"` // 用户状态1正常 2禁用 3黑名单
}
// ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额
// @Summary 查看用户积分余额
// @Description 查看指定用户当前积分余额(过滤过期)
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Success 200 {object} pointsBalanceResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/points/balance [get]
// @Security LoginVerifyToken
func (h *handler) GetUserPointsBalance() core.HandlerFunc {
return func(ctx core.Context) {
rsp := new(pointsBalanceResponse)
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
total, err := h.userSvc.GetPointsBalance(ctx.RequestContext(), userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error()))
return
}
rsp.Balance = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), total))
ctx.Payload(rsp)
}
}
type addPointsRequest struct {
Points float64 `json:"points"` // 正数=增加,负数=扣减
Kind string `json:"kind"`
Remark string `json:"remark"`
ValidDays *int `json:"valid_days"`
}
type addPointsResponse struct {
Success bool `json:"success"`
}
// AddUserPoints 给用户增加或扣减积分
// @Summary 给用户增加或扣减积分
// @Description 管理端为指定用户发放或扣减积分,正数为增加,负数为扣减
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param RequestBody body addPointsRequest true "请求参数"
// @Success 200 {object} addPointsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/points/add [post]
// @Security LoginVerifyToken
func (h *handler) AddUserPoints() core.HandlerFunc {
return func(ctx core.Context) {
req := new(addPointsRequest)
rsp := new(addPointsResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Points == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "积分变动值不能为0"))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
// 将浮点数积分转换为分Cents
// 1 积分 = 1 元 = 100 分
// 使用 math.Round 避免精度问题
pointsCents := int64(math.Round(req.Points * 100))
// 如果是扣减积分,先检查余额
if pointsCents < 0 {
balance, _ := h.userSvc.GetPointsBalance(ctx.RequestContext(), userID)
deductCents := -pointsCents
if balance < deductCents {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, "积分余额不足,无法扣减"))
return
}
}
var validStart *time.Time
var validEnd *time.Time
now := time.Now()
// 只有增加积分时才设置有效期
if pointsCents > 0 {
validStart = &now
if req.ValidDays != nil && *req.ValidDays > 0 {
ve := now.Add(time.Duration(*req.ValidDays) * 24 * time.Hour)
validEnd = &ve
}
}
if err := h.userSvc.AddPoints(ctx.RequestContext(), userID, pointsCents, req.Kind, req.Remark, validStart, validEnd); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
rsp.Success = true
ctx.Payload(rsp)
}
}
type addCouponRequest struct {
CouponID int64 `json:"coupon_id" binding:"required"`
}
type addCouponResponse struct {
Success bool `json:"success"`
}
// AddUserCoupon 给用户添加优惠券
// @Summary 给用户添加优惠券
// @Description 管理端为指定用户发放优惠券
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param RequestBody body addCouponRequest true "请求参数"
// @Success 200 {object} addCouponResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/coupons/add [post]
// @Security LoginVerifyToken
func (h *handler) AddUserCoupon() core.HandlerFunc {
return func(ctx core.Context) {
req := new(addCouponRequest)
rsp := new(addCouponResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
if err := h.userSvc.AddCoupon(ctx.RequestContext(), userID, req.CouponID); err != nil {
msg := err.Error()
if msg == "unsupported data" {
msg = "发券失败:模板配额已满"
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, msg))
return
}
rsp.Success = true
ctx.Payload(rsp)
}
}
type voidUserCouponRequest struct {
}
func (h *handler) VoidUserCoupon() core.HandlerFunc {
return func(ctx core.Context) {
req := new(voidUserCouponRequest)
if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
ucid, err := strconv.ParseInt(ctx.Param("user_coupon_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递持券ID"))
return
}
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
adminID := int64(ctx.SessionUserInfo().Id)
if err := h.userSvc.VoidUserCoupon(ctx.RequestContext(), adminID, userID, ucid); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type voidUserItemCardRequest struct {
}
func (h *handler) VoidUserItemCard() core.HandlerFunc {
return func(ctx core.Context) {
req := new(voidUserItemCardRequest)
if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
icid, err := strconv.ParseInt(ctx.Param("user_item_card_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递道具卡持有ID"))
return
}
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
adminID := int64(ctx.SessionUserInfo().Id)
if err := h.userSvc.VoidUserItemCard(ctx.RequestContext(), adminID, userID, icid); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type voidUserInventoryRequest struct {
}
func (h *handler) VoidUserInventory() core.HandlerFunc {
return func(ctx core.Context) {
req := new(voidUserInventoryRequest)
if err := ctx.ShouldBindJSON(req); err != nil && err.Error() != "EOF" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
invID, err := strconv.ParseInt(ctx.Param("inventory_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递资产ID"))
return
}
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
adminID := int64(ctx.SessionUserInfo().Id)
if err := h.userSvc.VoidUserInventory(ctx.RequestContext(), adminID, userID, invID); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type adminUserTitleItem struct {
ID int64 `json:"id"`
TitleID int64 `json:"title_id"`
Name string `json:"name"`
Description string `json:"description"`
ObtainedAt string `json:"obtained_at"`
ExpiresAt string `json:"expires_at"`
Status int32 `json:"status"`
}
type listUserTitlesResponse struct {
List []adminUserTitleItem `json:"list"`
}
func (h *handler) ListUserTitles() core.HandlerFunc {
return func(ctx core.Context) {
rsp := new(listUserTitlesResponse)
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
type row struct {
ID int64
TitleID int64
Active int32
ObtainedAt *string
ExpiresAt *string
Name string
Description string
}
q := h.readDB.UserTitles.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.SystemTitles, h.readDB.SystemTitles.ID.EqCol(h.readDB.UserTitles.TitleID)).
Select(
h.readDB.UserTitles.ID, h.readDB.UserTitles.TitleID, h.readDB.UserTitles.Active,
h.readDB.UserTitles.ObtainedAt, h.readDB.UserTitles.ExpiresAt,
h.readDB.SystemTitles.Name, h.readDB.SystemTitles.Description,
).
Where(h.readDB.UserTitles.UserID.Eq(userID)).
Order(h.readDB.UserTitles.ID.Desc())
var rows []row
if err := q.Scan(&rows); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20110, err.Error()))
return
}
rsp.List = make([]adminUserTitleItem, len(rows))
for i, v := range rows {
rsp.List[i] = adminUserTitleItem{
ID: v.ID,
TitleID: v.TitleID,
Name: v.Name,
Description: v.Description,
ObtainedAt: nullableToString(v.ObtainedAt),
ExpiresAt: nullableToString(v.ExpiresAt),
Status: v.Active,
}
}
ctx.Payload(rsp)
}
}
type listUserCouponUsageRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type adminUserCouponUsageItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserCouponID int64 `json:"user_coupon_id"`
ChangeAmount int64 `json:"change_amount"`
BalanceAfter int64 `json:"balance_after"`
OrderID int64 `json:"order_id"`
Action string `json:"action"`
CreatedAt string `json:"created_at"`
}
type listUserCouponUsageResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserCouponUsageItem `json:"list"`
}
func (h *handler) ListUserCouponUsage() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listUserCouponUsageRequest)
rsp := new(listUserCouponUsageResponse)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
ucid, err := strconv.ParseInt(ctx.Param("user_coupon_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递持券ID"))
return
}
var total int64
db := h.repo.GetDbR().Model(&model.UserCouponLedger{}).Where("user_id = ? AND user_coupon_id = ?", userID, ucid)
_ = db.Count(&total).Error
var list []model.UserCouponLedger
_ = db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error
rows := make([]adminUserCouponUsageItem, len(list))
for i, v := range list {
rows[i] = adminUserCouponUsageItem{
ID: v.ID,
UserID: v.UserID,
UserCouponID: v.UserCouponID,
ChangeAmount: v.ChangeAmount,
BalanceAfter: v.BalanceAfter,
OrderID: v.OrderID,
Action: v.Action,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
if rows == nil {
rows = []adminUserCouponUsageItem{}
}
rsp.List = rows
ctx.Payload(rsp)
}
}
// LinkUserDouyinRequest 关联用户抖音账号请求
type LinkUserDouyinRequest struct {
DouyinUserID string `json:"douyin_user_id" binding:"required"`
}
// UpdateUserDouyinID 更新用户的抖音账号ID
// @Summary 更新用户抖音ID
// @Description 管理员绑定或修改用户的抖音账号ID
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body LinkUserDouyinRequest true "抖音用户ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/douyin_user_id [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserDouyinID() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
req := new(LinkUserDouyinRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 更新用户抖音ID
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.DouyinUserID, req.DouyinUserID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20301, "更新失败: "+err.Error()))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": "抖音ID更新成功",
})
}
}
// updateUserRemarkRequest 更新用户备注请求
type updateUserRemarkRequest struct {
Remark string `json:"remark"`
}
// UpdateUserRemark 更新用户备注
// @Summary 更新用户备注
// @Description 管理员修改用户备注
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body updateUserRemarkRequest true "备注信息"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/remark [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserRemark() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
req := new(updateUserRemarkRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 更新用户备注
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.Remark, req.Remark)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20302, "更新失败: "+err.Error()))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": "备注更新成功",
})
}
}
type updateUserStatusRequest struct {
Status int32 `json:"status" form:"status"` // 1=正常 2=禁用 3=黑名单
}
// UpdateUserStatus 修改用户状态
// @Summary 修改用户状态
// @Description 管理员修改用户状态1正常 2禁用 3黑名单
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body updateUserStatusRequest true "状态信息"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/status [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserStatus() core.HandlerFunc {
return func(ctx core.Context) {
req := new(updateUserStatusRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
if req.Status != 1 && req.Status != 2 && req.Status != 3 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的状态值"))
return
}
// 使用 Updates 以支持更新为 0 (虽然这里status不为0) 但 gorm Update 单列更安全
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.Status, req.Status)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(map[string]any{"success": true})
}
}
// updateUserMobileRequest 更新用户手机号请求
type updateUserMobileRequest struct {
Mobile string `json:"mobile" binding:"required"`
}
// UpdateUserMobile 更新用户手机号
// @Summary 更新用户手机号
// @Description 管理员修改用户手机号
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body updateUserMobileRequest true "手机号信息"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/mobile [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserMobile() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
req := new(updateUserMobileRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 验证手机号格式中国大陆手机号11位数字1开头
if len(req.Mobile) != 11 || req.Mobile[0] != '1' {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "手机号格式不正确"))
return
}
// 检查手机号是否已被其他用户使用
existingUser, err := h.readDB.Users.WithContext(ctx.RequestContext()).
Where(h.readDB.Users.Mobile.Eq(req.Mobile)).
Where(h.readDB.Users.ID.Neq(userID)).
First()
if err == nil && existingUser != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该手机号已被其他用户使用"))
return
}
// 更新用户手机号
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.Mobile, req.Mobile)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "更新失败: "+err.Error()))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": "手机号更新成功",
})
}
}
type listAuditLogsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type auditLogItem struct {
CreatedAt string `json:"created_at"`
Category string `json:"category"` // points, order, shipping, draw
SubType string `json:"sub_type"` // 具体类型
AmountStr string `json:"amount_str"` // 变动金额/数量
RefInfo string `json:"ref_info"` // 关联信息
DetailInfo string `json:"detail_info"` // 详细信息
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 管理员删除用户及其所有关联数据(订单、积分、优惠券、道具卡、背包等)
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id} [delete]
// @Security LoginVerifyToken
func (h *handler) DeleteUser() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
// 检查用户是否存在
user, err := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "用户不存在"))
return
}
// 调用 service 层的删除方法
if err := h.userSvc.DeleteUser(ctx.RequestContext(), userID); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.DeleteUserError, fmt.Sprintf("删除用户失败: %s", err.Error())))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": fmt.Sprintf("用户 %s (ID:%d) 已成功删除", user.Nickname, userID),
})
}
}