游戏分发媒体读新增 owner 作用域路由

- 新增 GET /api/game-distribution/my-games/{gameId}/media/read-url 与 .../media/read-bytes,需 bearer
- 判定 = gameId 属于当前登录主体且 objectKey 命中该作品当前行 cover_object_key/screenshots_json
- 抽出 game_media_object_keys 与换签/字节中转共用函数,公开读判定与响应形状不变
- 新增媒体键提取单测与 owner 路由挂载/鉴权断言
This commit is contained in:
2026-10-07 10:26:37 +08:00
parent bd4801ef80
commit 9cd6357946
@@ -273,6 +273,27 @@ struct GameDistributionMediaReadQuery {
expire_seconds: Option<u64>,
}
/// 一条游戏行当前媒体(封面 + 截图)的 objectKey 集合;空串一律忽略。
fn game_media_object_keys(game: &GameDistributionGameRecord) -> Vec<String> {
let mut keys = Vec::new();
if let Some(cover) = game.cover_object_key.as_deref() {
let cover = cover.trim();
if !cover.is_empty() {
keys.push(cover.to_string());
}
}
keys.extend(
game.screenshots_json
.as_deref()
.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
.unwrap_or_default()
.into_iter()
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty()),
);
keys
}
/// 按 objectKey 判定该对象是否是某已发布作品的当前公开媒体;非公开一律 404。
async fn ensure_public_media_object_key(
state: &AppState,
@@ -293,24 +314,62 @@ async fn ensure_public_media_object_key(
Ok(object_key.to_string())
}
/// 公开读取游戏发布媒体(封面/截图)的签名读地址;与素材库 ACL 无关。
async fn read_game_distribution_media_url(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Query(query): Query<GameDistributionMediaReadQuery>,
/// 按 objectKey 判定该对象是否是调用者自己作品当前行的媒体;未发布 / 待审 / 被驳回都放行。
///
/// 与公开读共用同一把签名器,区别只在授权来源:公开读要求「已发布 + 命中当前公开媒体」,
/// 这里要求「命中调用者自己作品当前行的 `cover_object_key` / `screenshots_json`」。因此作者
/// 不必先把作品发出去才能看到自己刚传的封面/截图,也无需给素材库 ACL 开特例。
async fn ensure_owner_media_object_key(
state: &AppState,
game_id: &str,
owner_user_id: &str,
query: &GameDistributionMediaReadQuery,
) -> Result<String, AppError> {
let object_key = query.object_key.trim();
if object_key.is_empty() {
return Err(bad_request("缺少 objectKey"));
}
let game = state
.spacetime_client()
.get_game_distribution_game(GameDistributionGetGameRecordInput {
game_id: game_id.to_string(),
owner_user_id: Some(owner_user_id.to_string()),
})
.await
.map_err(map_spacetime_error)?
.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?;
if !game_media_object_keys(&game)
.iter()
.any(|key| key == object_key)
{
return Err(AppError::from_status(StatusCode::NOT_FOUND));
}
Ok(object_key.to_string())
}
/// 媒体读地址的换签口径:公开读与 owner 读共用同一档有效期。
fn clamp_game_distribution_media_expire_seconds(expire_seconds: Option<u64>) -> Option<u64> {
object_read::clamp_public_read_expire_seconds(
expire_seconds,
object_read::ObjectReadScope::Public,
)
}
/// 换签游戏发布媒体(封面/截图)签名读地址;公开读与 owner 读共用,与素材库 ACL 无关。
async fn sign_game_distribution_media_read_url(
state: &AppState,
ctx: &RequestContext,
object_key: &str,
expire_seconds: Option<u64>,
) -> Result<Json<Value>, AppError> {
let object_key = ensure_public_media_object_key(&state, &query).await?;
let oss = state.project_snapshot_oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("游戏发布媒体 OSS 未配置")
})?;
let payload = object_read::sign_read_url(
oss,
object_key.as_str(),
object_read::clamp_public_read_expire_seconds(
query.expire_seconds,
object_read::ObjectReadScope::Public,
),
object_key,
clamp_game_distribution_media_expire_seconds(expire_seconds),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -318,7 +377,36 @@ async fn read_game_distribution_media_url(
"message": error.to_string(),
}))
})?;
Ok(json_success_body(Some(&ctx), json!({ "read": payload })))
Ok(json_success_body(Some(ctx), json!({ "read": payload })))
}
/// 同源中转游戏发布媒体字节;公开读与 owner 读共用。
async fn proxy_game_distribution_media_read_bytes(
state: &AppState,
object_key: &str,
expire_seconds: Option<u64>,
) -> Result<Response, AppError> {
let oss = state.project_snapshot_oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("游戏发布媒体 OSS 未配置")
})?;
object_read::proxy_read_bytes(
oss,
object_key,
clamp_game_distribution_media_expire_seconds(expire_seconds),
"game-distribution-media-read-bytes",
)
.await
}
/// 公开读取游戏发布媒体(封面/截图)的签名读地址;与素材库 ACL 无关。
async fn read_game_distribution_media_url(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Query(query): Query<GameDistributionMediaReadQuery>,
) -> Result<Json<Value>, AppError> {
let object_key = ensure_public_media_object_key(&state, &query).await?;
sign_game_distribution_media_read_url(&state, &ctx, &object_key, query.expire_seconds).await
}
/// 公开同源读取游戏发布媒体字节;授权判定与 `read-url` 同口径。
@@ -327,20 +415,34 @@ async fn read_game_distribution_media_bytes(
Query(query): Query<GameDistributionMediaReadQuery>,
) -> Result<Response, AppError> {
let object_key = ensure_public_media_object_key(&state, &query).await?;
let oss = state.project_snapshot_oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("游戏发布媒体 OSS 未配置")
})?;
object_read::proxy_read_bytes(
oss,
object_key.as_str(),
object_read::clamp_public_read_expire_seconds(
query.expire_seconds,
object_read::ObjectReadScope::Public,
),
"game-distribution-media-read-bytes",
)
.await
proxy_game_distribution_media_read_bytes(&state, &object_key, query.expire_seconds).await
}
/// owner 读取自己作品媒体(封面/截图)的签名读地址:未发布 / 待审 / 被驳回都可见。
async fn read_owner_game_distribution_media_url(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(game_id): Path<String>,
Query(query): Query<GameDistributionMediaReadQuery>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = auth.claims().user_id().to_string();
let object_key =
ensure_owner_media_object_key(&state, &game_id, &owner_user_id, &query).await?;
sign_game_distribution_media_read_url(&state, &ctx, &object_key, query.expire_seconds).await
}
/// owner 同源读取自己作品媒体字节;授权判定与 owner `read-url` 同口径。
async fn read_owner_game_distribution_media_bytes(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(game_id): Path<String>,
Query(query): Query<GameDistributionMediaReadQuery>,
) -> Result<Response, AppError> {
let owner_user_id = auth.claims().user_id().to_string();
let object_key =
ensure_owner_media_object_key(&state, &game_id, &owner_user_id, &query).await?;
proxy_game_distribution_media_read_bytes(&state, &object_key, query.expire_seconds).await
}
pub fn router(state: AppState) -> Router<AppState> {
@@ -508,6 +610,24 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route_layer(middleware::from_fn(add_no_store_response_headers));
// owner 作用域媒体读:作者预览自己作品(未发布 / 待审 / 被驳回)的封面与截图。
// 与公开读共用签名器,但要求 bearer,且 objectKey 必须命中自己作品当前行的媒体。
// 软删作品在 `get_game_distribution_game` 已返回 None(404),这里不为其开路。
let owner_media = Router::new()
.route(
"/api/game-distribution/my-games/{game_id}/media/read-url",
get(read_owner_game_distribution_media_url),
)
.route(
"/api/game-distribution/my-games/{game_id}/media/read-bytes",
get(read_owner_game_distribution_media_bytes),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.route_layer(middleware::from_fn(add_no_store_response_headers));
Router::new()
.route(
"/api/game-distribution/releases/{game_id}/{*asset_path}",
@@ -536,6 +656,7 @@ pub fn router(state: AppState) -> Router<AppState> {
get(serve_admin_version_preview_entry),
)
.merge(public_games)
.merge(owner_media)
.merge(protected)
.merge(play_sessions)
.merge(user_reviews)
@@ -5134,6 +5255,25 @@ mod tests {
.expect("路由响应");
assert_eq!(media_read_with_key.status(), StatusCode::BAD_GATEWAY);
// owner 作用域媒体读已挂载且要求登录态:作者预览未发布/被驳回作品的封面与截图走这里,
// 不再依赖公开读,也不给素材库 ACL 开特例。
for uri in [
"/api/game-distribution/my-games/game_1/media/read-url?objectKey=generated%2Fcover.png",
"/api/game-distribution/my-games/game_1/media/read-bytes?objectKey=generated%2Fcover.png",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "uri={uri}");
}
// 作者作品详情是 owner 作用域路由,未带 Bearer 时同样在进入业务前被拒绝。
let unauthenticated_owner_game = app
.clone()
@@ -6141,6 +6281,25 @@ mod tests {
assert_eq!(user_agent_tag(&headers), "test-agent");
}
#[test]
fn game_media_object_keys_reads_cover_and_screenshots_and_skips_blanks() {
let mut game = paid_game_record("user_author", 0);
game.cover_object_key = Some(" cover.png ".to_string());
game.screenshots_json = Some(r#"["shot-1.png"," ","shot-2.png"]"#.to_string());
assert_eq!(
game_media_object_keys(&game),
vec![
"cover.png".to_string(),
"shot-1.png".to_string(),
"shot-2.png".to_string(),
]
);
game.cover_object_key = Some(" ".to_string());
game.screenshots_json = Some("not-json".to_string());
assert!(game_media_object_keys(&game).is_empty());
}
fn paid_game_record(owner_user_id: &str, price_mud_points: u64) -> GameDistributionGameRecord {
GameDistributionGameRecord {
game_id: "game_1".to_string(),