本文由 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 时用的同一手法。
三层保险:
method_exists($userModel, 'updateUsername')前瞻(核心若将来新增该方法则优先走它);catch捕获 UNIQUE 冲突(Database::query()会把PDOException包成RuntimeException,
故 exceptionText() 同时读自身 message 与 getPrevious()->getMessage());
- 回读校验:写后立刻
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_mode | password | password / email / both |
| 通用 | 验证码有效期 | uc_code_ttl | 600 | 60 ~ 3600 秒 |
| 通用 | 验证码最大尝试 | uc_code_max_attempts | 5 | 1 ~ 20 |
| 通用 | 允许管理员改名 | uc_allow_admin_change | '1' | 0 / 1 |
| 通用 | 强制 HTTPS | uc_force_https | '1' | 0 / 1 |
| 规则 | 用户名最小长度 | uc_username_min | 3 | 2 ~ 64 |
| 规则 | 用户名最大长度 | uc_username_max | 20 | 2 ~ 64 |
| 规则 | 保留字 | uc_reserved_words | admin,administrator,… | 逗号分隔 ≤ 500 字 |
| 积分 | 改名消耗积分 | uc_points_cost | 0 | 0 ~ 1000000 |
| 积分 | 冷却天数 | uc_cooldown_days | 0 | 0 ~ 3650 |
| 限流 | 提交改名 | uc_rate_submit_max / _window | 10 / 3600 | 1~1000 / 60~86400 |
| 限流 | 密码校验 | uc_rate_pwd_max / _window | 5 / 900 | 同上 |
| 限流 | 发送验证码 | uc_rate_code_max / _window | 3 / 3600 | 同上 |
| 限流 | 后台改名 | uc_rate_admin_max / _window | 30 / 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 探针发现)
| # | 坑 | 症状 | 修法 |
|---|---|---|---|
| 1 | reservedWords() 的 Settings::get($k, '') 默认值传了 '' | 插件未激活 / 设置未补种时返回空串 → 保留字黑名单整块失效,admin / 管理员 可被普通用户占用 | 默认值改取 defaultSettings()['uc_reserved_words'] |
| 2 | smtpConfigured() 只判 mail_host | 与 Mailer::sendSmtp() 的前置校验(host+user+pass 缺一不可)不一致 → 「host 有值但账号密码为空」的站点被判为已配置 → 不降级 → 用户选邮箱模式后每次发码都 mail_failed,降级机制形同虚设 | 判据补齐 mail_user / mail_pass |
| 3 | validateNewName() 里的 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 回归守卫) |
| §2 | validateNewName 16 组边界(空 / 短 / 长 / 空格 / 制表符 / @ / 首字符 / 保留字 / 中文 / 点号 / 逗号) |
| §3 | smtpConfigured 确定性判据 + effectiveMode 降级 + isDegraded 恒等式 |
| §4 | 24 个错误码映射唯一 + 文案全命中(先手动合并插件语言包) |
| §5 | 查询预算计数器(额度 5 / cost 加权 / 复位) |
| §6 | isHttps 四种来源 + forceHttps + httpsUrl |
| §7 | 建表 / 戳文件内容 / 三表三索引 / .htaccess+web.config / 唯一性 / same |
| §8 | 验证码全链路(成功 / 错码自增 / 边界超次 / 已超次 / 过期 / 换名 / 模式早退 / SMTP 早退) |
| §9 | 写入路径 A:成功 / UNIQUE 抢先 → taken / 不存在 uid → write_failed |
| §10 | 历史 / change_count / 冷却(0 天、7 天拦截、过期放行、未来时间戳不锁死) |
| §11 | Points 契约:扣减 / 余额不足 / 恰好扣完 / 退回 |
| §12 | afterChange:历史 +1 / 计数 +1 / 审计 1 条 / OP_ADMIN 标记 |
| §13 | saveSettings:哨兵缺失零写入 / 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 -l13/13 通过;三语 129×3 集合零差异 + 键序一致 + 零空值 - ✅ 逻辑探针 160 PASS / 0 FAIL,并据此修掉 3 个「静默失效」类坑(见 §6.12)
- ⏳ 待用户完整验收后决定是否发布