feat: expand superadmin user detail views

This commit is contained in:
2026-01-15 12:29:52 +08:00
parent 8419ddede7
commit bec984b959
10 changed files with 2874 additions and 12 deletions

View File

@@ -0,0 +1,118 @@
package dto
import (
"quyun/v2/app/requests"
"quyun/v2/pkg/consts"
)
// SuperUserNotificationListFilter 超管用户通知列表过滤条件。
type SuperUserNotificationListFilter struct {
requests.Pagination
// TenantID 租户ID过滤为空表示全部
TenantID *int64 `query:"tenant_id"`
// Type 通知类型过滤system/order/interaction
Type *string `query:"type"`
// Read 是否已读过滤。
Read *bool `query:"read"`
// CreatedAtFrom 创建时间起始RFC3339
CreatedAtFrom *string `query:"created_at_from"`
// CreatedAtTo 创建时间结束RFC3339
CreatedAtTo *string `query:"created_at_to"`
}
// SuperUserNotificationItem 超管用户通知列表项。
type SuperUserNotificationItem struct {
// ID 通知ID。
ID int64 `json:"id"`
// TenantID 通知所属租户ID。
TenantID int64 `json:"tenant_id"`
// TenantCode 通知所属租户编码。
TenantCode string `json:"tenant_code"`
// TenantName 通知所属租户名称。
TenantName string `json:"tenant_name"`
// Type 通知类型。
Type string `json:"type"`
// Title 通知标题。
Title string `json:"title"`
// Content 通知内容。
Content string `json:"content"`
// Read 是否已读。
Read bool `json:"read"`
// CreatedAt 发送时间RFC3339
CreatedAt string `json:"created_at"`
}
// SuperUserCouponListFilter 超管用户优惠券列表过滤条件。
type SuperUserCouponListFilter struct {
requests.Pagination
// TenantID 租户ID过滤为空表示全部
TenantID *int64 `query:"tenant_id"`
// TenantCode 租户编码(模糊匹配)。
TenantCode *string `query:"tenant_code"`
// TenantName 租户名称(模糊匹配)。
TenantName *string `query:"tenant_name"`
// Status 用户券状态过滤unused/used/expired
Status *consts.UserCouponStatus `query:"status"`
// Type 券模板类型过滤fix_amount/discount
Type *consts.CouponType `query:"type"`
// Keyword 标题或描述关键词(模糊匹配)。
Keyword *string `query:"keyword"`
// CreatedAtFrom 领取时间起始RFC3339
CreatedAtFrom *string `query:"created_at_from"`
// CreatedAtTo 领取时间结束RFC3339
CreatedAtTo *string `query:"created_at_to"`
}
// SuperUserCouponItem 超管用户优惠券列表项。
type SuperUserCouponItem struct {
// ID 用户券ID。
ID int64 `json:"id"`
// CouponID 券模板ID。
CouponID int64 `json:"coupon_id"`
// TenantID 券所属租户ID。
TenantID int64 `json:"tenant_id"`
// TenantCode 券所属租户编码。
TenantCode string `json:"tenant_code"`
// TenantName 券所属租户名称。
TenantName string `json:"tenant_name"`
// Title 券标题。
Title string `json:"title"`
// Description 券描述。
Description string `json:"description"`
// Type 券类型。
Type consts.CouponType `json:"type"`
// TypeDescription 券类型描述(用于展示)。
TypeDescription string `json:"type_description"`
// Value 券面值/折扣值。
Value int64 `json:"value"`
// MinOrderAmount 使用门槛金额(分)。
MinOrderAmount int64 `json:"min_order_amount"`
// MaxDiscount 折扣券最高抵扣金额(分)。
MaxDiscount int64 `json:"max_discount"`
// StartAt 生效时间RFC3339
StartAt string `json:"start_at"`
// EndAt 过期时间RFC3339
EndAt string `json:"end_at"`
// Status 用户券状态。
Status consts.UserCouponStatus `json:"status"`
// StatusDescription 用户券状态描述(用于展示)。
StatusDescription string `json:"status_description"`
// OrderID 使用订单ID未使用为0
OrderID int64 `json:"order_id"`
// UsedAt 使用时间RFC3339
UsedAt string `json:"used_at"`
// CreatedAt 领取时间RFC3339
CreatedAt string `json:"created_at"`
}
// SuperUserRealNameResponse 超管实名认证详情。
type SuperUserRealNameResponse struct {
// IsRealNameVerified 是否已实名认证。
IsRealNameVerified bool `json:"is_real_name_verified"`
// VerifiedAt 实名认证时间RFC3339
VerifiedAt string `json:"verified_at"`
// RealName 真实姓名(来自用户元数据)。
RealName string `json:"real_name"`
// IDCardMasked 身份证号脱敏展示。
IDCardMasked string `json:"id_card_masked"`
}

View File

@@ -226,6 +226,23 @@ func (r *Routes) Register(router fiber.Router) {
r.users.Get,
PathParam[int64]("id"),
))
r.log.Debugf("Registering route: Get /super/v1/users/:id<int>/coupons -> users.ListCoupons")
router.Get("/super/v1/users/:id<int>/coupons"[len(r.Path()):], DataFunc2(
r.users.ListCoupons,
PathParam[int64]("id"),
Query[dto.SuperUserCouponListFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/users/:id<int>/notifications -> users.ListNotifications")
router.Get("/super/v1/users/:id<int>/notifications"[len(r.Path()):], DataFunc2(
r.users.ListNotifications,
PathParam[int64]("id"),
Query[dto.SuperUserNotificationListFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/users/:id<int>/realname -> users.RealName")
router.Get("/super/v1/users/:id<int>/realname"[len(r.Path()):], DataFunc1(
r.users.RealName,
PathParam[int64]("id"),
))
r.log.Debugf("Registering route: Get /super/v1/users/:id<int>/tenants -> users.ListTenants")
router.Get("/super/v1/users/:id<int>/tenants"[len(r.Path()):], DataFunc2(
r.users.ListTenants,

View File

@@ -58,6 +58,57 @@ func (c *users) Wallet(ctx fiber.Ctx, id int64) (*dto.SuperWalletResponse, error
return services.Super.GetUserWallet(ctx, id)
}
// List user notifications
//
// @Router /super/v1/users/:id<int>/notifications [get]
// @Summary List user notifications
// @Description List notifications of a user
// @Tags User
// @Accept json
// @Produce json
// @Param id path int64 true "User ID"
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.SuperUserNotificationItem}
// @Bind id path
// @Bind filter query
func (c *users) ListNotifications(ctx fiber.Ctx, id int64, filter *dto.SuperUserNotificationListFilter) (*requests.Pager, error) {
return services.Super.ListUserNotifications(ctx, id, filter)
}
// List user coupons
//
// @Router /super/v1/users/:id<int>/coupons [get]
// @Summary List user coupons
// @Description List coupons of a user
// @Tags User
// @Accept json
// @Produce json
// @Param id path int64 true "User ID"
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.SuperUserCouponItem}
// @Bind id path
// @Bind filter query
func (c *users) ListCoupons(ctx fiber.Ctx, id int64, filter *dto.SuperUserCouponListFilter) (*requests.Pager, error) {
return services.Super.ListUserCoupons(ctx, id, filter)
}
// Get user real-name verification detail
//
// @Router /super/v1/users/:id<int>/realname [get]
// @Summary Get user real-name verification detail
// @Description Get real-name verification detail of a user
// @Tags User
// @Accept json
// @Produce json
// @Param id path int64 true "User ID"
// @Success 200 {object} dto.SuperUserRealNameResponse
// @Bind id path
func (c *users) RealName(ctx fiber.Ctx, id int64) (*dto.SuperUserRealNameResponse, error) {
return services.Super.GetUserRealName(ctx, id)
}
// List user tenants
//
// @Router /super/v1/users/:id<int>/tenants [get]

View File

@@ -2,6 +2,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
@@ -286,6 +287,7 @@ func (s *super) GetUser(ctx context.Context, id int64) (*super_dto.UserItem, err
Roles: u.Roles,
Status: u.Status,
StatusDescription: u.Status.Description(),
VerifiedAt: s.formatTime(u.VerifiedAt),
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
},
@@ -382,6 +384,301 @@ func (s *super) GetUserWallet(ctx context.Context, userID int64) (*super_dto.Sup
}, nil
}
func (s *super) GetUserRealName(ctx context.Context, userID int64) (*super_dto.SuperUserRealNameResponse, error) {
if userID == 0 {
return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空")
}
tbl, q := models.UserQuery.QueryContext(ctx)
u, err := q.Where(tbl.ID.Eq(userID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 从用户元数据中读取实名字段,便于超管展示。
meta := make(map[string]interface{})
if len(u.Metas) > 0 {
if err := json.Unmarshal(u.Metas, &meta); err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("解析实名认证信息失败")
}
}
realName := ""
if value, ok := meta["real_name"].(string); ok {
realName = value
}
idCard := ""
if value, ok := meta["id_card"].(string); ok {
idCard = value
}
return &super_dto.SuperUserRealNameResponse{
IsRealNameVerified: u.IsRealNameVerified,
VerifiedAt: s.formatTime(u.VerifiedAt),
RealName: realName,
IDCardMasked: s.maskIDCard(idCard),
}, nil
}
func (s *super) ListUserNotifications(ctx context.Context, userID int64, filter *super_dto.SuperUserNotificationListFilter) (*requests.Pager, error) {
if userID == 0 {
return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空")
}
if filter == nil {
filter = &super_dto.SuperUserNotificationListFilter{}
}
tbl, q := models.NotificationQuery.QueryContext(ctx)
q = q.Where(tbl.UserID.Eq(userID))
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" {
q = q.Where(tbl.Type.Eq(strings.TrimSpace(*filter.Type)))
}
if filter.Read != nil {
q = q.Where(tbl.IsRead.Is(*filter.Read))
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Order(tbl.CreatedAt.Desc()).
Offset(int(filter.Pagination.Offset())).
Limit(int(filter.Pagination.Limit)).
Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 补齐租户信息,便于跨租户展示。
tenantIDs := make([]int64, 0, len(list))
tenantSet := make(map[int64]struct{})
for _, n := range list {
if n.TenantID > 0 {
if _, ok := tenantSet[n.TenantID]; !ok {
tenantSet[n.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, n.TenantID)
}
}
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, tenant := range tenants {
tenantMap[tenant.ID] = tenant
}
}
items := make([]super_dto.SuperUserNotificationItem, 0, len(list))
for _, n := range list {
tenant := tenantMap[n.TenantID]
tenantCode := ""
tenantName := ""
if tenant != nil {
tenantCode = tenant.Code
tenantName = tenant.Name
}
items = append(items, super_dto.SuperUserNotificationItem{
ID: n.ID,
TenantID: n.TenantID,
TenantCode: tenantCode,
TenantName: tenantName,
Type: n.Type,
Title: n.Title,
Content: n.Content,
Read: n.IsRead,
CreatedAt: s.formatTime(n.CreatedAt),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListUserCoupons(ctx context.Context, userID int64, filter *super_dto.SuperUserCouponListFilter) (*requests.Pager, error) {
if userID == 0 {
return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空")
}
if filter == nil {
filter = &super_dto.SuperUserCouponListFilter{}
}
couponIDs, couponFilter, err := s.filterCouponIDs(ctx, filter)
if err != nil {
return nil, err
}
tbl, q := models.UserCouponQuery.QueryContext(ctx)
q = q.Where(tbl.UserID.Eq(userID))
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
if couponFilter {
if len(couponIDs) == 0 {
filter.Pagination.Format()
return &requests.Pager{
Pagination: filter.Pagination,
Total: 0,
Items: []super_dto.SuperUserCouponItem{},
}, nil
}
q = q.Where(tbl.CouponID.In(couponIDs...))
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Order(tbl.CreatedAt.Desc()).
Offset(int(filter.Pagination.Offset())).
Limit(int(filter.Pagination.Limit)).
Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if len(list) == 0 {
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: []super_dto.SuperUserCouponItem{},
}, nil
}
// 读取券模板与租户信息,便于聚合展示。
couponIDSet := make(map[int64]struct{})
couponIDs = make([]int64, 0, len(list))
for _, uc := range list {
if uc.CouponID > 0 {
if _, ok := couponIDSet[uc.CouponID]; !ok {
couponIDSet[uc.CouponID] = struct{}{}
couponIDs = append(couponIDs, uc.CouponID)
}
}
}
couponMap := make(map[int64]*models.Coupon, len(couponIDs))
tenantIDs := make([]int64, 0)
tenantSet := make(map[int64]struct{})
if len(couponIDs) > 0 {
couponTbl, couponQuery := models.CouponQuery.QueryContext(ctx)
coupons, err := couponQuery.Where(couponTbl.ID.In(couponIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, coupon := range coupons {
couponMap[coupon.ID] = coupon
if coupon.TenantID > 0 {
if _, ok := tenantSet[coupon.TenantID]; !ok {
tenantSet[coupon.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, coupon.TenantID)
}
}
}
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, tenant := range tenants {
tenantMap[tenant.ID] = tenant
}
}
items := make([]super_dto.SuperUserCouponItem, 0, len(list))
for _, uc := range list {
item := super_dto.SuperUserCouponItem{
ID: uc.ID,
CouponID: uc.CouponID,
Status: uc.Status,
StatusDescription: uc.Status.Description(),
OrderID: uc.OrderID,
UsedAt: s.formatTime(uc.UsedAt),
CreatedAt: s.formatTime(uc.CreatedAt),
}
coupon := couponMap[uc.CouponID]
if coupon != nil {
item.TenantID = coupon.TenantID
item.Title = coupon.Title
item.Description = coupon.Description
item.Type = coupon.Type
item.TypeDescription = coupon.Type.Description()
item.Value = coupon.Value
item.MinOrderAmount = coupon.MinOrderAmount
item.MaxDiscount = coupon.MaxDiscount
item.StartAt = s.formatTime(coupon.StartAt)
item.EndAt = s.formatTime(coupon.EndAt)
if tenant := tenantMap[coupon.TenantID]; tenant != nil {
item.TenantCode = tenant.Code
item.TenantName = tenant.Name
}
}
items = append(items, item)
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) UpdateUserStatus(ctx context.Context, id int64, form *super_dto.UserStatusUpdateForm) error {
tbl, q := models.UserQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Status, consts.UserStatus(form.Status))
@@ -3400,6 +3697,75 @@ func (s *super) formatTime(t time.Time) string {
return t.Format(time.RFC3339)
}
func (s *super) maskIDCard(raw string) string {
text := strings.TrimSpace(raw)
if text == "" {
return ""
}
text = strings.TrimPrefix(text, "ENC:")
if text == "" {
return ""
}
length := len(text)
if length <= 4 {
return strings.Repeat("*", length)
}
if length <= 8 {
return text[:2] + strings.Repeat("*", length-4) + text[length-2:]
}
return text[:3] + strings.Repeat("*", length-7) + text[length-4:]
}
func (s *super) filterCouponIDs(ctx context.Context, filter *super_dto.SuperUserCouponListFilter) ([]int64, bool, error) {
if filter == nil {
return nil, false, nil
}
couponTbl, couponQuery := models.CouponQuery.QueryContext(ctx)
applied := false
if filter.TenantID != nil && *filter.TenantID > 0 {
applied = true
couponQuery = couponQuery.Where(couponTbl.TenantID.Eq(*filter.TenantID))
} else {
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, true, err
}
if tenantFilter {
applied = true
if len(tenantIDs) == 0 {
return []int64{}, true, nil
}
couponQuery = couponQuery.Where(couponTbl.TenantID.In(tenantIDs...))
}
}
if filter.Type != nil && *filter.Type != "" {
applied = true
couponQuery = couponQuery.Where(couponTbl.Type.Eq(*filter.Type))
}
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
applied = true
keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%"
couponQuery = couponQuery.Where(field.Or(couponTbl.Title.Like(keyword), couponTbl.Description.Like(keyword)))
}
if !applied {
return nil, false, nil
}
coupons, err := couponQuery.Select(couponTbl.ID).Find()
if err != nil {
return nil, true, errorx.ErrDatabaseError.WithCause(err)
}
ids := make([]int64, 0, len(coupons))
for _, coupon := range coupons {
ids = append(ids, coupon.ID)
}
return ids, true, nil
}
func (s *super) RejectWithdrawal(ctx context.Context, operatorID, id int64, reason string) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")