Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,23 @@ const EN_END: &str = "\n };";
/// 阳性对照真值:**改动语言包/前端文案时应刻意更新这些数字**。
/// 它们的作用是把「提取器静默失真」与「语言包真的变了」区分开(见 `packs()`)。
///
/// 本次更新(运营概览「服务版本 / 运行时长」两张卡):775 → 783 = **+8 键**
/// (`ops.stats.version{,.sub}` / `ops.stats.uptime{,.sub}` / `ops.uptime.{days,hours,minutes,seconds}`),
/// 520 → 531 / 418 → 426 = **+11 个 `T()` 字面量调用点 / +8 个去重键**
/// (两张卡各 1 个 + `fmtUptime` 的 8 处:name/sub 各 1,四个单位里 `days` 1 处、
/// `hours`/`minutes`/`seconds` 各 2 处 ⇒ 11 处调用只有 8 个不同键)。
/// `STATIC_ATTR_*` 不变:本次未改 `ui/index.html` 的静态属性。
/// 本次更新(共享页「本月新增」卡):783 → 786 = **+3 键**
/// (`share.stats.newthis{,.sub,.none}`),531 → 535 / 426 → 429 = **+4 个 `T()` 字面量调用点 / +3 个去重键**
/// (卡片 1 个 + 副标题三元 3 个:单个模型取模型名不上键、多个 / 空各 1 个 `T()`,共 4 处调用 3 个新键)。
/// `STATIC_ATTR_*` 不变:本次未改 `ui/index.html` 的静态属性(仅 cache-bust 串)。
///
/// 上一轮(运营概览「服务版本 / 运行时长」两张卡):775 → 783 键,520 → 531 / 418 → 426。
///
/// ⚠️ 加数时**别口算**:C2013 我先把增量估成 +6(漏了 `fmtUptime` 里的重复调用),
/// 是这条阳性对照把 531 报了出来。改动后请以提取器的实际输出校准,再核对是否与 diff 相符。
const ZH_KEY_COUNT: usize = 783;
const EN_KEY_COUNT: usize = 783;
/// 本轮实测的校准路径就是这条规则:783/531/426 三处**依次**报出 786 → 535 → 429,
/// 每一步都是先让门禁报出真值、再照抄,而不是先写一个自己算的数。
const ZH_KEY_COUNT: usize = 786;
const EN_KEY_COUNT: usize = 786;
const STATIC_ATTR_COUNT: usize = 330;
const STATIC_ATTR_DISTINCT: usize = 305;
const T_LITERAL_COUNT: usize = 531;
const T_LITERAL_DISTINCT: usize = 426;
const T_LITERAL_COUNT: usize = 535;
const T_LITERAL_DISTINCT: usize = 429;

/// 切出语言包区段(起点标记 → 终点标记,含起点)。
fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str {
Expand Down
79 changes: 77 additions & 2 deletions src/routes/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ pub async fn create(
}

/// 单条共享(含收益汇总);key 先解密再脱敏展示
///
/// ⚠️ 本函数**按下标读列**(`r.get(N)`),因此列顺序由调用方的 `SELECT` 决定:
/// 两个调用点(`list` / `patch`)的列表**必须逐字一致**。只改一处会让所有字段静默错位
/// (不报错、类型也往往恰好兼容),所以新增列一律**追加在末尾**,并同步改两处。
fn sharing_row(
conn: &rusqlite::Connection,
crypto: &crate::crypto::Crypto,
Expand All @@ -136,6 +140,7 @@ fn sharing_row(
let start: String = r.get(9)?;
let end: String = r.get(10)?;
let note: String = r.get(11)?;
let created_at: String = r.get(12)?;
// 解密 → 脱敏(sk-****xxxx);解密失败展示 ****
let masked = crypto
.decrypt(&encrypted_key)
Expand Down Expand Up @@ -165,6 +170,8 @@ fn sharing_row(
"available_start": start,
"available_end": end,
"note": note,
// 上架时间(UTC 带 Z,与 api_keys 的 created_at 同口径);前端据此算「本月新增」与表格上架时间列
"created_at": crate::dao::utc_iso(&created_at),
}))
}

Expand All @@ -178,7 +185,7 @@ pub async fn list(
let mut stmt = conn
.prepare(
"SELECT id, provider, plan, model, status, encrypted_key, quota, used, \
available_days, available_start, available_end, note \
available_days, available_start, available_end, note, created_at \
FROM keys WHERE owner_id = ?1 ORDER BY id DESC",
)
.map_err(internal)?;
Expand Down Expand Up @@ -222,7 +229,7 @@ pub async fn patch(
let row = conn
.query_row(
"SELECT id, provider, plan, model, status, encrypted_key, quota, used, \
available_days, available_start, available_end, note \
available_days, available_start, available_end, note, created_at \
FROM keys WHERE id = ?1",
[id],
|r| sharing_row(&conn, &crypto, r),
Expand Down Expand Up @@ -432,6 +439,74 @@ mod tests {
assert_eq!(s, axum::http::StatusCode::NOT_FOUND);
}

/// 上架时间字段(C2015,rant 驱动:共享页「本月新增」卡的数据源)
///
/// 锁三件事:
/// ① `created_at` 随**两个** SELECT 一起到位(`list` 与 `patch` 走的是两份列清单,
/// 而 `sharing_row` 按下标读列 —— 只改一处会让既有字段**静默错位**,故两处都断言);
/// ② 既有字段未因新增列而错位(下标读取的回归护栏);
/// ③ 序列化口径是 **UTC**(带 `Z`)。前端据此算「本月」,用本地时间解析会在月末跨月:
/// 本用例把上架时间按到 `2026-08-31 17:30:00Z` —— UTC 月是 8 月,
/// 而本地(UTC+8)已是 9 月 1 日 01:30 ⇒ 两种口径**可区分**,不是自证。
#[tokio::test]
async fn created_at_is_exposed_and_utc_serialized() {
let st = test_state("created_at");
let key = login(st.clone()).await;
let (_, body) = send(
st.clone(),
"POST",
"/api/sharings",
Some(r#"{"provider":"deepseek","model":"deepseek-v4-flash","key":"sk-created1234","used":0,"note":"utc"}"#),
&key,
)
.await;
let id: i64 = serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();

// 回拨到「UTC 月末、本地已跨月」的时刻
{
let conn = st.db.lock().unwrap();
conn.execute(
"UPDATE keys SET created_at = '2026-08-31 17:30:00' WHERE id = ?1",
[id],
)
.unwrap();
}

let (s, body) = send(st.clone(), "GET", "/api/sharings", None, &key).await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let arr: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
let row = arr.iter().find(|r| r["id"] == id).expect("列表应含该行");

assert_eq!(
row["created_at"], "2026-08-31T17:30:00Z",
"上架时间应为 UTC ISO(带 Z,且时刻未被平移到本地时区)"
);
assert_eq!(&row["created_at"].as_str().unwrap()[..7], "2026-08");
// 既有字段未错位
assert_eq!(row["note"], "utc");
assert_eq!(row["provider"], "deepseek");
assert_eq!(row["status"], "on");
assert_eq!(row["key"], "sk-****1234");
assert_eq!(row["used"], 0.0);

// PATCH 的单条响应走**另一份** SELECT —— 两处不一致时这里会错位
let (s, body) = send(
st.clone(),
"PATCH",
&format!("/api/sharings/{id}"),
Some(r#"{"status":"paused"}"#),
&key,
)
.await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["created_at"], "2026-08-31T17:30:00Z", "PATCH 响应同样带");
assert_eq!(v["note"], "utc");
assert_eq!(v["status"], "paused");
}

#[tokio::test]
async fn sharing_requires_bearer() {
let st = test_state("nobearer");
Expand Down
14 changes: 13 additions & 1 deletion ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ ui/
## 时间显示约定(v1.17,rant 2026-08-17T16:57:17 B 相对时间)

- **相对时间**:`timeAgo(s)` 支持 `MM-DD HH:mm`(默认今年)与 `YYYY-MM-DD[ HH:mm]`,输出 `刚刚 / N 分钟前 / N 小时前 / 昨天 / MM-DD`,非标准格式原样返回;`timeCell(s)` 输出带 `title`(完整绝对时间)的 `.timeago` 单元格,hover 显示;
- **统一使用**:交易列表(时间列)、加额申请列表、共享列表(上架时间列)、API Key 最近使用时间;数据新增 / 生成时间用 `nowTime()`(`MM-DD HH:mm`)写入即可自动相对化。
- **统一使用**:交易列表(时间列)、加额申请列表、API Key 最近使用时间;数据新增 / 生成时间用 `nowTime()`(`MM-DD HH:mm`)写入即可自动相对化。
⚠️ **不含共享列表**:本行原写「共享列表(上架时间列)」,但该列**从未存在**(原型 `docs/prototype/aitokenpool-console.html:574` 与实现 `ui/index.html:338` 的 thead 均无时间列,`timeCell` 也只有交易/API Key 两个调用点)。后端 `GET /api/sharings` 自 C2015 起才返回 `created_at`,前端把它收进视图行的 `time` 字段(备用),但**未加列**——若要加列,需同时补 `share.col.time` 键与 8 列表头的 `colspan` 对齐。

## 表格数字列约定(v1.17,rant 2026-08-17T16:57:17 C 数字列对齐)

Expand Down Expand Up @@ -297,6 +298,17 @@ ui/
- 删除 = 彻底下架(key 从平台移除,不可恢复);暂停 = 临时不接单,可恢复
- 登录态数据全部来自后端 API(`ui/js/api.js`);`data.js` 内嵌数据仅限游客市场浏览与上架表单兜底(见 v1.22 约定),无后端依赖的纯静态浏览不再成立

## 共享页「本月新增」卡约定(v1.24,C2015)

- 共享管理页统计区 `#share-stats` 由 3 张卡扩为 **4 张**(对齐原型 `docs/prototype/aitokenpool-console.html:1197`):在架 Key / 累计收益 / 已用量 / **本月新增**;
- **数据源**:`GET /api/sharings` 自本次起返回每行 `created_at`(UTC ISO,带 `Z`;走既有 `dao::utc_iso`,与 `api_keys.created_at` 同口径);
⚠️ `sharing_row()` **按下标读列**,两个调用点(`list` / `patch`)的 SELECT 列清单必须逐字一致 ⇒ 新增列一律**追加在末尾并同改两处**,否则字段静默错位;
- **月份口径 = UTC,且只在一处计算**:视图行上的 `month` 由 `utcMonth(created_at)` 取 `YYYY-MM`,与后端 SQL 的 `strftime('%Y-%m', …)` 同源(ops/wallet/admin/org 全站聚合口径一致);当前月键取 `new Date().toISOString().slice(0,7)`。
**不得**改用 `getMonth()`(本地月份)——那会让每月 1 日 00:00–08:00(UTC+8)的本页数字与同页其它统计不一致;
- **副标题不复刻原型**:原型写死 `deepseek-v4-flash`,而该卡聚合的可能是多个模型(原型自己的表格就渲染多行)⇒ 单个模型时显示该模型名,多个时显示 `share.stats.newthis.sub`(多个模型),为空时显示 `share.stats.newthis.none`;
- 拿不到 `created_at` 的行**不计入**(宁可少报不误报);`Live.sharings` 未就绪时统计区整体清空(沿用零 mock 降级);
- 新键(zh/en 各 3 个):`share.stats.newthis{,.sub,.none}`。

## 登录态零 mock 约定(v1.22,rant 2026-08-19T15:54:06 系统性清理)

- **铁律**:登录态(`loggedIn()`)任何视图**不得渲染 `data.js` 的 `D.*` mock 数据**;mock 仅限游客模式(`GUEST_VIEWS = ["marketplace"]`,且市场模型列表登录态用 `GET /api/models` 真实数据);
Expand Down
10 changes: 5 additions & 5 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITokenPool</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234ecdc4'/%3E%3Cstop offset='1' stop-color='%232a9d8f'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Ctext x='32' y='43' font-family='-apple-system,Segoe UI,Arial,sans-serif' font-size='30' font-weight='800' text-anchor='middle' fill='%23062021'%3EAT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="css/style.css?v=20260911-16">
<link rel="stylesheet" href="css/style.css?v=20260912-1">
</head>
<body>

Expand Down Expand Up @@ -842,9 +842,9 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js?v=20260911-16"></script>
<script src="js/data.js?v=20260911-16"></script>
<script src="js/i18n.js?v=20260911-16"></script>
<script src="js/app.js?v=20260911-16"></script>
<script src="js/api.js?v=20260912-1"></script>
<script src="js/data.js?v=20260912-1"></script>
<script src="js/i18n.js?v=20260912-1"></script>
<script src="js/app.js?v=20260912-1"></script>
</body>
</html>
22 changes: 20 additions & 2 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,11 +898,20 @@
const on = list.filter((s) => s.status === "on");
const totalEarned = list.reduce((a, s) => a + s.earned, 0);
const totalUsed = list.reduce((a, s) => a + s.used, 0);
// 本月新增(原型 4 张卡的第 4 张):按上架时间(UTC)落在本月计数。
// 月键与后端 SQL 同源取 created_at;拿不到 created_at 的行**不计入**(宁可少报,不误报)。
const nowMonth = utcMonth(new Date().toISOString());
const thisMonth = list.filter((s) => s.month && s.month === nowMonth);

$("#share-stats").innerHTML = [
stat(T("share.stats.listings"), T("cnt.keys", { n: on.length }), T("cnt.hist", { n: list.length })),
stat(T("share.stats.earnings"), "+" + D.fmt(totalEarned) + " " + T("common.points"), T("share.stats.earnings.sub")),
stat(T("share.stats.used"), D.fmt(totalUsed) + " " + T("common.points"), T("cnt.quota", { n: D.fmt(list.reduce((a, s) => a + s.quota, 0)) })),
// 副标题**不复刻原型**:原型写死模型名 `deepseek-v4-flash`,而这张卡聚合的可能是多个模型
// (原型自己的表格就渲染多行)⇒ 多个时只报计数,单个时才带模型名(见下)
stat(T("share.stats.newthis"), T("cnt.keys", { n: thisMonth.length }),
thisMonth.length === 1 ? thisMonth[0].model
: (thisMonth.length ? T("share.stats.newthis.sub") : T("share.stats.newthis.none"))),
].join("");

// 表单下拉(厂商 → Plan → 模型 三级联动;Plan 中「API」= 按量计价的 key)
Expand Down Expand Up @@ -3002,7 +3011,15 @@
});
}

// 后端 sharings → 视图行(字段对齐 mock:earned=earn、price 用 autoPrice、time 占位)
// 后端 sharings → 视图行(字段对齐 mock:earned=earn、price 用 autoPrice、time=上架时间)
// 「本月新增」的**月份口径**在这里算,且只算一次(见 utcMonth):
// 服务端返回的 created_at 是 UTC ISO,全站聚合口径也都是 UTC(ops/wallet/admin/org),
// 所以卡片不能改用本地月份——那会在每月 1 日 00:00–08:00(UTC+8)与同页其它数字不一致。
function utcMonth(iso) {
if (!iso || iso.length < 7) return "";
return iso.slice(0, 7);
}

function sharingsToView(list) {
return list.map((s) => {
let days = [];
Expand All @@ -3018,7 +3035,8 @@
price: autoPrice(s.model),
earned: s.earn || 0,
status: s.status,
time: "",
time: s.created_at || "",
month: utcMonth(s.created_at),
note: s.note || "",
available: days.length ? { days, start: s.available_start || "", end: s.available_end || "" } : null,
};
Expand Down
6 changes: 6 additions & 0 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@
"share.stats.earnings.sub": "全部 key",
"share.stats.used": "已用量",
"share.stats.used.sub": "{n} 点总额度",
"share.stats.newthis": "本月新增",
"share.stats.newthis.sub": "多个模型",
"share.stats.newthis.none": "本月还没有上架",
"share.col.provider": "厂商 · Plan / 模型",
"share.col.key": "Key",
"share.col.used": "已用/额度",
Expand Down Expand Up @@ -1084,6 +1087,9 @@
"share.stats.earnings.sub": "all keys",
"share.stats.used": "Used",
"share.stats.used.sub": "{n} pts total quota",
"share.stats.newthis": "New this month",
"share.stats.newthis.sub": "several models",
"share.stats.newthis.none": "nothing listed this month",
"share.col.provider": "Provider · Plan / Model",
"share.col.key": "Key",
"share.col.used": "Used/Quota",
Expand Down
Loading