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
+217
View File
@@ -41,6 +41,7 @@ type Config struct {
Retrieval RetrievalConfig `mapstructure:"retrieval"`
Generation GenerationConfig `mapstructure:"generation"`
BrowserFetch BrowserFetchConfig `mapstructure:"browser_fetch"`
MediaSupply MediaSupplyConfig `mapstructure:"media_supply"`
}
type ServerConfig struct {
@@ -409,6 +410,41 @@ type BrowserFetchConfig struct {
TriggerStatus []int `mapstructure:"trigger_status"`
}
type MediaSupplyConfig struct {
Enabled bool `mapstructure:"enabled"`
DefaultMarkupPercent float64 `mapstructure:"default_markup_percent"`
MinimumMarkupCents int64 `mapstructure:"minimum_markup_cents"`
Worker MediaSupplyWorkerConfig `mapstructure:"worker"`
Meijiequan MeijiequanConfig `mapstructure:"meijiequan"`
}
type MediaSupplyWorkerConfig struct {
Enabled bool `mapstructure:"enabled"`
PollInterval time.Duration `mapstructure:"poll_interval"`
BatchSize int `mapstructure:"batch_size"`
OrderMaxAttempts int `mapstructure:"order_max_attempts"`
SyncMaxAttempts int `mapstructure:"sync_max_attempts"`
BacklinkSyncInterval time.Duration `mapstructure:"backlink_sync_interval"`
BacklinkBatchSize int `mapstructure:"backlink_batch_size"`
}
type MeijiequanConfig struct {
BaseURL string `mapstructure:"base_url"`
UserID string `mapstructure:"user_id"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
SessionTTL time.Duration `mapstructure:"session_ttl"`
RequestTimeout time.Duration `mapstructure:"request_timeout"`
SyncPageDelay time.Duration `mapstructure:"sync_page_delay"`
SyncPageSize int `mapstructure:"sync_page_size"`
MaxSyncPages int `mapstructure:"max_sync_pages"`
PublishedPageSize int `mapstructure:"published_page_size"`
PublishedMaxPages int `mapstructure:"published_max_pages"`
SearchOptionsTTL time.Duration `mapstructure:"search_options_ttl"`
UpstreamLockTTL time.Duration `mapstructure:"upstream_lock_ttl"`
UpstreamMinInterval time.Duration `mapstructure:"upstream_min_interval"`
}
func Load(configPath string) (*Config, error) {
cfg, _, err := loadWithFiles(configPath)
return cfg, err
@@ -444,6 +480,7 @@ func loadWithFiles(configPath string) (*Config, []string, error) {
NormalizeComplianceConfig(&cfg.Compliance)
NormalizeGenerationConfig(&cfg.Generation)
NormalizeBrowserFetchConfig(&cfg.BrowserFetch)
NormalizeMediaSupplyConfig(&cfg.MediaSupply)
files := []string{configFile}
if localConfigFile != "" {
@@ -781,6 +818,88 @@ func NormalizeBrowserFetchConfig(cfg *BrowserFetchConfig) {
}
}
func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) {
if cfg == nil {
return
}
if cfg.DefaultMarkupPercent < 0 {
cfg.DefaultMarkupPercent = 0
}
if cfg.MinimumMarkupCents < 0 {
cfg.MinimumMarkupCents = 0
}
if cfg.Worker.PollInterval <= 0 {
cfg.Worker.PollInterval = 5 * time.Second
}
if cfg.Worker.BatchSize <= 0 {
cfg.Worker.BatchSize = 1
}
if cfg.Worker.BatchSize > 10 {
cfg.Worker.BatchSize = 10
}
if cfg.Worker.OrderMaxAttempts <= 0 {
cfg.Worker.OrderMaxAttempts = 3
}
if cfg.Worker.SyncMaxAttempts <= 0 {
cfg.Worker.SyncMaxAttempts = 2
}
if cfg.Worker.BacklinkSyncInterval <= 0 {
cfg.Worker.BacklinkSyncInterval = 10 * time.Minute
}
if cfg.Worker.BacklinkBatchSize <= 0 {
cfg.Worker.BacklinkBatchSize = 20
}
if cfg.Worker.BacklinkBatchSize > 100 {
cfg.Worker.BacklinkBatchSize = 100
}
cfg.Meijiequan.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.Meijiequan.BaseURL), "/")
if cfg.Meijiequan.BaseURL == "" {
cfg.Meijiequan.BaseURL = "http://www.meijiequan.com"
}
cfg.Meijiequan.UserID = strings.TrimSpace(cfg.Meijiequan.UserID)
cfg.Meijiequan.Username = strings.TrimSpace(cfg.Meijiequan.Username)
cfg.Meijiequan.Password = strings.TrimSpace(cfg.Meijiequan.Password)
if cfg.Meijiequan.SessionTTL <= 0 {
cfg.Meijiequan.SessionTTL = 4 * time.Hour
}
if cfg.Meijiequan.RequestTimeout <= 0 {
cfg.Meijiequan.RequestTimeout = 30 * time.Second
}
if cfg.Meijiequan.SyncPageDelay <= 0 {
cfg.Meijiequan.SyncPageDelay = 800 * time.Millisecond
}
if cfg.Meijiequan.SyncPageSize <= 0 {
cfg.Meijiequan.SyncPageSize = 100
}
if cfg.Meijiequan.SyncPageSize > 500 {
cfg.Meijiequan.SyncPageSize = 500
}
if cfg.Meijiequan.MaxSyncPages <= 0 {
cfg.Meijiequan.MaxSyncPages = 200
}
if cfg.Meijiequan.PublishedPageSize <= 0 {
cfg.Meijiequan.PublishedPageSize = 20
}
if cfg.Meijiequan.PublishedPageSize > 100 {
cfg.Meijiequan.PublishedPageSize = 100
}
if cfg.Meijiequan.PublishedMaxPages <= 0 {
cfg.Meijiequan.PublishedMaxPages = 3
}
if cfg.Meijiequan.PublishedMaxPages > 20 {
cfg.Meijiequan.PublishedMaxPages = 20
}
if cfg.Meijiequan.SearchOptionsTTL <= 0 {
cfg.Meijiequan.SearchOptionsTTL = 12 * time.Hour
}
if cfg.Meijiequan.UpstreamLockTTL <= 0 {
cfg.Meijiequan.UpstreamLockTTL = 2 * time.Minute
}
if cfg.Meijiequan.UpstreamMinInterval <= 0 {
cfg.Meijiequan.UpstreamMinInterval = cfg.Meijiequan.SyncPageDelay
}
}
func NormalizeComplianceConfig(cfg *ComplianceConfig) {
if cfg == nil {
return
@@ -930,6 +1049,78 @@ func applyEnvOverrides(cfg *Config) {
if statuses, ok := lookupNonEmptyEnv("BROWSER_FETCH_TRIGGER_STATUS"); ok {
cfg.BrowserFetch.TriggerStatus = parseIntList(statuses)
}
if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_ENABLED"); ok {
cfg.MediaSupply.Enabled = enabled
}
if markup, ok := lookupFloatEnv("MEDIA_SUPPLY_DEFAULT_MARKUP_PERCENT"); ok {
cfg.MediaSupply.DefaultMarkupPercent = markup
}
if cents, ok := lookupInt64Env("MEDIA_SUPPLY_MINIMUM_MARKUP_CENTS"); ok {
cfg.MediaSupply.MinimumMarkupCents = cents
}
if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_WORKER_ENABLED"); ok {
cfg.MediaSupply.Worker.Enabled = enabled
}
if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_WORKER_POLL_INTERVAL"); ok {
cfg.MediaSupply.Worker.PollInterval = interval
}
if n, ok := lookupIntEnv("MEDIA_SUPPLY_WORKER_BATCH_SIZE"); ok {
cfg.MediaSupply.Worker.BatchSize = n
}
if n, ok := lookupIntEnv("MEDIA_SUPPLY_ORDER_MAX_ATTEMPTS"); ok {
cfg.MediaSupply.Worker.OrderMaxAttempts = n
}
if n, ok := lookupIntEnv("MEDIA_SUPPLY_SYNC_MAX_ATTEMPTS"); ok {
cfg.MediaSupply.Worker.SyncMaxAttempts = n
}
if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL"); ok {
cfg.MediaSupply.Worker.BacklinkSyncInterval = interval
}
if n, ok := lookupIntEnv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE"); ok {
cfg.MediaSupply.Worker.BacklinkBatchSize = n
}
if baseURL, ok := lookupNonEmptyEnv("MEIJIEQUAN_BASE_URL"); ok {
cfg.MediaSupply.Meijiequan.BaseURL = baseURL
}
if userID, ok := lookupNonEmptyEnv("MEIJIEQUAN_USER_ID"); ok {
cfg.MediaSupply.Meijiequan.UserID = userID
}
if username, ok := lookupNonEmptyEnv("MEIJIEQUAN_USERNAME"); ok {
cfg.MediaSupply.Meijiequan.Username = username
}
if password, ok := lookupNonEmptyEnv("MEIJIEQUAN_PASSWORD"); ok {
cfg.MediaSupply.Meijiequan.Password = password
}
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SESSION_TTL"); ok {
cfg.MediaSupply.Meijiequan.SessionTTL = ttl
}
if timeout, ok := lookupDurationEnv("MEIJIEQUAN_REQUEST_TIMEOUT"); ok {
cfg.MediaSupply.Meijiequan.RequestTimeout = timeout
}
if delay, ok := lookupDurationEnv("MEIJIEQUAN_SYNC_PAGE_DELAY"); ok {
cfg.MediaSupply.Meijiequan.SyncPageDelay = delay
}
if n, ok := lookupIntEnv("MEIJIEQUAN_SYNC_PAGE_SIZE"); ok {
cfg.MediaSupply.Meijiequan.SyncPageSize = n
}
if n, ok := lookupIntEnv("MEIJIEQUAN_MAX_SYNC_PAGES"); ok {
cfg.MediaSupply.Meijiequan.MaxSyncPages = n
}
if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE"); ok {
cfg.MediaSupply.Meijiequan.PublishedPageSize = n
}
if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_MAX_PAGES"); ok {
cfg.MediaSupply.Meijiequan.PublishedMaxPages = n
}
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SEARCH_OPTIONS_TTL"); ok {
cfg.MediaSupply.Meijiequan.SearchOptionsTTL = ttl
}
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_LOCK_TTL"); ok {
cfg.MediaSupply.Meijiequan.UpstreamLockTTL = ttl
}
if interval, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL"); ok {
cfg.MediaSupply.Meijiequan.UpstreamMinInterval = interval
}
if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok {
cfg.Qdrant.APIKey = apiKey
}
@@ -1468,6 +1659,32 @@ func lookupIntEnv(key string) (int, bool) {
return number, true
}
func lookupInt64Env(key string) (int64, bool) {
value, ok := lookupNonEmptyEnv(key)
if !ok {
return 0, false
}
number, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, false
}
return number, true
}
func lookupFloatEnv(key string) (float64, bool) {
value, ok := lookupNonEmptyEnv(key)
if !ok {
return 0, false
}
number, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, false
}
return number, true
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback
@@ -368,6 +368,46 @@ cache:
}
}
func TestLoadAppliesMediaSupplyMeijiequanAccountOverrides(t *testing.T) {
t.Setenv("MEIJIEQUAN_USERNAME", " 17788409108 ")
t.Setenv("MEIJIEQUAN_PASSWORD", " env-password ")
t.Setenv("MEIJIEQUAN_SYNC_PAGE_DELAY", "1500ms")
t.Setenv("MEIJIEQUAN_SEARCH_OPTIONS_TTL", "2h")
t.Setenv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL", "3s")
t.Setenv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL", "15m")
t.Setenv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE", "12")
t.Setenv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE", "30")
t.Setenv("MEIJIEQUAN_PUBLISHED_MAX_PAGES", "4")
configPath := writeTestConfig(t, `
media_supply:
meijiequan:
base_url: "http://www.meijiequan.com/"
user_id: " 962 "
username: config-user
password: config-password
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.MediaSupply.Meijiequan.BaseURL != "http://www.meijiequan.com" ||
cfg.MediaSupply.Meijiequan.UserID != "962" ||
cfg.MediaSupply.Meijiequan.Username != "17788409108" ||
cfg.MediaSupply.Meijiequan.Password != "env-password" ||
cfg.MediaSupply.Meijiequan.SyncPageDelay != 1500*time.Millisecond ||
cfg.MediaSupply.Meijiequan.SearchOptionsTTL != 2*time.Hour ||
cfg.MediaSupply.Meijiequan.UpstreamMinInterval != 3*time.Second ||
cfg.MediaSupply.Worker.BacklinkSyncInterval != 15*time.Minute ||
cfg.MediaSupply.Worker.BacklinkBatchSize != 12 ||
cfg.MediaSupply.Meijiequan.PublishedPageSize != 30 ||
cfg.MediaSupply.Meijiequan.PublishedMaxPages != 4 {
t.Fatalf("unexpected meijiequan config: %#v", cfg.MediaSupply.Meijiequan)
}
}
func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) {
t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0")
t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token")
+6
View File
@@ -92,6 +92,12 @@ func Diff(previous, current *Config) []FieldChange {
} else if !reflect.DeepEqual(previous.BrowserFetch, current.BrowserFetch) {
addChange("browser_fetch", true)
}
if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval ||
previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize {
addChange("media_supply.worker", false)
} else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) {
addChange("media_supply", true)
}
if previous.Generation.QueueSize != current.Generation.QueueSize ||
previous.Generation.WorkerConcurrency != current.Generation.WorkerConcurrency {
addChange("generation.worker", false)