修复生成图片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:
@@ -208,6 +208,7 @@ fn direct_upload_ticket_form_fields_from_oss(
|
||||
signature: value.signature,
|
||||
success_action_status: value.success_action_status,
|
||||
content_type: value.content_type,
|
||||
cache_control: value.cache_control,
|
||||
metadata: value.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 边界约束
|
||||
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +541,8 @@ pub struct DirectUploadTicketFormFields {
|
||||
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>,
|
||||
}
|
||||
@@ -684,6 +686,7 @@ mod tests {
|
||||
signature: "sig".to_string(),
|
||||
success_action_status: "200".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
cache_control: Some("public, max-age=31536000, immutable".to_string()),
|
||||
metadata: BTreeMap::from([(
|
||||
"x-oss-meta-asset-kind".to_string(),
|
||||
"character_visual".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user