869 lines
30 KiB
Go
869 lines
30 KiB
Go
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,
|
||
¬e,
|
||
&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 = ¬e.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
|
||
}
|
||
}
|