Files
wordpress-theme-aksrt/src/User/Restriction.php
T
kate522 a4af024f32 feat(ban): align ban prompts with the reference theme modals and block page
对照参考主题(Zibll)修正四项偏差,申诉链路从页面整体迁移至弹窗:

- feat(ban): 新增三个 1:1 复刻弹窗(REST /user-ban/{info,appeal,log}-modal):
  查看禁封状态(多彩头 c-gray + 信息行 + 申诉按钮)、禁封申诉(edit-header
  gavel/c-blue + 信息框 + 资料项 data[key] 输入 + 审核中态 + 禁止申诉态)、
  禁封记录(多彩头 c-yellow + 计数 + 逐条卡片);user-ban.js 增加
  data-aksrt-ban-info/appeal/log 委托与 admin-ajax 申诉提交
- feat(ban): 禁封/解封弹窗的「当前用户已有N次禁封记录」静态文案改为可点击
  黄链(N>1),打开禁封记录弹窗;解封视图信息行改复用统一数据源
- refactor(ban): 治理展示态收敛至 Restriction::state_badges()(封号=c-red
  违规已封号 / 小黑屋=c-yellow 小黑屋禁封中 / 禁言=c-gray 禁言中,颜色与
  字段统一管理)+ get_ban_info()(状态/起止时间/原因/说明/申诉权限,
  三弹窗与禁封页共用)
- feat(ban): 登录行为对齐参考主题——移除登录期拦截(enforce_login_ban)
  与 complete_login 禁封闸门,封号用户可正常登录;新增 template_redirect
  前台禁封页(1:1 zib_user_ban_restrict_login/zib_die_page:红盒提示 +
  信息行 + 申诉权限 + 退出登录/申诉按钮,模板 templates/parts/user/ban-block.php)
- feat(profile): 公开主页禁封状态从资料卡下方提示框改为占据身份行元信息位
  的 badg 徽章(参考主题 author_header_identity 语义);用户中心元信息改为
  but 按钮(徽章+箭头)打开查看禁封状态弹窗;评论拒绝态链接同源
- refactor: 移除申诉页面模板(page-ban-appeal.php)及其资产/注册/入队、
  get_profile_notice/profile_notice 链、state_labels/state_notices/
  login_ban_message、UserRuntime 的 public_notice/ban_notice_text 死配置、
  report_settings 死键、无消费方的 aksrt_ban_appeal_status 端点
- feat(ban): BanAppeal::ajax_submit 兼容弹窗 data[key] 表单结构
  (逐项清洗入库,申诉说明取 data['申诉说明'],旧平铺字段保持兼容)
- chore: 重新构建 public.css/public.min.css(三个新弹窗几何与头部样式)
- test: 契约测试重写(徽章元信息位、三弹窗与记录链接、禁封页、允许登录 +
  前台拦截、死代码移除断言),全量 362 项通过
2026-09-22 19:14:02 +08:00

376 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Restriction {
private static $instance = null;
const TYPE_WARN = 'warn';
const TYPE_MUTE = 'mute';
const TYPE_SUSPEND = 'suspend';
const TYPE_BAN = 'ban';
const SCOPE_GLOBAL = 'global';
const SCOPE_COMMENT = 'comment';
const TYPES = [self::TYPE_WARN, self::TYPE_MUTE, self::TYPE_SUSPEND, self::TYPE_BAN];
const SCOPES = [self::SCOPE_GLOBAL, self::SCOPE_COMMENT];
/**
* 治理展示态(与 user-home.css 的 --banned/--restricted/--muted 色调修饰符同名):
* 资料卡提示框、用户中心页头、评论拒绝态、昵称徽章共用同一组键。
*/
const STATE_BANNED = 'banned';
const STATE_RESTRICTED = 'restricted';
const STATE_MUTED = 'muted';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// 禁封用户前台拦截(对齐参考主题 zib_user_ban_restrict_login:
// 封号用户可正常登录,但前台任何页面均展示禁封状态页;禁封中
// 不得拦截登录本身——登录后的落地页即禁封页)。
add_action('template_redirect', [$this, 'restrict_frontend_access'], 1);
// Enforce comment mute
add_filter('pre_comment_on_post', [$this, 'enforce_comment_mute']);
}
/**
* Get the restriction table name.
*/
private function table(): string {
global $wpdb;
return $wpdb->prefix . 'aksrt_user_restriction';
}
/**
* Add a restriction to a user.
*/
public function restrict(int $user_id, string $type, string $reason, string $scope = self::SCOPE_GLOBAL, string $expires_at = '', int $created_by = 0): array {
global $wpdb;
if (!in_array($type, self::TYPES, true)) {
return ['success' => false, 'message' => 'Invalid restriction type.'];
}
if (!in_array($scope, self::SCOPES, true)) {
return ['success' => false, 'message' => 'Invalid scope.'];
}
if ($created_by <= 0) {
$created_by = get_current_user_id();
}
$data = [
'user_id' => $user_id,
'type' => $type,
'reason' => sanitize_textarea_field($reason),
'scope' => $scope,
'expires_at' => $expires_at ? gmdate('Y-m-d H:i:s', strtotime($expires_at)) : null,
'created_by' => $created_by,
'created_at' => current_time('mysql', true),
];
$result = $wpdb->insert($this->table(), $data);
if ($result === false) {
return ['success' => false, 'message' => 'Database error.'];
}
// 封禁立即生效:销毁目标用户全部登录会话
if ($type === self::TYPE_BAN && $user_id > 0) {
\WP_Session_Tokens::get_instance($user_id)->destroy_all();
}
do_action('aksrt_user_restriction_changed', $user_id, $type, 'applied', (string) $data['reason']);
return ['success' => true, 'id' => $wpdb->insert_id];
}
/**
* Remove a restriction by ID.
*/
public function remove_restriction(int $restriction_id): bool {
global $wpdb;
$restriction = $this->get_restriction($restriction_id);
if (!$restriction) return false;
$result = $wpdb->delete($this->table(), ['id' => $restriction_id]);
if ($result) {
do_action('aksrt_user_restriction_changed', (int) $restriction['user_id'], (string) ($restriction['type'] ?? ''), 'removed', '');
}
return (bool) $result;
}
/**
* 解除用户生效中的限制(可选按类型过滤),用于申诉通过或举报工单"解除限制"。
*
* @param int $user_id 用户 ID
* @param array $types 要解除的类型,默认解除 ban/suspend/mute(保留 warn 警告记录)
* @return int 解除的记录数
*/
public function lift_user_restriction(int $user_id, array $types = [self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE]): int {
global $wpdb;
$types = array_values(array_intersect($types, self::TYPES));
if ($user_id <= 0 || empty($types)) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($types), '%s'));
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) AND type IN ({$placeholders})",
array_merge([$user_id, current_time('mysql', true)], $types)
));
if ($deleted > 0) {
do_action('aksrt_user_restriction_changed', $user_id, implode(',', $types), 'lifted', '');
}
return (int) $deleted;
}
/**
* Get a single restriction by ID.
*/
public function get_restriction(int $restriction_id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$restriction_id
), ARRAY_A);
return $row ?: null;
}
/**
* Get all active restrictions for a user.
*/
public function get_user_restrictions(int $user_id, bool $active_only = true): array {
global $wpdb;
$now = current_time('mysql', true);
if ($active_only) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC",
$user_id, $now
), ARRAY_A);
} else {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d ORDER BY created_at DESC",
$user_id
), ARRAY_A);
}
return $rows ?: [];
}
/**
* Check if a user has an active restriction of a given type and scope.
*/
public function is_restricted(int $user_id, string $type = '', string $scope = ''): bool {
$restrictions = $this->get_user_restrictions($user_id, true);
foreach ($restrictions as $r) {
if ($type && $r['type'] !== $type) continue;
if ($scope && $r['scope'] !== $scope && $r['scope'] !== self::SCOPE_GLOBAL) continue;
return true;
}
return false;
}
/**
* Check if a user is banned (global).
*/
public function is_banned(int $user_id): bool {
return $this->is_restricted($user_id, self::TYPE_BAN, self::SCOPE_GLOBAL);
}
/**
* 治理展示态(空串 = 无治理状态;ban 优先于 suspend,对齐后台单一状态语义)。
*
* 供公开主页身份行徽章、用户中心元信息、评论拒绝态、昵称徽章共用,
* 徽章字段(文案/颜色/图标)见 state_badges()。
*/
public function get_display_state(int $user_id): string {
if ($user_id <= 0) {
return '';
}
if ($this->is_banned($user_id)) {
return self::STATE_BANNED;
}
if ($this->is_suspended($user_id)) {
return self::STATE_RESTRICTED;
}
if ($this->is_restricted($user_id, self::TYPE_MUTE)) {
return self::STATE_MUTED;
}
return '';
}
/**
* 治理展示态徽章字段(颜色 / 文案 / 图标统一管理,全前端提示面共用):
* 封号(ban) → c-red 违规已封号(最严重:无法登录);
* 小黑屋(suspend) → c-yellow 小黑屋禁封中(部分限制);
* 禁言(mute) → c-gray 禁言中(评论域限制)。
* 对齐参考主题 zib_ban_author_header_identity_filter 的红/默认双色语义,
* 禁言为 AKSRT 扩展状态。
*
* @return array<string,array{label:string,tone:string,icon:string}> 展示态 => 徽章字段
*/
public static function state_badges(): array {
return [
self::STATE_BANNED => ['label' => __('违规已封号', 'aksrt'), 'tone' => 'c-red', 'icon' => 'ban'],
self::STATE_RESTRICTED => ['label' => __('小黑屋禁封中', 'aksrt'), 'tone' => 'c-yellow', 'icon' => 'ban'],
self::STATE_MUTED => ['label' => __('禁言中', 'aksrt'), 'tone' => 'c-gray', 'icon' => 'comment-slash'],
];
}
/**
* 生效禁封信息行(查看禁封状态弹窗 / 申诉弹窗 / 登录后禁封页 / 管理端
* 禁封解封弹窗共用;对齐参考主题 zib_get_ban_info_modal 的字段集)。
*
* @return array<string,mixed>|array{} 空数组表示无生效禁封/小黑屋。
*/
public function get_ban_info(int $user_id): array {
if ($user_id <= 0) {
return [];
}
$ban = $this->get_active_ban($user_id) ?: $this->get_active_suspend($user_id);
if (!is_array($ban)) {
return [];
}
$type = (string) ($ban['type'] ?? '');
$raw = (string) ($ban['reason'] ?? '');
$reason = $raw;
$desc = '';
$marker = "\n" . __('禁封说明:', 'aksrt');
$pos = strpos($raw, $marker);
if (false !== $pos) {
$reason = substr($raw, 0, $pos);
$desc = trim(substr($raw, $pos + strlen($marker)));
}
$no_appeal = get_user_meta($user_id, '_aksrt_no_appeal', true);
$no_appeal_desc = is_array($no_appeal) ? trim((string) ($no_appeal['desc'] ?? '')) : '';
return [
'type' => $type,
'status' => $type === self::TYPE_BAN ? __('禁封中(禁止登录)', 'aksrt') : __('小黑屋禁封中', 'aksrt'),
'start' => (string) ($ban['created_at'] ?? ''),
'end' => !empty($ban['expires_at']) ? (string) $ban['expires_at'] : __('永久', 'aksrt'),
'reason' => trim($reason),
'desc' => $desc,
'no_appeal' => $no_appeal_desc !== '',
'no_appeal_desc' => $no_appeal_desc,
];
}
/**
* Check if a user is in 小黑屋 (active global suspend).
*
* 对齐参考主题语义:小黑屋用户失去所有发布权限(发帖、评论等)及大部分操作权限。
*/
public function is_suspended(int $user_id): bool {
return $this->is_restricted($user_id, self::TYPE_SUSPEND, self::SCOPE_GLOBAL);
}
/**
* Get the active suspend restriction for a user (if any).
*/
public function get_active_suspend(int $user_id): ?array {
$restrictions = $this->get_user_restrictions($user_id, true);
foreach ($restrictions as $r) {
if ($r['type'] === self::TYPE_SUSPEND && $r['scope'] === self::SCOPE_GLOBAL) {
return $r;
}
}
return null;
}
/**
* Get the active ban restriction for a user (if any).
*/
public function get_active_ban(int $user_id): ?array {
$restrictions = $this->get_user_restrictions($user_id, true);
foreach ($restrictions as $r) {
if ($r['type'] === self::TYPE_BAN && $r['scope'] === self::SCOPE_GLOBAL) {
return $r;
}
}
return null;
}
/**
* 前台禁封拦截页(1:1 复刻参考主题 zib_user_ban_restrict_login /
* zib_die_page):封号(ban)用户登录后访问任何前台页面,均展示禁封状态页
* (红盒提示 + 禁封信息行 + 申诉权限 + 退出登录/申诉按钮)。
* 小黑屋/禁言为部分限制,可正常浏览站点,不走此拦截。
*/
public function restrict_frontend_access(): void {
if (is_admin() || wp_doing_cron() || wp_doing_ajax()) {
return;
}
$user_id = get_current_user_id();
if ($user_id <= 0 || !$this->is_banned($user_id)) {
return;
}
if (!function_exists('aksrt_render_user_ban_block_page')) {
require_once AKSRT_DIR . '/inc/helpers/user-ban.php';
}
aksrt_render_user_ban_block_page($user_id);
}
/**
* Enforce comment restriction on submission (mute / 小黑屋).
*
* 禁言(mute,评论域)与小黑屋(suspend,全域)都会拦截评论——
* 对齐参考主题:小黑屋用户失去评论等发布权限。
*/
public function enforce_comment_mute($post_id) {
$user_id = get_current_user_id();
if ($user_id <= 0) {
return $post_id;
}
if ($this->is_restricted($user_id, self::TYPE_MUTE, self::SCOPE_COMMENT)) {
wp_die('您已被限制评论。', '评论限制', ['response' => 403]);
}
if ($this->is_suspended($user_id)) {
$suspend = $this->get_active_suspend($user_id);
$message = '您已被关入小黑屋,暂时无法评论。';
if (!empty($suspend['reason'])) {
$message .= '原因:' . $suspend['reason'];
}
wp_die($message, '发布限制', ['response' => 403]);
}
return $post_id;
}
/**
* Get restriction stats for admin overview.
*/
public function get_stats(): array {
global $wpdb;
$now = current_time('mysql', true);
$table = $this->table();
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
$active = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE expires_at IS NULL OR expires_at > %s", $now
));
$banned = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE type = %s AND (expires_at IS NULL OR expires_at > %s)",
self::TYPE_BAN, $now
));
return ['total' => $total, 'active' => $active, 'banned' => $banned];
}
}