进行了一些优化

This commit is contained in:
2026-08-27 03:27:26 +08:00
parent ad128ef94e
commit 50c5769d01
8 changed files with 340 additions and 71 deletions
@@ -0,0 +1,53 @@
## 现状结论(影响方案的既有事实)
- 下载系统集中在 `src/Content/Download.php`:路由前缀硬编码为常量 `ENDPOINT='aksrt-dl'`(同时用于 rewrite 正则 :68 和 URL 拼接 :226)、TTL 硬编码 `TOKEN_TTL=600`(:12)、`max_uses` 固定写死 1(:422)、`expires_in` 返回固定值(:242)。
- 扣次在"点击兑换"时发生(`Download.php:286-292`),无 HEAD 特判、无断开检测,需按需求重构。
- 过期/耗尽已是 `fail_410()`(HTTP 410 + wp_die 提示"回到资源页重新获取"),**不自动生成新链接** —— 该行为现状已满足,保持不变。
- `aksest_dl_token` 表(`src/Core/DBMigratorPart2.php:133-150`)的 token 列已是 `PRIMARY KEY`(即唯一索引),重复密钥不可能入库;需补的是 issue_token 插入失败时的换号重试。
- 设置体系是双端 schema:PHP 权威在 `src/Admin/Schema/*.php`,Vue 镜像在 `assets/admin/src/schema/sections/**`,菜单在 `assets/admin/src/constants/sections/permissions.js`,REST 用泛型路由自动生效。"功能&权限"= SPA 内 permissions 域。
- `Auth\Router::maybe_flush_rewrite_rules()`(:78-84) 已有"版本标记式 flush_rewrite_rules()"先例,可直接套用。
## 一、新增设置分区(permissions 域 → "下载配置")
存储为独立 option `aksrt_download_config`(沿用 captcha/moderation 的 standalone 模式),三个字段:
| key | 类型 | 默认 | 校验 |
|---|---|---|---|
| `url_alias` | input | `download` | 必填;trim 后非空;仅允许 `[A-Za-z0-9_-]`(禁中文/空格// \ ? # & 等);≤48 字符;拒绝保留字黑名单(wp-json、wp-admin、wp-content、wp-includes、admin、login、logout、register、lostpassword、feed、search、author、page、comments、sitemap、sign 等,防路由冲突/404) |
| `default_max_uses` | number | `1` | min 1 / max 100 |
| `ttl_minutes` | number | `10` | min 1 / max 1440 |
修改文件:
1. **`src/Admin/Schema/SchemaPermissions.php`**:新增 `download` section(version 1.0.0,option_key `aksrt_download_config`,单组 basic 三字段,含中文标签/help 说明"完整下载才计次、到期或耗尽后 410 需重新获取")。
2. **`src/Admin/SchemaProvider.php` `validate()`**:仿照 payment/withdrawal 特例先例(:269-292),对 `permissions/download` 增加中文错误消息校验块(空值/非法字符/过长/保留字)——422 错误经前端 `message.error('保存失败: …')` 弹窗呈现,满足"后台弹窗提示"。
3. **`src/Settings/Settings.php`**:`STANDALONE_OPTION_KEYS` 追加 `aksrt_download_config`(导入导出完整性 + 缓存失效覆盖)。
4. **`assets/admin/src/schema/sections/permissions/index.js`**:镜像新增 `download` 分区(defaults 与 PHP 完全一致)。
5. **`assets/admin/src/constants/sections/permissions.js`**:追加子页 `schemaPage({ key:'permissions-download', route:'/permissions/download', label:'下载配置', section:'download' })`,置于附件配置之后。泛型 REST 路由与 SchemaPage 无需改动。
## 二、Download.php 改造(`src/Content/Download.php`)
1. **动态前缀**:删掉 `const ENDPOINT`;新增私有 `endpoint()` 读配置(无效/被污染时回落 `download`);`register_rewrite()` 用 `preg_quote($endpoint)` 拼正则(:66-72);URL 拼接(:226)同源。
2. **别名变更刷新路由**:仿 `Auth\Router` 版本标记法——`init` 尾部挂 `maybe_flush_rewrite()`(priority 99):比较 `AKSRT_VERSION + 当前别名` 与 option `aksrt_dl_rewrite_version`,不一致则 `flush_rewrite_rules(false)` 并回写。导入/重置/直接改库都覆盖,无双路兼容逻辑。
3. **动态 TTL / 次数**:删掉 `TOKEN_TTL` 常量引用;`issue_token()` 写入 `max_uses = 配置.default_max_uses`、`expires_at = now + 配置.ttl_minutes*60`;`rest_request` 的 `expires_in` 改为动态秒数。配置读取做 clamp 兜底(别名非法回落 download、次数 1..100、分钟 1..1440)。
4. **完整下载才扣次**:
- 将扣次(UPDATE used_count)与文章计数 `_aksrt_res_download_count`(:286-300) 移到交付成功之后的新方法 `consume_token()`;UPDATE 加原子条件 `WHERE token=%s AND used_count < max_uses`,并发下计数封顶不超发。
- `stream_local_file()` 改为返回是否完整送达:输出循环中 `connection_aborted()` 为真立即中断并返回 false;收尾再核验一次。仅当完整送达且非 HEAD 才 `consume_token()`——网络波动中断不消耗次数,用户可重新点同一链接续下。
- **HEAD 探测**:`REQUEST_METHOD === 'HEAD'` 时照常走校验/输出 headers 但不扣次。
- **外链 302 源**(远端主机无法感知"是否下完"):把 302 交接视为一次交付,重定向发出后扣次(HEAD 时同样跳过)。此为唯一可实现语义,计划中明示。
- 过期/耗尽仍走现有 `fail_410()`(改动前后行为一致)。
5. **密钥冲突重试**:`issue_token()` 插入失败时用全新随机串重试至多 3 次(PRIMARY KEY 已保证唯一,此为极低概率兜底;表缺失仍在入口提前返回 WP_Error 不进循环)。
## 三、清理与文档
- 删除不再使用的 `Download::ENDPOINT` / `Download::TOKEN_TTL` 常量及全部残留引用(实现时全库 grep 复核;已知其余命中仅为 CSS 类名巧合,不动)。
- 更新 `CLAUDE.md:132` 文档行:rewrite 前缀改为"可配置别名(默认 download)",并补充"完整送达才计次"契约。
- 不写任何迁移/双路:现有表结构不动,存量过期 token 由每日 cron 自然清理。
- **关于唯一索引**:PRIMARY KEY(token) 本身即唯一索引且已存在(不加冗余 UNIQUE KEY,也不做表结构变更——与"不写迁移"一致),以插入重试落实需求的防冲突意图。
## 四、验证
1. 新增 Node 契约测试 `tests/widget-system/download-config-contract.test.cjs`:断言双端 schema 的字段/默认值/option_key 一致、菜单子项注册、Download.php 关键契约(动态 endpoint、流式完成后才条件 UPDATE 计次、HEAD 跳过、410 不自动续签、无 ENDPOINT/TOKEN_TTL 常量残留)。
2. `node --test tests/widget-system/download-config-contract.test.cjs` + 跑现有相关契约测试确认无回归。
3. `php -l` 全部改动的 PHP 文件;`php tools/audit-admin-contracts.php` 通过双端合同审计。
4. `cd assets/admin && npm ci && npm run build`,确认 build 产物完整(PHP 端有 chunk 缺失探测)。
5. 视觉不做真实验收(按约定跳过 Docker 对比环境)。
@@ -1,28 +1,82 @@
import { renderFaIcon, schemaPage } from './helpers'
const pages = [
['header-nav', '顶部导航栏'],
['header-components', '顶部多功能组件'],
['footer', '底部页脚'],
['homepage', '首页配置'],
['category', '分类页面'],
['tag', '标签页面'],
['user-home', '用户主页'],
['other', '其它页面'],
]
export const pageDisplaySection = {
key: 'page-display',
label: '页面&显示',
icon: renderFaIcon('fa fas fa-desktop'),
route: '/page-display',
children: pages.map(([section, label]) => schemaPage({
key: `page-display-${section}`,
name: `PageDisplay${section.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('')}`,
label,
route: `/page-display/${section}`,
title: label,
canonicalDomain: 'page_display',
section,
})),
children: [
schemaPage({
key: 'page-display-header-nav',
name: 'PageDisplayHeaderNav',
label: '顶部导航栏',
route: '/page-display/header-nav',
title: '顶部导航栏',
canonicalDomain: 'page_display',
section: 'header-nav',
}),
schemaPage({
key: 'page-display-header-components',
name: 'PageDisplayHeaderComponents',
label: '顶部多功能组件',
route: '/page-display/header-components',
title: '顶部多功能组件',
canonicalDomain: 'page_display',
section: 'header-components',
}),
schemaPage({
key: 'page-display-footer',
name: 'PageDisplayFooter',
label: '底部页脚',
route: '/page-display/footer',
title: '底部页脚',
canonicalDomain: 'page_display',
section: 'footer',
}),
schemaPage({
key: 'page-display-homepage',
name: 'PageDisplayHomepage',
label: '首页配置',
route: '/page-display/homepage',
title: '首页配置',
canonicalDomain: 'page_display',
section: 'homepage',
}),
schemaPage({
key: 'page-display-category',
name: 'PageDisplayCategory',
label: '分类页面',
route: '/page-display/category',
title: '分类页面',
canonicalDomain: 'page_display',
section: 'category',
}),
schemaPage({
key: 'page-display-tag',
name: 'PageDisplayTag',
label: '标签页面',
route: '/page-display/tag',
title: '标签页面',
canonicalDomain: 'page_display',
section: 'tag',
}),
schemaPage({
key: 'page-display-user-home',
name: 'PageDisplayUserHome',
label: '用户主页',
route: '/page-display/user-home',
title: '用户主页',
canonicalDomain: 'page_display',
section: 'user-home',
}),
schemaPage({
key: 'page-display-other',
name: 'PageDisplayOther',
label: '其它页面',
route: '/page-display/other',
title: '其它页面',
canonicalDomain: 'page_display',
section: 'other',
}),
],
}
@@ -48,5 +48,15 @@ export const permissionsSection = {
canonicalDomain: 'permissions',
section: 'attachment',
}),
schemaPage({
key: 'permissions-download',
name: 'PermissionsDownload',
label: '下载配置',
route: '/permissions/download',
title: '下载配置',
desc: '下载链接别名、默认使用次数与有效期限规则。',
canonicalDomain: 'permissions',
section: 'download',
}),
],
}
@@ -251,4 +251,32 @@ export const permissionsSchemas = {
],
},
/**
* Download link configuration
*/
download: {
key: 'permissions.download',
title: '下载配置',
icon: 'fa fas fa-download',
description: '下载链接别名、默认使用次数与有效期限规则。',
defaults: {
url_alias: 'download',
default_max_uses: 1,
ttl_minutes: 10,
},
groups: [
{
key: 'basic',
title: '链接规则',
icon: 'fa fas fa-link',
description: '文件完整送达才计次(HEAD 探测、传输中断不计次);到期或耗尽后访问旧地址返回 410,需重新获取,不会自动续签。',
fields: [
{ key: 'url_alias', label: '下载链接 URL 别名', type: 'input', placeholder: 'download', required: true, help: '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效。' },
{ key: 'default_max_uses', label: '下载链接默认使用次数', type: 'number', min: 1, max: 100, help: '单个链接可完成下载的次数;耗尽后链接返回 410,需重新获取。' },
{ key: 'ttl_minutes', label: '下载链接默认有效期限(分钟)', type: 'number', min: 1, max: 1440, help: '链接签发后超过该时长即失效并返回 410,不自动续期。' },
],
},
],
},
};
+13
View File
@@ -73,6 +73,19 @@ class SchemaPermissions {
]],
],
],
'download' => [
'key' => 'download',
'title' => '下载配置',
'version' => '1.0.0',
'option_key' => 'aksrt_download_config',
'groups' => [
['key' => 'basic', 'title' => '链接规则', 'fields' => [
['key' => 'url_alias', 'label' => '下载链接 URL 别名', 'type' => 'input', 'default' => 'download', 'required' => true, 'help' => '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效,需重新获取下载地址。'],
['key' => 'default_max_uses', 'label' => '下载链接默认使用次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100, 'help' => '单个链接可完成下载的次数;文件完整送达后才计次(HEAD 探测、传输中断不计次)。耗尽后链接返回 410,需重新获取。'],
['key' => 'ttl_minutes', 'label' => '下载链接默认有效期限(分钟)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 1440, 'help' => '链接签发后超过该时长即失效并返回 410,不自动续期,需重新获取。'],
]],
],
],
'moderation' => [
'key' => 'moderation',
'title' => '评论审核',
+25
View File
@@ -291,9 +291,34 @@ 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];
}
/**
* 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);
+136 -51
View File
@@ -9,10 +9,14 @@ defined('ABSPATH') || exit;
class Download {
const TOKEN_TTL = 600; // 10 minutes
const TOKEN_LEN = 48;
const QUERY_VAR = 'aksrt_dl_token';
const ENDPOINT = 'aksrt-dl';
// Hardened fallbacks used when the aksrt_download_config option is
// missing or contains values the admin validation would reject.
const FALLBACK_ENDPOINT = 'download';
const FALLBACK_MAX_USES = 1;
const FALLBACK_TTL_MINUTES = 10;
private static $instance = null;
@@ -31,6 +35,7 @@ class Download {
add_action('template_redirect', [$this, 'maybe_serve_download'], 0);
add_action('rest_api_init', [$this, 'register_routes']);
add_action('init', [$this, 'maybe_flush_rewrite'], 99);
add_action('wp', [$this, 'maybe_schedule_cleanup']);
add_action('aksrt_dl_token_cleanup', [$this, 'cleanup_expired_tokens']);
}
@@ -63,14 +68,54 @@ class Download {
));
}
/**
* Effective download settings (aksrt_download_config), clamped so a
* corrupted option can never break routing or accounting.
*/
private function config(): array {
$cfg = get_option('aksrt_download_config', []);
if (!is_array($cfg)) {
$cfg = [];
}
$alias = trim((string) ($cfg['url_alias'] ?? self::FALLBACK_ENDPOINT));
if ($alias === '' || strlen($alias) > 48 || !preg_match('/^[A-Za-z0-9_-]+$/', $alias)) {
$alias = self::FALLBACK_ENDPOINT;
}
$max_uses = isset($cfg['default_max_uses']) ? (int) $cfg['default_max_uses'] : self::FALLBACK_MAX_USES;
$max_uses = min(100, max(1, $max_uses));
$ttl_minutes = isset($cfg['ttl_minutes']) ? (int) $cfg['ttl_minutes'] : self::FALLBACK_TTL_MINUTES;
$ttl_minutes = min(1440, max(1, $ttl_minutes));
return [
'endpoint' => $alias,
'max_uses' => $max_uses,
'ttl' => $ttl_minutes * MINUTE_IN_SECONDS,
];
}
public function register_rewrite(): void {
add_rewrite_rule(
'^' . self::ENDPOINT . '/([A-Za-z0-9]{1,64})/?$',
'^' . preg_quote($this->config()['endpoint'], '/') . '/([A-Za-z0-9]{1,64})/?$',
'index.php?' . self::QUERY_VAR . '=$matches[1]',
'top'
);
}
/**
* Re-flush rewrite rules whenever the configured alias changes, using
* the same version-marker approach as Auth\Router.
*/
public function maybe_flush_rewrite(): void {
$version = AKSRT_VERSION . ':download-endpoint:' . $this->config()['endpoint'];
if ((string) get_option('aksrt_dl_rewrite_version') !== $version) {
flush_rewrite_rules(false);
update_option('aksrt_dl_rewrite_version', $version);
}
}
public function add_query_var(array $vars): array {
$vars[] = self::QUERY_VAR;
return $vars;
@@ -126,7 +171,7 @@ class Download {
: ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
$display_price = is_array($priced)
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
: (($pay_modo === 'points') ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
$access = class_exists('AKSRT\\Content\\Visibility')
? Visibility::get_post_access_state($post, $uid)
@@ -144,7 +189,7 @@ class Download {
'require_login' => !empty($access['need_login']),
'require_captcha' => $need_captcha,
'logged_in' => $uid > 0,
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
'has_paid_access' => !empty($access['has_paid_access']),
'need_purchase' => !empty($access['need_purchase']),
'need_vip' => !empty($access['need_vip']),
@@ -213,7 +258,8 @@ class Download {
}
// Purchase is one-shot via cashier (resource order). Token issue does not re-deduct.
$token = $this->issue_token($uid, $post_id, $source_index, 0);
$config = $this->config();
$token = $this->issue_token($uid, $post_id, $source_index, 0, $config);
if (is_wp_error($token)) {
return $token;
}
@@ -223,7 +269,7 @@ class Download {
aksrt_record_user_free_download($uid, $post_id);
}
$dl_url = home_url('/' . self::ENDPOINT . '/' . $token);
$dl_url = home_url('/' . $config['endpoint'] . '/' . $token);
if ($uid > 0 && function_exists('aksrt_add_user_notification')) {
aksrt_add_user_notification($uid, [
'type' => 'download_ready',
@@ -239,12 +285,12 @@ class Download {
'success' => true,
'token' => $token,
'dl_url' => $dl_url,
'expires_in' => self::TOKEN_TTL,
'expires_in' => $config['ttl'],
'source_label' => $source['label'] ?? '',
'source_type' => $source['type'] ?? 'direct',
'has_extract' => $extract_code !== '',
'extract_code' => $extract_code,
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
], 200);
}
@@ -283,33 +329,29 @@ class Download {
$idx = (int) $row['source_index'];
if (empty($meta['downloads'][$idx]['url'])) $this->fail_410();
$wpdb->update(
$table,
['used_count' => (int) $row['used_count'] + 1],
['token' => $token],
['%d'],
['%s']
);
// Count on actual token consumption.
if (function_exists('aksrt_update_post_counter')) {
aksrt_update_post_counter((int) $row['post_id'], '_aksrt_res_download_count', 1);
} else {
$count = (int) get_post_meta((int) $row['post_id'], '_aksrt_res_download_count', true);
update_post_meta((int) $row['post_id'], '_aksrt_res_download_count', $count + 1);
}
// HEAD probes validate the link but must never consume it.
$is_head = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD';
$target_url = esc_url_raw((string) $meta['downloads'][$idx]['url']);
// Local attachment / uploads file: stream without artificial throttling.
$local_path = $this->resolve_local_download_path($target_url);
if ($local_path !== '' && is_readable($local_path)) {
$this->stream_local_file($local_path);
$delivered = $this->stream_local_file($local_path);
// Count only fully delivered bodies; aborted transfers stay reusable.
if ($delivered && !$is_head) {
$this->consume_token($row, $token);
}
exit;
}
nocache_headers();
wp_redirect($target_url, 302);
// Remote hosts give no completion signal, so the redirect hand-off
// counts as delivery; HEAD probes still never consume.
if (!$is_head) {
$this->consume_token($row, $token);
}
exit;
}
@@ -347,9 +389,9 @@ class Download {
}
/**
* Stream a local file.
* Stream a local file. Returns true only when the body was sent in full.
*/
private function stream_local_file(string $path): void {
private function stream_local_file(string $path): bool {
$filename = basename($path);
$size = filesize($path);
if ($size === false) {
@@ -375,9 +417,14 @@ class Download {
}
@ini_set('zlib.output_compression', 'Off');
while (!feof($fh)) {
if (connection_aborted()) {
fclose($fh);
return false;
}
$buf = fread($fh, $chunk);
if ($buf === false) {
break;
fclose($fh);
return false;
}
echo $buf;
if (function_exists('flush')) {
@@ -385,6 +432,34 @@ class Download {
}
}
fclose($fh);
return !connection_aborted();
}
/**
* Count a delivered download. The guard caps concurrent finishers at
* max_uses instead of overshooting the counter.
*/
private function consume_token(array $row, string $token): void {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('dl_token');
if (!$table) return;
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET used_count = used_count + 1 WHERE token = %s AND used_count < max_uses",
$token
));
if (!$updated) {
return;
}
if (function_exists('aksrt_update_post_counter')) {
aksrt_update_post_counter((int) $row['post_id'], '_aksrt_res_download_count', 1);
} else {
$count = (int) get_post_meta((int) $row['post_id'], '_aksrt_res_download_count', true);
update_post_meta((int) $row['post_id'], '_aksrt_res_download_count', $count + 1);
}
}
private function fail_410(): void {
@@ -393,44 +468,54 @@ class Download {
wp_die(esc_html__('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'), 410);
}
private function issue_token(int $user_id, int $post_id, int $source_index, int $cost) {
private function issue_token(int $user_id, int $post_id, int $source_index, int $cost, array $config = []) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('dl_token');
if (!$table) {
return new \WP_Error('aksrt_no_table', __('下载令牌表未初始化', 'aksrt'), ['status' => 500]);
}
$token = $this->random_token(self::TOKEN_LEN);
if (empty($config)) {
$config = $this->config();
}
$ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$ua_hash = sha1((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$now_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$expires_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->modify('+' . self::TOKEN_TTL . ' seconds')->format('Y-m-d H:i:s');
$expires_utc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('+' . (int) $config['ttl'] . ' seconds')
->format('Y-m-d H:i:s');
$ok = $wpdb->insert(
$table,
[
'token' => $token,
'user_id' => $user_id,
'post_id' => $post_id,
'source_index' => $source_index,
'cost' => $cost,
'ip_bind' => $ip,
'ua_hash' => $ua_hash,
'used_count' => 0,
'max_uses' => 1,
'expires_at' => $expires_utc,
'created_at' => $now_utc,
],
['%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s']
);
// PRIMARY KEY(token) already guarantees uniqueness; retry on the
// astronomically unlikely collision instead of failing the request.
for ($attempt = 0; $attempt < 3; $attempt++) {
$token = $this->random_token(self::TOKEN_LEN);
if ($ok === false) {
return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]);
$ok = $wpdb->insert(
$table,
[
'token' => $token,
'user_id' => $user_id,
'post_id' => $post_id,
'source_index' => $source_index,
'cost' => $cost,
'ip_bind' => $ip,
'ua_hash' => $ua_hash,
'used_count' => 0,
'max_uses' => (int) $config['max_uses'],
'expires_at' => $expires_utc,
'created_at' => $now_utc,
],
['%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s']
);
if ($ok !== false) {
return $token;
}
}
return $token;
return new \WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]);
}
private function random_token(int $len): string {
+1
View File
@@ -38,6 +38,7 @@ class Settings {
'aksrt_oauth',
'aksrt_captcha_config',
'aksrt_moderation_config',
'aksrt_download_config',
'aksrt_footer_tabbar',
'aksrt_floating_buttons',
'aksrt_credit_settings',