本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/用户改名插件开发方案_2026-10-04.md。
「用户改名」插件 开发规划方案
依据:
docs/插件开发规范.md(2099 行 / 2026-10-03 版,以下简称「规范 §x」)日期:2026-10-04 | 状态:规划待评审(尚未动一行代码)
插件目录:
plugins/username_change/| 命名空间:Plugin\username_change| 显示名:用户改名核心版本:V1.1.5(
version.php单一版本源)
0. 速览(一页)
| 项 | 取值 | 依据 |
|---|---|---|
| 目录名 | username_change | 规范 §11.34「命名空间首段逐字节 = 目录名」 |
| 命名空间 | Plugin\username_change | 同目录名(① ② 两条候选路径都命中,最稳) |
| 目录名含下划线 | 是 → 必须 class_alias | 规范 §4.1(否则 activate/deactivate/uninstall 静默跳过) |
plugin.json 分类 | identity | app/Controllers/Admin/PluginController.php:22-23 白名单内 |
plugin.json 图标 | user-pen | 已实测在 TemplateCompiler::$map 内(grep -cE "^ 'user-pen' =>" = 1) |
| 权限声明 | ["route:admin","system:settings"] | 规范 §3:注册后台路由 / 写全局设置 |
| 独立库 | plugins/username_change/data/username_change.sqlite | 规范 §10.1 |
| 核心表改动 | 零 ALTER;仅 UPDATE users SET username | 用户需求明确;§1.2 有核心先例 |
| 三张插件表 | uc_requests / uc_history / uc_state | 本方案 §4.2 |
| 前台路由 | GET/POST /account/username、POST /account/username/send-code | 已避让站点根真实目录(§11.34b) |
| 后台路由 | /username-change(实为 /admin/username-change) | §11.22:不写 /admin 前缀 |
| 验证模式 | 站点级三选一:password / email / both(默认 password) | 需求 1 |
| 建议分 5 个阶段落地 | 见 §14 | — |
⚠️ 三个必须站长先拍板的决策点(详见 §4.5 / §5.4 / §15):
- 写
users.username走哪条路 —— 核心没有User::updateUsername(),三条候选路径,本方案推荐「经核心 Model 的execute()」(核心自己在ProfileController.php:49就是这么写 users 表的)。 - SMTP 未配置时怎么降级 —— 硬阻断 / 自动降级到仅密码 / 只警示?需求里给了两个方向(「提示站长」或「降级到仅密码」),需二选一。
- 改名后头像首字母 —— 保持旧首字母(零风险)还是重生成 SVG(
vn_postname有先例)。
1. 需求复核:先把「前提」校准
这一节不是抬杠。需求里有两条描述与核心实际情况不符,若照原文设计,会写出「处理一个根本不存在的字段」和「对齐一套根本不存在的规则」的代码。
1.1 ⚠️ 两处需求与核心事实不符(设计前必须先改口径)
① mention_user_id 在核心不存在
需求原文:「楼中楼的 @ 存的是 mention_user_id,改名后 @ 显示名也自动跟(正文里的 @张三 文本不变,可接受)。」
核查结果:全仓(app/、plugins/、cli/)grep mention_user_id → 零命中。核心的 @ 机制真实情况是:
- 楼层正文里的
@张三是纯文本(sanitizeHtml()白名单里没有 @ 标记),入库即文本,改名后不变; - 所有「显示名」都是渲染时按
user_id现取,不是快照列:
| 文件:行 | 代码 |
|---|---|
app/Models/Post.php:529 | 'username' => $users[$uid]['username'] |
app/Models/Thread.php:1072 | 'username' => $users[$uid]['username'] |
app/Models/Category.php:182 | 'username' => $users[$uid]['username'] |
结论(对方案的影响):
- 需求里「改一处全站生效」成立 —— 但机制是「用户名根本不进帖子表,渲染时按
user_id现取」,不是靠mention_user_id; - 因此方案不需要、也不应该去处理
mention_user_id; - 也没有「@ 显示名自动跟」这个额外工作项 —— 它本来就自动跟(因为压根没存快照)。
② 核心注册没有「用户名字符 / 长度规则」
需求原文:「新用户名:长度、字符与核心注册规则一致。」
核查结果:核心注册的用户名校验极弱,只有两条:
| 位置 | 代码 | 实际行为 |
|---|---|---|
app/Controllers/AuthController.php:215 | $username = trim($_POST['username'] ?? '') | 只 trim |
app/Controllers/AuthController.php:220 | if (strlen($username) > 64) { $username = substr($username, 0, 64); } | 超 64 静默截断,不报错 |
app/Views/auth/register.php:21 | <input ... required maxlength="64"> | 仅前端 |
app/Models/User.php:489 create() | 只 password_hash + INSERT | 无格式/字符集/保留字校验,也不查重(重复靠 users.username UNIQUE 抛异常) |
结论(对方案的影响):
- 核心没有可「保持一致」的规则 → 改名插件必须自定义一套规则(见 §8),并把它定位为「比核心注册更严的收敛层」;
- 这套规则要作为插件设置项暴露给站长(
uc_username_min/uc_username_max/uc_username_pattern/uc_reserved_words),默认值本方案给建议值,请站长确认; - ⚠️ 隐含风险:核心注册可以造出「不合插件规则」的存量用户名(如 1 个字符、含空格)。规则只作用于改名时的新名,不回溯存量。
1.2 核心能力盘点(本次已逐条核查,带证据)
| 能力 | 核心是否提供 | 证据(文件:行) | 插件对策 |
|---|---|---|---|
| 改名能力 | ❌ 无 | users.username 全仓只有 2 处写:User::create():496(INSERT)、User::updateByAdmin():518(SQL 里不含 username) | 插件自建写入路径(§4.5) |
| Model 白名单 | ⚠️ 受限 | User::$fillable = ['last_login'](app/Models/User.php:18)→ update() 改不了 username | 必须走 execute(),不能走 update() |
| 核心自己怎么写 users 表 | ✅ 有先例 | app/Controllers/ProfileController.php:49-52:(new User())->execute('UPDATE users SET email=:email, signature=:sig, email_verified=0, email_verify_token=NULL WHERE id=:id', [...]) | 复用同一手法(§4.5 路径 A) |
| UNIQUE 约束 | ✅ | users.username TEXT UNIQUE NOT NULL(app/SplitDB/Schema.php:302)+ idx_users_username(:325) | 预检 + 兜底捕获(§8.2) |
| 邮箱验证列 | ✅ 但语义属核心 | email_verified / email_verify_token / email_verify_token_sent_at(Schema.php:319-321);User::setVerifyToken()(:337) | 禁复用/覆盖(那是核心注册验证语义)→ 插件自建验证码表 |
| 当前用户 | ✅ | Auth::getCurrentUser()(静态缓存,:29)、Auth::getCurrentUserWithPassword()(:87)、Auth::clearUserCache()(:107) | 直接用 |
| 管理员判定 | ✅ 唯一口径 | Auth::isAdmin() = role==='admin' && group_id===4(AND 非 OR,Auth.php:20-27) | 只用它,禁自造 |
| 后台门禁 | ✅ | app/Controllers/Admin/BaseController::__construct() = isAdmin() + 二次验证超时(admin_verify_timeout,60s~7天) | 后台控制器继承它(33 个插件已验证) |
| 限频 | ✅ | RateLimiter::check($action,$max,$win) → true = 允许(:225);isLimited()(:188);retryAfter()(:309);hit()(:336);hitConfig()(:395);config() 读 rate_{action}_max/window(:376) | 直接用;⚠️ 阈值清单是核心硬编码 13 项(SettingsController.php:151-163)→ 插件用自己的设置键 |
| CSRF | ✅ | Csrf::token()(:16)/ Csrf::verifyOrDie($t)(:60)/ Csrf::consume()(:101) | 直接用 |
| 审计 | ✅ | AuditLog::log($action,$target_type,$target_id,$detail)(:110);setSessionUsername()(:218)→ $_SESSION['audit_username'](登录时由 AuthController.php:141 写入) | 直接用;⚠️ 改名后须同步 session 用户名 |
| 页面缓存 | ⚠️ 有限 | PageCache::invalidate() —— 仅列表页、仅游客(!isLoggedIn() 门控) | 仍要调,但别指望它修详情页 |
| 设置读写 | ✅ | Settings::get($k,$def)(:31);Settings::update($k,$v,$pluginName)(:255,需 system:settings 权限) | 直接用 |
| 邮件 | ✅ | Mailer::send($to,$subj,$body,$isHtml=false): array(:117);配置键 mail_{host,port,user,pass,encryption,from_addr,from_name} | ⚠️ Mailer::cfg() 是 private → 插件只能用 Settings::get('mail_host','') 等自行判定「是否已配置」 |
| 独立库 | ✅ | Plugin::db($name): \PDO(:49,原生 PDO);Plugin::ensureSchema($name,$ddl)(:86) | 直接用 |
| 进程预算 | ❌ 无 | 核心没有 Plugin::budget()(规范 §11.25) | 插件自实现(§4.4) |
| 头像 | ✅ | User::defaultAvatarPath($id,$username,$createdAt)(:205)落盘 seed_avatars/{月}/{id}.svg —— 路径不含用户名 | 改名不破坏头像;SVG 首字母仍为旧名(§10.4) |
1.3 需求逐条落点对照
| # | 需求 | 落点 | 备注 |
|---|---|---|---|
| 1 | 前台:用户改自己名,三选一验证 | §5 验证模式 + §6.2 前台控制器 | 站点级开关 uc_verify_mode |
| 2 | 后台:管理员改任意用户名,免验证 | §6.3 后台控制器 | 继承 Admin\BaseController |
| 3 | 校验:长度/字符/唯一性 | §8 校验规则 | ⚠️ 核心无规则 → 自定义(§1.1②) |
| 4 | 安全:CSRF / 限频 / HTTPS | §9 | 限频含「密码校验」与「发验证码」两条独立链路 |
| 5 | 缓存:改完清缓存 | §10 | 三处:Auth 静态缓存 / $_SESSION['audit_username'] / PageCache |
| 6 | 审计:谁改的 / 旧名→新名 / 时间 | §11 | 走核心 AuditLog::log() |
| 7 | 邮箱降级:SMTP 未配时提示或降级 | §5.4 | ⚠️ 决策点 2 |
| 扩展 | 改名冷却 / 扣积分 / 历史时间线 | §12 | 先留字段,本期不启用 |
| 边界 | 不改核心表(只 UPDATE,不 ALTER) | §4.5 | 已确认可行 |
| 收尾 | 发布前跑 4 个自检 | §13.3 | plugin_case_lint.py / fh_i18n_check.php / 零内联 grep / php_var_cjk_check.php |
2. 总体架构
2.1 目录结构
plugins/username_change/
├── plugin.json # 插件清单(§3)
├── Plugin.php # 主类:独立库 / 生命周期 / 业务方法(§4)
├── FrontController.php # 前台:改名页 + 提交 + 发码(§6.2)
├── AdminController.php # 后台:用户搜索 + 管理员改名 + 设置(§6.3)
├── hook/
│ ├── init_after.php # 建表兜底(§11.15)
│ ├── route_register.php # 前台路由
│ ├── admin_route_register.php# 后台路由
│ └── nav_user_menu_items.php # 用户下拉菜单入口(pc / mobile 双触发)
├── views/
│ ├── front.php # 前台改名页(.mn-section)
│ ├── admin.php # 后台管理页(.admin-tabs)
│ └── admin_settings.php # 后台设置 Tab
├── assets/
│ ├── style.css # 前台样式(--mn-* 变量)
│ ├── script.js # 前台交互(零内联)
│ ├── admin.css # 后台样式
│ └── admin.js # 后台交互
├── lang/
│ ├── zh.php / en.php / zh_tw.php # 三语,键前缀 plugin.username_change.*
├── data/ # 运行时生成(不进包)
│ └── username_change.sqlite
└── 说明.md # 插件说明(体例见 skill flinthub-plugin-doc)2.2 数据流
流程 A — 前台用户改自己名(三选一验证)
[用户下拉菜单 /account/username]
│
▼
GET /account/username ──► 渲染改名页(当前名 / 规则提示 / 按模式渲染验证区)
│
│ ┌─ mode=password ─► 表单含「当前密码」
│ ├─ mode=email ─► 先点「发送验证码」→ POST send-code(限频+写码表)→ 用户收邮件
│ └─ mode=both ─► 两者都要
▼
POST /account/username(csrf + 限频)
│
├─ ① requireLogin() ← 先登录
├─ ② Csrf::verifyOrDie() ← 后 CSRF(§11.28)
├─ ③ RateLimiter::check('uc_submit',…) ← 提交限频
├─ ④ 校验新名(§8:格式 + 唯一性预检)
├─ ⑤ 按 mode 验证身份
│ · password → password_verify(旧密码) ← 单独限频 uc_pwd
│ · email → 校验码哈希 + 有效期 + 尝试次数
│ · both → 两者
├─ ⑥ 写 users.username(§4.5)
├─ ⑦ 事务提交 + 写 uc_history + uc_state
├─ ⑧ 清缓存三连(§10)+ 审计日志(§11)
└─ ⑨ PRG 302 → /account/username?ok=1流程 B — 后台管理员改名
[后台 → 插件 → 用户改名 /admin/username-change]
│
▼
GET /admin/username-change ──► 用户搜索框 + 列表(只读,走 User::adminSearch)
│
▼
POST /admin/username-change/change(csrf + 限频 uc_admin)
│
├─ Admin\BaseController 构造:isAdmin() + 二次验证超时(自动)
├─ Csrf::verifyOrDie()
├─ 校验新名(同 §8,但可选放宽最小长度)
├─ 唯一性预检
├─ 写 users.username
├─ uc_history(operator_type='admin')
├─ 清缓存(若改的是自己 → 还要同步 session)
└─ 审计日志:username_change / operator=admin2.3 三选一验证状态机(uc_requests 单条记录的生命周期)
┌──────────┐ 发码成功(仅 email/both) ┌──────────┐
│ (无记录) │ ─────────────────────────► │ pending │
└──────────┘ └────┬─────┘
▲ │
│ 新建(每次提交前重置) │ 提交成功
│ ▼
┌──────┴──────┐ 码过期 / 超尝试次数 ┌──────────┐
│ cancelled │ ◄─────────────────────── │ done │
└─────────────┘ └──────────┘- 模式为
password时不建pending记录(无需验证码),直接走提交。 - 模式为
email/both时,发码建pending;改名成功置done;过期/超次由惰性清理或route_after_dispatch收尾。 - 同一
user_id只保留一条pending(发新码即覆盖旧的,防多码并存被爆破)。
3. plugin.json 设计
{
"name": "用户改名",
"version": "1.0.0",
"author": "FlintHub",
"description": "用户改名:前台用户改自己的用户名(密码/邮箱/双重三选一验证),后台管理员改任意用户;含唯一性校验、限频、审计与改名历史",
"activated": false,
"category": "identity",
"icon": "user-pen",
"admin_url": "/admin/username-change",
"hooks": {
"init_after": "hook/init_after.php",
"route_register": "hook/route_register.php",
"admin_route_register": "hook/admin_route_register.php",
"nav_user_menu_items": "hook/nav_user_menu_items.php"
},
"permissions": ["route:admin", "system:settings"]
}字段依据:
| 字段 | 取值 | 依据 / 注意 |
|---|---|---|
activated | false | 交付时未启用;站长装好后在后台启用 |
category | identity | app/Controllers/Admin/PluginController.php:22-23 白名单:content/community/identity/economy/operation/governance/tools/integration/entertainment/other。语义上「身份管理」最贴(对照:user_profile/user_level/medal 都是 identity) |
icon | user-pen | 已实测在 TemplateCompiler::$map 内。⚠️ 写前必跑 grep -cE "^ 'user-pen' =>" app/Core/TemplateCompiler.php(必须带 ^ * 前缀,否则缩进两空格会假阴性);商店会从本字段回读图标(§11.35 ④) |
hooks | 4 个 | 见下 |
permissions | ["route:admin","system:settings"] | §3 强制表:注册后台路由 → route:admin;activate()/设置页写全局设置 → system:settings。不声明即 DENY |
hooks 清单与触发点(规范 §9.1,⚠️ 行号会漂移,引用前 grep 复核):
| 钩子 | 触发点 | 用途 | 陷阱 |
|---|---|---|---|
init_after | app/init.php:103 | 建表兜底(§11.15) | 轻量:先判 marker 文件短路,别每页开库 |
route_register | index.php:295 | 注册前台 3 条路由 | 先 if (!isset($router)) return; |
admin_route_register | index.php:276 | 注册后台路由 | 路径不写 /admin 前缀(§11.22) |
nav_user_menu_items | app/Views/layouts/main.php:274(pc) / :388(mobile) | 用户下拉菜单加「改名」入口 | ⚠️ 双触发(pc+mobile),按 $params['placement'] 分别渲染;⚠️ 渲染钩子闭包作用域,$this 不可用(§11.24)→ 用 htmlspecialchars(..., ENT_QUOTES, 'UTF-8');图标用核心字号类 mn-fs-14(14px,§6.4) |
⚠️ 不注册 layout_head_end:前台页样式走视图的 section('css')(规范 §6.1 称其为「插件页面加载样式的首选方式」),比 layout_head_end 更精准,也避免每页都跑。
4. Plugin.php(主类)设计
4.1 命名空间与 class_alias
<?php
namespace Plugin\username_change;
class Plugin
{
// ... 见下
}
// ⚠️ 目录名含下划线 → 必须在文件末尾注册别名(规范 §4.1 硬性要求)
// 否则核心用「目录名拼类名」推导出的 \Plugin\username_change\Plugin 落空
// → activate()/deactivate()/uninstall() 被静默跳过(卸载留孤儿表,零日志)
if (!\class_exists('Plugin\\username_change\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\username_change\\Plugin', false);
}为什么选
Plugin\username_change而不是Plugin\UsernameChange:两者都能被自动加载器命中(候选 ① / ②),但前者与目录名逐字节相同,可读性最好、也最不容易在后续改动中写错。但无论选哪个,末尾的class_alias都必须写(生命周期硬拼目录名,不做 camelize)。
4.2 独立库表(3 张,SQLite 语法)
-- 表 1:改名申请 / 验证码(三选一验证的载体)
CREATE TABLE IF NOT EXISTS uc_requests (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
new_username TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'password', -- password / email / both
code_hash TEXT NOT NULL DEFAULT '', -- sha256(code),不存明文
code_expires_at INTEGER NOT NULL DEFAULT 0, -- unix ts
attempts INTEGER NOT NULL DEFAULT 0, -- 已尝试次数(防爆破)
status TEXT NOT NULL DEFAULT 'pending', -- pending / done / cancelled
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uc_requests_user ON uc_requests (user_id, status);
CREATE INDEX IF NOT EXISTS idx_uc_requests_expire ON uc_requests (code_expires_at);
-- 表 2:改名历史时间线(需求 6 + 扩展 3)
CREATE TABLE IF NOT EXISTS uc_history (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
operator_id INTEGER NOT NULL DEFAULT 0, -- 0 = 本人操作
operator_type TEXT NOT NULL DEFAULT 'self', -- self / admin
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uc_history_user ON uc_history (user_id, created_at);
-- 表 3:改名状态(扩展 1 冷却 / 扩展 2 计数 预留)
CREATE TABLE IF NOT EXISTS uc_state (
user_id INTEGER PRIMARY KEY,
last_changed_at INTEGER NOT NULL DEFAULT 0, -- unix ts(冷却判定)
change_count INTEGER NOT NULL DEFAULT 0 -- 累计改名次数
);DDL 规则(规范 §10.3):INTEGER PRIMARY KEY(不是 INT AUTO_INCREMENT)、TEXT(不是 VARCHAR/DATETIME)、CREATE TABLE IF NOT EXISTS(不用 SHOW TABLES LIKE)、CREATE INDEX IF NOT EXISTS 必须写在 CREATE TABLE 之后。
⚠️ 为什么不复用核心的 users.email_verify_token:那三列(email_verified / email_verify_token / email_verify_token_sent_at)承载的是核心注册邮箱验证语义,且登录拦截逻辑直接读它(AuthController.php:119-121)。插件若借用,会在「改名验证」与「注册验证」之间产生语义冲突(例如把 email_verified 置 0 会导致用户下次登录被拦)。→ 插件自建 uc_requests.code_hash,与核心列完全隔离。
4.3 生命周期三方法
public static function ddl(): string
{
return '... 上面 3 张表 + 索引 ...';
}
/** 激活:建表 + 写入默认设置(幂等) */
public static function activate(): bool
{
\app\Helpers\Plugin::ensureSchema('username_change', self::ddl());
// 只在键不存在时写默认值(避免覆盖站长已改的配置)
foreach (self::defaultSettings() as $k => $v) {
if (\app\Helpers\Settings::get($k, "\0") === "\0") {
\app\Helpers\Settings::update($k, $v, 'username_change');
}
}
return true; // ⚠️ 建表失败必须 return false / 抛异常,让系统回滚(§4.1)
}
/** 禁用:临时禁用,不删表(§4.1) */
public static function deactivate(): bool
{
return true;
}
/** 卸载:先删插件表 → 再清核心 settings 残留(§4.1 顺序不能反) */
public static function uninstall(): void
{
$db = self::db();
$db->exec('DROP TABLE IF EXISTS uc_requests');
$db->exec('DROP TABLE IF EXISTS uc_history');
$db->exec('DROP TABLE IF EXISTS uc_state');
// 清核心 settings 里本插件的 key(对照 §11.5-34 卸载残留自检)
foreach (\array_keys(self::defaultSettings()) as $k) {
\app\Helpers\Settings::update($k, '', 'username_change'); // 或核心提供的删除接口
}
\app\Helpers\Settings::clearCache();
}⚠️ 卸载时清
settings的准确做法以实际核心接口为准(Settings是否有delete()需在实现时 grep 确认;若无则update($k,'')并接受空串残留,或按plugins/mod_system的既有做法处理)。plugins_cache.json由系统自动重建,不要手动删。
4.4 自实现 budget() 与 isEnabled()
规范 §11.25:核心没有 Plugin::budget(),预算是插件自己的进程内计数器。
private static int $budgetUsed = 0;
private const QUERY_BUDGET = 5; // 规范 §11.4-24 红线:单插件每请求 hook 查询总和 ≤ 5
public static function budget(int $cost = 1): bool
{
if (self::$budgetUsed + $cost > self::QUERY_BUDGET) { return false; }
self::$budgetUsed += $cost;
return true;
}
public static function budgetUsed(): int { return self::$budgetUsed; }
public static function resetBudget(): void { self::$budgetUsed = 0; }isEnabled()(核心亦无,各插件自实现,范式见 plugins/bitiful_storage/Plugin.php:231):读 uc_enabled 设置键,默认 '1'。
本插件的预算消耗盘点(这是关键,因为 nav_user_menu_items 是每页必跑):
| 钩子 | 查询 | 说明 |
|---|---|---|
init_after | 0 | 用 marker 文件短路(is_file()),不建表就不开库(§11.4-27) |
route_register | 0 | 只注册路由 |
admin_route_register | 0 | 只注册路由 |
nav_user_menu_items | 0 | 只读 Settings::get('uc_enabled')(走 settings_cache.php,不查库)+ 判断 Auth::isLoggedIn();不查插件库 |
| 合计 | 0 次 | ✅ 远低于 5 次红线 |
4.5 ⚠️ 写入 users.username 的路径(决策点 1)
问题:核心没有任何改用户名的能力,User::$fillable = ['last_login'] 也堵死了走 update() 的路。
三条候选路径:
| 路径 | 写法 | 优点 | 缺点 / 风险 |
|---|---|---|---|
A(推荐) 经核心 Model 的 execute() | (new \app\Models\User())->execute('UPDATE users SET username = :u WHERE id = :id', [':u'=>$new, ':id'=>$uid]) | ① 核心自己有先例(ProfileController.php:49 改邮箱就是这么写的);② 走的是核心 Model 的受管连接(app\Core\Model::execute():217),不是裸 PDO;③ 不碰核心文件 | 未走「专属方法」,严格说仍属「直接写核心用户表」的灰区(见下) |
B 核心新增 User::updateUsername() | 改 app/Models/User.php + 插件调用 | 最干净,符合 §11.8 精神 | 要动核心文件 + 发核心版本;与「插件不改核心」的边界冲突,且要单独评审(§4.1 对「不要改核心加 fallback」的告诫同理) |
C 裸 PDO 直写 business.sqlite | new PDO(...) 或 DBFactory 直连 | — | ❌ 不推荐:绕过 Model、易踩 WAL 旧快照、且是 §11.8 明确点名的反模式 |
关于规范 §11.8 第 1 条「禁止直写核心库用户/积分表」的适用性:
该条的两个举例是
group_id(→User::updateGroupId())与points(→Points::deduct()/award())—— 即「已有专属方法的字段不得绕过」。而username核心没有任何专属方法,且用户需求明确授权「插件只能 UPDATE,不能 ALTER」。
本方案结论:走路径 A,并做两件事降低耦合:
- 写入逻辑只在一处(
Plugin::applyUsernameChange()),未来核心若提供updateUsername(),只需在这一个函数里切换实现; - 用
method_exists((new \app\Models\User()), 'updateUsername')做前瞻探测:若核心将来提供,优先走核心方法;否则走execute()。
/**
* 唯一的 username 写入点
* @return array{0:bool,1:string} [是否成功, 错误码/'']
*/
public static function applyUsernameChange(int $uid, string $newName): array
{
$userModel = new \app\Models\User();
// 前瞻:核心将来若提供专属方法,优先复用(零成本切换)
if (\method_exists($userModel, 'updateUsername')) {
$userModel->updateUsername($uid, $newName);
return [true, ''];
}
try {
$affected = $userModel->execute(
'UPDATE users SET username = :u WHERE id = :id',
[':u' => $newName, ':id' => $uid]
);
// execute() 的返回值语义以实际实现为准;稳妥起见回读校验
$row = $userModel->queryOne('SELECT username FROM users WHERE id = :id', [':id' => $uid]);
if (!$row || (string)$row['username'] !== $newName) {
return [false, 'write_failed'];
}
return [true, ''];
} catch (\PDOException $e) {
// UNIQUE 违约 = 并发下被抢先占用
if (\stripos($e->getMessage(), 'UNIQUE') !== false) {
return [false, 'taken'];
}
\error_log('username_change write error: ' . $e->getMessage());
return [false, 'write_failed'];
}
}⚠️ 实现时必须先核实
app\Core\Model::execute()的返回值语义(:217,是否返回rowCount())与app\Core\Database没有fetchColumn()/prepare()(项目记忆红线)→ 用queryOne()或fetchOne(),不要写fetchColumn()。
4.6 业务方法清单
| 方法 | 签名 | 用途 |
|---|---|---|
db() | (): \PDO | 独立库连接 |
ddl() | (): string | 3 张表 + 索引 |
defaultSettings() | (): array | 默认设置键值(§5.1) |
activate/deactivate/uninstall | — | 生命周期(§4.3) |
budget/budgetUsed/resetBudget | — | 进程预算(§4.4) |
isEnabled() | (): bool | 插件总开关 |
verifyMode() | (): string | 读 uc_verify_mode,非法值回落 password |
smtpConfigured() | (): bool | §5.4:Settings::get('mail_host') 等非空 |
issueCode(int $uid, string $newName): array | — | 生成验证码 + 写 uc_requests + 发邮件 |
verifyCode(int $uid, string $code): array | — | 校验哈希/有效期/尝试次数(原子自增 attempts) |
validateNewName(string $name, int $uid): array | — | §8 格式校验(不含唯一性) |
isNameTaken(string $name, int $exceptUid = 0): bool | — | §8.2 唯一性预检(走 User::findByUsername) |
applyUsernameChange(int $uid, string $newName): array | — | §4.5 唯一写入点 |
afterChange(int $uid, string $old, string $new, string $opType, int $opId): void | — | §10 清缓存三连 + §11 审计 + 写 uc_history/uc_state |
history(int $uid, int $limit = 20): array | — | 改名历史(扩展 3) |
checkCooldown(int $uid): array | — | 冷却判定(扩展 1,默认关闭) |
5. 验证模式(三选一)设计
5.1 站点级开关与设置项
存储:核心
settings表(经Settings::update/get),键统一uc_前缀。后台设置页可配。
| 键 | 默认 | 取值 | 说明 |
|---|---|---|---|
uc_enabled | '1' | 0/1 | 插件总开关(前台入口 + 后台入口都判) |
uc_verify_mode | 'password' | password / email / both | 三选一(需求 1)。非法值一律回落 password |
uc_code_ttl | '600' | 60~3600 | 验证码有效期(秒) |
uc_code_max_attempts | '5' | 1~20 | 验证码最大尝试次数(防爆破) |
uc_username_min | '3' | 2~64 | 新名最小长度(§8) |
uc_username_max | '20' | 3~64 | 新名最大长度(§8) |
uc_reserved_words | 'admin,administrator,root,system,moderator,管理员,版主,站长' | 逗号分隔 | 保留字黑名单(§8) |
uc_allow_admin_change | '1' | 0/1 | 是否允许后台管理员改名 |
uc_force_https | '1' | 0/1 | 改名页强制 HTTPS(§9.3) |
uc_cooldown_days | '0' | 0 = 不限 | 扩展 1 预留 |
uc_points_cost | '0' | 0 = 免费 | 扩展 2 预留 |
uc_rate_submit_max / uc_rate_submit_window | '10' / '3600' | — | 提交限频 |
uc_rate_pwd_max / uc_rate_pwd_window | '5' / '900' | — | 密码校验限频(需求 4) |
uc_rate_code_max / uc_rate_code_window | '3' / '3600' | — | 发验证码限频(需求 4) |
uc_rate_admin_max / uc_rate_admin_window | '30' / '600' | — | 后台改名限频 |
⚠️ **限频阈值为什么放插件自己的键、而不是核心的
rate_*键:核心后台「限流设置」Tab 的动作清单是硬编码 13 项**(app/Controllers/Admin/SettingsController.php:151-163),插件加不进去。因此插件用自己的uc_rate_*键,在自己的设置页暴露给站长,调用RateLimiter::check($action, $max, $win)时把值传进去。
5.2 三种模式行为矩阵
| 模式 | 前台需要什么 | 校验链路 | 邮件 | 备注 |
|---|---|---|---|---|
password(默认) | 当前密码 | Auth::getCurrentUserWithPassword() + password_verify() | 不涉及 | 零外部依赖,永远可用 → 是降级落点 |
email | 邮箱验证码 | uc_requests.code_hash + 有效期 + 尝试次数 | 必需 | SMTP 未配时不可用(§5.4) |
both | 密码 且 验证码 | 两条链路都要过 | 必需 | 同上 |
5.3 验证码生命周期
- 发码(
POST /account/username/send-code):
- 先
requireLogin()→Csrf::verifyOrDie()→RateLimiter::check('uc_code', …); - 校验「新名合法 + 未占用」(先校验再发码,避免为非法名白送邮件);
- 生成
random_int(100000, 999999)(6 位)→ 存sha256(code)(不存明文); - 同一
user_id的旧pending记录置cancelled,再插新记录(防多码并存); Mailer::send($user['email'], $subject, $body)→ 返回['success'=>bool,...];失败要回显、不静默。
- 校验:哈希比对(
hash_equals())+ 未过期 +attempts < uc_code_max_attempts;每次失败原子自增attempts。 - 成功:置
done,同一事务内完成「写 username」——避免「码验证过了但改名失败,码还能重放」。
5.4 ⚠️ SMTP 降级策略(决策点 2)
事实:Mailer::cfg() 是 private,核心没有公开的「SMTP 是否已配置」判定;Mailer::send() 未配置时返回 ['success'=>false, 'message'=>'SMTP 配置不完整']。
判定方法(插件自建,读核心 settings):
public static function smtpConfigured(): bool
{
// 与 Mailer::cfg() 同源:先读 settings 的 mail_*,再回退 config.php 常量
$host = \app\Helpers\Settings::get('mail_host', '');
if ($host === '' && \defined('MAIL_HOST')) { $host = (string)\constant('MAIL_HOST'); }
return $host !== '';
}降级策略(需站长拍板,三选一):
| 方案 | 行为 | 优点 | 缺点 |
|---|---|---|---|
| ① 自动降级(推荐) | 若 uc_verify_mode ∈ {email, both} 且 smtpConfigured() === false → 前台按 password 执行,同时后台设置页显示红色警示条、前台页面显示一行提示「邮件服务未配置,已改用密码验证」 | 用户永远能改名,不卡死;站长能看到问题 | 与站长「我设的是邮箱验证」的预期有偏差 → 靠警示条显性化 |
| ② 硬阻断 | 邮件未配置 → 前台改名页直接不可用,提示「请联系站长配置邮件服务」 | 严格遵循配置 | 用户完全卡死(正是需求里说的「别让用户卡在收不到验证码」) |
| ③ 仅提示不降级 | 照常走 email 模式,发码失败时提示「发送失败,请联系站长」 | 实现最简 | 用户会反复点、反复失败 |
本方案推荐 ①(它同时满足需求里「提示站长」与「降级到仅密码」两个方向)。后台设置页的警示条用 mail_host 空值判定 + 一行 Mailer 测试发送按钮(可选)。