feat: wire superadmin p1 data

This commit is contained in:
2026-01-15 09:35:16 +08:00
parent bb4c5b39d2
commit 235a216b0c
21 changed files with 3188 additions and 28 deletions

View File

@@ -0,0 +1,28 @@
package v1
import (
dto "quyun/v2/app/http/super/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"github.com/gofiber/fiber/v3"
)
// @provider
type coupons struct{}
// List coupons
//
// @Router /super/v1/coupons [get]
// @Summary List coupons
// @Description List coupon templates across tenants
// @Tags Coupon
// @Accept json
// @Produce json
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.SuperCouponItem}
// @Bind filter query
func (c *coupons) List(ctx fiber.Ctx, filter *dto.SuperCouponListFilter) (*requests.Pager, error) {
return services.Super.ListCoupons(ctx, filter)
}

View File

@@ -0,0 +1,28 @@
package v1
import (
dto "quyun/v2/app/http/super/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"github.com/gofiber/fiber/v3"
)
// @provider
type creators struct{}
// List creators
//
// @Router /super/v1/creators [get]
// @Summary List creators
// @Description List creator tenants (channels) across the platform
// @Tags Creator
// @Accept json
// @Produce json
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.TenantItem}
// @Bind filter query
func (c *creators) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.Pager, error) {
return services.Super.ListTenants(ctx, filter)
}

View File

@@ -0,0 +1,75 @@
package dto
import (
"quyun/v2/app/requests"
"quyun/v2/pkg/consts"
)
// SuperCouponListFilter 超管优惠券列表过滤条件。
type SuperCouponListFilter struct {
requests.Pagination
// ID 优惠券ID精确匹配。
ID *int64 `query:"id"`
// TenantID 租户ID精确匹配。
TenantID *int64 `query:"tenant_id"`
// TenantCode 租户编码,模糊匹配。
TenantCode *string `query:"tenant_code"`
// TenantName 租户名称,模糊匹配。
TenantName *string `query:"tenant_name"`
// Keyword 标题或描述关键词,模糊匹配。
Keyword *string `query:"keyword"`
// Type 优惠券类型过滤fix_amount/discount
Type *string `query:"type"`
// Status 状态过滤active/expired/upcoming
Status *string `query:"status"`
// CreatedAtFrom 创建时间起始RFC3339
CreatedAtFrom *string `query:"created_at_from"`
// CreatedAtTo 创建时间结束RFC3339
CreatedAtTo *string `query:"created_at_to"`
// Asc 升序字段id/created_at/start_at/end_at
Asc *string `query:"asc"`
// Desc 降序字段id/created_at/start_at/end_at
Desc *string `query:"desc"`
}
// SuperCouponItem 超管优惠券列表项。
type SuperCouponItem struct {
// ID 优惠券ID。
ID int64 `json:"id"`
// TenantID 租户ID。
TenantID int64 `json:"tenant_id"`
// TenantCode 租户编码。
TenantCode string `json:"tenant_code"`
// TenantName 租户名称。
TenantName string `json:"tenant_name"`
// Title 优惠券标题。
Title string `json:"title"`
// Description 优惠券描述。
Description string `json:"description"`
// Type 优惠券类型。
Type consts.CouponType `json:"type"`
// TypeDescription 类型描述(用于展示)。
TypeDescription string `json:"type_description"`
// Value 优惠券面额/折扣值。
Value int64 `json:"value"`
// MinOrderAmount 最低订单金额门槛。
MinOrderAmount int64 `json:"min_order_amount"`
// MaxDiscount 最大折扣金额(折扣券)。
MaxDiscount int64 `json:"max_discount"`
// TotalQuantity 总发行数量0 表示不限量)。
TotalQuantity int32 `json:"total_quantity"`
// UsedQuantity 已使用数量。
UsedQuantity int32 `json:"used_quantity"`
// Status 状态active/expired/upcoming
Status string `json:"status"`
// StatusDescription 状态描述(用于展示)。
StatusDescription string `json:"status_description"`
// StartAt 生效时间RFC3339
StartAt string `json:"start_at"`
// EndAt 结束时间RFC3339
EndAt string `json:"end_at"`
// CreatedAt 创建时间RFC3339
CreatedAt string `json:"created_at"`
// UpdatedAt 更新时间RFC3339
UpdatedAt string `json:"updated_at"`
}

View File

@@ -0,0 +1,27 @@
package dto
// SuperReportOverviewFilter 超管报表查询条件。
type SuperReportOverviewFilter struct {
// TenantID 租户ID不传代表全平台
TenantID *int64 `query:"tenant_id"`
// StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。
StartAt *string `query:"start_at"`
// EndAt 统计结束时间RFC3339可选默认当前时间
EndAt *string `query:"end_at"`
// Granularity 统计粒度day目前仅支持 day
Granularity *string `query:"granularity"`
}
// SuperReportExportForm 超管报表导出参数。
type SuperReportExportForm struct {
// TenantID 租户ID不传代表全平台
TenantID *int64 `json:"tenant_id"`
// StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。
StartAt *string `json:"start_at"`
// EndAt 统计结束时间RFC3339可选默认当前时间
EndAt *string `json:"end_at"`
// Granularity 统计粒度day目前仅支持 day
Granularity *string `json:"granularity"`
// Format 导出格式(仅支持 csv
Format string `json:"format"`
}

View File

@@ -0,0 +1,7 @@
package dto
// SuperWithdrawalRejectForm 超管驳回提现表单。
type SuperWithdrawalRejectForm struct {
// Reason 驳回原因。
Reason string `json:"reason" validate:"required"`
}

View File

@@ -17,6 +17,20 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*coupons, error) {
obj := &coupons{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*creators, error) {
obj := &creators{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*orders, error) {
obj := &orders{}
@@ -24,19 +38,34 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*reports, error) {
obj := &reports{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
contents *contents,
coupons *coupons,
creators *creators,
middlewares *middlewares.Middlewares,
orders *orders,
reports *reports,
tenants *tenants,
users *users,
withdrawals *withdrawals,
) (contracts.HttpRoute, error) {
obj := &Routes{
contents: contents,
coupons: coupons,
creators: creators,
middlewares: middlewares,
orders: orders,
reports: reports,
tenants: tenants,
users: users,
withdrawals: withdrawals,
}
if err := obj.Prepare(); err != nil {
return nil, err
@@ -60,5 +89,12 @@ func Provide(opts ...opt.Option) error {
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*withdrawals, error) {
obj := &withdrawals{}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,41 @@
package v1
import (
dto "quyun/v2/app/http/super/v1/dto"
v1_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/app/services"
"github.com/gofiber/fiber/v3"
)
// @provider
type reports struct{}
// Report overview
//
// @Router /super/v1/reports/overview [get]
// @Summary Report overview
// @Description Get platform report overview
// @Tags Report
// @Accept json
// @Produce json
// @Success 200 {object} v1_dto.ReportOverviewResponse
// @Bind filter query
func (c *reports) Overview(ctx fiber.Ctx, filter *dto.SuperReportOverviewFilter) (*v1_dto.ReportOverviewResponse, error) {
return services.Super.ReportOverview(ctx, filter)
}
// Export report
//
// @Router /super/v1/reports/export [post]
// @Summary Export report
// @Description Export platform report data
// @Tags Report
// @Accept json
// @Produce json
// @Param form body dto.SuperReportExportForm true "Export form"
// @Success 200 {object} v1_dto.ReportExportResponse
// @Bind form body
func (c *reports) Export(ctx fiber.Ctx, form *dto.SuperReportExportForm) (*v1_dto.ReportExportResponse, error) {
return services.Super.ExportReport(ctx, form)
}

View File

@@ -24,10 +24,14 @@ type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
contents *contents
orders *orders
tenants *tenants
users *users
contents *contents
coupons *coupons
creators *creators
orders *orders
reports *reports
tenants *tenants
users *users
withdrawals *withdrawals
}
// Prepare initializes the routes provider with logging configuration.
@@ -71,6 +75,18 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("id"),
Body[dto.SuperContentReviewForm]("form"),
))
// Register routes for controller: coupons
r.log.Debugf("Registering route: Get /super/v1/coupons -> coupons.List")
router.Get("/super/v1/coupons"[len(r.Path()):], DataFunc1(
r.coupons.List,
Query[dto.SuperCouponListFilter]("filter"),
))
// Register routes for controller: creators
r.log.Debugf("Registering route: Get /super/v1/creators -> creators.List")
router.Get("/super/v1/creators"[len(r.Path()):], DataFunc1(
r.creators.List,
Query[dto.TenantListFilter]("filter"),
))
// Register routes for controller: orders
r.log.Debugf("Registering route: Get /super/v1/orders -> orders.List")
router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1(
@@ -92,6 +108,17 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("id"),
Body[dto.SuperOrderRefundForm]("form"),
))
// Register routes for controller: reports
r.log.Debugf("Registering route: Get /super/v1/reports/overview -> reports.Overview")
router.Get("/super/v1/reports/overview"[len(r.Path()):], DataFunc1(
r.reports.Overview,
Query[dto.SuperReportOverviewFilter]("filter"),
))
r.log.Debugf("Registering route: Post /super/v1/reports/export -> reports.Export")
router.Post("/super/v1/reports/export"[len(r.Path()):], DataFunc1(
r.reports.Export,
Body[dto.SuperReportExportForm]("form"),
))
// Register routes for controller: tenants
r.log.Debugf("Registering route: Get /super/v1/tenants -> tenants.List")
router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1(
@@ -172,6 +199,25 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("id"),
Body[dto.UserStatusUpdateForm]("form"),
))
// Register routes for controller: withdrawals
r.log.Debugf("Registering route: Get /super/v1/withdrawals -> withdrawals.List")
router.Get("/super/v1/withdrawals"[len(r.Path()):], DataFunc1(
r.withdrawals.List,
Query[dto.SuperOrderListFilter]("filter"),
))
r.log.Debugf("Registering route: Post /super/v1/withdrawals/:id<int>/approve -> withdrawals.Approve")
router.Post("/super/v1/withdrawals/:id<int>/approve"[len(r.Path()):], Func2(
r.withdrawals.Approve,
Local[*models.User]("__ctx_user"),
PathParam[int64]("id"),
))
r.log.Debugf("Registering route: Post /super/v1/withdrawals/:id<int>/reject -> withdrawals.Reject")
router.Post("/super/v1/withdrawals/:id<int>/reject"[len(r.Path()):], Func3(
r.withdrawals.Reject,
Local[*models.User]("__ctx_user"),
PathParam[int64]("id"),
Body[dto.SuperWithdrawalRejectForm]("form"),
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,63 @@
package v1
import (
dto "quyun/v2/app/http/super/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
"github.com/gofiber/fiber/v3"
)
// @provider
type withdrawals struct{}
// List withdrawals
//
// @Router /super/v1/withdrawals [get]
// @Summary List withdrawals
// @Description List withdrawal orders across tenants
// @Tags Finance
// @Accept json
// @Produce json
// @Param page query int false "Page number"
// @Param limit query int false "Page size"
// @Success 200 {object} requests.Pager{items=[]dto.SuperOrderItem}
// @Bind filter query
func (c *withdrawals) List(ctx fiber.Ctx, filter *dto.SuperOrderListFilter) (*requests.Pager, error) {
return services.Super.ListWithdrawals(ctx, filter)
}
// Approve withdrawal
//
// @Router /super/v1/withdrawals/:id<int>/approve [post]
// @Summary Approve withdrawal
// @Description Approve a withdrawal request
// @Tags Finance
// @Accept json
// @Produce json
// @Param id path int64 true "Withdrawal order ID"
// @Success 200 {string} string "Approved"
// @Bind user local key(__ctx_user)
// @Bind id path
func (c *withdrawals) Approve(ctx fiber.Ctx, user *models.User, id int64) error {
return services.Super.ApproveWithdrawal(ctx, user.ID, id)
}
// Reject withdrawal
//
// @Router /super/v1/withdrawals/:id<int>/reject [post]
// @Summary Reject withdrawal
// @Description Reject a withdrawal request
// @Tags Finance
// @Accept json
// @Produce json
// @Param id path int64 true "Withdrawal order ID"
// @Param form body dto.SuperWithdrawalRejectForm true "Reject form"
// @Success 200 {string} string "Rejected"
// @Bind user local key(__ctx_user)
// @Bind id path
// @Bind form body
func (c *withdrawals) Reject(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperWithdrawalRejectForm) error {
return services.Super.RejectWithdrawal(ctx, user.ID, id, form.Reason)
}

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"errors"
"strconv"
"strings"
"time"
@@ -2201,16 +2202,134 @@ func (s *super) evaluateTenantHealth(tenant *models.Tenant, metrics tenantHealth
}
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)).Order(tbl.ID.Desc()).Find()
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
@@ -2226,6 +2345,436 @@ func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrde
}, nil
}
func (s *super) ListCoupons(ctx context.Context, filter *super_dto.SuperCouponListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperCouponListFilter{}
}
tbl, q := models.CouponQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" {
parsed, err := consts.ParseCouponType(strings.TrimSpace(*filter.Type))
if err != nil {
return nil, errorx.ErrInvalidParameter.WithCause(err).WithMsg("优惠券类型无效")
}
q = q.Where(tbl.Type.Eq(parsed))
}
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
keyword := strings.TrimSpace(*filter.Keyword)
q = q.Where(field.Or(
tbl.Title.Like("%"+keyword+"%"),
tbl.Description.Like("%"+keyword+"%"),
))
}
if filter.Status != nil && strings.TrimSpace(*filter.Status) != "" {
status := strings.ToLower(strings.TrimSpace(*filter.Status))
now := time.Now()
switch status {
case "active":
q = q.Where(field.Or(tbl.StartAt.Lte(now), tbl.StartAt.IsNull()))
q = q.Where(field.Or(tbl.EndAt.Gte(now), tbl.EndAt.IsNull()))
case "expired":
q = q.Where(tbl.EndAt.IsNotNull(), tbl.EndAt.Lt(now))
case "upcoming":
q = q.Where(tbl.StartAt.IsNotNull(), tbl.StartAt.Gt(now))
default:
return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效")
}
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
case "start_at":
q = q.Order(tbl.StartAt.Desc())
case "end_at":
q = q.Order(tbl.EndAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "created_at":
q = q.Order(tbl.CreatedAt)
case "start_at":
q = q.Order(tbl.StartAt)
case "end_at":
q = q.Order(tbl.EndAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.CreatedAt.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
items, err := s.buildSuperCouponItems(ctx, list)
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ReportOverview(ctx context.Context, filter *super_dto.SuperReportOverviewFilter) (*v1_dto.ReportOverviewResponse, error) {
// 统一统计时间范围与粒度。
rg, err := s.normalizeReportRange(filter)
if err != nil {
return nil, err
}
tenantID := int64(0)
if filter != nil && filter.TenantID != nil {
tenantID = *filter.TenantID
}
// 统计累计曝光(全量累计值,暂无按时间拆分的曝光记录)。
var totalViews int64
contentQuery := models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select("coalesce(sum(views), 0)")
if tenantID > 0 {
contentQuery = contentQuery.Where("tenant_id = ?", tenantID)
}
if err := contentQuery.Scan(&totalViews).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 订单仅统计内容购买类型,并按状态划分已支付/已退款。
paidCount, paidAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg)
if err != nil {
return nil, err
}
refundCount, refundAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg)
if err != nil {
return nil, err
}
conversionRate := 0.0
if totalViews > 0 {
conversionRate = float64(paidCount) / float64(totalViews)
}
paidSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg)
if err != nil {
return nil, err
}
refundSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg)
if err != nil {
return nil, err
}
items := make([]v1_dto.ReportOverviewItem, 0)
for day := rg.startDay; !day.After(rg.endDay); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
paidItem := paidSeries[key]
refundItem := refundSeries[key]
items = append(items, v1_dto.ReportOverviewItem{
Date: key,
PaidOrders: paidItem.Count,
PaidAmount: float64(paidItem.Amount) / 100.0,
RefundOrders: refundItem.Count,
RefundAmount: float64(refundItem.Amount) / 100.0,
})
}
return &v1_dto.ReportOverviewResponse{
Summary: v1_dto.ReportSummary{
TotalViews: totalViews,
PaidOrders: paidCount,
PaidAmount: float64(paidAmount) / 100.0,
RefundOrders: refundCount,
RefundAmount: float64(refundAmount) / 100.0,
ConversionRate: conversionRate,
},
Items: items,
}, nil
}
func (s *super) ExportReport(ctx context.Context, form *super_dto.SuperReportExportForm) (*v1_dto.ReportExportResponse, error) {
if form == nil {
return nil, errorx.ErrBadRequest.WithMsg("导出参数不能为空")
}
format := strings.ToLower(strings.TrimSpace(form.Format))
if format == "" {
format = "csv"
}
if format != "csv" {
return nil, errorx.ErrBadRequest.WithMsg("仅支持 CSV 导出")
}
overview, err := s.ReportOverview(ctx, &super_dto.SuperReportOverviewFilter{
TenantID: form.TenantID,
StartAt: form.StartAt,
EndAt: form.EndAt,
Granularity: form.Granularity,
})
if err != nil {
return nil, err
}
builder := &strings.Builder{}
builder.WriteString("date,paid_orders,paid_amount,refund_orders,refund_amount\n")
for _, item := range overview.Items {
builder.WriteString(item.Date)
builder.WriteString(",")
builder.WriteString(strconv.FormatInt(item.PaidOrders, 10))
builder.WriteString(",")
builder.WriteString(formatAmount(item.PaidAmount))
builder.WriteString(",")
builder.WriteString(strconv.FormatInt(item.RefundOrders, 10))
builder.WriteString(",")
builder.WriteString(formatAmount(item.RefundAmount))
builder.WriteString("\n")
}
filename := "report_overview_" + time.Now().Format("20060102_150405") + ".csv"
return &v1_dto.ReportExportResponse{
Filename: filename,
MimeType: "text/csv",
Content: builder.String(),
}, nil
}
func (s *super) reportOrderAggregate(
ctx context.Context,
tenantID int64,
status consts.OrderStatus,
timeField string,
rg reportRange,
) (int64, int64, error) {
var total struct {
Count int64 `gorm:"column:count"`
Amount int64 `gorm:"column:amount"`
}
query := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("count(*) as count, coalesce(sum(amount_paid), 0) as amount").
Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Scan(&total).Error; err != nil {
return 0, 0, errorx.ErrDatabaseError.WithCause(err)
}
return total.Count, total.Amount, nil
}
func (s *super) reportOrderSeries(
ctx context.Context,
tenantID int64,
status consts.OrderStatus,
timeField string,
rg reportRange,
) (map[string]reportAggRow, error) {
rows := make([]reportAggRow, 0)
query := models.OrderQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Order{}).
Select("date_trunc('day', "+timeField+") as day, count(*) as count, coalesce(sum(amount_paid), 0) as amount").
Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Group("day").Scan(&rows).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
result := make(map[string]reportAggRow, len(rows))
for _, row := range rows {
key := row.Day.Format("2006-01-02")
result[key] = row
}
return result, nil
}
func (s *super) normalizeReportRange(filter *super_dto.SuperReportOverviewFilter) (reportRange, error) {
granularity := "day"
if filter != nil && filter.Granularity != nil && strings.TrimSpace(*filter.Granularity) != "" {
granularity = strings.ToLower(strings.TrimSpace(*filter.Granularity))
}
if granularity != "day" {
return reportRange{}, errorx.ErrBadRequest.WithMsg("仅支持按天统计")
}
now := time.Now()
endAt := now
if filter != nil && filter.EndAt != nil && strings.TrimSpace(*filter.EndAt) != "" {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.EndAt))
if err != nil {
return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间格式错误")
}
endAt = parsed
}
startAt := endAt.AddDate(0, 0, -6)
if filter != nil && filter.StartAt != nil && strings.TrimSpace(*filter.StartAt) != "" {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.StartAt))
if err != nil {
return reportRange{}, errorx.ErrBadRequest.WithMsg("开始时间格式错误")
}
startAt = parsed
}
startDay := time.Date(startAt.Year(), startAt.Month(), startAt.Day(), 0, 0, 0, 0, startAt.Location())
endDay := time.Date(endAt.Year(), endAt.Month(), endAt.Day(), 0, 0, 0, 0, endAt.Location())
if endDay.Before(startDay) {
return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间不能早于开始时间")
}
endNext := endDay.AddDate(0, 0, 1)
return reportRange{
startDay: startDay,
endDay: endDay,
endNext: endNext,
}, nil
}
func (s *super) buildSuperCouponItems(ctx context.Context, list []*models.Coupon) ([]super_dto.SuperCouponItem, error) {
if len(list) == 0 {
return []super_dto.SuperCouponItem{}, nil
}
tenantIDs := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, c := range list {
if c == nil {
continue
}
if _, ok := seen[c.TenantID]; ok {
continue
}
seen[c.TenantID] = struct{}{}
tenantIDs = append(tenantIDs, c.TenantID)
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tbl, q := models.TenantQuery.QueryContext(ctx)
tenants, err := q.Where(tbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, t := range tenants {
tenantMap[t.ID] = t
}
}
items := make([]super_dto.SuperCouponItem, 0, len(list))
for _, c := range list {
if c == nil {
continue
}
items = append(items, s.toSuperCouponItem(c, tenantMap[c.TenantID]))
}
return items, nil
}
func (s *super) toSuperCouponItem(c *models.Coupon, tenant *models.Tenant) super_dto.SuperCouponItem {
status, statusDescription := s.resolveCouponStatus(c)
item := super_dto.SuperCouponItem{
ID: c.ID,
TenantID: c.TenantID,
Title: c.Title,
Description: c.Description,
Type: c.Type,
TypeDescription: c.Type.Description(),
Value: c.Value,
MinOrderAmount: c.MinOrderAmount,
MaxDiscount: c.MaxDiscount,
TotalQuantity: c.TotalQuantity,
UsedQuantity: c.UsedQuantity,
Status: status,
StatusDescription: statusDescription,
CreatedAt: s.formatTime(c.CreatedAt),
UpdatedAt: s.formatTime(c.UpdatedAt),
}
if tenant != nil {
item.TenantCode = tenant.Code
item.TenantName = tenant.Name
}
if !c.StartAt.IsZero() {
item.StartAt = s.formatTime(c.StartAt)
}
if !c.EndAt.IsZero() {
item.EndAt = s.formatTime(c.EndAt)
}
return item
}
func (s *super) resolveCouponStatus(c *models.Coupon) (string, string) {
now := time.Now()
if !c.EndAt.IsZero() && c.EndAt.Before(now) {
return "expired", "已过期"
}
if !c.StartAt.IsZero() && c.StartAt.After(now) {
return "upcoming", "未开始"
}
return "active", "生效中"
}
func (s *super) ApproveWithdrawal(ctx context.Context, operatorID, id int64) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")

View File

@@ -175,6 +175,110 @@ const docTemplate = `{
}
}
},
"/super/v1/coupons": {
"get": {
"description": "List coupon templates across tenants",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Coupon"
],
"summary": "List coupons",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperCouponItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/creators": {
"get": {
"description": "List creator tenants (channels) across the platform",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Creator"
],
"summary": "List creators",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.TenantItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/orders": {
"get": {
"description": "List orders",
@@ -325,6 +429,63 @@ const docTemplate = `{
}
}
},
"/super/v1/reports/export": {
"post": {
"description": "Export platform report data",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Report"
],
"summary": "Export report",
"parameters": [
{
"description": "Export form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperReportExportForm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ReportExportResponse"
}
}
}
}
},
"/super/v1/reports/overview": {
"get": {
"description": "Get platform report overview",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Report"
],
"summary": "Report overview",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ReportOverviewResponse"
}
}
}
}
},
"/super/v1/tenants": {
"get": {
"description": "List tenants",
@@ -1065,6 +1226,133 @@ const docTemplate = `{
}
}
},
"/super/v1/withdrawals": {
"get": {
"description": "List withdrawal orders across tenants",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "List withdrawals",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperOrderItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/withdrawals/{id}/approve": {
"post": {
"description": "Approve a withdrawal request",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "Approve withdrawal",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "Withdrawal order ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Approved",
"schema": {
"type": "string"
}
}
}
}
},
"/super/v1/withdrawals/{id}/reject": {
"post": {
"description": "Reject a withdrawal request",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "Reject withdrawal",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "Withdrawal order ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Reject form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperWithdrawalRejectForm"
}
}
],
"responses": {
"200": {
"description": "Rejected",
"schema": {
"type": "string"
}
}
}
}
},
"/t/{tenantCode}/v1/auth/login": {
"post": {
"description": "Login or register user using phone number and OTP",
@@ -3822,6 +4110,17 @@ const docTemplate = `{
"ContentStatusBlocked"
]
},
"consts.CouponType": {
"type": "string",
"enum": [
"fix_amount",
"discount"
],
"x-enum-varnames": [
"CouponTypeFixAmount",
"CouponTypeDiscount"
]
},
"consts.Currency": {
"type": "string",
"enum": [
@@ -5240,6 +5539,91 @@ const docTemplate = `{
}
}
},
"dto.SuperCouponItem": {
"type": "object",
"properties": {
"created_at": {
"description": "CreatedAt 创建时间RFC3339。",
"type": "string"
},
"description": {
"description": "Description 优惠券描述。",
"type": "string"
},
"end_at": {
"description": "EndAt 结束时间RFC3339。",
"type": "string"
},
"id": {
"description": "ID 优惠券ID。",
"type": "integer"
},
"max_discount": {
"description": "MaxDiscount 最大折扣金额(折扣券)。",
"type": "integer"
},
"min_order_amount": {
"description": "MinOrderAmount 最低订单金额门槛。",
"type": "integer"
},
"start_at": {
"description": "StartAt 生效时间RFC3339。",
"type": "string"
},
"status": {
"description": "Status 状态active/expired/upcoming。",
"type": "string"
},
"status_description": {
"description": "StatusDescription 状态描述(用于展示)。",
"type": "string"
},
"tenant_code": {
"description": "TenantCode 租户编码。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID。",
"type": "integer"
},
"tenant_name": {
"description": "TenantName 租户名称。",
"type": "string"
},
"title": {
"description": "Title 优惠券标题。",
"type": "string"
},
"total_quantity": {
"description": "TotalQuantity 总发行数量0 表示不限量)。",
"type": "integer"
},
"type": {
"description": "Type 优惠券类型。",
"allOf": [
{
"$ref": "#/definitions/consts.CouponType"
}
]
},
"type_description": {
"description": "TypeDescription 类型描述(用于展示)。",
"type": "string"
},
"updated_at": {
"description": "UpdatedAt 更新时间RFC3339。",
"type": "string"
},
"used_quantity": {
"description": "UsedQuantity 已使用数量。",
"type": "integer"
},
"value": {
"description": "Value 优惠券面额/折扣值。",
"type": "integer"
}
}
},
"dto.SuperOrderDetail": {
"type": "object",
"properties": {
@@ -5397,6 +5781,31 @@ const docTemplate = `{
}
}
},
"dto.SuperReportExportForm": {
"type": "object",
"properties": {
"end_at": {
"description": "EndAt 统计结束时间RFC3339可选默认当前时间。",
"type": "string"
},
"format": {
"description": "Format 导出格式(仅支持 csv。",
"type": "string"
},
"granularity": {
"description": "Granularity 统计粒度day目前仅支持 day。",
"type": "string"
},
"start_at": {
"description": "StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID不传代表全平台。",
"type": "integer"
}
}
},
"dto.SuperTenantContentStatusUpdateForm": {
"type": "object",
"required": [
@@ -5482,6 +5891,18 @@ const docTemplate = `{
}
}
},
"dto.SuperWithdrawalRejectForm": {
"type": "object",
"required": [
"reason"
],
"properties": {
"reason": {
"description": "Reason 驳回原因。",
"type": "string"
}
}
},
"dto.TenantAdminUserLite": {
"type": "object",
"properties": {

View File

@@ -169,6 +169,110 @@
}
}
},
"/super/v1/coupons": {
"get": {
"description": "List coupon templates across tenants",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Coupon"
],
"summary": "List coupons",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperCouponItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/creators": {
"get": {
"description": "List creator tenants (channels) across the platform",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Creator"
],
"summary": "List creators",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.TenantItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/orders": {
"get": {
"description": "List orders",
@@ -319,6 +423,63 @@
}
}
},
"/super/v1/reports/export": {
"post": {
"description": "Export platform report data",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Report"
],
"summary": "Export report",
"parameters": [
{
"description": "Export form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperReportExportForm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ReportExportResponse"
}
}
}
}
},
"/super/v1/reports/overview": {
"get": {
"description": "Get platform report overview",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Report"
],
"summary": "Report overview",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ReportOverviewResponse"
}
}
}
}
},
"/super/v1/tenants": {
"get": {
"description": "List tenants",
@@ -1059,6 +1220,133 @@
}
}
},
"/super/v1/withdrawals": {
"get": {
"description": "List withdrawal orders across tenants",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "List withdrawals",
"parameters": [
{
"type": "integer",
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/requests.Pager"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperOrderItem"
}
}
}
}
]
}
}
}
}
},
"/super/v1/withdrawals/{id}/approve": {
"post": {
"description": "Approve a withdrawal request",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "Approve withdrawal",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "Withdrawal order ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Approved",
"schema": {
"type": "string"
}
}
}
}
},
"/super/v1/withdrawals/{id}/reject": {
"post": {
"description": "Reject a withdrawal request",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Finance"
],
"summary": "Reject withdrawal",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "Withdrawal order ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Reject form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SuperWithdrawalRejectForm"
}
}
],
"responses": {
"200": {
"description": "Rejected",
"schema": {
"type": "string"
}
}
}
}
},
"/t/{tenantCode}/v1/auth/login": {
"post": {
"description": "Login or register user using phone number and OTP",
@@ -3816,6 +4104,17 @@
"ContentStatusBlocked"
]
},
"consts.CouponType": {
"type": "string",
"enum": [
"fix_amount",
"discount"
],
"x-enum-varnames": [
"CouponTypeFixAmount",
"CouponTypeDiscount"
]
},
"consts.Currency": {
"type": "string",
"enum": [
@@ -5234,6 +5533,91 @@
}
}
},
"dto.SuperCouponItem": {
"type": "object",
"properties": {
"created_at": {
"description": "CreatedAt 创建时间RFC3339。",
"type": "string"
},
"description": {
"description": "Description 优惠券描述。",
"type": "string"
},
"end_at": {
"description": "EndAt 结束时间RFC3339。",
"type": "string"
},
"id": {
"description": "ID 优惠券ID。",
"type": "integer"
},
"max_discount": {
"description": "MaxDiscount 最大折扣金额(折扣券)。",
"type": "integer"
},
"min_order_amount": {
"description": "MinOrderAmount 最低订单金额门槛。",
"type": "integer"
},
"start_at": {
"description": "StartAt 生效时间RFC3339。",
"type": "string"
},
"status": {
"description": "Status 状态active/expired/upcoming。",
"type": "string"
},
"status_description": {
"description": "StatusDescription 状态描述(用于展示)。",
"type": "string"
},
"tenant_code": {
"description": "TenantCode 租户编码。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID。",
"type": "integer"
},
"tenant_name": {
"description": "TenantName 租户名称。",
"type": "string"
},
"title": {
"description": "Title 优惠券标题。",
"type": "string"
},
"total_quantity": {
"description": "TotalQuantity 总发行数量0 表示不限量)。",
"type": "integer"
},
"type": {
"description": "Type 优惠券类型。",
"allOf": [
{
"$ref": "#/definitions/consts.CouponType"
}
]
},
"type_description": {
"description": "TypeDescription 类型描述(用于展示)。",
"type": "string"
},
"updated_at": {
"description": "UpdatedAt 更新时间RFC3339。",
"type": "string"
},
"used_quantity": {
"description": "UsedQuantity 已使用数量。",
"type": "integer"
},
"value": {
"description": "Value 优惠券面额/折扣值。",
"type": "integer"
}
}
},
"dto.SuperOrderDetail": {
"type": "object",
"properties": {
@@ -5391,6 +5775,31 @@
}
}
},
"dto.SuperReportExportForm": {
"type": "object",
"properties": {
"end_at": {
"description": "EndAt 统计结束时间RFC3339可选默认当前时间。",
"type": "string"
},
"format": {
"description": "Format 导出格式(仅支持 csv。",
"type": "string"
},
"granularity": {
"description": "Granularity 统计粒度day目前仅支持 day。",
"type": "string"
},
"start_at": {
"description": "StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID不传代表全平台。",
"type": "integer"
}
}
},
"dto.SuperTenantContentStatusUpdateForm": {
"type": "object",
"required": [
@@ -5476,6 +5885,18 @@
}
}
},
"dto.SuperWithdrawalRejectForm": {
"type": "object",
"required": [
"reason"
],
"properties": {
"reason": {
"description": "Reason 驳回原因。",
"type": "string"
}
}
},
"dto.TenantAdminUserLite": {
"type": "object",
"properties": {

View File

@@ -14,6 +14,14 @@ definitions:
- ContentStatusPublished
- ContentStatusUnpublished
- ContentStatusBlocked
consts.CouponType:
enum:
- fix_amount
- discount
type: string
x-enum-varnames:
- CouponTypeFixAmount
- CouponTypeDiscount
consts.Currency:
enum:
- CNY
@@ -1021,6 +1029,67 @@ definitions:
description: Name 租户名称。
type: string
type: object
dto.SuperCouponItem:
properties:
created_at:
description: CreatedAt 创建时间RFC3339
type: string
description:
description: Description 优惠券描述。
type: string
end_at:
description: EndAt 结束时间RFC3339
type: string
id:
description: ID 优惠券ID。
type: integer
max_discount:
description: MaxDiscount 最大折扣金额(折扣券)。
type: integer
min_order_amount:
description: MinOrderAmount 最低订单金额门槛。
type: integer
start_at:
description: StartAt 生效时间RFC3339
type: string
status:
description: Status 状态active/expired/upcoming
type: string
status_description:
description: StatusDescription 状态描述(用于展示)。
type: string
tenant_code:
description: TenantCode 租户编码。
type: string
tenant_id:
description: TenantID 租户ID。
type: integer
tenant_name:
description: TenantName 租户名称。
type: string
title:
description: Title 优惠券标题。
type: string
total_quantity:
description: TotalQuantity 总发行数量0 表示不限量)。
type: integer
type:
allOf:
- $ref: '#/definitions/consts.CouponType'
description: Type 优惠券类型。
type_description:
description: TypeDescription 类型描述(用于展示)。
type: string
updated_at:
description: UpdatedAt 更新时间RFC3339
type: string
used_quantity:
description: UsedQuantity 已使用数量。
type: integer
value:
description: Value 优惠券面额/折扣值。
type: integer
type: object
dto.SuperOrderDetail:
properties:
buyer:
@@ -1119,6 +1188,24 @@ definitions:
description: Reason 退款原因说明。
type: string
type: object
dto.SuperReportExportForm:
properties:
end_at:
description: EndAt 统计结束时间RFC3339可选默认当前时间
type: string
format:
description: Format 导出格式(仅支持 csv
type: string
granularity:
description: Granularity 统计粒度day目前仅支持 day
type: string
start_at:
description: StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。
type: string
tenant_id:
description: TenantID 租户ID不传代表全平台
type: integer
type: object
dto.SuperTenantContentStatusUpdateForm:
properties:
status:
@@ -1172,6 +1259,14 @@ definitions:
description: VerifiedAt 实名认证时间RFC3339
type: string
type: object
dto.SuperWithdrawalRejectForm:
properties:
reason:
description: Reason 驳回原因。
type: string
required:
- reason
type: object
dto.TenantAdminUserLite:
properties:
id:
@@ -2043,6 +2138,68 @@ paths:
summary: Review content
tags:
- Content
/super/v1/coupons:
get:
consumes:
- application/json
description: List coupon templates across tenants
parameters:
- description: Page number
in: query
name: page
type: integer
- description: Page size
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/requests.Pager'
- properties:
items:
items:
$ref: '#/definitions/dto.SuperCouponItem'
type: array
type: object
summary: List coupons
tags:
- Coupon
/super/v1/creators:
get:
consumes:
- application/json
description: List creator tenants (channels) across the platform
parameters:
- description: Page number
in: query
name: page
type: integer
- description: Page size
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/requests.Pager'
- properties:
items:
items:
$ref: '#/definitions/dto.TenantItem'
type: array
type: object
summary: List creators
tags:
- Creator
/super/v1/orders:
get:
consumes:
@@ -2139,6 +2296,43 @@ paths:
summary: Order statistics
tags:
- Order
/super/v1/reports/export:
post:
consumes:
- application/json
description: Export platform report data
parameters:
- description: Export form
in: body
name: form
required: true
schema:
$ref: '#/definitions/dto.SuperReportExportForm'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.ReportExportResponse'
summary: Export report
tags:
- Report
/super/v1/reports/overview:
get:
consumes:
- application/json
description: Get platform report overview
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.ReportOverviewResponse'
summary: Report overview
tags:
- Report
/super/v1/tenants:
get:
consumes:
@@ -2612,6 +2806,87 @@ paths:
summary: User statuses
tags:
- User
/super/v1/withdrawals:
get:
consumes:
- application/json
description: List withdrawal orders across tenants
parameters:
- description: Page number
in: query
name: page
type: integer
- description: Page size
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/requests.Pager'
- properties:
items:
items:
$ref: '#/definitions/dto.SuperOrderItem'
type: array
type: object
summary: List withdrawals
tags:
- Finance
/super/v1/withdrawals/{id}/approve:
post:
consumes:
- application/json
description: Approve a withdrawal request
parameters:
- description: Withdrawal order ID
format: int64
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: Approved
schema:
type: string
summary: Approve withdrawal
tags:
- Finance
/super/v1/withdrawals/{id}/reject:
post:
consumes:
- application/json
description: Reject a withdrawal request
parameters:
- description: Withdrawal order ID
format: int64
in: path
name: id
required: true
type: integer
- description: Reject form
in: body
name: form
required: true
schema:
$ref: '#/definitions/dto.SuperWithdrawalRejectForm'
produces:
- application/json
responses:
"200":
description: Rejected
schema:
type: string
summary: Reject withdrawal
tags:
- Finance
/t/{tenantCode}/v1/auth/login:
post:
consumes:

View File

@@ -0,0 +1,44 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const CouponService = {
async listCoupons({ page, limit, id, tenant_id, tenant_code, tenant_name, keyword, type, status, created_at_from, created_at_to, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
keyword,
type,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/coupons', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
}
};

View File

@@ -0,0 +1,42 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const CreatorService = {
async listCreators({ page, limit, id, user_id, name, code, status, created_at_from, created_at_to, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
user_id,
name,
code,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/creators', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
}
};

View File

@@ -0,0 +1,59 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const FinanceService = {
async listWithdrawals({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to, paid_at_from, paid_at_to, amount_paid_min, amount_paid_max, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to),
paid_at_from: iso(paid_at_from),
paid_at_to: iso(paid_at_to),
amount_paid_min,
amount_paid_max
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/withdrawals', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async approveWithdrawal(id) {
if (!id) throw new Error('id is required');
return requestJson(`/super/v1/withdrawals/${id}/approve`, { method: 'POST' });
},
async rejectWithdrawal(id, { reason } = {}) {
if (!id) throw new Error('id is required');
return requestJson(`/super/v1/withdrawals/${id}/reject`, {
method: 'POST',
body: { reason }
});
}
};

View File

@@ -0,0 +1,39 @@
import { requestJson } from './apiClient';
export const ReportService = {
async getOverview({ tenant_id, start_at, end_at, granularity } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
tenant_id,
start_at: iso(start_at),
end_at: iso(end_at),
granularity
};
return requestJson('/super/v1/reports/overview', { query });
},
async exportReport({ tenant_id, start_at, end_at, granularity, format } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
return requestJson('/super/v1/reports/export', {
method: 'POST',
body: {
tenant_id,
start_at: iso(start_at),
end_at: iso(end_at),
granularity,
format: format || 'csv'
}
});
}
};

View File

@@ -1,11 +1,251 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { CouponService } from '@/service/CouponService';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
const endpoints = ['GET /super/v1/coupons', 'PATCH /super/v1/coupons/:id/status', 'GET /super/v1/coupon-grants'];
const toast = useToast();
const notes = ['Current coupon CRUD endpoints are tenant-scoped and tied to creator ownership.', 'Expose cross-tenant coupon listing before adding bulk actions.'];
const coupons = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const couponID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const keyword = ref('');
const type = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sortField = ref('created_at');
const sortOrder = ref(-1);
const typeOptions = [
{ label: '全部', value: '' },
{ label: '固定金额', value: 'fix_amount' },
{ label: '折扣', value: 'discount' }
];
const statusOptions = [
{ label: '全部', value: '' },
{ label: '生效中', value: 'active' },
{ label: '未开始', value: 'upcoming' },
{ label: '已过期', value: 'expired' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function getStatusSeverity(value) {
switch (value) {
case 'active':
return 'success';
case 'upcoming':
return 'warn';
case 'expired':
return 'danger';
default:
return 'secondary';
}
}
async function loadCoupons() {
loading.value = true;
try {
const result = await CouponService.listCoupons({
page: page.value,
limit: rows.value,
id: couponID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
keyword: keyword.value,
type: type.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
coupons.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载优惠券列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadCoupons();
}
function onReset() {
couponID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
keyword.value = '';
type.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'created_at';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadCoupons();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadCoupons();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadCoupons();
}
onMounted(() => {
loadCoupons();
});
</script>
<template>
<PendingPanel title="Coupons" description="Coupon management needs a super admin aggregation layer." :endpoints="endpoints" :notes="notes" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">优惠券</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="CouponID">
<InputNumber v-model="couponID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="关键词">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="keyword" placeholder="标题/描述" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="类型">
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="coupons"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="flex"
responsiveLayout="scroll"
>
<Column field="id" header="ID" sortable style="min-width: 6rem" />
<Column header="租户" style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
<span class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }}</span>
</div>
</template>
</Column>
<Column field="title" header="标题" sortable style="min-width: 16rem" />
<Column header="类型" style="min-width: 10rem">
<template #body="{ data }">
{{ data.type_description || data.type || '-' }}
</template>
</Column>
<Column field="value" header="面额/折扣" style="min-width: 10rem">
<template #body="{ data }">
<span v-if="data.type === 'discount'">{{ data.value ?? '-' }}%</span>
<span v-else>{{ formatCny(data.value) }}</span>
</template>
</Column>
<Column field="min_order_amount" header="门槛" style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.min_order_amount) }}
</template>
</Column>
<Column header="使用情况" style="min-width: 10rem">
<template #body="{ data }">
<span v-if="data.total_quantity === 0">不限量</span>
<span v-else>{{ data.used_quantity ?? 0 }} / {{ data.total_quantity ?? 0 }}</span>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column field="start_at" header="开始时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.start_at) }}
</template>
</Column>
<Column field="end_at" header="结束时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.end_at) }}
</template>
</Column>
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -1,18 +1,269 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { CreatorService } from '@/service/CreatorService';
import { TenantService } from '@/service/TenantService';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
const endpoints = [
'GET /super/v1/creators',
'GET /super/v1/creator-applications',
'POST /super/v1/creator-applications/:id/review',
'GET /super/v1/creator-members',
'POST /super/v1/creator-members/:id/review',
'POST /super/v1/creator-members/invite'
];
const toast = useToast();
const notes = ['Tenant-level creator endpoints require the tenant owner and are not usable from super admin today.', 'Keep creator approvals in the tenant admin portal until super admin APIs are added.'];
const creators = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const tenantID = ref(null);
const ownerUserID = ref(null);
const nameKeyword = ref('');
const codeKeyword = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sortField = ref('id');
const sortOrder = ref(-1);
const statusDialogVisible = ref(false);
const statusOptionsLoading = ref(false);
const statusOptions = ref([]);
const statusUpdating = ref(false);
const statusTenant = ref(null);
const statusValue = ref(null);
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function getStatusSeverity(value) {
switch (value) {
case 'verified':
return 'success';
case 'pending_verify':
return 'warn';
case 'banned':
return 'danger';
default:
return 'secondary';
}
}
async function ensureStatusOptionsLoaded() {
if (statusOptions.value.length > 0) return;
statusOptionsLoading.value = true;
try {
const list = await TenantService.getTenantStatuses();
statusOptions.value = (list || [])
.map((kv) => ({
label: kv?.value ?? kv?.key ?? '-',
value: kv?.key ?? ''
}))
.filter((item) => item.value);
} finally {
statusOptionsLoading.value = false;
}
}
async function loadCreators() {
loading.value = true;
try {
const result = await CreatorService.listCreators({
page: page.value,
limit: rows.value,
id: tenantID.value || undefined,
user_id: ownerUserID.value || undefined,
name: nameKeyword.value,
code: codeKeyword.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
creators.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载创作者列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadCreators();
}
function onReset() {
tenantID.value = null;
ownerUserID.value = null;
nameKeyword.value = '';
codeKeyword.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadCreators();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadCreators();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadCreators();
}
async function openStatusDialog(tenant) {
statusTenant.value = tenant;
statusValue.value = tenant?.status ?? null;
statusDialogVisible.value = true;
try {
await ensureStatusOptionsLoaded();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载状态选项', life: 4000 });
}
}
async function confirmUpdateStatus() {
const id = statusTenant.value?.id;
if (!id || !statusValue.value) return;
statusUpdating.value = true;
try {
await TenantService.updateTenantStatus({ tenantID: id, status: statusValue.value });
toast.add({ severity: 'success', summary: '更新成功', detail: `TenantID: ${id}`, life: 3000 });
statusDialogVisible.value = false;
await loadCreators();
} catch (error) {
toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新状态', life: 4000 });
} finally {
statusUpdating.value = false;
}
}
onMounted(() => {
loadCreators();
ensureStatusOptionsLoaded().catch(() => {});
});
</script>
<template>
<PendingPanel title="Creators" description="Super admin creator operations require cross-tenant APIs." :endpoints="endpoints" :notes="notes" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">创作者列表</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Owner UserID">
<InputNumber v-model="ownerUserID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="名称">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="nameKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="Code">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="codeKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="statusOptionsLoading" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="creators"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="flex"
responsiveLayout="scroll"
>
<Column field="id" header="ID" sortable style="min-width: 6rem" />
<Column field="code" header="Code" style="min-width: 10rem" />
<Column field="name" header="名称" sortable style="min-width: 16rem">
<template #body="{ data }">
<Button :label="data.name || '-'" icon="pi pi-external-link" text size="small" class="p-0" as="router-link" :to="`/superadmin/tenants/${data.id}`" />
</template>
</Column>
<Column field="owner.username" header="Owner" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
<span v-else class="text-muted-color">{{ data.user_id ?? '-' }}</span>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openStatusDialog(data)" />
</template>
</Column>
<Column field="user_count" header="成员数" style="min-width: 8rem" />
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '420px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">更新创作者状态</span>
<span class="text-muted-color truncate max-w-[240px]">{{ statusTenant?.name ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">状态</label>
<Select v-model="statusValue" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="选择状态" :disabled="statusUpdating" :loading="statusOptionsLoading" fluid />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="statusDialogVisible = false" :disabled="statusUpdating" />
<Button label="确认" icon="pi pi-check" @click="confirmUpdateStatus" :loading="statusUpdating" :disabled="!statusValue" />
</template>
</Dialog>
</template>

View File

@@ -1,11 +1,341 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { FinanceService } from '@/service/FinanceService';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
const endpoints = ['GET /super/v1/withdrawals', 'POST /super/v1/withdrawals/:id/approve', 'POST /super/v1/withdrawals/:id/reject', 'GET /super/v1/wallet-ledgers'];
const toast = useToast();
const notes = ['Withdrawals currently exist only in tenant creator APIs.', 'Add a super admin ledger view before exposing approvals.'];
const withdrawals = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const orderID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const userID = ref(null);
const username = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const paidAtFrom = ref(null);
const paidAtTo = ref(null);
const amountPaidMin = ref(null);
const amountPaidMax = ref(null);
const sortField = ref('id');
const sortOrder = ref(-1);
const statusOptions = [
{ label: '全部', value: '' },
{ label: 'created', value: 'created' },
{ label: 'paid', value: 'paid' },
{ label: 'failed', value: 'failed' }
];
const approveDialogVisible = ref(false);
const approveLoading = ref(false);
const approveOrder = ref(null);
const rejectDialogVisible = ref(false);
const rejectLoading = ref(false);
const rejectOrder = ref(null);
const rejectReason = ref('');
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function getStatusSeverity(value) {
switch (value) {
case 'paid':
return 'success';
case 'created':
return 'warn';
case 'failed':
return 'danger';
default:
return 'secondary';
}
}
async function loadWithdrawals() {
loading.value = true;
try {
const result = await FinanceService.listWithdrawals({
page: page.value,
limit: rows.value,
id: orderID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
user_id: userID.value || undefined,
username: username.value,
status: status.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
paid_at_from: paidAtFrom.value || undefined,
paid_at_to: paidAtTo.value || undefined,
amount_paid_min: amountPaidMin.value || undefined,
amount_paid_max: amountPaidMax.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
withdrawals.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载提现列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadWithdrawals();
}
function onReset() {
orderID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
userID.value = null;
username.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
paidAtFrom.value = null;
paidAtTo.value = null;
amountPaidMin.value = null;
amountPaidMax.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadWithdrawals();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadWithdrawals();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadWithdrawals();
}
function openApproveDialog(order) {
approveOrder.value = order;
approveDialogVisible.value = true;
}
async function confirmApprove() {
const id = approveOrder.value?.id;
if (!id) return;
approveLoading.value = true;
try {
await FinanceService.approveWithdrawal(id);
toast.add({ severity: 'success', summary: '已批准', detail: `订单ID: ${id}`, life: 3000 });
approveDialogVisible.value = false;
await loadWithdrawals();
} catch (error) {
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法批准提现', life: 4000 });
} finally {
approveLoading.value = false;
}
}
function openRejectDialog(order) {
rejectOrder.value = order;
rejectReason.value = '';
rejectDialogVisible.value = true;
}
async function confirmReject() {
const id = rejectOrder.value?.id;
if (!id || !rejectReason.value.trim()) return;
rejectLoading.value = true;
try {
await FinanceService.rejectWithdrawal(id, { reason: rejectReason.value.trim() });
toast.add({ severity: 'success', summary: '已驳回', detail: `订单ID: ${id}`, life: 3000 });
rejectDialogVisible.value = false;
await loadWithdrawals();
} catch (error) {
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法驳回提现', life: 4000 });
} finally {
rejectLoading.value = false;
}
}
loadWithdrawals();
</script>
<template>
<PendingPanel title="Finance" description="Withdrawals and wallet visibility require super admin endpoints." :endpoints="endpoints" :notes="notes" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">提现审核</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="OrderID">
<InputNumber v-model="orderID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="userID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="用户名">
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="username" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</IconField>
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="支付时间 From">
<DatePicker v-model="paidAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="支付时间 To">
<DatePicker v-model="paidAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="金额 Min">
<InputNumber v-model="amountPaidMin" :min="0" placeholder=">= 0" class="w-full" />
</SearchField>
<SearchField label="金额 Max">
<InputNumber v-model="amountPaidMax" :min="0" placeholder=">= 0" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="withdrawals"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="totalRecords"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50, 100]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
scrollable
scrollHeight="flex"
responsiveLayout="scroll"
>
<Column field="id" header="订单ID" sortable style="min-width: 7rem" />
<Column header="租户" style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant?.name || '-' }}</span>
<span class="text-xs text-muted-color">Code: {{ data.tenant?.code || '-' }}</span>
</div>
</template>
</Column>
<Column header="申请人" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="data.buyer?.username">{{ data.buyer.username }}</span>
<span v-else class="text-muted-color">{{ data.buyer?.id ?? '-' }}</span>
</template>
</Column>
<Column field="amount_paid" header="金额" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatCny(data.amount_paid) }}
</template>
</Column>
<Column field="status" header="状态" style="min-width: 10rem">
<template #body="{ data }">
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column field="created_at" header="创建时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="paid_at" header="支付时间" sortable style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.paid_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Button label="通过" icon="pi pi-check" size="small" severity="success" :disabled="data.status !== 'created'" @click="openApproveDialog(data)" />
<Button label="驳回" icon="pi pi-times" size="small" severity="danger" :disabled="data.status !== 'created'" @click="openRejectDialog(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="approveDialogVisible" :modal="true" :style="{ width: '420px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">确认通过提现吗</span>
</div>
</template>
<div class="text-sm text-muted-color">确认后将标记为已支付请确保外部打款已完成</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="approveDialogVisible = false" :disabled="approveLoading" />
<Button label="确认通过" icon="pi pi-check" severity="success" @click="confirmApprove" :loading="approveLoading" />
</template>
</Dialog>
<Dialog v-model:visible="rejectDialogVisible" :modal="true" :style="{ width: '520px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">驳回提现申请</span>
</div>
</template>
<div class="flex flex-col gap-4">
<div>
<label class="block font-medium mb-2">驳回原因</label>
<InputText v-model="rejectReason" placeholder="请输入驳回原因" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="rejectDialogVisible = false" :disabled="rejectLoading" />
<Button label="确认驳回" icon="pi pi-check" severity="danger" @click="confirmReject" :loading="rejectLoading" :disabled="!rejectReason.trim()" />
</template>
</Dialog>
</template>

View File

@@ -1,11 +1,149 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { ReportService } from '@/service/ReportService';
import { useToast } from 'primevue/usetoast';
import { computed, ref } from 'vue';
const endpoints = ['GET /super/v1/reports/overview', 'GET /super/v1/reports/series', 'POST /super/v1/reports/export'];
const toast = useToast();
const notes = ['Current report APIs are scoped to creators in tenant context.', 'Add cross-tenant aggregation before wiring charts and exports.'];
const overview = ref(null);
const loading = ref(false);
const tenantID = ref(null);
const startAt = ref(null);
const endAt = ref(null);
const granularity = ref('day');
const granularityOptions = [{ label: '按天', value: 'day' }];
function formatPercent(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) return '-';
return `${(rate * 100).toFixed(2)}%`;
}
function formatCnyFromYuan(amount) {
const value = Number(amount);
if (!Number.isFinite(value)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value);
}
const summaryItems = computed(() => {
const summary = overview.value?.summary;
if (!summary) return [];
return [
{ key: 'views', label: '累计曝光:', value: summary.total_views ?? 0, icon: 'pi-eye' },
{ key: 'paid-orders', label: '已支付订单:', value: summary.paid_orders ?? 0, icon: 'pi-shopping-cart' },
{ key: 'paid-amount', label: '已支付金额:', value: formatCnyFromYuan(summary.paid_amount), icon: 'pi-wallet' },
{ key: 'refund-orders', label: '退款订单:', value: summary.refund_orders ?? 0, icon: 'pi-undo' },
{ key: 'refund-amount', label: '退款金额:', value: formatCnyFromYuan(summary.refund_amount), icon: 'pi-replay' },
{ key: 'conversion', label: '转化率:', value: formatPercent(summary.conversion_rate), icon: 'pi-percentage' }
];
});
async function loadOverview() {
loading.value = true;
try {
overview.value = await ReportService.getOverview({
tenant_id: tenantID.value || undefined,
start_at: startAt.value || undefined,
end_at: endAt.value || undefined,
granularity: granularity.value
});
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表数据', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
loadOverview();
}
function onReset() {
tenantID.value = null;
startAt.value = null;
endAt.value = null;
granularity.value = 'day';
loadOverview();
}
async function exportReport() {
try {
const res = await ReportService.exportReport({
tenant_id: tenantID.value || undefined,
start_at: startAt.value || undefined,
end_at: endAt.value || undefined,
granularity: granularity.value,
format: 'csv'
});
const content = res?.content ?? '';
const filename = res?.filename || 'report.csv';
const blob = new Blob([content], { type: res?.mime_type || 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
toast.add({ severity: 'success', summary: '导出成功', detail: filename, life: 3000 });
} catch (error) {
toast.add({ severity: 'error', summary: '导出失败', detail: error?.message || '无法导出报表', life: 4000 });
}
}
loadOverview();
</script>
<template>
<PendingPanel title="Reports" description="Platform reporting needs aggregated super admin APIs." :endpoints="endpoints" :notes="notes" />
<div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">运营报表</h4>
<Button label="导出 CSV" icon="pi pi-download" severity="secondary" :loading="loading" @click="exportReport" />
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="不填则全平台" class="w-full" />
</SearchField>
<SearchField label="开始时间">
<DatePicker v-model="startAt" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="结束时间">
<DatePicker v-model="endAt" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="统计粒度">
<Select v-model="granularity" :options="granularityOptions" optionLabel="label" optionValue="value" class="w-full" />
</SearchField>
</SearchPanel>
</div>
<StatisticsStrip v-if="summaryItems.length" :items="summaryItems" containerClass="card mb-4" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">趋势明细</h4>
</div>
<DataTable :value="overview?.items || []" :loading="loading" scrollable scrollHeight="420px" responsiveLayout="scroll">
<Column field="date" header="日期" style="min-width: 10rem" />
<Column field="paid_orders" header="已支付订单" style="min-width: 10rem" />
<Column field="paid_amount" header="已支付金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.paid_amount) }}
</template>
</Column>
<Column field="refund_orders" header="退款订单" style="min-width: 10rem" />
<Column field="refund_amount" header="退款金额" style="min-width: 12rem">
<template #body="{ data }">
{{ formatCnyFromYuan(data.refund_amount) }}
</template>
</Column>
</DataTable>
</div>
</div>
</template>