fix(publish): require submit marker before platform write
Desktop Client Build / Resolve Build Metadata (push) Successful in 20s
Frontend CI / Frontend (push) Successful in 2m57s
Desktop Client Build / Build Desktop Client (push) Successful in 25m11s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 34s
Backend CI / Backend (push) Successful in 50m56s
Desktop Client Build / Resolve Build Metadata (push) Successful in 20s
Frontend CI / Frontend (push) Successful in 2m57s
Desktop Client Build / Build Desktop Client (push) Successful in 25m11s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 34s
Backend CI / Backend (push) Successful in 50m56s
This commit is contained in:
@@ -48,16 +48,22 @@ var routeDocs = map[string]routeDoc{
|
||||
"POST /api/ops/admin-users/:id/reset-login-lock": {"解除登录锁定", "解除租户管理员账号的登录失败锁定。"},
|
||||
|
||||
// --- Ops:业务运营 ---
|
||||
"GET /api/ops/kol/packages": {"KOL 套餐列表(Ops)", "Ops 查看 KOL 市场套餐。"},
|
||||
"GET /api/ops/kol/subscriptions": {"KOL 订阅列表(Ops)", "Ops 查看租户 KOL 订阅记录。"},
|
||||
"POST /api/ops/kol/subscriptions/manual-bind": {"手动绑定 KOL 订阅", "Ops 为租户手动绑定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/approve": {"审批 KOL 订阅", "Ops 审批指定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/revoke": {"撤销 KOL 订阅", "Ops 撤销指定 KOL 订阅。"},
|
||||
"GET /api/ops/audits": {"审计日志列表", "分页查询 Ops 审计日志。"},
|
||||
"GET /api/ops/jobs": {"任务列表(Ops)", "分页查询后台任务。"},
|
||||
"GET /api/ops/jobs/:source/:id": {"任务详情(Ops)", "读取指定来源与 ID 的任务详情。"},
|
||||
"POST /api/ops/jobs/:source/:id/retry": {"重试任务(Ops)", "对失败任务发起重试。"},
|
||||
"POST /api/ops/jobs/:source/:id/cancel": {"取消任务(Ops)", "取消指定后台任务。"},
|
||||
"GET /api/ops/kol/packages": {"KOL 套餐列表(Ops)", "Ops 查看 KOL 市场套餐。"},
|
||||
"GET /api/ops/kol/subscriptions": {"KOL 订阅列表(Ops)", "Ops 查看租户 KOL 订阅记录。"},
|
||||
"POST /api/ops/kol/subscriptions/manual-bind": {"手动绑定 KOL 订阅", "Ops 为租户手动绑定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/approve": {"审批 KOL 订阅", "Ops 审批指定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/revoke": {"撤销 KOL 订阅", "Ops 撤销指定 KOL 订阅。"},
|
||||
"GET /api/ops/audits": {"审计日志列表", "分页查询 Ops 审计日志。"},
|
||||
"GET /api/ops/jobs": {"任务列表(Ops)", "分页查询后台任务。"},
|
||||
"GET /api/ops/jobs/:source/:id": {"任务详情(Ops)", "读取指定来源与 ID 的任务详情。"},
|
||||
"POST /api/ops/jobs/:source/:id/retry": {"重试任务(Ops)", "对失败任务发起重试。"},
|
||||
"POST /api/ops/jobs/:source/:id/cancel": {"取消任务(Ops)", "取消指定后台任务。"},
|
||||
"GET /api/ops/media-supply/resources": {"媒介资源列表(Ops)", "Ops 分页查看媒介供应资源。"},
|
||||
"PUT /api/ops/media-supply/resources/:id/price": {"调整媒介资源价格(Ops)", "Ops 调整指定媒介资源的销售价格。"},
|
||||
"PUT /api/ops/media-supply/resources/:id/visibility": {"切换媒介资源可见性", "Ops 启用或隐藏指定媒介供应资源。"},
|
||||
"POST /api/ops/media-supply/sync-jobs": {"同步媒介资源(Ops)", "Ops 创建媒介供应资源同步任务。"},
|
||||
"GET /api/ops/media-supply/wallets": {"媒介钱包列表(Ops)", "Ops 查询租户媒介供应钱包余额。"},
|
||||
"POST /api/ops/media-supply/wallets/adjustments": {"调整媒介钱包(Ops)", "Ops 为租户创建媒介钱包余额调整。"},
|
||||
|
||||
// --- Ops:调度器 ---
|
||||
"GET /api/ops/scheduler/jobs": {"调度任务列表", "分页查询调度器任务。"},
|
||||
@@ -159,12 +165,13 @@ var routeDocs = map[string]routeDoc{
|
||||
"DELETE /api/desktop/accounts/:id": {"删除媒体账号(桌面)", "桌面客户端解绑账号,仅清理本地侧记录。"},
|
||||
|
||||
// --- Desktop:发布任务执行 ---
|
||||
"POST /api/desktop/tasks/lease": {"领取发布任务", "桌面客户端从队列领取一批可执行任务,原子加锁。"},
|
||||
"POST /api/desktop/tasks/:id/lease": {"续锁/重领任务", "对指定任务再次加锁,常用于异常重试场景。"},
|
||||
"POST /api/desktop/tasks/:id/extend": {"延长任务锁", "执行较慢时延长锁定时间,避免任务被其他客户端抢走。"},
|
||||
"POST /api/desktop/tasks/:id/cancel": {"取消任务(桌面)", "桌面客户端在执行失败/用户中止时通知服务端取消。"},
|
||||
"POST /api/desktop/tasks/:id/result": {"上报任务结果", "桌面客户端将发布结果(成功/失败/截图/链接)回传服务端。"},
|
||||
"POST /api/desktop/publish-tasks/:id/retry": {"重试发布任务", "对失败的发布任务发起重试。"},
|
||||
"POST /api/desktop/tasks/lease": {"领取发布任务", "桌面客户端从队列领取一批可执行任务,原子加锁。"},
|
||||
"POST /api/desktop/tasks/:id/lease": {"续锁/重领任务", "对指定任务再次加锁,常用于异常重试场景。"},
|
||||
"POST /api/desktop/tasks/:id/extend": {"延长任务锁", "执行较慢时延长锁定时间,避免任务被其他客户端抢走。"},
|
||||
"POST /api/desktop/tasks/:id/publish-submit-marker": {"标记发布已进入提交阶段", "桌面客户端在执行不可逆平台提交前写入幂等保护标记,避免恢复流程自动重复发文。"},
|
||||
"POST /api/desktop/tasks/:id/cancel": {"取消任务(桌面)", "桌面客户端在执行失败/用户中止时通知服务端取消。"},
|
||||
"POST /api/desktop/tasks/:id/result": {"上报任务结果", "桌面客户端将发布结果(成功/失败/截图/链接)回传服务端。"},
|
||||
"POST /api/desktop/publish-tasks/:id/retry": {"重试发布任务", "对失败的发布任务发起重试。"},
|
||||
|
||||
// --- Desktop:监控采集任务 ---
|
||||
"POST /api/desktop/monitoring/tasks/lease": {"领取监控任务", "桌面客户端领取 GEO 监控的爬取任务(AI 平台问答抓取)。"},
|
||||
@@ -185,6 +192,23 @@ var routeDocs = map[string]routeDoc{
|
||||
// --- Tenant:媒体 ---
|
||||
"GET /api/tenant/media/platforms": {"媒体平台列表", "返回当前租户可用的媒体平台元数据(图标、能力开关等)。"},
|
||||
|
||||
// --- Tenant:媒介供应 ---
|
||||
"GET /api/tenant/media-supply/resources": {"媒介资源列表", "查询当前 Workspace 可购买的媒介供应资源。"},
|
||||
"GET /api/tenant/media-supply/resources/by-ids": {"按 ID 查询媒介资源", "按资源 ID 集合查询媒介供应资源详情。"},
|
||||
"GET /api/tenant/media-supply/resources/search-options": {"媒介资源筛选项", "返回媒介供应资源列表的筛选条件与可选项。"},
|
||||
"PUT /api/tenant/media-supply/resources/:id/price": {"调整媒介资源售价", "租户侧调整指定媒介资源的销售价格。"},
|
||||
"POST /api/tenant/media-supply/sync-jobs": {"同步媒介资源", "创建媒介供应资源同步任务。"},
|
||||
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
|
||||
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
|
||||
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
|
||||
"POST /api/tenant/media-supply/orders/sync-backlinks": {"同步媒介订单链接", "从供应商侧同步媒介订单的发布链接。"},
|
||||
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
|
||||
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
|
||||
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
|
||||
"GET /api/tenant/media-supply/supplier-session": {"供应商会话状态", "查询媒介供应商登录会话状态。"},
|
||||
"PUT /api/tenant/media-supply/supplier-session": {"导入供应商会话", "导入或更新媒介供应商登录会话。"},
|
||||
"POST /api/tenant/media-supply/supplier-session/login": {"登录供应商账号", "使用已配置账号登录媒介供应商并刷新会话。"},
|
||||
|
||||
// --- Tenant:内容合规 ---
|
||||
"GET /api/tenant/compliance/runtime-status": {"合规运行状态", "返回当前租户内容合规模块是否启用、执行模式、词库版本与检测预算。"},
|
||||
"POST /api/tenant/compliance/check": {"纯文本合规检测", "对标题/正文纯文本执行一次合规检测,返回聚合 check_record 与违规项。"},
|
||||
|
||||
@@ -722,8 +722,9 @@ func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.Desk
|
||||
// MarkPublishSubmitStarted records a durable intent marker right before the client performs the
|
||||
// irreversible platform submit POST. Lease-recovery uses it to avoid silently re-posting a
|
||||
// non-idempotent article: once submit may have started, recovery routes the task to 'unknown'
|
||||
// (manual reconcile) instead of auto-requeueing it. Best-effort — a stale/lost lease is a no-op
|
||||
// and must never block the in-flight publish.
|
||||
// (manual reconcile) instead of auto-requeueing it. The client must receive a
|
||||
// positive marker write before it performs the platform submit; a stale/lost
|
||||
// lease is rejected so the client can stop before the irreversible request.
|
||||
func (s *DesktopTaskService) MarkPublishSubmitStarted(ctx context.Context, client *repository.DesktopClient, taskID uuid.UUID, leaseToken string) error {
|
||||
if s == nil || s.pool == nil || client == nil {
|
||||
return nil
|
||||
@@ -732,7 +733,7 @@ func (s *DesktopTaskService) MarkPublishSubmitStarted(ctx context.Context, clien
|
||||
if leaseToken == "" {
|
||||
return response.ErrBadRequest(40001, "invalid_params", "lease_token is required")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET publish_submit_started_at = COALESCE(publish_submit_started_at, NOW()),
|
||||
updated_at = NOW()
|
||||
@@ -741,9 +742,13 @@ func (s *DesktopTaskService) MarkPublishSubmitStarted(ctx context.Context, clien
|
||||
AND kind = 'publish'
|
||||
AND status = 'in_progress'
|
||||
AND lease_token_hash = $3
|
||||
`, taskID, client.ID, HashDesktopClientToken(leaseToken)); err != nil {
|
||||
`, taskID, client.ID, HashDesktopClientToken(leaseToken))
|
||||
if err != nil {
|
||||
return response.ErrInternal(50200, "desktop_task_submit_marker_failed", "failed to mark publish submit started")
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -558,17 +558,17 @@ func TestMeijiequanUpstreamThrottleSerializesRequests(t *testing.T) {
|
||||
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
|
||||
defer redis.Close()
|
||||
|
||||
client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 25 * time.Millisecond})
|
||||
client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 80 * time.Millisecond})
|
||||
ctx := context.Background()
|
||||
if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil {
|
||||
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
|
||||
t.Fatalf("first wait failed: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil {
|
||||
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
|
||||
t.Fatalf("second wait failed: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 20*time.Millisecond {
|
||||
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
|
||||
t.Fatalf("expected serialized upstream wait, elapsed %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user