fix: stabilize admin tracking event display
This commit is contained in:
@@ -246,7 +246,7 @@ export function AdminTrackingEventsPage({
|
|||||||
{formatMetadataJson(entry.metadataJson)}
|
{formatMetadataJson(entry.metadataJson)}
|
||||||
</pre>
|
</pre>
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.occurredAt || '-'}</td>
|
<td>{formatOccurredAt(entry.occurredAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
className="admin-secondary-button"
|
className="admin-secondary-button"
|
||||||
@@ -314,7 +314,7 @@ function TrackingEventDetailPanel({
|
|||||||
{formatMetadataJson(entry.metadataJson)}
|
{formatMetadataJson(entry.metadataJson)}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
formatExportCell(entry[column.key]) || '-'
|
formatExportCell(entry[column.key], column.key) || '-'
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +358,10 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
|
|||||||
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
|
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
|
||||||
...entries.map((entry) =>
|
...entries.map((entry) =>
|
||||||
exportColumns
|
exportColumns
|
||||||
.map((column) => `<td style="mso-number-format:'\\@';">${escapeHtml(formatExportCell(entry[column.key]))}</td>`)
|
.map(
|
||||||
|
(column) =>
|
||||||
|
`<td style="mso-number-format:'\\@';">${escapeHtml(formatExportCell(entry[column.key], column.key))}</td>`,
|
||||||
|
)
|
||||||
.join(''),
|
.join(''),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -376,13 +379,41 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExportCell(value: unknown) {
|
function formatExportCell(value: unknown, key?: keyof AdminTrackingEventEntryPayload) {
|
||||||
if (value === null || typeof value === 'undefined') {
|
if (value === null || typeof value === 'undefined') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (key === 'occurredAt') {
|
||||||
|
return formatOccurredAt(String(value));
|
||||||
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOccurredAt(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const micros = Number(trimmed);
|
||||||
|
if (Number.isSafeInteger(micros)) {
|
||||||
|
return formatDateTime(new Date(Math.floor(micros / 1000)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return formatDateTime(parsed);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(date: Date) {
|
||||||
|
const pad = (value: number, size = 2) => String(value).padStart(size, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
function escapeHtml(value: string) {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ GET /admin/api/tracking/events?eventKey=&userId=&scopeKind=&scopeId=&limit=
|
|||||||
|
|
||||||
1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。
|
1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。
|
||||||
2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。
|
2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。
|
||||||
3. SQL 只读 `tracking_event`,固定白名单列,按 `occurred_at DESC` 排序,默认 200 条,最大 1000 条。
|
3. SQL 只读 `tracking_event`,固定白名单列;由于 SpacetimeDB 2.2 HTTP SQL 不支持 `ORDER BY`,后端取回默认 200 / 最大 1000 条后在 API 层按 `occurred_at` 倒序排序。
|
||||||
4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。
|
4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。
|
||||||
5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`。
|
5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`。
|
||||||
|
|
||||||
|
|||||||
69
docs/technical/ALIYUN_SMS_TIMESTAMP_FORMAT_FIX_2026-05-07.md
Normal file
69
docs/technical/ALIYUN_SMS_TIMESTAMP_FORMAT_FIX_2026-05-07.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 短信验证码阿里云时间戳格式修复(2026-05-07)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
使用阿里云短信验证码真实 provider 发送验证码时,接口返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
短信验证码发送失败:Specified time stamp or date value is not well formatted.
|
||||||
|
```
|
||||||
|
|
||||||
|
该错误来自阿里云 OpenAPI 网关对签名请求头 `x-acs-date` 的格式校验。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
`server-rs/crates/platform-auth/src/lib.rs` 中阿里云 ACS3 签名逻辑会构造 `x-acs-date` 请求头。
|
||||||
|
|
||||||
|
原实现使用 `time::format_description::well_known::Rfc3339`,当 `OffsetDateTime::now_utc()` 带纳秒时会生成形如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-05-07T14:23:59.364767Z
|
||||||
|
```
|
||||||
|
|
||||||
|
阿里云 ACS3 签名要求 `x-acs-date` 使用不带小数秒的 UTC ISO 8601 格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
yyyy-MM-dd'T'HH:mm:ss'Z'
|
||||||
|
```
|
||||||
|
|
||||||
|
即:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-05-07T14:23:59Z
|
||||||
|
```
|
||||||
|
|
||||||
|
带小数秒的时间戳会被阿里云网关判定为格式非法,从而返回 `Specified time stamp or date value is not well formatted.`。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
将 `current_aliyun_timestamp()` 改为手动输出不带小数秒的 UTC ISO 8601 格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
yyyy-MM-dd'T'HH:mm:ss'Z'
|
||||||
|
```
|
||||||
|
|
||||||
|
并新增单元测试,确保:
|
||||||
|
|
||||||
|
- 长度等于 `2026-05-07T12:34:56Z`;
|
||||||
|
- 固定位置包含 `-`、`T`、`:`、`Z`;
|
||||||
|
- 不包含小数点;
|
||||||
|
- 除固定分隔符外均为数字。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 仅影响阿里云短信验证码 provider 的请求签名头 `x-acs-date`。
|
||||||
|
- 不改动短信模板、签名、验证码业务参数。
|
||||||
|
- 不改动 mock 短信 provider。
|
||||||
|
- 不涉及前端接口契约变化。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server-rs
|
||||||
|
cargo test -p platform-auth aliyun -- --nocapture
|
||||||
|
cargo fmt -p platform-auth --check
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:相关测试通过,格式检查通过。
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
|
fs,
|
||||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -516,11 +517,13 @@ async fn fetch_admin_tracking_events(
|
|||||||
.spacetime_token
|
.spacetime_token
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(load_local_spacetime_cli_token);
|
||||||
let sql = build_admin_tracking_events_sql(&query)
|
let sql = build_admin_tracking_events_sql(&query)
|
||||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||||
|
|
||||||
let payload = fetch_spacetime_sql_json(&client, server_root, database, token, &sql)
|
let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||||
@@ -555,7 +558,7 @@ fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Resul
|
|||||||
};
|
};
|
||||||
let limit = clamp_admin_tracking_event_limit(query.limit);
|
let limit = clamp_admin_tracking_event_limit(query.limit);
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} ORDER BY occurred_at DESC LIMIT {limit}"
|
"SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} LIMIT {limit}"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +566,17 @@ fn normalized_non_empty(value: Option<&str>) -> Option<&str> {
|
|||||||
value.map(str::trim).filter(|value| !value.is_empty())
|
value.map(str::trim).filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_local_spacetime_cli_token() -> Option<String> {
|
||||||
|
// 本地开发清库后会通过 `/v1/identity` 重新登录 CLI;这里复用 CLI token,确保 SQL 可读取 private 表。
|
||||||
|
let content = fs::read_to_string(".spacetimedb/local/config/cli.toml")
|
||||||
|
.or_else(|_| fs::read_to_string("server-rs/.spacetimedb/local/config/cli.toml"))
|
||||||
|
.ok()?;
|
||||||
|
content.lines().find_map(|line| {
|
||||||
|
let value = line.trim().strip_prefix("spacetimedb_token = ")?;
|
||||||
|
Some(value.trim().trim_matches('"').to_string()).filter(|token| !token.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn quote_sql_string(value: &str) -> String {
|
fn quote_sql_string(value: &str) -> String {
|
||||||
format!("'{}'", value.replace('\'', "''"))
|
format!("'{}'", value.replace('\'', "''"))
|
||||||
}
|
}
|
||||||
@@ -621,6 +635,11 @@ fn parse_admin_tracking_events_sql_response(
|
|||||||
rows.iter()
|
rows.iter()
|
||||||
.map(parse_admin_tracking_event_row)
|
.map(parse_admin_tracking_event_row)
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map(|mut entries| {
|
||||||
|
// SpacetimeDB 2.2 的 HTTP SQL 暂不支持 ORDER BY;后台在 API 层按发生时间倒序收口。
|
||||||
|
entries.sort_by(|left, right| right.occurred_at.cmp(&left.occurred_at));
|
||||||
|
entries
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_first_sql_rows(payload: Value) -> Result<Vec<Value>, String> {
|
fn extract_first_sql_rows(payload: Value) -> Result<Vec<Value>, String> {
|
||||||
@@ -651,7 +670,12 @@ fn parse_admin_tracking_event_row(row: &Value) -> Result<AdminTrackingEventEntry
|
|||||||
event_id: required_string_column(columns, 0, "event_id")?,
|
event_id: required_string_column(columns, 0, "event_id")?,
|
||||||
event_title: admin_tracking_event_title(&event_key).to_string(),
|
event_title: admin_tracking_event_title(&event_key).to_string(),
|
||||||
event_key,
|
event_key,
|
||||||
scope_kind: required_string_column(columns, 2, "scope_kind")?,
|
scope_kind: tracking_scope_kind_to_string(
|
||||||
|
columns
|
||||||
|
.get(2)
|
||||||
|
.ok_or_else(|| "埋点行缺少 scope_kind".to_string())?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "埋点行 scope_kind 类型非法".to_string())?,
|
||||||
scope_id: required_string_column(columns, 3, "scope_id")?,
|
scope_id: required_string_column(columns, 3, "scope_id")?,
|
||||||
day_key: required_i64_column(columns, 4, "day_key")?,
|
day_key: required_i64_column(columns, 4, "day_key")?,
|
||||||
user_id: optional_string_column(columns, 5),
|
user_id: optional_string_column(columns, 5),
|
||||||
@@ -659,7 +683,12 @@ fn parse_admin_tracking_event_row(row: &Value) -> Result<AdminTrackingEventEntry
|
|||||||
profile_id: optional_string_column(columns, 7),
|
profile_id: optional_string_column(columns, 7),
|
||||||
module_key: optional_string_column(columns, 8),
|
module_key: optional_string_column(columns, 8),
|
||||||
metadata_json: required_string_column(columns, 9, "metadata_json")?,
|
metadata_json: required_string_column(columns, 9, "metadata_json")?,
|
||||||
occurred_at: required_string_column(columns, 10, "occurred_at")?,
|
occurred_at: timestamp_to_display_string(
|
||||||
|
columns
|
||||||
|
.get(10)
|
||||||
|
.ok_or_else(|| "埋点行缺少 occurred_at".to_string())?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "埋点行 occurred_at 不是字符串".to_string())?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +732,53 @@ fn value_to_string(value: &Value) -> Option<String> {
|
|||||||
Value::Object(object) => object.get("some").and_then(value_to_string),
|
Value::Object(object) => object.get("some").and_then(value_to_string),
|
||||||
Value::Number(number) => Some(number.to_string()),
|
Value::Number(number) => Some(number.to_string()),
|
||||||
Value::Bool(value) => Some(value.to_string()),
|
Value::Bool(value) => Some(value.to_string()),
|
||||||
_ => Some(value.to_string()),
|
Value::Array(items) => value_array_to_string(items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_array_to_string(items: &[Value]) -> Option<String> {
|
||||||
|
if items.len() == 2 {
|
||||||
|
if let Some(index) = items.first().and_then(Value::as_u64) {
|
||||||
|
if index == 0 {
|
||||||
|
return items.get(1).and_then(value_to_string);
|
||||||
|
}
|
||||||
|
if index == 1 && items.get(1).and_then(Value::as_array).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Array(items.to_vec()).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracking_scope_kind_to_string(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => Some(text.clone()),
|
||||||
|
Value::Object(object) => object
|
||||||
|
.get("tag")
|
||||||
|
.or_else(|| object.get("variant"))
|
||||||
|
.or_else(|| object.get("name"))
|
||||||
|
.and_then(value_to_string),
|
||||||
|
Value::Array(items) => {
|
||||||
|
let index = items.first().and_then(Value::as_u64)?;
|
||||||
|
Some(
|
||||||
|
match index {
|
||||||
|
0 => "site",
|
||||||
|
1 => "work",
|
||||||
|
2 => "module",
|
||||||
|
3 => "user",
|
||||||
|
_ => return Some(Value::Array(items.to_vec()).to_string()),
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => value_to_string(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_to_display_string(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_string),
|
||||||
|
_ => value_to_string(value),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,6 +1134,7 @@ mod tests {
|
|||||||
assert!(sql.contains("user_id = 'user-1'"));
|
assert!(sql.contains("user_id = 'user-1'"));
|
||||||
assert!(sql.contains("scope_kind = 'user'"));
|
assert!(sql.contains("scope_kind = 'user'"));
|
||||||
assert!(sql.contains("scope_id = 'scope-1'"));
|
assert!(sql.contains("scope_id = 'scope-1'"));
|
||||||
|
assert!(!sql.contains("ORDER BY"));
|
||||||
assert!(sql.ends_with("LIMIT 1000"));
|
assert!(sql.ends_with("LIMIT 1000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,6 +1173,80 @@ mod tests {
|
|||||||
assert_eq!(entries[0].event_title, "每日登录");
|
assert_eq!(entries[0].event_title, "每日登录");
|
||||||
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
|
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
|
||||||
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
|
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
|
||||||
|
assert_eq!(entries[0].module_key.as_deref(), Some("profile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_admin_tracking_events_sql_response_normalizes_sats_values() {
|
||||||
|
let payload = json!([
|
||||||
|
{
|
||||||
|
"rows": [[
|
||||||
|
"event-1",
|
||||||
|
"daily_login",
|
||||||
|
[3, []],
|
||||||
|
"user-1",
|
||||||
|
20580,
|
||||||
|
[0, "user-1"],
|
||||||
|
[1, []],
|
||||||
|
[0, "profile-1"],
|
||||||
|
[0, "profile"],
|
||||||
|
"{}",
|
||||||
|
[1778207451731746i64]
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let entries =
|
||||||
|
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
|
||||||
|
|
||||||
|
assert_eq!(entries[0].scope_kind, "user");
|
||||||
|
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
|
||||||
|
assert_eq!(entries[0].owner_user_id, None);
|
||||||
|
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
|
||||||
|
assert_eq!(entries[0].module_key.as_deref(), Some("profile"));
|
||||||
|
assert_eq!(entries[0].occurred_at, "1778207451731746");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_admin_tracking_events_sql_response_sorts_by_occurred_at_desc() {
|
||||||
|
let payload = json!([
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"event-old",
|
||||||
|
"daily_login",
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
20580,
|
||||||
|
{"some": "user-1"},
|
||||||
|
null,
|
||||||
|
{"some": "profile-1"},
|
||||||
|
"profile",
|
||||||
|
"{}",
|
||||||
|
"2026-05-07T00:00:00Z"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"event-new",
|
||||||
|
"daily_login",
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
20580,
|
||||||
|
{"some": "user-1"},
|
||||||
|
null,
|
||||||
|
{"some": "profile-1"},
|
||||||
|
"profile",
|
||||||
|
"{}",
|
||||||
|
"2026-05-07T01:00:00Z"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let entries =
|
||||||
|
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
|
||||||
|
|
||||||
|
assert_eq!(entries[0].event_id, "event-new");
|
||||||
|
assert_eq!(entries[1].event_id, "event-old");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1418,8 +1418,9 @@ mod tests {
|
|||||||
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
|
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
|
||||||
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
|
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
|
||||||
let payload_hash = "UNSIGNED-PAYLOAD";
|
let payload_hash = "UNSIGNED-PAYLOAD";
|
||||||
let canonical_headers =
|
let canonical_headers = format!(
|
||||||
format!("host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n");
|
"host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n"
|
||||||
|
);
|
||||||
let additional_headers = "host";
|
let additional_headers = "host";
|
||||||
let canonical_request = format!(
|
let canonical_request = format!(
|
||||||
"{}\n{}\n\n{}\n{}\n{}",
|
"{}\n{}\n\n{}\n{}\n{}",
|
||||||
|
|||||||
@@ -1456,9 +1456,19 @@ fn build_aliyun_sms_url(endpoint: &str) -> Result<String, SmsProviderError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn current_aliyun_timestamp() -> String {
|
fn current_aliyun_timestamp() -> String {
|
||||||
OffsetDateTime::now_utc()
|
// 阿里云 OpenAPI ACS3 签名头 x-acs-date 要求使用不带小数秒的 UTC ISO 8601 格式,
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
// 即 yyyy-MM-dd'T'HH:mm:ss'Z'。time crate 的 Rfc3339 会保留纳秒,
|
||||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
// 形如 2026-05-07T14:23:59.364767Z,阿里云网关会判定为时间格式非法。
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||||
|
now.year(),
|
||||||
|
u8::from(now.month()),
|
||||||
|
now.day(),
|
||||||
|
now.hour(),
|
||||||
|
now.minute(),
|
||||||
|
now.second()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonicalize_aliyun_form_params(params: &BTreeMap<String, String>) -> String {
|
fn canonicalize_aliyun_form_params(params: &BTreeMap<String, String>) -> String {
|
||||||
@@ -1480,8 +1490,9 @@ fn build_aliyun_form_body(params: &BTreeMap<String, String>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result<String, SmsProviderError> {
|
fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result<String, SmsProviderError> {
|
||||||
let mut signer = HmacSha256::new_from_slice(key)
|
let mut signer = HmacSha256::new_from_slice(key).map_err(|error| {
|
||||||
.map_err(|error| SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}")))?;
|
SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}"))
|
||||||
|
})?;
|
||||||
signer.update(content);
|
signer.update(content);
|
||||||
Ok(hex_lower(&signer.finalize().into_bytes()))
|
Ok(hex_lower(&signer.finalize().into_bytes()))
|
||||||
}
|
}
|
||||||
@@ -2146,6 +2157,23 @@ mod tests {
|
|||||||
assert!(headers.get("x-acs-content-sha256").is_some());
|
assert!(headers.get("x-acs-content-sha256").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_aliyun_timestamp_uses_acs_iso8601_format_without_fractional_seconds() {
|
||||||
|
let timestamp = current_aliyun_timestamp();
|
||||||
|
|
||||||
|
assert_eq!(timestamp.len(), "2026-05-07T12:34:56Z".len());
|
||||||
|
assert_eq!(timestamp.as_bytes()[4], b'-');
|
||||||
|
assert_eq!(timestamp.as_bytes()[7], b'-');
|
||||||
|
assert_eq!(timestamp.as_bytes()[10], b'T');
|
||||||
|
assert_eq!(timestamp.as_bytes()[13], b':');
|
||||||
|
assert_eq!(timestamp.as_bytes()[16], b':');
|
||||||
|
assert!(timestamp.ends_with('Z'));
|
||||||
|
assert!(!timestamp.contains('.'));
|
||||||
|
assert!(timestamp.chars().enumerate().all(|(index, value)| {
|
||||||
|
matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || value.is_ascii_digit()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aliyun_send_response_deserializes_pascal_case_fields() {
|
fn aliyun_send_response_deserializes_pascal_case_fields() {
|
||||||
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(
|
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(
|
||||||
|
|||||||
Reference in New Issue
Block a user