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