游戏分发媒体读新增 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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user