diff --git a/README.md b/README.md
index bb301b81..495e6aae 100644
--- a/README.md
+++ b/README.md
@@ -170,7 +170,7 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
### 4. 推广、邀请与佣金(用户中心 → 收益)
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
-- **一次性邀请码**:后台「用户 → 邀请码管理」批量生成(每码次数/有效期/备注/四类奖励:积分、余额、VIP、等级积分),支持文本导入(一行一码,格式 `邀请码 奖励 标识`,第三段标识存为备注;奖励参数串与 Zibll 兼容:`points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)与两种导出(CSV 全字段 / 文本格式,文本导出可直接回流导入)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
+- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或 Zibll 兼容文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
### 5. 部署注意事项
diff --git a/src/Admin/Native/UsersMenusInviteCode.php b/src/Admin/Native/UsersMenusInviteCode.php
index f74c2dd7..f1ff35c6 100644
--- a/src/Admin/Native/UsersMenusInviteCode.php
+++ b/src/Admin/Native/UsersMenusInviteCode.php
@@ -1,6 +1,9 @@
registration_settings();
+ if (empty($settings['enabled'])) {
+ $settings_url = admin_url('admin.php?page=aksrt-settings');
+ echo '
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ()
-
-
-
- points=20&balance=30&vip=1&vip_time=5&level_integral=100
- 93FW0XAY 无 没有奖励
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
- |
- |
- |
- |
- |
- |
- |
- |
- |
-
- |
-
-
+
+
+
+ render_invite_codes_list($service, $this_url, $nonce); ?>
+
+ render_invite_codes_add($this_url, $nonce); ?>
+
+ render_invite_codes_export($this_url, $nonce); ?>
+
+
list_codes([
+ 'page' => isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ 'status' => $status,
+ 's' => $s,
+ 'remark' => $remark,
+ 'orderby' => isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ 'desc' => isset($_GET['desc']) ? sanitize_key((string) wp_unslash($_GET['desc'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ ]);
+
+ echo '
' . esc_html__('邀请码管理', 'aksrt') . '
';
+ echo '
' . esc_html__('添加邀请码', 'aksrt') . '';
+ echo '
' . esc_html__('导出邀请码', 'aksrt') . '';
+ echo '';
+
+ // 顶部筛选行:左侧状态链接(搜索时改为提示语),右侧搜索表单。
+ $search_html = '
+
';
+ echo '
';
+ echo '
';
+ if ($s !== '') {
+ echo '- '
+ . esc_html(sprintf(__('搜索 “%s” 的内容', 'aksrt'), $s)) . '
';
+ } else {
+ $links = [
+ 'all' => __('全部', 'aksrt'),
+ 'unused' => __('未使用', 'aksrt'),
+ 'used' => __('已使用', 'aksrt'),
+ ];
+ $parts = [];
+ foreach ($links as $key => $label) {
+ $url = $key === 'all' ? $this_url : add_query_arg('status', $key, $this_url);
+ $parts[] = '- ' . esc_html($label) . '
';
+ }
+ echo implode(' | ', $parts); // phpcs:ignore WordPress.Security.EscapeOutput -- 各项均已转义。
+ }
+ echo '
' . $search_html . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- 静态结构。
+
+ // 可排序列:code / created_at / updated_at / used_count / remark。
+ $columns = [
+ ['width' => '6%', 'key' => 'code', 'name' => __('邀请码', 'aksrt')],
+ ['width' => '10%', 'key' => '', 'name' => __('使用奖励', 'aksrt')],
+ ['width' => '10%', 'key' => 'created_at', 'name' => __('创建时间', 'aksrt')],
+ ['width' => '10%', 'key' => 'updated_at', 'name' => __('更新时间', 'aksrt')],
+ ['width' => '6%', 'key' => 'used_count', 'name' => __('状态', 'aksrt')],
+ ['width' => '12%', 'key' => 'remark', 'name' => __('标识', 'aksrt')],
+ ];
+
+ echo '
';
+
+ // 分页条:总数 + 页码链接。
+ $total = (int) $data['total'];
+ $max_pages = (int) $data['max_pages'];
+ echo '
';
+ echo '' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n($total))) . '';
+ if ($max_pages > 1) {
+ echo paginate_links([
+ 'base' => add_query_arg('paged', '%#%'),
+ 'format' => '',
+ 'current' => (int) $data['page'],
+ 'total' => $max_pages,
+ 'type' => 'plain',
+ 'prev_text' => '«',
+ 'next_text' => '»',
+ ]); // phpcs:ignore WordPress.Security.EscapeOutput -- paginate_links 自行转义。
+ }
+ echo '
';
+ }
+
+ /** 添加视图:系统自动生成 / 文本导入 二选一。 */
+ private function render_invite_codes_add(string $this_url, string $nonce): void {
+ echo '
' . esc_html__('添加邀请码', 'aksrt') . '
';
+ echo '
' . esc_html__('返回列表', 'aksrt') . '';
+ echo '';
+
+ echo '
';
+ }
+
+ /** 使用奖励字段集:经验值 / 积分 / 余额 / VIP 会员与赠送时长。 */
+ private function render_invite_reward_fields(): void {
+ echo '
';
+ echo '
'
+ . ''
+ . ''
+ . ''
+ . '
';
+ echo '
';
+ echo '
' . esc_html__('会员赠送时长', 'aksrt')
+ . ' ' . esc_html__('天', 'aksrt') . ''
+ . '' . esc_html__('仅在上方选择 VIP 会员等级后生效', 'aksrt') . '
';
+ echo '
';
+ }
+
+ /** 导出视图:状态 / 格式 选择后提交下载。 */
+ private function render_invite_codes_export(string $this_url, string $nonce): void {
+ echo '
' . esc_html__('导出邀请码', 'aksrt') . '
';
+ echo '
' . esc_html__('返回列表', 'aksrt') . '';
+ echo '';
+
+ echo '
';
+ }
}
diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php
index ffae75ec..b9952fb9 100644
--- a/src/Core/DBMigratorPart2.php
+++ b/src/Core/DBMigratorPart2.php
@@ -389,6 +389,7 @@ trait DBMigratorPart2 {
expires_at DATETIME DEFAULT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY uniq_invite_code (code),
KEY idx_batch (batch_id),
diff --git a/src/Payment/InviteCode.php b/src/Payment/InviteCode.php
index 92b3ff72..836a3cc5 100644
--- a/src/Payment/InviteCode.php
+++ b/src/Payment/InviteCode.php
@@ -31,6 +31,9 @@ final class InviteCode {
add_action('user_register', [$this, 'handle_registration'], 30, 1);
add_action('aksrt_email_verified', [$this, 'handle_email_verified'], 10, 1);
add_action('rest_api_init', [$this, 'register_routes']);
+ add_action('admin_post_aksrt_invite_add', [$this, 'handle_add']);
+ add_action('admin_post_aksrt_invite_delete', [$this, 'handle_delete']);
+ add_action('admin_post_aksrt_invite_export', [$this, 'handle_export']);
}
/* ------------------------------------------------------------------
@@ -133,10 +136,12 @@ final class InviteCode {
if ($code === '' || $table === '') {
return null;
}
+ $now = current_time('mysql');
$updated = $wpdb->query($wpdb->prepare(
- "UPDATE {$table} SET used_count = used_count + 1 WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
+ "UPDATE {$table} SET used_count = used_count + 1, updated_at = %s WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
+ $now,
$code,
- current_time('mysql')
+ $now
));
if ($updated !== 1) {
return null;
@@ -232,9 +237,10 @@ final class InviteCode {
if ($table === '') {
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
}
- $count = min(500, max(1, (int) ($args['count'] ?? 1)));
+ $count = min(1000, max(1, (int) ($args['count'] ?? 1)));
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
+ $code_length = min(50, max(4, (int) ($args['code_length'] ?? self::CODE_LENGTH)));
$rewards = $this->normalize_rewards((string) wp_json_encode($args['rewards'] ?? []));
if (max($rewards['points'], $rewards['balance_minor'], $rewards['vip_level'] * $rewards['vip_days'], $rewards['level_points']) === 0) {
$rewards = ['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0];
@@ -251,7 +257,7 @@ final class InviteCode {
$code = '';
for ($attempt = 0; $attempt < 20; $attempt++) {
$candidate = '';
- for ($j = 0; $j < self::CODE_LENGTH; $j++) {
+ for ($j = 0; $j < $code_length; $j++) {
$candidate .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
}
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $candidate));
@@ -273,7 +279,8 @@ final class InviteCode {
'expires_at' => $expires_at,
'created_by' => $created_by,
'created_at' => $created_at,
- ], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
+ 'updated_at' => $created_at,
+ ], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s']);
if ($inserted === 1) {
$codes[] = $code;
}
@@ -342,7 +349,8 @@ final class InviteCode {
'expires_at' => $expires_at,
'created_by' => $created_by,
'created_at' => $created_at,
- ], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
+ 'updated_at' => $created_at,
+ ], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s']);
if ($inserted === 1) {
$imported++;
} else {
@@ -394,45 +402,77 @@ final class InviteCode {
return $parts === [] ? '无' : implode('&', $parts);
}
- public function set_status(int $id, string $status): bool {
- global $wpdb;
- $table = $this->table();
- if ($id <= 0 || $table === '' || !in_array($status, ['active', 'disabled'], true)) {
- return false;
- }
- return $wpdb->update($table, ['status' => $status], ['id' => $id], ['%s'], ['%d']) === 1;
+ /** 状态过滤片段(列表与导出共用):all=全部,unused=未使用,used=已使用。 */
+ private function status_where(string $status): array {
+ return match ($status) {
+ 'unused' => ['used_count = 0', []],
+ 'used' => ['used_count > 0', []],
+ default => ['1=1', []],
+ };
}
- /** @return array{items:array
>,total:int,page:int,per_page:int,max_pages:int} */
+ /**
+ * 删除邀请码(连同其使用记录)。
+ *
+ * @param array $ids
+ */
+ public function delete_codes(array $ids): int {
+ global $wpdb;
+ $table = $this->table();
+ $usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
+ $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn($id) => $id > 0)));
+ if ($ids === [] || $table === '') {
+ return 0;
+ }
+ $placeholders = implode(',', array_fill(0, count($ids), '%d'));
+ if ($usage_table !== '') {
+ $wpdb->query($wpdb->prepare("DELETE FROM {$usage_table} WHERE code_id IN ({$placeholders})", $ids));
+ }
+ $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids));
+ return (int) $wpdb->rows_affected;
+ }
+
+ /**
+ * 服务端列表查询:状态筛选 / 关键字搜索 / 标识定位 / 可排序 / 分页。
+ *
+ * @return array{items:array>,total:int,counts:array{all:int,unused:int,used:int},page:int,per_page:int,max_pages:int,orderby:string,desc:string}
+ */
public function list_codes(array $args): array {
global $wpdb;
$table = $this->table();
$page = max(1, (int) ($args['page'] ?? 1));
- $per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
- $status = sanitize_key((string) ($args['status'] ?? ''));
- $search = trim((string) ($args['search'] ?? ''));
+ $per_page = max(1, min(100, (int) ($args['per_page'] ?? 30)));
+ $status = in_array(($args['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $args['status'] : 'all';
+ $search = trim((string) ($args['s'] ?? ''));
+ $remark = trim((string) ($args['remark'] ?? ''));
+ $orderby_map = ['code' => 'code', 'created_at' => 'created_at', 'updated_at' => 'updated_at', 'used_count' => 'used_count', 'remark' => 'remark'];
+ $orderby = $orderby_map[sanitize_key((string) ($args['orderby'] ?? ''))] ?? 'updated_at';
+ $desc = strtoupper((string) ($args['desc'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
if ($table === '') {
- return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1];
+ return ['items' => [], 'total' => 0, 'counts' => ['all' => 0, 'unused' => 0, 'used' => 0], 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1, 'orderby' => $orderby, 'desc' => $desc];
}
- $where = ['1=1'];
- $params = [];
- if (in_array($status, ['active', 'disabled'], true)) {
- $where[] = 'status = %s';
- $params[] = $status;
- }
+ [$status_sql, $status_params] = $this->status_where($status);
+ $where = [$status_sql];
+ $params = $status_params;
if ($search !== '') {
- $where[] = '(code LIKE %s OR batch_id LIKE %s)';
$like = '%' . $wpdb->esc_like($search) . '%';
- array_push($params, $like, $like);
+ $where[] = '(code LIKE %s OR remark LIKE %s OR batch_id LIKE %s)';
+ array_push($params, $like, $like, $like);
+ }
+ if ($remark !== '') {
+ $where[] = 'remark = %s';
+ $params[] = $remark;
}
$where_sql = implode(' AND ', $where);
- $total = (int) ($params
- ? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params))
- : $wpdb->get_var("SELECT COUNT(*) FROM {$table}"));
+ $count_row = $params
+ ? $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) AS all_count, COALESCE(SUM(used_count = 0), 0) AS unused_count, COALESCE(SUM(used_count > 0), 0) AS used_count FROM {$table} WHERE {$where_sql}", $params), ARRAY_A)
+ : $wpdb->get_row("SELECT COUNT(*) AS all_count, COALESCE(SUM(used_count = 0), 0) AS unused_count, COALESCE(SUM(used_count > 0), 0) AS used_count FROM {$table}", ARRAY_A);
+ $total = (int) ($count_row['all_count'] ?? 0);
+
$rows = $wpdb->get_results($wpdb->prepare(
- "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d",
+ "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY {$orderby} {$desc}, id DESC LIMIT %d OFFSET %d",
array_merge($params, [$per_page, ($page - 1) * $per_page])
), ARRAY_A);
@@ -440,13 +480,13 @@ final class InviteCode {
$code_ids = array_map(static fn($row) => (int) $row['id'], (array) $rows);
$recent_users = $this->recent_users_map($code_ids);
foreach ((array) $rows as $row) {
- $rewards = $this->normalize_rewards((string) ($row['rewards'] ?? ''));
$items[] = [
'id' => (int) $row['id'],
'code' => (string) $row['code'],
'batch_id' => (string) $row['batch_id'],
'remark' => (string) ($row['remark'] ?? ''),
- 'rewards' => $rewards,
+ 'rewards' => (string) ($row['rewards'] ?? ''),
+ 'reward_text' => $this->reward_text((string) ($row['rewards'] ?? '')),
'max_uses' => (int) $row['max_uses'],
'used_count' => (int) $row['used_count'],
'recent_users' => $recent_users[(int) $row['id']] ?? [],
@@ -454,18 +494,45 @@ final class InviteCode {
'expired' => !empty($row['expires_at']) && strtotime((string) $row['expires_at']) <= time(),
'expires_at' => (string) ($row['expires_at'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
+ 'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
return [
'items' => $items,
'total' => $total,
+ 'counts' => [
+ 'all' => (int) ($count_row['all_count'] ?? 0),
+ 'unused' => (int) ($count_row['unused_count'] ?? 0),
+ 'used' => (int) ($count_row['used_count'] ?? 0),
+ ],
'page' => $page,
'per_page' => $per_page,
'max_pages' => max(1, (int) ceil($total / $per_page)),
+ 'orderby' => $orderby,
+ 'desc' => $desc,
];
}
+ /** 奖励 JSON → 多行展示文本(对应导入参数串的可读形态)。 */
+ public function reward_text(string $rewards_json): string {
+ $rewards = $this->normalize_rewards($rewards_json);
+ $lines = [];
+ if ($rewards['level_points'] > 0) {
+ $lines[] = sprintf(__('经验值+%d', 'aksrt'), $rewards['level_points']);
+ }
+ if ($rewards['points'] > 0) {
+ $lines[] = sprintf(__('积分+%d', 'aksrt'), $rewards['points']);
+ }
+ if ($rewards['balance_minor'] > 0) {
+ $lines[] = sprintf(__('余额+%s', 'aksrt'), number_format($rewards['balance_minor'] / 100, 2));
+ }
+ if ($rewards['vip_level'] > 0 && $rewards['vip_days'] > 0) {
+ $lines[] = sprintf(__('VIP%d×%d天', 'aksrt'), $rewards['vip_level'], $rewards['vip_days']);
+ }
+ return $lines === [] ? __('无', 'aksrt') : implode('
', $lines);
+ }
+
/**
* 批量取每个码最近的使用者(最多 3 个),用于运营列表展示。
*
@@ -506,7 +573,7 @@ final class InviteCode {
}
/* ------------------------------------------------------------------
- * REST
+ * REST(仅公开校验)与管理表单处理器
* ----------------------------------------------------------------*/
public function register_routes(): void {
@@ -521,143 +588,138 @@ final class InviteCode {
'code' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
],
]);
-
- $admin_guard = fn() => current_user_can('edit_users')
- ? true
- : new \WP_Error('aksrt_invite_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
- $write_guard = function () use ($admin_guard) {
- $allowed = $admin_guard();
- if (is_wp_error($allowed)) {
- return $allowed;
- }
- $nonce = (string) isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
- return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
- ? true
- : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
- };
-
- register_rest_route('aksrt/v1', '/invite-codes', [
- 'methods' => \WP_REST_Server::READABLE,
- 'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_codes($request->get_params())),
- 'permission_callback' => $admin_guard,
- 'args' => [
- 'page' => ['type' => 'integer', 'default' => 1, 'minimum' => 1],
- 'per_page' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100],
- 'search' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
- 'status' => ['type' => 'string', 'default' => '', 'enum' => ['', 'active', 'disabled']],
- ],
- ]);
-
- register_rest_route('aksrt/v1', '/invite-codes/generate', [
- 'methods' => \WP_REST_Server::CREATABLE,
- 'callback' => fn(\WP_REST_Request $request) => $this->rest_generate($request),
- 'permission_callback' => $write_guard,
- ]);
-
- register_rest_route('aksrt/v1', '/invite-codes/import', [
- 'methods' => \WP_REST_Server::CREATABLE,
- 'callback' => function (\WP_REST_Request $request) {
- $result = $this->import_codes([
- 'data' => (string) $request->get_param('data'),
- 'division' => (string) $request->get_param('division'),
- 'max_uses' => (int) $request->get_param('max_uses'),
- 'expires_days' => (int) $request->get_param('expires_days'),
- ]);
- return is_wp_error($result) ? $result : new \WP_REST_Response(['success' => true] + $result, 201);
- },
- 'permission_callback' => $write_guard,
- 'args' => [
- 'data' => ['type' => 'string', 'required' => true],
- 'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
- 'max_uses' => ['type' => 'integer', 'default' => 1, 'minimum' => 0, 'maximum' => 9999],
- 'expires_days' => ['type' => 'integer', 'default' => 0, 'minimum' => 0, 'maximum' => 3650],
- ],
- ]);
-
- register_rest_route('aksrt/v1', '/invite-codes/export', [
- 'methods' => \WP_REST_Server::READABLE,
- 'callback' => [$this, 'rest_export'],
- 'permission_callback' => $admin_guard,
- ]);
-
- register_rest_route('aksrt/v1', '/invite-codes/export-text', [
- 'methods' => \WP_REST_Server::READABLE,
- 'callback' => [$this, 'rest_export_text'],
- 'permission_callback' => $admin_guard,
- 'args' => [
- 'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
- ],
- ]);
-
- foreach (['disable' => 'disabled', 'enable' => 'active'] as $action => $status) {
- register_rest_route('aksrt/v1', '/invite-codes/(?P\d+)/' . $action, [
- 'methods' => \WP_REST_Server::CREATABLE,
- 'callback' => function (\WP_REST_Request $request) use ($status) {
- $updated = $this->set_status((int) $request['id'], $status);
- return $updated
- ? rest_ensure_response(['success' => true, 'id' => (int) $request['id'], 'status' => $status])
- : new \WP_Error('aksrt_invite_status', __('邀请码状态更新失败', 'aksrt'), ['status' => 409]);
- },
- 'permission_callback' => $write_guard,
- 'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
- ]);
- }
}
- private function rest_generate(\WP_REST_Request $request) {
- $rewards = $request->get_param('rewards');
+ /** 管理操作入口守卫:权限 + nonce,通过后回跳带结果的列表页。 */
+ private function guard_and_redirect(string $tab = ''): string {
+ if (!current_user_can('edit_users')) {
+ wp_die(esc_html__('您没有权限执行此操作。', 'aksrt'), '', ['response' => 403]);
+ }
+ check_admin_referer('aksrt_invite_admin', 'aksrt_nonce');
+ return admin_url('users.php?page=aksrt_invite_codes' . ($tab !== '' ? '&tab=' . $tab : ''));
+ }
+
+ private function redirect_with_notice(string $url, string $title, string $message, bool $is_error = false): never {
+ wp_safe_redirect(add_query_arg([
+ 'aksrt_inv_title' => rawurlencode($title),
+ 'aksrt_inv_msg' => rawurlencode($message),
+ 'aksrt_inv_type' => $is_error ? 'error' : 'success',
+ ], $url));
+ exit;
+ }
+
+ /** 添加视图提交:系统自动生成 或 文本导入。 */
+ public function handle_add(): void {
+ $back = $this->guard_and_redirect('add');
+ $add_type = ($_POST['add_type'] ?? 'auto') === 'import' ? 'import' : 'auto';
+
+ if ($add_type === 'import') {
+ $data = (string) wp_unslash($_POST['import_data'] ?? '');
+ if (trim($data) === '') {
+ $this->redirect_with_notice($back, __('错误!', 'aksrt'), __('请粘贴您需要导入的数据', 'aksrt'), true);
+ }
+ $division = trim((string) wp_unslash($_POST['import_division'] ?? ' '));
+ $result = $this->import_codes(['data' => $data, 'division' => $division]);
+ if (is_wp_error($result)) {
+ $this->redirect_with_notice($back, __('错误!', 'aksrt'), $result->get_error_message(), true);
+ }
+ $message = sprintf(__('成功导入%d个邀请码', 'aksrt'), $result['imported'])
+ . ($result['failed'] ? sprintf(__(',%d个导入失败', 'aksrt'), $result['failed']) : '');
+ $this->redirect_with_notice($back, __('导入完成', 'aksrt'), $message, $result['failed'] > 0);
+ }
+
+ $num = (int) ($_POST['auto_num'] ?? 0);
+ if ($num < 1) {
+ $this->redirect_with_notice($back, __('错误!', 'aksrt'), __('请输入需要生成的数量', 'aksrt'), true);
+ }
+ $code_length = !empty($_POST['auto_top_s'])
+ ? max(4, (int) ($_POST['auto_rand_password_limit'] ?? 8))
+ : self::CODE_LENGTH;
$codes = $this->generate_batch([
- 'count' => (int) $request->get_param('count'),
- 'max_uses' => (int) $request->get_param('max_uses'),
- 'expires_days' => (int) $request->get_param('expires_days'),
- 'remark' => (string) $request->get_param('remark'),
- 'rewards' => is_array($rewards) ? $rewards : [],
+ 'count' => $num,
+ 'code_length' => $code_length,
+ 'remark' => sanitize_text_field(wp_unslash((string) ($_POST['auto_remarks'] ?? ''))),
+ 'rewards' => [
+ 'level_points' => (int) ($_POST['level_integral'] ?? 0),
+ 'points' => (int) ($_POST['points'] ?? 0),
+ 'balance' => (float) ($_POST['balance'] ?? 0),
+ 'vip_level' => (int) ($_POST['vip'] ?? 0),
+ 'vip_days' => (int) ($_POST['vip_time'] ?? 0),
+ ],
]);
if (is_wp_error($codes)) {
- return $codes;
+ $this->redirect_with_notice($back, __('错误!', 'aksrt'), $codes->get_error_message(), true);
}
- return new \WP_REST_Response(['success' => true, 'codes' => $codes, 'count' => count($codes)], 201);
+ $this->redirect_with_notice($back, __('完成!', 'aksrt'), sprintf(__('已自动生成%d个邀请码', 'aksrt'), count($codes)));
}
- public function rest_export(): \WP_REST_Response {
- global $wpdb;
- $table = $this->table();
- $rows = $wpdb->get_results("SELECT code, batch_id, remark, rewards, max_uses, used_count, status, expires_at, created_at FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
- $lines = ['code,batch_id,remark,rewards,max_uses,used_count,status,expires_at,created_at'];
- foreach ((array) $rows as $row) {
- $row['rewards'] = $this->reward_param_string((string) ($row['rewards'] ?? ''));
- $lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', (string) $value) . '"', array_values($row)));
+ /** 列表批量删除。 */
+ public function handle_delete(): void {
+ $back = $this->guard_and_redirect();
+ $ids = array_map('intval', (array) ($_POST['action_id'] ?? []));
+ if ($ids === []) {
+ $this->redirect_with_notice($back, __('错误!', 'aksrt'), __('未选择需要删除的内容', 'aksrt'), true);
}
- $response = new \WP_REST_Response(implode("\n", $lines), 200);
- $response->set_headers([
- 'Content-Type' => 'text/csv; charset=utf-8',
- 'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.csv"',
- ]);
- return $response;
+ $deleted = $this->delete_codes($ids);
+ $this->redirect_with_notice($back, __('删除完成', 'aksrt'), sprintf(__('已删除%d个邀请码', 'aksrt'), $deleted));
}
- /** 文本导出:与导入格式互通(`码 分隔符 奖励 标识`),导出即可回流导入。 */
- public function rest_export_text(\WP_REST_Request $request): \WP_REST_Response {
- global $wpdb;
- $table = $this->table();
- $division = trim((string) ($request['division'] ?? ' '));
+ /** 导出视图提交:文本(可回流导入)或 Excel/CSV 表格。 */
+ public function handle_export(): void {
+ if (!current_user_can('edit_users')) {
+ wp_die(esc_html__('您没有权限执行此操作。', 'aksrt'), '', ['response' => 403]);
+ }
+ check_admin_referer('aksrt_invite_admin', 'aksrt_nonce');
+ $status = in_array(($_GET['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $_GET['status'] : 'all';
+ $format = ($_GET['export_format'] ?? 'xls') === 'text' ? 'text' : 'xls';
+ $division = trim((string) wp_unslash($_GET['text_division'] ?? ' '));
if ($division === '') {
$division = ' ';
}
- $rows = $wpdb->get_results("SELECT code, remark, rewards FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
- $lines = [];
- foreach ((array) $rows as $row) {
- $line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
- if ((string) ($row['remark'] ?? '') !== '') {
- $line .= $division . $row['remark'];
+ [$status_sql, $status_params] = $this->status_where($status);
+
+ global $wpdb;
+ $table = $this->table();
+ $rows = $wpdb->get_results($wpdb->prepare(
+ "SELECT code, remark, rewards, max_uses, used_count, created_at, updated_at FROM {$table} WHERE {$status_sql} ORDER BY id DESC LIMIT 10000",
+ $status_params
+ ), ARRAY_A);
+
+ if ($format === 'text') {
+ $lines = [];
+ foreach ((array) $rows as $row) {
+ $line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
+ if ((string) ($row['remark'] ?? '') !== '') {
+ $line .= $division . $row['remark'];
+ }
+ $lines[] = $line;
}
- $lines[] = $line;
+ $this->stream_download('aksrt-invite-codes.txt', 'text/plain; charset=utf-8', implode("\n", $lines));
}
- $response = new \WP_REST_Response(implode("\n", $lines), 200);
- $response->set_headers([
- 'Content-Type' => 'text/plain; charset=utf-8',
- 'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.txt"',
- ]);
- return $response;
+
+ $lines = [implode(',', array_map(static fn($h) => '"' . $h . '"', [__('邀请码', 'aksrt'), __('使用奖励', 'aksrt'), __('创建时间', 'aksrt'), __('更新时间', 'aksrt'), __('状态', 'aksrt'), __('标识', 'aksrt')]))];
+ foreach ((array) $rows as $row) {
+ $used = (int) $row['used_count'];
+ $cells = [
+ (string) $row['code'],
+ $this->reward_param_string((string) ($row['rewards'] ?? '')),
+ (string) $row['created_at'],
+ (string) $row['updated_at'],
+ $used > 0 ? __('已使用', 'aksrt') : __('未使用', 'aksrt'),
+ (string) ($row['remark'] ?? ''),
+ ];
+ $lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', $value) . '"', $cells));
+ }
+ // UTF-8 BOM:保证 Excel 直接打开不乱码。
+ $this->stream_download('aksrt-invite-codes.csv', 'text/csv; charset=utf-8', "\xEF\xBB\xBF" . implode("\n", $lines));
+ }
+
+ private function stream_download(string $filename, string $content_type, string $body): never {
+ nocache_headers();
+ header('Content-Type: ' . $content_type);
+ header('Content-Disposition: attachment; filename="' . $filename . '"');
+ header('Content-Length: ' . strlen($body));
+ echo $body;
+ exit;
}
}
diff --git a/tests/widget-system/invite-code-contract.test.cjs b/tests/widget-system/invite-code-contract.test.cjs
index 3edc47ed..38726db9 100644
--- a/tests/widget-system/invite-code-contract.test.cjs
+++ b/tests/widget-system/invite-code-contract.test.cjs
@@ -19,8 +19,9 @@ test('invite code table has a unique code key and a usage log table', () => {
test('consumption is a single conditional UPDATE, not select-then-update', () => {
const service = read('src', 'Payment', 'InviteCode.php')
- // TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定。
- assert.match(service, /SET used_count = used_count \+ 1 WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
+ // TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定;
+ // 同语句维护 updated_at(列表"更新时间"列与排序依据)。
+ assert.match(service, /SET used_count = used_count \+ 1, updated_at = %s WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
assert.match(service, /\$updated !== 1/)
// 消费成功后写入使用记录(使用者视图)。
assert.match(service, /invite_code_usage/)
@@ -58,10 +59,20 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
assert.match(template, /name="invite_code"/)
assert.match(template, /get_invite_code_registration_settings/)
+ // 管理页为服务端渲染三视图(列表/添加/导出),通过 page-title-action 切换。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /render_invite_codes_page/)
- assert.match(page, /\$\{endpoint\}\/generate/)
- assert.match(page, /_wpnonce/)
+ assert.match(page, /page-title-action/)
+ assert.match(page, /subsubsub/)
+ assert.match(page, /data-clipboard-text/)
+ assert.match(page, /orderby-but/)
+ assert.match(page, /bulk_action/)
+ assert.match(page, /aksrt_invite_delete/)
+ assert.match(page, /aksrt_invite_add/)
+ assert.match(page, /aksrt_invite_export/)
+ assert.match(page, /paginate_links/)
+ // 管理页不再依赖 REST 与 wp_rest nonce。
+ assert.doesNotMatch(page, /wp_rest|\$\{endpoint\}/)
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
assert.match(menus, /use UsersMenusInviteCode/)
@@ -79,6 +90,30 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
}
})
+test('admin operations run through admin_post handlers with capability and nonce guards', () => {
+ const service = read('src', 'Payment', 'InviteCode.php')
+ assert.match(service, /admin_post_aksrt_invite_add/)
+ assert.match(service, /admin_post_aksrt_invite_delete/)
+ assert.match(service, /admin_post_aksrt_invite_export/)
+ assert.match(service, /check_admin_referer\('aksrt_invite_admin', 'aksrt_nonce'\)/)
+ assert.match(service, /current_user_can\('edit_users'\)/)
+ // 管理端 REST 已移除:除公开 verify 外不再注册邀请码路由(导出文件名除外)。
+ assert.doesNotMatch(service, /register_rest_route\('aksrt\/v1', '\/invite-codes/)
+ assert.match(service, /register_rest_route\('aksrt\/v1', '\/invite-code\/verify'/)
+ // 列表/导出共用状态过滤;批量删除连使用记录一起清。
+ assert.match(service, /private function status_where\(string \$status\): array/)
+ assert.match(service, /'unused' => \['used_count = 0', \[\]\]/)
+ assert.match(service, /public function delete_codes\(array \$ids\): int/)
+ assert.match(service, /DELETE FROM \{\$usage_table\} WHERE code_id IN/)
+ // 列表支持状态/搜索/标识定位/排序/分页,默认按更新时间倒序。
+ assert.match(service, /\$orderby_map = \['code' => 'code', 'created_at' => 'created_at', 'updated_at' => 'updated_at', 'used_count' => 'used_count', 'remark' => 'remark'\]/)
+ assert.match(service, /\?\? 'updated_at'/)
+ assert.match(service, /'remark' => 'remark'/)
+ // 生成支持自定义位数(高级选项),单次上限 1000。
+ assert.match(service, /min\(1000, max\(1, \(int\) \(\$args\['count'\] \?\? 1\)\)\)/)
+ assert.match(service, /min\(50, max\(4, \(int\) \(\$args\['code_length'\] \?\? self::CODE_LENGTH\)\)\)/)
+})
+
test('import accepts Zibll-compatible lines with per-code rewards', () => {
const service = read('src', 'Payment', 'InviteCode.php')
assert.match(service, /public function import_codes\(array \$args\)/)
@@ -90,17 +125,17 @@ test('import accepts Zibll-compatible lines with per-code rewards', () => {
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
assert.match(service, /\$exists !== null/)
assert.match(service, /\$failed_codes\[\] = \$code/)
- // REST 写入口 + 后台导入面板。
- assert.match(service, /invite-codes\/import/)
+ // 导入走添加视图表单提交(add_type=import),textarea + 分隔符。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
- assert.match(page, /aksrt-inv-import-data/)
- assert.match(page, /\$\{endpoint\}\/import/)
- assert.match(page, /aksrt-inv-import-division/)
+ assert.match(page, /name="import_data"/)
+ assert.match(page, /name="import_division"/)
+ assert.match(page, /name="add_type"/)
})
test('per-code remark persists through generate/import and both exports round-trip', () => {
const schema = read('src', 'Core', 'DBMigratorPart2.php')
assert.match(schema, /remark\s+VARCHAR\(191\) NOT NULL DEFAULT ''/)
+ assert.match(schema, /updated_at\s+DATETIME NOT NULL DEFAULT/)
const service = read('src', 'Payment', 'InviteCode.php')
// 生成与导入都落 remark;导入第三段"标识"→备注。
@@ -111,16 +146,16 @@ test('per-code remark persists through generate/import and both exports round-tr
assert.match(service, /private function reward_param_string\(string \$rewards_json\): string/)
assert.match(service, /'balance=' \. /)
assert.match(service, /\$parts === \[\] \? '无'/)
- // CSV 含 remark+奖励列;文本导出与导入格式互通。
- assert.match(service, /code,batch_id,remark,rewards,max_uses/)
- assert.match(service, /invite-codes\/export-text/)
+ // 文本导出与导入格式互通;CSV(Excel)含中文表头与状态列。
assert.match(service, /\$row\['code'\] \. \$division \. \$this->reward_param_string/)
+ assert.match(service, /__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\)/)
+ assert.match(service, /\$used > 0 \? __\('已使用', 'aksrt'\) : __\('未使用', 'aksrt'\)/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
- assert.match(page, /aksrt-inv-remark/)
- assert.match(page, /remark: root\.querySelector\('#aksrt-inv-remark'\)/)
- assert.match(page, /invite-codes\/export-text/)
- assert.match(page, /item\.remark \|\| '—'/)
+ // 添加表单标识字段(默认 invita_YmdHis);列表标识列可点击定位。
+ assert.match(page, /name="auto_remarks"/)
+ assert.match(page, /invita_' \. current_time\('YmdHis'\)/)
+ assert.match(page, /add_query_arg\('remark', \$item\['remark'\], \$this_url\)/)
})
test('settings reader exposes normalized invite-code registration mode', () => {