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))
@@ -763,9 +763,10 @@ func (s *MediaSupplyService) AdjustWallet(ctx context.Context, req AdjustMediaSu
}
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, $7)
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, 0)
`, actor.TenantID, req.UserID, req.DeltaCents, balanceAfter, reason, nullableTrimmedString(req.Note), actor.UserID); err != nil {
return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
}
@@ -848,9 +849,15 @@ func (s *MediaSupplyService) debitMediaSupplyWallet(ctx context.Context, tx pgx.
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_wallet_ledgers (
tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by
tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by,
sales_cents, cost_cents, gross_profit_cents
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
(SELECT sell_total_cents FROM media_supply_orders WHERE id = $3),
(SELECT cost_total_cents FROM media_supply_orders WHERE id = $3),
(SELECT sell_total_cents - cost_total_cents FROM media_supply_orders WHERE id = $3)
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, tenantID, userID, orderID, -amountCents, balanceAfter, "order_debit", nullableTrimmedString(orderTitle), createdBy); err != nil {
return 0, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
}
@@ -900,9 +907,15 @@ func (s *MediaSupplyService) refundMediaSupplyWalletForOrder(ctx context.Context
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_wallet_ledgers (
tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by
tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by,
sales_cents, cost_cents, gross_profit_cents
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
(SELECT -sell_total_cents FROM media_supply_orders WHERE id = $3),
(SELECT -cost_total_cents FROM media_supply_orders WHERE id = $3),
(SELECT -(sell_total_cents - cost_total_cents) FROM media_supply_orders WHERE id = $3)
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, tenantID, userID, orderID, debitCents, balanceAfter, "order_refund", nullableTrimmedString(ledgerNote), userID); err != nil {
return response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
}
@@ -0,0 +1,5 @@
ALTER TABLE media_supply_wallet_ledgers
DROP CONSTRAINT IF EXISTS chk_media_supply_wallet_ledger_fact_non_negative,
DROP COLUMN IF EXISTS gross_profit_cents,
DROP COLUMN IF EXISTS cost_cents,
DROP COLUMN IF EXISTS sales_cents;
@@ -0,0 +1,34 @@
ALTER TABLE media_supply_wallet_ledgers
ADD COLUMN IF NOT EXISTS sales_cents BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS cost_cents BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS gross_profit_cents BIGINT NOT NULL DEFAULT 0;
UPDATE media_supply_wallet_ledgers l
SET sales_cents = CASE
WHEN l.reason = 'order_debit' THEN o.sell_total_cents
WHEN l.reason = 'order_refund' THEN -o.sell_total_cents
ELSE 0
END,
cost_cents = CASE
WHEN l.reason = 'order_debit' THEN o.cost_total_cents
WHEN l.reason = 'order_refund' THEN -o.cost_total_cents
ELSE 0
END,
gross_profit_cents = CASE
WHEN l.reason = 'order_debit' THEN o.sell_total_cents - o.cost_total_cents
WHEN l.reason = 'order_refund' THEN -(o.sell_total_cents - o.cost_total_cents)
ELSE 0
END
FROM media_supply_orders o
WHERE l.order_id = o.id
AND l.reason IN ('order_debit', 'order_refund');
ALTER TABLE media_supply_wallet_ledgers
ADD CONSTRAINT chk_media_supply_wallet_ledger_fact_non_negative
CHECK (
reason = 'order_refund'
OR (sales_cents >= 0 AND cost_cents >= 0 AND gross_profit_cents >= 0)
) NOT VALID;
ALTER TABLE media_supply_wallet_ledgers
VALIDATE CONSTRAINT chk_media_supply_wallet_ledger_fact_non_negative;
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_created_id;
@@ -0,0 +1,12 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_created_id
ON media_supply_wallet_ledgers (created_at DESC, id DESC)
INCLUDE (
tenant_id,
user_id,
order_id,
reason,
delta_cents,
sales_cents,
cost_cents,
gross_profit_cents
);
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_created;
@@ -0,0 +1,11 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_tenant_created
ON media_supply_wallet_ledgers (tenant_id, created_at DESC, id DESC)
INCLUDE (
user_id,
order_id,
reason,
delta_cents,
sales_cents,
cost_cents,
gross_profit_cents
);
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_user_created_cover;
@@ -0,0 +1,10 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_tenant_user_created_cover
ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC, id DESC)
INCLUDE (
order_id,
reason,
delta_cents,
sales_cents,
cost_cents,
gross_profit_cents
);
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_reason_created;
@@ -0,0 +1,11 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_reason_created
ON media_supply_wallet_ledgers (reason, created_at DESC, id DESC)
INCLUDE (
tenant_id,
user_id,
order_id,
delta_cents,
sales_cents,
cost_cents,
gross_profit_cents
);