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
+60
View File
@@ -70,6 +70,60 @@ export interface OpsMediaSupplyWalletsParams {
page_size?: number
}
export interface OpsMediaSupplyBillingEntry {
id: number
tenant_id: number
tenant_name?: string | null
user_id: number
user_name?: string | null
user_phone?: string | null
order_id?: number | null
order_title?: string | null
order_status?: string | null
external_order_code?: string | null
delta_cents: number
balance_after_cents: number
reason: string
note?: string | null
sales_cents: number
cost_cents: number
gross_profit_cents: number
created_by?: number | null
created_at: string
}
export interface OpsMediaSupplyBillingSummary {
total_entries: number
total_sales_cents: number
total_cost_cents: number
gross_profit_cents: number
net_wallet_delta_cents: number
order_debit_cents: number
order_refund_cents: number
recharge_cents: number
manual_adjustment_cents: number
}
export interface OpsMediaSupplyBillingsResponse {
items: OpsMediaSupplyBillingEntry[]
total: number
page: number
size: number
summary: OpsMediaSupplyBillingSummary
}
export interface OpsMediaSupplyBillingsParams {
keyword?: string
tenant_id?: number
user_id?: number
order_id?: number
reason?: string
start_at?: string
end_at?: string
page?: number
page_size?: number
}
export const opsMediaSupplyApi = {
listResources(params: OpsMediaSupplyResourcesParams) {
return http.get<OpsMediaSupplyResourcesResponse>(
@@ -107,6 +161,12 @@ export const opsMediaSupplyApi = {
params as Record<string, unknown>,
)
},
listBillings(params: OpsMediaSupplyBillingsParams) {
return http.get<OpsMediaSupplyBillingsResponse>(
'/media-supply/billings',
params as Record<string, unknown>,
)
},
adjustWallet(payload: {
tenant_id: number
user_id: number
+455 -4
View File
@@ -214,7 +214,162 @@
{{ formatDate(record.updated_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button type="link" @click="openWalletModal(record)">调整余额</a-button>
<div class="wallet-actions">
<a-button type="link" @click="openUserBillings(record)">账单</a-button>
<a-button type="link" @click="openWalletModal(record)">调整余额</a-button>
</div>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
<a-tab-pane key="billings" tab="账单中心">
<div class="ops-card media-ops-card billing-card">
<div class="billing-summary">
<div class="billing-summary__item">
<span>销售额</span>
<strong>{{ formatMoney(billingSummary.total_sales_cents) }}</strong>
</div>
<div class="billing-summary__item">
<span>成本</span>
<strong>{{ formatMoney(billingSummary.total_cost_cents) }}</strong>
</div>
<div class="billing-summary__item billing-summary__item--profit">
<span>毛利</span>
<strong>{{ formatMoney(billingSummary.gross_profit_cents) }}</strong>
<small>毛利率 {{ formatPercent(grossProfitRate) }}</small>
</div>
<div class="billing-summary__item">
<span>账单数</span>
<strong>{{ billingSummary.total_entries }}</strong>
</div>
<div class="billing-summary__item">
<span>净账变</span>
<strong>{{ formatMoney(billingSummary.net_wallet_delta_cents) }}</strong>
</div>
</div>
<div class="ops-toolbar media-ops-toolbar billing-toolbar">
<a-input
v-model:value="billingFilter.keyword"
allow-clear
placeholder="客户 / 手机 / 租户 / 订单 / 备注"
style="width: 280px"
@press-enter="resetBillings"
/>
<a-input
v-model:value="billingFilter.tenantId"
allow-clear
placeholder="租户 ID"
style="width: 120px"
@press-enter="resetBillings"
/>
<a-input
v-model:value="billingFilter.userId"
allow-clear
placeholder="用户 ID"
style="width: 120px"
@press-enter="resetBillings"
/>
<a-input
v-model:value="billingFilter.orderId"
allow-clear
placeholder="订单 ID"
style="width: 120px"
@press-enter="resetBillings"
/>
<a-select
v-model:value="billingFilter.reason"
allow-clear
placeholder="账单类型"
style="width: 140px"
:options="billingReasonOptions"
@change="resetBillings"
/>
<a-range-picker
v-model:value="billingDateRange"
show-time
style="width: 360px"
@change="resetBillings"
/>
<a-button @click="setBillingThisMonth">本月</a-button>
<a-button @click="showAllBillings">全部</a-button>
<a-button type="primary" @click="resetBillings">查询</a-button>
<a-button @click="clearBillingFilters">重置</a-button>
</div>
<a-table
:columns="billingColumns"
:data-source="billingRows"
:loading="billingLoading"
:pagination="billingPagination"
row-key="billing_key"
:scroll="{ x: 1780 }"
@change="onBillingTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
<div class="mono-line">{{ formatDate(record.created_at) }}</div>
<div class="muted">#{{ record.id }}</div>
</template>
<template v-else-if="column.key === 'user'">
<div class="resource-main">
<strong>
{{ record.user_name || record.user_phone || `用户 ${record.user_id}` }}
</strong>
<div class="resource-sub">
<code>U{{ record.user_id }}</code>
<span>{{ record.user_phone || '未绑定手机' }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'tenant'">
<div>{{ record.tenant_name || `租户 ${record.tenant_id}` }}</div>
<div class="muted">T{{ record.tenant_id }}</div>
</template>
<template v-else-if="column.key === 'order'">
<div class="resource-main">
<strong>{{ record.order_title || record.note || '--' }}</strong>
<div class="resource-sub">
<code v-if="record.order_id">O{{ record.order_id }}</code>
<span v-if="record.external_order_code">{{ record.external_order_code }}</span>
<a-tag v-if="record.order_status" :color="orderStatusColor(record.order_status)">
{{ orderStatusLabel(record.order_status) }}
</a-tag>
</div>
</div>
</template>
<template v-else-if="column.key === 'reason'">
<a-tag :color="billingReasonColor(record.reason)">
{{ billingReasonLabel(record.reason) }}
</a-tag>
</template>
<template v-else-if="column.key === 'delta_cents'">
<strong :class="amountClass(record.delta_cents)">
{{ formatSignedMoney(record.delta_cents) }}
</strong>
</template>
<template v-else-if="column.key === 'sales_cents'">
<strong :class="amountClass(record.sales_cents)">
{{ formatMoney(record.sales_cents) }}
</strong>
</template>
<template v-else-if="column.key === 'cost_cents'">
<strong :class="amountClass(record.cost_cents)">
{{ formatMoney(record.cost_cents) }}
</strong>
</template>
<template v-else-if="column.key === 'gross_profit_cents'">
<div class="price-stack">
<strong :class="amountClass(record.gross_profit_cents)">
{{ formatMoney(record.gross_profit_cents) }}
</strong>
<span>{{ formatPercent(profitRate(record)) }}</span>
</div>
</template>
<template v-else-if="column.key === 'balance_after_cents'">
<strong>{{ formatMoney(record.balance_after_cents) }}</strong>
</template>
</template>
</a-table>
@@ -287,12 +442,14 @@
import { EditOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import dayjs, { type Dayjs } from 'dayjs'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { showOpsError } from '@/lib/errors'
import {
opsMediaSupplyApi,
type OpsMediaSupplyBillingEntry,
type OpsMediaSupplyBillingSummary,
type OpsMediaSupplyResource,
type OpsMediaSupplyWallet,
} from '@/lib/media-supply'
@@ -301,9 +458,25 @@ interface WalletRow extends OpsMediaSupplyWallet {
wallet_key: string
}
interface BillingRow extends OpsMediaSupplyBillingEntry {
billing_key: string
}
const MODEL_ID_AUTHORITY_NEWS = 1
const PAGE_SIZE = 20
const emptyBillingSummary: OpsMediaSupplyBillingSummary = {
total_entries: 0,
total_sales_cents: 0,
total_cost_cents: 0,
gross_profit_cents: 0,
net_wallet_delta_cents: 0,
order_debit_cents: 0,
order_refund_cents: 0,
recharge_cents: 0,
manual_adjustment_cents: 0,
}
const activeTab = ref('resources')
const resourcePage = ref(1)
const resourcePageSize = ref(PAGE_SIZE)
@@ -332,6 +505,17 @@ const walletForm = reactive({
note: '',
})
const billingPage = ref(1)
const billingPageSize = ref(PAGE_SIZE)
const billingLoading = ref(false)
const billingTotal = ref(0)
const billingRows = ref<BillingRow[]>([])
const billingSummary = ref<OpsMediaSupplyBillingSummary>({ ...emptyBillingSummary })
const billingDateRange = ref<[Dayjs, Dayjs] | null>([
dayjs().startOf('month'),
dayjs().endOf('day'),
])
const resourceFilter = reactive({
keyword: '',
remarkKeyword: '',
@@ -347,11 +531,26 @@ const walletFilter = reactive({
tenantId: '',
})
const billingFilter = reactive({
keyword: '',
tenantId: '',
userId: '',
orderId: '',
reason: undefined as string | undefined,
})
const visibilityOptions = [
{ label: '客户可见', value: 'visible' },
{ label: '客户隐藏', value: 'hidden' },
]
const billingReasonOptions = [
{ label: '投稿扣费', value: 'order_debit' },
{ label: '订单退款', value: 'order_refund' },
{ label: '充值', value: 'recharge' },
{ label: '手动调整', value: 'adjustment' },
]
const resourceColumns: TableColumnsType<OpsMediaSupplyResource> = [
{ title: '媒体名称', key: 'name', width: 320, fixed: 'left' },
{ title: '售价 / 成本', key: 'price', width: 150, align: 'right' },
@@ -375,7 +574,20 @@ const walletColumns: TableColumnsType<WalletRow> = [
{ title: '余额', key: 'balance_cents', width: 160, align: 'right' },
{ title: '账单', key: 'ledger', width: 180 },
{ title: '更新时间', key: 'updated_at', width: 180 },
{ title: '操作', key: 'actions', width: 130, fixed: 'right', align: 'center' },
{ title: '操作', key: 'actions', width: 160, fixed: 'right', align: 'center' },
]
const billingColumns: TableColumnsType<BillingRow> = [
{ title: '时间', key: 'created_at', width: 180, fixed: 'left' },
{ title: '客户', key: 'user', width: 240 },
{ title: '租户', key: 'tenant', width: 200 },
{ title: '订单 / 备注', key: 'order', width: 320 },
{ title: '类型', key: 'reason', width: 120, align: 'center' },
{ title: '账变', key: 'delta_cents', width: 140, align: 'right' },
{ title: '销售额', key: 'sales_cents', width: 140, align: 'right' },
{ title: '成本', key: 'cost_cents', width: 140, align: 'right' },
{ title: '毛利', key: 'gross_profit_cents', width: 150, align: 'right' },
{ title: '余额快照', key: 'balance_after_cents', width: 150, align: 'right' },
]
const resourcePagination = computed<TablePaginationConfig>(() => ({
@@ -394,10 +606,27 @@ const walletPagination = computed<TablePaginationConfig>(() => ({
showTotal: (total) => `${total}`,
}))
const billingPagination = computed<TablePaginationConfig>(() => ({
current: billingPage.value,
pageSize: billingPageSize.value,
total: billingTotal.value,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}))
const grossProfitRate = computed(() =>
billingSummary.value.total_sales_cents === 0
? null
: billingSummary.value.gross_profit_cents / billingSummary.value.total_sales_cents,
)
watch(activeTab, (key) => {
if (key === 'wallets' && walletRows.value.length === 0) {
void loadWallets()
}
if (key === 'billings' && billingRows.value.length === 0) {
void loadBillings()
}
})
onMounted(() => {
@@ -449,6 +678,33 @@ async function loadWallets(): Promise<void> {
}
}
async function loadBillings(): Promise<void> {
billingLoading.value = true
try {
const result = await opsMediaSupplyApi.listBillings({
keyword: billingFilter.keyword.trim() || undefined,
tenant_id: parsePositiveNumber(billingFilter.tenantId),
user_id: parsePositiveNumber(billingFilter.userId),
order_id: parsePositiveNumber(billingFilter.orderId),
reason: billingFilter.reason,
start_at: billingDateRange.value?.[0]?.toISOString(),
end_at: billingDateRange.value?.[1]?.toISOString(),
page: billingPage.value,
page_size: billingPageSize.value,
})
billingRows.value = result.items.map((item) => ({
...item,
billing_key: String(item.id),
}))
billingTotal.value = result.total
billingSummary.value = result.summary
} catch (error) {
showOpsError(error)
} finally {
billingLoading.value = false
}
}
function resetResources(): void {
resourcePage.value = 1
void loadResources()
@@ -470,12 +726,37 @@ function resetWallets(): void {
void loadWallets()
}
function resetBillings(): void {
billingPage.value = 1
void loadBillings()
}
function clearWalletFilters(): void {
walletFilter.keyword = ''
walletFilter.tenantId = ''
resetWallets()
}
function clearBillingFilters(): void {
billingFilter.keyword = ''
billingFilter.tenantId = ''
billingFilter.userId = ''
billingFilter.orderId = ''
billingFilter.reason = undefined
billingDateRange.value = [dayjs().startOf('month'), dayjs().endOf('day')]
resetBillings()
}
function setBillingThisMonth(): void {
billingDateRange.value = [dayjs().startOf('month'), dayjs().endOf('day')]
resetBillings()
}
function showAllBillings(): void {
billingDateRange.value = null
resetBillings()
}
function onResourceTableChange(pagination: TablePaginationConfig): void {
resourcePage.value = pagination.current ?? 1
resourcePageSize.value = pagination.pageSize ?? PAGE_SIZE
@@ -488,6 +769,29 @@ function onWalletTableChange(pagination: TablePaginationConfig): void {
void loadWallets()
}
function onBillingTableChange(pagination: TablePaginationConfig): void {
billingPage.value = pagination.current ?? 1
billingPageSize.value = pagination.pageSize ?? PAGE_SIZE
void loadBillings()
}
function openUserBillings(row: WalletRow): void {
billingFilter.keyword = ''
billingFilter.tenantId = String(row.tenant_id)
billingFilter.userId = String(row.user_id)
billingFilter.orderId = ''
billingFilter.reason = undefined
billingPage.value = 1
if (activeTab.value === 'billings') {
void loadBillings()
return
}
activeTab.value = 'billings'
if (billingRows.value.length > 0) {
void loadBillings()
}
}
async function queueSync(): Promise<void> {
syncLoading.value = true
try {
@@ -587,7 +891,29 @@ async function submitWalletAdjustment(): Promise<void> {
function formatMoney(cents?: number | null): string {
if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--'
return `${(cents / 100).toFixed(2)}`
const prefix = cents < 0 ? '-' : ''
return `${prefix}${(Math.abs(cents) / 100).toFixed(2)}`
}
function formatSignedMoney(cents?: number | null): string {
if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--'
const prefix = cents > 0 ? '+' : cents < 0 ? '-' : ''
return `${prefix}${(Math.abs(cents) / 100).toFixed(2)}`
}
function formatPercent(value: number | null): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '--'
return `${(value * 100).toFixed(1)}%`
}
function profitRate(row: OpsMediaSupplyBillingEntry): number | null {
if (!row.sales_cents) return null
return row.gross_profit_cents / row.sales_cents
}
function amountClass(cents?: number | null): string {
if (typeof cents !== 'number' || cents === 0) return 'amount-neutral'
return cents > 0 ? 'amount-positive' : 'amount-negative'
}
function formatSellPrice(cents?: number | null): string {
@@ -643,6 +969,37 @@ function statusColor(status: string): string {
if (status === 'disabled') return 'default'
return 'blue'
}
function billingReasonLabel(reason: string): string {
if (reason === 'order_debit') return '投稿扣费'
if (reason === 'order_refund') return '订单退款'
if (reason === 'recharge') return '充值'
if (reason === 'adjustment') return '调整'
return reason || '--'
}
function billingReasonColor(reason: string): string {
if (reason === 'order_debit') return 'blue'
if (reason === 'order_refund') return 'orange'
if (reason === 'recharge') return 'green'
if (reason === 'adjustment') return 'purple'
return 'default'
}
function orderStatusLabel(status: string): string {
if (status === 'queued') return '排队'
if (status === 'submitting') return '提交中'
if (status === 'submitted') return '已提交'
if (status === 'failed') return '失败'
return status || '--'
}
function orderStatusColor(status: string): string {
if (status === 'failed') return 'red'
if (status === 'submitted') return 'green'
if (status === 'submitting') return 'gold'
return 'blue'
}
</script>
<style scoped>
@@ -676,11 +1033,67 @@ function statusColor(status: string): string {
padding: 16px;
}
.billing-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.media-ops-toolbar {
gap: 10px;
margin-bottom: 16px;
}
.billing-toolbar {
margin-bottom: 0;
}
.billing-summary {
display: grid;
gap: 12px;
grid-template-columns: repeat(5, minmax(150px, 1fr));
}
.billing-summary__item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 86px;
padding: 14px 16px;
}
.billing-summary__item span {
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.billing-summary__item strong {
color: #0f172a;
font-size: 22px;
font-variant-numeric: tabular-nums;
font-weight: 800;
line-height: 1.2;
}
.billing-summary__item small {
color: #64748b;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.billing-summary__item--profit {
background: #ecfdf5;
border-color: #bbf7d0;
}
.billing-summary__item--profit strong {
color: #047857;
}
.resource-main {
display: flex;
flex-direction: column;
@@ -741,16 +1154,54 @@ function statusColor(status: string): string {
color: #0f766e;
}
.wallet-actions {
align-items: center;
display: flex;
justify-content: center;
white-space: nowrap;
}
.mono-line,
.amount-positive,
.amount-negative,
.amount-neutral,
.wallet-balance,
.price-stack strong {
font-variant-numeric: tabular-nums;
}
.amount-positive {
color: #0f766e;
}
.amount-negative {
color: #b91c1c;
}
.amount-neutral {
color: #475569;
}
.edit-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 1280px) {
.billing-summary {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
@media (max-width: 900px) {
.media-ops-header {
align-items: flex-start;
flex-direction: column;
}
.billing-summary {
grid-template-columns: 1fr;
}
}
</style>
+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
);