refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
1 parent
56f1a8175f
commit
8c5b1555c9
285 files changed
+26
-3600
No files matched your search
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class 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 = '', bool $dispatch_hooks = true) {
|
||||
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);
|
||||
if ($dispatch_hooks) {
|
||||
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\Core\DBMigrator')) {
|
||||
return;
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::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\Core\DBMigrator')) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::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\Settings')) {
|
||||
return false;
|
||||
}
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
return !empty(\AKSRT\Settings\Settings::get_all()['pay_balance_s']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically transfer cash balance between two users.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return array{sender_balance:float,recipient_balance:float}|\WP_Error
|
||||
*/
|
||||
public static function transfer(int $sender_id, int $recipient_id, float $amount, string $reason_out = 'tip_sent', string $reason_in = 'tip_received', array $ref = [], string $remark = '') {
|
||||
global $wpdb;
|
||||
|
||||
$amount = round($amount, 2);
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id) {
|
||||
return new \WP_Error('aksrt_balance_transfer_user', __('转账用户无效', 'aksrt'));
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_balance_transfer_amount', __('转账金额必须大于零', 'aksrt'));
|
||||
}
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return new \WP_Error('aksrt_balance_transfer_unavailable', __('余额服务不可用', 'aksrt'));
|
||||
}
|
||||
|
||||
foreach ([$sender_id, $recipient_id] as $user_id) {
|
||||
if (!metadata_exists('user', $user_id, self::META_KEY)) {
|
||||
add_user_meta($user_id, self::META_KEY, '0', true);
|
||||
}
|
||||
}
|
||||
|
||||
$meta_table = $wpdb->usermeta;
|
||||
$log_table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
$locked_ids = [$sender_id, $recipient_id];
|
||||
sort($locked_ids, SORT_NUMERIC);
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
foreach ($locked_ids as $user_id) {
|
||||
$wpdb->get_var($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s FOR UPDATE",
|
||||
$user_id,
|
||||
self::META_KEY
|
||||
));
|
||||
}
|
||||
|
||||
$sender_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$sender_id,
|
||||
self::META_KEY
|
||||
));
|
||||
if ($sender_before < $amount) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'));
|
||||
}
|
||||
|
||||
$sender_after = round($sender_before - $amount, 2);
|
||||
$recipient_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$recipient_id,
|
||||
self::META_KEY
|
||||
));
|
||||
$recipient_after = round($recipient_before + $amount, 2);
|
||||
|
||||
$sender_updated = $wpdb->update($meta_table, ['meta_value' => (string) $sender_after], ['user_id' => $sender_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
$recipient_updated = $wpdb->update($meta_table, ['meta_value' => (string) $recipient_after], ['user_id' => $recipient_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
if ($sender_updated !== 1 || $recipient_updated !== 1) {
|
||||
throw new \RuntimeException('balance_update_failed');
|
||||
}
|
||||
|
||||
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : 'tip';
|
||||
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
||||
$created_at = current_time('mysql', true);
|
||||
$log_rows = [
|
||||
[$sender_id, -$amount, $sender_after, $reason_out],
|
||||
[$recipient_id, $amount, $recipient_after, $reason_in],
|
||||
];
|
||||
foreach ($log_rows as [$user_id, $delta, $balance_after, $reason]) {
|
||||
$inserted = $wpdb->insert($log_table, [
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $balance_after,
|
||||
'reason' => sanitize_key($reason),
|
||||
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
|
||||
'ref_type' => $ref_type,
|
||||
'ref_id' => $ref_id,
|
||||
'created_at' => $created_at,
|
||||
], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
throw new \RuntimeException('balance_log_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
wp_cache_delete($sender_id, 'user_meta');
|
||||
wp_cache_delete($recipient_id, 'user_meta');
|
||||
do_action('aksrt_balance_transferred', $sender_id, $recipient_id, $amount, $ref);
|
||||
return ['sender_balance' => $sender_after, 'recipient_balance' => $recipient_after];
|
||||
} catch (\Throwable $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_transfer_failed', __('打赏入账失败,请稍后重试', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
'tip_sent' => __('打赏支出', 'aksrt'),
|
||||
'tip_received' => __('收到打赏', '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,33 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class BalanceService {
|
||||
use BalanceServiceRest;
|
||||
use BalanceServiceCharge;
|
||||
use BalanceServicePoints;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
|
||||
|
||||
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']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait BalanceServiceCharge {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* When a recharge order is paid, credit the wallet.
|
||||
*
|
||||
* @param string $order_number Order number.
|
||||
* @param array<string,mixed> $order Order row.
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $order = []): void {
|
||||
if ($order === [] && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: [];
|
||||
}
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Points purchase fulfill (zibll order_type=9).
|
||||
if ($type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
if (!class_exists('AKSRT\\Credits\\Credits')) {
|
||||
return;
|
||||
}
|
||||
$note = json_decode((string) ($order['note'] ?? ''), true);
|
||||
if (!is_array($note)) {
|
||||
$note = [];
|
||||
}
|
||||
if (!empty($note['points_credited'])) {
|
||||
return;
|
||||
}
|
||||
$rate = 100;
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$rate = max(1, (int) (\AKSRT\Settings\Settings::get_all()['points_rate'] ?? 100));
|
||||
}
|
||||
$points = isset($note['points_amount'])
|
||||
? (int) $note['points_amount']
|
||||
: (int) round((float) ($order['amount'] ?? 0) * $rate);
|
||||
if ($points <= 0) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return;
|
||||
}
|
||||
$add = Credits::add(
|
||||
$user_id,
|
||||
$points,
|
||||
'points_buy',
|
||||
['type' => 'order', 'order' => $order_number],
|
||||
sprintf(__('购买积分订单 %s', 'aksrt'), $order_number),
|
||||
false
|
||||
);
|
||||
if (is_wp_error($add)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
$note['points_credited'] = 1;
|
||||
$note['credited_points'] = $points;
|
||||
$updated = $wpdb->update(
|
||||
$wpdb->prefix . 'aksrt_order',
|
||||
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
if ($updated === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
do_action('aksrt_credits_added', $user_id, $points, 'points_buy', (int) $add);
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('购买积分已到账。', 'aksrt')]
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return;
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return;
|
||||
}
|
||||
// Honor pay_auto_to_balance (default on). When off, leave order paid without wallet credit.
|
||||
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge_cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
if (isset($recharge_cfg['auto_to_balance']) && empty($recharge_cfg['auto_to_balance'])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$note = json_decode((string) ($order['note'] ?? ''), true);
|
||||
if (!is_array($note)) {
|
||||
$note = [];
|
||||
}
|
||||
if (!empty($note['balance_credited'])) {
|
||||
return;
|
||||
}
|
||||
$credit_amount = isset($note['credit_amount'])
|
||||
? round((float) $note['credit_amount'], 2)
|
||||
: round((float) ($order['amount'] ?? 0), 2);
|
||||
if ($credit_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return;
|
||||
}
|
||||
$result = Balance::add(
|
||||
$user_id,
|
||||
$credit_amount,
|
||||
'recharge',
|
||||
['type' => 'order', 'order' => $order_number],
|
||||
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
|
||||
false
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
$note['balance_credited'] = 1;
|
||||
$note['credited_amount'] = $credit_amount;
|
||||
$updated = $wpdb->update(
|
||||
$wpdb->prefix . 'aksrt_order',
|
||||
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
if ($updated === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('充值金额已到账。', 'aksrt')]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_charge_view(int $user_id): array {
|
||||
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
$packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? ''));
|
||||
$custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']);
|
||||
$custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999));
|
||||
$methods = [];
|
||||
if (function_exists('aksrt_list_cash_payment_methods')) {
|
||||
// Exclude balance gateway for recharge (cannot pay wallet with wallet).
|
||||
foreach (aksrt_list_cash_payment_methods(true) as $m) {
|
||||
$id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m;
|
||||
if ($id === '' || $id === 'balance') {
|
||||
continue;
|
||||
}
|
||||
$methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'desc' => (string) ($cfg['desc'] ?? ''),
|
||||
'wallet' => class_exists('AKSRT\\Credits\\Balance') ? Balance::balance($user_id) : 0.0,
|
||||
'packages' => $packages,
|
||||
'custom_s' => $custom_s,
|
||||
'custom_limit' => $custom_limit,
|
||||
'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'),
|
||||
'payment_methods' => $methods,
|
||||
'transfer' => [
|
||||
'enabled' => !empty($cfg['transfer']['enabled']),
|
||||
'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0),
|
||||
'desc' => (string) ($cfg['transfer']['desc'] ?? ''),
|
||||
],
|
||||
'exchange' => [
|
||||
'enabled' => !empty($cfg['exchange']['enabled']),
|
||||
'rate' => (int) ($cfg['exchange']['rate'] ?? 100),
|
||||
'desc' => (string) ($cfg['exchange']['desc'] ?? ''),
|
||||
],
|
||||
'pass_charge' => is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<int,array{key:string,price:float,pay_price:float,title:string,tag:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$out = [];
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$i = 0;
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
// pay|credit|title OR credit|pay|title — Zibll uses price(credit)|pay_price|tag
|
||||
// Admin help: 支付金额|到账金额|标题
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
$pay = (float) $parts[0];
|
||||
$credit = (float) $parts[1];
|
||||
$title = $parts[2] ?? '';
|
||||
if ($pay <= 0 || $credit <= 0) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => (string) $i,
|
||||
'pay_price' => round($pay, 2),
|
||||
'price' => round($credit, 2),
|
||||
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit),
|
||||
'tag' => $title,
|
||||
];
|
||||
$i++;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|\WP_Error
|
||||
*/
|
||||
public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Payment\Order')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$view = $this->get_charge_view($user_id);
|
||||
$credit_amount = 0.0;
|
||||
$pay_amount = 0.0;
|
||||
$title = __('余额充值', 'aksrt');
|
||||
|
||||
if ($package_key === 'custom' || $package_key === '') {
|
||||
if (empty($view['custom_s'])) {
|
||||
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$custom_price = round($custom_price, 2);
|
||||
$min = 1.0;
|
||||
$max = (float) $view['custom_limit'];
|
||||
if ($custom_price < $min || $custom_price > $max) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = $custom_price;
|
||||
$credit_amount = $custom_price;
|
||||
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price);
|
||||
$package_key = 'custom';
|
||||
} else {
|
||||
$found = null;
|
||||
foreach ((array) $view['packages'] as $pkg) {
|
||||
if ((string) ($pkg['key'] ?? '') === (string) $package_key) {
|
||||
$found = $pkg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = (float) $found['pay_price'];
|
||||
$credit_amount = (float) $found['price'];
|
||||
$title = (string) $found['title'];
|
||||
}
|
||||
|
||||
if ($pay_amount <= 0 || $credit_amount <= 0) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
if ($payment_method === '' || $payment_method === 'balance') {
|
||||
// Prefer first non-balance method.
|
||||
$methods = $view['payment_methods'] ?? [];
|
||||
$payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay';
|
||||
}
|
||||
|
||||
$note = wp_json_encode([
|
||||
'balance_recharge' => 1,
|
||||
'package' => $package_key,
|
||||
'credit_amount' => $credit_amount,
|
||||
'pay_amount' => $pay_amount,
|
||||
'title' => $title,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
|
||||
'product_id' => 0,
|
||||
'amount' => $pay_amount,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$order['subject'] = $title;
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
|
||||
if (empty($resolved['gateway'])) {
|
||||
return new \WP_Error(
|
||||
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
|
||||
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
|
||||
['status' => 400, 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
|
||||
'paid_note' => __('余额充值支付完成', 'aksrt'),
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($payment_result['redirect'])) {
|
||||
return [
|
||||
'redirect' => true,
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
|
||||
// aksrt_order_paid, so fulfillment has run through the single hook path.
|
||||
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'wallet' => Balance::balance($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait BalanceServicePoints {
|
||||
|
||||
|
||||
|
||||
public function points_buy_enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return false;
|
||||
}
|
||||
$all = \AKSRT\Settings\Settings::get_all();
|
||||
return !empty($all['points_pay_s']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_points_buy_view(int $user_id): array {
|
||||
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$rate = max(1, (int) ($all['points_rate'] ?? 100));
|
||||
$packages_raw = (string) ($all['pay_points_product'] ?? '');
|
||||
$packages = $this->parse_points_packages($packages_raw);
|
||||
$custom_s = !isset($all['pay_points_product_custom_s']) || !empty($all['pay_points_product_custom_s']);
|
||||
$custom_limit = max(1, (int) ($all['pay_points_product_custom_limit'] ?? 99999));
|
||||
$methods = [];
|
||||
if (function_exists('aksrt_list_cash_payment_methods')) {
|
||||
foreach (aksrt_list_cash_payment_methods(true) as $m) {
|
||||
$id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m;
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
if ($id === 'balance' && !(class_exists('AKSRT\\Credits\\Balance') && Balance::enabled())) {
|
||||
continue;
|
||||
}
|
||||
$methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id];
|
||||
}
|
||||
}
|
||||
if (class_exists('AKSRT\\Credits\\Balance') && Balance::enabled()) {
|
||||
$has_bal = false;
|
||||
foreach ($methods as $m) {
|
||||
if (($m['id'] ?? '') === 'balance') {
|
||||
$has_bal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$has_bal) {
|
||||
array_unshift($methods, ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'enabled' => $this->points_buy_enabled(),
|
||||
'desc' => (string) ($all['pay_points_desc'] ?? ''),
|
||||
'rate' => $rate,
|
||||
'points' => class_exists('AKSRT\\Credits\\Credits') ? Credits::balance($user_id) : 0,
|
||||
'packages' => $packages,
|
||||
'custom_s' => $custom_s,
|
||||
'custom_limit' => $custom_limit,
|
||||
'payment_methods' => $methods,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<int,array{key:string,points:int,pay_price:float,title:string}>
|
||||
*/
|
||||
public function parse_points_packages(string $raw): array {
|
||||
$out = [];
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$i = 0;
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
// 支付金额|积分数量|标题
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
$pay = (float) $parts[0];
|
||||
$points = (int) $parts[1];
|
||||
$title = $parts[2] ?? '';
|
||||
if ($pay <= 0 || $points <= 0) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => (string) $i,
|
||||
'pay_price' => round($pay, 2),
|
||||
'points' => $points,
|
||||
'title' => $title !== '' ? $title : sprintf(__('%d 积分', 'aksrt'), $points),
|
||||
];
|
||||
$i++;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|\WP_Error
|
||||
*/
|
||||
public function create_points_buy_order(int $user_id, string $package_key, int $custom_points, string $payment_method) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Payment\Order')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$view = $this->get_points_buy_view($user_id);
|
||||
$rate = max(1, (int) ($view['rate'] ?? 100));
|
||||
$points_amount = 0;
|
||||
$pay_amount = 0.0;
|
||||
$title = __('购买积分', 'aksrt');
|
||||
|
||||
if ($package_key === 'custom' || $package_key === '') {
|
||||
if (empty($view['custom_s'])) {
|
||||
return new \WP_Error('aksrt_custom_closed', __('未开放自定义数量', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$custom_points = max(0, $custom_points);
|
||||
$max = (int) ($view['custom_limit'] ?? 99999);
|
||||
if ($custom_points < $rate || $custom_points > $max) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('购买积分需在 %d ~ %d 之间', 'aksrt'), $rate, $max), ['status' => 400]);
|
||||
}
|
||||
$points_amount = $custom_points;
|
||||
$pay_amount = round($custom_points / $rate, 2);
|
||||
if ($pay_amount <= 0) {
|
||||
$pay_amount = 0.01;
|
||||
}
|
||||
$title = sprintf(__('购买 %d 积分', 'aksrt'), $points_amount);
|
||||
$package_key = 'custom';
|
||||
} else {
|
||||
$found = null;
|
||||
foreach ((array) $view['packages'] as $pkg) {
|
||||
if ((string) ($pkg['key'] ?? '') === (string) $package_key) {
|
||||
$found = $pkg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('积分套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$points_amount = (int) $found['points'];
|
||||
$pay_amount = (float) $found['pay_price'];
|
||||
$title = (string) $found['title'];
|
||||
}
|
||||
if ($points_amount <= 0 || $pay_amount <= 0) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
if ($payment_method === '') {
|
||||
$methods = $view['payment_methods'] ?? [];
|
||||
$payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay';
|
||||
}
|
||||
|
||||
$note = wp_json_encode([
|
||||
'points_buy' => 1,
|
||||
'package' => $package_key,
|
||||
'points_amount' => $points_amount,
|
||||
'pay_amount' => $pay_amount,
|
||||
'title' => $title,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
|
||||
'product_id' => 0,
|
||||
'amount' => $pay_amount,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$order['subject'] = $title;
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
|
||||
if (empty($resolved['gateway'])) {
|
||||
return new \WP_Error(
|
||||
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
|
||||
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
|
||||
['status' => 400, 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
|
||||
'paid_note' => __('购买积分支付完成', 'aksrt'),
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
if (!empty($payment_result['redirect'])) {
|
||||
return [
|
||||
'redirect' => true,
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'points_amount' => $points_amount,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
];
|
||||
}
|
||||
// PaymentOrchestrator's paid transition already dispatched the fulfillment hook.
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'points_amount' => $points_amount,
|
||||
'points' => Credits::balance($user_id),
|
||||
'message' => __('购买成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait BalanceServiceRest {
|
||||
|
||||
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/pay/balance/charge', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_charge_options'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_charge_create'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/transfer', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_transfer'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/transfer/search', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_transfer_search'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/balance/exchange', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_exchange_from_credits'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/points/buy', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_points_buy_options'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_points_buy_create'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/balance/withdraw', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_balance_withdraw'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* GET charge options (packages + methods + limits).
|
||||
*/
|
||||
public function rest_charge_options(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => $this->get_charge_view($user_id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* POST create recharge order and initiate payment.
|
||||
*/
|
||||
public function rest_charge_create(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
|
||||
$custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0;
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
|
||||
|
||||
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Transfer balance or points between users.
|
||||
*/
|
||||
public function rest_transfer(\WP_REST_Request $request) {
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
|
||||
$recipient = (int) ($params['recipient'] ?? 0);
|
||||
$amount_raw = $params['amount'] ?? $params['price'] ?? 0;
|
||||
$from_id = get_current_user_id();
|
||||
|
||||
if (!in_array($type, ['balance', 'points'], true)) {
|
||||
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($from_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if ($recipient <= 0 || $recipient === $from_id) {
|
||||
return new \WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!get_userdata($recipient)) {
|
||||
return new \WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
if ($type === 'balance') {
|
||||
if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) {
|
||||
return new \WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$amount = round((float) $amount_raw, 2);
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return new \WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$from_bal = Balance::balance($from_id);
|
||||
if ($from_bal < $amount) {
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
|
||||
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Balance::deduct(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
Balance::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
);
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Balance::balance($from_id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// points transfer
|
||||
$points_transfer_on = !empty(\AKSRT\Settings\Settings::get_all()['points_transfer_s']);
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled() || !$points_transfer_on) {
|
||||
return new \WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$amount = (int) $amount_raw;
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) (\AKSRT\Settings\Settings::get_all()['points_service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount;
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Credits::deduct(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
Credits::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
);
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Credits::balance($from_id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function rest_transfer_search(\WP_REST_Request $request) {
|
||||
$s = sanitize_text_field((string) $request->get_param('s'));
|
||||
if (mb_strlen($s) < 1) {
|
||||
return rest_ensure_response(['success' => true, 'data' => ['users' => []]]);
|
||||
}
|
||||
$exclude = [get_current_user_id()];
|
||||
$q = new \WP_User_Query([
|
||||
'search' => '*' . $s . '*',
|
||||
'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'],
|
||||
'exclude' => $exclude,
|
||||
'number' => 20,
|
||||
'fields' => ['ID', 'display_name', 'user_login'],
|
||||
]);
|
||||
$users = [];
|
||||
foreach ((array) $q->get_results() as $u) {
|
||||
$users[] = [
|
||||
'id' => (int) $u->ID,
|
||||
'display_name' => (string) $u->display_name,
|
||||
'user_login' => (string) $u->user_login,
|
||||
'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]),
|
||||
];
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Exchange points → cash wallet (pay_balance_exchange_*).
|
||||
*/
|
||||
public function rest_exchange_from_credits(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
if (empty($cfg['exchange']['enabled'])) {
|
||||
return new \WP_Error('aksrt_exchange_closed', __('积分兑换余额未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$points = (int) ($params['points'] ?? 0);
|
||||
$rate = max(1, (int) ($cfg['exchange']['rate'] ?? 100)); // N points = 1 yuan
|
||||
if ($points < $rate) {
|
||||
return new \WP_Error('aksrt_exchange_min', sprintf(__('最少兑换 %d 积分', 'aksrt'), $rate), ['status' => 400]);
|
||||
}
|
||||
// Floor to whole yuan.
|
||||
$yuan = (int) floor($points / $rate);
|
||||
$cost = $yuan * $rate;
|
||||
if ($yuan <= 0 || $cost <= 0) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换数量无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$deduct = Credits::deduct(
|
||||
$user_id,
|
||||
$cost,
|
||||
'exchange_to_balance',
|
||||
['type' => 'balance'],
|
||||
sprintf(__('积分兑换余额 %d 元', 'aksrt'), $yuan)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
$add = Balance::add(
|
||||
$user_id,
|
||||
(float) $yuan,
|
||||
'exchange_from_credits',
|
||||
['type' => 'credits'],
|
||||
sprintf(__('积分兑换:消耗 %d 积分', 'aksrt'), $cost)
|
||||
);
|
||||
if (is_wp_error($add)) {
|
||||
// best-effort refund points
|
||||
Credits::add($user_id, $cost, 'exchange_refund', [], __('兑换失败退回积分', 'aksrt'));
|
||||
return $add;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => sprintf(__('兑换成功:+%d 元', 'aksrt'), $yuan),
|
||||
'wallet_balance' => Balance::balance($user_id),
|
||||
'credit_balance' => Credits::balance($user_id),
|
||||
'reload' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function rest_points_buy_options(\WP_REST_Request $request) {
|
||||
if (!$this->points_buy_enabled()) {
|
||||
return new \WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => $this->get_points_buy_view(get_current_user_id()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function rest_points_buy_create(\WP_REST_Request $request) {
|
||||
if (!$this->points_buy_enabled()) {
|
||||
return new \WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
|
||||
$custom_points = (int) ($params['custom_points'] ?? $params['points'] ?? 0);
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
|
||||
$result = $this->create_points_buy_order(get_current_user_id(), $package_key, $custom_points, $payment_method);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function rest_balance_withdraw(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (empty($settings['pay_balance_withdraw_s'])) {
|
||||
return new \WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
return new \WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
|
||||
return new \WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$amount = round((float) ($params['amount'] ?? 0), 2);
|
||||
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
|
||||
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
|
||||
$lowest = max(1, (float) ($settings['pay_balance_withdraw_lowest_money'] ?? $settings['pay_rebate_withdraw_lowest_money'] ?? 50));
|
||||
if ($amount < $lowest) {
|
||||
return new \WP_Error('aksrt_withdraw_min', sprintf(__('最低提现 %s 元', 'aksrt'), (string) $lowest), ['status' => 400]);
|
||||
}
|
||||
if ($account === '') {
|
||||
return new \WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
// One open balance withdrawal at a time (Zibll parity).
|
||||
if (\AKSRT\Payment\Withdrawal::get_instance()->has_open_withdrawal($user_id)) {
|
||||
return new \WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$wallet = Balance::balance($user_id);
|
||||
if ($wallet < $amount) {
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
|
||||
}
|
||||
$deduct = Balance::deduct($user_id, $amount, 'withdraw_hold', ['type' => 'withdraw'], __('余额提现申请冻结', 'aksrt'));
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($settings['withdraw_service_charge'] ?? 0)));
|
||||
$net = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
|
||||
$account_payload = wp_json_encode([
|
||||
'account' => $account,
|
||||
'gross_amount' => $amount,
|
||||
'fee_rate' => $fee_rate,
|
||||
'source' => 'balance',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
|
||||
$wid = \AKSRT\Payment\Withdrawal::get_instance()->create($user_id, $net, $method !== '' ? $method : 'alipay', $account_payload);
|
||||
if ($wid <= 0) {
|
||||
Balance::add($user_id, $amount, 'withdraw_refund', [], __('提现申请失败退回', 'aksrt'));
|
||||
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('提现申请已提交,请等待处理', 'aksrt'),
|
||||
'withdrawal_id' => $wid,
|
||||
'amount' => $amount,
|
||||
'net_amount' => $net,
|
||||
'wallet_balance' => Balance::balance($user_id),
|
||||
'reload' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class 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 = '', bool $dispatch_hooks = true) {
|
||||
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);
|
||||
|
||||
if ($dispatch_hooks) {
|
||||
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\Core\DBMigrator::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\Core\DBMigrator::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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class 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);
|
||||
}
|
||||
|
||||
public function on_user_register(int $user_id): void {
|
||||
if (!class_exists('AKSRT\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
PointsFree::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\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
$today = (new \DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
|
||||
$last = (string) get_user_meta($user_id, PointsFree::META_LOGIN, true);
|
||||
if ($last === $today) {
|
||||
return;
|
||||
}
|
||||
$granted = PointsFree::grant($user_id, 'login', 0, ['type' => 'user', 'id' => $user_id]);
|
||||
if (!is_wp_error($granted) && $granted >= 0) {
|
||||
update_user_meta($user_id, PointsFree::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\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
if (get_post_meta($post->ID, 'aksrt_credit_post_rewarded', true)) {
|
||||
return;
|
||||
}
|
||||
$granted = PointsFree::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\Settings') ? \AKSRT\Settings\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\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) {
|
||||
return;
|
||||
}
|
||||
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\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 = PointsFree::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\\Credits\\PointsFree')) {
|
||||
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;
|
||||
}
|
||||
PointsFree::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\\Credits\\PointsFree')) {
|
||||
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;
|
||||
}
|
||||
PointsFree::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\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
$comment = get_comment($comment_id);
|
||||
if (!$comment) {
|
||||
return;
|
||||
}
|
||||
$author = (int) $comment->user_id;
|
||||
if ($author <= 0 || $author === $actor_id) {
|
||||
return;
|
||||
}
|
||||
PointsFree::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\\Credits\\PointsFree')) {
|
||||
return;
|
||||
}
|
||||
PointsFree::grant($following_id, 'followed', 0, ['type' => 'user', 'id' => $follower_id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class 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\Settings') && !\AKSRT\Settings\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\Settings') && !\AKSRT\Settings\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\Settings')
|
||||
? \AKSRT\Settings\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\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return;
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::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\\Credits') || !class_exists('\AKSRT\Settings\Settings')) return;
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('invite');
|
||||
if (!$table) return;
|
||||
$claimed = $wpdb->update(
|
||||
$table,
|
||||
['status' => 'rewarding'],
|
||||
['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id, 'status' => 'verified'],
|
||||
['%s'],
|
||||
['%d', '%d', '%s']
|
||||
);
|
||||
if ($claimed !== 1) return;
|
||||
|
||||
$invite_cfg = \AKSRT\Settings\Settings::get_invite_reward_settings();
|
||||
if (empty($invite_cfg['enabled']) || !$this->ip_daily_check($invite_cfg)) {
|
||||
$wpdb->update($table, ['status' => 'verified'], ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id], ['%s'], ['%d', '%d']);
|
||||
return;
|
||||
}
|
||||
|
||||
$inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0);
|
||||
$invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0);
|
||||
$level_integral = max(0, (int) ($invite_cfg['invit_reward_level'] ?? 0));
|
||||
|
||||
if ($inviter_amount > 0) {
|
||||
Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]);
|
||||
}
|
||||
if ($invitee_amount > 0) {
|
||||
Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]);
|
||||
}
|
||||
if ($level_integral > 0 && class_exists('\AKSRT\User\Level')) {
|
||||
\AKSRT\User\Level::add($inviter_id, 'invitation', $level_integral, true);
|
||||
}
|
||||
|
||||
// 标记 rewarded
|
||||
$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\Core\DBMigrator::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\Settings')
|
||||
? \AKSRT\Settings\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\Core\DBMigrator::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\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) {
|
||||
$aff = \AKSRT\Settings\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\Settings') && !\AKSRT\Settings\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\Settings') && \AKSRT\Settings\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\Core\DBMigrator::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\Settings') && !\AKSRT\Settings\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\Core\DBMigrator::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\Settings') && !\AKSRT\Settings\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\Core\DBMigrator::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,333 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PointsFree {
|
||||
|
||||
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],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$credit = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\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\Settings') && \AKSRT\Settings\Settings::credits_enabled() && class_exists('AKSRT\\Credits\\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 = 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'];
|
||||
$active_once = ['post_new', 'comment_new'];
|
||||
|
||||
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));
|
||||
default:
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class 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']);
|
||||
add_action('wp_ajax_aksrt_checkin_details_modal', [$this, 'ajax_details_modal']);
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/history', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_history'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/details', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_details_html'],
|
||||
'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\Settings')
|
||||
? \AKSRT\Settings\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\Settings')
|
||||
? \AKSRT\Settings\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\Core\DBMigrator::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, $reward);
|
||||
|
||||
if ($reward > 0) {
|
||||
if (class_exists('AKSRT\\Credits\\PointsFree')) {
|
||||
$granted = PointsFree::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
|
||||
if (is_wp_error($granted)) {
|
||||
if (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
} else {
|
||||
$reward = (int) $granted;
|
||||
}
|
||||
} elseif (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep lightweight meta for header/badge consumers (zibll checkin_all_day).
|
||||
update_user_meta($user_id, 'aksrt_signin_' . $today_str, 1);
|
||||
$all_day = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
|
||||
if ($all_day <= 0) {
|
||||
// Fallback to zibll key if migrated users only have that meta.
|
||||
$all_day = (int) get_user_meta($user_id, 'checkin_all_day', true);
|
||||
}
|
||||
$all_day = $all_day + 1;
|
||||
update_user_meta($user_id, 'aksrt_checkin_all_day', $all_day);
|
||||
update_user_meta($user_id, 'checkin_all_day', $all_day); // zibll alias
|
||||
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $new_streak);
|
||||
update_user_meta($user_id, 'checkin_continuous_day', $new_streak); // zibll alias
|
||||
do_action('aksrt_checkin_done', $user_id);
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
/* translators: 1: reward points 2: streak days */
|
||||
__('签到成功,积分 +%1$d(连续 %2$d 天)', 'aksrt'),
|
||||
(int) $reward,
|
||||
(int) $new_streak
|
||||
),
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
'balance' => class_exists('AKSRT\Credits\Credits') ? Credits::balance($user_id) : 0,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/** GET /signin/history — last N days for calendar/modals. */
|
||||
public function rest_history(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$days = max(7, min(90, (int) ($request->get_param('days') ?: 30)));
|
||||
$rows = $this->get_recent_signins($user_id, $days);
|
||||
$dates = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!empty($row['signin_date'])) {
|
||||
$dates[] = (string) $row['signin_date'];
|
||||
}
|
||||
}
|
||||
$today = $this->today_date();
|
||||
return new \WP_REST_Response([
|
||||
'dates' => $dates,
|
||||
'streak' => $this->compute_current_streak($user_id, $today),
|
||||
'total' => $this->get_total_days($user_id),
|
||||
'signed' => (bool) $this->get_record($user_id, $today),
|
||||
'all_day' => (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true) ?: $this->get_total_days($user_id),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/** GET /signin/details — HTML fragment for remote modal. */
|
||||
public function rest_details_html(\WP_REST_Request $request) {
|
||||
$html = $this->render_details_modal_html(get_current_user_id());
|
||||
return new \WP_REST_Response(['success' => true, 'html' => $html], 200);
|
||||
}
|
||||
|
||||
/** admin-ajax HTML (remote modal fetch often hits admin-ajax). */
|
||||
public function ajax_details_modal(): void {
|
||||
if (!is_user_logged_in()) {
|
||||
wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401);
|
||||
}
|
||||
$html = $this->render_details_modal_html(get_current_user_id());
|
||||
wp_send_json_success(['html' => $html]);
|
||||
}
|
||||
|
||||
public function is_signed_today(int $user_id = 0): bool {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
if ($user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$today = $this->today_date();
|
||||
if ((bool) get_user_meta($user_id, 'aksrt_signin_' . $today, true)) {
|
||||
return true;
|
||||
}
|
||||
return (bool) $this->get_record($user_id, $today);
|
||||
}
|
||||
|
||||
public function get_total_days(int $user_id): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('signin_log');
|
||||
if (!$table) {
|
||||
return (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
|
||||
}
|
||||
$n = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* zibll checkin_details_modal lite HTML.
|
||||
*/
|
||||
public function render_details_modal_html(int $user_id): string {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user) {
|
||||
return '<div class="aksrt-checkin-modal"><p>' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
|
||||
}
|
||||
$cfg = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_signin_reward_settings()
|
||||
: ['enabled' => false, 'base' => 0, 'streak' => []];
|
||||
$today = $this->today_date();
|
||||
$rec = $this->get_record($user_id, $today);
|
||||
$streak = $this->compute_current_streak($user_id, $today);
|
||||
$total = $this->get_total_days($user_id);
|
||||
$balance = class_exists('\AKSRT\Credits\Credits') ? (int) Credits::balance($user_id) : 0;
|
||||
$currency = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_currency_name()
|
||||
: __('积分', 'aksrt');
|
||||
$base = (int) ($cfg['base'] ?? 0);
|
||||
$avatar = function_exists('aksrt_get_avatar_url')
|
||||
? aksrt_get_avatar_url($user_id, ['size' => 64])
|
||||
: get_avatar_url($user_id, ['size' => 64]);
|
||||
$center = function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url('assets')
|
||||
: home_url('/');
|
||||
|
||||
$html = '<div class="aksrt-checkin-modal">';
|
||||
$html .= '<div class="aksrt-checkin-modal__head">';
|
||||
$html .= '<i class="fa fa-calendar-check-o" aria-hidden="true"></i>';
|
||||
$html .= '<strong>' . esc_html__('签到领奖励', 'aksrt') . '</strong>';
|
||||
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">×</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="aksrt-checkin-modal__user">';
|
||||
$html .= '<img class="aksrt-checkin-modal__avatar" src="' . esc_url($avatar) . '" alt="" width="40" height="40" />';
|
||||
$html .= '<div class="aksrt-checkin-modal__user-meta">';
|
||||
$html .= '<div class="aksrt-checkin-modal__name">' . esc_html($user->display_name) . '</div>';
|
||||
$html .= '<div class="aksrt-checkin-modal__sub">' . esc_html(sprintf(__('累计签到 %d 天', 'aksrt'), $total)) . '</div>';
|
||||
$html .= '</div></div>';
|
||||
|
||||
$html .= '<div class="aksrt-checkin-modal__wallet">';
|
||||
$html .= '<a class="aksrt-checkin-modal__wallet-item" href="' . esc_url($center) . '">';
|
||||
$html .= '<span class="muted">' . esc_html($currency) . '</span>';
|
||||
$html .= '<b>' . esc_html(number_format_i18n($balance)) . '</b>';
|
||||
$html .= '</a></div>';
|
||||
|
||||
$html .= '<div class="aksrt-checkin-modal__card">';
|
||||
$html .= '<div class="aksrt-checkin-modal__card-main">';
|
||||
$html .= '<div class="aksrt-checkin-modal__card-title"><i class="fas fa-gift" aria-hidden="true"></i> ' . esc_html__('每日签到奖励', 'aksrt') . '</div>';
|
||||
$html .= '<div class="aksrt-checkin-modal__badge">' . esc_html($currency) . ' +' . esc_html((string) $base) . '</div>';
|
||||
$html .= '</div>';
|
||||
if ($rec) {
|
||||
$html .= '<div class="aksrt-checkin-modal__done"><i class="fa fa-calendar-check-o" aria-hidden="true"></i> ' . esc_html__('今日已签到', 'aksrt');
|
||||
$html .= '<div class="em09">+' . esc_html((string) (int) ($rec['reward'] ?? 0)) . '</div></div>';
|
||||
} else {
|
||||
$html .= '<button type="button" class="but c-blue aksrt-checkin-modal__btn" data-aksrt-checkin="1">' . esc_html__('立即签到', 'aksrt') . '</button>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
$streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : [];
|
||||
$max = 7;
|
||||
foreach (array_keys($streak_table) as $k) {
|
||||
$max = max($max, (int) $k);
|
||||
}
|
||||
$max = min(14, max(2, $max));
|
||||
$html .= '<div class="aksrt-checkin-modal__streak">';
|
||||
$html .= '<div class="aksrt-checkin-modal__streak-head"><span>' . esc_html__('连续签到奖励', 'aksrt') . '</span>';
|
||||
if ($streak > 1) {
|
||||
$html .= '<badge class="c-blue">' . esc_html(sprintf(__('已连续 %d 天', 'aksrt'), $streak)) . '</badge>';
|
||||
}
|
||||
$html .= '</div><div class="aksrt-checkin-modal__ladder">';
|
||||
for ($d = 1; $d <= $max; $d++) {
|
||||
$extra = 0;
|
||||
foreach ($streak_table as $k => $v) {
|
||||
if ((int) $k === $d) {
|
||||
$extra = (int) $v;
|
||||
}
|
||||
}
|
||||
$pts = $base + $extra;
|
||||
$is_done = $streak >= $d;
|
||||
$html .= '<div class="aksrt-checkin-modal__day' . ($is_done ? ' is-done' : '') . '">';
|
||||
$html .= '<div class="aksrt-checkin-modal__day-n">D' . (int) $d . '</div>';
|
||||
$html .= '<div class="aksrt-checkin-modal__day-p">+' . esc_html((string) $pts) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div></div>';
|
||||
|
||||
$dates = [];
|
||||
foreach ($this->get_recent_signins($user_id, 30) as $row) {
|
||||
if (!empty($row['signin_date'])) {
|
||||
$dates[] = (string) $row['signin_date'];
|
||||
}
|
||||
}
|
||||
$html .= '<div class="aksrt-checkin-modal__cal-title">' . esc_html__('最近 30 天', 'aksrt') . '</div>';
|
||||
$html .= '<div data-aksrt-signin-calendar=\'' . esc_attr(wp_json_encode(array_values($dates))) . '\'></div>';
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
// ---------- 工具方法 ----------
|
||||
|
||||
/** 站点时区下的今日日期 '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\Core\DBMigrator::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\Core\DBMigrator::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\Core\DBMigrator::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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user