本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/用户改名插件开发方案_2026-10-04.md。
6. 控制器与路由
6.1 路由表
前台(hook/route_register.php,先 if (!isset($router)) return;):
| 方法 | 路径 | 处理 | 说明 |
|---|---|---|---|
| GET | /account/username | FrontController::page | 改名页 |
| POST | /account/username | FrontController::submit | 提交改名 |
| POST | /account/username/send-code | FrontController::sendCode | 发验证码 |
⚠️ 路径避让(§11.34b):站点根真实目录为
app/ assets/ cli/ data/ docs/ lang/ plugins/ protected/ release/ update/ 审计/ 待办事宜/(本次实测)—— 没有account/→/account/...安全。不要用/plugins、/data、/assets等真实目录名开前台路由。
后台(hook/admin_route_register.php):
| 方法 | 注册路径 | 实际 URL | 处理 |
|---|---|---|---|
| GET | /username-change | /admin/username-change | AdminController::index(用户搜索 + 列表) |
| POST | /username-change/change | /admin/username-change/change | AdminController::change(管理员改名) |
| GET | /username-change/settings | /admin/username-change/settings | AdminController::settings(设置 Tab) |
| POST | /username-change/settings | /admin/username-change/settings | AdminController::saveSettings |
⚠️ 不写
/admin前缀(§11.22,否则 404)。⚠️ 后台多页必须页内自建 Tab 导航(项目记忆红线:核心卡片只认单个
admin_url,其余页 UI 上等于不存在)→ 用核心.admin-tabs/.admin-tab-btn(§11.10,勿自定义 tab 类)。
6.2 前台控制器(FrontController extends app\Core\Controller)
public function page()
{
$this->requireLogin();
$this->view('plugins/username_change/front', [
'mode' => Plugin::verifyMode(),
'smtpOk' => Plugin::smtpConfigured(),
'settings' => Plugin::publicSettings(), // 只暴露规则提示所需的最小集
'history' => Plugin::history((int)$_SESSION['user_id'], 5),
'ok' => (string)($_GET['ok'] ?? ''),
'err' => (string)($_GET['err'] ?? ''),
]);
}
public function submit()
{
$this->requireLogin(); // ① 先登录(§11.28)
\app\Helpers\Csrf::verifyOrDie($_POST['csrf'] ?? ''); // ② 后 CSRF
$max = (int)\app\Helpers\Settings::get('uc_rate_submit_max', '10');
$win = (int)\app\Helpers\Settings::get('uc_rate_submit_window', '3600');
if (!\app\Helpers\RateLimiter::check('uc_submit', $max, $win)) { // ⚠️ true = 允许
$this->redirect('/account/username?err=rate_limited');
return;
}
$uid = (int)$_SESSION['user_id'];
$newName = \trim((string)($_POST['new_username'] ?? ''));
// ... 校验 → 身份验证 → 写入 → 收尾(§8/§9/§10/§11)
// 全部走 PRG:失败 redirect(?err=xxx)、成功 redirect(?ok=1)
}⚠️
RateLimiter::check()契约 =true表示「允许」(项目记忆红线,写反会恒 429)。⚠️ 本插件不用 htmx 局部刷新(改名是整页表单 + PRG),因此不触发 §11.4-35 的 SW 缓存陷阱;若后续改成 htmx,必须按该条加
no-store+HX-Request排除。
6.3 后台控制器(AdminController extends app\Controllers\Admin\BaseController)
namespace Plugin\username_change;
use app\Controllers\Admin\BaseController;
use app\Helpers\Csrf;
class AdminController extends BaseController
{
// ⚠️ 不要覆写 __construct() 后忘了 parent::__construct()
// 父类构造函数 = isAdmin() + 二次验证超时门禁(33 个插件已验证)
// ⚠️ 若确实需要自定义构造函数,必须显式调用 parent::__construct()
// 否则后台门禁被整个绕过(越权高危,项目记忆已复发 2 次)
public function index() { /* 用户搜索 + 列表(只读,走 User::adminSearch) */ }
public function change() { /* 管理员改名 */ }
public function settings() { /* 设置 Tab */ }
public function saveSettings() { /* 保存设置(⚠️ §11.32 字段缺失静默写最小值) */ }
}后台设置保存的红线(§11.32 / 项目记忆):
// ❌ 错误:字段缺失会被静默写成 0 / 最小值,302 成功但 DB 与缓存都不变、无日志
$value = (int)($_POST[$key] ?? 0);
// ✅ 正确:缺失即跳过
foreach (Plugin::defaultSettings() as $key => $def) {
if (!\array_key_exists($key, $_POST)) { continue; }
\app\Helpers\Settings::update($key, (string)$_POST[$key], 'username_change');
}
\app\Helpers\Settings::clearCache(); // ⚠️ settings_cache.php 不自动补新键⚠️ 验证设置保存必须同时回读 DB +
protected/settings_cache.php(项目记忆:只回读一处会漏掉缓存未清的情况)。
6.4 写操作安全顺序(硬性,§11.28)
$this->requireLogin(); // ① 先登录
Csrf::verifyOrDie($_POST['csrf'] ?? ''); // ② 后 CSRF
// ③ 再限频 → ④ 校验 → ⑤ 身份验证 → ⑥ 写入顺序颠倒的后果:会话过期时 CSRF 先失败 → 403,且来源页丢失。
7. 视图与前端
7.1 前台改名页(views/front.php)
- 容器用核心
.mn-section(§6.4); - 样式走
section('css'),JS 走section('js')(若布局支持)或assets/script.js; - 表单:
<form method="post" action="…/account/username">+<input type="hidden" name="csrf" value="<?php echo $this->e(\app\Helpers\Csrf::token()); ?>">; - 按
mode条件渲染: password/both→ 当前密码框;email/both→ 「发送验证码」按钮(type="button",JS 走assets/script.js的addEventListener,禁内联onclick)+ 验证码输入框 + 倒计时;- 顶部提示区:成功 / 各类错误 / SMTP 降级提示(
smtpOk === false且 mode 含 email 时); - 底部:最近 5 条改名历史(
history); - 双主题:只用
--mn-变量,禁硬编码色(§11.7);⚠️--mn-danger/--mn-spectrum-/--mn-radius-*不存在(项目记忆红线)。
7.2 用户菜单入口(hook/nav_user_menu_items.php)
⚠️ 三个陷阱同时存在:
- 双触发(pc + mobile,
main.php:274/:388)→ 必须按$params['placement']分支渲染,且钩子文件顶部禁声明函数/类(include_once降级会静默少输出); $this不可用(闭包作用域,§11.24)→ 用htmlspecialchars($s, ENT_QUOTES, 'UTF-8')与\app\Helpers\Csrf::token();- 图标 14px(
nav_user_menu_items专属,§6.4)→ 用核心字号类mn-fs-14,禁内联style=。
<?php
/** @file plugins/username_change/hook/nav_user_menu_items.php */
if (!\app\Helpers\Plugin::isActivated('username_change')) { return; }
if (\app\Helpers\Settings::get('uc_enabled', '1') !== '1') { return; }
if (!\app\Helpers\Auth::isLoggedIn()) { return; }
$placement = (string)($params['placement'] ?? 'pc');
$url = (\defined('BASE_PATH') ? BASE_PATH : '') . '/account/username';
$text = \app\Helpers\I18n::get('plugin.username_change.menu_entry');
?>
<?php echo htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); ?>
⚠️ 上面
是占位:实现时必须从核心图标表取user-pen的真实码点(grep -A1 "'user-pen' =>" app/Core/TemplateCompiler.php),不要照抄本示例的十六进制。
7.3 后台页(views/admin.php / views/admin_settings.php)
- ⚠️⚠️ 后台弹层/表单 markup 必须留在
<main class="admin-content">内(核心表单/按钮样式全在.admin-content作用域,放</main>之后 = 「弹出页没有 UI」且零日志); - Tab 用核心
.admin-tabs/.admin-tab-btn; - 插件后台 CSS 走
section('css'),并注意插件 CSS 注入早于核心editor.css→ 同权重核心后胜(§4/项目记忆)→ 需要提权时用双类(如.uc-table.uc-table td); - 用户列表只读展示 + 单个「改名」按钮(打开表单);改名前显示当前名与新名对照,二次确认。
7.4 零内联 / 双主题 / 图标(验收)
- 零内联:
grep -nE '<style|<script>|on(click|change|submit|load)=' plugins/username_change/views plugins/username_change/hook→ 应零命中(<script src>引用除外); - 双主题:默认浅色 + 暗夜星辰各走查一次(§11.7);
- 图标:
$this->icon('名称')的名称必须在核心$map内,否则回退问号。
8. 用户名校验规则(自定义,因核心无规则)
8.1 规则表
| 规则 | 默认取值 | 依据 / 说明 |
|---|---|---|
先 trim() | — | 与核心注册一致(AuthController:215) |
| 最小长度 | 3 字符(可配 2~64) | 核心只截断到 ≤64,无下限 → 插件补下限 |
| 最大长度 | 20 字符(可配 3~64) | 与核心的 64 硬上限兼容;默认收得更紧 |
| 字符集 | ^[\p{L}\p{N}_\-\x{4e00}-\x{9fa5}]+$(字母/数字/下划线/连字符/中文) | ⚠️ 需站长确认是否允许中文名、空格、点号 |
| 首字符 | 不得为 _ / - / 数字(可配) | 防 _admin 类伪装 |
| 保留字黑名单 | admin, administrator, root, system, moderator, 管理员, 版主, 站长(可配) | 大小写不敏感比对 |
| 不得等于自己的当前名 | — | 无变化直接拒(省一次写入 + 一条无意义审计) |
| 不得等于任何用户的邮箱 | — | 防「用户名看起来像邮箱」的钓鱼观感(⚠️ 需站长确认是否要这条) |
| 控制字符 / 换行 | 一律拒 | 防注入与排版破坏 |
| 前后空格 | trim 后与原值不等 → 静默按 trim 后处理 | 与核心一致 |
⚠️ 这套规则只作用于改名时的新名,不回溯存量用户(核心注册可以造出不合规的名字,插件无权改)。
8.2 唯一性(预检 + 兜底,双保险)
// 预检:走核心 Model,不直查 users 表(§10.1)
$taken = (new \app\Models\User())->findByUsername($newName); // User.php:27
if ($taken && (int)$taken['id'] !== $uid) { return [false, 'taken']; }
// 兜底:并发下仍可能被抢先 → applyUsernameChange() 捕获 UNIQUE 违约(§4.5)⚠️ 必须做预检,理由与核心改邮箱一致(
ProfileController.php:40-44注释):users.username是 UNIQUE,直接 UPDATE 撞名会抛PDOException→ 通用 500,且「成功 / 500」的差异构成用户名占用枚举预言机。
8.3 错误码(→ i18n 键)
| 错误码 | 语言键 | 文案要点 |
|---|---|---|
empty | plugin.username_change.err_empty | 请输入新用户名 |
too_short | …err_too_short | 不少于 N 个字符 |
too_long | …err_too_long | 不超过 N 个字符 |
bad_chars | …err_bad_chars | 含不允许的字符 |
reserved | …err_reserved | 该名称已被系统保留 |
taken | …err_taken | 该用户名已被占用 |
same | …err_same | 与当前用户名相同 |
pwd_wrong | …err_pwd_wrong | 密码不正确 |
code_invalid | …err_code_invalid | 验证码不正确或已过期 |
code_attempts | …err_code_attempts | 验证码尝试次数过多,请重新获取 |
rate_limited | …err_rate_limited | 操作过于频繁,请稍后再试 |
smtp_down | …err_smtp_down | 邮件服务未配置,已改用密码验证 |
write_failed | …err_write_failed | 保存失败,请稍后重试 |
not_admin | …err_not_admin | 无权限 |
cooldown | …err_cooldown | 距上次改名不足 N 天(扩展 1) |
points_low | …err_points_low | 积分不足(扩展 2) |
⚠️ 语言键必须三语齐全(
lang/zh.php/en.php/zh_tw.php),前缀plugin.username_change.*(§8)。I18n::get()缺键返回键名原文、永不白屏 → 缺键只能靠全量比对发现,不能靠肉眼。
9. 安全设计
9.1 CSRF(需求 4)
- 所有写操作(提交改名 / 发码 / 后台改名 / 保存设置)必带
csrf字段,服务端Csrf::verifyOrDie($_POST['csrf'] ?? ''); - 若用 AJAX 发码:
FormData带csrf或X-CSRF-Token头(红线 8); - 改名成功后可选
Csrf::consume()(对照核心改密码的做法ProfileController.php:80,防 Token 重放)。
9.2 限频(需求 4)
| 动作 | action 名 | 默认阈值 | 保护对象 |
|---|---|---|---|
| 提交改名 | uc_submit | 10 次 / 3600s | 防刷 |
| 密码校验 | uc_pwd | 5 次 / 900s | 防暴力猜密码(需求点名) |
| 发验证码 | uc_code | 3 次 / 3600s | 防刷邮件(需求点名) |
| 后台改名 | uc_admin | 30 次 / 600s | 防误操作/脚本 |
- ⚠️
check()返回true= 允许; - ⚠️ action 名不得复用核心已有名(
login/resend_verify/forgot_password…)—— 复用会共享同一个计数器,互相挤占额度; - 需要向用户报剩余等待时间时用
RateLimiter::retryAfter($action, $max, $win)(不要写死整窗口); - 若走
hit()/hitConfig()(直接输出 429),注意它会自己再判一次。
9.3 HTTPS(需求 4)
- 插件级开关
uc_force_https(默认'1'); - 判定(兼容反代):
$_SERVER['HTTPS'] === 'on'或(int)$_SERVER['SERVER_PORT'] === 443或($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; - 未命中时:
- 若
SITE_URL(或uc_https_host)是https://…→ 302 跳到 https 同路径; - 否则(站点压根没 HTTPS)→ 不跳(避免跳死循环),改为页面顶部警示 + 提交时若
uc_force_https=1则拒绝并提示站长; - ⚠️ 核心无统一
isHttps()助手(各文件各自判$_SERVER['HTTPS']),插件自建一个小函数即可,不要改核心。
9.4 越权 / IDOR
- 前台改名只能改自己:
$uid一律取$_SESSION['user_id'],绝不从$_POST['user_id']取; - 后台改名:目标 id 一律
(int)强转 + 校验用户存在; - 所有 SQL 走
prepare()+ 绑定参数(红线 2); - 后台控制器继承
Admin\BaseController(门禁在构造函数里,继承app\Core\Controller不触发)。
9.5 管理员判定唯一口径
if (!\app\Helpers\Auth::isAdmin()) { /* 拒绝 */ } // role==='admin' && group_id===4(AND)禁自造(项目记忆:插件自造管理员判定 = 越权高危,已复发 2 次)。
10. 缓存一致性(需求 5)
改名是「当前请求还是旧名 / 游客看到旧名」的典型场景,必须三处都处理:
| # | 位置 | 调用 | 说明 |
|---|---|---|---|
| 1 | Auth 静态缓存 | \app\Helpers\Auth::clearUserCache();(Auth.php:107) | 否则本请求后续(含视图渲染)读到的仍是旧名 |
| 2 | 审计 session 名 | \app\Helpers\AuditLog::setSessionUsername($newName);(:218) | 否则后续审计日志仍写旧名($_SESSION['audit_username'] 是登录时写入的) |
| 3 | 前台页面缓存 | \app\Helpers\PageCache::invalidate();(:151) | ⚠️ 仅列表页且仅游客;游客在列表页看到的是「按 user_id 现取」的名字,理论上无需清,但按规范 §11.5-31 一律调用更稳妥 |
| 4 | 设置缓存(改设置时) | \app\Helpers\Settings::clearCache();(:242) | ⚠️ settings_cache.php 不自动补新键 → 上线新设置项后必须清一次 |
| 5 | 插件设置缓存 | 本插件无 | 设置走核心 settings_cache.php,无需自建 |
⚠️ 改的是自己时才需要同步 #1 / #2(管理员改别人 → 那人的 session 在别的进程,下次请求自然重取;无需也不能动)。
10.4 头像首字母(可选)
User::defaultAvatarPath() 生成的 SVG 首字母取自 username,但落盘路径是 seed_avatars/{月}/{id}.svg(不含用户名)→ 改名不破坏头像文件,只是 SVG 里的首字母仍是旧名。
| 选项 | 行为 | 风险 |
|---|---|---|
| 保持旧字母(默认推荐) | 什么都不做 | 视觉上「名字变了但头像字母没变」,可接受 |
| 重生成 | 调 User::defaultAvatarPath($uid, $newName, $createdAt) 覆写同名文件 | 需确认该方法只对默认头像生效(用户上传过头像时绝不能覆写,否则把用户头像冲掉) |
⚠️ 若选「重生成」,必须先判断当前
users.avatar是否为空/是否指向seed_avatars/,否则会覆盖用户自传头像。建议本期不做,留作后续可选。
11. 审计(需求 6)
统一走核心 AuditLog::log(string $action, string $target_type = '', int $target_id = 0, string $detail = '')(AuditLog.php:110):
\app\Helpers\AuditLog::log(
'username_change', // action
'user', // target_type
$uid, // target_id
'用户名变更: ' . $oldName . ' -> ' . $newName // detail(旧名 → 新名)
);| 场景 | action | detail |
|---|---|---|
| 本人改名 | username_change | 用户名变更: 旧名 -> 新名 |
| 管理员改名 | username_change | 管理员改名: 旧名 -> 新名(+ 管理员 id 由 AuditLog 自带的 admin_id 字段记录) |
| 发验证码(可选) | username_change_code | 发送改名验证码(⚠️ 量大,默认不记,避免审计表膨胀) |
⚠️
audit_logs.username是历史快照列(Schema.php:615,TEXT DEFAULT '')→ 日志里的「谁」是当时的名字,不会被后续改名改写(这正是审计要的效果)。⚠️ 改名后必须同步
$_SESSION['audit_username'](§10 #2),否则本条日志之后的日志仍写旧名。
12. 可选扩展(先留字段,本期不启用)
| 扩展 | 预留 | 启用方式 | 依赖 |
|---|---|---|---|
| 1 改名频率限制(30 天一次) | uc_state.last_changed_at + uc_cooldown_days(默认 0 = 不限) | 提交前查 uc_state:time() - last_changed_at < days*86400 → 拒 cooldown | ⚠️ 冷却检查用 $diff >= 0 保护未来/异常时间戳 |
| 2 改名扣积分 | uc_points_cost(默认 0 = 免费) | 写名前调 \app\Helpers\Points::deduct($uid, $cost, 'username_change', 0, 'username_change') | ⚠️ 必须用 Points::deduct()(条件原子扣减,§11.8 红线),禁 UPDATE users SET points=points-…;⚠️ 扣积分要在写名之前,且失败要能回滚(同一事务或先扣后退) |
| 3 改名历史时间线 | uc_history 表本期即建即写(成本极低) | 前台改名页底部已展示最近 5 条;后台可查全量 | 无 |
⚠️ 扩展 2 与「唯一写入点」的事务边界要一起设计:积分扣减 + 用户名写入若跨库(
points在核心库、uc_*在插件库),无法用一个事务包住 → 建议顺序为「先扣积分(核心库,自带事务)→ 再写名(核心库)→ 最后写插件库历史」,任一步失败则补偿(积分Points::award()退回)。
13. 红线与自检清单
13.1 最高级红线(8 条,一票否决)逐条对照
| # | 红线 | 本插件如何满足 |
|---|---|---|
| 1 | 禁 unserialize/eval/assert/preg_replace('/e') | 全程 json_decode;无动态执行 |
| 2 | 禁滥用绝对路径 | 全部基于 __DIR__ / BASE_PATH;不用 $_SERVER['DOCUMENT_ROOT'] |
| 3 | 渲染钩子禁 die()/exit()/header() | nav_user_menu_items / init_after 一律 return; |
| 4 | 上传白名单 + 强随机名 | 本插件无上传功能(不适用) |
| 5 | 数量原子扣减 | 扩展 2 走 Points::deduct()(条件原子);验证码 attempts 用 UPDATE … SET attempts = attempts + 1 |
| 6 | IN (...) 强转 + 占位符 | 用户列表/历史查询若有 IN,array_map('intval') + 占位符 |
| 7 | PDO 不覆盖核心属性 | Plugin::db() 原样用,不设任何 setAttribute |
| 8 | AJAX 必带 CSRF | 发码接口必带 csrf 字段/头 |
13.2 规范 §11 相关条目对照(本插件涉及项)
| 条目 | 落点 |
|---|---|
| §11.4-24 每请求 hook 查询 ≤ 5 | §4.4:本插件合计 0 次 |
§11.5-30 改 hooks 后更新 plugins_cache.json | 交付后由系统重建;改 hooks 清单时必须刷新 |
| §11.5-31 改布局钩子后清页面缓存 | 调 PageCache::invalidate() |
| §11.5-34 卸载残留自检 | §4.3 uninstall() + 交付前自查 |
| §11.8 核心库访问边界 | §4.5:无专属方法的 username 经 Model execute();扩展 2 走 Points::deduct() |
| §11.15 建表兜底两条路 | init_after(marker 短路)+ db() 自愈(可选) |
§11.22 admin_route_register 不写 /admin | §6.1 |
§11.24 渲染钩子 $this 不可用 | §7.2 |
§11.25 核心无 Plugin::budget() | §4.4 自实现 |
§11.28 Auth::loginUrl() + 先登录后 CSRF | §6.4;视图里用 $this->loginUrl(),钩子里用 Auth::loginUrl() |
| §11.32 后台设置字段缺失静默写最小值 | §6.3 array_key_exists |
| §11.34 命名空间逐字节 = 目录名 | §4.1 + 自检 |
| §11.34b 前台路由避让真实目录 | §6.1(/account/... 已核实安全) |
| §11.35 投稿包结构(若走前台投稿) | §13.4 |
13.3 发布前必跑的自检(4 个)
# ① 命名空间大小写体检(Linux 部署必做,本机 Windows 全绿不代表 Linux 能跑)
python .workbuddy-ai/tools/plugin_case_lint.py plugins/username_change
# → 期望:「全部命名空间在 Linux 精确匹配下均可被自动加载」
# ② i18n 三语键全量比对(应零缺失)
php .workbuddy-ai/tools/fh_i18n_check.php plugins/username_change
# ③ 零内联(应零命中,附加自检:
# 语法
for f in $(find plugins/username_change -name '*.php'); do php -l "$f"; done
# 登录链接不得裸写 /login(§11.28)
grep -rnE 'href="[^"]*/login' plugins/username_change/views plugins/username_change/hook # 应零命中
# 图标名必须在核心表内
grep -cE "^ *'user-pen' *=>" app/Core/TemplateCompiler.php # 应为 113.4 发布路径(两条,二选一)
| 路径 | 命令 / 入口 | 包结构 |
|---|---|---|
| 后台发布(推荐,直接上架) | php cli/release_build.php --type=plugin --name=username_change --version=1.0.0 --sign --key-id=flinthub-2026 --notes=<说明文件>,先 --dry-run 过 9 项门禁 | 允许根级 改动说明.md |
| 前台投稿 | /plughub/submit(build_submit_zip.py + verify_submit_zip.php) | 禁止任何根级文件,必须 plugins/username_change/… 单根 |
⚠️ 后台发布出完包后必须再跑
--type=index重建release/plugins/index.json,否则前台橱窗看不到新版本。⚠️ ~~交付 3 个文件:
release/plugins/index.json+data/market/packages/.zip+data/market/manifests/.json(不传data/market/mirrors/)。~~ 🔴 【已作废 · 见下方更正】⚠️
plugin.json的version必须与包名一致(否则商店永远显示「可升级」)。
🔴 【2026-10-09 更正 · 以上第 2 条「交付 3 个文件」已作废】
正确口径:只交付 2 个文件 ——
data/market/packages/.zip+data/market/manifests/.json;
release/plugins/index.json禁止上传。理由:它是
data/market/manifests/的确定性函数(HubCatalog::rebuildIndex()只
glob('manifests/manifest-*.json')再写Plugin::indexFile())。上传本地那份会① 抹掉只在 Hub 上的社区投稿;② 复活已下架插件(Hub 把它们移进了
manifests-off/)。正确做法 = 增量摆上 2 个文件后,到 Hub 后台 市场 Hub → 设置 → 「重建清单」。
⚠️
--type=index仍要跑 —— 但那是给本机(发布端 = Hub 同机) 重建本地橱窗用的,不是「上传」。权威依据:
待办事宜/FlintHub_发布流程手册.md§12.5 + 红线表 #8;明细见
.workbuddy-ai/memory/REF_发布流程.md§A.1.8 ⑤ / §A.1.9 ③。
14. 建议开发阶段划分
| 阶段 | 内容 | 产出 | 验证方式 |
|---|---|---|---|
| P1 骨架 | 目录 + plugin.json + Plugin.php(3 表 DDL + 生命周期 + class_alias + budget)+ 4 个 hook 骨架 + 三语空包 | 可安装/可激活/可卸载 | 激活后建表成功;plugin_case_lint.py 通过;卸载后无孤儿表 |
| P2 前台改名(password 模式) | FrontController + front.php + 校验/唯一性/写入/缓存/审计 | 密码模式端到端可用 | 探针跑:正常改名、重名、非法名、错密码、限频、并发抢先 |
| P3 三选一验证 + 邮件 | uc_requests 发码/验码 + send-code 路由 + 邮件模板 + SMTP 降级 | 三种模式可用 | 探针跑:发码限频、码过期、超尝试次数、SMTP 未配降级 |
| P4 后台管理 | AdminController(搜索/改名/设置)+ admin.php/admin_settings.php + Tab | 管理员改名 + 设置保存 | HTTP 冒烟(flinthub-admin-http-smoke):路由 200/302、CSRF 负例、设置回读 DB + 缓存 |
| P5 收尾与发布 | 说明.md + 清理测试数据 + 4 个自检 + 出包 | 可分发包 | --dry-run 门禁全绿 + 出包后 --type=index |
每个阶段结束都跑一次「业务逻辑探针」(CLI 或 HTTP),界面观感由站长自测(分工约定)。
15. 风险与待站长确认项
15.1 必须站长拍板(3 项)
| # | 问题 | 选项 | 本方案默认 |
|---|---|---|---|
| 1 | users.username 写入路径 | A 经 Model execute()(推荐)/ B 核心新增 updateUsername() / C 裸 PDO | A(含 method_exists 前瞻) |
| 2 | SMTP 未配置时的行为 | ① 自动降级仅密码 + 双端警示(推荐)/ ② 硬阻断 / ③ 仅提示 | ① |
| 3 | 改名后头像首字母 | 保持旧字母(推荐)/ 重生成 SVG | 保持 |
15.2 建议站长确认(4 项)
| # | 问题 | 本方案默认 |
|---|---|---|
| 4 | 新名字符集是否允许中文?是否允许空格/点号? | 允许中文;不允许空格/点号 |
| 5 | 是否禁止「用户名 = 别人邮箱」? | 禁止 |
| 6 | 后台管理员改名是否也受冷却/扣积分约束? | 不受(管理员豁免) |
| 7 | 扩展 1/2(冷却、扣积分)本期是否要启用? | 本期只留字段,不启用 |
15.3 已知风险
| 风险 | 影响 | 缓解 |
|---|---|---|
| 核心注册规则宽松 → 存量用户名可能不合插件规则 | 只影响新名,不回溯 | 规则只作用于改名;文档写明 |
Model::execute() 返回值语义需核实 | 写入成功判定可能误判 | §4.5 已改为「回读校验」而非依赖返回值 |
核心 settings 无删除接口(需核实) | 卸载可能留空串 key | 实现时 grep 确认;必要时按既有插件做法 |
| 并发改名同一用户名 | 唯一性预检通过但写入撞车 | applyUsernameChange() 捕获 UNIQUE 违约 → 返回 taken |
| 跨库事务(扩展 2 积分) | 无法原子 | 补偿式:先扣后写,失败退回 |
前台路由 /account/... 与将来核心新增同名路由 | 冲突 | 路由注册顺序 + 文档记录;已避让现有真实目录 |
附:本方案已完成的事实核查清单(可复现)
# 需求①校准:mention_user_id 全仓不存在
grep -rn "mention_user_id" app/ plugins/ cli/ # → 零命中
# 需求②校准:核心注册无用户名字符规则
sed -n '215,222p' app/Controllers/AuthController.php # → 仅 trim + ≤64 截断
sed -n '489,500p' app/Models/User.php # → create() 只 password_hash
# 改名能力:users.username 只有两处写
grep -rn "username" app/Models/User.php | grep -iE "insert|update|set" # → create / updateByAdmin(不含 username)
# 核心自己怎么写 users 表(路径 A 的先例)
sed -n '40,53p' app/Controllers/ProfileController.php
# Model 白名单
sed -n '18p' app/Models/User.php # → $fillable = ['last_login']
# 唯一约束
sed -n '300,328p' app/SplitDB/Schema.php # → username TEXT UNIQUE NOT NULL
# 分类白名单
sed -n '22,23p' app/Controllers/Admin/PluginController.php
# 图标名
grep -cE "^ *'user-pen' *=>" app/Core/TemplateCompiler.php # → 1
# 限频阈值硬编码清单(插件加不进去 → 用自己的键)
sed -n '151,163p' app/Controllers/Admin/SettingsController.php
# 站点根真实目录(路由避让)
ls -d */
# 钩子触发点(行号会漂移,引用前复核)
grep -rn "nav_user_menu_items" app/Views/layouts/main.php本方案为规划文档,未改动任何代码。评审通过后按 §14 分阶段落地。