客户端埋点复用已有下载渠道配置
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

删除独立埋点 origin 环境变量及配置字段,按 dev/release 渠道校验官方站点。
本地开发、测试及容器的 dev 渠道允许规范回环地址,兼容可变端口。
同步环境变量示例、技术方案和运维说明,补充渠道隔离与身份校验测试。
This commit is contained in:
2026-09-23 08:36:44 +00:00
parent 016356e509
commit 30283ec651
9 changed files with 163 additions and 76 deletions
+2 -6
View File
@@ -237,14 +237,10 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.worldrelease 对应 https://www.genarrative.world。
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
# dev 使用 https://dev.genarrative.worldrelease 使用 https://www.genarrative.world。
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.
+4 -5
View File
@@ -5,11 +5,10 @@
GENARRATIVE_ENV=container
GENARRATIVE_API_HOST=0.0.0.0
GENARRATIVE_API_PORT=8082
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503
# 以下为 compose 默认宿主机入口;更改映射端口或接入域名时同步修改,不填容器内部地址
# dev 使用 https://dev.genarrative.worldrelease 使用 https://www.genarrative.world。
GENARRATIVE_AGC_ANALYTICS_ORIGIN=http://127.0.0.1:18080
# 官网客户端下载与客户端埋点共用部署渠道;修改后重启 API Server
# dev 对应 https://dev.genarrative.worldcontainer + dev 额外允许 loopback 地址及可变映射端口
# release 对应 https://www.genarrative.world,且不接受 loopback 地址;其它渠道不接收埋点
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL=dev
GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=1024
GENARRATIVE_API_WORKER_THREADS=4
+4 -4
View File
@@ -4,10 +4,10 @@
GENARRATIVE_ENV=production
GENARRATIVE_API_HOST=127.0.0.1
GENARRATIVE_API_PORT=8082
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503
# 以下为 releasedev 部署改为 https://dev.genarrative.world
GENARRATIVE_AGC_ANALYTICS_ORIGIN=https://www.genarrative.world
# 官网客户端下载与客户端埋点共用部署渠道;修改后重启 API Server
# release 对应 https://www.genarrative.worlddev 部署改为 dev,对应 https://dev.genarrative.world
# production 环境的埋点不接受 loopback 地址;其它渠道不接收埋点
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL=release
GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=1024
GENARRATIVE_API_WORKER_THREADS=4
@@ -53,7 +53,7 @@
- 当前合同唯一维护入口为[客户端本地埋点与主站入库契约](../../technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md),原始需求作为仓库内历史来源保存;后续里程碑规范与实施计划放在 `docs/project-memory/plans/`
- 本地采集阶段已验收明文 JSONL 持久化;当前仍不做加密。一个项目对应一个目标,事件按业务节点采集,5 分钟封存,7 天或 20 MiB 清理;上传失败也受保留上限约束。
- 当前上传实现已完成隔离环境验收,证据见同一主规范第 13 节:每 15 分钟上传匹配当前账号与平台的封存批次,新增一张客户端事件私有表、批次原子入库与幂等确认、成功清理及独立后台明细栏目;失败静默留待下周期重试。真实客户端文件、HTTP、数据库与后台查询已关联同一事件验证,浏览器列表/筛选/详情通过;未部署生产。保持原 12 类事件和原采集边界。上线须配置 `GENARRATIVE_AGC_ANALYTICS_ORIGIN`按数据库、API/后台、客户端顺序发布。
- 当前上传实现已完成隔离环境验收,证据见同一主规范第 13 节:每 15 分钟上传匹配当前账号与平台的封存批次,新增一张客户端事件私有表、批次原子入库与幂等确认、成功清理及独立后台明细栏目;失败静默留待下周期重试。真实客户端文件、HTTP、数据库与后台查询已关联同一事件验证,浏览器列表/筛选/详情通过;未部署生产。保持原 12 类事件和原采集边界。埋点接收复用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL` 的 dev/release 官方站点映射;仅 dev 渠道且 `GENARRATIVE_ENV` 为 development/test/container 时额外接受 loopback origin,无独立埋点环境变量。按数据库、API/后台、客户端顺序发布。
- 已按技术负责人授权开始实施:合同与本地队列、会话窗口与项目接入、策划阶段成果、首次提交及两类 Agent run 已实现并经独立审查;定向测试、生产编译和前序 GUI 启停证据统一见主规范第 12 节。不得宣称完整产品采集已上线。
## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离
@@ -1,6 +1,6 @@
# 客户端本地埋点与主站入库契约
Version: 0.31
Version: 0.32
Status: 本地采集及上传、入库、成功清理、后台查询已实现并完成本地隔离环境验收;未部署生产
Date: 2026-09-21
需求来源:[Game Agent 埋点设计原始方案](./【需求来源】GameAgent埋点设计原始方案-2026-09-05.md);原始方案与后续已确认决策有差异时,以本文为准。
@@ -609,9 +609,9 @@ session.json 是本地恢复元数据,不是待上传事件;事件文件不
- 一次请求只发一个已封存批次,1 至 500 条。事件数据沿用 1 MiB 上限,JSON 请求体上限 2 MiB,容纳数组及 envelope 开销;服务端不依赖 Content-Length 自报大小。
- 批次与所有事件的 user_id 必须相同且等于鉴权主体;匿名、未知 origin 批次不上传,不补认领到后来登录的账号。
- destination_origin 必须匹配当前服务部署的可信公开 origin 配置,不使用未经信任的 Host 头作为判据
- 接收端配置 `GENARRATIVE_AGC_ANALYTICS_ORIGIN` 为规范 origin(无路径和尾部斜杠):正式站 `https://www.genarrative.world`,测试站 `https://dev.genarrative.world`,本地联调填写客户端实际连接的 loopback origin。未配置/非法配置时接口返回 503,不猜测默认站点;允许 HTTPS 和本地 loopback HTTP
- 该变量在 API Server 运行时注入,修改后重启服务;客户端与管理后台无需新增埋点构建变量。配置示例见根目录 `.env.example`(本地默认 `http://127.0.0.1:8082`)、`deploy/env/api-server.env.example`release并注明 dev)与 `deploy/container/api-server.env.example`compose 默认宿主机入口 `http://127.0.0.1:18080`)。实际端口、映射或域名变化时同步修改,始终与客户端登录地址一致
- 接收端复用 API Server 已有的 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`,不再单独配置埋点 origin`dev` 只对应 `https://dev.genarrative.world``release` 只对应 `https://www.genarrative.world`;渠道未设置时沿用现有默认 `dev`,空值或其它渠道返回 503。destination_origin 与渠道不匹配时返回 403,不使用客户端请求的 Host / Forwarded 头推断部署渠道
- 本地联调例外:仅 `dev` 渠道且已有 `GENARRATIVE_ENV``development`(现有默认)、`test``container` 时,额外接受规范的 HTTP(S) loopback originlocalhost、IPv4/IPv6 回环地址,无路径、查询串、凭据或尾部斜杠)。端口可变以兼容本地漂移和容器映射;服务端不再按本地端口区分实例,客户端仍按精确登录 origin 与 user_id 隔离批次。`production` 环境或 `release` 渠道均不接受此例外
- 渠道与环境在 API Server 运行时读取,修改后重启服务;客户端与管理后台无需新增埋点构建变量。配置示例见根目录 `.env.example`(本地 dev)、`deploy/env/api-server.env.example`releasedev 部署改为 dev)与 `deploy/container/api-server.env.example`container + dev
- 复用现有事件版本、枚举、字段长度与必填 nullable 校验;批内重复 event_id、未知字段或非法事件整批拒绝。
- 本接口不进入普通成功路由 tracking 映射,不为每次上传另生成主站产品事件。
@@ -571,6 +571,12 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 使用 `AppState` 内同一个 OSS HTTP Client/连接池,并受进程级 8 路 OSS permit 保护;BgFilter、阿里云抠图和本地处理不占用该 permit。每个 OSS attempt 最多 3 次(首次 + 2 次重试),退避为 250ms、500ms;只重试 timeout、无 HTTP 响应传输错误、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 500599。动作帧 PUT 收到 400 时只读取最多 16 KiB OSS 错误 XML,提取 `Code``RequestId``oss_request_id` 优先使用响应头 `x-oss-request-id`,XML 字段只作回退。错误体读取超时/断流不再按确定性 400 处理:已解析出的 `Code` 优先生效;未解析出 `Code` 时按读取失败原因置 `timeout`/`transport` 并重试,message 追加「错误响应体读取失败」。日志字段包括 `frame_index``object_key``operation=source_put|final_put|final_head``attempt``max_attempts``retryable``will_retry``retry_delay_ms``permit_wait_ms``timeout``connect``transport``oss_code``oss_request_id``status``elapsed_ms``请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败;`status=400, oss_code=RequestTimeout, timeout=true``status=429``500599` 表示暂时性失败,PUT 的 `status=400``oss_code` 为空且 `timeout=true``transport=true`(message 含「错误响应体读取失败」)同样是暂时性失败。除 `RequestTimeout` 和该错误体读取失败两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT;如果任一帧最终失败,确认整段动作已排空已启动 Future,并检查任务按现有契约退款且没有发布缺帧动画。
### AGC 客户端埋点接收渠道
埋点接收复用 API Server 运行时的 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`:测试站设置 `dev`,匹配 `https://dev.genarrative.world`;正式站必须设置 `release`,匹配 `https://www.genarrative.world`。修改后重启 API Server。未设置时沿用现有默认 `dev`;空值或其它渠道的埋点上传返回 503。
本地联调使用 `dev`,且 `GENARRATIVE_ENV``development`(默认)、`test``container` 时,允许规范 HTTP(S) loopback 地址及可变端口,无需配置独立埋点变量。客户端登录时使用实际 API 入口,容器使用宿主机映射入口。线上部署设置 `GENARRATIVE_ENV=production`,不接受 loopback 例外;详细合同见[客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)第 13 节。
### AGC 项目快照上传目标
后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。
+130 -48
View File
@@ -14,6 +14,7 @@ use crate::{
admin::{AuthenticatedAdmin, require_admin_auth},
api_response::json_success_body,
auth::{AuthenticatedAccessToken, require_bearer_auth},
config::AppConfig,
http_error::AppError,
request_context::RequestContext,
state::AppState,
@@ -65,11 +66,7 @@ async fn upload_batch(
let batch: AgcAnalyticsBatch = serde_json::from_value(raw).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点字段不符合合同")
})?;
validate_subject_and_origin(
&batch,
auth.claims().user_id(),
&state.config.agc_analytics_origin,
)?;
validate_subject_and_origin(&batch, auth.claims().user_id(), &state.config)?;
module_runtime::agc_analytics::validate_agc_analytics_batch(&batch).map_err(|error| {
let status = if error == "events_too_large" {
StatusCode::PAYLOAD_TOO_LARGE
@@ -106,29 +103,38 @@ async fn list_events(
fn validate_subject_and_origin(
batch: &AgcAnalyticsBatch,
user_id: &str,
expected_origin: &str,
config: &AppConfig,
) -> Result<(), AppError> {
// 公开地址必须来自部署配置;客户端 body Host 头均不是配置来源
let origin = url::Url::parse(expected_origin).ok().filter(|url| {
let loopback = url.host_str().is_some_and(|host| {
host == "localhost"
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
// 复用部署渠道,不使用客户端 body Host / Forwarded 头推断服务器所属平台
let expected_origin = match config.client_download_channel.as_str() {
"dev" => "https://dev.genarrative.world",
"release" => "https://www.genarrative.world",
_ => {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("客户端埋点接收渠道无效"));
}
};
// 本地开发允许回环地址及可变端口,兼容端口漂移和容器映射;线上部署不启用此例外。
let local_origin = config.client_download_channel == "dev"
&& matches!(
config.environment.as_str(),
"development" | "test" | "container"
)
&& url::Url::parse(&batch.destination_origin).is_ok_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some_and(|host| {
host == "localhost"
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
&& url.username().is_empty()
&& url.password().is_none()
&& url.origin().ascii_serialization() == batch.destination_origin
});
(url.scheme() == "https" || (url.scheme() == "http" && loopback))
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.origin().ascii_serialization() == expected_origin
});
if origin.is_none() {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("客户端埋点接收地址尚未配置"));
}
if batch.user_id != user_id
|| batch.destination_origin != expected_origin
|| (batch.destination_origin != expected_origin && !local_origin)
|| batch
.events
.iter()
@@ -173,41 +179,117 @@ mod tests {
#[test]
fn agc_analytics_subject_and_deployment_are_both_required() {
let batch = batch();
assert!(validate_subject_and_origin(&batch, "user-a", &batch.destination_origin).is_ok());
for (user, origin, expected) in [
for (channel, origin, other_origin) in [
(
"user-b",
"dev",
"https://dev.genarrative.world",
StatusCode::FORBIDDEN,
),
(
"user-a",
"https://www.genarrative.world",
StatusCode::FORBIDDEN,
),
("user-a", "", StatusCode::SERVICE_UNAVAILABLE),
(
"user-a",
"https://dev.genarrative.world/path",
StatusCode::SERVICE_UNAVAILABLE,
),
(
"user-a",
"http://dev.genarrative.world",
StatusCode::SERVICE_UNAVAILABLE,
"release",
"https://www.genarrative.world",
"https://dev.genarrative.world",
),
] {
let config = AppConfig {
environment: "production".into(),
client_download_channel: channel.into(),
..AppConfig::default()
};
let mut batch = batch();
batch.destination_origin = origin.into();
assert!(validate_subject_and_origin(&batch, "user-a", &config).is_ok());
assert_eq!(
validate_subject_and_origin(&batch, user, origin)
validate_subject_and_origin(&batch, "user-b", &config)
.unwrap_err()
.status_code(),
expected
StatusCode::FORBIDDEN
);
for rejected in [
other_origin,
"http://127.0.0.1:8082",
"https://custom.example",
] {
batch.destination_origin = rejected.into();
assert_eq!(
validate_subject_and_origin(&batch, "user-a", &config)
.unwrap_err()
.status_code(),
StatusCode::FORBIDDEN,
"{channel}: {rejected}"
);
}
}
for channel in ["", "qa-2026", "dev-win"] {
let config = AppConfig {
client_download_channel: channel.into(),
..AppConfig::default()
};
assert_eq!(
validate_subject_and_origin(&batch(), "user-a", &config)
.unwrap_err()
.status_code(),
StatusCode::SERVICE_UNAVAILABLE
);
}
}
#[test]
fn agc_analytics_loopback_supports_local_ports_only_in_dev_environments() {
let mut batch = batch();
for environment in ["development", "test", "container", "production", "staging"] {
for channel in ["dev", "release"] {
let config = AppConfig {
environment: environment.into(),
client_download_channel: channel.into(),
..AppConfig::default()
};
let allowed =
channel == "dev" && matches!(environment, "development" | "test" | "container");
for origin in [
"http://127.0.0.1:8082",
"http://localhost:18080",
"http://[::1]:19001",
"https://localhost:8443",
] {
batch.destination_origin = origin.into();
assert_eq!(
validate_subject_and_origin(&batch, "user-a", &config).is_ok(),
allowed,
"{environment}/{channel}: {origin}"
);
}
}
}
}
#[test]
fn agc_analytics_local_exception_rejects_noncanonical_and_nonloopback_origins() {
let config = AppConfig::default();
let mut batch = batch();
for origin in [
"http://192.168.1.2:8082",
"http://localhost.example:8082",
"http://0.0.0.0:8082",
"http://localhost:8082/",
"http://localhost:8082/api",
"http://localhost:8082?x=1",
"http://localhost:8082#fragment",
"http://user:password@localhost:8082",
"ftp://localhost:8082",
"https://dev.genarrative.world/",
"http://dev.genarrative.world",
"",
] {
batch.destination_origin = origin.into();
assert_eq!(
validate_subject_and_origin(&batch, "user-a", &config)
.unwrap_err()
.status_code(),
StatusCode::FORBIDDEN,
"{origin}"
);
}
let mut local = batch;
local.destination_origin = "http://127.0.0.1:8082".into();
assert!(validate_subject_and_origin(&local, "user-a", &local.destination_origin).is_ok());
}
#[test]
+12 -2
View File
@@ -282,7 +282,8 @@ mod tests {
spacetime_server_url: db_url.into(),
spacetime_database: connection["database"].as_str().unwrap().into(),
spacetime_token: Some(connection["token"].as_str().unwrap().into()),
agc_analytics_origin: origin.clone(),
environment: "test".into(),
client_download_channel: "dev".into(),
admin_username: Some("analytics-smoke-owner".into()),
admin_password: Some("analytics-smoke-fixture-password".into()),
..AppConfig::default()
@@ -418,7 +419,8 @@ mod tests {
#[tokio::test]
async fn agc_analytics_upload_enforces_auth_identity_contract_and_body_limit() {
let state = AppState::new(AppConfig {
agc_analytics_origin: "https://dev.genarrative.world".into(),
environment: "production".into(),
client_download_channel: "dev".into(),
admin_username: Some("analytics-smoke-owner".into()),
admin_password: Some("analytics-smoke-fixture-password".into()),
..AppConfig::default()
@@ -444,12 +446,18 @@ mod tests {
wrong_user["user_id"] = "other-user".into();
let mut wrong_origin = payload.clone();
wrong_origin["destination_origin"] = "https://www.genarrative.world".into();
let mut local_origin = payload.clone();
local_origin["destination_origin"] = "http://127.0.0.1:8082".into();
let mut wrong_event_user = payload.clone();
wrong_event_user["events"][0]["user_id"] = "other-user".into();
let mut bad_contract = payload.clone();
bad_contract["events"][0]["properties"]["prompt"] = "not collected".into();
for (body, authenticated, expected) in [
(payload.to_string(), false, StatusCode::UNAUTHORIZED),
(wrong_user.to_string(), true, StatusCode::FORBIDDEN),
(wrong_origin.to_string(), true, StatusCode::FORBIDDEN),
(local_origin.to_string(), true, StatusCode::FORBIDDEN),
(wrong_event_user.to_string(), true, StatusCode::FORBIDDEN),
(bad_contract.to_string(), true, StatusCode::BAD_REQUEST),
("{".into(), true, StatusCode::BAD_REQUEST),
(
@@ -461,6 +469,8 @@ mod tests {
let mut request = Request::builder()
.method("POST")
.uri("/api/agc/analytics/batches")
.header("host", "www.genarrative.world")
.header("x-forwarded-host", "www.genarrative.world")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1");
if authenticated {
@@ -92,8 +92,6 @@ pub struct AppConfig {
pub editor_bgfilter_circuit_cooldown: Duration,
pub image_editor_agent_sidebar_enabled: bool,
pub client_download_channel: String,
/// 客户端埋点接收所绑定的公开 origin;未配置时拒绝上传。
pub agc_analytics_origin: String,
/// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。
pub project_snapshot_channel: String,
pub log_filter: String,
@@ -399,7 +397,6 @@ impl Default for AppConfig {
),
image_editor_agent_sidebar_enabled: false,
client_download_channel: "dev".to_string(),
agc_analytics_origin: String::new(),
project_snapshot_channel: "dev".to_string(),
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
@@ -723,9 +720,6 @@ impl AppConfig {
// 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。
config.client_download_channel = channel.trim().to_string();
}
if let Ok(origin) = std::env::var("GENARRATIVE_AGC_ANALYTICS_ORIGIN") {
config.agc_analytics_origin = origin.trim().to_string();
}
// 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先;
// 显式空值或非法值同样保留,由快照入口失败关闭。
config.project_snapshot_channel = config.client_download_channel.clone();