diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index 68721c8..3af6dfa 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -122,7 +122,7 @@ const SCHEMA_SQL: &str = r#" CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号', - name VARCHAR(50) COMMENT '姓名', + name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '姓名(选填,为空串表示未填写;⛔ 不允许 NULL)', password VARCHAR(255) NOT NULL, role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员', organization_id BIGINT, @@ -902,18 +902,37 @@ struct ColumnSpec { 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 { - 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 ` 的值(无 `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 { match target.find("DEFAULT ") { Some(pos) => target[pos + "DEFAULT ".len()..] .split_whitespace() .next() - .unwrap_or_default(), + .unwrap_or_default() + .trim_matches('\''), None => "", } } @@ -982,6 +1001,12 @@ impl ColumnSpec { /// `DATA_TYPE`/`IS_NULLABLE`/`COLUMN_DEFAULT`/`EXTRA`/`COLUMN_COMMENT`, /// **只有与目标不一致才 ALTER**;一致时一条 DDL 都不发(避免每次启动都重建表)。 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)────────────────────────── // `created_at` 家族: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 { use super::{ ColumnSpec, CABINETS_STATUS_COMMENT_ALIGN, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS, - SCHEMA_SQL, + SCHEMA_SQL, target_data_type, }; /// `SCHEMA_SQL` 中每条建表语句的固定前缀 @@ -1844,4 +1869,43 @@ const EXPECTED_TABLE_COUNT: usize = 22; "建表语句里不允许出现 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, ""); + } } diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index e1ca372..561f989 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -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#"id BIGINT AUTO_INCREMENT PRIMARY KEY,"#); 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#"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"#); diff --git a/software/api-server/src/routes/users.rs b/software/api-server/src/routes/users.rs index c16409b..b8b6a2c 100644 --- a/software/api-server/src/routes/users.rs +++ b/software/api-server/src/routes/users.rs @@ -35,7 +35,9 @@ pub(crate) struct UserListQuery { struct UserRow { id: i64, phone: String, - name: Option, + /// 姓名。⛔ 数据库层已 `NOT NULL DEFAULT ''`,空串 = 未填写 + /// (不再用 NULL 表达"没有" —— 那正是"只改组织把姓名抹没"那类 bug 的温床) + name: String, role: i32, organization_id: Option, status: i32, @@ -515,7 +517,9 @@ pub async fn create_user( let phone = input.phone.trim(); 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); if !(0..=2).contains(&role) { 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?; // 记录操作日志 - 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; tracing::info!(user_id, phone = %phone, role, "创建用户"); @@ -568,6 +573,23 @@ pub async fn create_user( // 编辑用户 // --------------------------------------------------------------------------- +/// 姓名三态解析(纯函数,可单测) +/// +/// - `None`(**未传该字段**)⇒ 保持原值。部分字段更新(如只改组织)必须走这条, +/// 否则会把原有姓名抹掉; +/// - `Some("")` / `Some(" ")`(**显式传空**)⇒ 清空为空串。姓名是选填项, +/// 用户刻意清掉时不能被"保持原值"抵消; +/// - `Some(" 张三 ")` ⇒ 去首尾空白后写入。 +/// +/// ⛔ 返回 `String` 而**不是** `Option`:数据库列已 `NOT NULL DEFAULT ''`, +/// 用空串表达"未填写"。曾经返回 None 直接写库,就是"只改组织把姓名抹没"的成因。 +fn resolve_new_name(input: Option, old_name: String) -> String { + match input { + Some(s) => s.trim().to_string(), + None => old_name, + } +} + /// 按操作者身份与目标角色确定编辑后的组织归属 /// /// 企业管理员(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 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); if !(0..=2).contains(&new_role) { return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into())); @@ -649,8 +678,8 @@ pub async fn update_user( current_user.user_id, user_id, &phone, - old_name.as_deref(), - new_name.as_deref(), + &old_name, + &new_name, old_role, new_role, ) @@ -665,9 +694,9 @@ pub async fn update_user( async fn load_user_for_update( pool: &sqlx::MySqlPool, user_id: i64, -) -> Result<(String, Option, i32, Option), AppError> { +) -> Result<(String, String, i32, Option), AppError> { // 检查用户存在 - let old: Option<(String, Option, i32, Option)> = sqlx::query_as( + let old: Option<(String, String, i32, Option)> = sqlx::query_as( "SELECT phone, name, role, organization_id FROM users WHERE id = ?" ) .bind(user_id) @@ -684,7 +713,7 @@ async fn load_user_for_update( async fn persist_user_update( pool: &sqlx::MySqlPool, user_id: i64, - new_name: &Option, + new_name: &str, new_role: i32, new_org_id: Option, old_role: i32, @@ -730,18 +759,17 @@ async fn log_user_update( actor_id: i64, user_id: i64, phone: &str, - old_name: Option<&str>, - new_name: Option<&str>, + old_name: &str, + new_name: &str, old_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!( "编辑用户: 手机号={}, 姓名: {}→{}, 角色: {}→{}", - phone, - old_name.unwrap_or("-"), - new_name.unwrap_or("-"), - old_role, new_role + phone, old_show, new_show, old_role, new_role ); 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 未被括号包裹"); } + /// 姓名三态:未传保持原值 / 传空串清空 / 传非空去空白写入。 + /// + /// ⛔ 这是修过的真 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 次数"一致 #[test] fn test_user_list_filter_placeholder_count_matches_binds() { diff --git a/software/web/src/api/user.ts b/software/web/src/api/user.ts index 9c995fd..414b04b 100644 --- a/software/web/src/api/user.ts +++ b/software/web/src/api/user.ts @@ -10,7 +10,12 @@ import { api } from '@/api/request'; export interface User { id: number; phone: string; - name: string | null; + /** + * 姓名。⛔ 数据库列已 `NOT NULL DEFAULT ''`,**空串 = 未填写**, + * 不再是 `null`(null 正是"只改组织把姓名抹没"那类 bug 的温床)。 + * 界面展示仍用 `v || '-'` 兼容空串。 + */ + name: string; role: number; role_text: string; organization_id: number | null;