2026-05-29 23:17:01 +08:00
package app
import (
"context"
"database/sql"
"encoding/json"
2026-07-10 20:10:50 +08:00
"errors"
2026-05-29 23:17:01 +08:00
"fmt"
"math"
2026-06-29 23:13:33 +08:00
"strconv"
2026-05-29 23:17:01 +08:00
"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" `
}
2026-06-29 23:13:33 +08:00
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" `
}
2026-05-29 23:17:01 +08:00
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" `
}
2026-06-29 23:13:33 +08:00
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" `
}
2026-05-29 23:17:01 +08:00
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 {
2026-07-10 20:10:50 +08:00
tx , err := s . pool . Begin ( ctx )
if err != nil {
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
defer tx . Rollback ( ctx )
if ! input . CustomerVisible {
ownerRows , err := tx . Query ( ctx , `
SELECT favorite_owner.tenant_id, favorite_owner.user_id
FROM media_supply_favorite_owners favorite_owner
WHERE EXISTS (
SELECT 1
FROM media_supply_favorite_resources favorite
WHERE favorite.tenant_id = favorite_owner.tenant_id
AND favorite.user_id = favorite_owner.user_id
AND favorite.resource_id = $1
)
ORDER BY favorite_owner.tenant_id, favorite_owner.user_id
FOR UPDATE OF favorite_owner
` , resourceID )
if err != nil {
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
for ownerRows . Next ( ) {
var tenantID , userID int64
if err := ownerRows . Scan ( & tenantID , & userID ) ; err != nil {
ownerRows . Close ( )
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
}
if err := ownerRows . Err ( ) ; err != nil {
ownerRows . Close ( )
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
ownerRows . Close ( )
}
var lockedResourceID int64
if err := tx . QueryRow ( ctx , `
SELECT id
FROM supplier_media_resources
2026-05-29 23:17:01 +08:00
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
2026-07-10 20:10:50 +08:00
FOR UPDATE
` , resourceID , opsMediaSupplySupplierMeijiequan ) . Scan ( & lockedResourceID ) ; err != nil {
if errors . Is ( err , pgx . ErrNoRows ) {
return response . ErrNotFound ( 40460 , "media_supply_resource_not_found" , "媒体资源不存在或已下架" )
}
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
var updated , removedFavorites int
err = tx . QueryRow ( ctx , `
WITH updated_resource AS (
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
RETURNING id
), deleted_favorites AS (
DELETE FROM media_supply_favorite_resources favorite
USING updated_resource resource
WHERE $3 = FALSE AND favorite.resource_id = resource.id
RETURNING favorite.tenant_id, favorite.user_id, favorite.group_key
), touched_groups AS (
UPDATE media_supply_favorite_groups favorite_group
SET updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_group.tenant_id
AND deleted.user_id = favorite_group.user_id
AND deleted.group_key = favorite_group.group_key
)
RETURNING favorite_group.tenant_id, favorite_group.user_id
), touched_owners AS (
UPDATE media_supply_favorite_owners favorite_owner
SET revision = revision + 1, updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_owner.tenant_id
AND deleted.user_id = favorite_owner.user_id
)
RETURNING favorite_owner.tenant_id
)
SELECT (SELECT COUNT(*) FROM updated_resource),
(SELECT COUNT(*) FROM deleted_favorites)
` , resourceID , opsMediaSupplySupplierMeijiequan , input . CustomerVisible ) . Scan ( & updated , & removedFavorites )
2026-05-29 23:17:01 +08:00
if err != nil {
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
2026-07-10 20:10:50 +08:00
if updated == 0 {
2026-05-29 23:17:01 +08:00
return response . ErrNotFound ( 40460 , "media_supply_resource_not_found" , "媒体资源不存在或已下架" )
}
2026-07-10 20:10:50 +08:00
if err := tx . Commit ( ctx ) ; err != nil {
return response . ErrInternal ( 50087 , "media_supply_visibility_update_failed" , "媒体展示状态更新失败" )
}
2026-05-29 23:17:01 +08:00
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 {
2026-07-10 20:10:50 +08:00
"customer_visible" : input . CustomerVisible ,
"removed_favorite_count" : removedFavorites ,
2026-05-29 23:17:01 +08:00
} ) )
}
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
}
2026-06-29 23:13:33 +08:00
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
}
2026-05-29 23:17:01 +08:00
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 (
2026-06-29 23:13:33 +08:00
tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by,
sales_cents, cost_cents, gross_profit_cents
2026-05-29 23:17:01 +08:00
)
2026-06-29 23:13:33 +08:00
VALUES ($1, $2, $3, $4, $5, $6, NULL, 0, 0, 0)
2026-05-29 23:17:01 +08:00
` , 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
}
}