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 '

' + . esc_html__('注意:邀请码功能暂未启用。您可以在', 'aksrt') + . ' ' . esc_html__('登录注册设置', 'aksrt') . ' ' + . esc_html__('中启用此功能', 'aksrt') + . '

'; + } + + // 操作结果回跳提示:标题 + 说明,成功 / 错误两种形态。 + $inv_title = isset($_GET['aksrt_inv_title']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_title']))) : ''; + $inv_msg = isset($_GET['aksrt_inv_msg']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_msg']))) : ''; + $inv_type = isset($_GET['aksrt_inv_type']) && $_GET['aksrt_inv_type'] === 'error' ? 'error' : 'success'; + if ($inv_title !== '') { + echo '

' . esc_html($inv_title) . '

' + . ($inv_msg !== '' ? '

' . esc_html($inv_msg) . '

' : '') . '
'; + } ?> -
-

-

- -

-
-
- - - - - - - - - - - - - - - - - - - - - - -
-
- - - -

-
-
- - - -
-
- -
- - - - - - - - - - - - - - -
-
+
+ + + 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 '' . $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 '
'; + echo ''; + echo ''; + echo '
+ + +
'; + echo '
'; + echo ''; + echo ''; + foreach ($columns as $column) { + $arrows = ''; + if ($column['key'] !== '') { + $up = add_query_arg(['orderby' => $column['key'], 'desc' => 'ASC'], $this_url); + $down = add_query_arg(['orderby' => $column['key'], 'desc' => 'DESC'], $this_url); + $arrows = '' + . ''; + } + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput -- 箭头为静态结构。 + } + echo ''; + + if ($data['items'] === []) { + echo ''; + } else { + foreach ($data['items'] as $item) { + $used = (int) $item['used_count']; + $max_uses = (int) $item['max_uses']; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + if ($used === 0) { + echo ''; + } else { + $label = $max_uses > 0 && $used < $max_uses ? sprintf(__('已使用 %1$d/%2$d', 'aksrt'), $used, $max_uses) : __('已使用', 'aksrt'); + echo ''; + } + echo ''; + } + } + echo '
' . esc_html($column['name']) . $arrows . '
' . esc_html__('未找到对应内容,或暂无内容', 'aksrt') . '
' . esc_html($item['code']) . '
' . wp_kses((string) $item['reward_text'], ['br' => []]) . '
' . esc_html($item['created_at']) . '' . esc_html($item['updated_at']) . '' . esc_html__('未使用', 'aksrt') . '' . esc_html($label) . ''; + foreach ((array) $item['recent_users'] as $user) { + $user_link = get_edit_user_link((int) $user['user_id']); + echo '
' . esc_html($user['name']) . ''; + } + echo '
'; + if ($item['remark'] !== '') { + echo '' . esc_html($item['remark']) . ''; + } + 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 '
'; + echo ''; + echo ''; + + echo '

' . esc_html__('在此添加邀请码', 'aksrt') . '

    ' + . '
  • ' . esc_html__('如果您已经准备好了邀请码资料,请选择导入的方式添加', 'aksrt') . '
  • ' + . '
  • ' . esc_html__('您也可以采用系统生成的方式,自动批量添加邀请码', 'aksrt') . '
'; + + // 添加方式(按钮组二选一)。 + echo '
' + . '' + . '' + . '
'; + + // 自动生成区。 + echo '
'; + echo '
' + . '' . esc_html__('个', 'aksrt') . '' + . '' . esc_html__('需要生成多少个邀请码(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') . '
'; + echo '
' + . '' + . '' . esc_html__('对生成的邀请码做标记标识,方便后期查找管理', 'aksrt') . '
'; + $this->render_invite_reward_fields(); + echo '
' + . '
'; + echo '
'; + echo '
' + . '' . esc_html__('位数', 'aksrt') . '' + . '' . esc_html__('自定义自动生成的长度(不能太短,太短可能会出现重复)', 'aksrt') . '
'; + echo '
'; + + // 导入区。 + echo '
'; + echo '

' . esc_html__('导入邀请码', 'aksrt') . '

    ' + . '
  • ' . esc_html__('一行一个邀请码,单行格式为:', 'aksrt') . '' . esc_html__('邀请码 奖励 标识', 'aksrt') . '
  • ' + . '
  • ' . esc_html__('邀请码、奖励、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '
  • ' + . '
  • ' . esc_html__('奖励格式为:', 'aksrt') . 'level_integral=10&points=20&balance=30&vip=1&vip_time=5,' . esc_html__('无需奖励填', 'aksrt') . ' ' . esc_html__('无', 'aksrt') . '
  • ' + . '
  • ' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '
' + . '

' . esc_html__('数据示例', 'aksrt') . '
' + . '93FW0XAY ' . esc_html__('无', 'aksrt') . ' ' . esc_html__('没有奖励', 'aksrt') . '
' + . 'P86NPWKI level_integral=100 ' . esc_html__('奖励100经验值', 'aksrt') . '
' + . 'P86NPWKI points=10 ' . esc_html__('奖励10积分', 'aksrt') . '
' + . 'auF9D2B4 balance=30&vip=1&vip_time=5 ' . esc_html__('奖励余额30和5天1级会员', 'aksrt') . '

'; + echo '
' + . '
'; + echo '
' + . '' + . '' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '
'; + echo '
'; + + echo '
' + . '
'; + echo '
'; + } + + /** 使用奖励字段集:经验值 / 积分 / 余额 / VIP 会员与赠送时长。 */ + private function render_invite_reward_fields(): void { + echo '
'; + echo '
' + . '' + . '' + . '' + . '
'; + echo '
'; + echo '
' + . '' . 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 '
'; + echo ''; + echo ''; + + echo '
' + . '' + . '' + . '' + . '
'; + echo '
' + . '' + . '' + . '
'; + echo '
'; + echo '
' + . '' + . '' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '
'; + echo '
'; + 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', () => {