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:
12451 2026-09-21 10:42:22 +08:00
parent f3f841b1f0
commit 8025cd831c
4 changed files with 157 additions and 22 deletions

View File

@ -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 <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 {
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, "");
}
}

View File

@ -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"#);

View File

@ -35,7 +35,9 @@ pub(crate) struct UserListQuery {
struct UserRow {
id: i64,
phone: String,
name: Option<String>,
/// 姓名。⛔ 数据库层已 `NOT NULL DEFAULT ''`,空串 = 未填写
/// (不再用 NULL 表达"没有" —— 那正是"只改组织把姓名抹没"那类 bug 的温床)
name: String,
role: i32,
organization_id: Option<i64>,
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<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)只能编辑本组织用户、不能改自己、不能授予总管理员;
@ -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<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 = ?"
)
.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<String>,
new_name: &str,
new_role: i32,
new_org_id: Option<i64>,
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() {

View File

@ -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;