- fix1 曝光中性: 零增量事件不写聚合不建全零行, 仅 engagement 事件触发 quality 重算(修曝光清零 bug) - fix2 原子累加: GameStatMapper.insertOrAccumulate 单语句 ON DUPLICATE KEY UPDATE 替换读改写丢增量 - fix3 下架编排: reviewProject UNLIST 委托 unlist 单事务(项目4→5+feed_rank 全分区下线), 填 R7 缺口 - 修 batch-001 P1: backend_gw.feed_find_game cursor 翻页(原 size=50 超后端单页上限30 致 400 误熔断) - 文档: M-b 生成下沉 execution(HJ-AGENT-LOOP-EXEC-002 已审定+§16 裁决) + 数据回路小波 execution + 真实鉴权与匿名玩家 review(双镜头 12 条意见全修订) + EXEC-001 §16 建设期裁决增补 三修均经 mini-desktop 隔离克隆构建: telemetry 17测/project+feed 39测 全绿+逐修对抗核验 pass Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
12 KiB
Python
234 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
staging 后端 HTTP 网关(D3 件)—— spec:HJ-AGENT-LOOP-EXEC-001 §7.1
|
||
|
||
封装编排器对 staging 单体(默认 http://100.64.0.7:48080,在跑勿动)的全部读写:
|
||
draft / generate / getTask / callback / package / manifest / publish / review / feed 探测。
|
||
|
||
鉴权配方(B1 已验):`Authorization: Bearer test1` + `tenant-id: 1`(staging mock;
|
||
内网环境地址与 mock 口令允许入库——repo 既有纪律)。
|
||
|
||
错误分类(对齐 §11 失败路径表):
|
||
- transport 类(网络异常/HTTP≥500/401/403/超时)→ BackendError(kind="transport"),
|
||
默认重试 ×2 指数退避(F3/F4 口径);
|
||
- business 类(HTTP 200 但 CommonResult.code != 0)→ BackendError(kind="business"),
|
||
不重试(F5:回调写链失败任务已被补偿置终态,重复重放同 traceId 必拒)。
|
||
全部外部交互打中文日志(可追溯)。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
# B1 已验 staging mock 鉴权(内网 staging 专用)
|
||
DEFAULT_BACKEND_BASE = "http://100.64.0.7:48080"
|
||
DEFAULT_TOKEN = "test1"
|
||
DEFAULT_TENANT_ID = "1"
|
||
|
||
# HTTP 重试纪律(F3/F4:重试 ×2,指数退避 1s/2s)
|
||
HTTP_MAX_RETRIES = 2
|
||
HTTP_TIMEOUT_SECONDS = 30
|
||
|
||
# 发布去向专区:staging 既有夹具 9001/9002 与 B3 种子全部为 launch_zone_id=0 / feed zone_id=0
|
||
#(seed-staging-report.md §3.2 实测基线),批跑沿用同一专区
|
||
DEFAULT_LAUNCH_ZONE_ID = 0
|
||
|
||
|
||
class BackendError(Exception):
|
||
"""后端调用失败。kind ∈ {"transport","business"};business 携带业务码 biz_code(如 1101003001)。"""
|
||
|
||
def __init__(self, message, kind, http_status=None, biz_code=None):
|
||
super(BackendError, self).__init__(message)
|
||
self.kind = kind
|
||
self.http_status = http_status
|
||
self.biz_code = biz_code
|
||
|
||
|
||
def sha256_hex(text):
|
||
"""对响应原文计算 sha256 hex(manifest 完整性核验 §9.5 用;UTF-8 字节面)。"""
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
class BackendGateway(object):
|
||
"""staging 后端网关:CommonResult 解包 + 错误分类 + 重试 + 中文日志。"""
|
||
|
||
def __init__(self, base=DEFAULT_BACKEND_BASE, token=DEFAULT_TOKEN, tenant_id=DEFAULT_TENANT_ID,
|
||
sleeper=time.sleep, opener=None, log=None):
|
||
self.base = base.rstrip("/")
|
||
self.token = token
|
||
self.tenant_id = tenant_id
|
||
self._sleep = sleeper
|
||
self._open = opener or urllib.request.urlopen
|
||
self._log = log or (lambda msg: print(msg, flush=True))
|
||
|
||
# ------------------------------------------------------------------ 基础请求
|
||
|
||
def _request(self, method, path, json_body=None, raw=False, retries=HTTP_MAX_RETRIES):
|
||
"""
|
||
发请求并按错误分类返回。
|
||
:param raw: True=返回响应原文字符串(manifest 端点不包 CommonResult,原样返回——§2.1);
|
||
False=解 CommonResult,code!=0 抛 business 类错误,否则返回 data。
|
||
:param retries: transport 类重试次数(business 类一律不重试)。
|
||
"""
|
||
url = self.base + path
|
||
body = None
|
||
headers = {
|
||
"Authorization": "Bearer " + self.token,
|
||
"tenant-id": self.tenant_id,
|
||
}
|
||
if json_body is not None:
|
||
body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
last_err = None
|
||
for attempt in range(1, retries + 2):
|
||
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||
try:
|
||
with self._open(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
||
text = resp.read().decode("utf-8")
|
||
if raw:
|
||
return text
|
||
envelope = json.loads(text)
|
||
code = envelope.get("code")
|
||
if code != 0:
|
||
# business 类:后端业务异常(含回调写链失败补偿后的 ServiceException),不重试
|
||
raise BackendError(
|
||
"后端业务错误 code=%s msg=%s(%s %s)" % (code, envelope.get("msg"), method, path),
|
||
kind="business", biz_code=code)
|
||
return envelope.get("data")
|
||
except BackendError:
|
||
raise # business 类直接上抛
|
||
except urllib.error.HTTPError as ex:
|
||
detail = ""
|
||
try:
|
||
detail = ex.read().decode("utf-8", "replace")[:200]
|
||
except Exception: # noqa: BLE001 —— 读错误体失败不掩盖原错误
|
||
pass
|
||
last_err = BackendError("HTTP %s(%s %s)%s" % (ex.code, method, path, detail),
|
||
kind="transport", http_status=ex.code)
|
||
self._log("[backend] 第 %d 次尝试 HTTP %s:%s %s" % (attempt, ex.code, method, path))
|
||
except Exception as ex: # 网络超时/连接异常/JSON 解析失败
|
||
last_err = BackendError("通道异常 %s(%s %s)" % (ex, method, path), kind="transport")
|
||
self._log("[backend] 第 %d 次尝试通道异常:%s %s(%s)" % (attempt, method, path, ex))
|
||
if attempt <= retries:
|
||
self._sleep(1.0 * (2 ** (attempt - 1))) # 指数退避 1s/2s
|
||
raise last_err
|
||
|
||
# ------------------------------------------------------------------ studio 链(submit 阶段)
|
||
|
||
def create_draft(self, title, template_id, prompt):
|
||
"""建草稿:POST /app-api/studio/draft → {id: sessionId, gameId, ...}(P-CRT-10/P-TPL-03)。"""
|
||
self._log("[backend] 建草稿 title=%s templateId=%s" % (title, template_id))
|
||
return self._request("POST", "/app-api/studio/draft", {
|
||
"title": title, "templateId": template_id, "prompt": prompt,
|
||
})
|
||
|
||
def studio_generate(self, session_id, prompt):
|
||
"""一句话生成:POST /app-api/studio/generate → 任务链 {id, aigcTaskId, gameId, ...}(P-CRT-01)。"""
|
||
self._log("[backend] 提交生成 sessionId=%s" % session_id)
|
||
return self._request("POST", "/app-api/studio/generate", {
|
||
"sessionId": session_id, "prompt": prompt,
|
||
})
|
||
|
||
def get_aigc_task(self, task_id):
|
||
"""生成任务轮询:GET /app-api/aigc/task/{id} → AigcTaskRespVO(含 traceId/status/versionId)。"""
|
||
return self._request("GET", "/app-api/aigc/task/%d" % int(task_id))
|
||
|
||
# ------------------------------------------------------------------ 回调(写入面唯一交叉点,§3)
|
||
|
||
def dify_callback(self, payload, allow_retry=True):
|
||
"""
|
||
伪装 Dify 出参回调:POST /admin-api/aigc/dify/callback(契约 #6 output)。
|
||
transport 类按 F4 重试 ×2;business 类按 F5 不重试(任务已终态拒重入,resume 走新任务)。
|
||
"""
|
||
self._log("[backend] Dify 回调 traceId=%s status=%s" % (payload.get("traceId"), payload.get("status")))
|
||
return self._request("POST", "/admin-api/aigc/dify/callback", payload,
|
||
retries=HTTP_MAX_RETRIES if allow_retry else 0)
|
||
|
||
# ------------------------------------------------------------------ runtime 取包(§9.5 前置核验)
|
||
|
||
def get_package(self, version_id, scene="preview"):
|
||
"""取包清单:GET /app-api/runtime/package/{versionId}?scene= → RuntimePackageRespVO(含 checksum)。"""
|
||
return self._request("GET", "/app-api/runtime/package/%d?scene=%s"
|
||
% (int(version_id), urllib.parse.quote(scene)))
|
||
|
||
def get_manifest_raw(self, version_id):
|
||
"""取 manifest 原文:GET …/manifest(原样字符串返回,不包 CommonResult——§2.1 已核)。"""
|
||
return self._request("GET", "/app-api/runtime/package/%d/manifest" % int(version_id), raw=True)
|
||
|
||
# ------------------------------------------------------------------ 发布段(§7.6,仅 accept 走)
|
||
|
||
def submit_publish(self, game_id, version_id, launch_zone_id=DEFAULT_LAUNCH_ZONE_ID):
|
||
"""①提交发布:POST /app-api/project/{gameId}/publish → {admitted, gates...}(门禁聚合)。"""
|
||
self._log("[backend] 提交发布 gameId=%s versionId=%s zone=%s" % (game_id, version_id, launch_zone_id))
|
||
return self._request("POST", "/app-api/project/%d/publish" % int(game_id), {
|
||
"versionId": int(version_id), "launchZoneId": int(launch_zone_id),
|
||
})
|
||
|
||
def review_approve(self, game_id, version_id, reason):
|
||
"""②审核通过:POST /admin-api/project/review decision=1(staging 自动批,reason 即审计线索——§7.6)。"""
|
||
self._log("[backend] 审核通过 gameId=%s versionId=%s" % (game_id, version_id))
|
||
return self._request("POST", "/admin-api/project/review", {
|
||
"gameId": int(game_id), "versionId": int(version_id), "decision": 1, "reason": reason,
|
||
})
|
||
|
||
def get_project(self, game_id):
|
||
"""项目详情:GET /app-api/project/{id}(postcheck 用:status=4 已发布)。"""
|
||
return self._request("GET", "/app-api/project/%d" % int(game_id))
|
||
|
||
# ------------------------------------------------------------------ feed(postcheck/探活)
|
||
|
||
def feed_stream(self, size=30):
|
||
"""feed 流:GET /app-api/feed/stream?size=(B3 assert_feed 配方;postcheck 金丝雀可见性)。"""
|
||
data = self._request("GET", "/app-api/feed/stream?size=%d" % int(size))
|
||
# 兼容字段名定位卡片列表(沿 assert_feed.py 配方)
|
||
if isinstance(data, dict):
|
||
for key in ("list", "cards", "items", "records"):
|
||
if isinstance(data.get(key), list):
|
||
return data[key]
|
||
if isinstance(data, list):
|
||
return data
|
||
raise BackendError("feed/stream 响应未找到卡片列表字段:%r" % (data,), kind="business")
|
||
|
||
def feed_find_game(self, game_id, page_size=30, max_pages=10):
|
||
"""feed 流逐页查找指定 gameId(postcheck 金丝雀可见性专用)。
|
||
|
||
修复背景(批 batch-001 P1 误熔断):后端 /app-api/feed/stream 为 cursor 分页且单页上限 30,
|
||
原 postcheck 用 size=50 直接被参数校验拒(code=400「单页条数不能超过 30」)→ 连续 2 条
|
||
accept_publish_fail → F10 误停发布段。本方法按 cursor 翻页直到找到 / 翻尽 / 达页数上限,
|
||
同时消灭「feed 超过一页后新游戏(quality 基线 0 沉底)落在首页外被误判不可见」的同款隐患。
|
||
|
||
:param game_id: 目标游戏 ID
|
||
:param page_size: 单页条数(钳制到后端上限 30)
|
||
:param max_pages: 安全页数上限(防异常 cursor 死循环)
|
||
:return: True=在流中找到该游戏;False=翻尽未找到
|
||
"""
|
||
size = min(int(page_size), 30) # 后端单页硬上限 30,超出会 400
|
||
cursor = None
|
||
for _ in range(int(max_pages)):
|
||
path = "/app-api/feed/stream?size=%d" % size
|
||
if cursor:
|
||
path += "&cursor=%s" % cursor
|
||
data = self._request("GET", path)
|
||
cards = data.get("list") if isinstance(data, dict) else None
|
||
if not isinstance(cards, list):
|
||
raise BackendError("feed/stream 响应未找到 list 字段:%r" % (data,), kind="business")
|
||
if any(c.get("gameId") == game_id for c in cards):
|
||
return True
|
||
# nextCursor 空串=已到底(T-FED-06 契约);hasMore=False 同义兜底
|
||
cursor = data.get("nextCursor")
|
||
if not cursor or data.get("hasMore") is False:
|
||
return False
|
||
# 翻页达上限仍未找到:如实返回不可见(不抛错,交由 postcheck 记 fail 留证)
|
||
return False
|
||
|
||
def probe_staging(self):
|
||
"""staging 探活(F9/§7.4-3 前置):GET /app-api/feed/zones 只读端点。失败抛 BackendError。"""
|
||
self._request("GET", "/app-api/feed/zones")
|
||
self._log("[backend] staging 探活通过:%s" % self.base)
|
||
return True
|