Files
quyun-v2/backend/app/services/super.go

4423 lines
120 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 services
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"quyun/v2/app/errorx"
super_dto "quyun/v2/app/http/super/v1/dto"
v1_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
jwt_provider "quyun/v2/providers/jwt"
"github.com/google/uuid"
"github.com/spf13/cast"
"go.ipao.vip/gen/field"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type super struct {
jwt *jwt_provider.JWT
}
func (s *super) Login(ctx context.Context, form *super_dto.LoginForm) (*super_dto.LoginResponse, error) {
if form == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("登录参数不能为空")
}
username := strings.TrimSpace(form.Username)
password := strings.TrimSpace(form.Password)
if username == "" || password == "" {
return nil, errorx.ErrInvalidParameter.WithMsg("账号或密码不能为空")
}
// 校验账号与权限。
tbl, q := models.UserQuery.QueryContext(ctx)
u, err := q.Where(tbl.Username.Eq(username)).First()
if err != nil {
return nil, errorx.ErrInvalidCredentials.WithCause(err).WithMsg("账号或密码错误")
}
if u.Password != password {
return nil, errorx.ErrInvalidCredentials.WithMsg("账号或密码错误")
}
if u.Status == consts.UserStatusBanned {
return nil, errorx.ErrAccountDisabled
}
if !hasRole(u.Roles, consts.RoleSuperAdmin) {
return nil, errorx.ErrForbidden.WithMsg("无权限访问")
}
// 生成登录令牌。
token, err := s.jwt.CreateToken(s.jwt.CreateClaims(jwt_provider.BaseClaims{
UserID: u.ID,
}))
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("生成令牌失败")
}
return &super_dto.LoginResponse{
Token: token,
User: s.toSuperUserDTO(u),
}, nil
}
func (s *super) CheckToken(ctx context.Context, token string) (*super_dto.LoginResponse, error) {
if token == "" {
return nil, errorx.ErrUnauthorized.WithMsg("Missing token")
}
claims, err := s.jwt.Parse(token)
if err != nil {
return nil, errorx.ErrUnauthorized.WithCause(err)
}
tbl, q := models.UserQuery.QueryContext(ctx)
u, err := q.Where(tbl.ID.Eq(claims.UserID)).First()
if err != nil {
return nil, errorx.ErrUnauthorized.WithCause(err).WithMsg("UserNotFound")
}
if u.Status == consts.UserStatusBanned {
return nil, errorx.ErrAccountDisabled
}
if !hasRole(u.Roles, consts.RoleSuperAdmin) {
return nil, errorx.ErrForbidden.WithMsg("无权限访问")
}
newToken, err := s.jwt.CreateTokenByOldToken(token, s.jwt.CreateClaims(jwt_provider.BaseClaims{
UserID: u.ID,
}))
if err != nil {
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("生成令牌失败")
}
return &super_dto.LoginResponse{
Token: newToken,
User: s.toSuperUserDTO(u),
}, nil
}
func (s *super) ListUsers(ctx context.Context, filter *super_dto.UserListFilter) (*requests.Pager, error) {
tbl, q := models.UserQuery.QueryContext(ctx)
if filter.Username != nil && strings.TrimSpace(*filter.Username) != "" {
keyword := "%" + strings.TrimSpace(*filter.Username) + "%"
q = q.Where(field.Or(tbl.Username.Like(keyword), tbl.Nickname.Like(keyword)))
}
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.Role != nil && *filter.Role != "" {
q = q.Where(tbl.Roles.Contains(types.Array[consts.Role]{*filter.Role}))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
// 按租户成员过滤用户,需要先定位租户成员关系。
tblTu, qTu := models.TenantUserQuery.QueryContext(ctx)
userIDs, err := qTu.Where(tblTu.TenantID.Eq(*filter.TenantID)).Select(tblTu.UserID).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
ids := make([]int64, 0, len(userIDs))
for _, row := range userIDs {
ids = append(ids, row.UserID)
}
if len(ids) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.ID.In(ids...))
}
}
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 filter.VerifiedAtFrom != nil {
from, err := s.parseFilterTime(filter.VerifiedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.VerifiedAt.Gte(*from))
}
}
if filter.VerifiedAtTo != nil {
to, err := s.parseFilterTime(filter.VerifiedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.VerifiedAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "username":
q = q.Order(tbl.Username.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "verified_at":
q = q.Order(tbl.VerifiedAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "updated_at":
q = q.Order(tbl.UpdatedAt.Desc())
case "balance":
q = q.Order(tbl.Balance.Desc())
case "balance_frozen":
q = q.Order(tbl.BalanceFrozen.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "username":
q = q.Order(tbl.Username)
case "status":
q = q.Order(tbl.Status)
case "verified_at":
q = q.Order(tbl.VerifiedAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "updated_at":
q = q.Order(tbl.UpdatedAt)
case "balance":
q = q.Order(tbl.Balance)
case "balance_frozen":
q = q.Order(tbl.BalanceFrozen)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.ID.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
userIDs := make([]int64, 0, len(list))
for _, u := range list {
userIDs = append(userIDs, u.ID)
}
ownedCountMap, err := s.userOwnedTenantCount(ctx, userIDs)
if err != nil {
return nil, err
}
joinedCountMap, err := s.userJoinedTenantCount(ctx, userIDs)
if err != nil {
return nil, err
}
var data []super_dto.UserItem
for _, u := range list {
data = append(data, super_dto.UserItem{
SuperUserLite: super_dto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Roles: u.Roles,
Status: u.Status,
StatusDescription: u.Status.Description(),
VerifiedAt: s.formatTime(u.VerifiedAt),
CreatedAt: s.formatTime(u.CreatedAt),
UpdatedAt: s.formatTime(u.UpdatedAt),
},
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
OwnedTenantCount: ownedCountMap[u.ID],
JoinedTenantCount: joinedCountMap[u.ID],
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: data,
}, nil
}
func (s *super) GetUser(ctx context.Context, id int64) (*super_dto.UserItem, error) {
tbl, q := models.UserQuery.QueryContext(ctx)
u, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return &super_dto.UserItem{
SuperUserLite: super_dto.SuperUserLite{
ID: u.ID,
Username: u.Username,
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),
},
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
}, nil
}
func (s *super) GetUserWallet(ctx context.Context, userID int64) (*super_dto.SuperWalletResponse, error) {
if userID == 0 {
return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空")
}
// 查询用户余额。
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
u, err := userQuery.Where(userTbl.ID.Eq(userID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 仅返回最近交易记录,避免超管页面加载过重。
orderTbl, orderQuery := models.OrderQuery.QueryContext(ctx)
orders, err := orderQuery.
Where(orderTbl.UserID.Eq(userID), orderTbl.Status.Eq(consts.OrderStatusPaid)).
Order(orderTbl.CreatedAt.Desc()).
Limit(20).
Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 补齐订单对应的租户信息。
tenantIDMap := make(map[int64]struct{})
for _, o := range orders {
if o.TenantID > 0 {
tenantIDMap[o.TenantID] = struct{}{}
}
}
tenantIDs := make([]int64, 0, len(tenantIDMap))
for id := range tenantIDMap {
tenantIDs = append(tenantIDs, id)
}
tenantMap := make(map[int64]*models.Tenant)
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
}
}
txs := make([]super_dto.SuperWalletTransaction, 0, len(orders))
for _, o := range orders {
txType := "expense"
switch o.Type {
case consts.OrderTypeRecharge:
txType = "income"
case consts.OrderTypeWithdrawal:
txType = "expense"
case consts.OrderTypeContentPurchase:
txType = "expense"
}
tenant := tenantMap[o.TenantID]
tenantCode := ""
tenantName := ""
if tenant != nil {
tenantCode = tenant.Code
tenantName = tenant.Name
}
txs = append(txs, super_dto.SuperWalletTransaction{
ID: o.ID,
OrderType: o.Type,
Title: o.Type.Description(),
Amount: o.AmountPaid,
Type: txType,
Date: o.CreatedAt.Format(time.RFC3339),
TenantID: o.TenantID,
TenantCode: tenantCode,
TenantName: tenantName,
})
}
return &super_dto.SuperWalletResponse{
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
Transactions: txs,
}, 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))
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) UpdateUserRoles(ctx context.Context, id int64, form *super_dto.UserRolesUpdateForm) error {
var roles types.Array[consts.Role]
for _, r := range form.Roles {
roles = append(roles, r)
}
tbl, q := models.UserQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Roles, roles)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.TenantListFilter{}
}
tbl, q := models.TenantQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Name != nil && strings.TrimSpace(*filter.Name) != "" {
q = q.Where(tbl.Name.Like("%" + strings.TrimSpace(*filter.Name) + "%"))
}
if filter.Code != nil && strings.TrimSpace(*filter.Code) != "" {
q = q.Where(tbl.Code.Like("%" + strings.TrimSpace(*filter.Code) + "%"))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.ExpiredAtFrom != nil {
from, err := s.parseFilterTime(filter.ExpiredAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.ExpiredAt.Gte(*from))
}
}
if filter.ExpiredAtTo != nil {
to, err := s.parseFilterTime(filter.ExpiredAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.ExpiredAt.Lte(*to))
}
}
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))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "name":
q = q.Order(tbl.Name.Desc())
case "code":
q = q.Order(tbl.Code.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "expired_at":
q = q.Order(tbl.ExpiredAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "updated_at":
q = q.Order(tbl.UpdatedAt.Desc())
case "user_id":
q = q.Order(tbl.UserID.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "name":
q = q.Order(tbl.Name)
case "code":
q = q.Order(tbl.Code)
case "status":
q = q.Order(tbl.Status)
case "expired_at":
q = q.Order(tbl.ExpiredAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "updated_at":
q = q.Order(tbl.UpdatedAt)
case "user_id":
q = q.Order(tbl.UserID)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
data, err := s.buildTenantItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: data,
}, nil
}
func (s *super) TenantHealth(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.TenantListFilter{}
}
tbl, q := models.TenantQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Name != nil && strings.TrimSpace(*filter.Name) != "" {
q = q.Where(tbl.Name.Like("%" + strings.TrimSpace(*filter.Name) + "%"))
}
if filter.Code != nil && strings.TrimSpace(*filter.Code) != "" {
q = q.Where(tbl.Code.Like("%" + strings.TrimSpace(*filter.Code) + "%"))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.ExpiredAtFrom != nil {
from, err := s.parseFilterTime(filter.ExpiredAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.ExpiredAt.Gte(*from))
}
}
if filter.ExpiredAtTo != nil {
to, err := s.parseFilterTime(filter.ExpiredAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.ExpiredAt.Lte(*to))
}
}
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))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "name":
q = q.Order(tbl.Name.Desc())
case "code":
q = q.Order(tbl.Code.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "expired_at":
q = q.Order(tbl.ExpiredAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "updated_at":
q = q.Order(tbl.UpdatedAt.Desc())
case "user_id":
q = q.Order(tbl.UserID.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "name":
q = q.Order(tbl.Name)
case "code":
q = q.Order(tbl.Code)
case "status":
q = q.Order(tbl.Status)
case "expired_at":
q = q.Order(tbl.ExpiredAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "updated_at":
q = q.Order(tbl.UpdatedAt)
case "user_id":
q = q.Order(tbl.UserID)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildTenantHealthItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListCreatorApplications(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.TenantListFilter{}
}
if filter.Status == nil || *filter.Status == "" {
status := consts.TenantStatusPendingVerify
filter.Status = &status
}
return s.ListTenants(ctx, filter)
}
func (s *super) ReviewCreatorApplication(ctx context.Context, operatorID, tenantID int64, form *super_dto.SuperCreatorApplicationReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if tenantID == 0 || form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数无效")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作无效")
}
tbl, q := models.TenantQuery.QueryContext(ctx)
tenant, err := q.Where(tbl.ID.Eq(tenantID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("创作者申请不存在")
}
return errorx.ErrDatabaseError.WithCause(err)
}
if tenant.Status != consts.TenantStatusPendingVerify {
return errorx.ErrBadRequest.WithMsg("创作者申请已处理")
}
nextStatus := consts.TenantStatusVerified
if action == "reject" {
nextStatus = consts.TenantStatusBanned
}
_, err = q.Where(tbl.ID.Eq(tenant.ID), tbl.Status.Eq(consts.TenantStatusPendingVerify)).Update(tbl.Status, nextStatus)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if Notification != nil {
title := "创作者申请审核结果"
detail := "您的创作者申请已通过"
if nextStatus == consts.TenantStatusBanned {
detail = "您的创作者申请已驳回"
if strings.TrimSpace(form.Reason) != "" {
detail += ",原因:" + strings.TrimSpace(form.Reason)
}
}
_ = Notification.Send(ctx, tenant.ID, tenant.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
detail := "approve"
if nextStatus == consts.TenantStatusBanned {
detail = "reject"
}
if strings.TrimSpace(form.Reason) != "" {
detail += ",原因:" + strings.TrimSpace(form.Reason)
}
Audit.Log(ctx, operatorID, "review_creator_application", cast.ToString(tenant.ID), detail)
}
return nil
}
func (s *super) CreateTenant(ctx context.Context, form *super_dto.TenantCreateForm) error {
uid := form.AdminUserID
if _, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First(); err != nil {
return errorx.ErrRecordNotFound.WithMsg("用户不存在")
}
t := &models.Tenant{
UserID: uid,
Name: form.Name,
Code: form.Code,
UUID: types.UUID(uuid.New()),
Status: consts.TenantStatusVerified,
}
if err := models.TenantQuery.WithContext(ctx).Create(t); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) GetTenant(ctx context.Context, id int64) (*super_dto.TenantItem, error) {
tbl, q := models.TenantQuery.QueryContext(ctx)
t, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildTenantItems(ctx, []*models.Tenant{t})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, errorx.ErrRecordNotFound
}
return &items[0], nil
}
func (s *super) UpdateTenantStatus(ctx context.Context, id int64, form *super_dto.TenantStatusUpdateForm) error {
tbl, q := models.TenantQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.Status, consts.TenantStatus(form.Status))
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) UpdateTenantExpire(ctx context.Context, id int64, form *super_dto.TenantExpireUpdateForm) error {
expire := time.Now().AddDate(0, 0, form.Duration)
tbl, q := models.TenantQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(id)).Update(tbl.ExpiredAt, expire)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) ListTenantUsers(ctx context.Context, tenantID int64, filter *super_dto.SuperTenantUserListFilter) (*requests.Pager, error) {
tbl, q := models.TenantUserQuery.QueryContext(ctx)
q = q.Where(tbl.TenantID.Eq(tenantID))
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.Role != nil && *filter.Role != "" {
q = q.Where(tbl.Role.Contains(types.Array[consts.TenantUserRole]{*filter.Role}))
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
userMap, err := s.userMapByTenantUsers(ctx, list)
if err != nil {
return nil, err
}
items := make([]super_dto.SuperTenantUserItem, 0, len(list))
for _, tu := range list {
items = append(items, super_dto.SuperTenantUserItem{
User: s.toSuperUserLite(userMap[tu.UserID]),
TenantUser: s.toSuperTenantUserDTO(tu),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListPayoutAccounts(ctx context.Context, filter *super_dto.SuperPayoutAccountListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperPayoutAccountListFilter{}
}
tbl, q := models.PayoutAccountQuery.QueryContext(ctx)
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" {
q = q.Where(tbl.Type.Eq(strings.TrimSpace(*filter.Type)))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
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)
}
tenantMap := make(map[int64]*models.Tenant)
userMap := make(map[int64]*models.User)
if len(list) > 0 {
tenantIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, pa := range list {
if pa.TenantID > 0 {
tenantIDSet[pa.TenantID] = struct{}{}
}
if pa.UserID > 0 {
userIDSet[pa.UserID] = struct{}{}
}
}
tenantIDs = tenantIDs[:0]
for id := range tenantIDSet {
tenantIDs = append(tenantIDs, id)
}
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
}
}
userIDs = userIDs[:0]
for id := range userIDSet {
userIDs = append(userIDs, id)
}
if len(userIDs) > 0 {
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, user := range users {
userMap[user.ID] = user
}
}
}
items := make([]super_dto.SuperPayoutAccountItem, 0, len(list))
for _, pa := range list {
tenant := tenantMap[pa.TenantID]
user := userMap[pa.UserID]
tenantCode := ""
tenantName := ""
if tenant != nil {
tenantCode = tenant.Code
tenantName = tenant.Name
}
username := ""
if user != nil {
username = user.Username
}
if username == "" && pa.UserID > 0 {
username = "ID:" + strconv.FormatInt(pa.UserID, 10)
}
items = append(items, super_dto.SuperPayoutAccountItem{
ID: pa.ID,
TenantID: pa.TenantID,
TenantCode: tenantCode,
TenantName: tenantName,
UserID: pa.UserID,
Username: username,
Type: pa.Type,
Name: pa.Name,
Account: pa.Account,
Realname: pa.Realname,
CreatedAt: s.formatTime(pa.CreatedAt),
UpdatedAt: s.formatTime(pa.UpdatedAt),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) RemovePayoutAccount(ctx context.Context, operatorID, id int64) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if id == 0 {
return errorx.ErrBadRequest.WithMsg("结算账户ID不能为空")
}
tbl, q := models.PayoutAccountQuery.QueryContext(ctx)
account, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("结算账户不存在")
}
return errorx.ErrDatabaseError.WithCause(err)
}
if _, err := q.Where(tbl.ID.Eq(account.ID)).Delete(); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "remove_payout_account", cast.ToString(account.ID), "Removed payout account")
}
return nil
}
func (s *super) ListTenantJoinRequests(ctx context.Context, filter *super_dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperTenantJoinRequestListFilter{}
}
tbl, q := models.TenantJoinRequestQuery.QueryContext(ctx)
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(string(*filter.Status)))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
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)
}
// 补齐租户与用户信息,便于前端展示。
tenantMap := make(map[int64]*models.Tenant)
userMap := make(map[int64]*models.User)
if len(list) > 0 {
tenantIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, req := range list {
if req.TenantID > 0 {
tenantIDSet[req.TenantID] = struct{}{}
}
if req.UserID > 0 {
userIDSet[req.UserID] = struct{}{}
}
}
tenantIDs := make([]int64, 0, len(tenantIDSet))
for id := range tenantIDSet {
tenantIDs = append(tenantIDs, id)
}
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
}
}
userIDs := make([]int64, 0, len(userIDSet))
for id := range userIDSet {
userIDs = append(userIDs, id)
}
if len(userIDs) > 0 {
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, user := range users {
userMap[user.ID] = user
}
}
}
items := make([]super_dto.SuperTenantJoinRequestItem, 0, len(list))
for _, req := range list {
tenant := tenantMap[req.TenantID]
user := userMap[req.UserID]
status := consts.TenantJoinRequestStatus(req.Status)
statusDescription := status.Description()
if statusDescription == "" {
statusDescription = req.Status
}
tenantCode := ""
tenantName := ""
if tenant != nil {
tenantCode = tenant.Code
tenantName = tenant.Name
}
username := ""
if user != nil {
username = user.Username
}
if username == "" && req.UserID > 0 {
username = "ID:" + strconv.FormatInt(req.UserID, 10)
}
items = append(items, super_dto.SuperTenantJoinRequestItem{
ID: req.ID,
TenantID: req.TenantID,
TenantCode: tenantCode,
TenantName: tenantName,
UserID: req.UserID,
Username: username,
Status: req.Status,
StatusDescription: statusDescription,
Reason: req.Reason,
DecidedAt: s.formatTime(req.DecidedAt),
DecidedOperatorUserID: req.DecidedOperatorUserID,
DecidedReason: req.DecidedReason,
CreatedAt: s.formatTime(req.CreatedAt),
UpdatedAt: s.formatTime(req.UpdatedAt),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ReviewTenantJoinRequest(ctx context.Context, operatorID, requestID int64, form *v1_dto.TenantJoinReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数不能为空")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作无效")
}
tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx)
req, err := qReq.Where(tblReq.ID.Eq(requestID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("申请不存在")
}
return errorx.ErrDatabaseError.WithCause(err)
}
if req.Status != string(consts.TenantJoinRequestStatusPending) {
return errorx.ErrBadRequest.WithMsg("申请已处理")
}
reason := strings.TrimSpace(form.Reason)
now := time.Now()
if action == "reject" {
_, err = qReq.Where(
tblReq.ID.Eq(req.ID),
tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)),
).UpdateSimple(
tblReq.Status.Value(string(consts.TenantJoinRequestStatusRejected)),
tblReq.DecidedAt.Value(now),
tblReq.DecidedOperatorUserID.Value(operatorID),
tblReq.DecidedReason.Value(reason),
)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
// 审核通过需在事务内写入成员并更新申请状态。
return models.Q.Transaction(func(tx *models.Query) error {
tblMember, qMember := tx.TenantUser.QueryContext(ctx)
exists, err := qMember.Where(
tblMember.TenantID.Eq(req.TenantID),
tblMember.UserID.Eq(req.UserID),
).Exists()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if exists {
return errorx.ErrBadRequest.WithMsg("用户已是成员")
}
member := &models.TenantUser{
TenantID: req.TenantID,
UserID: req.UserID,
Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember},
Status: consts.UserStatusVerified,
}
if err := qMember.Create(member); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
tblReqTx, qReqTx := tx.TenantJoinRequest.QueryContext(ctx)
_, err = qReqTx.Where(
tblReqTx.ID.Eq(req.ID),
tblReqTx.Status.Eq(string(consts.TenantJoinRequestStatusPending)),
).UpdateSimple(
tblReqTx.Status.Value(string(consts.TenantJoinRequestStatusApproved)),
tblReqTx.DecidedAt.Value(now),
tblReqTx.DecidedOperatorUserID.Value(operatorID),
tblReqTx.DecidedReason.Value(reason),
)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
})
}
func (s *super) CreateTenantInvite(ctx context.Context, tenantID int64, form *v1_dto.TenantInviteCreateForm) (*v1_dto.TenantInviteItem, error) {
if tenantID == 0 {
return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在")
}
// 使用租户主账号执行创建邀请码逻辑,复用既有校验流程。
tbl, q := models.TenantQuery.QueryContext(ctx)
tenant, err := q.Where(tbl.ID.Eq(tenantID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在")
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return Tenant.CreateInvite(ctx, tenantID, tenant.UserID, form)
}
func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) {
tbl, q := models.TenantUserQuery.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.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.Role != nil && *filter.Role != "" {
q = q.Where(tbl.Role.Contains(types.Array[consts.TenantUserRole]{*filter.Role}))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.Code, filter.Name)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
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))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "tenant_id":
q = q.Order(tbl.TenantID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "tenant_id":
q = q.Order(tbl.TenantID)
case "created_at":
q = q.Order(tbl.CreatedAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.CreatedAt.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
tenantMap, ownerMap, err := s.tenantMapsForTenantUsers(ctx, list)
if err != nil {
return nil, err
}
items := make([]super_dto.UserTenantItem, 0, len(list))
for _, tu := range list {
tenant := tenantMap[tu.TenantID]
owner := ownerMap[tu.TenantID]
item := super_dto.UserTenantItem{
TenantID: tu.TenantID,
Role: tu.Role,
MemberStatus: tu.Status,
MemberStatusDescription: tu.Status.Description(),
JoinedAt: s.formatTime(tu.CreatedAt),
}
if tenant != nil {
item.TenantStatus = tenant.Status
item.TenantStatusDescription = tenant.Status.Description()
item.Name = tenant.Name
item.Code = tenant.Code
item.ExpiredAt = s.formatTime(tenant.ExpiredAt)
}
if owner != nil {
item.Owner = &super_dto.TenantOwnerUserLite{
ID: owner.ID,
Username: owner.Username,
}
}
items = append(items, item)
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListContents(ctx context.Context, filter *super_dto.SuperContentListFilter) (*requests.Pager, error) {
tbl, q := models.ContentQuery.QueryContext(ctx)
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%"
q = q.Where(field.Or(
tbl.Title.Like(keyword),
tbl.Description.Like(keyword),
tbl.Summary.Like(keyword),
))
}
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.Visibility != nil && *filter.Visibility != "" {
q = q.Where(tbl.Visibility.Eq(*filter.Visibility))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
if filter.PublishedAtFrom != nil {
from, err := s.parseFilterTime(filter.PublishedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.PublishedAt.Gte(*from))
}
}
if filter.PublishedAtTo != nil {
to, err := s.parseFilterTime(filter.PublishedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.PublishedAt.Lte(*to))
}
}
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 filter.PriceAmountMin != nil || filter.PriceAmountMax != nil {
pTbl, pQ := models.ContentPriceQuery.QueryContext(ctx)
pq := pQ
if filter.PriceAmountMin != nil {
pq = pq.Where(pTbl.PriceAmount.Gte(*filter.PriceAmountMin))
}
if filter.PriceAmountMax != nil {
pq = pq.Where(pTbl.PriceAmount.Lte(*filter.PriceAmountMax))
}
prices, err := pq.Select(pTbl.ContentID).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
ids := make([]int64, 0, len(prices))
for _, price := range prices {
ids = append(ids, price.ContentID)
}
if len(ids) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.ID.In(ids...))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "title":
q = q.Order(tbl.Title.Desc())
case "tenant_id":
q = q.Order(tbl.TenantID.Desc())
case "user_id":
q = q.Order(tbl.UserID.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "visibility":
q = q.Order(tbl.Visibility.Desc())
case "published_at":
q = q.Order(tbl.PublishedAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "title":
q = q.Order(tbl.Title)
case "tenant_id":
q = q.Order(tbl.TenantID)
case "user_id":
q = q.Order(tbl.UserID)
case "status":
q = q.Order(tbl.Status)
case "visibility":
q = q.Order(tbl.Visibility)
case "published_at":
q = q.Order(tbl.PublishedAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
var list []*models.Content
err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).
UnderlyingDB().
Preload("Author").
Preload("ContentAssets.Asset").
Find(&list).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
priceMap, err := s.contentPriceMap(ctx, list)
if err != nil {
return nil, err
}
tenantMap, err := s.contentTenantMap(ctx, list)
if err != nil {
return nil, err
}
data := make([]super_dto.AdminContentItem, 0, len(list))
for _, c := range list {
data = append(data, s.toSuperContentItem(c, priceMap[c.ID], tenantMap[c.TenantID]))
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: data,
}, nil
}
func (s *super) UpdateContentStatus(ctx context.Context, tenantID, contentID int64, form *super_dto.SuperTenantContentStatusUpdateForm) error {
tbl, q := models.ContentQuery.QueryContext(ctx)
_, err := q.Where(tbl.ID.Eq(contentID), tbl.TenantID.Eq(tenantID)).Update(tbl.Status, consts.ContentStatus(form.Status))
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
}
func (s *super) ReviewContent(ctx context.Context, operatorID, contentID int64, form *super_dto.SuperContentReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数不能为空")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作非法")
}
tbl, q := models.ContentQuery.QueryContext(ctx)
content, err := q.Where(tbl.ID.Eq(contentID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound
}
return errorx.ErrDatabaseError.WithCause(err)
}
if content.Status != consts.ContentStatusReviewing {
return errorx.ErrStatusConflict.WithMsg("内容未处于审核中状态")
}
// 审核动作映射为内容状态。
nextStatus := consts.ContentStatusBlocked
if action == "approve" {
nextStatus = consts.ContentStatusPublished
}
updates := &models.Content{
Status: nextStatus,
UpdatedAt: time.Now(),
}
if nextStatus == consts.ContentStatusPublished {
updates.PublishedAt = time.Now()
}
_, err = q.Where(tbl.ID.Eq(contentID)).Updates(updates)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
// 审核完成后通知作者并记录审计日志。
title := "内容审核结果"
detail := "内容审核通过"
if action == "reject" {
detail = "内容审核驳回"
if strings.TrimSpace(form.Reason) != "" {
detail += ",原因:" + strings.TrimSpace(form.Reason)
}
}
if Notification != nil {
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "review_content", cast.ToString(contentID), detail)
}
return nil
}
func (s *super) BatchReviewContents(ctx context.Context, operatorID int64, form *super_dto.SuperContentBatchReviewForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("审核参数不能为空")
}
action := strings.ToLower(strings.TrimSpace(form.Action))
if action != "approve" && action != "reject" {
return errorx.ErrBadRequest.WithMsg("审核动作非法")
}
// 去重并过滤非法ID确保审核集合有效。
unique := make(map[int64]struct{})
contentIDs := make([]int64, 0, len(form.ContentIDs))
for _, id := range form.ContentIDs {
if id <= 0 {
continue
}
if _, ok := unique[id]; ok {
continue
}
unique[id] = struct{}{}
contentIDs = append(contentIDs, id)
}
if len(contentIDs) == 0 {
return errorx.ErrBadRequest.WithMsg("内容ID不能为空")
}
// 审核动作映射为内容状态。
nextStatus := consts.ContentStatusBlocked
if action == "approve" {
nextStatus = consts.ContentStatusPublished
}
reason := strings.TrimSpace(form.Reason)
var contents []*models.Content
err := models.Q.Transaction(func(tx *models.Query) error {
tbl, q := tx.Content.QueryContext(ctx)
list, err := q.Where(tbl.ID.In(contentIDs...)).Find()
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if len(list) != len(contentIDs) {
return errorx.ErrRecordNotFound.WithMsg("部分内容不存在")
}
for _, content := range list {
if content.Status != consts.ContentStatusReviewing {
return errorx.ErrStatusConflict.WithMsg("仅可审核待审核内容")
}
}
updates := &models.Content{
Status: nextStatus,
UpdatedAt: time.Now(),
}
if nextStatus == consts.ContentStatusPublished {
updates.PublishedAt = time.Now()
}
if _, err := q.Where(tbl.ID.In(contentIDs...)).Updates(updates); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
contents = list
return nil
})
if err != nil {
return err
}
// 审核完成后通知作者并记录审计日志(批量逐条记录,便于追溯)。
title := "内容审核结果"
detail := "内容审核通过"
if action == "reject" {
detail = "内容审核驳回"
if reason != "" {
detail += ",原因:" + reason
}
}
for _, content := range contents {
if Notification != nil {
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "review_content", cast.ToString(content.ID), detail)
}
}
return nil
}
func (s *super) ContentStatistics(ctx context.Context, filter *super_dto.SuperContentStatisticsFilter) (*super_dto.SuperContentStatisticsResponse, error) {
// 统一统计时间范围与粒度,默认最近 7 天。
reportFilter := &super_dto.SuperReportOverviewFilter{}
if filter != nil {
reportFilter.TenantID = filter.TenantID
reportFilter.StartAt = filter.StartAt
reportFilter.EndAt = filter.EndAt
reportFilter.Granularity = filter.Granularity
}
rg, err := s.normalizeReportRange(reportFilter)
if err != nil {
return nil, err
}
tenantID := int64(0)
if filter != nil && filter.TenantID != nil {
tenantID = *filter.TenantID
}
// 统计内容总量,支持租户维度过滤。
tbl, q := models.ContentQuery.QueryContext(ctx)
if tenantID > 0 {
q = q.Where(tbl.TenantID.Eq(tenantID))
}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 按天聚合新增内容数量,补齐趋势序列。
type contentAggRow struct {
Day time.Time `gorm:"column:day"`
Count int64 `gorm:"column:count"`
}
rows := make([]contentAggRow, 0)
query := models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select("date_trunc('day', created_at) as day, count(*) as count").
Where("created_at >= ? AND created_at < ?", rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Group("day").Scan(&rows).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
trendMap := make(map[string]int64, len(rows))
for _, row := range rows {
key := row.Day.Format("2006-01-02")
trendMap[key] = row.Count
}
trend := make([]super_dto.SuperContentTrendItem, 0)
for day := rg.startDay; !day.After(rg.endDay); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
trend = append(trend, super_dto.SuperContentTrendItem{
Date: key,
CreatedCount: trendMap[key],
})
}
return &super_dto.SuperContentStatisticsResponse{
TotalCount: total,
Trend: trend,
}, nil
}
func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
tbl, q := models.OrderQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil && *filter.Type != "" {
q = q.Where(tbl.Type.Eq(*filter.Type))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.AmountPaidMin != nil {
q = q.Where(tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
q = q.Where(tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
orderIDs, contentFilter, err := s.lookupOrderIDsByContent(ctx, filter.ContentID, filter.ContentTitle)
if err != nil {
return nil, err
}
if contentFilter {
if len(orderIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.ID.In(orderIDs...))
}
}
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 filter.PaidAtFrom != nil {
from, err := s.parseFilterTime(filter.PaidAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.PaidAt.Gte(*from))
}
}
if filter.PaidAtTo != nil {
to, err := s.parseFilterTime(filter.PaidAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.PaidAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "paid_at":
q = q.Order(tbl.PaidAt.Desc())
case "amount_paid":
q = q.Order(tbl.AmountPaid.Desc())
case "amount_original":
q = q.Order(tbl.AmountOriginal.Desc())
case "amount_discount":
q = q.Order(tbl.AmountDiscount.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
case "type":
q = q.Order(tbl.Type.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "paid_at":
q = q.Order(tbl.PaidAt)
case "amount_paid":
q = q.Order(tbl.AmountPaid)
case "amount_original":
q = q.Order(tbl.AmountOriginal)
case "amount_discount":
q = q.Order(tbl.AmountDiscount)
case "status":
q = q.Order(tbl.Status)
case "type":
q = q.Order(tbl.Type)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildSuperOrderItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) GetOrder(ctx context.Context, id int64) (*super_dto.SuperOrderDetail, error) {
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
var tenant *models.Tenant
if t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(o.TenantID)).First(); err == nil {
tenant = t
}
var buyer *models.User
if u, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(o.UserID)).First(); err == nil {
buyer = u
}
itemTbl, itemQ := models.OrderItemQuery.QueryContext(ctx)
orderItems, err := itemQ.Where(itemTbl.OrderID.Eq(o.ID)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items := make([]super_dto.SuperOrderItemLine, 0, len(orderItems))
for _, it := range orderItems {
items = append(items, s.toSuperOrderItemLine(it))
}
item := s.toSuperOrderItem(o, tenant, buyer)
item.Snapshot = o.Snapshot.Data()
item.Items = items
return &super_dto.SuperOrderDetail{
Order: &item,
Tenant: item.Tenant,
Buyer: item.Buyer,
}, nil
}
func (s *super) RefundOrder(ctx context.Context, id int64, form *super_dto.SuperOrderRefundForm) error {
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound
}
return errorx.ErrDatabaseError.WithCause(err)
}
if o.Status != consts.OrderStatusRefunding {
if !form.Force {
return errorx.ErrStatusConflict.WithMsg("订单状态不是退款中")
}
_, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).Updates(&models.Order{
Status: consts.OrderStatusRefunding,
RefundReason: form.Reason,
UpdatedAt: time.Now(),
})
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
}
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(o.TenantID)).First()
if err != nil {
return errorx.ErrRecordNotFound.WithMsg("租户不存在")
}
return Creator.ProcessRefund(ctx, t.ID, t.UserID, id, &v1_dto.RefundForm{
Action: "accept",
Reason: form.Reason,
})
}
func (s *super) OrderStatistics(ctx context.Context) (*super_dto.OrderStatisticsResponse, error) {
var totals struct {
TotalCount int64 `gorm:"column:total_count"`
TotalAmountPaidSum int64 `gorm:"column:total_amount_paid_sum"`
}
err := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("count(*) as total_count, coalesce(sum(amount_paid), 0) as total_amount_paid_sum").
Scan(&totals).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
var rows []struct {
Status consts.OrderStatus `gorm:"column:status"`
Count int64 `gorm:"column:count"`
AmountPaidSum int64 `gorm:"column:amount_paid_sum"`
}
err = models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("status, count(*) as count, coalesce(sum(amount_paid), 0) as amount_paid_sum").
Group("status").
Scan(&rows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
stats := make([]super_dto.OrderStatisticsRow, 0, len(rows))
for _, row := range rows {
stats = append(stats, super_dto.OrderStatisticsRow{
Status: row.Status,
StatusDescription: row.Status.Description(),
Count: row.Count,
AmountPaidSum: row.AmountPaidSum,
})
}
return &super_dto.OrderStatisticsResponse{
TotalCount: totals.TotalCount,
TotalAmountPaidSum: totals.TotalAmountPaidSum,
ByStatus: stats,
}, nil
}
func (s *super) UserStatistics(ctx context.Context) ([]super_dto.UserStatistics, error) {
var rows []struct {
Status consts.UserStatus `gorm:"column:status"`
Count int64 `gorm:"column:count"`
}
err := models.UserQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.User{}).
Select("status, count(*) as count").
Group("status").
Scan(&rows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
stats := make([]super_dto.UserStatistics, 0, len(rows))
for _, row := range rows {
stats = append(stats, super_dto.UserStatistics{
Status: row.Status,
StatusDescription: row.Status.Description(),
Count: row.Count,
})
}
return stats, nil
}
func (s *super) UserStatuses(ctx context.Context) ([]requests.KV, error) {
return consts.UserStatusItems(), nil
}
func (s *super) TenantStatuses(ctx context.Context) ([]requests.KV, error) {
return consts.TenantStatusItems(), nil
}
func (s *super) toSuperUserDTO(u *models.User) *super_dto.User {
return &super_dto.User{
ID: u.ID,
Phone: u.Phone,
Nickname: u.Nickname,
Avatar: u.Avatar,
Gender: u.Gender,
Bio: u.Bio,
Balance: float64(u.Balance) / 100.0,
Points: u.Points,
IsRealNameVerified: u.IsRealNameVerified,
}
}
func (s *super) toSuperUserLite(u *models.User) *super_dto.SuperUserLite {
if u == nil {
return nil
}
return &super_dto.SuperUserLite{
ID: u.ID,
Username: u.Username,
Roles: u.Roles,
Status: u.Status,
StatusDescription: u.Status.Description(),
VerifiedAt: s.formatTime(u.VerifiedAt),
CreatedAt: s.formatTime(u.CreatedAt),
UpdatedAt: s.formatTime(u.UpdatedAt),
}
}
func hasRole(roles types.Array[consts.Role], role consts.Role) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
}
func hasTenantRole(roles types.Array[consts.TenantUserRole], role consts.TenantUserRole) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
}
func (s *super) buildSuperOrderItems(ctx context.Context, orders []*models.Order) ([]super_dto.SuperOrderItem, error) {
if len(orders) == 0 {
return []super_dto.SuperOrderItem{}, nil
}
tenantIDs := make([]int64, 0, len(orders))
userIDs := make([]int64, 0, len(orders))
tenantSet := make(map[int64]struct{})
userSet := make(map[int64]struct{})
for _, o := range orders {
if _, ok := tenantSet[o.TenantID]; !ok {
tenantSet[o.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, o.TenantID)
}
if _, ok := userSet[o.UserID]; !ok {
userSet[o.UserID] = struct{}{}
userIDs = append(userIDs, o.UserID)
}
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tbl, q := models.TenantQuery.QueryContext(ctx)
tenants, err := q.Where(tbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, t := range tenants {
tenantMap[t.ID] = t
}
}
userMap := make(map[int64]*models.User, len(userIDs))
if len(userIDs) > 0 {
tbl, q := models.UserQuery.QueryContext(ctx)
users, err := q.Where(tbl.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
userMap[u.ID] = u
}
}
items := make([]super_dto.SuperOrderItem, 0, len(orders))
for _, o := range orders {
items = append(items, s.toSuperOrderItem(o, tenantMap[o.TenantID], userMap[o.UserID]))
}
return items, nil
}
func (s *super) toSuperOrderItem(o *models.Order, tenant *models.Tenant, buyer *models.User) super_dto.SuperOrderItem {
item := super_dto.SuperOrderItem{
ID: o.ID,
Type: o.Type,
Status: o.Status,
StatusDescription: o.Status.Description(),
Currency: o.Currency,
AmountOriginal: o.AmountOriginal,
AmountDiscount: o.AmountDiscount,
AmountPaid: o.AmountPaid,
CreatedAt: o.CreatedAt.Format(time.RFC3339),
UpdatedAt: o.UpdatedAt.Format(time.RFC3339),
}
if !o.PaidAt.IsZero() {
item.PaidAt = o.PaidAt.Format(time.RFC3339)
}
if !o.RefundedAt.IsZero() {
item.RefundedAt = o.RefundedAt.Format(time.RFC3339)
}
if tenant != nil {
item.Tenant = &super_dto.OrderTenantLite{
ID: tenant.ID,
Code: tenant.Code,
Name: tenant.Name,
}
}
if buyer != nil {
item.Buyer = &super_dto.OrderBuyerLite{
ID: buyer.ID,
Username: buyer.Username,
}
}
return item
}
func (s *super) toSuperOrderItemLine(item *models.OrderItem) super_dto.SuperOrderItemLine {
return super_dto.SuperOrderItemLine{
ID: item.ID,
ContentID: item.ContentID,
AmountPaid: item.AmountPaid,
Snapshot: item.Snapshot.Data(),
}
}
func (s *super) parseFilterTime(value *string) (*time.Time, error) {
if value == nil {
return nil, nil
}
text := strings.TrimSpace(*value)
if text == "" {
return nil, nil
}
if t, err := time.Parse(time.RFC3339, text); err == nil {
return &t, nil
}
t, err := time.Parse("2006-01-02", text)
if err != nil {
return nil, errorx.ErrInvalidFormat.WithCause(err)
}
return &t, nil
}
func (s *super) lookupTenantIDs(ctx context.Context, code, name *string) ([]int64, bool, error) {
codeText := ""
if code != nil {
codeText = strings.TrimSpace(*code)
}
nameText := ""
if name != nil {
nameText = strings.TrimSpace(*name)
}
if codeText == "" && nameText == "" {
return nil, false, nil
}
tbl, q := models.TenantQuery.QueryContext(ctx)
if codeText != "" {
q = q.Where(tbl.Code.Like("%" + codeText + "%"))
}
if nameText != "" {
q = q.Where(tbl.Name.Like("%" + nameText + "%"))
}
tenants, err := q.Select(tbl.ID).Find()
if err != nil {
return nil, true, errorx.ErrDatabaseError.WithCause(err)
}
ids := make([]int64, 0, len(tenants))
for _, tenant := range tenants {
ids = append(ids, tenant.ID)
}
return ids, true, nil
}
func (s *super) lookupUserIDs(ctx context.Context, username *string) ([]int64, bool, error) {
text := ""
if username != nil {
text = strings.TrimSpace(*username)
}
if text == "" {
return nil, false, nil
}
tbl, q := models.UserQuery.QueryContext(ctx)
keyword := "%" + text + "%"
q = q.Where(field.Or(tbl.Username.Like(keyword), tbl.Nickname.Like(keyword)))
users, err := q.Select(tbl.ID).Find()
if err != nil {
return nil, true, errorx.ErrDatabaseError.WithCause(err)
}
ids := make([]int64, 0, len(users))
for _, user := range users {
ids = append(ids, user.ID)
}
return ids, true, nil
}
func (s *super) lookupOrderIDsByContent(ctx context.Context, contentID *int64, contentTitle *string) ([]int64, bool, error) {
var id int64
if contentID != nil {
id = *contentID
}
title := ""
if contentTitle != nil {
title = strings.TrimSpace(*contentTitle)
}
if id <= 0 && title == "" {
return nil, false, nil
}
tbl, q := models.OrderItemQuery.QueryContext(ctx)
if id > 0 {
q = q.Where(tbl.ContentID.Eq(id))
}
var items []*models.OrderItem
if title != "" {
// JSONB 字段需要使用 UnderlyingDB 做模糊查询。
keyword := "%" + title + "%"
err := q.UnderlyingDB().
Where("snapshot ->> 'content_title' ILIKE ?", keyword).
Select("order_id").
Find(&items).Error
if err != nil {
return nil, true, errorx.ErrDatabaseError.WithCause(err)
}
} else {
list, err := q.Select(tbl.OrderID).Find()
if err != nil {
return nil, true, errorx.ErrDatabaseError.WithCause(err)
}
items = list
}
idMap := make(map[int64]struct{}, len(items))
for _, item := range items {
idMap[item.OrderID] = struct{}{}
}
ids := make([]int64, 0, len(idMap))
for orderID := range idMap {
ids = append(ids, orderID)
}
return ids, true, nil
}
func (s *super) contentPriceMap(ctx context.Context, list []*models.Content) (map[int64]*models.ContentPrice, error) {
if len(list) == 0 {
return map[int64]*models.ContentPrice{}, nil
}
ids := make([]int64, 0, len(list))
for _, item := range list {
ids = append(ids, item.ID)
}
tbl, q := models.ContentPriceQuery.QueryContext(ctx)
prices, err := q.Where(tbl.ContentID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
priceMap := make(map[int64]*models.ContentPrice, len(prices))
for _, price := range prices {
priceMap[price.ContentID] = price
}
return priceMap, nil
}
func (s *super) contentTenantMap(ctx context.Context, list []*models.Content) (map[int64]*models.Tenant, error) {
if len(list) == 0 {
return map[int64]*models.Tenant{}, nil
}
ids := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if _, ok := seen[item.TenantID]; ok {
continue
}
seen[item.TenantID] = struct{}{}
ids = append(ids, item.TenantID)
}
tbl, q := models.TenantQuery.QueryContext(ctx)
tenants, err := q.Where(tbl.ID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
tenantMap := make(map[int64]*models.Tenant, len(tenants))
for _, tenant := range tenants {
tenantMap[tenant.ID] = tenant
}
return tenantMap, nil
}
func (s *super) toSuperContentItem(item *models.Content, price *models.ContentPrice, tenant *models.Tenant) super_dto.AdminContentItem {
return super_dto.AdminContentItem{
Content: s.toSuperContentDTO(item, price),
Owner: s.toSuperContentOwner(item.Author),
Price: s.toSuperContentPrice(price),
StatusDescription: item.Status.Description(),
VisibilityDescription: item.Visibility.Description(),
Tenant: s.toSuperContentTenant(tenant),
}
}
func (s *super) toSuperContentOwner(author *models.User) *super_dto.AdminContentOwnerLite {
if author == nil {
return nil
}
return &super_dto.AdminContentOwnerLite{
ID: author.ID,
Username: author.Username,
Roles: author.Roles,
Status: author.Status,
}
}
func (s *super) toSuperContentTenant(tenant *models.Tenant) *super_dto.SuperContentTenantLite {
if tenant == nil {
return nil
}
return &super_dto.SuperContentTenantLite{
ID: tenant.ID,
Code: tenant.Code,
Name: tenant.Name,
}
}
func (s *super) toSuperContentDTO(item *models.Content, price *models.ContentPrice) *v1_dto.ContentItem {
dto := &v1_dto.ContentItem{
ID: item.ID,
TenantID: item.TenantID,
UserID: item.UserID,
Title: item.Title,
Genre: item.Genre,
Status: string(item.Status),
Visibility: string(item.Visibility),
AuthorID: item.UserID,
Views: int(item.Views),
Likes: int(item.Likes),
CreatedAt: item.CreatedAt.Format("2006-01-02"),
IsPurchased: false,
}
if !item.PublishedAt.IsZero() {
dto.PublishedAt = item.PublishedAt.Format("2006-01-02")
}
if price != nil {
dto.Price = float64(price.PriceAmount) / 100.0
}
if item.Author != nil {
dto.AuthorName = item.Author.Nickname
if dto.AuthorName == "" {
dto.AuthorName = item.Author.Username
}
dto.AuthorAvatar = item.Author.Avatar
}
var hasVideo, hasAudio bool
for _, asset := range item.ContentAssets {
if asset.Asset == nil {
continue
}
if asset.Role == consts.ContentAssetRoleCover {
dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
}
switch asset.Asset.Type {
case consts.MediaAssetTypeVideo:
hasVideo = true
case consts.MediaAssetTypeAudio:
hasAudio = true
}
}
if dto.Cover == "" && len(item.ContentAssets) > 0 {
for _, asset := range item.ContentAssets {
if asset.Asset != nil && asset.Asset.Type == consts.MediaAssetTypeImage {
dto.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
break
}
}
}
if hasVideo {
dto.Type = "video"
} else if hasAudio {
dto.Type = "audio"
} else {
dto.Type = "article"
}
return dto
}
func (s *super) toSuperContentPrice(price *models.ContentPrice) *v1_dto.ContentPrice {
if price == nil {
return nil
}
dto := &v1_dto.ContentPrice{
Currency: string(price.Currency),
PriceAmount: float64(price.PriceAmount) / 100.0,
DiscountType: string(price.DiscountType),
DiscountValue: s.toSuperDiscountValue(price),
}
if !price.DiscountStartAt.IsZero() {
dto.DiscountStartAt = price.DiscountStartAt.Format(time.RFC3339)
}
if !price.DiscountEndAt.IsZero() {
dto.DiscountEndAt = price.DiscountEndAt.Format(time.RFC3339)
}
return dto
}
func (s *super) toSuperDiscountValue(price *models.ContentPrice) float64 {
if price == nil {
return 0
}
if price.DiscountType == consts.DiscountTypeAmount {
return float64(price.DiscountValue) / 100.0
}
return float64(price.DiscountValue)
}
func (s *super) buildTenantItems(ctx context.Context, list []*models.Tenant) ([]super_dto.TenantItem, error) {
if len(list) == 0 {
return []super_dto.TenantItem{}, nil
}
tenantIDs := make([]int64, 0, len(list))
tenantOwnerIDs := make(map[int64]struct{}, len(list))
for _, t := range list {
tenantIDs = append(tenantIDs, t.ID)
tenantOwnerIDs[t.UserID] = struct{}{}
}
// 统计租户成员数与管理员列表(基于 tenant_users
userCountMap := make(map[int64]int64, len(list))
adminSet := make(map[int64]map[int64]struct{}, len(list))
tblTu, qTu := models.TenantUserQuery.QueryContext(ctx)
tenantUsers, err := qTu.Where(tblTu.TenantID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, tu := range tenantUsers {
userCountMap[tu.TenantID]++
if hasTenantRole(tu.Role, consts.TenantUserRoleTenantAdmin) {
if _, ok := adminSet[tu.TenantID]; !ok {
adminSet[tu.TenantID] = make(map[int64]struct{})
}
adminSet[tu.TenantID][tu.UserID] = struct{}{}
}
}
adminIDs := make(map[int64]struct{})
for _, ids := range adminSet {
for id := range ids {
adminIDs[id] = struct{}{}
}
}
userIDs := make([]int64, 0, len(adminIDs)+len(tenantOwnerIDs))
seen := make(map[int64]struct{})
for id := range adminIDs {
seen[id] = struct{}{}
userIDs = append(userIDs, id)
}
for id := range tenantOwnerIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
userIDs = append(userIDs, id)
}
userMap := make(map[int64]*models.User, len(userIDs))
if len(userIDs) > 0 {
tblUser, qUser := models.UserQuery.QueryContext(ctx)
users, err := qUser.Where(tblUser.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
userMap[u.ID] = u
}
}
// 汇总租户收入(按已支付/退款中/已退款订单统计实付金额)。
incomeMap := make(map[int64]int64, len(list))
if len(tenantIDs) > 0 {
var rows []struct {
TenantID int64 `gorm:"column:tenant_id"`
AmountPaidSum int64 `gorm:"column:amount_paid_sum"`
}
err := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("tenant_id, coalesce(sum(amount_paid), 0) as amount_paid_sum").
Where("tenant_id IN ?", tenantIDs).
Where("status IN ?", []consts.OrderStatus{
consts.OrderStatusPaid,
consts.OrderStatusRefunding,
consts.OrderStatusRefunded,
}).
Group("tenant_id").
Scan(&rows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range rows {
incomeMap[row.TenantID] = row.AmountPaidSum
}
}
items := make([]super_dto.TenantItem, 0, len(list))
for _, t := range list {
item := super_dto.TenantItem{
ID: t.ID,
UUID: t.UUID.String(),
Name: t.Name,
Code: t.Code,
Status: t.Status,
StatusDescription: t.Status.Description(),
UserID: t.UserID,
UserCount: userCountMap[t.ID],
IncomeAmountPaidSum: incomeMap[t.ID],
ExpiredAt: s.formatTime(t.ExpiredAt),
CreatedAt: s.formatTime(t.CreatedAt),
UpdatedAt: s.formatTime(t.UpdatedAt),
}
if owner := userMap[t.UserID]; owner != nil {
item.Owner = &super_dto.TenantOwnerUserLite{
ID: owner.ID,
Username: owner.Username,
}
}
if adminSet[t.ID] != nil {
admins := make([]*super_dto.TenantAdminUserLite, 0, len(adminSet[t.ID]))
for adminID := range adminSet[t.ID] {
if u := userMap[adminID]; u != nil {
admins = append(admins, &super_dto.TenantAdminUserLite{
ID: u.ID,
Username: u.Username,
})
}
}
item.AdminUsers = admins
}
items = append(items, item)
}
return items, nil
}
type tenantHealthContentAgg struct {
TenantID int64 `gorm:"column:tenant_id"`
ContentCount int64 `gorm:"column:content_count"`
PublishedCount int64 `gorm:"column:published_count"`
}
type tenantHealthOrderAgg struct {
TenantID int64 `gorm:"column:tenant_id"`
PaidCount int64 `gorm:"column:paid_count"`
PaidAmount int64 `gorm:"column:paid_amount"`
RefundCount int64 `gorm:"column:refund_count"`
RefundAmount int64 `gorm:"column:refund_amount"`
LastPaidAt time.Time `gorm:"column:last_paid_at"`
}
type tenantHealthMetrics struct {
MemberCount int64
ContentCount int64
PublishedContentCount int64
PaidOrders int64
PaidAmount int64
RefundOrders int64
RefundAmount int64
RefundRate float64
LastPaidAt time.Time
}
func (s *super) buildTenantHealthItems(ctx context.Context, list []*models.Tenant) ([]super_dto.TenantHealthItem, error) {
if len(list) == 0 {
return []super_dto.TenantHealthItem{}, nil
}
tenantIDs := make([]int64, 0, len(list))
ownerIDs := make(map[int64]struct{}, len(list))
for _, t := range list {
tenantIDs = append(tenantIDs, t.ID)
ownerIDs[t.UserID] = struct{}{}
}
// 查询租户所有者信息。
ownerMap := make(map[int64]*models.User, len(ownerIDs))
if len(ownerIDs) > 0 {
ids := make([]int64, 0, len(ownerIDs))
for id := range ownerIDs {
ids = append(ids, id)
}
tblUser, qUser := models.UserQuery.QueryContext(ctx)
users, err := qUser.Where(tblUser.ID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
ownerMap[u.ID] = u
}
}
// 汇总租户成员数。
memberCountMap := make(map[int64]int64, len(list))
var memberRows []struct {
TenantID int64 `gorm:"column:tenant_id"`
Count int64 `gorm:"column:count"`
}
err := models.TenantUserQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.TenantUser{}).
Select("tenant_id, count(*) as count").
Where("tenant_id IN ?", tenantIDs).
Group("tenant_id").
Scan(&memberRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range memberRows {
memberCountMap[row.TenantID] = row.Count
}
// 汇总内容总量与发布量。
contentMap := make(map[int64]tenantHealthContentAgg, len(list))
contentRows := make([]tenantHealthContentAgg, 0)
err = models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select(
"tenant_id, count(*) as content_count, coalesce(sum(case when status = ? then 1 else 0 end), 0) as published_count",
consts.ContentStatusPublished,
).
Where("tenant_id IN ?", tenantIDs).
Group("tenant_id").
Scan(&contentRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range contentRows {
contentMap[row.TenantID] = row
}
// 汇总订单成交/退款指标。
orderMap := make(map[int64]tenantHealthOrderAgg, len(list))
orderRows := make([]tenantHealthOrderAgg, 0)
err = models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select(
"tenant_id, "+
"coalesce(sum(case when status = ? then 1 else 0 end), 0) as paid_count, "+
"coalesce(sum(case when status = ? then amount_paid else 0 end), 0) as paid_amount, "+
"coalesce(sum(case when status = ? then 1 else 0 end), 0) as refund_count, "+
"coalesce(sum(case when status = ? then amount_paid else 0 end), 0) as refund_amount, "+
"max(case when status = ? then paid_at else null end) as last_paid_at",
consts.OrderStatusPaid,
consts.OrderStatusPaid,
consts.OrderStatusRefunded,
consts.OrderStatusRefunded,
consts.OrderStatusPaid,
).
Where("tenant_id IN ? AND type = ?", tenantIDs, consts.OrderTypeContentPurchase).
Group("tenant_id").
Scan(&orderRows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range orderRows {
orderMap[row.TenantID] = row
}
items := make([]super_dto.TenantHealthItem, 0, len(list))
for _, t := range list {
contentAgg := contentMap[t.ID]
orderAgg := orderMap[t.ID]
refundRate := 0.0
if orderAgg.PaidCount > 0 {
refundRate = float64(orderAgg.RefundCount) / float64(orderAgg.PaidCount)
}
metrics := tenantHealthMetrics{
MemberCount: memberCountMap[t.ID],
ContentCount: contentAgg.ContentCount,
PublishedContentCount: contentAgg.PublishedCount,
PaidOrders: orderAgg.PaidCount,
PaidAmount: orderAgg.PaidAmount,
RefundOrders: orderAgg.RefundCount,
RefundAmount: orderAgg.RefundAmount,
RefundRate: refundRate,
LastPaidAt: orderAgg.LastPaidAt,
}
healthLevel, alerts := s.evaluateTenantHealth(t, metrics)
item := super_dto.TenantHealthItem{
TenantID: t.ID,
Code: t.Code,
Name: t.Name,
Status: t.Status,
StatusDescription: t.Status.Description(),
MemberCount: metrics.MemberCount,
ContentCount: metrics.ContentCount,
PublishedContentCount: metrics.PublishedContentCount,
PaidOrders: metrics.PaidOrders,
PaidAmount: metrics.PaidAmount,
RefundOrders: metrics.RefundOrders,
RefundAmount: metrics.RefundAmount,
RefundRate: metrics.RefundRate,
LastPaidAt: s.formatTime(metrics.LastPaidAt),
HealthLevel: healthLevel,
Alerts: alerts,
}
if owner := ownerMap[t.UserID]; owner != nil {
item.Owner = &super_dto.TenantOwnerUserLite{
ID: owner.ID,
Username: owner.Username,
}
}
items = append(items, item)
}
return items, nil
}
func (s *super) evaluateTenantHealth(tenant *models.Tenant, metrics tenantHealthMetrics) (string, []string) {
level := 0
alerts := make([]string, 0)
now := time.Now()
// 根据租户状态与过期情况判断风险级别。
if tenant.Status == consts.TenantStatusBanned {
level = 2
alerts = append(alerts, "租户已封禁")
} else if tenant.Status == consts.TenantStatusPendingVerify {
if level < 1 {
level = 1
}
alerts = append(alerts, "租户待审核")
}
if !tenant.ExpiredAt.IsZero() && tenant.ExpiredAt.Before(now) {
level = 2
alerts = append(alerts, "租户已过期")
}
// 内容与成交基础判断。
if metrics.PublishedContentCount == 0 {
if level < 1 {
level = 1
}
alerts = append(alerts, "无已发布内容")
}
if metrics.PaidOrders == 0 {
if level < 1 {
level = 1
}
alerts = append(alerts, "暂无成交")
} else if !metrics.LastPaidAt.IsZero() {
if metrics.LastPaidAt.Before(now.AddDate(0, 0, -90)) {
level = 2
alerts = append(alerts, "成交活跃度偏低")
} else if metrics.LastPaidAt.Before(now.AddDate(0, 0, -30)) {
if level < 1 {
level = 1
}
alerts = append(alerts, "成交活跃度偏低")
}
}
// 退款率异常判断。
if metrics.RefundRate >= 0.2 {
level = 2
alerts = append(alerts, "退款率偏高")
} else if metrics.RefundRate >= 0.1 {
if level < 1 {
level = 1
}
alerts = append(alerts, "退款率偏高")
}
switch level {
case 1:
return "warning", alerts
case 2:
return "risk", alerts
default:
return "healthy", alerts
}
}
func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperOrderListFilter{}
}
tbl, q := models.OrderQuery.QueryContext(ctx)
q = q.Where(tbl.Type.Eq(consts.OrderTypeWithdrawal))
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.AmountPaidMin != nil {
q = q.Where(tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
q = q.Where(tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
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 filter.PaidAtFrom != nil {
from, err := s.parseFilterTime(filter.PaidAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.PaidAt.Gte(*from))
}
}
if filter.PaidAtTo != nil {
to, err := s.parseFilterTime(filter.PaidAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.PaidAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "paid_at":
q = q.Order(tbl.PaidAt.Desc())
case "amount_paid":
q = q.Order(tbl.AmountPaid.Desc())
case "status":
q = q.Order(tbl.Status.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "paid_at":
q = q.Order(tbl.PaidAt)
case "amount_paid":
q = q.Order(tbl.AmountPaid)
case "status":
q = q.Order(tbl.Status)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.ID.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildSuperOrderItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListCoupons(ctx context.Context, filter *super_dto.SuperCouponListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperCouponListFilter{}
}
tbl, q := models.CouponQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" {
parsed, err := consts.ParseCouponType(strings.TrimSpace(*filter.Type))
if err != nil {
return nil, errorx.ErrInvalidParameter.WithCause(err).WithMsg("优惠券类型无效")
}
q = q.Where(tbl.Type.Eq(parsed))
}
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
keyword := strings.TrimSpace(*filter.Keyword)
q = q.Where(field.Or(
tbl.Title.Like("%"+keyword+"%"),
tbl.Description.Like("%"+keyword+"%"),
))
}
if filter.Status != nil && strings.TrimSpace(*filter.Status) != "" {
status := strings.ToLower(strings.TrimSpace(*filter.Status))
now := time.Now()
switch status {
case "active":
q = q.Where(field.Or(tbl.StartAt.Lte(now), tbl.StartAt.IsNull()))
q = q.Where(field.Or(tbl.EndAt.Gte(now), tbl.EndAt.IsNull()))
case "expired":
q = q.Where(tbl.EndAt.IsNotNull(), tbl.EndAt.Lt(now))
case "upcoming":
q = q.Where(tbl.StartAt.IsNotNull(), tbl.StartAt.Gt(now))
default:
return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效")
}
}
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))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "start_at":
q = q.Order(tbl.StartAt.Desc())
case "end_at":
q = q.Order(tbl.EndAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "start_at":
q = q.Order(tbl.StartAt)
case "end_at":
q = q.Order(tbl.EndAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.CreatedAt.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildSuperCouponItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListCouponGrants(ctx context.Context, filter *super_dto.SuperCouponGrantListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperCouponGrantListFilter{}
}
tbl, q := models.UserCouponQuery.QueryContext(ctx)
if filter.UserID != nil && *filter.UserID > 0 {
q = q.Where(tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username)
if err != nil {
return nil, err
}
if userFilter {
if len(userIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.UserID.In(userIDs...))
}
}
couponIDs, couponFilter, err := s.filterCouponGrantCouponIDs(ctx, filter)
if err != nil {
return nil, err
}
if couponFilter {
if len(couponIDs) == 0 {
filter.Pagination.Format()
return &requests.Pager{
Pagination: filter.Pagination,
Total: 0,
Items: []super_dto.SuperCouponGrantItem{},
}, nil
}
q = q.Where(tbl.CouponID.In(couponIDs...))
}
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 filter.UsedAtFrom != nil {
from, err := s.parseFilterTime(filter.UsedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.UsedAt.Gte(*from))
}
}
if filter.UsedAtTo != nil {
to, err := s.parseFilterTime(filter.UsedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.UsedAt.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)
}
if len(list) == 0 {
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: []super_dto.SuperCouponGrantItem{},
}, nil
}
couponIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, uc := range list {
if uc.CouponID > 0 {
couponIDSet[uc.CouponID] = struct{}{}
}
if uc.UserID > 0 {
userIDSet[uc.UserID] = struct{}{}
}
}
couponIDs = couponIDs[:0]
for id := range couponIDSet {
couponIDs = append(couponIDs, id)
}
userIDs = userIDs[:0]
for id := range userIDSet {
userIDs = append(userIDs, id)
}
couponMap := make(map[int64]*models.Coupon, len(couponIDs))
tenantMap := make(map[int64]*models.Tenant)
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)
}
tenantSet := make(map[int64]struct{})
for _, coupon := range coupons {
couponMap[coupon.ID] = coupon
if coupon.TenantID > 0 {
tenantSet[coupon.TenantID] = struct{}{}
}
}
tenantIDs := make([]int64, 0, len(tenantSet))
for id := range tenantSet {
tenantIDs = append(tenantIDs, id)
}
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
}
}
}
userMap := make(map[int64]*models.User, len(userIDs))
if len(userIDs) > 0 {
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, user := range users {
userMap[user.ID] = user
}
}
items := make([]super_dto.SuperCouponGrantItem, 0, len(list))
for _, uc := range list {
item := super_dto.SuperCouponGrantItem{
ID: uc.ID,
CouponID: uc.CouponID,
UserID: uc.UserID,
Status: uc.Status,
StatusDescription: uc.Status.Description(),
OrderID: uc.OrderID,
UsedAt: s.formatTime(uc.UsedAt),
CreatedAt: s.formatTime(uc.CreatedAt),
}
if user := userMap[uc.UserID]; user != nil {
item.Username = user.Username
} else if uc.UserID > 0 {
item.Username = "ID:" + strconv.FormatInt(uc.UserID, 10)
}
if coupon := couponMap[uc.CouponID]; coupon != nil {
item.CouponTitle = coupon.Title
item.TenantID = coupon.TenantID
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) UpdateCouponStatus(ctx context.Context, operatorID, couponID int64, form *super_dto.SuperCouponStatusUpdateForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if couponID == 0 || form == nil {
return errorx.ErrBadRequest.WithMsg("参数无效")
}
status := strings.ToLower(strings.TrimSpace(form.Status))
if status != "frozen" {
return errorx.ErrBadRequest.WithMsg("仅支持冻结操作")
}
tbl, q := models.CouponQuery.QueryContext(ctx)
coupon, err := q.Where(tbl.ID.Eq(couponID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
}
return errorx.ErrDatabaseError.WithCause(err)
}
now := time.Now()
if !coupon.EndAt.IsZero() && now.After(coupon.EndAt) {
return nil
}
_, err = q.Where(tbl.ID.Eq(coupon.ID)).UpdateSimple(
tbl.EndAt.Value(now),
tbl.UpdatedAt.Value(now),
)
if err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "freeze_coupon", cast.ToString(coupon.ID), "Freeze coupon")
}
return nil
}
func (s *super) ReportOverview(ctx context.Context, filter *super_dto.SuperReportOverviewFilter) (*v1_dto.ReportOverviewResponse, error) {
// 统一统计时间范围与粒度。
rg, err := s.normalizeReportRange(filter)
if err != nil {
return nil, err
}
tenantID := int64(0)
if filter != nil && filter.TenantID != nil {
tenantID = *filter.TenantID
}
// 统计累计曝光(全量累计值,暂无按时间拆分的曝光记录)。
var totalViews int64
contentQuery := models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select("coalesce(sum(views), 0)")
if tenantID > 0 {
contentQuery = contentQuery.Where("tenant_id = ?", tenantID)
}
if err := contentQuery.Scan(&totalViews).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 订单仅统计内容购买类型,并按状态划分已支付/已退款。
paidCount, paidAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg)
if err != nil {
return nil, err
}
refundCount, refundAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg)
if err != nil {
return nil, err
}
conversionRate := 0.0
if totalViews > 0 {
conversionRate = float64(paidCount) / float64(totalViews)
}
paidSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg)
if err != nil {
return nil, err
}
refundSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg)
if err != nil {
return nil, err
}
items := make([]v1_dto.ReportOverviewItem, 0)
for day := rg.startDay; !day.After(rg.endDay); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
paidItem := paidSeries[key]
refundItem := refundSeries[key]
items = append(items, v1_dto.ReportOverviewItem{
Date: key,
PaidOrders: paidItem.Count,
PaidAmount: float64(paidItem.Amount) / 100.0,
RefundOrders: refundItem.Count,
RefundAmount: float64(refundItem.Amount) / 100.0,
})
}
return &v1_dto.ReportOverviewResponse{
Summary: v1_dto.ReportSummary{
TotalViews: totalViews,
PaidOrders: paidCount,
PaidAmount: float64(paidAmount) / 100.0,
RefundOrders: refundCount,
RefundAmount: float64(refundAmount) / 100.0,
ConversionRate: conversionRate,
},
Items: items,
}, nil
}
func (s *super) ExportReport(ctx context.Context, form *super_dto.SuperReportExportForm) (*v1_dto.ReportExportResponse, error) {
if form == nil {
return nil, errorx.ErrBadRequest.WithMsg("导出参数不能为空")
}
format := strings.ToLower(strings.TrimSpace(form.Format))
if format == "" {
format = "csv"
}
if format != "csv" {
return nil, errorx.ErrBadRequest.WithMsg("仅支持 CSV 导出")
}
overview, err := s.ReportOverview(ctx, &super_dto.SuperReportOverviewFilter{
TenantID: form.TenantID,
StartAt: form.StartAt,
EndAt: form.EndAt,
Granularity: form.Granularity,
})
if err != nil {
return nil, err
}
builder := &strings.Builder{}
builder.WriteString("date,paid_orders,paid_amount,refund_orders,refund_amount\n")
for _, item := range overview.Items {
builder.WriteString(item.Date)
builder.WriteString(",")
builder.WriteString(strconv.FormatInt(item.PaidOrders, 10))
builder.WriteString(",")
builder.WriteString(formatAmount(item.PaidAmount))
builder.WriteString(",")
builder.WriteString(strconv.FormatInt(item.RefundOrders, 10))
builder.WriteString(",")
builder.WriteString(formatAmount(item.RefundAmount))
builder.WriteString("\n")
}
filename := "report_overview_" + time.Now().Format("20060102_150405") + ".csv"
return &v1_dto.ReportExportResponse{
Filename: filename,
MimeType: "text/csv",
Content: builder.String(),
}, nil
}
func (s *super) reportOrderAggregate(
ctx context.Context,
tenantID int64,
status consts.OrderStatus,
timeField string,
rg reportRange,
) (int64, int64, error) {
var total struct {
Count int64 `gorm:"column:count"`
Amount int64 `gorm:"column:amount"`
}
query := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("count(*) as count, coalesce(sum(amount_paid), 0) as amount").
Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Scan(&total).Error; err != nil {
return 0, 0, errorx.ErrDatabaseError.WithCause(err)
}
return total.Count, total.Amount, nil
}
func (s *super) reportOrderSeries(
ctx context.Context,
tenantID int64,
status consts.OrderStatus,
timeField string,
rg reportRange,
) (map[string]reportAggRow, error) {
rows := make([]reportAggRow, 0)
query := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("date_trunc('day', "+timeField+") as day, count(*) as count, coalesce(sum(amount_paid), 0) as amount").
Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Group("day").Scan(&rows).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
result := make(map[string]reportAggRow, len(rows))
for _, row := range rows {
key := row.Day.Format("2006-01-02")
result[key] = row
}
return result, nil
}
func (s *super) normalizeReportRange(filter *super_dto.SuperReportOverviewFilter) (reportRange, error) {
granularity := "day"
if filter != nil && filter.Granularity != nil && strings.TrimSpace(*filter.Granularity) != "" {
granularity = strings.ToLower(strings.TrimSpace(*filter.Granularity))
}
if granularity != "day" {
return reportRange{}, errorx.ErrBadRequest.WithMsg("仅支持按天统计")
}
now := time.Now()
endAt := now
if filter != nil && filter.EndAt != nil && strings.TrimSpace(*filter.EndAt) != "" {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.EndAt))
if err != nil {
return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间格式错误")
}
endAt = parsed
}
startAt := endAt.AddDate(0, 0, -6)
if filter != nil && filter.StartAt != nil && strings.TrimSpace(*filter.StartAt) != "" {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.StartAt))
if err != nil {
return reportRange{}, errorx.ErrBadRequest.WithMsg("开始时间格式错误")
}
startAt = parsed
}
startDay := time.Date(startAt.Year(), startAt.Month(), startAt.Day(), 0, 0, 0, 0, startAt.Location())
endDay := time.Date(endAt.Year(), endAt.Month(), endAt.Day(), 0, 0, 0, 0, endAt.Location())
if endDay.Before(startDay) {
return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间不能早于开始时间")
}
endNext := endDay.AddDate(0, 0, 1)
return reportRange{
startDay: startDay,
endDay: endDay,
endNext: endNext,
}, nil
}
func (s *super) buildSuperCouponItems(ctx context.Context, list []*models.Coupon) ([]super_dto.SuperCouponItem, error) {
if len(list) == 0 {
return []super_dto.SuperCouponItem{}, nil
}
tenantIDs := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, c := range list {
if c == nil {
continue
}
if _, ok := seen[c.TenantID]; ok {
continue
}
seen[c.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, c.TenantID)
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tbl, q := models.TenantQuery.QueryContext(ctx)
tenants, err := q.Where(tbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, t := range tenants {
tenantMap[t.ID] = t
}
}
items := make([]super_dto.SuperCouponItem, 0, len(list))
for _, c := range list {
if c == nil {
continue
}
items = append(items, s.toSuperCouponItem(c, tenantMap[c.TenantID]))
}
return items, nil
}
func (s *super) toSuperCouponItem(c *models.Coupon, tenant *models.Tenant) super_dto.SuperCouponItem {
status, statusDescription := s.resolveCouponStatus(c)
item := super_dto.SuperCouponItem{
ID: c.ID,
TenantID: c.TenantID,
Title: c.Title,
Description: c.Description,
Type: c.Type,
TypeDescription: c.Type.Description(),
Value: c.Value,
MinOrderAmount: c.MinOrderAmount,
MaxDiscount: c.MaxDiscount,
TotalQuantity: c.TotalQuantity,
UsedQuantity: c.UsedQuantity,
Status: status,
StatusDescription: statusDescription,
CreatedAt: s.formatTime(c.CreatedAt),
UpdatedAt: s.formatTime(c.UpdatedAt),
}
if tenant != nil {
item.TenantCode = tenant.Code
item.TenantName = tenant.Name
}
if !c.StartAt.IsZero() {
item.StartAt = s.formatTime(c.StartAt)
}
if !c.EndAt.IsZero() {
item.EndAt = s.formatTime(c.EndAt)
}
return item
}
func (s *super) resolveCouponStatus(c *models.Coupon) (string, string) {
now := time.Now()
if !c.EndAt.IsZero() && c.EndAt.Before(now) {
return "expired", "已过期"
}
if !c.StartAt.IsZero() && c.StartAt.After(now) {
return "upcoming", "未开始"
}
return "active", "生效中"
}
func (s *super) ApproveWithdrawal(ctx context.Context, operatorID, id int64) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
if o.Status != consts.OrderStatusCreated {
return errorx.ErrStatusConflict.WithMsg("订单状态不正确")
}
// Mark as Paid (Assumes external transfer done)
_, err = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).Updates(&models.Order{
Status: consts.OrderStatusPaid,
PaidAt: time.Now(),
UpdatedAt: time.Now(),
})
if err == nil && Audit != nil {
Audit.Log(ctx, operatorID, "approve_withdrawal", cast.ToString(id), "Approved withdrawal")
}
return err
}
func (s *super) userOwnedTenantCount(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var rows []struct {
UserID int64 `gorm:"column:user_id"`
Count int64 `gorm:"column:count"`
}
err := models.TenantQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Tenant{}).
Select("user_id, count(*) as count").
Where("user_id IN ?", userIDs).
Group("user_id").
Scan(&rows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range rows {
result[row.UserID] = row.Count
}
return result, nil
}
func (s *super) userJoinedTenantCount(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var rows []struct {
UserID int64 `gorm:"column:user_id"`
Count int64 `gorm:"column:count"`
}
err := models.TenantUserQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.TenantUser{}).
Select("user_id, count(*) as count").
Where("user_id IN ?", userIDs).
Group("user_id").
Scan(&rows).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, row := range rows {
result[row.UserID] = row.Count
}
return result, nil
}
func (s *super) userMapByTenantUsers(ctx context.Context, list []*models.TenantUser) (map[int64]*models.User, error) {
userIDs := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, tu := range list {
if _, ok := seen[tu.UserID]; ok {
continue
}
seen[tu.UserID] = struct{}{}
userIDs = append(userIDs, tu.UserID)
}
userMap := make(map[int64]*models.User, len(userIDs))
if len(userIDs) == 0 {
return userMap, nil
}
tblUser, qUser := models.UserQuery.QueryContext(ctx)
users, err := qUser.Where(tblUser.ID.In(userIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
userMap[u.ID] = u
}
return userMap, nil
}
func (s *super) toSuperTenantUserDTO(tu *models.TenantUser) *super_dto.TenantUser {
if tu == nil {
return nil
}
return &super_dto.TenantUser{
ID: tu.ID,
TenantID: tu.TenantID,
UserID: tu.UserID,
Role: tu.Role,
Status: tu.Status,
CreatedAt: s.formatTime(tu.CreatedAt),
UpdatedAt: s.formatTime(tu.UpdatedAt),
}
}
func (s *super) tenantMapsForTenantUsers(ctx context.Context, list []*models.TenantUser) (map[int64]*models.Tenant, map[int64]*models.User, error) {
tenantIDs := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, tu := range list {
if _, ok := seen[tu.TenantID]; ok {
continue
}
seen[tu.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, tu.TenantID)
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
ownerMap := make(map[int64]*models.User, len(tenantIDs))
if len(tenantIDs) == 0 {
return tenantMap, ownerMap, nil
}
tblTenant, qTenant := models.TenantQuery.QueryContext(ctx)
tenants, err := qTenant.Where(tblTenant.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, nil, errorx.ErrDatabaseError.WithCause(err)
}
ownerIDs := make([]int64, 0, len(tenants))
ownerSeen := make(map[int64]struct{}, len(tenants))
for _, t := range tenants {
tenantMap[t.ID] = t
if _, ok := ownerSeen[t.UserID]; ok {
continue
}
ownerSeen[t.UserID] = struct{}{}
ownerIDs = append(ownerIDs, t.UserID)
}
userMap := make(map[int64]*models.User, len(ownerIDs))
if len(ownerIDs) > 0 {
tblUser, qUser := models.UserQuery.QueryContext(ctx)
users, err := qUser.Where(tblUser.ID.In(ownerIDs...)).Find()
if err != nil {
return nil, nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, u := range users {
userMap[u.ID] = u
}
}
for tenantID, tenant := range tenantMap {
if tenant == nil {
continue
}
if owner := userMap[tenant.UserID]; owner != nil {
ownerMap[tenantID] = owner
}
}
return tenantMap, ownerMap, nil
}
func (s *super) formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
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) filterCouponGrantCouponIDs(ctx context.Context, filter *super_dto.SuperCouponGrantListFilter) ([]int64, bool, error) {
if filter == nil {
return nil, false, nil
}
if filter.CouponID != nil && *filter.CouponID > 0 {
return []int64{*filter.CouponID}, true, nil
}
tenantIDs := make([]int64, 0)
applied := false
if filter.TenantID != nil && *filter.TenantID > 0 {
applied = true
tenantIDs = append(tenantIDs, *filter.TenantID)
}
lookupIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, true, err
}
if tenantFilter {
applied = true
tenantIDs = append(tenantIDs, lookupIDs...)
}
if !applied {
return nil, false, nil
}
if len(tenantIDs) == 0 {
return []int64{}, true, nil
}
couponTbl, couponQuery := models.CouponQuery.QueryContext(ctx)
coupons, err := couponQuery.Where(couponTbl.TenantID.In(tenantIDs...)).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("缺少操作者信息")
}
err := models.Q.Transaction(func(tx *models.Query) error {
o, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
if o.Status != consts.OrderStatusCreated {
return errorx.ErrStatusConflict.WithMsg("订单状态不正确")
}
// Refund User Balance
_, err = tx.User.WithContext(ctx).Where(tx.User.ID.Eq(o.UserID)).Update(tx.User.Balance, gorm.Expr("balance + ?", o.AmountPaid))
if err != nil {
return err
}
// Update Order
_, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).Updates(&models.Order{
Status: consts.OrderStatusFailed, // or Canceled
RefundReason: reason,
UpdatedAt: time.Now(),
})
if err != nil {
return err
}
// Create Ledger (Adjustment/Unfreeze)
ledger := &models.TenantLedger{
TenantID: o.TenantID,
UserID: o.UserID,
OrderID: o.ID,
Type: consts.TenantLedgerTypeAdjustment,
Amount: o.AmountPaid,
Remark: "提现拒绝返还: " + reason,
OperatorUserID: operatorID,
IdempotencyKey: uuid.NewString(),
}
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
return err
}
return nil
})
if err == nil && Audit != nil {
Audit.Log(ctx, operatorID, "reject_withdrawal", cast.ToString(id), "Rejected: "+reason)
}
return err
}