fix(db+users): users.name 改 NOT NULL DEFAULT '',并修掉迁移非幂等的两个解析 bug
## 1. users.name 不再允许 NULL(用户要求) **数据库层加闸**:`name VARCHAR(50) COMMENT '姓名'` → `name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名(选填,为空串表示未填写;⛔ 不允许 NULL)'` 同步三处(AGENTS.md 硬约束): - `SCHEMA_SQL` 建表语句; - `LEGACY_COLUMN_TARGETS` 加 `("users", "name", "VARCHAR(50) NOT NULL DEFAULT ''")` (老库靠它对齐,`CREATE TABLE IF NOT EXISTS` 不改已存在的表); - Rust `UserRow.name` / `load_user_for_update` / `persist_user_update` / `log_user_update` 全部 `Option<String>` → `String`; 创建用户时 `input.name.unwrap_or_default().trim()`,**不再可能插 NULL**。 前端 `User.name` 类型同步改为 `string`(空串 = 未填写,界面 `v || '-'` 照常)。 ### 实测(库层) ``` users.name: varchar(50) nullable=NO default='' OK 插空串姓名 → name='' OK 插 NULL → 1048 Column 'name' cannot be null OK(被拒) ``` ## 2. 顺带修掉两个**迁移非幂等**的解析 bug(都是这次上线才暴露的) `ensure_column_type` 的定位是"只有与目标不一致才 ALTER",但两个归一化缺失让 `users.name` **每次重启都重发一遍 ALTER**(白搭 DDL,大表上还可能触发重建): | # | 症状 | 根因 | 修法 | |---|---|---|---| | 1 | 日志 `default '' -> ''''` 永远不相等 | `target_default_value` 原样返回带引号的 `''`,而 `information_schema.COLUMN_DEFAULT` 返回**去引用后**的空串 | `.trim_matches('\'')` | | 2 | 日志 `type varchar -> VARCHAR(50)` 永远不相等 | `target_data_type` 返回 `VARCHAR(50)`,而 `information_schema.DATA_TYPE` 只报**裸类型名** `varchar`(长度在 `COLUMN_TYPE`) | 去掉 `(...)` 后缀 | `users.name` 是对齐清单里**第一条带括号长度**的类型,之前的条目都是单词型 (`BIGINT`/`DATETIME`/`TINYINT`),所以这两个 bug 一直没暴露。 ### 幂等已验证 ``` 测试环境:连续重启两次,ALTER users.name 次数 = 0,迁移完成日志 6 条,ERROR 0 生产环境:重启一次, ALTER users.name 次数 = 0,ERROR 0 ``` ## 3. 守门 - `resolve_new_name` 三态单测(未传保持 / 传空清空 / 传非空去空白)—— 已自证能失败; - `target_default_value` 去引号单测 —— 已自证能失败; - `target_data_type` 去括号单测 —— 已自证能失败; - SQL 快照守卫**有意更新**(新增 `users.name` 两条片段)—— 已自证能失败。 ⚠️ 守卫报警是**对的**:我确实改了 SQL,所以显式更新快照,而不是放宽守卫。 ## 4. 数据修复(本次 bug 的实际损害) "表格内分配组织"只传 `organization_id`,命中姓名三态 bug,把生产 **李四 / 王五** 的姓名抹成 NULL。已按用户此前截图还原(`李四`/`王五`), 组织分配结果保留(org=2)。 ## 验收 - `cargo check --all-targets` 两服务:**0 error / 0 warning** - `cargo test --bin pms-api`:**214 passed** - 已发布测试 + 生产;生产重启后 0 ERROR、迁移幂等、域名 401/200
This commit is contained in:
parent
f3f841b1f0
commit
8025cd831c
@ -122,7 +122,7 @@ const SCHEMA_SQL: &str = r#"
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
|
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
|
||||||
name VARCHAR(50) COMMENT '姓名',
|
name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名(选填,为空串表示未填写;⛔ 不允许 NULL)',
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员',
|
role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员',
|
||||||
organization_id BIGINT,
|
organization_id BIGINT,
|
||||||
@ -902,18 +902,37 @@ struct ColumnSpec {
|
|||||||
ddl: String,
|
ddl: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取目标定义原文的第一个词(SQL 类型名),如 `DATETIME` / `BIGINT`
|
/// 取目标定义原文的第一个词(SQL 裸类型名),如 `DATETIME` / `BIGINT` / `VARCHAR`
|
||||||
|
///
|
||||||
|
/// ⚠️ **必须去掉 `(...)` 长度/精度后缀**:`information_schema.COLUMNS.DATA_TYPE`
|
||||||
|
/// 只报裸类型名(`varchar`、`bigint`、`decimal`),长度在 `COLUMN_TYPE` 里
|
||||||
|
/// (`varchar(50)`)。目标定义原文写的是 `VARCHAR(50)`,若不去后缀就永远
|
||||||
|
/// 与 `varchar` 不相等 ⇒ `ensure_column_type` 每次启动都重发 ALTER(非幂等)。
|
||||||
|
/// `users.name VARCHAR(50) NOT NULL DEFAULT ''` 上线后实测复现,才补上这条。
|
||||||
|
///
|
||||||
|
/// 生成的 DDL 不受影响 —— [`ColumnSpec::ddl`] 用的是完整 target 原文(含长度)。
|
||||||
fn target_data_type(target: &str) -> &str {
|
fn target_data_type(target: &str) -> &str {
|
||||||
target.split_whitespace().next().unwrap_or_default()
|
let first = target.split_whitespace().next().unwrap_or_default();
|
||||||
|
match first.find('(') {
|
||||||
|
Some(pos) => &first[..pos],
|
||||||
|
None => first,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取目标定义原文里 `DEFAULT <token>` 的值(无 `DEFAULT` 子句返回空串)
|
/// 取目标定义原文里 `DEFAULT <token>` 的值(无 `DEFAULT` 子句返回空串)
|
||||||
|
///
|
||||||
|
/// ⚠️ **必须去掉 SQL 字符串字面量的引号**:`information_schema.COLUMNS.COLUMN_DEFAULT`
|
||||||
|
/// 返回的是**去引用后的值**(`DEFAULT ''` ⇒ 空串、`DEFAULT '0'` ⇒ `0`),
|
||||||
|
/// 而目标定义原文里是带引号的 `''` / `'0'`。不归一化两者就永远不相等 ⇒
|
||||||
|
/// `ensure_column_type` 认为"不一致",**每次重启都重复发一遍 ALTER**(非幂等,
|
||||||
|
/// 白搭一次 DDL;对 InnoDB 大表还可能触发重建)。
|
||||||
fn target_default_value(target: &str) -> &str {
|
fn target_default_value(target: &str) -> &str {
|
||||||
match target.find("DEFAULT ") {
|
match target.find("DEFAULT ") {
|
||||||
Some(pos) => target[pos + "DEFAULT ".len()..]
|
Some(pos) => target[pos + "DEFAULT ".len()..]
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default()
|
||||||
|
.trim_matches('\''),
|
||||||
None => "",
|
None => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -982,6 +1001,12 @@ impl ColumnSpec {
|
|||||||
/// `DATA_TYPE`/`IS_NULLABLE`/`COLUMN_DEFAULT`/`EXTRA`/`COLUMN_COMMENT`,
|
/// `DATA_TYPE`/`IS_NULLABLE`/`COLUMN_DEFAULT`/`EXTRA`/`COLUMN_COMMENT`,
|
||||||
/// **只有与目标不一致才 ALTER**;一致时一条 DDL 都不发(避免每次启动都重建表)。
|
/// **只有与目标不一致才 ALTER**;一致时一条 DDL 都不发(避免每次启动都重建表)。
|
||||||
const LEGACY_COLUMN_TARGETS: &[(&str, &str, &str)] = &[
|
const LEGACY_COLUMN_TARGETS: &[(&str, &str, &str)] = &[
|
||||||
|
// ── users.name:不允许 NULL ─────────────────────────────────────────────
|
||||||
|
// 为什么单独列:历史上 `name` 是可空列,"只改组织不改姓名"的代码会把
|
||||||
|
// 原有姓名抹成 NULL(input.name 未传 ⇒ None ⇒ 直接覆盖)。已修代码三态逻辑,
|
||||||
|
// 但**数据库层也必须拦住** —— 同类 bug 不该再有机会写进 NULL。
|
||||||
|
// 空串表示"未填写"(前端 `v || '-'` 照常显示占位),不用 NULL 表达"没有"。
|
||||||
|
("users", "name", "VARCHAR(50) NOT NULL DEFAULT ''"),
|
||||||
// ── 时间列:一律 DATETIME(TIMESTAMP → DATETIME)──────────────────────────
|
// ── 时间列:一律 DATETIME(TIMESTAMP → DATETIME)──────────────────────────
|
||||||
// `created_at` 家族:NOT NULL + DEFAULT CURRENT_TIMESTAMP
|
// `created_at` 家族:NOT NULL + DEFAULT CURRENT_TIMESTAMP
|
||||||
("organizations", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
|
("organizations", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
|
||||||
@ -1500,7 +1525,7 @@ async fn grant_permissions_by_codes(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ColumnSpec, CABINETS_STATUS_COMMENT_ALIGN, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS,
|
ColumnSpec, CABINETS_STATUS_COMMENT_ALIGN, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS,
|
||||||
SCHEMA_SQL,
|
SCHEMA_SQL, target_data_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `SCHEMA_SQL` 中每条建表语句的固定前缀
|
/// `SCHEMA_SQL` 中每条建表语句的固定前缀
|
||||||
@ -1844,4 +1869,43 @@ const EXPECTED_TABLE_COUNT: usize = 22;
|
|||||||
"建表语句里不允许出现 kWh(口径是 Wh,写进去的也是 Wh)"
|
"建表语句里不允许出现 kWh(口径是 Wh,写进去的也是 Wh)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DEFAULT ''` / `DEFAULT '0'` 这类**字符串字面量默认值必须去引号**。
|
||||||
|
///
|
||||||
|
/// ⛔ 修过的真 bug:解析器原样返回 `''`(两个引号),而
|
||||||
|
/// `information_schema.COLUMN_DEFAULT` 返回去引用后的空串 ⇒ 两边永远不相等 ⇒
|
||||||
|
/// `ensure_column_type` 每次启动都重发 ALTER(非幂等)。
|
||||||
|
/// `users.name NOT NULL DEFAULT ''` 上线后实测复现,才补上这条。
|
||||||
|
#[test]
|
||||||
|
fn test_target_default_value_unquotes_string_literals() {
|
||||||
|
assert_eq!(target_data_type("VARCHAR(50) NOT NULL DEFAULT ''"), "VARCHAR");
|
||||||
|
assert_eq!(target_data_type("VARCHAR(100) NOT NULL"), "VARCHAR");
|
||||||
|
assert_eq!(target_data_type("DECIMAL(10,7) NULL"), "DECIMAL");
|
||||||
|
// 无括号的单词型不受影响
|
||||||
|
assert_eq!(target_data_type("DATETIME NOT NULL"), "DATETIME");
|
||||||
|
assert_eq!(target_data_type("BIGINT NOT NULL DEFAULT 0"), "BIGINT");
|
||||||
|
assert_eq!(target_data_type("TINYINT DEFAULT 0"), "TINYINT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经 `ColumnSpec` 归一化后,`VARCHAR(50)` 目标的 data_type 必须与
|
||||||
|
/// `information_schema` 报的裸类型名逐字相等(大小写不敏感由比对层处理)。
|
||||||
|
#[test]
|
||||||
|
fn test_column_spec_varchar_type_matches_information_schema() {
|
||||||
|
let spec = ColumnSpec::from_target("users", "name", "VARCHAR(50) NOT NULL DEFAULT ''");
|
||||||
|
assert_eq!(spec.data_type, "VARCHAR");
|
||||||
|
// DDL 仍须含长度(否则真跑 ALTER 时会把列改成无长度)
|
||||||
|
assert!(spec.ddl.contains("VARCHAR(50)"), "DDL 丢了长度: {}", spec.ddl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经 `ColumnSpec` 归一化后,`DEFAULT ''` 的目标值必须与
|
||||||
|
/// `information_schema` 报上来的空串**逐字相等**(否则迁移非幂等)。
|
||||||
|
#[test]
|
||||||
|
fn test_column_spec_empty_string_default_matches_information_schema() {
|
||||||
|
let spec = ColumnSpec::from_target("users", "name", "VARCHAR(50) NOT NULL DEFAULT ''");
|
||||||
|
assert!(!spec.nullable);
|
||||||
|
// 类型归一为裸名(information_schema.DATA_TYPE 不含长度)
|
||||||
|
assert_eq!(spec.data_type, "VARCHAR");
|
||||||
|
// information_schema 对 `DEFAULT ''` 报的 COLUMN_DEFAULT 就是空串
|
||||||
|
assert_eq!(spec.default_value, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -800,6 +800,20 @@ const FINALIZE_EXPIRED_ORDER_SQL: &str = "#);
|
|||||||
assert_contains(r#"db/migrate.rs"#, src, r#"CREATE TABLE IF NOT EXISTS organizations ("#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"CREATE TABLE IF NOT EXISTS organizations ("#);
|
||||||
assert_contains(r#"db/migrate.rs"#, src, r#"id BIGINT AUTO_INCREMENT PRIMARY KEY,"#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"id BIGINT AUTO_INCREMENT PRIMARY KEY,"#);
|
||||||
assert_contains(r#"db/migrate.rs"#, src, r#"name VARCHAR(100) NOT NULL COMMENT '组织名称',"#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"name VARCHAR(100) NOT NULL COMMENT '组织名称',"#);
|
||||||
|
// users.name:2026-09-21 由「可空、无默认」改为 NOT NULL DEFAULT ''。
|
||||||
|
// 起因:"只改组织不传姓名"的代码把原有姓名抹成 NULL(真 bug,已修三态逻辑),
|
||||||
|
// 这里在**数据库层**加第二道闸:NULL 直接被 1048 拒绝。
|
||||||
|
// ⚠️ 这是**有意的** schema 变更,故显式更新快照(不是放宽守卫)。
|
||||||
|
assert_contains(
|
||||||
|
r#"db/migrate.rs"#,
|
||||||
|
src,
|
||||||
|
r#"name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名(选填,为空串表示未填写;⛔ 不允许 NULL)',"#,
|
||||||
|
);
|
||||||
|
assert_contains(
|
||||||
|
r#"db/migrate.rs"#,
|
||||||
|
src,
|
||||||
|
r#"("users", "name", "VARCHAR(50) NOT NULL DEFAULT ''"),"#,
|
||||||
|
);
|
||||||
assert_contains(r#"db/migrate.rs"#, src, r#"parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)',"#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)',"#);
|
||||||
assert_contains(r#"db/migrate.rs"#, src, r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用'"#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"ALTER TABLE cabinets MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '启用/禁用配置态(不表示在线):1启用 2禁用(拦截指令链路) 0=建表默认值产生的取值,语义等同启用'"#);
|
||||||
assert_contains(r#"db/migrate.rs"#, src, r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#);
|
assert_contains(r#"db/migrate.rs"#, src, r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#);
|
||||||
|
|||||||
@ -35,7 +35,9 @@ pub(crate) struct UserListQuery {
|
|||||||
struct UserRow {
|
struct UserRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
phone: String,
|
phone: String,
|
||||||
name: Option<String>,
|
/// 姓名。⛔ 数据库层已 `NOT NULL DEFAULT ''`,空串 = 未填写
|
||||||
|
/// (不再用 NULL 表达"没有" —— 那正是"只改组织把姓名抹没"那类 bug 的温床)
|
||||||
|
name: String,
|
||||||
role: i32,
|
role: i32,
|
||||||
organization_id: Option<i64>,
|
organization_id: Option<i64>,
|
||||||
status: i32,
|
status: i32,
|
||||||
@ -515,7 +517,9 @@ pub async fn create_user(
|
|||||||
let phone = input.phone.trim();
|
let phone = input.phone.trim();
|
||||||
validate_phone(phone)?;
|
validate_phone(phone)?;
|
||||||
|
|
||||||
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
// 姓名:选填。⛔ 未填写时落**空串**而不是 NULL —— 数据库列已 NOT NULL,
|
||||||
|
// 传 NULL 会直接 1048 报错;且空串 + 前端 `v || '-'` 就能表达"未填写"。
|
||||||
|
let name = input.name.unwrap_or_default().trim().to_string();
|
||||||
let role = input.role.unwrap_or(0);
|
let role = input.role.unwrap_or(0);
|
||||||
if !(0..=2).contains(&role) {
|
if !(0..=2).contains(&role) {
|
||||||
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
||||||
@ -552,7 +556,8 @@ pub async fn create_user(
|
|||||||
sync_user_role_mapping(pool, user_id, role).await?;
|
sync_user_role_mapping(pool, user_id, role).await?;
|
||||||
|
|
||||||
// 记录操作日志
|
// 记录操作日志
|
||||||
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-"));
|
let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role,
|
||||||
|
if name.is_empty() { "-" } else { &name });
|
||||||
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
|
log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
tracing::info!(user_id, phone = %phone, role, "创建用户");
|
tracing::info!(user_id, phone = %phone, role, "创建用户");
|
||||||
@ -568,6 +573,23 @@ pub async fn create_user(
|
|||||||
// 编辑用户
|
// 编辑用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 姓名三态解析(纯函数,可单测)
|
||||||
|
///
|
||||||
|
/// - `None`(**未传该字段**)⇒ 保持原值。部分字段更新(如只改组织)必须走这条,
|
||||||
|
/// 否则会把原有姓名抹掉;
|
||||||
|
/// - `Some("")` / `Some(" ")`(**显式传空**)⇒ 清空为空串。姓名是选填项,
|
||||||
|
/// 用户刻意清掉时不能被"保持原值"抵消;
|
||||||
|
/// - `Some(" 张三 ")` ⇒ 去首尾空白后写入。
|
||||||
|
///
|
||||||
|
/// ⛔ 返回 `String` 而**不是** `Option<String>`:数据库列已 `NOT NULL DEFAULT ''`,
|
||||||
|
/// 用空串表达"未填写"。曾经返回 None 直接写库,就是"只改组织把姓名抹没"的成因。
|
||||||
|
fn resolve_new_name(input: Option<String>, old_name: String) -> String {
|
||||||
|
match input {
|
||||||
|
Some(s) => s.trim().to_string(),
|
||||||
|
None => old_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 按操作者身份与目标角色确定编辑后的组织归属
|
/// 按操作者身份与目标角色确定编辑后的组织归属
|
||||||
///
|
///
|
||||||
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
|
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
|
||||||
@ -625,7 +647,14 @@ pub async fn update_user(
|
|||||||
|
|
||||||
let (phone, old_name, old_role, old_org_id) = load_user_for_update(pool, user_id).await?;
|
let (phone, old_name, old_role, old_org_id) = load_user_for_update(pool, user_id).await?;
|
||||||
|
|
||||||
let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
// 姓名三态,⛔ 不可混:
|
||||||
|
// 未传(None) ⇒ 保持原值(部分字段更新,如只改组织)
|
||||||
|
// 传了空/纯空白 ⇒ 显式清空(姓名是选填,用户就是要清掉)
|
||||||
|
// 传了非空 ⇒ 去除首尾空白后写入
|
||||||
|
// 曾经的写法 `input.name.filter(...)` 把「未传」也变成 None,
|
||||||
|
// 于是"只改组织"会把原有姓名一起抹成 NULL(编辑弹窗总带 name 所以没暴露,
|
||||||
|
// 表格内联分配组织只传 organization_id 立马踩中)。
|
||||||
|
let new_name = resolve_new_name(input.name, old_name.clone());
|
||||||
let new_role = input.role.unwrap_or(old_role);
|
let new_role = input.role.unwrap_or(old_role);
|
||||||
if !(0..=2).contains(&new_role) {
|
if !(0..=2).contains(&new_role) {
|
||||||
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
||||||
@ -649,8 +678,8 @@ pub async fn update_user(
|
|||||||
current_user.user_id,
|
current_user.user_id,
|
||||||
user_id,
|
user_id,
|
||||||
&phone,
|
&phone,
|
||||||
old_name.as_deref(),
|
&old_name,
|
||||||
new_name.as_deref(),
|
&new_name,
|
||||||
old_role,
|
old_role,
|
||||||
new_role,
|
new_role,
|
||||||
)
|
)
|
||||||
@ -665,9 +694,9 @@ pub async fn update_user(
|
|||||||
async fn load_user_for_update(
|
async fn load_user_for_update(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> Result<(String, Option<String>, i32, Option<i64>), AppError> {
|
) -> Result<(String, String, i32, Option<i64>), AppError> {
|
||||||
// 检查用户存在
|
// 检查用户存在
|
||||||
let old: Option<(String, Option<String>, i32, Option<i64>)> = sqlx::query_as(
|
let old: Option<(String, String, i32, Option<i64>)> = sqlx::query_as(
|
||||||
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
|
"SELECT phone, name, role, organization_id FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@ -684,7 +713,7 @@ async fn load_user_for_update(
|
|||||||
async fn persist_user_update(
|
async fn persist_user_update(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
new_name: &Option<String>,
|
new_name: &str,
|
||||||
new_role: i32,
|
new_role: i32,
|
||||||
new_org_id: Option<i64>,
|
new_org_id: Option<i64>,
|
||||||
old_role: i32,
|
old_role: i32,
|
||||||
@ -730,18 +759,17 @@ async fn log_user_update(
|
|||||||
actor_id: i64,
|
actor_id: i64,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
phone: &str,
|
phone: &str,
|
||||||
old_name: Option<&str>,
|
old_name: &str,
|
||||||
new_name: Option<&str>,
|
new_name: &str,
|
||||||
old_role: i32,
|
old_role: i32,
|
||||||
new_role: i32,
|
new_role: i32,
|
||||||
) {
|
) {
|
||||||
// 记录操作日志
|
// 记录操作日志(姓名为空串时显示占位,不显示误导内容)
|
||||||
|
let old_show = if old_name.is_empty() { "-" } else { old_name };
|
||||||
|
let new_show = if new_name.is_empty() { "-" } else { new_name };
|
||||||
let detail = format!(
|
let detail = format!(
|
||||||
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",
|
"编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}",
|
||||||
phone,
|
phone, old_show, new_show, old_role, new_role
|
||||||
old_name.unwrap_or("-"),
|
|
||||||
new_name.unwrap_or("-"),
|
|
||||||
old_role, new_role
|
|
||||||
);
|
);
|
||||||
log_operation(pool, actor_id, "edit", "user", Some(user_id), &detail).await;
|
log_operation(pool, actor_id, "edit", "user", Some(user_id), &detail).await;
|
||||||
|
|
||||||
@ -932,6 +960,30 @@ mod tests {
|
|||||||
assert!(!f.clause.contains("WHERE phone LIKE"), "OR 未被括号包裹");
|
assert!(!f.clause.contains("WHERE phone LIKE"), "OR 未被括号包裹");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 姓名三态:未传保持原值 / 传空串清空 / 传非空去空白写入。
|
||||||
|
///
|
||||||
|
/// ⛔ 这是修过的真 bug:曾经用 `input.name.filter(|s| !s.trim().is_empty())`,
|
||||||
|
/// 把「未传」也变成 None,导致"只改组织"会把原有姓名抹成 NULL。
|
||||||
|
/// 现在数据库列已 `NOT NULL DEFAULT ''`,本函数也改返回 `String`,
|
||||||
|
/// 双层保险:代码层不再产出 NULL,库层也拒绝 NULL。
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_new_name_three_states() {
|
||||||
|
let old = "张三".to_string();
|
||||||
|
|
||||||
|
// 未传 ⇒ 保持原值
|
||||||
|
assert_eq!(resolve_new_name(None, old.clone()), old);
|
||||||
|
// 未传且原本就没姓名(空串)⇒ 仍为空串
|
||||||
|
assert_eq!(resolve_new_name(None, String::new()), "");
|
||||||
|
// 传空串 / 纯空白 ⇒ 清空为空串
|
||||||
|
assert_eq!(resolve_new_name(Some("".to_string()), old.clone()), "");
|
||||||
|
assert_eq!(resolve_new_name(Some(" ".to_string()), old.clone()), "");
|
||||||
|
// 传非空 ⇒ 去首尾空白
|
||||||
|
assert_eq!(
|
||||||
|
resolve_new_name(Some(" 李四 ".to_string()), old.clone()),
|
||||||
|
"李四".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 占位符个数必须与"会执行的 bind 次数"一致
|
/// 占位符个数必须与"会执行的 bind 次数"一致
|
||||||
#[test]
|
#[test]
|
||||||
fn test_user_list_filter_placeholder_count_matches_binds() {
|
fn test_user_list_filter_placeholder_count_matches_binds() {
|
||||||
|
|||||||
@ -10,7 +10,12 @@ import { api } from '@/api/request';
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string | null;
|
/**
|
||||||
|
* 姓名。⛔ 数据库列已 `NOT NULL DEFAULT ''`,**空串 = 未填写**,
|
||||||
|
* 不再是 `null`(null 正是"只改组织把姓名抹没"那类 bug 的温床)。
|
||||||
|
* 界面展示仍用 `v || '-'` 兼容空串。
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
role: number;
|
role: number;
|
||||||
role_text: string;
|
role_text: string;
|
||||||
organization_id: number | null;
|
organization_id: number | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user