Files
quyun-v2/backend/app/services/super.go
2026-01-15 11:10:43 +08:00

3100 lines
84 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"
"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(),
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
},
Balance: u.Balance,
BalanceFrozen: u.BalanceFrozen,
}, 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) 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) 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) 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) 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) 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
}