refactor(classes): organize inc/classes into domain folders

Move ~94 root class shells and root *-traits into domain directories
(core, auth, user, credits, content, payment/gateways, forum, shop,
sms, ai, messages, extensions, widgets, settings, admin). Update
bootstrap require paths, co-locate traits with owners, fix forum/user
relative requires, drop duplicate shop controller requires, and wire
home-modules load. Path-only; class names and init order unchanged.
This commit is contained in:
kate522 committed 2026-07-17 11:11:57 +08:00
1 parent 2a8fe68048
commit ce1c13b947
115 files changed
+171 -224

No files matched your search

File diff suppressed because it is too large. Load diff
+279
View File
@@ -0,0 +1,279 @@
<?php
/**
* AKSRT cash wallet (站内余额) — separate from points/credits.
*
* Storage:
* - Balance: user_meta `aksrt_balance` (float, 2 decimals)
* - Ledger: `{prefix}aksrt_balance_log`
*
* Mirrors Zibll zibpay_get_user_balance / zibpay_update_user_balance.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Balance {
public const META_KEY = 'aksrt_balance';
/**
* Current cash balance (yuan).
*/
public static function balance(int $user_id): float {
if ($user_id <= 0) {
return 0.0;
}
$val = get_user_meta($user_id, self::META_KEY, true);
return is_numeric($val) ? round((float) $val, 2) : 0.0;
}
public static function get_balance(int $user_id): float {
return self::balance($user_id);
}
/**
* Credit cash wallet.
*
* @param array<string,mixed> $ref
* @return float|WP_Error New balance.
*/
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) {
return new WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
}
$delta = round((float) $delta, 2);
if ($delta <= 0) {
return new WP_Error('aksrt_balance_delta', __('增加余额必须为正数', 'aksrt'));
}
$new = self::atomic_update($user_id, $delta, false);
if ($new === null) {
return new WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt'));
}
self::log($user_id, $delta, $new, $reason, $ref, $remark);
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
return $new;
}
/**
* Debit cash wallet.
*
* @param array<string,mixed> $ref
* @return float|WP_Error New balance.
*/
public static function deduct(int $user_id, float $cost, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) {
return new WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
}
$cost = round((float) $cost, 2);
if ($cost < 0) {
return new WP_Error('aksrt_balance_delta', __('扣减余额必须为非负数', 'aksrt'));
}
if ($cost === 0.0) {
return self::balance($user_id);
}
$new = self::atomic_update($user_id, $cost, true);
if ($new === null) {
$current = self::balance($user_id);
return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), [
'balance' => $current,
'cost' => $cost,
]);
}
self::log($user_id, -$cost, $new, $reason, $ref, $remark);
do_action('aksrt_balance_deducted', $user_id, $cost, $reason, $new);
return $new;
}
/**
* @return float|null
*/
private static function atomic_update(int $user_id, float $abs_delta, bool $is_deduct) {
global $wpdb;
$abs_delta = round(abs($abs_delta), 2);
$meta_key = self::META_KEY;
$table = $wpdb->usermeta;
// Ensure meta row exists.
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT umeta_id FROM {$table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
$user_id,
$meta_key
));
if (!$exists) {
add_user_meta($user_id, $meta_key, '0', true);
}
if ($is_deduct) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) - %f, 2) AS CHAR)
WHERE user_id = %d AND meta_key = %s
AND CAST(meta_value AS DECIMAL(18,2)) >= %f",
$abs_delta,
$user_id,
$meta_key,
$abs_delta
));
} else {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) + %f, 2) AS CHAR)
WHERE user_id = %d AND meta_key = %s",
$abs_delta,
$user_id,
$meta_key
));
}
if ($updated !== 1) {
return null;
}
wp_cache_delete($user_id, 'user_meta');
return self::balance($user_id);
}
/**
* @param array<string,mixed> $ref
*/
public static function log(int $user_id, float $delta, float $balance_after, string $reason, array $ref = [], string $remark = ''): void {
global $wpdb;
if (!class_exists('AKSRT_DB_Migrator')) {
return;
}
$table = AKSRT_DB_Migrator::table('balance_log');
if ($table === '') {
return;
}
$ip = '';
if (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR']));
}
$ua = '';
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
$ua = substr(sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])), 0, 255);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
[
'user_id' => $user_id,
'delta' => round($delta, 2),
'balance_after' => round($balance_after, 2),
'reason' => sanitize_key(substr($reason, 0, 64)),
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
'ref_type' => isset($ref['type']) ? sanitize_key((string) $ref['type']) : null,
'ref_id' => isset($ref['id']) ? (int) $ref['id'] : (isset($ref['ref_id']) ? (int) $ref['ref_id'] : null),
'ip' => $ip !== '' ? $ip : null,
'ua' => $ua !== '' ? $ua : null,
'created_at' => current_time('mysql', true),
],
['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
}
/**
* @param array<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,total:int}
*/
public static function get_log(array $args = []): array {
global $wpdb;
if (!class_exists('AKSRT_DB_Migrator')) {
return ['items' => [], 'total' => 0];
}
$table = AKSRT_DB_Migrator::table('balance_log');
if ($table === '') {
return ['items' => [], 'total' => 0];
}
$user_id = max(0, (int) ($args['user_id'] ?? 0));
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
$offset = ($page - 1) * $per_page;
$where = '1=1';
$params = [];
if ($user_id > 0) {
$where .= ' AND user_id = %d';
$params[] = $user_id;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$list_sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
$list_params = array_merge($params, [$per_page, $offset]);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
if (!is_array($rows)) {
$rows = [];
}
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'user_id' => (int) ($row['user_id'] ?? 0),
'delta' => round((float) ($row['delta'] ?? 0), 2),
'balance_after' => round((float) ($row['balance_after'] ?? 0), 2),
'reason' => (string) ($row['reason'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
return ['items' => $items, 'total' => $total];
}
/**
* Whether cash wallet feature is enabled (Zibll pay_balance_s).
*/
public static function enabled(): bool {
if (!class_exists('AKSRT_Settings')) {
return false;
}
if (method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) {
$cfg = AKSRT_Settings::get_payment_recharge_settings();
return !empty($cfg['enabled']);
}
return !empty(AKSRT_Settings::get_all()['pay_balance_s']);
}
/**
* Format yuan for display.
*/
public static function format(float $amount): string {
return number_format_i18n(round($amount, 2), 2);
}
/**
* Reason label for ledger UI.
*/
public static function reason_label(string $reason): string {
$map = [
'card_pass_balance' => __('卡密充值', 'aksrt'),
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'recharge' => __('余额充值', 'aksrt'),
'transfer_in' => __('转入', 'aksrt'),
'transfer_out' => __('转出', 'aksrt'),
'admin' => __('管理员调整', 'aksrt'),
'exchange_from_credits' => __('积分兑换', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
];
$key = sanitize_key($reason);
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
}
}
@@ -0,0 +1,247 @@
<?php
/**
* AKSRT Credits Earn — free-task matrix hooks via AKSRT_Points_Free.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Credits_Earn {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('user_register', [$this, 'on_user_register'], 10, 1);
add_action('wp_login', [$this, 'on_wp_login'], 20, 2);
add_action('wp_insert_comment', [$this, 'on_comment_inserted'], 10, 2);
add_action('transition_comment_status', [$this, 'on_comment_transition'], 10, 3);
add_action('transition_post_status', [$this, 'on_post_status'], 10, 3);
add_action('aksrt_post_liked', [$this, 'on_post_liked'], 10, 3);
add_action('aksrt_post_favorited', [$this, 'on_post_favorited'], 10, 3);
add_action('aksrt_comment_liked', [$this, 'on_comment_liked'], 10, 3);
add_action('aksrt_user_followed', [$this, 'on_user_followed'], 10, 2);
add_action('aksrt_forum_topic_created', [$this, 'on_forum_topic_created'], 10, 2);
add_action('comment_post', [$this, 'on_forum_reply_posted'], 20, 3);
add_action('aksrt_forum_topic_featured', [$this, 'on_forum_topic_featured'], 10, 2);
}
public function on_user_register(int $user_id): void {
if (!class_exists('AKSRT_Points_Free')) {
return;
}
AKSRT_Points_Free::grant($user_id, 'sign_up', 0, ['type' => 'user', 'id' => $user_id]);
}
/**
* Daily first login free points.
*
* @param string $user_login
* @param WP_User $user
*/
public function on_wp_login($user_login, $user = null): void {
unset($user_login);
$user_id = $user instanceof WP_User ? (int) $user->ID : 0;
if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
$today = (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
$last = (string) get_user_meta($user_id, AKSRT_Points_Free::META_LOGIN, true);
if ($last === $today) {
return;
}
$granted = AKSRT_Points_Free::grant($user_id, 'login', 0, ['type' => 'user', 'id' => $user_id]);
if (!is_wp_error($granted) && $granted >= 0) {
update_user_meta($user_id, AKSRT_Points_Free::META_LOGIN, $today);
}
}
/**
* @param string $new_status
* @param string $old_status
* @param WP_Post $post
*/
public function on_post_status($new_status, $old_status, $post): void {
if (!($post instanceof WP_Post) || $post->post_type !== 'post') {
return;
}
if ($new_status !== 'publish' || $old_status === 'publish') {
return;
}
$user_id = (int) $post->post_author;
if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
if (get_post_meta($post->ID, 'aksrt_credit_post_rewarded', true)) {
return;
}
$granted = AKSRT_Points_Free::grant($user_id, 'post_new', 0, ['type' => 'post', 'id' => (int) $post->ID]);
if (!is_wp_error($granted) && $granted > 0) {
update_post_meta($post->ID, 'aksrt_credit_post_rewarded', 1);
}
}
public function on_comment_inserted(int $comment_id, $comment): void {
$cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : [];
$reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : [];
// Legacy on_approved=true → wait for transition; default true.
$on_approved = !isset($reward['on_approved']) || !empty($reward['on_approved']);
if ($on_approved) {
return;
}
$comment = $comment instanceof WP_Comment ? $comment : get_comment($comment_id);
if (!($comment instanceof WP_Comment) || (int) $comment->comment_approved !== 1) {
return;
}
$this->reward_comment($comment);
}
public function on_comment_transition($new_status, $old_status, $comment): void {
unset($old_status);
if ($new_status !== 'approved' || !($comment instanceof WP_Comment)) {
return;
}
$this->reward_comment($comment);
}
private function reward_comment(WP_Comment $comment): void {
$user_id = (int) $comment->user_id;
if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) {
return;
}
// Skip forum topic replies (handled separately).
$post = get_post((int) $comment->comment_post_ID);
if ($post && $post->post_type === 'aksrt_forum_topic') {
return;
}
$cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : [];
$reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : [];
$min_length = (int) ($reward['min_length'] ?? 0);
if ($min_length > 0 && mb_strlen(wp_strip_all_tags((string) $comment->comment_content)) < $min_length) {
return;
}
$granted = AKSRT_Points_Free::grant($user_id, 'comment_new', 0, [
'type' => 'comment',
'id' => (int) $comment->comment_ID,
]);
if (!is_wp_error($granted)) {
update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', $granted > 0 ? 1 : 'capped');
}
}
/**
* @param int $post_id
* @param int $actor_id liker
* @param bool $active
*/
public function on_post_liked(int $post_id, int $actor_id, bool $active): void {
if (!$active || $post_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === $actor_id) {
return;
}
AKSRT_Points_Free::grant($author, 'post_like', 0, ['type' => 'post', 'id' => $post_id]);
}
public function on_post_favorited(int $post_id, int $actor_id, bool $active): void {
if (!$active || $post_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === $actor_id) {
return;
}
AKSRT_Points_Free::grant($author, 'post_favorite', 0, ['type' => 'post', 'id' => $post_id]);
}
public function on_comment_liked(int $comment_id, int $actor_id, bool $active): void {
if (!$active || $comment_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
$comment = get_comment($comment_id);
if (!$comment) {
return;
}
$author = (int) $comment->user_id;
if ($author <= 0 || $author === $actor_id) {
return;
}
AKSRT_Points_Free::grant($author, 'comment_like', 0, ['type' => 'comment', 'id' => $comment_id]);
}
public function on_user_followed(int $follower_id, int $following_id): void {
if ($following_id <= 0 || $follower_id === $following_id || !class_exists('AKSRT_Points_Free')) {
return;
}
AKSRT_Points_Free::grant($following_id, 'followed', 0, ['type' => 'user', 'id' => $follower_id]);
}
public function on_forum_topic_created(int $user_id, int $post_id): void {
if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
AKSRT_Points_Free::grant($user_id, 'bbs_posts_new', 0, ['type' => 'post', 'id' => $post_id]);
}
public function on_forum_reply_posted(int $comment_id, $approved, array $commentdata = []): void {
unset($commentdata);
if ((int) $approved !== 1 && $approved !== '1' && $approved !== true) {
// Still load comment and check.
}
$comment = get_comment($comment_id);
if (!$comment || (int) $comment->comment_approved !== 1) {
return;
}
$post = get_post((int) $comment->comment_post_ID);
if (!$post || $post->post_type !== 'aksrt_forum_topic') {
return;
}
$user_id = (int) $comment->user_id;
if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) {
return;
}
if (get_comment_meta($comment_id, 'aksrt_credit_forum_reply', true)) {
return;
}
$granted = AKSRT_Points_Free::grant($user_id, 'bbs_reply', 0, ['type' => 'comment', 'id' => $comment_id]);
if (!is_wp_error($granted)) {
update_comment_meta($comment_id, 'aksrt_credit_forum_reply', 1);
}
}
/**
* Essence reward is owned by AKSRT_Forum::reward_essence() (free-day-cap).
* Keep hook for external integrations only — do not double-grant.
*
* @param int $topic_id
* @param int $author_or_operator Author id from forum (2nd arg) or operator.
*/
public function on_forum_topic_featured(int $topic_id, int $author_or_operator = 0): void {
unset($topic_id, $author_or_operator);
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* AKSRT Credits Service
*
* 积分系统核心:余额读取、加减、日志写入。
*
* 数据存储:
* - 余额:user_meta key = 'aksrt_credits' (int)
* - 流水:表 {prefix}aksrt_credit_log
*
* 并发安全:
* - 所有加减操作通过 user_meta 直读 + DB 更新,
* 使用 wp_cache 失效 + 短事务,避免高并发下的丢失。
* - 同一用户短时间多次扣减时仍可能出现少量不一致,
* 可通过 P5 阶段的 token 唯一性兜底(同一资源不会被重复扣费)。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Credits {
/** user_meta 键名 */
const META_KEY = 'aksrt_credits';
/**
* 取用户余额。
*/
public static function balance(int $user_id): int {
if ($user_id <= 0) return 0;
$val = get_user_meta($user_id, self::META_KEY, true);
return is_numeric($val) ? (int) $val : 0;
}
/**
* Alias used by forum unlock/purchase paths.
*/
public static function get_balance(int $user_id): int {
return self::balance($user_id);
}
/**
* 增加积分。
*
* @param int $user_id
* @param int $delta 必须 > 0
* @param string $reason 例 register/signin/comment/invite/admin
* @param array $ref ['type' => 'post|comment|user|invite', 'id' => int]
* @return int|WP_Error 新余额或错误
*/
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
if ($delta <= 0) return new WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
$new_balance = self::atomic_update($user_id, $delta, false);
if ($new_balance === null) {
return new WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt'));
}
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
return $new_balance;
}
/**
* 扣减积分。
*
* @return int|WP_Error 新余额或错误
*/
public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
if ($cost < 0) return new WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt'));
if ($cost === 0) {
return self::balance($user_id);
}
$new_balance = self::atomic_update($user_id, $cost, true);
if ($new_balance === null) {
$current = self::balance($user_id);
return new WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [
'balance' => $current,
'cost' => $cost,
]);
}
self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark);
do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance);
return $new_balance;
}
/**
* 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。
*
* @param int $user_id
* @param int $abs_delta 绝对值
* @param bool $is_deduct true=扣减(带余额检查),false=增加
* @return int|null 新余额;失败(余额不足或 DB 错误)返回 null
*/
private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int {
global $wpdb;
// 确保 meta 行存在(首次扣费可能没有 row)
$existing = get_user_meta($user_id, self::META_KEY, true);
if ($existing === '' || $existing === false || $existing === null) {
update_user_meta($user_id, self::META_KEY, 0);
}
$meta_key = self::META_KEY;
if ($is_deduct) {
// 扣减:仅当 meta_value >= cost 时才更新
$sql = $wpdb->prepare(
"UPDATE {$wpdb->usermeta}
SET meta_value = CAST(meta_value AS SIGNED) - %d
WHERE user_id = %d
AND meta_key = %s
AND CAST(meta_value AS SIGNED) >= %d",
$abs_delta,
$user_id,
$meta_key,
$abs_delta
);
} else {
$sql = $wpdb->prepare(
"UPDATE {$wpdb->usermeta}
SET meta_value = CAST(meta_value AS SIGNED) + %d
WHERE user_id = %d
AND meta_key = %s",
$abs_delta,
$user_id,
$meta_key
);
}
$affected = $wpdb->query($sql);
if ($affected === false || $affected === 0) {
return null;
}
// 失效 user meta cache,下次 get_user_meta 会重读
wp_cache_delete($user_id, 'user_meta');
$new = (int) get_user_meta($user_id, self::META_KEY, true);
return $new;
}
/**
* 写一条流水。
*
* @param int $user_id
* @param int $delta 本次变动(正/负/0)
* @param int $balance_after 操作后余额
* @param string $reason
* @param array $ref
*/
public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void {
global $wpdb;
$table = AKSRT_DB_Migrator::table('credit_log');
if (!$table) return;
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null;
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
$wpdb->insert(
$table,
[
'user_id' => $user_id,
'delta' => $delta,
'balance_after' => $balance_after,
'reason' => substr($reason, 0, 64),
'remark' => $remark !== '' ? substr($remark, 0, 255) : null,
'ref_type' => $ref_type,
'ref_id' => $ref_id,
'ip' => self::client_ip(),
'ua' => self::client_ua(),
'created_at' => current_time('mysql', true),
],
['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
}
/**
* 取流水(管理员页面用)。
*
* @param array $args { user_id, reason, page, per_page, date_from, date_to }
* @return array { items, total, page, per_page }
*/
public static function get_log(array $args = []): array {
global $wpdb;
$table = AKSRT_DB_Migrator::table('credit_log');
if (!$table) {
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
}
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$offset = ($page - 1) * $per_page;
$where = ['1=1'];
$params = [];
if (!empty($args['user_id'])) {
$where[] = 'user_id = %d';
$params[] = (int) $args['user_id'];
}
if (!empty($args['reason'])) {
$where[] = 'reason = %s';
$params[] = sanitize_key((string) $args['reason']);
}
if (!empty($args['date_from'])) {
$where[] = 'created_at >= %s';
$params[] = (string) $args['date_from'];
}
if (!empty($args['date_to'])) {
$where[] = 'created_at <= %s';
$params[] = (string) $args['date_to'];
}
$where_sql = implode(' AND ', $where);
// total
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = (int) ($params
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
: $wpdb->get_var($count_sql));
// items
$items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
$items_params = array_merge($params, [$per_page, $offset]);
$items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A);
if (!is_array($items)) $items = [];
// user 名字预填
$uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items));
$names = [];
foreach ($uid_set as $uid) {
$u = get_userdata($uid);
if ($u) $names[$uid] = $u->display_name;
}
foreach ($items as &$row) {
$row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id'];
$row['delta'] = (int) $row['delta'];
$row['balance_after'] = (int) $row['balance_after'];
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
];
}
/**
* 查询用户余额列表(管理员)。
*
* @param array $args { search, page, per_page }
* @return array
*/
public static function list_users(array $args = []): array {
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$q = [
'number' => $per_page,
'paged' => $page,
'orderby' => 'registered',
'order' => 'DESC',
];
if (!empty($args['search'])) {
$q['search'] = '*' . esc_attr((string) $args['search']) . '*';
$q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename'];
}
$user_query = new WP_User_Query($q);
$users = $user_query->get_results();
$items = [];
foreach ($users as $u) {
$items[] = [
'id' => (int) $u->ID,
'login' => $u->user_login,
'email' => $u->user_email,
'display_name' => $u->display_name,
'balance' => self::balance((int) $u->ID),
'registered' => $u->user_registered,
];
}
return [
'items' => $items,
'total' => (int) $user_query->get_total(),
'page' => $page,
'per_page' => $per_page,
];
}
// ---------- 工具方法 ----------
private static function client_ip(): string {
if (function_exists('aksrt_get_client_ip')) {
return (string) aksrt_get_client_ip();
}
if (!empty($_SERVER['REMOTE_ADDR'])) {
return (string) $_SERVER['REMOTE_ADDR'];
}
return '';
}
private static function client_ua(): string {
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
return substr($ua, 0, 255);
}
}
+668
View File
@@ -0,0 +1,668 @@
<?php
/**
* AKSRT Invite
*
* 邀请注册系统:
* - 邀请人:每人一个唯一邀请码(aksrt_invite_code user_meta),生成后复用
* - 被邀请人:访问 ?invite=XYZ 时 set cookie aksrt_invite (30 天)
* - 注册时:从 cookie 读 invite_code,写入 aksrt_invite 表 status=registered
* - 邮箱验证完成后:状态升级 verified → 触发奖励 → status=rewarded
* - 反作弊:同 IP 当日上限(ip_daily_cap)
*
* REST:
* GET /invite/me 当前用户邀请码 + 统计
* GET /invite/list 当前用户的邀请列表
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Invite {
const COOKIE_NAME = 'aksrt_invite';
const COOKIE_TTL_DAYS = 30;
const META_INVITE_CODE = 'aksrt_invite_code';
const OPT_STANDALONE_CODES = 'aksrt_standalone_invite_codes';
/**
* 反向映射 option:code => user_id
*
* 注意:option 大小受限(默认非 autoload),10 万级用户没问题,
* 百万级仍可走,性能瓶颈在 update_option 写入;此处采用懒写策略:
* 仅在 get_or_create_code 生成新码时同步写入。
*/
const OPT_REVERSE_MAP = 'aksrt_invite_code_map';
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
// 在请求最早期捕获邀请码
add_action('init', [$this, 'capture_invite_cookie'], 1);
// 注册时记录邀请关系
add_action('user_register', [$this, 'on_user_register'], 20, 1);
// 邮箱验证后兑现
add_action('aksrt_email_verified', [$this, 'on_email_verified'], 10, 1);
// 用户删除时清理邀请码映射
add_action('delete_user', [$this, 'on_delete_user'], 10, 1);
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/invite/me', [
'methods' => 'GET',
'callback' => [$this, 'rest_me'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/invite/list', [
'methods' => 'GET',
'callback' => [$this, 'rest_list'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/** 捕获 ?invite=XYZ */
public function capture_invite_cookie(): void {
if (is_admin()) return;
if (empty($_GET['invite'])) return;
// 邀请注册功能关闭时不处理
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return;
$code = sanitize_key((string) $_GET['invite']);
if ($code === '') return;
// 校验邀请码是否对应真实用户
$inviter_id = $this->resolve_code($code);
if ($inviter_id <= 0) return;
// 已登录用户不能用自己的邀请码
if (is_user_logged_in() && $inviter_id === get_current_user_id()) return;
// 不覆盖已存在的(防多次访问被覆盖)
if (!empty($_COOKIE[self::COOKIE_NAME])) return;
$expires = time() + DAY_IN_SECONDS * self::COOKIE_TTL_DAYS;
// 不覆盖 secure 标记的检测
$secure = is_ssl();
// headers_sent 检查
if (!headers_sent()) {
setcookie(self::COOKIE_NAME, $code, [
'expires' => $expires,
'path' => COOKIEPATH ?: '/',
'domain' => COOKIE_DOMAIN,
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[self::COOKIE_NAME] = $code;
}
}
/** 注册:写入 aksrt_invite 表 */
public function on_user_register(int $invitee_id): void {
// 邀请注册功能关闭时不记录邀请关系
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return;
// 优先级 1:表单显式填写的邀请码(由 AKSRT_Auth_Service 注入)
$code = '';
$inviter_id = 0;
if (class_exists('AKSRT_Auth_Service')) {
$ctx = AKSRT_Auth_Service::take_pending_invite_context();
if (!empty($ctx['inviter_id']) && $ctx['inviter_id'] !== $invitee_id) {
$inviter_id = (int) $ctx['inviter_id'];
$code = (string) $ctx['code'];
}
}
// 优先级 2:fallback 到 cookie
if ($inviter_id <= 0) {
if (empty($_COOKIE[self::COOKIE_NAME])) return;
$code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]);
if ($code === '') return;
$inviter_id = $this->get_user_by_code($code);
}
if ($inviter_id <= 0 || $inviter_id === $invitee_id) return;
$this->record_invite_registration($inviter_id, $invitee_id, $code, 'registered');
// 清 cookie,避免再次触发
if (!headers_sent()) {
setcookie(self::COOKIE_NAME, '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN);
}
// 若配置不要求邮箱验证就立即兑现
$invite_cfg = class_exists('AKSRT_Settings')
? AKSRT_Settings::get_invite_reward_settings()
: [
'require_verify' => true,
];
if (empty($invite_cfg['require_verify'])) {
$this->reward_pair($inviter_id, $invitee_id);
}
}
/** 邮箱验证后兑现奖励(钩子由 AKSRT_Auth_Service 触发,可后续接入) */
public function on_email_verified(int $user_id): void {
// 邀请注册功能关闭时不兑现奖励
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return;
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
if (!$table) return;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE invitee_id = %d AND status IN ('registered', 'verified') LIMIT 1",
$user_id
), ARRAY_A);
if (!$row) return;
$wpdb->update(
$table,
['status' => 'verified'],
['id' => (int) $row['id']],
['%s'],
['%d']
);
if ((int) $row['inviter_id'] <= 0) {
return;
}
$this->reward_pair((int) $row['inviter_id'], $user_id);
}
/** 用户被删除时清理邀请码映射 */
public function on_delete_user(int $user_id): void {
$code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true);
if ($code === '') return;
$map = $this->load_reverse_map();
if (isset($map[$code]) && (int) $map[$code] === $user_id) {
unset($map[$code]);
update_option(self::OPT_REVERSE_MAP, $map, false);
}
}
/** 双向发放奖励 */
private function reward_pair(int $inviter_id, int $invitee_id): void {
if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return;
$invite_cfg = AKSRT_Settings::get_invite_reward_settings();
if (empty($invite_cfg['enabled'])) return;
// 反作弊:同 IP 当日上限
if (!$this->ip_daily_check($invite_cfg)) return;
$inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0);
$invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0);
if ($inviter_amount > 0) {
AKSRT_Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]);
}
if ($invitee_amount > 0) {
AKSRT_Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]);
}
// 标记 rewarded
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
if ($table) {
$wpdb->update(
$table,
[
'status' => 'rewarded',
'rewarded_at' => current_time('mysql', true),
],
[
'inviter_id' => $inviter_id,
'invitee_id' => $invitee_id,
],
['%s', '%s'],
['%d', '%d']
);
}
}
/** 同 IP 当日邀请兑现次数限制 */
private function ip_daily_check(array $invite_cfg): bool {
$cap = (int) ($invite_cfg['ip_daily_cap'] ?? 3);
if ($cap <= 0) return true;
$ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : '';
if (!$ip) return true;
global $wpdb;
$log_table = AKSRT_DB_Migrator::table('credit_log');
if (!$log_table) return true;
$today_utc = (new DateTimeImmutable('today', wp_timezone()))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$log_table} WHERE reason = 'invite' AND ip = %s AND created_at >= %s",
$ip,
$today_utc
));
return $count < $cap;
}
/** 取或生成用户的邀请码 */
public function get_or_create_code(int $user_id): string {
$code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true);
// 邀请码长度(后台可配置,4-12,默认 6)
$code_length = class_exists('AKSRT_Settings')
? AKSRT_Settings::invite_code_length()
: max(4, min(12, (int) aksrt_get_setting('invite_code_length', 6)));
// 兼容配置变更:已有码只要长度合理就复用
if ($code !== '' && strlen($code) <= $code_length + 2) return $code;
// 生成唯一码
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
$map = $this->load_reverse_map();
for ($i = 0; $i < 5; $i++) {
$code = '';
for ($j = 0; $j < $code_length; $j++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
if (!isset($map[$code]) && !$this->code_exists_legacy($code)) break;
}
update_user_meta($user_id, self::META_INVITE_CODE, $code);
// 同步反向映射
$map[$code] = $user_id;
update_option(self::OPT_REVERSE_MAP, $map, false);
return $code;
}
/**
* 公开 API:根据邀请码反查邀请人 user_id(不存在返回 0)。
* 给注册流程在 wp_create_user 之前做"邀请码是否合法"的预校验用。
*/
/**
* Resolve affiliate / invite referrer for a buyer (zibll-style).
*
* Priority:
* 1. Logged-in: inviter_id from aksrt_invite table for this invitee
* 2. Cookie invite code → resolve_code
*
* Respects pay_rebate_self_s when available (via affiliate settings).
*/
public function get_referrer(int $user_id = 0): int {
$uid = $user_id > 0 ? $user_id : get_current_user_id();
$referrer_id = 0;
if ($uid > 0) {
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
if ($table) {
$referrer_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT inviter_id FROM {$table} WHERE invitee_id = %d AND inviter_id > 0 ORDER BY id DESC LIMIT 1",
$uid
));
}
}
if ($referrer_id <= 0) {
$code = '';
if (!empty($_COOKIE[self::COOKIE_NAME])) {
$code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]);
}
if ($code !== '') {
$referrer_id = $this->resolve_code($code);
}
}
if ($referrer_id <= 0) {
return 0;
}
// Self-purchase rebate gate (default allow, matching pay_rebate_self_s default true).
$allow_self = true;
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_payment_affiliate_settings')) {
$aff = AKSRT_Settings::get_payment_affiliate_settings();
$allow_self = !isset($aff['self_s']) || !empty($aff['self_s']);
}
if ($uid > 0 && $referrer_id === $uid && !$allow_self) {
return 0;
}
return $referrer_id;
}
public function resolve_code(string $code): int {
$code = sanitize_key($code);
if ($code === '') return 0;
return $this->get_user_by_code($code);
}
/**
* 校验邀请码是否合法。
*
* 用户邀请码与后台批量生成的独立邀请码都视为有效。
*/
public function validate_code(string $code): bool {
$code = sanitize_key($code);
if ($code === '') {
return false;
}
if ($this->get_user_by_code($code) > 0) {
return true;
}
return $this->find_standalone_code_index($code) >= 0;
}
/**
* 注册完成后处理邀请码。
*
* - 用户邀请码:沿用 inviter/invitee canonical 关系
* - 独立邀请码:记录使用人并标记为已消耗,不参与奖励
*/
public function handle_registration_invite(int $invitee_id, string $invite_code): void {
$invitee_id = (int) $invitee_id;
$invite_code = sanitize_key($invite_code);
if ($invitee_id <= 0 || $invite_code === '') {
return;
}
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) {
return;
}
$inviter_id = $this->get_user_by_code($invite_code);
if ($inviter_id > 0) {
if ($inviter_id === $invitee_id) {
return;
}
$this->record_invite_registration($inviter_id, $invitee_id, $invite_code, 'registered');
return;
}
$index = $this->find_standalone_code_index($invite_code);
if ($index < 0) {
return;
}
$requires_verify = class_exists('AKSRT_Settings') && AKSRT_Settings::registration_requires_email_verification();
$status = $requires_verify ? 'registered' : 'verified';
if ($this->record_invite_registration(0, $invitee_id, $invite_code, $status)) {
$this->mark_standalone_code_used($invite_code, $invitee_id);
}
}
/** 反查邀请码对应的用户(O(1) option 查表,回退 user_meta 扫描) */
private function get_user_by_code(string $code): int {
if ($code === '') return 0;
$map = $this->load_reverse_map();
if (isset($map[$code])) {
$uid = (int) $map[$code];
if ($uid > 0) {
// 校验:确保该用户的 meta 仍然指向该 code(用户可能已被删除或 meta 被清空)
$meta_code = (string) get_user_meta($uid, self::META_INVITE_CODE, true);
if ($meta_code === $code) {
return $uid;
}
// 失配 → 修正映射
unset($map[$code]);
update_option(self::OPT_REVERSE_MAP, $map, false);
}
}
// 回退 + 自愈:扫描 user_meta(应只在迁移期或映射损坏时触发)
$uid = $this->code_exists_legacy($code);
if ($uid > 0) {
$map[$code] = $uid;
update_option(self::OPT_REVERSE_MAP, $map, false);
}
return $uid;
}
private function load_reverse_map(): array {
$map = get_option(self::OPT_REVERSE_MAP, []);
return is_array($map) ? $map : [];
}
/**
* 写入邀请关系记录。
*/
private function record_invite_registration(int $inviter_id, int $invitee_id, string $code, string $status = 'registered'): bool {
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
if (!$table || $invitee_id <= 0 || $code === '') {
return false;
}
$existing_code = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE invite_code = %s LIMIT 1",
$code
));
if ($existing_code) {
return false;
}
$existing_invitee = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1",
$invitee_id
));
if ($existing_invitee) {
return false;
}
$result = $wpdb->insert(
$table,
[
'inviter_id' => max(0, $inviter_id),
'invitee_id' => $invitee_id,
'invite_code' => $code,
'status' => $status,
'created_at' => current_time('mysql', true),
],
['%d', '%d', '%s', '%s', '%s']
);
return (bool) $result;
}
private function load_standalone_codes(): array {
$codes = get_option(self::OPT_STANDALONE_CODES, []);
return is_array($codes) ? array_values($codes) : [];
}
private function save_standalone_codes(array $codes): void {
update_option(self::OPT_STANDALONE_CODES, array_values($codes), false);
}
private function normalize_standalone_code(array $entry): array {
$code = sanitize_key((string) ($entry['code'] ?? ''));
$used_by = (int) ($entry['used_by'] ?? 0);
$used = !empty($entry['used']) || $used_by > 0;
return [
'code' => $code,
'created_at' => sanitize_text_field((string) ($entry['created_at'] ?? '')),
'used' => $used,
'used_by' => $used_by,
'used_at' => sanitize_text_field((string) ($entry['used_at'] ?? '')),
'disabled' => !empty($entry['disabled']),
'note' => sanitize_textarea_field((string) ($entry['note'] ?? '')),
'batch_id' => sanitize_text_field((string) ($entry['batch_id'] ?? '')),
'created_by' => (int) ($entry['created_by'] ?? 0),
];
}
private function find_standalone_code_index(string $code): int {
$code = sanitize_key($code);
$codes = $this->load_standalone_codes();
foreach ($codes as $index => $entry) {
$normalized = $this->normalize_standalone_code((array) $entry);
if ($normalized['code'] !== $code) {
continue;
}
if ($normalized['disabled'] || $normalized['used']) {
return -1;
}
return $index;
}
return -1;
}
private function mark_standalone_code_used(string $code, int $invitee_id): void {
$code = sanitize_key($code);
$codes = $this->load_standalone_codes();
foreach ($codes as $index => $entry) {
$normalized = $this->normalize_standalone_code((array) $entry);
if ($normalized['code'] !== $code) {
continue;
}
$normalized['used'] = true;
$normalized['used_by'] = $invitee_id;
$normalized['used_at'] = current_time('mysql', true);
$codes[$index] = $normalized;
$this->save_standalone_codes($codes);
return;
}
}
/** 旧路径:通过 user_meta 反查(保留作为自愈兜底) */
private function code_exists_legacy(string $code): int {
$users = get_users([
'meta_key' => self::META_INVITE_CODE,
'meta_value' => $code,
'number' => 1,
'fields' => 'ID',
]);
if (empty($users)) return 0;
return (int) $users[0];
}
private function code_exists(string $code): bool {
return $this->get_user_by_code($code) > 0;
}
// ---------- REST ----------
public function rest_me(WP_REST_Request $request) {
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) {
return new WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403);
}
$user_id = get_current_user_id();
$code = $this->get_or_create_code($user_id);
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
$stats = [
'registered' => 0,
'verified' => 0,
'rewarded' => 0,
];
if ($table) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT status, COUNT(*) AS c FROM {$table} WHERE inviter_id = %d GROUP BY status",
$user_id
), ARRAY_A);
if (is_array($rows)) {
foreach ($rows as $r) {
$status = (string) $r['status'];
$count = (int) $r['c'];
// The stored status is terminal; dashboard stats are milestone totals.
if ($status === 'registered') {
$stats['registered'] += $count;
} elseif ($status === 'verified') {
$stats['registered'] += $count;
$stats['verified'] += $count;
} elseif ($status === 'rewarded') {
$stats['registered'] += $count;
$stats['verified'] += $count;
$stats['rewarded'] += $count;
}
}
}
}
$invite_url = add_query_arg('invite', $code, aksrt_get_register_url());
return new WP_REST_Response([
'code' => $code,
'invite_url' => $invite_url,
'stats' => $stats,
], 200);
}
public function rest_list(WP_REST_Request $request) {
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) {
return new WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403);
}
global $wpdb;
$user_id = get_current_user_id();
$page = max(1, (int) $request->get_param('page'));
$per_page = min(50, max(1, (int) $request->get_param('per_page') ?: 20));
$offset = ($page - 1) * $per_page;
$table = AKSRT_DB_Migrator::table('invite');
if (!$table) {
return new WP_REST_Response(['items' => [], 'total' => 0], 200);
}
$total = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE inviter_id = %d",
$user_id
));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE inviter_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
), ARRAY_A);
$items = [];
foreach ((array) $rows as $r) {
$invitee = $r['invitee_id'] ? get_userdata((int) $r['invitee_id']) : null;
$items[] = [
'id' => (int) $r['id'],
'invitee_id' => (int) $r['invitee_id'],
'invitee_name' => $invitee ? $invitee->display_name : '',
'invitee_avatar' => $invitee ? get_avatar_url((int) $invitee->ID, ['size' => 64]) : '',
'status' => (string) $r['status'],
'created_at' => (string) $r['created_at'],
'rewarded_at' => (string) $r['rewarded_at'],
];
}
return new WP_REST_Response([
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
], 200);
}
}
@@ -0,0 +1,384 @@
<?php
/**
* Free points tasks — Zibll points_free_opt parity.
*
* Unified grant path with daily free-points cap + per-day detail meta.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Points_Free {
public const META_DETAIL = 'aksrt_free_points_detail';
public const META_LOGIN = 'aksrt_free_points_login_date';
public const META_SIGNIN = 'aksrt_free_points_signin_date';
public const META_TASKS = 'aksrt_free_points_task_dates';
/**
* Task catalog: key => [label, short_desc, default_points]
*
* @return array<string, array{0:string,1:string,2:int}>
*/
public static function catalog(): array {
return [
'sign_up' => [__('注册奖励', 'aksrt'), __('新用户注册成功后发放', 'aksrt'), 10],
'sign_in' => [__('每日签到', 'aksrt'), __('完成每日签到', 'aksrt'), 1],
'login' => [__('每日登录', 'aksrt'), __('每日首次登录', 'aksrt'), 1],
'followed' => [__('被关注', 'aksrt'), __('被其他用户关注', 'aksrt'), 1],
'post_new' => [__('发布文章', 'aksrt'), __('发布并通过审核的文章', 'aksrt'), 5],
'post_like' => [__('文章被赞', 'aksrt'), __('你的文章被点赞', 'aksrt'), 1],
'post_favorite' => [__('文章被收藏', 'aksrt'), __('你的文章被收藏', 'aksrt'), 1],
'comment_new' => [__('发表评论', 'aksrt'), __('发表并通过审核的评论', 'aksrt'), 1],
'comment_like' => [__('评论被赞', 'aksrt'), __('你的评论被点赞', 'aksrt'), 1],
'bbs_posts_new' => [__('论坛发帖', 'aksrt'), __('发布论坛主题', 'aksrt'), 5],
'bbs_reply' => [__('论坛回复', 'aksrt'), __('回复论坛主题', 'aksrt'), 2],
'bbs_essence' => [__('帖子加精', 'aksrt'), __('主题被设为精华', 'aksrt'), 10],
'bbs_adopt' => [__('回复被采纳', 'aksrt'), __('问答帖回复被采纳', 'aksrt'), 20],
'bbs_posts_hot' => [__('热帖奖励', 'aksrt'), __('主题成为热帖', 'aksrt'), 5],
'bbs_comment_hot'=> [__('神评奖励', 'aksrt'), __('回复成为神评', 'aksrt'), 5],
'bbs_score_extra'=> [__('帖子加分', 'aksrt'), __('主题被加分时奖励', 'aksrt'), 3],
];
}
/**
* Runtime free-task settings from theme options.
*
* @return array{day_max:int,tasks:array<string,int>}
*/
public static function settings(): array {
$all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : [];
$credit = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : [];
$tasks = [];
foreach (self::catalog() as $key => $meta) {
$flat_key = 'points_free_' . $key;
if (array_key_exists($flat_key, $all)) {
$tasks[$key] = max(0, (int) $all[$flat_key]);
continue;
}
// Fallback mapping from legacy / parallel settings.
$tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]);
}
$day_max = array_key_exists('points_earn_daily_limit', $all)
? max(0, (int) $all['points_earn_daily_limit'])
: 50;
return [
'day_max' => $day_max,
'tasks' => $tasks,
];
}
/**
* Points configured for a task (0 = disabled).
*/
public static function task_points(string $key): int {
$cfg = self::settings();
return max(0, (int) ($cfg['tasks'][$key] ?? 0));
}
/**
* Whether free points system can run (credits enabled).
*/
public static function enabled(): bool {
return class_exists('AKSRT_Settings') && AKSRT_Settings::credits_enabled() && class_exists('AKSRT_Credits');
}
/**
* Today's free points total.
*/
public static function today_total(int $user_id): int {
if ($user_id <= 0) {
return 0;
}
$detail = self::detail_map($user_id);
$today = self::today_key();
return max(0, (int) ($detail[$today] ?? 0));
}
/**
* Remaining free points allowed today (PHP_INT_MAX if unlimited).
*/
public static function remaining_today(int $user_id): int {
$day_max = (int) self::settings()['day_max'];
if ($day_max <= 0) {
return PHP_INT_MAX;
}
return max(0, $day_max - self::today_total($user_id));
}
/**
* Daily free points detail (newest first).
*
* @return array<string,int>
*/
public static function date_detail(int $user_id): array {
$detail = self::detail_map($user_id);
krsort($detail, SORT_STRING);
return $detail;
}
/**
* Grant free points for a task. Respects day_max; clamps amount.
*
* @param array{type?:string,id?:int} $ref
* @return int|WP_Error granted amount (0 if skipped)
*/
public static function grant(int $user_id, string $task_key, int $points = 0, array $ref = []) {
if (!self::enabled() || $user_id <= 0) {
return 0;
}
$task_key = sanitize_key($task_key);
if ($task_key === '') {
return 0;
}
if ($points <= 0) {
$points = self::task_points($task_key);
}
$points = max(0, (int) $points);
if ($points <= 0) {
return 0;
}
$remaining = self::remaining_today($user_id);
if ($remaining <= 0) {
return 0;
}
if ($points > $remaining) {
$points = $remaining;
}
$reason = 'free_' . $task_key;
$result = AKSRT_Credits::add(
$user_id,
$points,
$reason,
$ref,
self::catalog()[$task_key][0] ?? $task_key
);
if (is_wp_error($result)) {
return $result;
}
self::bump_detail($user_id, $points);
self::mark_task_done_today($user_id, $task_key);
return $points;
}
/**
* Grant with explicit amount (e.g. signin streak reward), still under day_max.
*
* @return int|WP_Error
*/
public static function grant_amount(int $user_id, int $points, string $task_key, array $ref = []) {
return self::grant($user_id, $task_key, $points, $ref);
}
/**
* Task list DTO for user center UI.
*
* @return list<array{key:string,title:string,desc:string,points:int,enabled:bool}>
*/
public static function task_list_for_user(int $user_id = 0): array {
$cfg = self::settings();
$list = [];
$today = self::today_key();
$login_done = false;
$signin_done = false;
$task_dates = [];
if ($user_id > 0) {
$login_done = (string) get_user_meta($user_id, self::META_LOGIN, true) === $today;
$signin_done = (string) get_user_meta($user_id, self::META_SIGNIN, true) === $today;
$raw = get_user_meta($user_id, self::META_TASKS, true);
$task_dates = is_array($raw) ? $raw : [];
}
$passive = ['post_like', 'post_favorite', 'followed', 'comment_like', 'bbs_essence', 'bbs_adopt', 'bbs_posts_hot', 'bbs_comment_hot', 'bbs_score_extra'];
$active_once = ['post_new', 'comment_new', 'bbs_posts_new', 'bbs_reply'];
foreach (self::catalog() as $key => $meta) {
$pts = (int) ($cfg['tasks'][$key] ?? 0);
if ($key === 'sign_up') {
continue;
}
if ($pts <= 0) {
continue;
}
$status = 'todo';
$progress = null;
$target = null;
if ($key === 'login' && $login_done) {
$status = 'done_today';
$progress = 1;
$target = 1;
} elseif ($key === 'sign_in' && $signin_done) {
$status = 'done_today';
$progress = 1;
$target = 1;
} elseif (in_array($key, $passive, true)) {
$status = 'passive';
} elseif (in_array($key, $active_once, true)) {
$target = 1;
$done_day = is_array($task_dates[$key] ?? null) ? (string) ($task_dates[$key]['day'] ?? '') : (string) ($task_dates[$key] ?? '');
if ($done_day === $today) {
$status = 'done_today';
$progress = 1;
} else {
$progress = 0;
}
}
$list[] = [
'key' => $key,
'title' => $meta[0],
'desc' => $meta[1],
'points' => $pts,
'enabled' => true,
'status' => $status,
'progress' => $progress,
'target' => $target,
];
}
return $list;
}
/**
* Mark a free-task as completed today (for progress DTO).
*/
public static function mark_task_done_today(int $user_id, string $task_key): void {
if ($user_id <= 0) {
return;
}
$task_key = sanitize_key($task_key);
$today = self::today_key();
if ($task_key === 'login') {
update_user_meta($user_id, self::META_LOGIN, $today);
return;
}
if ($task_key === 'sign_in') {
update_user_meta($user_id, self::META_SIGNIN, $today);
return;
}
$raw = get_user_meta($user_id, self::META_TASKS, true);
$map = is_array($raw) ? $raw : [];
$map[$task_key] = ['day' => $today, 'ts' => time()];
if (count($map) > 40) {
$map = array_slice($map, -40, null, true);
}
update_user_meta($user_id, self::META_TASKS, $map);
}
private static function today_key(): string {
return (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
}
/**
* @return array<string,int>
*/
private static function detail_map(int $user_id): array {
$raw = get_user_meta($user_id, self::META_DETAIL, true);
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $day => $val) {
$day = is_string($day) ? $day : (string) $day;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) {
continue;
}
$out[$day] = max(0, (int) $val);
}
return $out;
}
private static function bump_detail(int $user_id, int $points): void {
if ($points <= 0) {
return;
}
$detail = self::detail_map($user_id);
$today = self::today_key();
$detail[$today] = max(0, (int) ($detail[$today] ?? 0)) + $points;
// Keep ~50 newest days.
krsort($detail, SORT_STRING);
$detail = array_slice($detail, 0, 50, true);
update_user_meta($user_id, self::META_DETAIL, $detail);
}
/**
* @param array<string,mixed> $all
* @param array<string,mixed> $credit
*/
private static function legacy_task_points(string $key, array $all, array $credit, int $default): int {
switch ($key) {
case 'sign_up':
return max(0, (int) ($credit['register_bonus'] ?? $default));
case 'sign_in':
$signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : [];
if (isset($signin['enabled']) && empty($signin['enabled'])) {
return 0;
}
return max(0, (int) ($signin['base'] ?? $default));
case 'login':
return max(0, (int) ($all['points_earn_login'] ?? $default));
case 'post_new':
return max(0, (int) ($all['points_earn_post'] ?? $default));
case 'post_like':
return max(0, (int) ($all['points_earn_like_received'] ?? $default));
case 'comment_new':
if (isset($all['points_earn_comment'])) {
return max(0, (int) $all['points_earn_comment']);
}
$cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : [];
if (isset($cr['enabled']) && empty($cr['enabled'])) {
return 0;
}
return max(0, (int) ($cr['amount'] ?? $default));
case 'bbs_posts_new':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['post_new'] ?? $f['credits']['topic_new'] ?? $all['bbs_credits_post_new'] ?? $default));
}
return max(0, (int) ($all['bbs_credits_post_new'] ?? $default));
case 'bbs_reply':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['comment_new'] ?? $default));
}
return $default;
case 'bbs_essence':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['essence'] ?? $default));
}
return $default;
case 'bbs_adopt':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['adopted'] ?? $default));
}
return $default;
case 'bbs_posts_hot':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['posts_hot'] ?? $default));
}
return $default;
case 'bbs_comment_hot':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['comment_hot'] ?? $f['credits']['comment_like'] ?? $default));
}
return $default;
case 'bbs_score_extra':
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) {
$f = AKSRT_Settings::get_forum_settings();
return max(0, (int) ($f['credits']['score_extra'] ?? $default));
}
return $default;
default:
return $default;
}
}
}
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* AKSRT Signin
*
* 每日签到服务:
* - REST GET /signin/today 返回今日是否签到 + 连续天数 + 今日奖励 + 明日奖励
* - REST POST /signin 执行签到
*
* 签到日期使用站点时区当日 0 点为切换点。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Signin {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/signin/today', [
'methods' => 'GET',
'callback' => [$this, 'rest_today'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/signin', [
'methods' => 'POST',
'callback' => [$this, 'rest_signin'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/** GET /signin/today */
public function rest_today(WP_REST_Request $request) {
$user_id = get_current_user_id();
$signin_cfg = class_exists('AKSRT_Settings')
? AKSRT_Settings::get_signin_reward_settings()
: [
'enabled' => false,
'base' => 0,
'streak' => [],
];
$today_str = $this->today_date();
$rec = $this->get_record($user_id, $today_str);
$current_streak = $this->compute_current_streak($user_id, $today_str);
$next_streak = $current_streak + ($rec ? 0 : 1);
return new WP_REST_Response([
'enabled' => !empty($signin_cfg['enabled']),
'signed' => (bool) $rec,
'streak' => $current_streak,
'reward_today' => $rec ? (int) $rec['reward'] : 0,
'reward_if_signin' => $rec ? 0 : $this->calc_reward($next_streak, $signin_cfg),
'date' => $today_str,
], 200);
}
/** POST /signin */
public function rest_signin(WP_REST_Request $request) {
global $wpdb;
$user_id = get_current_user_id();
$signin_cfg = class_exists('AKSRT_Settings')
? AKSRT_Settings::get_signin_reward_settings()
: [
'enabled' => false,
'base' => 0,
'streak' => [],
];
if (empty($signin_cfg['enabled'])) {
return new WP_Error('aksrt_signin_disabled', __('签到功能未启用', 'aksrt'), ['status' => 403]);
}
$today_str = $this->today_date();
if ($this->get_record($user_id, $today_str)) {
return new WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
}
// 人机验证(若启用)
if (function_exists('aksrt_require_captcha')) {
$body = $request->get_json_params();
$cap_payload = is_array($body) && isset($body['captcha']) && is_array($body['captcha'])
? $body['captcha']
: (is_array($body) ? $body : []);
$verify = aksrt_require_captcha('signin', $cap_payload);
if ($verify !== true) {
$msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt');
return new WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]);
}
}
$current_streak = $this->compute_current_streak($user_id, $today_str);
$new_streak = $current_streak + 1;
$reward = $this->calc_reward($new_streak, $signin_cfg);
$table = AKSRT_DB_Migrator::table('signin_log');
if (!$table) {
return new WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]);
}
$inserted = $wpdb->insert(
$table,
[
'user_id' => $user_id,
'signin_date' => $today_str,
'streak' => $new_streak,
'reward' => $reward,
],
['%d', '%s', '%d', '%d']
);
if ($inserted === false) {
return new WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
}
do_action('aksrt_user_signed_in', $user_id);
if ($reward > 0) {
if (class_exists('AKSRT_Points_Free')) {
$granted = AKSRT_Points_Free::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
if (is_wp_error($granted)) {
if (class_exists('AKSRT_Credits')) {
AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
} else {
$reward = (int) $granted;
}
} elseif (class_exists('AKSRT_Credits')) {
AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
}
return new WP_REST_Response([
'success' => true,
'streak' => $new_streak,
'reward' => $reward,
'balance' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0,
], 200);
}
// ---------- 工具方法 ----------
/** 站点时区下的今日日期 'Y-m-d' */
private function today_date(): string {
return (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
}
/** 取签到记录 */
private function get_record(int $user_id, string $date): ?array {
global $wpdb;
$table = AKSRT_DB_Migrator::table('signin_log');
if (!$table) return null;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE user_id = %d AND signin_date = %s",
$user_id,
$date
), ARRAY_A);
return $row ?: null;
}
/**
* 获取最近 N 天的签到记录
*
* @param int $user_id
* @param int $days
* @return array
*/
public function get_recent_signins(int $user_id, int $days = 30): array {
global $wpdb;
$table = AKSRT_DB_Migrator::table('signin_log');
if (!$table) return [];
$from_date = (new DateTimeImmutable("-{$days} days", wp_timezone()))->format('Y-m-d');
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date >= %s ORDER BY signin_date ASC",
$user_id,
$from_date
), ARRAY_A);
return $rows ?: [];
}
/**
* 算"今天之前"的连续签到天数。
*/
private function compute_current_streak(int $user_id, string $today_str): int {
global $wpdb;
$table = AKSRT_DB_Migrator::table('signin_log');
if (!$table) return 0;
// 拿最近 60 天的签到记录降序
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date <= %s ORDER BY signin_date DESC LIMIT 60",
$user_id,
$today_str
), ARRAY_A);
if (empty($rows)) return 0;
$dates = array_map(fn($r) => $r['signin_date'], $rows);
$today = new DateTimeImmutable($today_str);
$tz = wp_timezone();
// 起点:若今天已签则从今天回看,否则从昨天回看
$cursor = in_array($today_str, $dates, true)
? $today
: $today->sub(new DateInterval('P1D'));
$count = 0;
foreach ($dates as $d) {
if ($d === $cursor->format('Y-m-d')) {
$count++;
$cursor = $cursor->sub(new DateInterval('P1D'));
} else {
break;
}
}
return $count;
}
/**
* 根据连续天数计算奖励 = base + streak 段额外奖励。
*/
private function calc_reward(int $streak, array $cfg): int {
$base = (int) ($cfg['base'] ?? 1);
$extra = 0;
$streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : [];
// streak_table 是 { N: extra } —— 当 streak 恰好命中 N 时给 extra
// 对 array_keys 不稳定的存储格式做容错
$normalized = [];
foreach ($streak_table as $k => $v) {
$normalized[(int) $k] = (int) $v;
}
if (isset($normalized[$streak])) {
$extra = $normalized[$streak];
}
return max(0, $base + $extra);
}
}