feat(media-supply): add media resource supply marketplace
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>
This commit is contained in:
2026-05-29 23:17:01 +08:00
parent 607a3fffe7
commit 9d6181260a
121 changed files with 14909 additions and 36 deletions
+631
View File
@@ -0,0 +1,631 @@
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
}
}
+23
View File
@@ -28,6 +28,7 @@ type Config struct {
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"`
Log sharedconfig.LogConfig `mapstructure:"log"`
MediaSupply sharedconfig.MediaSupplyConfig `mapstructure:"media_supply"`
JWT JWTConfig `mapstructure:"jwt"`
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
@@ -244,6 +245,12 @@ func Diff(previous, current *Config) []FieldChange {
if previous.Log != current.Log {
add("log", false)
}
if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval ||
previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize {
add("media_supply.worker", false)
} else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) {
add("media_supply", true)
}
if previous.IPRegion != current.IPRegion {
add("ip_region", false)
}
@@ -360,6 +367,7 @@ func normalizeConfig(cfg *Config) {
}
sharedconfig.NormalizeCacheConfig(&cfg.Cache)
normalizeOpsRabbitMQConfig(&cfg.RabbitMQ)
sharedconfig.NormalizeMediaSupplyConfig(&cfg.MediaSupply)
if strings.TrimSpace(cfg.Log.Level) == "" {
cfg.Log.Level = "info"
}
@@ -395,6 +403,21 @@ func defaultSettings() map[string]any {
"driver": "redis",
},
"rabbitmq": map[string]any{},
"media_supply": map[string]any{
"enabled": true,
"default_markup_percent": 50,
"minimum_markup_cents": 0,
"worker": map[string]any{
"enabled": true,
"poll_interval": 5 * time.Second,
"batch_size": 1,
"order_max_attempts": 3,
"sync_max_attempts": 2,
},
"meijiequan": map[string]any{
"base_url": "http://www.meijiequan.com",
},
},
"log": map[string]any{
"level": "info",
"format": "json",
@@ -0,0 +1,201 @@
package transport
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type setMediaSupplyPriceRequest struct {
PriceType string `json:"price_type"`
SellPriceCents int64 `json:"sell_price_cents"`
Enabled *bool `json:"enabled,omitempty"`
}
type setMediaSupplyVisibilityRequest struct {
CustomerVisible bool `json:"customer_visible"`
}
type adjustMediaSupplyWalletRequest struct {
TenantID int64 `json:"tenant_id" binding:"required"`
UserID int64 `json:"user_id" binding:"required"`
DeltaCents int64 `json:"delta_cents" binding:"required"`
Note string `json:"note"`
}
func listMediaSupplyResourcesHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
modelID, err := parseOptionalOpsIntQuery(c, "model_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
return
}
minPriceCents, err := parseOptionalOpsInt64Query(c, "min_price_cents")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_min_price_cents", "最低价格参数必须是数字"))
return
}
maxPriceCents, err := parseOptionalOpsInt64Query(c, "max_price_cents")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_max_price_cents", "最高价格参数必须是数字"))
return
}
page, _ := parseOptionalOpsIntQuery(c, "page")
size, _ := parseOptionalOpsIntQuery(c, "page_size")
result, err := svc.ListResources(c.Request.Context(), app.ListMediaSupplyResourcesInput{
ModelID: modelID,
Keyword: c.Query("keyword"),
RemarkKeyword: c.Query("remark_keyword"),
ChannelType: c.Query("channel_type"),
Region: c.Query("region"),
InclusionEffect: c.Query("inclusion_effect"),
LinkType: c.Query("link_type"),
MinPriceCents: minPriceCents,
MaxPriceCents: maxPriceCents,
Visibility: c.Query("visibility"),
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func setMediaSupplyResourcePriceHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body setMediaSupplyPriceRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "价格参数不正确"))
return
}
enabled := true
if body.Enabled != nil {
enabled = *body.Enabled
}
if err := svc.SetResourcePrice(c.Request.Context(), actorFromGin(c), id, app.SetMediaSupplyPriceInput{
PriceType: body.PriceType,
SellPriceCents: body.SellPriceCents,
Enabled: enabled,
}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"updated": true})
}
}
func setMediaSupplyResourceVisibilityHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body setMediaSupplyVisibilityRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "展示状态参数不正确"))
return
}
if err := svc.SetResourceVisibility(c.Request.Context(), actorFromGin(c), id, app.SetMediaSupplyVisibilityInput{
CustomerVisible: body.CustomerVisible,
}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"updated": true})
}
}
func queueMediaSupplySyncHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
ModelID int `json:"model_id"`
}
if c.Request.Body != nil {
_ = c.ShouldBindJSON(&body)
}
jobID, err := svc.QueueSync(c.Request.Context(), actorFromGin(c), body.ModelID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"job_id": jobID})
}
}
func listMediaSupplyWalletsHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
tenantID, err := parseOptionalOpsInt64Query(c, "tenant_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_tenant_id", "租户参数必须是数字"))
return
}
page, _ := parseOptionalOpsIntQuery(c, "page")
size, _ := parseOptionalOpsIntQuery(c, "page_size")
result, err := svc.ListWallets(c.Request.Context(), app.ListMediaSupplyWalletsInput{
Keyword: c.Query("keyword"),
TenantID: tenantID,
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func adjustMediaSupplyWalletHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
return func(c *gin.Context) {
var body adjustMediaSupplyWalletRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "余额调整参数不正确"))
return
}
actor := actorFromGin(c)
operatorTag := "ops manual adjustment"
if actor != nil && actor.DisplayName != "" {
operatorTag = "ops " + actor.DisplayName
}
result, err := svc.AdjustWallet(c.Request.Context(), actor, app.AdjustMediaSupplyWalletInput{
TenantID: body.TenantID,
UserID: body.UserID,
DeltaCents: body.DeltaCents,
Note: body.Note,
OperatorTag: operatorTag,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func parseOptionalOpsIntQuery(c *gin.Context, key string) (int, error) {
raw := c.Query(key)
if raw == "" {
return 0, nil
}
return strconv.Atoi(raw)
}
func parseOptionalOpsInt64Query(c *gin.Context, key string) (int64, error) {
raw := c.Query(key)
if raw == "" {
return 0, nil
}
return strconv.ParseInt(raw, 10, 64)
}
+8
View File
@@ -30,6 +30,7 @@ type Deps struct {
ObjectStorage *app.ObjectStorageService
Compliance *app.ComplianceService
Scheduler *app.SchedulerService
MediaSupply *app.MediaSupplyService
}
func (d Deps) ServerAllowedOrigins() []string {
@@ -123,6 +124,13 @@ func RegisterRoutes(d Deps) {
authed.POST("/jobs/:source/:id/retry", retryJobHandler(d.Jobs))
authed.POST("/jobs/:source/:id/cancel", cancelJobHandler(d.Jobs))
authed.GET("/media-supply/resources", listMediaSupplyResourcesHandler(d.MediaSupply))
authed.PUT("/media-supply/resources/:id/price", setMediaSupplyResourcePriceHandler(d.MediaSupply))
authed.PUT("/media-supply/resources/:id/visibility", setMediaSupplyResourceVisibilityHandler(d.MediaSupply))
authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply))
authed.GET("/media-supply/wallets", listMediaSupplyWalletsHandler(d.MediaSupply))
authed.POST("/media-supply/wallets/adjustments", adjustMediaSupplyWalletHandler(d.MediaSupply))
authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler))
authed.GET("/scheduler/jobs/:key", getSchedulerJobHandler(d.Scheduler))
authed.PATCH("/scheduler/jobs/:key", updateSchedulerJobHandler(d.Scheduler))