refactor(referral): drop admin referral-code ops page, self-serve codes

- 移除「用户 → 推广码运营」页面(trait、子菜单、admin SPA 跳转路由)
- 移除管理端 REST:/revenue/codes 列表与 generate/disable/regenerate 变更接口,
  清理 rest_codes/rest_mutate_code/can_manage_codes/list_codes 死代码
- 推广码改为注册即得:bind_registered_user 内生成唯一推广码,无需后台管理
- 推广配置统一收敛到「支付&付费 → 推广返佣」设置段(现状保留,无改动)
- 契约测试固化:无管理路由/页面残留 + 注册即得码
This commit is contained in:
2026-09-21 21:07:38 +08:00
parent 3dd3f71c97
commit a5f0484466
5 changed files with 23 additions and 328 deletions
-1
View File
@@ -78,7 +78,6 @@ function createSectionRedirectRoutes() {
*/
const NATIVE_OPS_REDIRECTS = [
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
{ path: '/users/referral-codes', title: '推广码运营', nativeAdminPath: 'users.php?page=aksrt_referral_codes' },
{ path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban' },
]
-10
View File
@@ -11,7 +11,6 @@ class UsersMenus {
use UsersMenusVerify;
use UsersMenusVip;
use UsersMenusReferral;
use UsersMenusInviteCode;
use UsersMenusTickets;
@@ -42,15 +41,6 @@ class UsersMenus {
}
public function register_menus(): void {
add_submenu_page(
'users.php',
__('推广码运营', 'aksrt'),
__('推广码运营', 'aksrt'),
'edit_users',
'aksrt_referral_codes',
[$this, 'render_referral_codes_page']
);
add_submenu_page(
'users.php',
__('邀请码管理', 'aksrt'),
-163
View File
@@ -1,163 +0,0 @@
<?php
/**
* Native user referral-code operations.
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusReferral {
public function render_referral_codes_page(): void {
if (!current_user_can('edit_users')) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
$endpoint = rest_url('aksrt/v1/revenue/codes');
$nonce = wp_create_nonce('wp_rest');
?>
<div class="wrap" id="aksrt-referral-ops">
<h1><?php esc_html_e('推广码运营', 'aksrt'); ?></h1>
<p><?php esc_html_e('推广码与用户一一绑定。禁用或重新生成只影响后续访问和注册,不改变已有推广关系。', 'aksrt'); ?></p>
<div class="tablenav top">
<div class="alignleft actions">
<label class="screen-reader-text" for="aksrt-ref-status"><?php esc_html_e('筛选状态', 'aksrt'); ?></label>
<select id="aksrt-ref-status">
<option value="all"><?php esc_html_e('全部状态', 'aksrt'); ?></option>
<option value="active"><?php esc_html_e('已启用', 'aksrt'); ?></option>
<option value="disabled"><?php esc_html_e('已禁用', 'aksrt'); ?></option>
<option value="missing"><?php esc_html_e('未生成', 'aksrt'); ?></option>
</select>
<input type="search" id="aksrt-ref-search" placeholder="<?php esc_attr_e('搜索归属用户或推广码', 'aksrt'); ?>">
<button type="button" class="button" id="aksrt-ref-filter"><?php esc_html_e('筛选', 'aksrt'); ?></button>
</div>
</div>
<div id="aksrt-ref-notice" role="status" aria-live="polite"></div>
<table class="wp-list-table widefat fixed striped">
<thead><tr>
<th><?php esc_html_e('归属用户', 'aksrt'); ?></th>
<th><?php esc_html_e('推广码', 'aksrt'); ?></th>
<th><?php esc_html_e('启用状态', 'aksrt'); ?></th>
<th style="text-align:right;"><?php esc_html_e('推广用户数', 'aksrt'); ?></th>
<th style="text-align:right;"><?php esc_html_e('有效付费用户数', 'aksrt'); ?></th>
<th><?php esc_html_e('创建时间', 'aksrt'); ?></th>
<th><?php esc_html_e('操作', 'aksrt'); ?></th>
</tr></thead>
<tbody id="aksrt-ref-rows"><tr><td colspan="7"><?php esc_html_e('正在加载…', 'aksrt'); ?></td></tr></tbody>
</table>
<div class="tablenav bottom"><div class="tablenav-pages" id="aksrt-ref-pages"></div></div>
</div>
<script>
(() => {
const init = () => {
const root = document.getElementById('aksrt-referral-ops');
if (!root || root.dataset.aksrtReferralReady === '1') return;
const endpoint = <?php echo wp_json_encode($endpoint); ?>;
const nonce = <?php echo wp_json_encode($nonce); ?>;
const rows = root.querySelector('#aksrt-ref-rows');
const pages = root.querySelector('#aksrt-ref-pages');
const notice = root.querySelector('#aksrt-ref-notice');
const search = root.querySelector('#aksrt-ref-search');
const status = root.querySelector('#aksrt-ref-status');
const filter = root.querySelector('#aksrt-ref-filter');
if (!rows || !pages || !notice || !search || !status || !filter) {
console.error('[AKSRT] Referral operations could not initialize: required page elements are missing.');
return;
}
root.dataset.aksrtReferralReady = '1';
let currentPage = 1;
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;'
})[char]);
const setNotice = (message, isError = false) => {
notice.className = message ? `notice notice-${isError ? 'error' : 'success'} inline` : '';
notice.innerHTML = message ? `<p>${escapeHtml(message)}</p>` : '';
};
const request = async (url, options = {}) => {
const response = await fetch(url, {
credentials: 'same-origin',
...options,
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || '<?php echo esc_js(__('请求失败', 'aksrt')); ?>');
return payload;
};
const actionButton = (item) => {
const id = Number(item.user.id);
if (item.status === 'missing') {
return `<button class="button button-primary" data-ref-action="generate" data-user-id="${id}"><?php echo esc_js(__('生成', 'aksrt')); ?></button>`;
}
if (item.status === 'disabled') {
return `<button class="button" data-ref-action="regenerate" data-user-id="${id}"><?php echo esc_js(__('重新生成', 'aksrt')); ?></button>`;
}
return `<button class="button" data-ref-action="disable" data-user-id="${id}"><?php echo esc_js(__('禁用', 'aksrt')); ?></button> `
+ `<button class="button" data-ref-action="regenerate" data-user-id="${id}"><?php echo esc_js(__('重新生成', 'aksrt')); ?></button>`;
};
const load = async (page = 1) => {
currentPage = page;
setNotice('');
rows.innerHTML = '<tr><td colspan="7"><?php echo esc_js(__('正在加载…', 'aksrt')); ?></td></tr>';
const url = new URL(endpoint);
url.searchParams.set('page', String(page));
url.searchParams.set('per_page', '20');
url.searchParams.set('status', status.value);
if (search.value.trim()) url.searchParams.set('search', search.value.trim());
try {
const data = await request(url.toString());
rows.innerHTML = data.items.length ? data.items.map((item) => `
<tr>
<td><strong>${escapeHtml(item.user.name)}</strong><br><small>${escapeHtml(item.user.login)} · #${Number(item.user.id)}</small></td>
<td>${item.ref ? `<code>${escapeHtml(item.ref)}</code>` : '—'}</td>
<td>${escapeHtml({active:'<?php echo esc_js(__('已启用', 'aksrt')); ?>',disabled:'<?php echo esc_js(__('已禁用', 'aksrt')); ?>',missing:'<?php echo esc_js(__('未生成', 'aksrt')); ?>'}[item.status] || item.status)}</td>
<td style="text-align:right;">${Number(item.bound_count || 0)}</td>
<td style="text-align:right;">${Number(item.effective_payers || 0)}</td>
<td>${item.created_at ? escapeHtml(item.created_at) : '—'}</td>
<td>${actionButton(item)}</td>
</tr>`).join('') : '<tr><td colspan="7"><?php echo esc_js(__('没有符合条件的用户', 'aksrt')); ?></td></tr>';
pages.innerHTML = Array.from({length: Number(data.max_pages || 1)}, (_, index) => {
const number = index + 1;
return `<button type="button" class="button ${number === Number(data.page) ? 'button-primary' : ''}" data-ref-page="${number}">${number}</button>`;
}).join(' ');
} catch (error) {
rows.innerHTML = '<tr><td colspan="6"><?php echo esc_js(__('推广码数据加载失败', 'aksrt')); ?></td></tr>';
setNotice(error.message, true);
}
};
filter.addEventListener('click', () => load(1));
search.addEventListener('keydown', (event) => { if (event.key === 'Enter') load(1); });
root.addEventListener('click', async (event) => {
const pageButton = event.target.closest('[data-ref-page]');
if (pageButton) return load(Number(pageButton.dataset.refPage));
const button = event.target.closest('[data-ref-action]');
if (!button) return;
button.disabled = true;
try {
await request(`${endpoint}/${Number(button.dataset.userId)}/${button.dataset.refAction}`, { method: 'POST', body: '{}' });
setNotice('<?php echo esc_js(__('推广码已更新', 'aksrt')); ?>');
await load(currentPage);
} catch (error) {
setNotice(error.message, true);
button.disabled = false;
}
});
load();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
</script>
<?php
}
}
+5 -154
View File
@@ -116,6 +116,11 @@ final class ReferralAttribution {
}
public function bind_registered_user(int $invitee_id): void {
// 每个新用户注册即拥有唯一推广码(无需后台生成或管理)。
if ($invitee_id > 0) {
$this->generate_code($invitee_id, false);
}
if ($invitee_id <= 0 || !$this->feature_enabled()) {
self::$pending_registration_ref = '';
return;
@@ -353,39 +358,6 @@ final class ReferralAttribution {
'callback' => [$this, 'rest_me'],
'permission_callback' => static fn() => is_user_logged_in(),
]);
register_rest_route('aksrt/v1', '/revenue/codes', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_codes'],
'permission_callback' => [$this, 'can_manage_codes'],
'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' => 'all',
'enum' => ['all', self::STATUS_ACTIVE, self::STATUS_DISABLED, 'missing'],
],
],
]);
foreach (['generate', 'disable', 'regenerate'] as $action) {
register_rest_route('aksrt/v1', '/revenue/codes/(?P<user_id>\d+)/' . $action, [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => function (\WP_REST_Request $request) use ($action) {
return $this->rest_mutate_code($request, $action);
},
'permission_callback' => [$this, 'can_manage_codes'],
'args' => [
'user_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
],
]);
}
}
public function can_manage_codes(): bool {
return current_user_can('edit_users');
}
public function rest_me(): \WP_REST_Response {
@@ -418,127 +390,6 @@ final class ReferralAttribution {
], 200);
}
public function rest_codes(\WP_REST_Request $request): \WP_REST_Response {
return new \WP_REST_Response($this->list_codes([
'page' => (int) $request['page'],
'per_page' => (int) $request['per_page'],
'search' => (string) $request['search'],
'status' => (string) $request['status'],
]), 200);
}
public function rest_mutate_code(\WP_REST_Request $request, string $action) {
$user_id = (int) $request['user_id'];
if (!get_userdata($user_id)) {
return new \WP_Error('aksrt_ref_user_missing', __('用户不存在', 'aksrt'), ['status' => 404]);
}
$record = $this->code_record($user_id);
if ($action === 'generate') {
if ($record['status'] !== 'missing') {
return new \WP_Error('aksrt_ref_exists', __('该用户已有推广码', 'aksrt'), ['status' => 409]);
}
$code = $this->generate_code($user_id, false);
} elseif ($action === 'disable') {
if ($record['status'] !== self::STATUS_ACTIVE) {
return new \WP_Error('aksrt_ref_not_active', __('推广码当前不可禁用', 'aksrt'), ['status' => 409]);
}
update_user_meta($user_id, self::META_STATUS, self::STATUS_DISABLED);
if ($this->code_record($user_id)['status'] !== self::STATUS_DISABLED) {
return new \WP_Error('aksrt_ref_disable_failed', __('推广码禁用失败', 'aksrt'), ['status' => 500]);
}
$code = $record['code'];
} elseif ($action === 'regenerate') {
if ($record['status'] === 'missing') {
return new \WP_Error('aksrt_ref_missing', __('该用户尚未生成推广码', 'aksrt'), ['status' => 409]);
}
$code = $this->generate_code($user_id, true);
} else {
return new \WP_Error('aksrt_ref_action', __('不支持的推广码操作', 'aksrt'), ['status' => 400]);
}
if ($code === '') {
return new \WP_Error('aksrt_ref_generate_failed', __('推广码生成失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response([
'user_id' => $user_id,
'ref' => $code,
'status' => $action === 'disable' ? self::STATUS_DISABLED : self::STATUS_ACTIVE,
]);
}
private function list_codes(array $args): array {
global $wpdb;
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
$status = (string) ($args['status'] ?? 'all');
$search = trim((string) ($args['search'] ?? ''));
$where = ['1=1'];
$params = [];
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$where[] = '(u.user_login LIKE %s OR u.display_name LIKE %s OR u.user_email LIKE %s OR code.meta_value LIKE %s)';
array_push($params, $like, $like, $like, $like);
}
if ($status === 'missing') {
$where[] = "(code.meta_value IS NULL OR code.meta_value = '')";
} elseif ($status === self::STATUS_DISABLED) {
$where[] = "code.meta_value <> '' AND state.meta_value = 'disabled'";
} elseif ($status === self::STATUS_ACTIVE) {
$where[] = "code.meta_value <> '' AND (state.meta_value IS NULL OR state.meta_value <> 'disabled')";
}
$where_sql = implode(' AND ', $where);
$joins = $wpdb->prepare(
"LEFT JOIN {$wpdb->usermeta} code ON code.user_id=u.ID AND code.meta_key=%s LEFT JOIN {$wpdb->usermeta} state ON state.user_id=u.ID AND state.meta_key=%s LEFT JOIN {$wpdb->usermeta} created ON created.user_id=u.ID AND created.meta_key=%s",
self::META_CODE,
self::META_STATUS,
self::META_CREATED
);
$count_sql = "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u {$joins} WHERE {$where_sql}";
$total = (int) ($params
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
: $wpdb->get_var($count_sql));
$relation_table = \AKSRT\Core\DBMigrator::table('affiliate_relation');
$ledger_table = \AKSRT\Core\DBMigrator::table('commission_ledger');
$stats_joins = "LEFT JOIN (SELECT promoter_id, COUNT(*) AS bound_count FROM {$relation_table} WHERE status='bound' GROUP BY promoter_id) relation_stats ON relation_stats.promoter_id=u.ID "
. "LEFT JOIN (SELECT promoter_id, COUNT(DISTINCT source_user_id) AS effective_payers FROM {$ledger_table} WHERE original_amount > 0 GROUP BY promoter_id) payer_stats ON payer_stats.promoter_id=u.ID";
$list_sql = "SELECT DISTINCT u.ID, u.user_login, u.display_name, u.user_email, code.meta_value AS ref, state.meta_value AS ref_status, created.meta_value AS created_at, "
. "COALESCE(relation_stats.bound_count, 0) AS bound_count, COALESCE(payer_stats.effective_payers, 0) AS effective_payers "
. "FROM {$wpdb->users} u {$joins} {$stats_joins} WHERE {$where_sql} ORDER BY u.ID DESC LIMIT %d OFFSET %d";
$list_params = array_merge($params, [$per_page, ($page - 1) * $per_page]);
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$user_id = (int) $row['ID'];
$ref = sanitize_key((string) ($row['ref'] ?? ''));
$items[] = [
'user' => [
'id' => $user_id,
'login' => (string) $row['user_login'],
'name' => (string) $row['display_name'],
'email' => (string) $row['user_email'],
],
'ref' => $ref,
'status' => $ref === '' ? 'missing' : ((string) ($row['ref_status'] ?? '') === self::STATUS_DISABLED ? self::STATUS_DISABLED : self::STATUS_ACTIVE),
'created_at' => (string) ($row['created_at'] ?? ''),
'bound_count' => (int) ($row['bound_count'] ?? 0),
'effective_payers' => (int) ($row['effective_payers'] ?? 0),
];
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'max_pages' => max(1, (int) ceil($total / $per_page)),
];
}
private function generate_code(int $user_id, bool $force): string {
if ($user_id <= 0) {
return '';
@@ -75,3 +75,21 @@ test('lazy settlement backstop guards the earnings entry points', () => {
// 收益总览与我的推广两个入口都接入了兜底。
assert.match(attribution, /CommissionLedger::get_instance\(\)->lazy_settle_due\(\)/)
})
test('referral codes are self-serve: no admin ops page, granted at registration', () => {
const service = read('src', 'Payment', 'ReferralAttribution.php')
// 管理端代码已清理:无 /revenue/codes 路由、无后台增删改接口、无管理列表。
assert.doesNotMatch(service, /revenue\/codes/)
assert.doesNotMatch(service, /rest_mutate_code|rest_codes|can_manage_codes|private function list_codes/)
// 注册即得唯一推广码:bind_registered_user 内调用 generate_code,不依赖归因开关。
assert.match(service, /public function bind_registered_user\(int \$invitee_id\): void \{\s*\n\s*\/\/ 每个新用户注册即拥有唯一推广码[\s\S]*?\$this->generate_code\(\$invitee_id, false\)/)
// 用户菜单不再挂"推广码运营"页,trait 文件已删除。
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
assert.doesNotMatch(menus, /UsersMenusReferral|aksrt_referral_codes|render_referral_codes_page/)
assert.equal(fs.existsSync(path.join(root, 'src', 'Admin', 'Native', 'UsersMenusReferral.php')), false)
// admin SPA 不再保留跳转路由。
const router = read('assets', 'admin', 'src', 'router', 'index.js')
assert.doesNotMatch(router, /referral-codes/)
})