feat: add media supply billing center
Frontend CI / Frontend (push) Successful in 5m44s
Backend CI / Backend (push) Failing after 6m58s

This commit is contained in:
2026-06-29 23:13:33 +08:00
parent 4040d22605
commit 31811b07d4
16 changed files with 926 additions and 12 deletions
+239 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -97,6 +98,40 @@ type MediaSupplyWalletStatus struct {
TenantName *string `json:"tenant_name,omitempty"`
}
type MediaSupplyBillingEntry struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
TenantName *string `json:"tenant_name,omitempty"`
UserID int64 `json:"user_id"`
UserName *string `json:"user_name,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
OrderID *int64 `json:"order_id,omitempty"`
OrderTitle *string `json:"order_title,omitempty"`
OrderStatus *string `json:"order_status,omitempty"`
ExternalOrderCode *string `json:"external_order_code,omitempty"`
DeltaCents int64 `json:"delta_cents"`
BalanceAfterCents int64 `json:"balance_after_cents"`
Reason string `json:"reason"`
Note *string `json:"note,omitempty"`
SalesCents int64 `json:"sales_cents"`
CostCents int64 `json:"cost_cents"`
GrossProfitCents int64 `json:"gross_profit_cents"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type MediaSupplyBillingSummary struct {
TotalEntries int64 `json:"total_entries"`
TotalSalesCents int64 `json:"total_sales_cents"`
TotalCostCents int64 `json:"total_cost_cents"`
GrossProfitCents int64 `json:"gross_profit_cents"`
NetWalletDeltaCents int64 `json:"net_wallet_delta_cents"`
OrderDebitCents int64 `json:"order_debit_cents"`
OrderRefundCents int64 `json:"order_refund_cents"`
RechargeCents int64 `json:"recharge_cents"`
ManualAdjustmentCents int64 `json:"manual_adjustment_cents"`
}
type ListMediaSupplyWalletsInput struct {
Keyword string
TenantID int64
@@ -111,6 +146,26 @@ type ListMediaSupplyWalletsResult struct {
Size int `json:"size"`
}
type ListMediaSupplyBillingsInput struct {
Keyword string
TenantID int64
UserID int64
OrderID int64
Reason string
StartAt *time.Time
EndAt *time.Time
Page int
Size int
}
type ListMediaSupplyBillingsResult struct {
Items []MediaSupplyBillingEntry `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Summary MediaSupplyBillingSummary `json:"summary"`
}
type AdjustMediaSupplyWalletInput struct {
TenantID int64
UserID int64
@@ -371,6 +426,187 @@ func (s *MediaSupplyService) ListWallets(ctx context.Context, input ListMediaSup
return &ListMediaSupplyWalletsResult{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *MediaSupplyService) ListBillings(ctx context.Context, input ListMediaSupplyBillingsInput) (*ListMediaSupplyBillingsResult, error) {
if s == nil || s.pool == nil {
return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
}
page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size)
args := make([]any, 0, 8)
where := []string{"1=1"}
needsSearchJoin := false
if input.TenantID > 0 {
args = append(args, input.TenantID)
where = append(where, fmt.Sprintf("l.tenant_id = $%d", len(args)))
}
if input.UserID > 0 {
args = append(args, input.UserID)
where = append(where, fmt.Sprintf("l.user_id = $%d", len(args)))
}
if input.OrderID > 0 {
args = append(args, input.OrderID)
where = append(where, fmt.Sprintf("l.order_id = $%d", len(args)))
}
if reason := strings.TrimSpace(input.Reason); reason != "" {
args = append(args, reason)
where = append(where, fmt.Sprintf("l.reason = $%d", len(args)))
}
if input.StartAt != nil {
args = append(args, *input.StartAt)
where = append(where, fmt.Sprintf("l.created_at >= $%d", len(args)))
}
if input.EndAt != nil {
args = append(args, *input.EndAt)
where = append(where, fmt.Sprintf("l.created_at < $%d", len(args)))
}
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
needsSearchJoin = true
args = append(args, "%"+keyword+"%")
keywordArg := len(args)
predicates := []string{
fmt.Sprintf("COALESCE(u.phone, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(u.email, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(u.name, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(t.name, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(o.title, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(o.external_order_code, '') ILIKE $%d", keywordArg),
fmt.Sprintf("COALESCE(l.note, '') ILIKE $%d", keywordArg),
}
if id, err := strconv.ParseInt(keyword, 10, 64); err == nil && id > 0 {
args = append(args, id)
idArg := len(args)
predicates = append(predicates, fmt.Sprintf("(l.tenant_id = $%d OR l.user_id = $%d OR l.order_id = $%d)", idArg, idArg, idArg))
}
where = append(where, "("+strings.Join(predicates, " OR ")+")")
}
whereSQL := strings.Join(where, " AND ")
summarySQL := `
SELECT COUNT(*),
COALESCE(SUM(l.sales_cents), 0),
COALESCE(SUM(l.cost_cents), 0),
COALESCE(SUM(l.gross_profit_cents), 0),
COALESCE(SUM(l.delta_cents), 0),
COALESCE(SUM(CASE WHEN l.reason = 'order_debit' THEN -l.delta_cents ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN l.reason = 'order_refund' THEN l.delta_cents ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN l.reason = 'recharge' THEN l.delta_cents ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN l.reason = 'adjustment' THEN l.delta_cents ELSE 0 END), 0)
FROM media_supply_wallet_ledgers l`
if needsSearchJoin {
summarySQL += `
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN tenants t ON t.id = l.tenant_id
LEFT JOIN media_supply_orders o ON o.id = l.order_id`
}
summarySQL += `
WHERE ` + whereSQL
var summary MediaSupplyBillingSummary
if err := s.pool.QueryRow(ctx, summarySQL, args...).Scan(
&summary.TotalEntries,
&summary.TotalSalesCents,
&summary.TotalCostCents,
&summary.GrossProfitCents,
&summary.NetWalletDeltaCents,
&summary.OrderDebitCents,
&summary.OrderRefundCents,
&summary.RechargeCents,
&summary.ManualAdjustmentCents,
); err != nil {
return nil, response.ErrInternal(50091, "media_supply_billing_summary_failed", "媒体投稿账单汇总失败")
}
listArgs := append([]any{}, args...)
listArgs = append(listArgs, size, (page-1)*size)
rows, err := s.pool.Query(ctx, `
SELECT l.id, l.tenant_id, t.name, l.user_id, u.name, u.phone, l.order_id,
o.title, o.status, o.external_order_code, l.delta_cents,
l.balance_after_cents, l.reason, l.note, l.sales_cents, l.cost_cents,
l.gross_profit_cents, l.created_by, l.created_at
FROM media_supply_wallet_ledgers l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN tenants t ON t.id = l.tenant_id
LEFT JOIN media_supply_orders o ON o.id = l.order_id
WHERE `+whereSQL+`
ORDER BY l.created_at DESC, l.id DESC
LIMIT $`+fmt.Sprint(len(listArgs)-1)+` OFFSET $`+fmt.Sprint(len(listArgs))+`
`, listArgs...)
if err != nil {
return nil, response.ErrInternal(50092, "media_supply_billing_query_failed", "媒体投稿账单读取失败")
}
defer rows.Close()
items := make([]MediaSupplyBillingEntry, 0, size)
for rows.Next() {
var item MediaSupplyBillingEntry
var tenantName sql.NullString
var userName sql.NullString
var userPhone sql.NullString
var orderID sql.NullInt64
var orderTitle sql.NullString
var orderStatus sql.NullString
var externalOrderCode sql.NullString
var note sql.NullString
var createdBy sql.NullInt64
if err := rows.Scan(
&item.ID,
&item.TenantID,
&tenantName,
&item.UserID,
&userName,
&userPhone,
&orderID,
&orderTitle,
&orderStatus,
&externalOrderCode,
&item.DeltaCents,
&item.BalanceAfterCents,
&item.Reason,
&note,
&item.SalesCents,
&item.CostCents,
&item.GrossProfitCents,
&createdBy,
&item.CreatedAt,
); err != nil {
return nil, response.ErrInternal(50093, "media_supply_billing_scan_failed", "媒体投稿账单解析失败")
}
if tenantName.Valid {
item.TenantName = &tenantName.String
}
if userName.Valid {
item.UserName = &userName.String
}
if userPhone.Valid {
item.UserPhone = &userPhone.String
}
if orderID.Valid {
item.OrderID = &orderID.Int64
}
if orderTitle.Valid {
item.OrderTitle = &orderTitle.String
}
if orderStatus.Valid {
item.OrderStatus = &orderStatus.String
}
if externalOrderCode.Valid {
item.ExternalOrderCode = &externalOrderCode.String
}
if note.Valid {
item.Note = &note.String
}
if createdBy.Valid {
item.CreatedBy = &createdBy.Int64
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50093, "media_supply_billing_scan_failed", "媒体投稿账单解析失败")
}
return &ListMediaSupplyBillingsResult{
Items: items,
Total: summary.TotalEntries,
Page: page,
Size: size,
Summary: summary,
}, nil
}
func (s *MediaSupplyService) QueueSync(ctx context.Context, actor *Actor, modelID int) (int64, error) {
if s == nil || s.pool == nil {
return 0, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
@@ -455,9 +691,10 @@ func (s *MediaSupplyService) AdjustWallet(ctx context.Context, actor *Actor, inp
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_wallet_ledgers (
tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by
tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by,
sales_cents, cost_cents, gross_profit_cents
)
VALUES ($1, $2, $3, $4, $5, $6, NULL)
VALUES ($1, $2, $3, $4, $5, $6, NULL, 0, 0, 0)
`, input.TenantID, input.UserID, input.DeltaCents, balanceAfter, reason, nullableOpsMediaSupplyString(note)); err != nil {
return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
}
@@ -2,6 +2,7 @@ package transport
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -157,6 +158,58 @@ func listMediaSupplyWalletsHandler(svc *app.MediaSupplyService) gin.HandlerFunc
}
}
func listMediaSupplyBillingsHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
tenantID, err := parseOptionalOpsInt64Query(c, "tenant_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_tenant_id", "租户参数必须是数字"))
return
}
userID, err := parseOptionalOpsInt64Query(c, "user_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_user_id", "用户参数必须是数字"))
return
}
orderID, err := parseOptionalOpsInt64Query(c, "order_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_order_id", "订单参数必须是数字"))
return
}
startAt, err := parseOptionalOpsTimeQuery(c, "start_at")
if err != nil {
response.Error(c, response.ErrBadRequest(40003, "invalid_start_at", "start_at 必须是 RFC3339"))
return
}
endAt, err := parseOptionalOpsTimeQuery(c, "end_at")
if err != nil {
response.Error(c, response.ErrBadRequest(40004, "invalid_end_at", "end_at 必须是 RFC3339"))
return
}
if startAt != nil && endAt != nil && !startAt.Before(*endAt) {
response.Error(c, response.ErrBadRequest(40005, "invalid_billing_time_range", "结束时间必须晚于开始时间"))
return
}
page, _ := parseOptionalOpsIntQuery(c, "page")
size, _ := parseOptionalOpsIntQuery(c, "page_size")
result, err := svc.ListBillings(c.Request.Context(), app.ListMediaSupplyBillingsInput{
Keyword: c.Query("keyword"),
TenantID: tenantID,
UserID: userID,
OrderID: orderID,
Reason: c.Query("reason"),
StartAt: startAt,
EndAt: endAt,
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func adjustMediaSupplyWalletHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
var body adjustMediaSupplyWalletRequest
@@ -199,3 +252,15 @@ func parseOptionalOpsInt64Query(c *gin.Context, key string) (int64, error) {
}
return strconv.ParseInt(raw, 10, 64)
}
func parseOptionalOpsTimeQuery(c *gin.Context, key string) (*time.Time, error) {
raw := c.Query(key)
if raw == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return nil, err
}
return &parsed, nil
}
+1
View File
@@ -139,6 +139,7 @@ func RegisterRoutes(d Deps) {
authed.PUT("/media-supply/resources/:id/visibility", setMediaSupplyResourceVisibilityHandler(d.MediaSupply))
authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply))
authed.GET("/media-supply/wallets", listMediaSupplyWalletsHandler(d.MediaSupply))
authed.GET("/media-supply/billings", listMediaSupplyBillingsHandler(d.MediaSupply))
authed.POST("/media-supply/wallets/adjustments", adjustMediaSupplyWalletHandler(d.MediaSupply))
authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler))