fix(ban): state-aware ban copy and real login failure reasons

- fix(profile): 封号用户的公开主页提示不再沿用「当前账号处于限制状态,
  部分互动能力已关闭」——封号=全面限制(无法登录),该描述与语境不符。
  提示尾句按治理展示态分级:封号→禁封期间无法登录、账号功能全面停用;
  小黑屋→发布、评论等操作已受限制;禁言→暂时无法发表评论。
  UserRuntime 不再烘焙通用默认文案,缺省时由 get_profile_notice 按态取
- fix(auth): 登录失败透传真实原因——REST auth_login 此前将
  Service::login_user 返回的具体错误硬编码为「账号或密码错误」,
  禁封用户登录时 Toast 语境错误;现透传 error 字段,
  friendly_login_error 识别 user_banned 错误码后直接返回禁封文案
  (无法登录 + 解封时间/永久 + 原因)
- fix(auth): 禁封闸口覆盖全部登录路径——此前仅密码登录经
  enforce_login_ban 拦截,短信验证码、公众号扫码、第三方 OAuth 均
  直连 complete_login 可绕过封号限制;现将闸口收敛至 complete_login
  (返回 WP_Error),各调用方中止登录并透出原因;OAuth 重定向新增
  account_banned 错误码,auth.js 与 user-center.js 同步文案映射
- test: 新增登录禁封面契约测试(文案单一来源、Toast 透传、免密闸口、
  统一收口、前端映射),全量 362 项通过
This commit is contained in:
2026-09-22 18:36:09 +08:00
parent f9bc0f69c4
commit 11eda33e58
12 changed files with 169 additions and 21 deletions
@@ -29,6 +29,15 @@ const singleComponents = read('inc/helpers/aksrt-single-components.php')
const banAppeal = read('src/User/BanAppeal.php')
const mainCss = read('assets/css/main.css')
const mainMinCss = read('assets/css/main.min.css')
const userRuntime = read('src/Settings/UserRuntime.php')
const phoneOps = read('src/Admin/UserControllerAuthPhoneOperations.php')
const authService = read('src/Auth/Service.php')
const oauthAccountService = read('src/Auth/OAuthAccountService.php')
const weixinGzh = read('src/Auth/WeixinGzh.php')
const oauthFlow = read('src/Auth/OAuth.php')
const oauthProxy = read('src/Auth/OAuthProxyClient.php')
const authJs = read('assets/js/auth.js')
const userCenterJs = read('assets/js/routes/user-center.js')
test('report REST surface is canonical: submit + users/reports only', () => {
assert.match(userController, /'\/report\/submit'/)
@@ -147,7 +156,10 @@ test('profile governance notice reads from the restriction table', () => {
// 提示框内容契约:state|label|text,label 对齐参考主题公开主页禁封徽章文案。
assert.match(home, /'state'\s*=> \$state/)
assert.match(home, /'label'\s*=>/)
assert.match(home, /'text'\s*=> \(string\) \$settings\['report'\]\['ban_notice_text'\]/)
// 提示尾句分级:封号=全面限制(无法登录),不得沿用“部分互动能力已关闭”。
assert.match(home, /\$text = trim\(\(string\) \(\$settings\['report'\]\['ban_notice_text'\] \?\? ''\)\)/)
assert.match(home, /state_notices\(\)/)
assert.ok(!userRuntime.includes('当前账号处于限制状态,部分互动能力已关闭'), 'generic partial-restriction copy must not be the banned-state default')
// 模板必须消费真实契约键:tone/message 双键曾导致资料卡下方提示框渲染为空。
assert.match(authorHero, /\$notice\['state'\]/)
assert.match(authorHero, /\$notice\['text'\]/)
@@ -163,9 +175,18 @@ test('governance display state is a single source shared by every prompt surface
}
assert.match(restriction, /public function get_display_state\(int \$user_id\): string/)
assert.match(restriction, /public static function state_labels\(\): array/)
assert.match(restriction, /public static function state_notices\(\): array/)
for (const label of ["__('违规已封号', 'aksrt')", "__('小黑屋禁封中', 'aksrt')", "__('禁言中', 'aksrt')"]) {
assert.ok(restriction.includes(label), `missing state label: ${label}`)
}
// 提示尾句分级:封号=全面限制,小黑屋=部分限制,禁言=评论域限制。
for (const notice of [
"__('禁封期间无法登录,账号功能全面停用。', 'aksrt')",
"__('发布、评论等操作已受限制。', 'aksrt')",
"__('暂时无法发表评论。', 'aksrt')",
]) {
assert.ok(restriction.includes(notice), `missing state notice: ${notice}`)
}
assert.match(restriction, /is_banned\(\$user_id\)[\s\S]*?is_suspended\(\$user_id\)[\s\S]*?is_restricted\(\$user_id, self::TYPE_MUTE\)/)
// 请求级缓存:评论列表逐条渲染昵称徽章不得反复查库。
assert.match(userBanHelper, /static \$cache = \[\]/)
@@ -218,6 +239,37 @@ test('report entry hides targets already banned or suspended', () => {
assert.match(authorHero, /aksrt_is_user_ban_restricted\(\$user_id\)/)
})
test('banned users get the ban reason on every login path, not a credentials error', () => {
// 登录禁封文案单一来源(全面限制语义 + 期限 + 原因)。
assert.match(restriction, /public function login_ban_message\(int \$user_id\): string/)
assert.match(restriction, /__\('您的账号已被封禁,禁封期间无法登录。', 'aksrt'\)/)
assert.match(restriction, /new \\WP_Error\('user_banned', \$this->login_ban_message/)
// Toast 透传:REST 层不得把失败原因硬编码为“账号或密码错误”。
assert.match(phoneOps, /\$result\['error'\]/)
assert.ok(
phoneOps.includes("$login_error !== '' ? $login_error : __('账号或密码错误', 'aksrt')"),
'auth_login must pass through the real failure reason'
)
assert.match(authService, /friendly_login_error\(\$user->get_error_message\(\), \(string\) \$user->get_error_code\(\)\)/)
assert.match(authService, /'user_banned' === \$error_code/)
// 短信验证码(免密)登录禁封闸口:封号用户不得经此路径登录。
assert.match(phoneOps, /is_banned\(\(int\) \$user->ID\)/)
assert.match(phoneOps, /login_ban_message\(\(int\) \$user->ID\)/)
// 非密码登录统一收口:complete_login 返回 WP_Error,调用方必须中止。
assert.match(oauthAccountService, /public static function complete_login\(\\WP_User \$user, bool \$remember = true\): \?\\WP_Error/)
assert.match(oauthAccountService, /is_banned\(\(int\) \$user->ID\)/)
assert.match(weixinGzh, /\$login_error = OAuthAccountService::complete_login/)
assert.match(weixinGzh, /error_response\(\$login_error->get_error_message\(\), 403\)/)
assert.match(oauthFlow, /\$login_error = OAuthAccountService::complete_login/)
assert.match(oauthFlow, /redirect_error\('account_banned'/)
assert.match(oauthProxy, /\$login_error = OAuthAccountService::complete_login/)
assert.match(oauthProxy, /redirect_error\('account_banned'/)
// 前端 OAuth 错误映射同步补充 account_banned。
for (const js of [authJs, userCenterJs]) {
assert.match(js, /account_banned: '您的账号已被封禁,禁封期间无法登录'/)
}
})
test('admin report notification links to the real page', () => {
assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban'/)
assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link')