Compare commits

...
2 Commits
Author SHA1 Message Date
kate522 800baca0ca feat(admin): rework download logs page columns and selection
- Headers are now checkbox / index / post / user / credits cost / IP / time;
  drop the source index, usage and expiry columns
- Add a check-column before the index: header select-all for multi-select
  plus per-row checkboxes, tokens as row values
- Bulk delete selected tokens behind nonce and capability checks with
  prepared statements, redirect-after-post back to the filtered view
- Index numbers continue across pagination; dead comments removed
2026-09-24 18:29:48 +08:00
kate522 1c1d3f17b7 refactor(admin): remove download config settings page and chain
- Drop the download config menu entry, route and schema from the settings SPA
- Remove the PHP schema section, the server-side alias validation and the
  reserved-alias list that only served that section
- Unregister aksrt_download_config from standalone import/export keys
- Drop the contract tests that locked the removed page; the Download.php
  runtime contracts stay untouched
2026-09-24 18:29:38 +08:00
7 changed files with 115 additions and 174 deletions

No files matched your search

@@ -47,15 +47,5 @@ export const permissionsSection = {
canonicalDomain: 'permissions',
section: 'attachment',
}),
schemaPage({
key: 'permissions-download',
name: 'PermissionsDownload',
label: '下载配置',
route: '/permissions/download',
title: '下载配置',
desc: '下载链接别名、默认使用次数与有效期限规则。',
canonicalDomain: 'permissions',
section: 'download',
}),
],
}
@@ -338,41 +338,4 @@ export const permissionsSchemas = {
],
},
/**
* Download link configuration
*/
download: {
key: 'permissions.download',
title: '下载配置',
icon: 'fa fas fa-download',
description: '下载总开关与链接别名、默认使用次数、有效期限规则。',
defaults: {
enabled: true,
url_alias: 'download',
default_max_uses: 1,
ttl_minutes: 10,
},
groups: [
{
key: 'basic',
title: '下载总开关',
icon: 'fa fas fa-power-off',
fields: [
{ key: 'enabled', label: '启用本站下载链接', type: 'switch', help: '总开关:开启后下载通过本站中转链接;关闭后下载按钮直接使用原始下载地址(直连),下方链接规则全部不生效,旧的中转链接同步失效。' },
],
},
{
key: 'rules',
title: '链接规则',
icon: 'fa fas fa-link',
description: '文件完整送达才计次(HEAD 探测、传输中断不计次);到期或耗尽后访问旧地址返回 410,需重新获取,不会自动续签。',
fields: [
{ key: 'url_alias', label: '下载链接 URL 别名', type: 'input', placeholder: 'download', required: true, dependsOn: { key: 'enabled', value: true }, help: '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效。' },
{ key: 'default_max_uses', label: '下载链接默认使用次数', type: 'number', min: 1, max: 100, dependsOn: { key: 'enabled', value: true }, help: '单个链接可完成下载的次数;耗尽后链接返回 410,需重新获取。' },
{ key: 'ttl_minutes', label: '下载链接默认有效期限(分钟)', type: 'number', min: 1, max: 1440, dependsOn: { key: 'enabled', value: true }, help: '链接签发后超过该时长即失效并返回 410,不自动续期。' },
],
},
],
},
};
+115 -31
View File
@@ -11,6 +11,10 @@ class PayDownloadLogs {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_download_logs';
public const NONCE = 'aksrt_download_logs_bulk';
/** 单次批量删除的令牌上限,与列表每页条数量级一致,防止超大 IN 查询。 */
private const BULK_DELETE_LIMIT = 100;
private static $instance = null;
@@ -22,13 +26,28 @@ class PayDownloadLogs {
}
public function init(): void {
// Read-only.
// 页面动作(批量删除)在 render_page() 渲染前就地处理,无需额外钩子。
}
public function render_page(): void {
$this->ensure_cap();
if ($this->is_bulk_delete_request()) {
$this->handle_bulk_delete();
}
echo '<div class="wrap"><h1>' . esc_html__('下载记录', 'aksrt') . '</h1>';
if (isset($_GET['dl_deleted'])) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(sprintf(
/* translators: %d: deleted record count */
__('已删除 %d 条下载记录。', 'aksrt'),
max(0, (int) $_GET['dl_deleted'])
)) . '</p></div>';
} elseif (isset($_GET['dl_empty'])) {
echo '<div class="notice notice-error"><p>' . esc_html__('未选择需要删除的记录。', 'aksrt') . '</p></div>';
}
if (!class_exists('\AKSRT\Core\DBMigrator')) {
echo '<div class="notice notice-error"><p>' . esc_html__('数据库服务不可用。', 'aksrt') . '</p></div></div>';
return;
@@ -64,55 +83,52 @@ class PayDownloadLogs {
echo '<label>' . esc_html__('文章 ID', 'aksrt') . ' <input type="number" min="0" name="post_id" value="' . esc_attr($post_id > 0 ? (string) $post_id : '') . '" class="small-text" /></label> ';
echo '<label>' . esc_html__('用户 ID', 'aksrt') . ' <input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" class="small-text" /></label> ';
echo '<label><input type="checkbox" name="used" value="1" ' . checked($only_used, true, false) . ' /> ' . esc_html__('仅已使用', 'aksrt') . '</label> ';
// When unchecked, pass used=0 explicitly.
if (!$only_used) {
// no-op; checkbox not checked
}
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo ' <a class="button" href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG)) . '">' . esc_html__('重置', 'aksrt') . '</a>';
echo '</form>';
// Fix: unchecked checkbox means used filter off — re-read without default if form submitted with no used.
// Already handled via isset($_GET['used']).
// 批量操作(多选 / 单选)包裹列表;删除成功后重定向回当前筛选视图。
echo '<form method="post" action="' . esc_url($this->current_url()) . '">';
wp_nonce_field(self::NONCE);
echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
echo '<label for="bulk-action-selector-top" class="screen-reader-text">' . esc_html__('选择批量操作', 'aksrt') . '</label>';
echo '<select name="bulk_action" id="bulk-action-selector-top">';
echo '<option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option>';
echo '<option value="delete">' . esc_html__('删除', 'aksrt') . '</option>';
echo '</select>';
echo '<input type="submit" class="button action" value="' . esc_attr__('应用', 'aksrt') . '">';
echo '</div><br class="clear"></div>';
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<td id="cb" class="manage-column column-cb check-column" style="width:2%;"><label class="screen-reader-text" for="cb-select-all-1">' . esc_html__('全选', 'aksrt') . '</label><input id="cb-select-all-1" type="checkbox" onclick="document.querySelectorAll(\'tbody [name=&quot;dl_token[]&quot;]\').forEach(c=>c.checked=this.checked)"></td>';
echo '<th>' . esc_html__('序号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('文章', 'aksrt') . '</th>';
echo '<th>' . esc_html__('源序号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('积分消耗', 'aksrt') . '</th>';
echo '<th>' . esc_html__('使用', 'aksrt') . '</th>';
echo '<th>' . esc_html__('过期', 'aksrt') . '</th>';
echo '<th>' . esc_html__('IP', 'aksrt') . '</th>';
echo '<th>' . esc_html__('时间', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo '<tr><td colspan="8">' . esc_html__('暂无下载记录。', 'aksrt') . '</td></tr>';
echo '<tr><td colspan="7">' . esc_html__('暂无下载记录。', 'aksrt') . '</td></tr>';
}
foreach ($items as $row) {
$uid = (int) ($row['user_id'] ?? 0);
$pid = (int) ($row['post_id'] ?? 0);
$user = $uid > 0 ? get_userdata($uid) : null;
$seq_offset = ($page_num - 1) * $per_page;
foreach ($items as $i => $row) {
$uid = (int) ($row['user_id'] ?? 0);
$pid = (int) ($row['post_id'] ?? 0);
$token = (string) ($row['token'] ?? '');
$user = $uid > 0 ? get_userdata($uid) : null;
$title = $pid > 0 ? get_the_title($pid) : '';
if ($title === '') {
$title = $pid > 0 ? sprintf(__('(已删除 #%d)', 'aksrt'), $pid) : '—';
}
$edit = $pid > 0 ? get_edit_post_link($pid, 'raw') : '';
$used = (int) ($row['used_count'] ?? 0);
$max = (int) ($row['max_uses'] ?? 1);
echo '<tr>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '<td>';
if ($user) {
echo esc_html($user->display_name . ' (#' . $uid . ')');
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&user_id=' . $uid)) . '">' . esc_html__('仅此用户', 'aksrt') . '</a>';
} else {
echo esc_html((string) $uid);
}
echo '</td>';
echo '<th scope="row" class="check-column"><label class="screen-reader-text" for="cb-select-' . esc_attr($token) . '">' . esc_html__('选择', 'aksrt') . '</label><input id="cb-select-' . esc_attr($token) . '" type="checkbox" name="dl_token[]" value="' . esc_attr($token) . '"></th>';
echo '<td>' . esc_html((string) ($seq_offset + $i + 1)) . '</td>';
echo '<td>';
if ($edit) {
echo '<a href="' . esc_url($edit) . '">' . esc_html($title) . '</a>';
@@ -123,14 +139,21 @@ class PayDownloadLogs {
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $pid)) . '">' . esc_html__('仅此文章', 'aksrt') . '</a>';
}
echo '</td>';
echo '<td>' . esc_html((string) (int) ($row['source_index'] ?? 0)) . '</td>';
echo '<td>';
if ($user) {
echo esc_html($user->display_name . ' (#' . $uid . ')');
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&user_id=' . $uid)) . '">' . esc_html__('仅此用户', 'aksrt') . '</a>';
} else {
echo esc_html((string) $uid);
}
echo '</td>';
echo '<td>' . esc_html((string) (int) ($row['cost'] ?? 0)) . '</td>';
echo '<td>' . esc_html($used . ' / ' . $max) . ($used > 0 ? ' <span class="dashicons dashicons-yes" style="color:green;"></span>' : '') . '</td>';
echo '<td>' . esc_html((string) ($row['expires_at'] ?? '')) . '</td>';
echo '<td><code>' . esc_html((string) ($row['ip_bind'] ?? '')) . '</code></td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
echo '</form>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">';
@@ -147,6 +170,67 @@ class PayDownloadLogs {
echo '</div>';
}
/**
* 当前页面 URL(含筛选参数),用于批量表单回帖与删除后重定向。
*/
private function current_url(): string {
$args = ['page' => self::PAGE_SLUG];
foreach (['post_id', 'user_id', 'used', 'paged'] as $key) {
if (isset($_GET[$key])) {
$args[$key] = wp_unslash($_GET[$key]);
}
}
return add_query_arg($args, admin_url('admin.php'));
}
private function is_bulk_delete_request(): bool {
return (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST')
&& isset($_POST['bulk_action'])
&& ((string) wp_unslash($_POST['bulk_action']) === 'delete');
}
/**
* 批量删除选中的下载令牌记录;完成后重定向回当前筛选视图,避免刷新重复提交。
*/
private function handle_bulk_delete(): void {
check_admin_referer(self::NONCE);
$raw = isset($_POST['dl_token']) ? (array) wp_unslash($_POST['dl_token']) : [];
$tokens = array_values(array_unique(array_filter($raw, static function ($token): bool {
return is_string($token) && preg_match('/^[A-Za-z0-9]{1,64}$/', $token) === 1;
})));
$tokens = array_slice($tokens, 0, self::BULK_DELETE_LIMIT);
if (!$tokens) {
wp_safe_redirect(add_query_arg('dl_empty', '1', $this->current_url()));
exit;
}
$deleted = $this->delete_tokens($tokens);
wp_safe_redirect(add_query_arg('dl_deleted', $deleted, $this->current_url()));
exit;
}
/**
* @param array<int,string> $tokens
*/
private function delete_tokens(array $tokens): int {
global $wpdb;
if (!class_exists('\AKSRT\Core\DBMigrator')) {
return 0;
}
$table = \AKSRT\Core\DBMigrator::table('dl_token');
if ($table === '') {
return 0;
}
$placeholders = implode(',', array_fill(0, count($tokens), '%s'));
$sql = "DELETE FROM {$table} WHERE token IN ({$placeholders})";
return (int) $wpdb->query($wpdb->prepare($sql, $tokens));
}
/**
* @param array{post_id?:int,user_id?:int,only_used?:bool,limit?:int,offset?:int} $args
* @return array{items: array<int,array<string,mixed>>, total:int}
-16
View File
@@ -140,22 +140,6 @@ class SchemaPermissions {
]],
],
],
'download' => [
'key' => 'download',
'title' => '下载配置',
'version' => '1.1.0',
'option_key' => 'aksrt_download_config',
'groups' => [
['key' => 'basic', 'title' => '下载总开关', 'fields' => [
['key' => 'enabled', 'label' => '启用本站下载链接', 'type' => 'switch', 'default' => true, 'help' => '总开关:开启后下载通过本站中转链接(别名、次数、有效期生效);关闭后下载按钮直接使用原始下载地址(直连),下方链接规则全部不生效,旧的中转链接同步失效。'],
]],
['key' => 'rules', 'title' => '链接规则', 'fields' => [
['key' => 'url_alias', 'label' => '下载链接 URL 别名', 'type' => 'input', 'default' => 'download', 'required' => true, 'dependsOn' => ['key' => 'enabled', 'value' => true], 'help' => '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效,需重新获取下载地址。'],
['key' => 'default_max_uses', 'label' => '下载链接默认使用次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'enabled', 'value' => true], 'help' => '单个链接可完成下载的次数;文件完整送达后才计次(HEAD 探测、传输中断不计次)。耗尽后链接返回 410,需重新获取。'],
['key' => 'ttl_minutes', 'label' => '下载链接默认有效期限(分钟)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 1440, 'dependsOn' => ['key' => 'enabled', 'value' => true], 'help' => '链接签发后超过该时长即失效并返回 410,不自动续期,需重新获取。'],
]],
],
],
'moderation' => [
'key' => 'moderation',
'title' => '评论审核',
-25
View File
@@ -301,19 +301,6 @@ class SchemaProvider {
}
}
if ($domain === 'permissions' && $section === 'download') {
$alias = trim((string) ($data['url_alias'] ?? ''));
if ($alias === '') {
$errors[] = __('下载链接 URL 别名不能为空', 'aksrt');
} elseif (strlen($alias) > 48) {
$errors[] = __('下载链接 URL 别名长度不能超过 48 个字符', 'aksrt');
} elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $alias)) {
$errors[] = __('下载链接 URL 别名只能包含英文字母、数字、连字符和下划线,禁止中文、空格和 / \\ ? # & 等特殊符号', 'aksrt');
} elseif (in_array(strtolower($alias), $this->reserved_download_aliases(), true)) {
$errors[] = __('该别名与系统保留路径冲突,请更换其他别名以避免路由 404', 'aksrt');
}
}
return ['valid' => empty($errors), 'errors' => $errors];
}
@@ -443,18 +430,6 @@ class SchemaProvider {
&& empty($parts['fragment']);
}
/**
* Top-level slugs that must never be shadowed by the download route.
*/
private function reserved_download_aliases(): array {
return [
'wp-json', 'wp-admin', 'wp-content', 'wp-includes',
'admin', 'login', 'logout', 'register', 'lostpassword',
'feed', 'search', 'author', 'page', 'comments',
'sitemap', 'sign', 'cgi-bin',
];
}
public function read_settings(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
-1
View File
@@ -36,7 +36,6 @@ class Settings {
public const STANDALONE_OPTION_KEYS = [
'aksrt_captcha_config',
'aksrt_moderation_config',
'aksrt_download_config',
'aksrt_footer_tabbar',
'aksrt_floating_buttons',
'aksrt_credit_settings',
@@ -8,60 +8,6 @@ const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('download settings section exists on both schema sides with matching defaults', () => {
const php = read('src/Admin/Schema/SchemaPermissions.php');
const vue = read('assets/admin/src/schema/sections/permissions/index.js');
// PHP side is the authoritative contract.
assert.match(php, /'download' => \[/);
assert.match(php, /'key'\s+=> 'download'/);
assert.match(php, /'option_key' => 'aksrt_download_config'/);
// Field parity: same four keys with the same defaults on both ends.
const fields = [
['enabled', 'true', 'enabled: true'],
['url_alias', "'download'", "url_alias: 'download'"],
['default_max_uses', '1', 'default_max_uses: 1'],
['ttl_minutes', '10', 'ttl_minutes: 10'],
];
for (const [key, phpDefault, vueDefault] of fields) {
const phpField = new RegExp(`'key' => '${key}'[^\\]]*'default' => ${phpDefault}`);
assert.match(php, phpField, `PHP default mismatch for ${key}`);
assert.match(vue, new RegExp(`${vueDefault}`), `Vue default mismatch for ${key}`);
}
// The link rules depend on the master switch on both ends.
assert.match(php, /'title' => '下载总开关'/);
assert.match(php, /'dependsOn' => \['key' => 'enabled', 'value' => true\]/);
assert.match(vue, /dependsOn: \{ key: 'enabled', value: true \}/);
// Vue mirrors labels so admins see the same wording as validation errors.
assert.match(vue, /download: \{/);
assert.match(vue, /下载链接 URL 别名/);
assert.match(vue, /下载链接默认使用次数/);
assert.match(vue, /下载链接默认有效期限(分钟)/);
});
test('the download settings page is registered under the permissions menu', () => {
const menu = read('assets/admin/src/constants/sections/permissions.js');
assert.match(menu, /schemaPage\(\{\s*key: 'permissions-download'/);
assert.match(menu, /route: '\/permissions\/download'/);
assert.match(menu, /canonicalDomain: 'permissions',\s*section: 'download',/);
const settings = read('src/Settings/Settings.php');
assert.match(settings, /'aksrt_download_config'/);
});
test('invalid url aliases are rejected server-side with admin-facing messages', () => {
const provider = read('src/Admin/SchemaProvider.php');
assert.match(provider, /\$domain === 'permissions' && \$section === 'download'/);
assert.match(provider, /URL 别名不能为空/);
assert.match(provider, /\/\^\[A-Za-z0-9_-\]\+\$\//);
assert.match(provider, /禁止中文、空格和 \/ \\\\ \? # & 等特殊符号/);
assert.match(provider, /reserved_download_aliases/);
assert.match(provider, /'wp-json', 'wp-admin', 'wp-content', 'wp-includes'/);
});
test('Download.php derives endpoint, ttl and max uses from configuration', () => {
const dl = read('src/Content/Download.php');