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
@@ -874,11 +874,7 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de
}
}
eventType := "task_reconciled"
if task.Kind == "monitor" && req.Status == "retry" {
eventType = "task_available"
}
s.publishTaskEvent(ctx, task, eventType)
s.publishTaskEvent(ctx, task, reconcileDesktopTaskEventType(task.Kind, req.Status))
view := buildDesktopTaskView(task)
return &view, nil
@@ -1785,6 +1781,13 @@ func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *reposit
publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, task, eventType)
}
func reconcileDesktopTaskEventType(_ string, status string) string {
if status == "retry" {
return "task_available"
}
return "task_reconciled"
}
func (s *DesktopTaskService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil {
return
@@ -45,6 +45,24 @@ func TestDesktopTaskRecoverySelectQueryLeaseExpiryFiltersExpiredLeases(t *testin
}
}
func TestReconcileRetryPublishesTaskAvailableForAllDesktopTaskKinds(t *testing.T) {
t.Parallel()
for _, kind := range []string{"publish", "monitor"} {
if got := reconcileDesktopTaskEventType(kind, "retry"); got != "task_available" {
t.Fatalf("reconcileDesktopTaskEventType(%q, retry) = %q, want task_available", kind, got)
}
}
}
func TestReconcileNonRetryPublishesTaskReconciled(t *testing.T) {
t.Parallel()
if got := reconcileDesktopTaskEventType("publish", "failed"); got != "task_reconciled" {
t.Fatalf("reconcileDesktopTaskEventType(publish, failed) = %q, want task_reconciled", got)
}
}
func recoverDesktopTaskSelectColumns(query string) []string {
re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`)
match := re.FindStringSubmatch(query)
@@ -0,0 +1,98 @@
package app
import (
"errors"
"strings"
)
const mediaSupplyPublicSupplierCode = "authority_news"
var errMediaSupplyAccountUserIDMissing = errors.New("media supply account user_id is not configured")
func publicMediaSupplySupplierCode(supplier string) string {
supplier = strings.TrimSpace(supplier)
if supplier == "" {
return ""
}
if strings.EqualFold(supplier, mediaSupplySupplierMeijiequan) {
return mediaSupplyPublicSupplierCode
}
return "media_supply"
}
func publicMediaSupplyArticleURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if isLikelyPublishedArticleURL(value) {
return value
}
return ""
}
func mediaSupplyPublicErrorMessage(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, errMeijiequanCredentialsMissing):
return "媒体供应商账号未配置,请联系管理员处理"
case errors.Is(err, errMeijiequanSessionMissing), errors.Is(err, errMeijiequanSessionExpired), errors.Is(err, errMeijiequanAuthRequired):
return "媒体供应商登录状态已失效,请联系管理员处理"
case errors.Is(err, errMeijiequanChallengeRequired):
return "媒体供应商登录验证失败,请联系管理员处理"
case errors.Is(err, errMeijiequanOrderRejected):
return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理"
case errors.Is(err, errMediaSupplySelectedResourcesMissing):
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体"
case errors.Is(err, errMediaSupplyLockedSellBelowCost):
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
case errors.Is(err, errMediaSupplyAccountUserIDMissing):
return "媒体供应商账号信息不完整,请联系管理员处理"
default:
return mediaSupplyPublicGenericErrorMessage(err.Error())
}
}
func mediaSupplyPublicGenericErrorMessage(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return "媒体投稿失败,请稍后重试或联系管理员"
}
lower := strings.ToLower(message)
switch {
case strings.Contains(lower, "failed to refresh supplier price"):
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体"
case strings.Contains(lower, "supplier cost increased above locked sell price"):
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"):
return "媒体供应商账号未配置,请联系管理员处理"
case strings.Contains(lower, "session") || strings.Contains(lower, "authentication") || strings.Contains(lower, "auth required"):
return "媒体供应商登录状态已失效,请联系管理员处理"
case strings.Contains(lower, "challenge") || strings.Contains(lower, "captcha"):
return "媒体供应商登录验证失败,请联系管理员处理"
case strings.Contains(lower, "user_id"):
return "媒体供应商账号信息不完整,请联系管理员处理"
case strings.Contains(lower, "meijiequan"):
return "媒体供应商请求失败,请稍后重试或联系管理员"
case strings.Contains(lower, "supplier"):
return "媒体投稿失败,请稍后重试或联系管理员"
case strings.Contains(lower, "failed to"):
return "媒体投稿失败,请稍后重试或联系管理员"
default:
if hasCJKText(message) {
return message
}
return "媒体投稿失败,请稍后重试或联系管理员"
}
}
func hasCJKText(message string) bool {
for _, r := range message {
if r >= '\u4e00' && r <= '\u9fff' {
return true
}
}
return false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,574 @@
package app
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
goredis "github.com/redis/go-redis/v9"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
type mediaSupplyOrderScanStub struct {
values []any
}
func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
for i, value := range s.values {
switch target := dest[i].(type) {
case *int:
*target = value.(int)
case *int64:
*target = value.(int64)
case *string:
*target = value.(string)
case **string:
*target = value.(*string)
case **int64:
*target = value.(*int64)
case *time.Time:
*target = value.(time.Time)
case **time.Time:
*target = value.(*time.Time)
default:
panic("unsupported scan target")
}
}
return nil
}
func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) {
got := mediaSupplySellPrice(1000, 900, true, 0, 0)
if got != 1000 {
t.Fatalf("expected cost floor 1000, got %d", got)
}
got = mediaSupplySellPrice(1000, 1100, true, 20, 0)
if got != 1100 {
t.Fatalf("expected explicit override above cost 1100, got %d", got)
}
got = mediaSupplySellPrice(1000, 1400, true, 20, 0)
if got != 1400 {
t.Fatalf("expected override above floor 1400, got %d", got)
}
}
func TestMediaSupplySellPricePrefersManualOverride(t *testing.T) {
got := mediaSupplySellPrice(1000, 1100, true, 50, 0)
if got != 1100 {
t.Fatalf("expected manual override 1100 to win over 50%% markup floor, got %d", got)
}
got = mediaSupplySellPrice(1000, 1101, true, 50, 0)
if got != 1200 {
t.Fatalf("expected manual override 1101 to round up to 1200, got %d", got)
}
}
func TestMediaSupplySellPriceRoundsUpToWholeYuan(t *testing.T) {
got := mediaSupplySellPrice(8040, 0, false, 0, 0)
if got != 8100 {
t.Fatalf("expected 8040 cents to round up to 8100, got %d", got)
}
got = mediaSupplySellPrice(7001, 0, false, 0, 0)
if got != 7100 {
t.Fatalf("expected 7001 cents to round up to 7100, got %d", got)
}
got = mediaSupplySellPrice(7000, 0, false, 0, 0)
if got != 7000 {
t.Fatalf("expected whole yuan price to stay 7000, got %d", got)
}
}
func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) {
got := mediaSupplySellPrice(300, 0, false, 50, 0)
if got != 500 {
t.Fatalf("expected 300 cents with 50%% markup to round up to 500, got %d", got)
}
got = mediaSupplySellPrice(800, 0, false, 50, 0)
if got != 1200 {
t.Fatalf("expected 800 cents with 50%% markup to be 1200, got %d", got)
}
got = mediaSupplySellPrice(8040, 0, false, 50, 0)
if got != 12100 {
t.Fatalf("expected 8040 cents with 50%% markup to round up to 12100, got %d", got)
}
}
func TestMediaSupplyResourceOrderSQL(t *testing.T) {
priceSQL := "sell_price_expr"
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
t.Fatalf("unexpected price order SQL: %s", got)
}
if got := mediaSupplyResourceOrderSQL("weight", priceSQL); got != "COALESCE(r.baidu_weight, 0) DESC, r.id DESC" {
t.Fatalf("unexpected weight order SQL: %s", got)
}
if got := mediaSupplyResourceOrderSQL("unknown", priceSQL); got != "r.updated_at DESC, r.id DESC" {
t.Fatalf("unexpected default order SQL: %s", got)
}
}
func TestFormatMeijiequanResourceID(t *testing.T) {
item := MediaSupplyOrderItem{SupplierResourceID: "36112", PriceType: "price"}
if got := formatMeijiequanResourceID(item); got != "36112_price_0" {
t.Fatalf("unexpected default price resource id: %s", got)
}
item.PriceType = "priceyc"
if got := formatMeijiequanResourceID(item); got != "36112_priceyc_0" {
t.Fatalf("unexpected variant resource id: %s", got)
}
}
func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T) {
terminalErrors := []error{
errMeijiequanSessionMissing,
errMeijiequanSessionExpired,
errMeijiequanAuthRequired,
errMeijiequanOrderRejected,
errMediaSupplySelectedResourcesMissing,
errMediaSupplyLockedSellBelowCost,
}
for _, err := range terminalErrors {
if retryableMediaSupplyError(err) {
t.Fatalf("expected terminal media supply error to be non-retryable: %v", err)
}
}
if !retryableMediaSupplyError(errors.New("temporary network failure")) {
t.Fatal("expected generic temporary error to remain retryable")
}
}
func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
now := time.Now().UTC()
errorMessage := "failed to refresh supplier price for 1 selected resources"
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
int64(1),
int64(2),
int64(3),
int64(4),
int64(5),
(*int64)(nil),
mediaSupplySupplierMeijiequan,
1,
mediaSupplyOrderStatusFailed,
"测试标题",
(*string)(nil),
(*string)(nil),
(*string)(nil),
(*string)(nil),
(*string)(nil),
int64(100),
int64(120),
int64(120),
(*time.Time)(nil),
(*time.Time)(nil),
&errorMessage,
1,
now,
(*time.Time)(nil),
(*time.Time)(nil),
now,
now,
}})
if err != nil {
t.Fatalf("scan failed: %v", err)
}
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
t.Fatalf("supplier was not redacted: %s", item.Supplier)
}
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源价格刷新失败,请稍后重试或重新选择媒体" {
t.Fatalf("unexpected public error message: %#v", item.ErrorMessage)
}
}
func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
form := meijiequanCreateOrderForm(MeijiequanCreateOrderRequest{
UID: "962",
ModelID: 1,
Title: "2026合肥全屋定制推荐 6家选型参考",
Content: "# 如何选择\n\n![封面](http://example.test/a.png \"178.png\")\n\n- 正文内容\n- **重点**",
ResourceIDArr: []string{"36122_price_0"},
PointTime: "2026-06-06 11:00:00",
PointPriceRange: "1",
Extra: map[string]any{
"source": "平台稿件",
"description": "稿件描述",
},
})
if got := form.Get("uid"); got != "962" {
t.Fatalf("unexpected uid: %s", got)
}
if got := form.Get("prop[biaoti]"); got != "2026合肥全屋定制推荐 6家选型参考" {
t.Fatalf("unexpected title: %s", got)
}
if got := form.Get("prop[laiyuan]"); got != "平台稿件" {
t.Fatalf("unexpected source: %s", got)
}
if got := form.Get("prop[miaoshu]"); got != "稿件描述" {
t.Fatalf("unexpected description: %s", got)
}
content := form.Get("prop[neirong]")
if !strings.Contains(content, "<h1>如何选择</h1>") ||
!strings.Contains(content, `src="http://example.test/a.png"`) ||
!strings.Contains(content, `title="178.png"`) ||
!strings.Contains(content, `_src="http://example.test/a.png"`) ||
!strings.Contains(content, "<li>正文内容</li>") ||
!strings.Contains(content, "<strong>重点</strong>") {
t.Fatalf("unexpected html content: %s", content)
}
if got := form.Get("model_id"); got != "1" {
t.Fatalf("unexpected model_id: %s", got)
}
if got := form.Get("voucher_id"); got != "0" {
t.Fatalf("unexpected voucher_id: %s", got)
}
if got := form.Get("resource_id_arr"); got != `["36122_price_0"]` {
t.Fatalf("unexpected resource_id_arr: %s", got)
}
if got := form.Get("resource_spare_arr"); got != `[]` {
t.Fatalf("unexpected resource_spare_arr: %s", got)
}
if got := form.Get("point_time"); got != "2026-06-06 11:00:00" {
t.Fatalf("unexpected point_time: %s", got)
}
if got := form.Get("point_price_range"); got != "1" {
t.Fatalf("unexpected point_price_range: %s", got)
}
}
func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) {
input := `<p><img src="http://example.test/a.png" title="x" _src="http://example.test/a.png" alt="a"></p><p>正文</p>`
if got := meijiequanArticleHTML(input); got != input {
t.Fatalf("expected html passthrough, got %s", got)
}
}
func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) {
extra := mediaSupplySubmitOrderExtra([]byte(`{
"title": "ignored",
"extra": {
"source": "来源",
"point_time": "2026-06-06 11:00:00"
},
"point_price_range": "1"
}`))
if got := meijiequanAnyString(extra["source"]); got != "来源" {
t.Fatalf("unexpected source: %s", got)
}
if got := meijiequanAnyString(extra["point_time"]); got != "2026-06-06 11:00:00" {
t.Fatalf("unexpected point_time: %s", got)
}
if got := meijiequanAnyString(extra["point_price_range"]); got != "1" {
t.Fatalf("unexpected point_price_range: %s", got)
}
}
func TestManualMeijiequanChallengeResolverRequiresManualHandling(t *testing.T) {
_, err := ManualMeijiequanChallengeCodeResolver{}.ResolveCode(context.Background(), MeijiequanChallenge{})
if !errors.Is(err, errMeijiequanChallengeRequired) {
t.Fatalf("expected challenge required error, got %v", err)
}
}
func TestMeijiequanHiddenInputs(t *testing.T) {
fields := parseMeijiequanHiddenInputs(strings.NewReader(`
<form>
<input type="hidden" name="_token" value="token-value">
<input type="hidden" name="sk" id="sk" value="sk-value">
<input type="text" name="username" value="ignore">
</form>`))
if fields["_token"] != "token-value" || fields["sk"] != "sk-value" {
t.Fatalf("unexpected hidden fields: %#v", fields)
}
}
func TestDecodeMeijiequanMediaListAcceptsStringNumbers(t *testing.T) {
parsed, err := decodeMeijiequanMediaList([]byte(`{
"status": "1",
"info": "ok",
"page": "2",
"all_page": "7",
"all_num": "121",
"user_id": "8899",
"data": [{"id":"1","name":"测试媒体","sale_price":"12.5"}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if parsed.Status != 1 || parsed.Page != 2 || parsed.AllPage != 7 || parsed.AllNum != 121 || parsed.UserID != 8899 {
t.Fatalf("unexpected parsed page metadata: %#v", parsed)
}
if len(parsed.Data) != 1 {
t.Fatalf("unexpected item count: %d", len(parsed.Data))
}
}
func TestDecodeMeijiequanSearchOptions(t *testing.T) {
groups, err := decodeMeijiequanSearchOptions([]byte(`{
"pindaoleixing": {"name": "频道类型", "list": ["IT科技", "财经金融"]},
"quyu": {"name": "所在地区", "list": ["全国", "北京"]}
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(groups) != 2 {
t.Fatalf("unexpected group count: %d", len(groups))
}
if groups[0].Key != "pindaoleixing" || groups[0].Name != "频道类型" || groups[0].List[1] != "财经金融" {
t.Fatalf("unexpected first group: %#v", groups[0])
}
}
func TestDecodeMeijiequanPublishedArticleJSON(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
"status": "1",
"page": "1",
"all_page": "2",
"all_num": "21",
"data": [{
"id": "8891",
"order_code": "A20260529001",
"biaoti": "2026合肥品牌推荐",
"resource_id": "36122_price_0",
"media_name": "测试新闻网",
"article_url": "https://news.example.test/a.html",
"status_text": "已发表",
"publish_time": "2026-05-29 16:00:00"
}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if page.Page != 1 || page.AllPage != 2 || page.AllNum != 21 || len(page.Items) != 1 {
t.Fatalf("unexpected page: %#v", page)
}
item := page.Items[0]
if item.ResourceID != "36122" ||
item.ExternalArticleURL != "https://news.example.test/a.html" ||
item.Title != "2026合肥品牌推荐" ||
item.Status != "已发表" ||
item.PublishedAt == nil {
t.Fatalf("unexpected article item: %#v", item)
}
}
func TestDecodeMeijiequanPublishedArticleHTML(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<tbody>
<tr data-id="8891" data-resource-id="36122_price_0">
<td>2026合肥品牌推荐</td>
<td>测试新闻网</td>
<td>WM118996</td>
<td><a href="https://news.example.test/a.html">查看链接</a></td>
<td>已发表</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
item := page.Items[0]
if item.OrderID != "8891" ||
item.OrderCode != "WM118996" ||
item.ResourceID != "36122" ||
item.ExternalArticleURL != "https://news.example.test/a.html" ||
item.Title != "2026合肥品牌推荐" {
t.Fatalf("unexpected html article item: %#v", item)
}
}
func TestDecodeMeijiequanPublishedArticleHTMLUsesCopiedBacklinkNotShowURL(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<tbody>
<tr data-id="8891" data-resource-id="36122_price_0">
<td>WM118996</td>
<td>2026合肥品牌推荐</td>
<td>金融树洞网</td>
<td>
<a href="http://show.meitidaren.top/review/abc">查看回链</a>
<button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button>
</td>
<td>已发布</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
item := page.Items[0]
if item.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
t.Fatalf("expected copied real backlink, got %#v", item)
}
}
func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<thead>
<tr>
<th>订单号</th><th>标题</th><th>资源名称</th><th>价格</th><th>状态</th>
<th>回链</th><th>标签</th><th>创建时间</th><th>发稿时间</th><th>退稿原因</th><th>退稿时间</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>WM119065</td>
<td>合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议</td>
<td>博客园(GEO排名可发)</td>
<td>3.00</td>
<td>退稿申请中</td>
<td></td>
<td></td>
<td>05-29 20:45</td>
<td></td>
<td>不想发布了</td>
<td>05-29 20:54</td>
<td>标签 | 再次发稿</td>
</tr>
<tr>
<td>WM118996</td>
<td>全屋定制品牌哪家强?2026 年合肥品牌推荐与排名</td>
<td>金融树洞网</td>
<td>3.00</td>
<td>已发布</td>
<td><button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button></td>
<td></td>
<td>05-29 17:16</td>
<td>05-29 17:20</td>
<td></td>
<td></td>
<td>标签 | 再次发稿 | 申请退稿</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 2 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
problem := page.Items[0]
if problem.OrderCode != "WM119065" ||
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
problem.ResourceName != "博客园(GEO排名可发)" ||
problem.Status != "退稿申请中" ||
problem.RejectReason != "不想发布了" {
t.Fatalf("unexpected problem article: %#v", problem)
}
published := page.Items[1]
if published.OrderCode != "WM118996" ||
published.ResourceName != "金融树洞网" ||
published.Status != "已发布" ||
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
t.Fatalf("unexpected published article: %#v", published)
}
}
func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
"status": "1",
"data": [{
"order_code": "WM118996",
"biaoti": "2026合肥品牌推荐",
"media_name": "金融树洞网",
"article_url": "http://show.meitidaren.top/review/abc"
}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
if page.Items[0].ExternalArticleURL != "" {
t.Fatalf("show url should not be treated as backlink: %#v", page.Items[0])
}
}
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
items := []MediaSupplyOrderItem{
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网"},
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体"},
}
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
{
Title: "2026合肥品牌推荐",
ResourceID: "36123_price_0",
ResourceName: "另一个媒体",
ExternalArticleURL: "https://news.example.test/b.html",
},
})
if len(matches) != 1 {
t.Fatalf("unexpected matches: %#v", matches)
}
if matches[12].ExternalArticleURL != "https://news.example.test/b.html" {
t.Fatalf("unexpected matched item: %#v", matches)
}
}
func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
orderCode := "WM119065"
article := matchMeijiequanProblemArticle(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议", ExternalOrderCode: &orderCode},
[]MediaSupplyOrderItem{{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "博客园(GEO排名可发)"}},
[]MeijiequanPublishedArticle{{
OrderCode: "WM119065",
Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议",
ResourceName: "博客园(GEO排名可发)",
Status: "退稿申请中",
RejectReason: "不想发布了",
}},
)
if article == nil {
t.Fatal("expected problem article match")
}
if got := mediaSupplyProblemArticleReason(*article); got != "退稿原因:不想发布了" {
t.Fatalf("unexpected customer reject reason: %s", got)
}
}
func TestMaskMeijiequanUsername(t *testing.T) {
if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
t.Fatalf("unexpected masked username: %s", got)
}
}
func TestMeijiequanUpstreamThrottleSerializesRequests(t *testing.T) {
server := miniredis.RunT(t)
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
defer redis.Close()
client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 25 * time.Millisecond})
ctx := context.Background()
if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil {
t.Fatalf("first wait failed: %v", err)
}
start := time.Now()
if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil {
t.Fatalf("second wait failed: %v", err)
}
if elapsed := time.Since(start); elapsed < 20*time.Millisecond {
t.Fatalf("expected serialized upstream wait, elapsed %s", elapsed)
}
}
@@ -0,0 +1,371 @@
package app
import (
"encoding/json"
"math"
"strings"
"time"
)
const (
mediaSupplySupplierMeijiequan = "meijiequan"
mediaSupplyOrderStatusQueued = "queued"
mediaSupplyOrderStatusSubmitting = "submitting"
mediaSupplyOrderStatusSubmitted = "submitted"
mediaSupplyOrderStatusFailed = "failed"
mediaSupplySyncStatusQueued = "queued"
mediaSupplySyncStatusRunning = "running"
mediaSupplySyncStatusSuccess = "success"
mediaSupplySyncStatusFailed = "failed"
)
var meijiequanModelIDs = []int{1, 2, 10, 3, 4, 7, 5, 6, 9, 11, 13}
type SupplierMediaResource struct {
ID int64 `json:"id"`
Supplier string `json:"supplier"`
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"`
SalePriceLabel *string `json:"sale_price_label,omitempty"`
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"`
SupplierUpdatedAt *time.Time `json:"supplier_updated_at,omitempty"`
LastSyncedAt time.Time `json:"last_synced_at"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type ListSupplierMediaResourcesRequest struct {
ModelID int
Keyword string
RemarkKeyword string
ChannelType string
Portal string
Region string
InclusionEffect string
SpecialIndustry string
LinkType string
DeliverySpeed string
AIInclude string
OtherOption string
MinPriceCents int64
MaxPriceCents int64
Sort string
IncludeHidden bool
CustomerView bool
Page int
PageSize int
}
type ListSupplierMediaResourcesResponse struct {
Items []SupplierMediaResource `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type MediaSupplySearchOptionGroup struct {
Key string `json:"key"`
Name string `json:"name"`
List []string `json:"list"`
}
type MediaSupplySearchOptionsResponse struct {
ModelID int `json:"model_id"`
Groups []MediaSupplySearchOptionGroup `json:"groups"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpsertSupplierMediaResourceInput struct {
Supplier string
SupplierResourceID string
ModelID int
Name string
Status string
CostPriceCents int64
CostPrices map[string]int64
SalePriceLabel string
ResourceURL string
BaiduWeight *int
ResourceRemark string
ChannelType string
Region string
InclusionEffect string
LinkType string
PublishRate string
DeliverySpeed string
SupplierUpdatedAt *time.Time
Raw json.RawMessage
}
type SetSupplierMediaPriceRequest struct {
PriceType string `json:"price_type"`
SellPriceCents int64 `json:"sell_price_cents"`
Enabled *bool `json:"enabled,omitempty"`
}
type SetSupplierMediaVisibilityRequest struct {
CustomerVisible bool `json:"customer_visible"`
}
type CustomerSupplierMediaResource struct {
ID int64 `json:"id"`
SupplierResourceID string `json:"supplier_resource_id"`
ModelID int `json:"model_id"`
Name string `json:"name"`
Status string `json:"status"`
SellPriceCents int64 `json:"sell_price_cents"`
ResourceURL *string `json:"resource_url,omitempty"`
BaiduWeight *int `json:"baidu_weight,omitempty"`
ResourceRemark *string `json:"resource_remark,omitempty"`
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 ListCustomerSupplierMediaResourcesResponse struct {
Items []CustomerSupplierMediaResource `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type CreateMediaSupplyOrderRequest struct {
ArticleID *int64 `json:"article_id,omitempty"`
ModelID int `json:"model_id" binding:"required"`
Title string `json:"title"`
Content string `json:"content"`
Remark string `json:"remark"`
OrderBrand string `json:"order_brand"`
Items []CreateMediaSupplyOrderItem `json:"items" binding:"required,min=1"`
Extra map[string]any `json:"extra,omitempty"`
}
type CreateMediaSupplyOrderItem struct {
ResourceID int64 `json:"resource_id" binding:"required"`
PriceType string `json:"price_type"`
}
type CreateMediaSupplyOrderResponse struct {
OrderID int64 `json:"order_id"`
Status string `json:"status"`
CostTotalCents int64 `json:"cost_total_cents"`
SellTotalCents int64 `json:"sell_total_cents"`
BalanceAfterCents int64 `json:"balance_after_cents"`
}
type MediaSupplyOrderDetail struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
BrandID int64 `json:"brand_id"`
UserID int64 `json:"user_id"`
ArticleID *int64 `json:"article_id,omitempty"`
Supplier string `json:"supplier"`
ModelID int `json:"model_id"`
Status string `json:"status"`
Title string `json:"title"`
Remark *string `json:"remark,omitempty"`
OrderBrand *string `json:"order_brand,omitempty"`
ExternalOrderID *string `json:"external_order_id,omitempty"`
ExternalOrderCode *string `json:"external_order_code,omitempty"`
SupplierStatus *string `json:"supplier_status,omitempty"`
CostTotalCents int64 `json:"cost_total_cents"`
SellTotalCents int64 `json:"sell_total_cents"`
WalletDebitCents int64 `json:"wallet_debit_cents"`
WalletDebitedAt *time.Time `json:"wallet_debited_at,omitempty"`
WalletRefundedAt *time.Time `json:"wallet_refunded_at,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
AttemptCount int `json:"attempt_count"`
QueuedAt time.Time `json:"queued_at"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []MediaSupplyOrderItem `json:"items,omitempty"`
}
type MediaSupplyOrderItem struct {
ID int64 `json:"id"`
OrderID int64 `json:"order_id"`
ResourceID int64 `json:"resource_id"`
SupplierResourceID string `json:"supplier_resource_id"`
PriceType string `json:"price_type"`
ResourceNameSnapshot string `json:"resource_name_snapshot"`
LockedCostPriceCents int64 `json:"locked_cost_price_cents"`
LockedSellPriceCents int64 `json:"locked_sell_price_cents"`
Status string `json:"status"`
ExternalArticleURL *string `json:"external_article_url,omitempty"`
}
type ListMediaSupplyOrdersRequest struct {
Status string
ArticleID *int64
Page int
PageSize int
}
type ListMediaSupplyOrdersResponse struct {
Items []MediaSupplyOrderDetail `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type SyncMediaSupplyBacklinksResponse struct {
PublishedFetched int `json:"published_fetched"`
PendingFetched int `json:"pending_fetched"`
ProblemFetched int `json:"problem_fetched"`
OrdersChecked int `json:"orders_checked"`
OrdersUpdated int `json:"orders_updated"`
OrderCodesUpdated int `json:"order_codes_updated"`
RefundedOrders int `json:"refunded_orders"`
LinksUpdated int `json:"links_updated"`
}
type MediaSupplyWalletStatus struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
BalanceCents int64 `json:"balance_cents"`
UpdatedAt time.Time `json:"updated_at"`
LedgerTotal *int64 `json:"ledger_total,omitempty"`
LastLedgerAt *time.Time `json:"last_ledger_at,omitempty"`
}
type MediaSupplyWalletLedger struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
OrderID *int64 `json:"order_id,omitempty"`
OrderTitle *string `json:"order_title,omitempty"`
DeltaCents int64 `json:"delta_cents"`
BalanceAfterCents int64 `json:"balance_after_cents"`
Reason string `json:"reason"`
Note *string `json:"note,omitempty"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ListMediaSupplyWalletLedgersRequest struct {
Page int
PageSize int
UserID int64
CreatedFrom *time.Time
CreatedTo *time.Time
}
type ListMediaSupplyWalletLedgersResponse struct {
Items []MediaSupplyWalletLedger `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type AdjustMediaSupplyWalletRequest struct {
UserID int64 `json:"user_id" binding:"required"`
DeltaCents int64 `json:"delta_cents" binding:"required"`
Note string `json:"note"`
}
type ImportMeijiequanSessionRequest struct {
Cookies []MeijiequanCookieInput `json:"cookies" binding:"required,min=1"`
CSRFToken string `json:"csrf_token"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type MeijiequanCookieInput struct {
Name string `json:"name" binding:"required"`
Value string `json:"value" binding:"required"`
Domain string `json:"domain,omitempty"`
Path string `json:"path,omitempty"`
Expires *time.Time `json:"expires,omitempty"`
HTTPOnly bool `json:"http_only,omitempty"`
Secure bool `json:"secure,omitempty"`
}
type MeijiequanSessionStatus struct {
Available bool `json:"available"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
AccountConfigured bool `json:"account_configured"`
UserID string `json:"-"`
}
func normalizeMediaSupplyPagination(page, pageSize int) (int, int) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func normalizeMediaSupplyResourceIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
normalized := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
normalized = append(normalized, id)
if len(normalized) >= 100 {
break
}
}
return normalized
}
func normalizeMediaSupplyPriceType(priceType string) string {
priceType = strings.ToLower(strings.TrimSpace(priceType))
if priceType == "" {
return "price"
}
return priceType
}
func mediaSupplySellPrice(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 mediaSupplyRoundUpToYuan(overrideCents)
}
return mediaSupplyRoundUpToYuan(floor)
}
func mediaSupplyRoundUpToYuan(cents int64) int64 {
if cents <= 0 {
return 0
}
return ((cents + 99) / 100) * 100
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
// image/png is registered by the digitocr package; jpeg/gif are registered
// here so a future change of the captcha format still decodes.
_ "image/gif"
_ "image/jpeg"
"github.com/geo-platform/tenant-api/internal/shared/digitocr"
)
// meijiequanChallengeDigits is the fixed length of the meijiequan login captcha.
const meijiequanChallengeDigits = 4
var errMeijiequanChallengeEmpty = errors.New("meijiequan challenge image is empty")
// DigitOCRMeijiequanChallengeCodeResolver decodes the meijiequan numeric login
// captcha locally via the digitocr template matcher, enabling unattended login
// without an external OCR service.
type DigitOCRMeijiequanChallengeCodeResolver struct{}
// ResolveCode recognizes the digits in the captcha image. A decode failure is
// returned as a non-challenge error so the login flow retries with a fresh
// captcha rather than giving up immediately (as the manual resolver does).
func (DigitOCRMeijiequanChallengeCodeResolver) ResolveCode(_ context.Context, challenge MeijiequanChallenge) (string, error) {
if len(challenge.Image) == 0 {
return "", errMeijiequanChallengeEmpty
}
code, err := digitocr.RecognizeReader(bytes.NewReader(challenge.Image), digitocr.Options{Digits: meijiequanChallengeDigits})
if err != nil {
return "", fmt.Errorf("digitocr resolve meijiequan challenge: %w", err)
}
return code, nil
}
@@ -0,0 +1,66 @@
package app
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
func newTestChallengePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 50, 25))
orange := color.RGBA{R: 240, G: 130, B: 40, A: 255}
// Four orange blocks emulate the four-digit captcha; digitocr only needs to
// segment and match them, so exact glyphs are not required for this test.
for d := 0; d < 4; d++ {
x0 := 5 + d*10
for y := 5; y < 21; y++ {
for x := x0; x < x0+8; x++ {
img.Set(x, y, orange)
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestDigitOCRChallengeResolverDecodesImage(t *testing.T) {
resolver := DigitOCRMeijiequanChallengeCodeResolver{}
code, err := resolver.ResolveCode(context.Background(), MeijiequanChallenge{
Image: newTestChallengePNG(t),
ContentType: "image/png",
})
if err != nil {
t.Fatalf("resolve code: %v", err)
}
if len(code) != meijiequanChallengeDigits {
t.Fatalf("expected %d digit code, got %q", meijiequanChallengeDigits, code)
}
for _, r := range code {
if r < '0' || r > '9' {
t.Fatalf("expected numeric code, got %q", code)
}
}
}
func TestDigitOCRChallengeResolverRejectsEmptyImage(t *testing.T) {
resolver := DigitOCRMeijiequanChallengeCodeResolver{}
if _, err := resolver.ResolveCode(context.Background(), MeijiequanChallenge{}); err == nil {
t.Fatal("expected error for empty challenge image")
}
}
func TestNewMeijiequanClientDefaultsToDigitOCRResolver(t *testing.T) {
client := NewMeijiequanClient(nil, config.MeijiequanConfig{})
if _, ok := client.challengeResolver().(DigitOCRMeijiequanChallengeCodeResolver); !ok {
t.Fatalf("expected digitocr resolver by default, got %T", client.challengeResolver())
}
}
File diff suppressed because it is too large Load Diff
@@ -89,6 +89,7 @@ type CreatePublishJobResponse struct {
TaskIDs []string `json:"task_ids"`
CreatedTaskIDs []string `json:"created_task_ids"`
ExistingTaskIDs []string `json:"existing_task_ids"`
RequeuedTaskIDs []string `json:"requeued_task_ids,omitempty"`
}
type publishTarget struct {
@@ -668,6 +669,9 @@ func (s *PublishJobService) RetryByDesktopTask(
if task.Status == "queued" || task.Status == "in_progress" {
return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(task) {
return s.requeueUnknownPublishTask(ctx, task)
}
payload := unmarshalJSONObject(task.Payload)
articleID, err := s.resolveRetryArticleID(ctx, client.TenantID, payload)
@@ -730,6 +734,9 @@ func (s *PublishJobService) RetryTenantDesktopTask(
if task.Status == "queued" || task.Status == "in_progress" {
return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(task) {
return s.requeueUnknownPublishTask(ctx, task)
}
payload := unmarshalJSONObject(task.Payload)
articleID, err := s.resolveRetryArticleID(ctx, actor.TenantID, payload)
@@ -758,6 +765,59 @@ func (s *PublishJobService) RetryTenantDesktopTask(
})
}
func shouldRequeuePublishTaskInsteadOfCreatingNew(task *repository.DesktopTask) bool {
return task != nil && task.Kind == "publish" && task.Status == "unknown"
}
func (s *PublishJobService) requeueUnknownPublishTask(
ctx context.Context,
task *repository.DesktopTask,
) (*CreatePublishJobResponse, error) {
if task == nil {
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50105, "desktop_task_reconcile_begin_failed", "failed to start desktop task reconcile")
}
defer tx.Rollback(ctx)
taskRepo := repository.NewDesktopTaskRepository(tx)
requeued, err := taskRepo.Reconcile(ctx, task.DesktopID, task.WorkspaceID, "retry", nil, nil)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile")
}
return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50106, "desktop_task_reconcile_commit_failed", "failed to commit desktop task reconcile")
}
publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, requeued, "task_available")
return createPublishJobResponseForRequeuedTask(requeued), nil
}
func createPublishJobResponseForRequeuedTask(task *repository.DesktopTask) *CreatePublishJobResponse {
if task == nil {
return &CreatePublishJobResponse{
TaskIDs: []string{},
CreatedTaskIDs: []string{},
ExistingTaskIDs: []string{},
RequeuedTaskIDs: []string{},
}
}
taskID := task.DesktopID.String()
return &CreatePublishJobResponse{
JobID: "",
TaskIDs: []string{taskID},
CreatedTaskIDs: []string{},
ExistingTaskIDs: []string{},
RequeuedTaskIDs: []string{taskID},
}
}
func (s *PublishJobService) authorizePublishTaskForClientUser(
ctx context.Context,
client *repository.DesktopClient,
@@ -155,6 +155,54 @@ func TestPublishTargetPlatformsSortsAndDedupes(t *testing.T) {
}
}
func TestShouldRequeuePublishTaskInsteadOfCreatingNewOnlyForUnknownPublish(t *testing.T) {
t.Parallel()
if !shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "publish",
Status: "unknown",
}) {
t.Fatal("unknown publish tasks should be retried by requeueing the existing task")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "publish",
Status: "failed",
}) {
t.Fatal("failed publish tasks should keep create-new retry semantics")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "monitor",
Status: "unknown",
}) {
t.Fatal("monitor retry semantics are owned by desktop task reconcile")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(nil) {
t.Fatal("nil tasks should not be requeued")
}
}
func TestCreatePublishJobResponseForRequeuedTaskReportsRequeuedTaskID(t *testing.T) {
t.Parallel()
taskID := uuid.MustParse("55555555-5555-5555-5555-555555555555")
got := createPublishJobResponseForRequeuedTask(&repository.DesktopTask{DesktopID: taskID})
if got.JobID != "" {
t.Fatalf("JobID = %q, want empty for requeued task", got.JobID)
}
if len(got.TaskIDs) != 1 || got.TaskIDs[0] != taskID.String() {
t.Fatalf("TaskIDs = %v, want [%s]", got.TaskIDs, taskID)
}
if len(got.CreatedTaskIDs) != 0 {
t.Fatalf("CreatedTaskIDs = %v, want empty", got.CreatedTaskIDs)
}
if len(got.ExistingTaskIDs) != 0 {
t.Fatalf("ExistingTaskIDs = %v, want empty", got.ExistingTaskIDs)
}
if len(got.RequeuedTaskIDs) != 1 || got.RequeuedTaskIDs[0] != taskID.String() {
t.Fatalf("RequeuedTaskIDs = %v, want [%s]", got.RequeuedTaskIDs, taskID)
}
}
func TestLoadExistingPublishRecordTargetsDedupeUnknownTasks(t *testing.T) {
t.Parallel()
@@ -0,0 +1,312 @@
package transport
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type MediaSupplyHandler struct {
svc *app.MediaSupplyService
}
func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler {
svc := a.MediaSupplyService
if svc == nil {
svc = app.NewMediaSupplyService(a.DB, a.Redis, a.Logger, a.ConfigStore)
}
return &MediaSupplyHandler{
svc: svc,
}
}
func (h *MediaSupplyHandler) ListResources(c *gin.Context) {
modelID, err := parseOptionalIntQuery(c, "model_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
return
}
page, _ := parseOptionalIntQuery(c, "page")
pageSize, _ := parseOptionalIntQuery(c, "page_size")
minPriceCents, err := parseOptionalInt64Query(c, "min_price_cents")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_min_price_cents", "最低价格参数必须是数字"))
return
}
maxPriceCents, err := parseOptionalInt64Query(c, "max_price_cents")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_max_price_cents", "最高价格参数必须是数字"))
return
}
data, err := h.svc.ListCustomerResources(c.Request.Context(), app.ListSupplierMediaResourcesRequest{
ModelID: modelID,
Keyword: c.Query("keyword"),
RemarkKeyword: c.Query("remark_keyword"),
ChannelType: c.Query("channel_type"),
Portal: c.Query("portal"),
Region: c.Query("region"),
InclusionEffect: c.Query("inclusion_effect"),
SpecialIndustry: c.Query("special_industry"),
LinkType: c.Query("link_type"),
DeliverySpeed: c.Query("delivery_speed"),
AIInclude: c.Query("ai_include"),
OtherOption: c.Query("other_option"),
MinPriceCents: minPriceCents,
MaxPriceCents: maxPriceCents,
Sort: c.Query("sort"),
Page: page,
PageSize: pageSize,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) ListResourcesByIDs(c *gin.Context) {
ids, err := parseInt64ListQuery(c, "ids")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_resource_ids", "媒体资源参数必须是数字"))
return
}
modelID, err := parseOptionalIntQuery(c, "model_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
return
}
data, err := h.svc.ListCustomerResourcesByIDs(c.Request.Context(), ids, modelID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) QueueSync(c *gin.Context) {
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
}
func (h *MediaSupplyHandler) SearchOptions(c *gin.Context) {
modelID, err := parseOptionalIntQuery(c, "model_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
return
}
data, err := h.svc.SearchOptions(c.Request.Context(), modelID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) SetResourcePrice(c *gin.Context) {
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
}
func (h *MediaSupplyHandler) CreateOrder(c *gin.Context) {
var req app.CreateMediaSupplyOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "投稿参数不正确"))
return
}
data, err := h.svc.CreateOrder(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *MediaSupplyHandler) WalletStatus(c *gin.Context) {
data, err := h.svc.WalletStatus(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) ListWalletLedgers(c *gin.Context) {
page, _ := parseOptionalIntQuery(c, "page")
pageSize, _ := parseOptionalIntQuery(c, "page_size")
userID, err := parseOptionalInt64Query(c, "user_id")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_user_id", "用户参数必须是数字"))
return
}
createdFrom, err := parseOptionalTimeQuery(c, "created_from")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_created_from", "开始时间格式不正确"))
return
}
createdTo, err := parseOptionalTimeQuery(c, "created_to")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_created_to", "结束时间格式不正确"))
return
}
data, err := h.svc.ListWalletLedgers(c.Request.Context(), app.ListMediaSupplyWalletLedgersRequest{
Page: page,
PageSize: pageSize,
UserID: userID,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) AdjustWallet(c *gin.Context) {
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
}
func (h *MediaSupplyHandler) ListOrders(c *gin.Context) {
page, _ := parseOptionalIntQuery(c, "page")
pageSize, _ := parseOptionalIntQuery(c, "page_size")
var articleID *int64
if raw := c.Query("article_id"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_article_id", "文章参数必须是数字"))
return
}
articleID = &parsed
}
data, err := h.svc.ListOrders(c.Request.Context(), app.ListMediaSupplyOrdersRequest{
Status: c.Query("status"),
ArticleID: articleID,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) GetOrder(c *gin.Context) {
orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || orderID <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "订单参数必须是数字"))
return
}
data, err := h.svc.GetOrder(c.Request.Context(), orderID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) ImportSession(c *gin.Context) {
var req app.ImportMeijiequanSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "登录状态参数不正确"))
return
}
data, err := h.svc.ImportSession(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) LoginConfiguredAccount(c *gin.Context) {
data, err := h.svc.LoginConfiguredAccount(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) SessionStatus(c *gin.Context) {
data, err := h.svc.SessionStatus(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) SyncBacklinks(c *gin.Context) {
data, err := h.svc.SyncBacklinks(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseOptionalIntQuery(c *gin.Context, key string) (int, error) {
raw := c.Query(key)
if raw == "" {
return 0, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
return value, nil
}
func parseOptionalInt64Query(c *gin.Context, key string) (int64, error) {
raw := c.Query(key)
if raw == "" {
return 0, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, err
}
return value, nil
}
func parseOptionalTimeQuery(c *gin.Context, key string) (*time.Time, error) {
raw := c.Query(key)
if raw == "" {
return nil, nil
}
value, err := time.Parse(time.RFC3339, raw)
if err != nil {
return nil, err
}
return &value, nil
}
func parseInt64ListQuery(c *gin.Context, key string) ([]int64, error) {
rawValues := c.QueryArray(key)
if len(rawValues) == 0 {
rawValues = []string{c.Query(key)}
}
values := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil || value <= 0 {
return nil, strconv.ErrSyntax
}
values = append(values, value)
if len(values) >= 100 {
return values, nil
}
}
}
return values, nil
}
@@ -97,6 +97,24 @@ func RegisterRoutes(app *bootstrap.App) {
media := tenantProtected.Group("/media")
media.GET("/platforms", mediaHandler.ListPlatforms)
mediaSupplyHandler := NewMediaSupplyHandler(app)
mediaSupply := tenantProtected.Group("/media-supply")
mediaSupply.GET("/resources", mediaSupplyHandler.ListResources)
mediaSupply.GET("/resources/by-ids", mediaSupplyHandler.ListResourcesByIDs)
mediaSupply.GET("/resources/search-options", mediaSupplyHandler.SearchOptions)
mediaSupply.POST("/sync-jobs", mediaSupplyHandler.QueueSync)
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)
mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders)
mediaSupply.POST("/orders/sync-backlinks", mediaSupplyHandler.SyncBacklinks)
mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder)
mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus)
mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers)
mediaSupply.POST("/wallet/adjustments", mediaSupplyHandler.AdjustWallet)
mediaSupply.GET("/supplier-session", mediaSupplyHandler.SessionStatus)
mediaSupply.PUT("/supplier-session", mediaSupplyHandler.ImportSession)
mediaSupply.POST("/supplier-session/login", mediaSupplyHandler.LoginConfiguredAccount)
workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app)
workspace.GET("/overview", middleware.RequireCurrentBrand(), wsHandler.Overview)