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
153 changes: 148 additions & 5 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ pub async fn wallet(
/// 值为字符串以宽容空值/非法输入(解析失败按未筛处理)。
#[derive(Debug, Default, Deserialize)]
pub struct TxColFilters {
/// 模型名 LIKE(%v%)
/// 模型名 LIKE(%v%;无模型的行按显示占位符 `—` 匹配,见 `tx_where`)
pub model: Option<String>,
/// 用户名 LIKE(JOIN users u)
pub user_name: Option<String>,
/// Key 名 LIKE(JOIN api_keys ak;历史行兜底 key_label 表达式)
/// Key 名 LIKE(JOIN api_keys ak;历史行兜底 key_label 表达式,无 key 行按占位符 `—`)
pub key_name: Option<String>,
/// 状态精确匹配(库内中文值:成功/入账/处理中)
pub status: Option<String>,
Expand Down Expand Up @@ -210,7 +210,15 @@ fn tx_where(
.map(|s| format!("%{s}%"))
};
if let Some(s) = like(&f.model) {
conds.push(format!("{} LIKE ?{}", col("model"), binds.len() + 1));
// C2113:**筛选口径必须等于显示口径**。前端「模型」列无值的行(gift/expire 等)显示占位符
// `—`(`ui/js/app.js` `txsToView`:`t.model || "—"`),故筛选表达式也只能搜同一个文案
// (`NULLIF` 把库内空串(`model TEXT NOT NULL DEFAULT ''`)归零,与 JS `||` 同义)。
// 此前该列无值行显示的本地化类型名(「赠送」)在服务端不存在对应文案 —— 按它筛选永远 0 行。
conds.push(format!(
"COALESCE(NULLIF({}, ''), '—') LIKE ?{}",
col("model"),
binds.len() + 1
));
binds.push(rusqlite::types::Value::Text(s));
}
if let Some(s) = like(&f.user_name) {
Expand All @@ -224,10 +232,15 @@ fn tx_where(
// 且登录自动建的分发 key 正是空名(dao::get_or_create_api_key 写 '')——
// 而 `COALESCE` 只对 NULL 回退 ⇒ 表格显示兜底文案、按该文案筛选却 0 行。
// 先 `NULLIF(...,'')` 归零再 COALESCE,与前端 `t.key_name || t.key_label`(JS `||` 视空串为缺失)同口径。
// C2113:尾部的 `NULLIF(…, '')` + `'—'` 是 Key 列显示的**最后两层兜底** ——
// 前端单元格是 `t.key_name || t.key_label || "—"`:`key_label` 本身可能为空串
// (key 存在但 note/plan/provider 全空),无 key 的历史行(如 gift/expire)则连
// `key_label` 都是 NULL ⇒ 单元格显示 `—`。JS `||` 逐层把空串当缺失,SQL 侧必须逐层
// `NULLIF` 才能对齐,否则显示 `—` 而按 `—` 筛选 0 行。
conds.push(format!(
"COALESCE(NULLIF(ak.name, ''), CASE WHEN k.note <> '' THEN k.note \
"COALESCE(NULLIF(ak.name, ''), NULLIF(CASE WHEN k.note <> '' THEN k.note \
WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \
ELSE k.provider END) LIKE ?{}",
ELSE k.provider END, ''), '—') LIKE ?{}",
binds.len() + 1
));
binds.push(rusqlite::types::Value::Text(s));
Expand Down Expand Up @@ -1425,6 +1438,136 @@ mod tests {
assert_eq!(v["total"], 0, "改名后旧兜底文案不再命中: {body}");
}

/// C2113:模型列 / Key 列 —— **单元格里出现的每一段文字都必须能被该列筛选命中**。
///
/// 两列的筛选都走服务端(`tx_where` 的 `model` / `key_name` LIKE),而前端的无值兜底曾是
/// **本地化类型名**(`ui/js/app.js` `txsToView` 旧版 `t.model || txType(t.type)`)—— 服务端没有
/// 语言包,那个文案永远匹配不到 ⇒ 用户按屏幕上刚看到的文案筛选得 0 行;客户端 `filterRows`
/// 与「类型」列本就有正确的控件 ⇒ 丢的不是功能,是**可筛选的假象**。
/// 规格:无值行显示语言中性的占位符 `—`(与 `user` 列同款),服务端搜同一个文案。
///
/// 夹具与「显示文案」助手被下面三个测试共用 —— 拆成三个是为了让 A/B 能读出互不遮蔽的红集合。
fn c2113_rows(st: &AppState) {
let conn = st.db.lock().unwrap();
// 与 gift.rs 的真实写入一致:当日赠送行既无模型也无 key(gift.rs `model` 传 '')
conn.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \
VALUES (1, '', NULL, '', 0, 2.0, 'gift', '成功')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \
VALUES (1, '2', 1, 'deepseek-v4-flash', 100, 10.0, 'consume', '成功')",
[],
)
.unwrap();
}

/// 前端「模型」列显示的文案(`ui/js/app.js` `txsToView`:`t.model || "—"`)。
/// 由**规则**派生,不照抄任何一处输出。
fn displayed_model(row: &serde_json::Value) -> String {
let m = row["model"].as_str().unwrap_or("");
if m.is_empty() {
"—".into()
} else {
m.into()
}
}

/// 前端「Key」列显示的文案(`txsToView`:`t.key_name || t.key_label || "—"`;JS `||` 视空串为缺失)。
fn displayed_key(row: &serde_json::Value) -> String {
for k in ["key_name", "key_label"] {
let v = row[k].as_str().unwrap_or("");
if !v.is_empty() {
return v.into();
}
}
"—".into()
}

async fn tx_row_by_type(st: &AppState, key: &str, ty: &str) -> serde_json::Value {
let (s, body) = get(st.clone(), "/api/transactions", key).await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
serde_json::from_str::<serde_json::Value>(&body).unwrap()["items"]
.as_array()
.unwrap()
.iter()
.find(|r| r["type"].as_str() == Some(ty))
.unwrap_or_else(|| panic!("no {ty} row in {body}"))
.clone()
}

#[tokio::test]
async fn tx_model_and_key_filters_match_the_displayed_placeholder() {
let st = test_state("txfall");
let key = login(st.clone()).await;
c2113_rows(&st);
let gift = tx_row_by_type(&st, &key, "gift").await;
assert_eq!(gift["model"].as_str(), Some(""), "前提:赠送行库内无模型");
assert!(gift["key_label"].is_null(), "前提:赠送行无 key");
// 1) 显示的文案 = 占位符
assert_eq!(displayed_model(&gift), "—", "模型列无值行显示占位符");
assert_eq!(displayed_key(&gift), "—", "Key 列无值行显示占位符");
// 2) 缺陷断言:按**表格里显示的文案**筛选必须命中该行(修前模型列为 0 行)
for (col, shown) in [
("model", displayed_model(&gift)),
("key_name", displayed_key(&gift)),
] {
let (s, body) = get(
st.clone(),
&format!("/api/transactions?{col}={}", pct(&shown)),
&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["total"], 1, "按 {col} 列显示的文案筛选命中该行: {body}");
assert_eq!(
v["items"][0]["type"].as_str(),
Some("gift"),
"命中正是那一行: {body}"
);
}
}

#[tokio::test]
async fn tx_model_filter_still_matches_real_model_names() {
// 阳性对照:修复没弄坏正常路径 —— 有模型名的行照旧按模型名命中。
let st = test_state("txfallpos");
let key = login(st.clone()).await;
c2113_rows(&st);
let (s, body) = get(
st.clone(),
"/api/transactions?model=deepseek-v4-flash",
&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["total"], 1, "按真实模型名命中消费行: {body}");
assert_eq!(v["items"][0]["type"].as_str(), Some("consume"));
}

#[tokio::test]
async fn tx_model_filter_does_not_take_localized_type_labels() {
// 阴性对照:本地化类型名(zh 包 `tx.type.gift` = 「赠送」)**不属于该列口径** ⇒ 0 行。
// 这条是给「反向修法」准备的:服务端没有语言包,若有人把类型名硬编码进 SQL 去匹配,
// 它就亮了 —— 正确解只有一处:让单元格显示服务端搜得到的文案(`—`)。
let st = test_state("txfallneg");
let key = login(st.clone()).await;
c2113_rows(&st);
let (s, body) = get(
st.clone(),
&format!("/api/transactions?model={}", pct("赠送")),
&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["total"], 0, "类型名不再是模型列的可筛值: {body}");
}

#[tokio::test]
async fn pts_range_filter_matches_the_rendered_signed_value() {
// C2054:交易页「点数」列**渲染**的是有符号值(income 正 / expense 负,`signedPts()`),
Expand Down
8 changes: 8 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,11 @@ ui/
- **删掉的第二份状态**:`txTab`。此前顶部 tab 另存一份 `txTab`,而请求按 `filters.type || txTab` 取值 ⇒ 列筛选一旦出值,tab 的写入就被永久盖住:点 tab 只是挪高亮、列表不变(**死控件**),高亮却仍按 `txTab` 画(**说谎的指示器**)。jsdom 实测(改前 5/11):列筛选选「赠送」后,请求 `type=gift` 而「全部」仍高亮;点「消费」后请求仍是 `gift`、`select` 仍显示 `gift` 而高亮已跳到「消费」——三者各说各话。
- **高亮口径**:生效值不属于 all/consume/earn 时(topup / withdraw / gift / expire)**没有任何 tab 自称生效**(都不高亮),因为把「全部」点亮而列表只有赠送行同样是说谎。改版后不变量:**请求参数 / tab 高亮 / 列筛选 select 三者恒为同一份状态的投影**。
- **冒烟测试注意**:点 tab 后状态已变,重拉由 `renderTransactions` 的筛选签名比对触发 —— **不要再显式 `loadTransactions()`**(会与它并发两次请求,后到者可能把先到者的行覆盖回去)。断言请比对「最近一次 `/api/transactions` 请求的 `type`」与「高亮的 tab」与「`select` 的 value」,三者用**同一个派生**(`wantTabs(type)`),不要写死字面量 —— 字面量会在应用的高亮恰好拼对时误绿。

## 交易表「模型」/「Key」列:单元格文案 = 筛选口径(C2113)

- **两列都是服务端筛选**:`txFilterParams()` 把「模型」列的文字发成 `model`、「Key」列发成 `key_name`,服务端 `tx_where` 用 LIKE 匹配库内值(`transactions.model` / `api_keys.name` → `key_label` 表达式)。**客户端 `filterRows` 只用于导出 CSV**(过滤当前页),表格本身的行由服务端全量过滤 + 分页。
- **不变量**:**单元格里出现的每一段文字,都必须能被该列筛选命中**。因此无值行只能用**语言中性的占位符 `—`**(`user` 列与 Key 列消费分支早已如此),**不能**填本地化类型名 —— 服务端没有语言包,`txType(t.type)`(「赠送」「过期」)在 SQL 里永远匹配不到,按它筛选得 0 行(C2113 修前即此:文案看似可筛选,实际是**说谎的漏斗**)。
- **两侧逐字对应**:`txsToView` 的 `t.model || "—"` / `t.key_name || t.key_label || "—"` ↔ `tx_where` 的 `COALESCE(NULLIF(…, ''), '—')`(Key 列是 `COALESCE(NULLIF(ak.name,''), NULLIF(<key_label 表达式>,''), '—')`,逐层 `NULLIF` 才能对齐 JS `||` 把空串当缺失的语义)。
- **改文案就要同时改两侧**:这是「显示口径 = 筛选口径」类的第 4 处(前 3 处:点数有符号值 C2054、时间列 C2111、Key 列空名兜底 C2101)。`transactions_model_and_key_filters_match_the_displayed_placeholder`(`src/routes/wallet.rs`)钉住服务端半边;阴性对照断言「类型名不再是模型列的可筛值」,防止有人反向把中文标签硬编码进 SQL。
- **冒烟测试注意**:前端半边(单元格文本)用 jsdom 启真 `index.html` + 四脚本、stub `fetch` 喂各类行(consume / gift / topup 哨兵)后**读渲染文本**;服务端半边由 Rust 测试覆盖。两侧的期望值都要**从同一条规则推出**(「库内值,空则 `—`」),不要照抄另一侧的输出 —— 照抄会让两边一起错。
2 changes: 1 addition & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,6 @@ <h3 id="chat-title">使用模型</h3>
<script src="js/api.js?v=20260912-5"></script>
<script src="js/data.js?v=20260912-5"></script>
<script src="js/i18n.js?v=20260914-4"></script>
<script src="js/app.js?v=20260914-7"></script>
<script src="js/app.js?v=20260914-8"></script>
</body>
</html>
17 changes: 12 additions & 5 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3209,11 +3209,18 @@
const exact = typeof v === "number" && v > 0 ? fmtTokensExact(v) : "0";
return ' title="' + esc(label + ": " + exact) + '"';
};
// 模型列:consume/earn 显示模型名;无模型(topup/gift 等)显示交易类型说明
// Key 列(rant 2026-08-22T17:21:39 需求 2):优先分发 key 的 name(api_keys.name);
// 历史行无 api_key_id → 兜底 key_label(note/provider/plan),再兜底交易类型说明
const model = t.model || txType(t.type);
const key = t.key_name || t.key_label || (t.type === "consume" || t.type === "earn" ? "—" : txType(t.type));
// 模型列 / Key 列(rant 2026-08-22T17:21:39 需求 2):优先库内值 ——
// 模型列取 `t.model`;Key 列优先分发 key 的 name(api_keys.name),历史行无 api_key_id
// → 兜底 key_label(note/provider/plan)。
// C2113:**无值时只能用服务端同样搜得到的占位符**。两列的筛选都走后端(`tx_where` 的
// `model` / `key_name` LIKE),故单元格里出现的每一段文字都必须是该列筛选能命中的值;
// 此前无值行显示的是**本地化类型名**(`txType(t.type)`,如「赠送」)—— 服务端拿不到语言包,
// 那个文案永远匹配不到 ⇒ 按屏幕上刚看到的文案筛选 0 行(客户端与「类型」列都有正确的控件,
// 所以这不是丢功能,是**说谎的漏斗**:文案看着可筛选、实际 0 行)。占位符 `—` 与
// `user` 列(`t.user_name || "—"`)及 Key 列消费分支同款:**无值、且语言中性**,
// 服务端筛选表达式尾部的 `'—'` 与这里逐字对应(见 `src/routes/wallet.rs` `tx_where`)。
const model = t.model || "—";
const key = t.key_name || t.key_label || "—";
return {
id: t.id,
time: (t.time || "").replace("T", " ").slice(0, 16),
Expand Down
Loading