zizi b87511b967 feat(data-loop): 数据回路三修 + postcheck翻页修复 + M-b/小波双spec + 鉴权评审版
- 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>
2026-06-10 01:05:55 +00:00

234 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
staging 后端 HTTP 网关D3 件)—— specHJ-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 hexmanifest 完整性核验 §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=解 CommonResultcode!=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 重试 ×2business 类按 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=1staging 自动批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))
# ------------------------------------------------------------------ feedpostcheck/探活)
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 流逐页查找指定 gameIdpostcheck 金丝雀可见性专用)。
修复背景(批 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