9d6181260a
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Introduce an end-to-end media-supply feature: tenant-side resource sync service/worker backed by a Meijiequan supplier client, ops-side management APIs, and admin/ops web views for resources, orders, favorites and submission. Adds a shared digitocr helper, MediaSupply config blocks for tenant and ops, shared types, and migrations for supplier media resources, price overrides, customer visibility and order refunds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
632 lines
22 KiB
Go
632 lines
22 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"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 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 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) 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
|
||
)
|
||
VALUES ($1, $2, $3, $4, $5, $6, NULL)
|
||
`, 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
|
||
}
|
||
}
|