[开发]「用户改名」插件 开发规划方案

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

本文由 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 分类identityapp/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):

  1. 写 users.username 走哪条路 —— 核心没有 User::updateUsername(),三条候选路径,本方案推荐「经核心 Model 的 execute()」(核心自己在 ProfileController.php:49 就是这么写 users 表的)。
  2. SMTP 未配置时怎么降级 —— 硬阻断 / 自动降级到仅密码 / 只警示?需求里给了两个方向(「提示站长」或「降级到仅密码」),需二选一。
  3. 改名后头像首字母 —— 保持旧首字母(零风险)还是重生成 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:220if (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.3plugin_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=admin

2.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"]
}

字段依据:

字段取值依据 / 注意
activatedfalse交付时未启用;站长装好后在后台启用
categoryidentityapp/Controllers/Admin/PluginController.php:22-23 白名单:content/community/identity/economy/operation/governance/tools/integration/entertainment/other。语义上「身份管理」最贴(对照:user_profile/user_level/medal 都是 identity)
iconuser-pen已实测在 TemplateCompiler::$map 内。⚠️ 写前必跑 grep -cE "^ 'user-pen' =>" app/Core/TemplateCompiler.php(必须带 ^ * 前缀,否则缩进两空格会假阴性);商店会从本字段回读图标(§11.35 ④)
hooks4 个见下
permissions["route:admin","system:settings"]§3 强制表:注册后台路由 → route:admin;activate()/设置页写全局设置 → system:settings。不声明即 DENY

hooks 清单与触发点(规范 §9.1,⚠️ 行号会漂移,引用前 grep 复核):

钩子触发点用途陷阱
init_afterapp/init.php:103建表兜底(§11.15)轻量:先判 marker 文件短路,别每页开库
route_registerindex.php:295注册前台 3 条路由先 if (!isset($router)) return;
admin_route_registerindex.php:276注册后台路由路径不写 /admin 前缀(§11.22)
nav_user_menu_itemsapp/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_after0用 marker 文件短路(is_file()),不建表就不开库(§11.4-27)
route_register0只注册路由
admin_route_register0只注册路由
nav_user_menu_items0只读 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.sqlitenew PDO(...) 或 DBFactory 直连—❌ 不推荐:绕过 Model、易踩 WAL 旧快照、且是 §11.8 明确点名的反模式

关于规范 §11.8 第 1 条「禁止直写核心库用户/积分表」的适用性:

该条的两个举例是 group_id(→ User::updateGroupId())与 points(→ Points::deduct()/award())—— 即「已有专属方法的字段不得绕过」。而 username 核心没有任何专属方法,且用户需求明确授权「插件只能 UPDATE,不能 ALTER」。

本方案结论:走路径 A,并做两件事降低耦合:

  1. 写入逻辑只在一处(Plugin::applyUsernameChange()),未来核心若提供 updateUsername(),只需在这一个函数里切换实现;
  2. 用 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()(): string3 张表 + 索引
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 验证码生命周期

  1. 发码(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,...];失败要回显、不静默。
  1. 校验:哈希比对(hash_equals())+ 未过期 + attempts < uc_code_max_attempts;每次失败原子自增 attempts。
  2. 成功:置 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 测试发送按钮(可选)。


轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 2 | Replies 0

All Replies (0)

No replies yet

Please Log in