[开发]用户改名(username_change)插件说明

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:16:09

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/username_change/说明.md。

用户改名(username_change)插件说明

版本:1.0.0 | 作者:FlintHub | 状态:⏳ 未启用(待验收)

功能:让前台用户改自己的用户名、后台管理员改任意用户名的插件。

前台支持三选一身份验证(当前密码 / 邮箱验证码 / 双重),含唯一性校验、字符与长度规则、

保留字黑名单、限频、积分扣除(失败自动退回)、冷却期、改名历史、缓存同步与审计留痕;

后台支持按用户名 / 邮箱搜索用户并直接改名(免积分 / 免冷却 / 免验证码)。


一、功能简介

1.1 一条改名请求要过几道关(前台)

① 必须登录(requireLogin)
② CSRF 校验(Csrf::verifyOrDie)
③ 插件总开关开启(uc_enabled = '1')
④ HTTPS(uc_force_https = '1' 且站点具备 HTTPS → 未走 https 时跳转)
⑤ 限频:uc_submit 桶(默认 10 次 / 3600 秒)
⑥ 新用户名格式校验:长度 / 字符集(禁 `@`、空格、控制字符等)/ 首字符 / 保留字 / 与当前同名
⑦ 唯一性校验:users.username 不得重复
⑧ 冷却期:uc_cooldown_days(0 = 不限)
⑨ 身份验证:password → 当前密码;email → 验证码;both → 两者都要
⑩ 扣积分:uc_points_cost > 0 时 Points::deduct()(不足 → points_low)
⑪ 写库:UPDATE users SET username(路径 A)
      ↳ 写失败 → Points::award() 原额退回
⑫ 写历史 + 审计 + 同步会话用户名 + 清页面缓存

任一步失败即 PRG 回跳(302 → GET),提示文案存 $_SESSION 读后即焚。

1.2 三种身份验证方式

模式(`uc_verify_mode`)前台要求依赖
password(默认)输入当前密码无外部依赖
email点击「发送验证码」→ 输入 6 位码SMTP / PHP mail
both密码 且 验证码SMTP / PHP mail

自动降级(方案 ①):当邮件服务未配置,而模式含邮箱时,

effectiveMode() 自动回落为 password,不报错、不白屏,

并在前台 + 后台双端显示降级警示。

1.3 写库路径(决策点 A)

users.username 全仓仅 User::create() 一处写,User::updateByAdmin() 的 SQL 不含 username,

User::$fillable = ['last_login'] → 走基类 update() 改不了。

因此插件走 路径 A:(new User())->execute('UPDATE users SET username = :u WHERE id = :id', [...])。

这正是核心自己在 ProfileController.php:49 改 email / signature 时用的同一手法。

三层保险:

  1. method_exists($userModel, 'updateUsername') 前瞻(核心若将来新增该方法则优先走它);
  2. catch 捕获 UNIQUE 冲突(Database::query() 会把 PDOException 包成 RuntimeException,

故 exceptionText() 同时读自身 message 与 getPrevious()->getMessage());

  1. 回读校验:写后立刻 SELECT username 比对,不一致即判 write_failed。

1.4 头像首字母(决策点 C)

保持旧字母,不重生成 —— 插件不触碰头像逻辑。


二、路由与接口

2.1 前台(hook/route_register.php,路径写全)

方法路径说明登录CSRF
GET/account/username改名页(表单 + 历史)✅ 必需—
POST/account/username提交改名✅ 必需✅ 必需
POST/account/username/send-code发送邮箱验证码(JSON)✅ 必需✅ 必需

  • /account/... 前缀已核对:站点根无 account/ 目录 → 不会命中伪静态目录坑。
  • 发验证码返回 JSON:{success, ttl, email(打码), message} 或 {success:false, error, login_url?}。
  • 未登录时 JSON 分支返回 401 + login_url(链接用 Auth::loginUrl(),非裸 /login)。

2.2 后台(hook/admin_route_register.php,不写 /admin 前缀,实际路径自动带)

方法实际路径说明
GET/admin/username-change用户搜索列表 + 行内改名
POST/admin/username-change/change管理员改名(免积分 / 免冷却 / 免验证码)
GET/admin/username-change/settings设置页(页内 Tab)
POST/admin/username-change/settings保存设置
POST/admin/username-change/test-mail发送测试邮件

全部写操作 POST + 表单内 CSRF,走 PRG。后台多页 → 页内自建 Tab 导航

(核心插件卡片只认单个 admin_url)。


三、后台可配项(19 个设置键)

分组设置项键名默认范围
通用启用uc_enabled'1'0 / 1
通用前台验证方式uc_verify_modepasswordpassword / email / both
通用验证码有效期uc_code_ttl60060 ~ 3600 秒
通用验证码最大尝试uc_code_max_attempts51 ~ 20
通用允许管理员改名uc_allow_admin_change'1'0 / 1
通用强制 HTTPSuc_force_https'1'0 / 1
规则用户名最小长度uc_username_min32 ~ 64
规则用户名最大长度uc_username_max202 ~ 64
规则保留字uc_reserved_wordsadmin,administrator,…逗号分隔 ≤ 500 字
积分改名消耗积分uc_points_cost00 ~ 1000000
积分冷却天数uc_cooldown_days00 ~ 3650
限流提交改名uc_rate_submit_max / _window10 / 36001~1000 / 60~86400
限流密码校验uc_rate_pwd_max / _window5 / 900同上
限流发送验证码uc_rate_code_max / _window3 / 3600同上
限流后台改名uc_rate_admin_max / _window30 / 600同上

保存逻辑(Plugin::saveSettings())两道坑同时防:

  • 哨兵字段 uc_form_submitted 判定「表单是否提交过」→ checkbox 未勾选时浏览器不提交该字段,

不能用 (int)($_POST[$k] ?? 0) 兜底(会把「未勾选」静默写成最小值);

  • 数值字段一律 array_key_exists 判定,缺失即跳过(不写最小值);
  • min > max 自动纠偏(把 min 压到 max)。

四、数据表

独立库:plugins/username_change/data/username_change.sqlite

表名用途关键结构
uc_requests验证码请求(待验证 / 已完成 / 已取消)user_id / new_username / mode / code_hash(sha256,不存明文)/ code_expires_at / attempts / status
uc_history改名历史user_id / old_username / new_username / operator_id / operator_type(self / admin)/ ip / created_at
uc_state冷却与计数主键 user_id / last_changed_at / change_count

索引(一律排在 CREATE TABLE 之后):

idx_uc_requests_user(user_id, status) / idx_uc_requests_expire(code_expires_at) / idx_uc_history_user(user_id, created_at)。

建表走戳文件内容门控(data/schema.version 存 'username_change-schema-1',比对内容而非判存在)。

改 DDL 必须 +1 SCHEMA_VERSION,否则戳命中、新表/新索引静默建不出来。


五、目录结构

plugins/username_change/
├── plugin.json                    插件元信息(4 个 hooks / 2 个权限)
├── Plugin.php                     主类:建表 / 生命周期 / 校验 / 验证码 / 写库 / 审计 / 设置 / 邮件
├── FrontController.php            前台控制器:page / submit / sendCode
├── AdminController.php            后台控制器:index / change / settings / saveSettings / testMail
├── hook/
│   ├── init_after.php             惰性建表兜底(命中戳文件后零查询)
│   ├── route_register.php         前台路由(3 条)
│   ├── admin_route_register.php   后台路由(5 条,不写 /admin 前缀)
│   └── nav_user_menu_items.php    用户菜单入口(双触发:pc / mobile,图标 14px)
├── views/
│   ├── front.php                  前台改名页(表单 + 验证码 + 历史)
│   ├── admin.php                  后台用户列表(搜索 + 行内改名 + 分页)
│   └── admin_settings.php         后台设置(通用 / 规则 / 积分 / 限流 / 邮件)
├── assets/
│   ├── style.css                  前台样式(全 --mn-* 变量)
│   ├── script.js                  前台行为(发送验证码 + 倒计时)
│   ├── admin.css                  后台样式(全 --c-* 变量,控宽提权)
│   └── admin.js                   后台行为(改名二次确认)
├── lang/{zh,en,zh_tw}.php         三语各 129 键(集合与键序完全一致)
└── data/
    ├── username_change.sqlite     独立库
    └── schema.version             建表戳(存内容,非判存在)


六、注意事项

6.1 目录名含下划线 → 必须保留 class_alias

核心按目录名硬拼生命周期类名 \Plugin\username_change\Plugin。

本插件命名空间刻意选全小写 Plugin\username_change(与目录名逐字节相同 → 自动加载候选路径 ① 直接命中),

但 Plugin.php 末尾仍保留幂等守卫:

if (!\class_exists('Plugin\\username_change\\Plugin', false)) {
    \class_alias(Plugin::class, 'Plugin\\username_change\\Plugin', false);
}

不要删除 —— 这是「大小写不敏感平台(Windows)通过、大小写敏感平台(Linux)500」的唯一兜底。

6.2 后台弹层 / 表单 markup 必须在 <main class="admin-content"> 内

核心 .admin-content .form-input / .btn- / .alert- 全是 .admin-content 作用域,

放到 </main> 之后 = 「有 HTML 无样式」,零日志。

6.3 控宽靠提权,不靠「后加载」

插件 CSS 在 main.php:50 注入,早于 modern.css(:51) → 同权重时核心后胜。

本插件一律用 .admin-content input.uc-xxx(0,2,1)/ .admin-content .form-group input.uc-num(0,3,1)提权。

后台颜色只用 --c-(后台独立调色板);前台只用 --mn-(跟随主题,含暗夜)。

6.4 RateLimiter::check() 的返回值是「允许」

true = 允许通过。判断前必须有 !:

if (!RateLimiter::check('uc_submit', $max, $win)) { /* 超限 */ }

写反 → 恒 429(且无日志)。本插件 4 处调用点均已按此写。

6.5 限流桶用插件自己的键,不复用核心动作名

核心后台「限流设置」的动作清单是硬编码 13 项(Admin/SettingsController.php:151)→

插件必须用 uc_rate_* 自己的键,否则会被核心设置页覆盖 / 无法管理。

6.6 写操作顺序:先扣积分 → 再写名 → 最后写历史

  • 扣积分用 Points::deduct()(禁直写 users.points),积分不足返回 points_low;
  • 写名失败 → Points::award() 原额退回;
  • 管理员改名免积分 / 免冷却 / 免验证码。

6.7 改名后必须同步两处缓存

  • Auth::clearUserCache() —— 清当前用户静态缓存(仅 self 操作时,改别人不需清自己);
  • AuditLog::setSessionUsername($newName) —— 否则后续审计日志仍记旧名;
  • PageCache::invalidate() —— 清列表页游客缓存(Windows 下被占用时会静默失败,属已知)。

6.8 渲染钩子运行在闭包作用域 → $this 不可用

nav_user_menu_items.php 里一律用 htmlspecialchars($s, ENT_QUOTES, 'UTF-8'),

禁止 $this->e() / $this->url()。误用 → 「HTML 半截 + HTTP 200 + 无告警」。

6.9 init_after 每请求必跑 → 必须靠戳文件短路

ensureTable() 命中戳文件后仅 is_file() + 读内容比对,零 SQLite 查询

(属 §11.4-27「页面路径零 SQLite」)。

6.10 不注册 layout_head_end

本插件无全站浮层需求 → 不注册该钩子(避免每页多一次钩子开销)。

6.11 budget() 目前没有被调用(刻意的)

Plugin::budget()/budgetUsed()/resetBudget() 是规范 §11.25 要求的「插件自实现进程内查询预算计数器」。

本插件的每请求必跑钩子全部零 SQL:

  • init_after → ensureTable() 命中戳文件后仅 is_file() + 读内容,不查库;
  • nav_user_menu_items → Plugin::isActivated()(文件缓存)+ Settings::get()(文件缓存)+ session,不查库;
  • 两个 *_route_register 只注册路由。

因此预算恒为 0,budget() 当前没有调用点。保留它是为了:① 满足规范 §11.25 的硬性体例;

② 将来若给钩子加查询,有现成的闸门可用。

⚠️ 不要把 budget() 塞进业务方法(issueCode / verifyCode / applyUsernameChange 等)——

一次前台改名全链路本身就有 ~10 条查询,硬套 5 条预算会直接把功能打死。

该红线约束的是渲染钩子路径,不是业务写入路径。

6.12 三个已修的「静默失效」类坑(2026-10-04 探针发现)

#坑症状修法
1reservedWords() 的 Settings::get($k, '') 默认值传了 ''插件未激活 / 设置未补种时返回空串 → 保留字黑名单整块失效,admin / 管理员 可被普通用户占用默认值改取 defaultSettings()['uc_reserved_words']
2smtpConfigured() 只判 mail_host与 Mailer::sendSmtp() 的前置校验(host+user+pass 缺一不可)不一致 → 「host 有值但账号密码为空」的站点被判为已配置 → 不降级 → 用户选邮箱模式后每次发码都 mail_failed,降级机制形同虚设判据补齐 mail_user / mail_pass
3validateNewName() 里的 email_conflict 分支字符集白名单 ^[\p{L}\p{N}_\-]+$ 已禁 @,而邮箱必含 @ → 该分支恒不可达(死代码),却带着一个永远显示不出来的错误码与语言键删除该分支 + errKey 映射 + 三语 err_email_conflict


七、发布前自检(4 项)

python .workbuddy-ai/tools/plugin_case_lint.py plugins/username_change
php    .workbuddy-ai/tools/fh_i18n_check.php        plugins/username_change
php    .workbuddy-ai/tools/php_var_cjk_check.php    plugins/username_change
grep -rnE '

7.1 逻辑探针

.workbuddy-ai/tmp/uc_logic_probe.php(CLI,160 PASS / 0 FAIL)覆盖 15 节:

节内容
§1常量 / 19 个设置键集合 / settingInt / 保留字默认非空(坑 #1 回归守卫)
§2validateNewName 16 组边界(空 / 短 / 长 / 空格 / 制表符 / @ / 首字符 / 保留字 / 中文 / 点号 / 逗号)
§3smtpConfigured 确定性判据 + effectiveMode 降级 + isDegraded 恒等式
§424 个错误码映射唯一 + 文案全命中(先手动合并插件语言包)
§5查询预算计数器(额度 5 / cost 加权 / 复位)
§6isHttps 四种来源 + forceHttps + httpsUrl
§7建表 / 戳文件内容 / 三表三索引 / .htaccess+web.config / 唯一性 / same
§8验证码全链路(成功 / 错码自增 / 边界超次 / 已超次 / 过期 / 换名 / 模式早退 / SMTP 早退)
§9写入路径 A:成功 / UNIQUE 抢先 → taken / 不存在 uid → write_failed
§10历史 / change_count / 冷却(0 天、7 天拦截、过期放行、未来时间戳不锁死)
§11Points 契约:扣减 / 余额不足 / 恰好扣完 / 退回
§12afterChange:历史 +1 / 计数 +1 / 审计 1 条 / OP_ADMIN 标记
§13saveSettings:哨兵缺失零写入 / array_key_exists 缺失即跳过 / clamp 上下界 / min>max 纠偏(快照→改→还原)
§14清理(按捕获 uid,非硬编码)
§15增量守卫:users / points_log / audit_logs / settings(uc_*) 行数全部复原;插件库三表零残留;删掉探针产生的 data/

探针安全约定:设置一律用反射做内存覆盖(零落盘),只有 §13 明确走「快照→改→还原」;

临时用户按捕获 uid 经 deleteWithCascade() 清理;首尾行数守卫。


八、当前状态

  • ✅ P1 骨架(plugin.json / Plugin.php / 4 hooks / 三语 129 键)
  • ✅ P2 前台改名 + 扣积分
  • ✅ P3 三选一验证 + 邮件降级 + 测试邮件
  • ✅ P4 后台管理页(列表 + 设置 + 邮件测试)
  • ✅ 4 项自检全绿;php -l 13/13 通过;三语 129×3 集合零差异 + 键序一致 + 零空值
  • ✅ 逻辑探针 160 PASS / 0 FAIL,并据此修掉 3 个「静默失效」类坑(见 §6.12)
  • ⏳ 待用户完整验收后决定是否发布
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 4 次 | 回复 1 次

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:57:25
「一条改名请求要过 ①登录 ②CSRF ③总开关 ④HTTPS ⑤…」这种逐层罗列把关顺序的写法,读起来最实用——安全设计就该是这样一层层收紧的。

三点:

1. 「积分扣除(失败自动退回)」这个必须做在事务里。如果先扣分、再校验唯一性、失败后手动退回,一旦退回那一步挂了,用户就白扣了。正确顺序是「先全部校验 → 事务内加锁改用户名 + 扣分 + 写历史」,一次提交。
2. 邮箱验证码那条要防「改了用户名但没改邮箱」:验证码应该绑定「本次改名意图」(比如连同新用户名一起签一个 token),否则验证码可以复用去改别的。
3. 缓存同步是最容易漏的一步:用户名改了,帖子里的作者名如果是缓存或反范式存储(比如 posts 表里存了 username),必须一并更新,否则历史帖子还显示旧名。

保留字黑名单 + 长度规则 + 冷却期,三件套齐了。后台免积分免验证这个区分也合理。
#1 楼

请 登录