package services import ( "context" "encoding/json" "errors" "path/filepath" "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) ListAssets(ctx context.Context, filter *super_dto.SuperAssetListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperAssetListFilter{} } tbl, q := models.MediaAssetQuery.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.Provider != nil && strings.TrimSpace(*filter.Provider) != "" { q = q.Where(tbl.Provider.Eq(strings.TrimSpace(*filter.Provider))) } if filter.ObjectKey != nil && strings.TrimSpace(*filter.ObjectKey) != "" { keyword := "%" + strings.TrimSpace(*filter.ObjectKey) + "%" q = q.Where(tbl.ObjectKey.Like(keyword)) } 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.SizeMin != nil { // JSONB 元信息内的 size 需要使用原生表达式过滤。 q = q.Where(field.NewUnsafeFieldRaw("coalesce((meta->>'size')::bigint,0) >= ?", *filter.SizeMin)) } if filter.SizeMax != nil { // JSONB 元信息内的 size 需要使用原生表达式过滤。 q = q.Where(field.NewUnsafeFieldRaw("coalesce((meta->>'size')::bigint,0) <= ?", *filter.SizeMax)) } 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()) } 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) } 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) } if len(list) == 0 { return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: []super_dto.SuperAssetItem{}, }, nil } tenantSet := make(map[int64]struct{}) userSet := make(map[int64]struct{}) assetIDs := make([]int64, 0, len(list)) for _, asset := range list { assetIDs = append(assetIDs, asset.ID) if asset.TenantID > 0 { tenantSet[asset.TenantID] = struct{}{} } if asset.UserID > 0 { userSet[asset.UserID] = struct{}{} } } tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) if len(tenantSet) > 0 { ids := make([]int64, 0, len(tenantSet)) for id := range tenantSet { ids = append(ids, id) } tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).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(userSet)) if len(userSet) > 0 { ids := make([]int64, 0, len(userSet)) for id := range userSet { ids = append(ids, id) } userTbl, userQuery := models.UserQuery.QueryContext(ctx) users, err := userQuery.Where(userTbl.ID.In(ids...)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } for _, user := range users { userMap[user.ID] = user } } usedCountMap := make(map[int64]int64) if len(assetIDs) > 0 { type assetUseRow struct { AssetID int64 `gorm:"column:asset_id"` Count int64 `gorm:"column:count"` } rows := make([]assetUseRow, 0) query := models.ContentAssetQuery.WithContext(ctx). UnderlyingDB(). Model(&models.ContentAsset{}). Select("asset_id, count(*) as count"). Where("asset_id in ?", assetIDs). Group("asset_id") if err := query.Scan(&rows).Error; err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } for _, row := range rows { usedCountMap[row.AssetID] = row.Count } } items := make([]super_dto.SuperAssetItem, 0, len(list)) for _, asset := range list { meta := asset.Meta.Data() filename := strings.TrimSpace(meta.Filename) if filename == "" { filename = filepath.Base(asset.ObjectKey) } url := "" if Common != nil { url = Common.GetAssetURL(asset.ObjectKey) } item := super_dto.SuperAssetItem{ ID: asset.ID, TenantID: asset.TenantID, UserID: asset.UserID, Type: asset.Type, Status: asset.Status, Provider: asset.Provider, Bucket: asset.Bucket, ObjectKey: asset.ObjectKey, URL: url, Filename: filename, Size: meta.Size, Hash: asset.Hash, Variant: asset.Variant, SourceAssetID: asset.SourceAssetID, UsedCount: usedCountMap[asset.ID], CreatedAt: s.formatTime(asset.CreatedAt), UpdatedAt: s.formatTime(asset.UpdatedAt), } if tenant := tenantMap[asset.TenantID]; tenant != nil { item.TenantCode = tenant.Code item.TenantName = tenant.Name } if user := userMap[asset.UserID]; user != nil { item.Username = user.Username } else if asset.UserID > 0 { item.Username = "ID:" + strconv.FormatInt(asset.UserID, 10) } items = append(items, item) } return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: items, }, nil } func (s *super) AssetUsage(ctx context.Context, filter *super_dto.SuperAssetUsageFilter) (*super_dto.SuperAssetUsageResponse, error) { tenantID := int64(0) if filter != nil && filter.TenantID != nil { tenantID = *filter.TenantID } tbl, q := models.MediaAssetQuery.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) } var totalSize int64 sizeQuery := models.MediaAssetQuery.WithContext(ctx). UnderlyingDB(). Model(&models.MediaAsset{}). Select("coalesce(sum((meta->>'size')::bigint), 0) as size") if tenantID > 0 { sizeQuery = sizeQuery.Where("tenant_id = ?", tenantID) } if err := sizeQuery.Scan(&totalSize).Error; err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } type usageRow struct { Type string `gorm:"column:type"` Count int64 `gorm:"column:count"` Size int64 `gorm:"column:size"` } rows := make([]usageRow, 0) typeQuery := models.MediaAssetQuery.WithContext(ctx). UnderlyingDB(). Model(&models.MediaAsset{}). Select("type, count(*) as count, coalesce(sum((meta->>'size')::bigint), 0) as size"). Group("type") if tenantID > 0 { typeQuery = typeQuery.Where("tenant_id = ?", tenantID) } if err := typeQuery.Scan(&rows).Error; err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } byType := make([]super_dto.SuperAssetUsageItem, 0, len(rows)) for _, row := range rows { byType = append(byType, super_dto.SuperAssetUsageItem{ Type: consts.MediaAssetType(row.Type), Count: row.Count, TotalSize: row.Size, }) } return &super_dto.SuperAssetUsageResponse{ TotalCount: total, TotalSize: totalSize, ByType: byType, }, nil } func (s *super) DeleteAsset(ctx context.Context, assetID int64, force bool) error { if assetID <= 0 { return errorx.ErrBadRequest.WithMsg("资产ID不能为空") } tbl, q := models.MediaAssetQuery.QueryContext(ctx) asset, err := q.Where(tbl.ID.Eq(assetID)).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorx.ErrRecordNotFound.WithMsg("资产不存在") } return errorx.ErrDatabaseError.WithCause(err) } useTbl, useQuery := models.ContentAssetQuery.QueryContext(ctx) usedCount, err := useQuery.Where(useTbl.AssetID.Eq(assetID)).Count() if err != nil { return errorx.ErrDatabaseError.WithCause(err) } if usedCount > 0 && !force { return errorx.ErrStatusConflict.WithMsg("资产已被内容引用,无法删除") } if usedCount > 0 && force { // 强制删除时先清理引用关系,避免残留无效关联。 if _, err := useQuery.Where(useTbl.AssetID.Eq(assetID)).Delete(); err != nil { return errorx.ErrDatabaseError.WithCause(err) } } if _, err := q.Where(tbl.ID.Eq(assetID)).Delete(); err != nil { return errorx.ErrDatabaseError.WithCause(err) } if Common != nil && asset.ObjectKey != "" { count, err := models.MediaAssetQuery.WithContext(ctx). Where(models.MediaAssetQuery.ObjectKey.Eq(asset.ObjectKey)). Count() if err != nil { return errorx.ErrDatabaseError.WithCause(err) } if count == 0 && Common.storage != nil { _ = Common.storage.Delete(asset.ObjectKey) } } return nil } func (s *super) ListNotifications(ctx context.Context, filter *super_dto.SuperNotificationListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperNotificationListFilter{} } tbl, q := models.NotificationQuery.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.String())) } if filter.IsRead != nil { q = q.Where(tbl.IsRead.Is(*filter.IsRead)) } if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" q = q.Where(field.Or(tbl.Title.Like(keyword), tbl.Content.Like(keyword))) } 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)) } } 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()) } 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) } 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) } if len(list) == 0 { return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: []super_dto.SuperNotificationItem{}, }, nil } tenantSet := make(map[int64]struct{}) userSet := make(map[int64]struct{}) for _, n := range list { if n.TenantID > 0 { tenantSet[n.TenantID] = struct{}{} } if n.UserID > 0 { userSet[n.UserID] = struct{}{} } } tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) if len(tenantSet) > 0 { ids := make([]int64, 0, len(tenantSet)) for id := range tenantSet { ids = append(ids, id) } tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).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(userSet)) if len(userSet) > 0 { ids := make([]int64, 0, len(userSet)) for id := range userSet { ids = append(ids, id) } userTbl, userQuery := models.UserQuery.QueryContext(ctx) users, err := userQuery.Where(userTbl.ID.In(ids...)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } for _, user := range users { userMap[user.ID] = user } } items := make([]super_dto.SuperNotificationItem, 0, len(list)) for _, n := range list { item := super_dto.SuperNotificationItem{ ID: n.ID, TenantID: n.TenantID, UserID: n.UserID, Type: consts.NotificationType(n.Type), Title: n.Title, Content: n.Content, IsRead: n.IsRead, CreatedAt: s.formatTime(n.CreatedAt), } if tenant := tenantMap[n.TenantID]; tenant != nil { item.TenantCode = tenant.Code item.TenantName = tenant.Name } if user := userMap[n.UserID]; user != nil { item.Username = user.Username } else if n.UserID > 0 { item.Username = "ID:" + strconv.FormatInt(n.UserID, 10) } items = append(items, item) } return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: items, }, nil } func (s *super) BroadcastNotifications(ctx context.Context, form *super_dto.SuperNotificationBroadcastForm) error { if form == nil { return errorx.ErrBadRequest.WithMsg("群发参数不能为空") } title := strings.TrimSpace(form.Title) content := strings.TrimSpace(form.Content) if title == "" || content == "" { return errorx.ErrBadRequest.WithMsg("通知标题和内容不能为空") } if !form.Type.IsValid() { return errorx.ErrBadRequest.WithMsg("通知类型非法") } tenantID := int64(0) if form.TenantID != nil { tenantID = *form.TenantID } userSet := make(map[int64]struct{}) for _, id := range form.UserIDs { if id <= 0 { continue } userSet[id] = struct{}{} } if len(userSet) == 0 && tenantID <= 0 { return errorx.ErrBadRequest.WithMsg("请指定租户或用户") } if len(userSet) == 0 && tenantID > 0 { // 仅向该租户的已验证成员发送。 tbl, q := models.TenantUserQuery.QueryContext(ctx) list, err := q.Where(tbl.TenantID.Eq(tenantID), tbl.Status.Eq(consts.UserStatusVerified)).Find() if err != nil { return errorx.ErrDatabaseError.WithCause(err) } for _, tu := range list { if tu.UserID > 0 { userSet[tu.UserID] = struct{}{} } } } if len(userSet) == 0 { return errorx.ErrRecordNotFound.WithMsg("未找到可通知的用户") } if Notification == nil { return errorx.ErrInternalError.WithMsg("通知服务不可用") } typ := form.Type.String() for userID := range userSet { if err := Notification.Send(ctx, tenantID, userID, typ, title, content); err != nil { return err } } return nil } func (s *super) ListNotificationTemplates(ctx context.Context, filter *super_dto.SuperNotificationTemplateListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperNotificationTemplateListFilter{} } tbl, q := models.NotificationTemplateQuery.QueryContext(ctx) if filter.TenantID != nil && *filter.TenantID >= 0 { q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) } if filter.Type != nil && *filter.Type != "" { q = q.Where(tbl.Type.Eq(*filter.Type)) } if filter.IsActive != nil { q = q.Where(tbl.IsActive.Is(*filter.IsActive)) } if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" q = q.Where(field.Or(tbl.Name.Like(keyword), tbl.Title.Like(keyword))) } 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()) } 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) } 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) } if len(list) == 0 { return &requests.Pager{ Pagination: filter.Pagination, Total: total, Items: []super_dto.SuperNotificationTemplateItem{}, }, nil } tenantSet := make(map[int64]struct{}) for _, tmpl := range list { if tmpl.TenantID > 0 { tenantSet[tmpl.TenantID] = struct{}{} } } tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) if len(tenantSet) > 0 { ids := make([]int64, 0, len(tenantSet)) for id := range tenantSet { ids = append(ids, id) } tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } for _, tenant := range tenants { tenantMap[tenant.ID] = tenant } } items := make([]super_dto.SuperNotificationTemplateItem, 0, len(list)) for _, tmpl := range list { item := super_dto.SuperNotificationTemplateItem{ ID: tmpl.ID, TenantID: tmpl.TenantID, Name: tmpl.Name, Type: tmpl.Type, Title: tmpl.Title, Content: tmpl.Content, IsActive: tmpl.IsActive, CreatedAt: s.formatTime(tmpl.CreatedAt), UpdatedAt: s.formatTime(tmpl.UpdatedAt), } if tenant := tenantMap[tmpl.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) CreateNotificationTemplate(ctx context.Context, form *super_dto.SuperNotificationTemplateCreateForm) (*super_dto.SuperNotificationTemplateItem, error) { if form == nil { return nil, errorx.ErrBadRequest.WithMsg("模板参数不能为空") } name := strings.TrimSpace(form.Name) title := strings.TrimSpace(form.Title) content := strings.TrimSpace(form.Content) if name == "" || title == "" || content == "" { return nil, errorx.ErrBadRequest.WithMsg("模板名称、标题和内容不能为空") } if !form.Type.IsValid() { return nil, errorx.ErrBadRequest.WithMsg("通知类型非法") } tenantID := int64(0) if form.TenantID != nil { tenantID = *form.TenantID } isActive := true if form.IsActive != nil { isActive = *form.IsActive } tmpl := &models.NotificationTemplate{ TenantID: tenantID, Name: name, Type: form.Type, Title: title, Content: content, IsActive: isActive, } if err := models.NotificationTemplateQuery.WithContext(ctx).Create(tmpl); err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } item := &super_dto.SuperNotificationTemplateItem{ ID: tmpl.ID, TenantID: tmpl.TenantID, Name: tmpl.Name, Type: tmpl.Type, Title: tmpl.Title, Content: tmpl.Content, IsActive: tmpl.IsActive, CreatedAt: s.formatTime(tmpl.CreatedAt), UpdatedAt: s.formatTime(tmpl.UpdatedAt), } if tmpl.TenantID > 0 { tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) tenant, err := tenantQuery.Where(tenantTbl.ID.Eq(tmpl.TenantID)).First() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrDatabaseError.WithCause(err) } if tenant != nil { item.TenantCode = tenant.Code item.TenantName = tenant.Name } } return item, 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 }