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
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user