Files
root 31811b07d4
Frontend CI / Frontend (push) Successful in 5m44s
Backend CI / Backend (push) Failing after 6m58s
feat: add media supply billing center
2026-06-29 23:13:33 +08:00

869 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
opsMediaSupplySupplierMeijiequan = "meijiequan"
)
type MediaSupplyService struct {
pool *pgxpool.Pool
audits *AuditService
cfgFunc func() sharedconfig.MediaSupplyConfig
}
func NewMediaSupplyService(pool *pgxpool.Pool, audits *AuditService, cfgFunc func() sharedconfig.MediaSupplyConfig) *MediaSupplyService {
return &MediaSupplyService{pool: pool, audits: audits, cfgFunc: cfgFunc}
}
type MediaSupplyResource struct {
ID int64 `json:"id"`
SupplierResourceID string `json:"supplier_resource_id"`
ModelID int `json:"model_id"`
Name string `json:"name"`
Status string `json:"status"`
CostPriceCents int64 `json:"cost_price_cents"`
CostPrices map[string]int64 `json:"cost_prices"`
SellPriceCents int64 `json:"sell_price_cents"`
ResourceURL *string `json:"resource_url,omitempty"`
BaiduWeight *int `json:"baidu_weight,omitempty"`
ResourceRemark *string `json:"resource_remark,omitempty"`
CustomerVisible bool `json:"customer_visible"`
ChannelType *string `json:"channel_type,omitempty"`
Region *string `json:"region,omitempty"`
InclusionEffect *string `json:"inclusion_effect,omitempty"`
LinkType *string `json:"link_type,omitempty"`
PublishRate *string `json:"publish_rate,omitempty"`
DeliverySpeed *string `json:"delivery_speed,omitempty"`
LastSyncedAt time.Time `json:"last_synced_at"`
}
type ListMediaSupplyResourcesInput struct {
ModelID int
Keyword string
RemarkKeyword string
ChannelType string
Region string
InclusionEffect string
LinkType string
MinPriceCents int64
MaxPriceCents int64
Visibility string
Page int
Size int
}
type ListMediaSupplyResourcesResult struct {
Items []MediaSupplyResource `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type SetMediaSupplyPriceInput struct {
PriceType string
SellPriceCents int64
Enabled bool
}
type SetMediaSupplyVisibilityInput struct {
CustomerVisible bool
}
type MediaSupplyWalletStatus struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
UserName *string `json:"user_name,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
BalanceCents int64 `json:"balance_cents"`
UpdatedAt time.Time `json:"updated_at"`
LastLedgerAt *time.Time `json:"last_ledger_at,omitempty"`
LedgerEntries int64 `json:"ledger_entries"`
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
Page int
Size int
}
type ListMediaSupplyWalletsResult struct {
Items []MediaSupplyWalletStatus `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
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
DeltaCents int64
Note string
OperatorID int64
OperatorTag string
}
func (s *MediaSupplyService) ListResources(ctx context.Context, input ListMediaSupplyResourcesInput) (*ListMediaSupplyResourcesResult, error) {
if s == nil || s.pool == nil {
return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
}
page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size)
args := []any{opsMediaSupplySupplierMeijiequan}
where := []string{"r.supplier = $1", "r.deleted_at IS NULL"}
cfg := s.mediaSupplyConfig()
sellPriceSQL := opsMediaSupplySellPriceSQL(cfg)
if input.ModelID > 0 {
args = append(args, input.ModelID)
where = append(where, fmt.Sprintf("r.model_id = $%d", len(args)))
}
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
args = append(args, "%"+keyword+"%")
where = append(where, fmt.Sprintf("r.name ILIKE $%d", len(args)))
}
if keyword := strings.TrimSpace(input.RemarkKeyword); keyword != "" {
args = append(args, "%"+keyword+"%")
where = append(where, fmt.Sprintf("r.resource_remark ILIKE $%d", len(args)))
}
if value := strings.TrimSpace(input.ChannelType); value != "" {
args = append(args, value)
where = append(where, fmt.Sprintf("r.channel_type = $%d", len(args)))
}
if value := strings.TrimSpace(input.Region); value != "" {
args = append(args, value)
where = append(where, fmt.Sprintf("r.region = $%d", len(args)))
}
if value := strings.TrimSpace(input.InclusionEffect); value != "" {
args = append(args, value)
where = append(where, fmt.Sprintf("r.inclusion_effect = $%d", len(args)))
}
if value := strings.TrimSpace(input.LinkType); value != "" {
args = append(args, value)
where = append(where, fmt.Sprintf("r.link_type = $%d", len(args)))
}
if input.MinPriceCents > 0 {
args = append(args, input.MinPriceCents)
where = append(where, fmt.Sprintf("(%s) >= $%d", sellPriceSQL, len(args)))
}
if input.MaxPriceCents > 0 {
args = append(args, input.MaxPriceCents)
where = append(where, fmt.Sprintf("(%s) <= $%d", sellPriceSQL, len(args)))
}
switch strings.TrimSpace(input.Visibility) {
case "visible":
where = append(where, "r.customer_visible = TRUE")
case "hidden":
where = append(where, "r.customer_visible = FALSE")
}
whereSQL := strings.Join(where, " AND ")
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM supplier_media_resources r
LEFT JOIN supplier_media_price_overrides o
ON o.resource_id = r.id AND o.price_type = 'price'
WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, response.ErrInternal(50060, "media_supply_resource_count_failed", "媒体资源统计失败")
}
args = append(args, size, (page-1)*size)
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.supplier_resource_id, r.model_id, r.name, r.status,
r.cost_price_cents, r.cost_prices_json,
`+sellPriceSQL+` AS sell_price_cents,
r.resource_url, r.baidu_weight, r.resource_remark, r.customer_visible,
r.channel_type, r.region, r.inclusion_effect, r.link_type,
r.publish_rate, r.delivery_speed, r.last_synced_at
FROM supplier_media_resources r
LEFT JOIN supplier_media_price_overrides o
ON o.resource_id = r.id AND o.price_type = 'price'
WHERE `+whereSQL+`
ORDER BY r.updated_at DESC, r.id DESC
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+`
`, args...)
if err != nil {
return nil, response.ErrInternal(50061, "media_supply_resource_query_failed", "媒体资源读取失败")
}
defer rows.Close()
items := make([]MediaSupplyResource, 0, size)
for rows.Next() {
var item MediaSupplyResource
var costPricesRaw []byte
if err := rows.Scan(
&item.ID,
&item.SupplierResourceID,
&item.ModelID,
&item.Name,
&item.Status,
&item.CostPriceCents,
&costPricesRaw,
&item.SellPriceCents,
&item.ResourceURL,
&item.BaiduWeight,
&item.ResourceRemark,
&item.CustomerVisible,
&item.ChannelType,
&item.Region,
&item.InclusionEffect,
&item.LinkType,
&item.PublishRate,
&item.DeliverySpeed,
&item.LastSyncedAt,
); err != nil {
return nil, response.ErrInternal(50062, "media_supply_resource_scan_failed", "媒体资源解析失败")
}
_ = json.Unmarshal(costPricesRaw, &item.CostPrices)
if item.CostPrices == nil {
item.CostPrices = map[string]int64{}
}
items = append(items, item)
}
return &ListMediaSupplyResourcesResult{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyPriceInput) error {
priceType := normalizeOpsMediaSupplyPriceType(input.PriceType)
if input.SellPriceCents < 0 {
return response.ErrBadRequest(40061, "invalid_media_supply_price", "媒体售价不能为负数")
}
cost, err := s.resourceCostForPriceType(ctx, resourceID, priceType)
if err != nil {
return err
}
sellPriceCents := opsMediaSupplyRoundUpToYuan(input.SellPriceCents)
if sellPriceCents < cost {
return response.ErrConflict(40960, "media_supply_price_below_cost", "媒体售价不能低于成本价")
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO supplier_media_price_overrides (resource_id, price_type, sell_price_cents, enabled, updated_by)
VALUES ($1, $2, $3, $4, NULL)
ON CONFLICT (resource_id, price_type) DO UPDATE SET
sell_price_cents = EXCLUDED.sell_price_cents,
enabled = EXCLUDED.enabled,
updated_by = NULL,
updated_at = NOW()
`, resourceID, priceType, sellPriceCents, input.Enabled); err != nil {
return response.ErrInternal(50064, "media_supply_price_update_failed", "媒体价格更新失败")
}
if s.audits != nil && actor != nil {
_ = s.audits.Append(ctx, actor.audit("media_supply.price_update", "supplier_media_resource", resourceID, map[string]any{
"price_type": priceType,
"sell_price_cents": sellPriceCents,
"enabled": input.Enabled,
}))
}
return nil
}
func (s *MediaSupplyService) SetResourceVisibility(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyVisibilityInput) error {
tag, err := s.pool.Exec(ctx, `
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
`, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if tag.RowsAffected() == 0 {
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
if s.audits != nil && actor != nil {
action := "media_supply.resource_show"
if !input.CustomerVisible {
action = "media_supply.resource_hide"
}
_ = s.audits.Append(ctx, actor.audit(action, "supplier_media_resource", resourceID, map[string]any{
"customer_visible": input.CustomerVisible,
}))
}
return nil
}
func (s *MediaSupplyService) ListWallets(ctx context.Context, input ListMediaSupplyWalletsInput) (*ListMediaSupplyWalletsResult, error) {
page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size)
args := make([]any, 0)
where := []string{"tm.deleted_at IS NULL"}
if input.TenantID > 0 {
args = append(args, input.TenantID)
where = append(where, fmt.Sprintf("tm.tenant_id = $%d", len(args)))
}
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
args = append(args, "%"+keyword+"%")
where = append(where, fmt.Sprintf("(u.phone ILIKE $%d OR COALESCE(u.email, '') ILIKE $%d OR COALESCE(u.name, '') ILIKE $%d OR COALESCE(t.name, '') ILIKE $%d)", len(args), len(args), len(args), len(args)))
}
whereSQL := strings.Join(where, " AND ")
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM tenant_memberships tm
JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
LEFT JOIN tenants t ON t.id = tm.tenant_id AND t.deleted_at IS NULL
WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, response.ErrInternal(50088, "media_supply_wallet_count_failed", "媒体投稿余额统计失败")
}
args = append(args, size, (page-1)*size)
rows, err := s.pool.Query(ctx, `
SELECT tm.tenant_id, t.name, u.id, u.name, u.phone, COALESCE(w.balance_cents, 0),
COALESCE(w.updated_at, u.updated_at), COUNT(l.id), MAX(l.created_at)
FROM tenant_memberships tm
JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
LEFT JOIN tenants t ON t.id = tm.tenant_id AND t.deleted_at IS NULL
LEFT JOIN media_supply_user_wallets w ON w.tenant_id = tm.tenant_id AND w.user_id = tm.user_id
LEFT JOIN media_supply_wallet_ledgers l ON l.tenant_id = tm.tenant_id AND l.user_id = tm.user_id
WHERE `+whereSQL+`
GROUP BY tm.tenant_id, t.name, u.id, u.name, u.phone, w.balance_cents, w.updated_at, u.updated_at
ORDER BY COALESCE(MAX(l.created_at), COALESCE(w.updated_at, u.updated_at)) DESC, u.id DESC
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+`
`, args...)
if err != nil {
return nil, response.ErrInternal(50089, "media_supply_wallet_query_failed", "媒体投稿余额读取失败")
}
defer rows.Close()
items := make([]MediaSupplyWalletStatus, 0, size)
for rows.Next() {
var item MediaSupplyWalletStatus
var tenantName sql.NullString
var name sql.NullString
var phone sql.NullString
var lastLedgerAt sql.NullTime
if err := rows.Scan(
&item.TenantID,
&tenantName,
&item.UserID,
&name,
&phone,
&item.BalanceCents,
&item.UpdatedAt,
&item.LedgerEntries,
&lastLedgerAt,
); err != nil {
return nil, response.ErrInternal(50090, "media_supply_wallet_scan_failed", "媒体投稿余额解析失败")
}
if name.Valid {
item.UserName = &name.String
}
if tenantName.Valid {
item.TenantName = &tenantName.String
}
if phone.Valid {
item.UserPhone = &phone.String
}
if lastLedgerAt.Valid {
item.LastLedgerAt = &lastLedgerAt.Time
}
items = append(items, item)
}
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", "媒体投稿服务暂不可用")
}
if modelID <= 0 {
modelID = 1
}
if !isKnownOpsMeijiequanModel(modelID) {
return 0, response.ErrBadRequest(40060, "invalid_media_supply_model", "不支持的媒体资源类型")
}
var id int64
if err := s.pool.QueryRow(ctx, `
WITH existing AS (
SELECT id
FROM media_supply_sync_jobs
WHERE supplier = $1
AND status IN ($3::varchar, $4::varchar)
ORDER BY created_at ASC
LIMIT 1
), inserted AS (
INSERT INTO media_supply_sync_jobs (supplier, model_id, requested_by)
SELECT $1, $2, NULL
WHERE NOT EXISTS (SELECT 1 FROM existing)
RETURNING id
)
SELECT id FROM inserted
UNION ALL
SELECT id FROM existing
LIMIT 1
`, opsMediaSupplySupplierMeijiequan, modelID, "queued", "running").Scan(&id); err != nil {
return 0, response.ErrInternal(50063, "media_supply_sync_queue_failed", "媒体资源同步排队失败")
}
if s.audits != nil && actor != nil {
_ = s.audits.Append(ctx, actor.audit("media_supply.sync_queue", "media_supply_sync_job", id, map[string]any{
"model_id": modelID,
}))
}
return id, nil
}
func (s *MediaSupplyService) AdjustWallet(ctx context.Context, actor *Actor, input AdjustMediaSupplyWalletInput) (*MediaSupplyWalletStatus, error) {
if input.TenantID <= 0 || input.UserID <= 0 {
return nil, response.ErrBadRequest(40067, "invalid_media_supply_wallet_user", "租户或用户参数不正确")
}
if input.DeltaCents == 0 {
return nil, response.ErrBadRequest(40068, "invalid_media_supply_wallet_delta", "调整金额不能为 0")
}
if err := s.ensureWalletUserInTenant(ctx, input.TenantID, input.UserID); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50082, "media_supply_wallet_begin_failed", "媒体投稿余额调整失败")
}
defer tx.Rollback(ctx)
balance, err := s.lockWallet(ctx, tx, input.TenantID, input.UserID)
if err != nil {
return nil, err
}
balanceAfter := balance + input.DeltaCents
if balanceAfter < 0 {
return nil, response.ErrConflict(40965, "media_supply_balance_insufficient", "媒体投稿余额不足")
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_user_wallets
SET balance_cents = $3, updated_at = NOW()
WHERE tenant_id = $1 AND user_id = $2
`, input.TenantID, input.UserID, balanceAfter); err != nil {
return nil, response.ErrInternal(50083, "media_supply_wallet_update_failed", "媒体投稿余额更新失败")
}
reason := "adjustment"
if input.DeltaCents > 0 {
reason = "recharge"
}
note := strings.TrimSpace(input.Note)
if tag := strings.TrimSpace(input.OperatorTag); tag != "" {
if note == "" {
note = tag
} else {
note = tag + "" + note
}
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_wallet_ledgers (
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, 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", "媒体投稿账单创建失败")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50085, "media_supply_wallet_commit_failed", "媒体投稿余额调整失败")
}
if s.audits != nil && actor != nil {
_ = s.audits.Append(ctx, actor.audit("media_supply.wallet_adjust", "media_supply_wallet", input.UserID, map[string]any{
"tenant_id": input.TenantID,
"user_id": input.UserID,
"delta_cents": input.DeltaCents,
"note": input.Note,
}))
}
return &MediaSupplyWalletStatus{
TenantID: input.TenantID,
UserID: input.UserID,
BalanceCents: balanceAfter,
UpdatedAt: time.Now().UTC(),
}, nil
}
type opsMediaSupplyWalletExecutor interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}
func (s *MediaSupplyService) ensureWalletRow(ctx context.Context, q opsMediaSupplyWalletExecutor, tenantID, userID int64) error {
_, err := q.Exec(ctx, `
INSERT INTO media_supply_user_wallets (tenant_id, user_id, balance_cents)
VALUES ($1, $2, 0)
ON CONFLICT (tenant_id, user_id) DO NOTHING
`, tenantID, userID)
return err
}
func (s *MediaSupplyService) ensureWalletUserInTenant(ctx context.Context, tenantID, userID int64) error {
var exists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM tenant_memberships
WHERE tenant_id = $1 AND user_id = $2 AND deleted_at IS NULL
)
`, tenantID, userID).Scan(&exists); err != nil {
return response.ErrInternal(50086, "media_supply_wallet_user_lookup_failed", "媒体投稿余额用户校验失败")
}
if !exists {
return response.ErrNotFound(40463, "media_supply_wallet_user_not_found", "用户不存在或不属于当前租户")
}
return nil
}
func (s *MediaSupplyService) lockWallet(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) {
if err := s.ensureWalletRow(ctx, tx, tenantID, userID); err != nil {
return 0, response.ErrInternal(50077, "media_supply_wallet_prepare_failed", "媒体投稿余额初始化失败")
}
var balance int64
if err := tx.QueryRow(ctx, `
SELECT balance_cents
FROM media_supply_user_wallets
WHERE tenant_id = $1 AND user_id = $2
FOR UPDATE
`, tenantID, userID).Scan(&balance); err != nil {
return 0, response.ErrInternal(50078, "media_supply_wallet_lookup_failed", "媒体投稿余额读取失败")
}
return balance, nil
}
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
var costPrice int64
var costPricesRaw []byte
if err := s.pool.QueryRow(ctx, `
SELECT cost_price_cents, cost_prices_json
FROM supplier_media_resources
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
`, resourceID, opsMediaSupplySupplierMeijiequan).Scan(&costPrice, &costPricesRaw); err != nil {
if err == pgx.ErrNoRows {
return 0, response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
return 0, response.ErrInternal(50071, "media_supply_resource_lookup_failed", "媒体资源读取失败")
}
var costPrices map[string]int64
_ = json.Unmarshal(costPricesRaw, &costPrices)
if priceTypeCost := costPrices[priceType]; priceTypeCost > 0 {
return priceTypeCost, nil
}
return costPrice, nil
}
func (s *MediaSupplyService) mediaSupplyConfig() sharedconfig.MediaSupplyConfig {
if s != nil && s.cfgFunc != nil {
cfg := s.cfgFunc()
sharedconfig.NormalizeMediaSupplyConfig(&cfg)
return cfg
}
var cfg sharedconfig.MediaSupplyConfig
sharedconfig.NormalizeMediaSupplyConfig(&cfg)
return cfg
}
func normalizeOpsMediaSupplyPagination(page, size int) (int, int) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func normalizeOpsMediaSupplyPriceType(priceType string) string {
priceType = strings.ToLower(strings.TrimSpace(priceType))
if priceType == "" {
return "price"
}
return priceType
}
func nullableOpsMediaSupplyString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func opsMediaSupplySellPrice(costCents, overrideCents int64, overrideEnabled bool, markupPercent float64, minimumMarkupCents int64) int64 {
floor := costCents
if markupPercent > 0 {
floor = int64(math.Ceil(float64(costCents) * (1 + markupPercent/100)))
}
if minimumMarkupCents > 0 && costCents+minimumMarkupCents > floor {
floor = costCents + minimumMarkupCents
}
if overrideEnabled && overrideCents >= costCents {
return opsMediaSupplyRoundUpToYuan(overrideCents)
}
return opsMediaSupplyRoundUpToYuan(floor)
}
func opsMediaSupplyRoundUpToYuan(cents int64) int64 {
if cents <= 0 {
return 0
}
return ((cents + 99) / 100) * 100
}
func opsMediaSupplySellPriceSQL(cfg sharedconfig.MediaSupplyConfig) string {
floorSQL := "r.cost_price_cents"
if cfg.DefaultMarkupPercent > 0 {
multiplier := 1 + cfg.DefaultMarkupPercent/100
floorSQL = fmt.Sprintf("CEIL(r.cost_price_cents * %.8f)::BIGINT", multiplier)
}
if cfg.MinimumMarkupCents > 0 {
floorSQL = fmt.Sprintf("GREATEST(%s, r.cost_price_cents + %d)", floorSQL, cfg.MinimumMarkupCents)
}
rawPriceSQL := fmt.Sprintf("CASE WHEN COALESCE(o.enabled, false) AND COALESCE(o.sell_price_cents, 0) >= r.cost_price_cents THEN o.sell_price_cents ELSE (%s) END", floorSQL)
return fmt.Sprintf("CEIL((%s) / 100.0)::BIGINT * 100", rawPriceSQL)
}
func isKnownOpsMeijiequanModel(modelID int) bool {
switch modelID {
case 1, 2, 10, 3, 4, 7, 5, 6, 9, 11, 13:
return true
default:
return false
}
}