修复生成图片OSS签名缓存链路

前端将完整阿里云OSS generated 地址归一为 legacy path 后走 read-url 换签。

platform-oss 为 generated 私有对象 PostObject 和 PutObject 写入 immutable Cache-Control。

补齐 shared-contracts 与 api-server 直传票据字段映射。

更新后端、运维文档和 Hermes 团队记忆,明确不使用服务端磁盘缓存兜底。
This commit is contained in:
2026-06-07 17:19:03 +08:00
parent 3965f34b02
commit 5daeef21bf
10 changed files with 193 additions and 8 deletions

View File

@@ -23,6 +23,7 @@
6. `x-oss-meta-*` 元数据归一化与大小限制校验
7. `content-type``content-length-range``success_action_status` policy 条件生成
8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object``PutObject` 的结构化日志
9. generated 私有对象上传默认写入 `Cache-Control: public, max-age=31536000, immutable`
当前仍未落地的内容:
@@ -38,6 +39,8 @@
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
6. 结构化日志只记录 `provider``operation``bucket``endpoint``object_key` / `key_prefix``access``content_type``content_length``status``status_class``error_kind``elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
7. 完整 OSS URL 不能当作 object key 传入签名接口;前端收到 `https://*.oss-*.aliyuncs.com/generated-*` 时应先归一为 legacy public path再通过 `/api/assets/read-url` 换取短期 signed URL。
8. generated 资源缓存的主路径是 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN不允许改成 api-server 本地磁盘静态资源兜底。
## 3. 边界约束

View File

@@ -16,6 +16,7 @@ pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
pub const DEFAULT_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
@@ -199,6 +200,8 @@ pub struct OssPostObjectFormFields {
pub success_action_status: String,
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
@@ -425,6 +428,7 @@ impl OssClient {
let legacy_public_path = format!("/{}", object_key);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
let cache_control = Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string());
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
@@ -448,6 +452,7 @@ impl OssClient {
max_size_bytes,
success_action_status,
content_type.as_deref(),
cache_control.as_deref(),
&metadata,
&credential,
&signature_date,
@@ -485,6 +490,7 @@ impl OssClient {
signature,
success_action_status: success_action_status.to_string(),
content_type,
cache_control,
metadata,
},
})
@@ -788,7 +794,7 @@ impl OssClient {
let file_name = sanitize_file_name(&request.file_name)?;
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
let headers = build_put_object_headers(request.metadata)?;
let target_url =
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
@@ -802,7 +808,7 @@ impl OssClient {
Some(&object_key),
target_url,
content_type.as_deref(),
&metadata,
&headers,
)?
.header(reqwest::header::CONTENT_LENGTH, content_length)
.body(request.body);
@@ -957,6 +963,7 @@ fn build_policy_json(
max_size_bytes: u64,
success_action_status: u16,
content_type: Option<&str>,
cache_control: Option<&str>,
metadata: &BTreeMap<String, String>,
credential: &str,
signature_date: &str,
@@ -979,6 +986,10 @@ fn build_policy_json(
conditions.push(json!(["eq", "$content-type", content_type]));
}
if let Some(cache_control) = cache_control {
conditions.push(json!(["eq", "$Cache-Control", cache_control]));
}
for (key, value) in metadata {
conditions.push(json!(["eq", format!("${key}"), value]));
}
@@ -1089,6 +1100,18 @@ fn normalize_metadata(
Ok(normalized)
}
fn build_put_object_headers(
metadata: BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, OssError> {
// 中文注释:生成资产 object key 含会话与 asset id内容不可变适合交给浏览器/CDN 长缓存。
let mut headers = BTreeMap::from([(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
)]);
headers.extend(normalize_metadata(metadata)?);
Ok(headers)
}
fn normalize_metadata_key(raw: &str) -> String {
let stripped = raw
.trim()
@@ -1283,13 +1306,13 @@ fn signed_request_builder(
}
let canonical_headers = build_v4_canonical_headers(&signed_headers);
let additional_headers = "host";
let additional_headers = build_v4_additional_headers(&signed_headers);
let canonical_request = build_v4_canonical_request(
method.as_str(),
&canonical_uri,
"",
&canonical_headers,
additional_headers,
&additional_headers,
&body_sha256,
);
let string_to_sign =
@@ -1468,6 +1491,16 @@ fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
.collect::<String>()
}
fn build_v4_additional_headers(headers: &BTreeMap<String, String>) -> String {
let mut additional_headers = headers
.keys()
.map(|key| key.to_ascii_lowercase())
.filter(|key| key != "content-type" && key != "content-md5" && !key.starts_with("x-oss-"))
.collect::<Vec<_>>();
additional_headers.sort();
additional_headers.join(";")
}
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
@@ -1713,6 +1746,14 @@ mod tests {
policy["conditions"][7],
json!(["eq", "$content-type", "image/png"])
);
assert_eq!(
policy["conditions"][8],
json!(["eq", "$Cache-Control", DEFAULT_IMMUTABLE_CACHE_CONTROL])
);
assert_eq!(
response.form_fields.cache_control,
Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
}
@@ -1870,6 +1911,10 @@ mod tests {
#[test]
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
let headers = BTreeMap::from([
(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
),
(
"x-oss-meta-source-job-id".to_string(),
" job_001 ".to_string(),
@@ -1882,7 +1927,47 @@ mod tests {
assert_eq!(
build_v4_canonical_headers(&headers),
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
"cache-control:public, max-age=31536000, immutable\nx-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}
#[test]
fn additional_headers_include_plain_headers_and_skip_oss_managed_headers() {
let headers = BTreeMap::from([
(
"host".to_string(),
"genarrative-assets.oss-cn-beijing.aliyuncs.com".to_string(),
),
("content-type".to_string(), "image/png".to_string()),
(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
),
("x-oss-date".to_string(), "20260507T120000Z".to_string()),
(
"x-oss-meta-asset-kind".to_string(),
"puzzle-cover".to_string(),
),
]);
assert_eq!(build_v4_additional_headers(&headers), "cache-control;host");
}
#[test]
fn put_object_headers_include_immutable_cache_control_for_generated_assets() {
let headers = build_put_object_headers(BTreeMap::from([(
"asset-kind".to_string(),
"puzzle-cover".to_string(),
)]))
.expect("headers should build");
assert_eq!(
headers.get("Cache-Control"),
Some(&DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
);
assert_eq!(
headers.get("x-oss-meta-asset-kind"),
Some(&"puzzle-cover".to_string())
);
}