Files
2026-07-23 08:58:13 +08:00

469 lines
20 KiB
PHP

<?php
declare(strict_types=1);
$config = require dirname(__DIR__) . '/config.php';
if (empty($config['installed'])) {
if (PHP_SAPI !== 'cli' && !headers_sent()) {
header('Location: /install.php', true, 302);
exit;
}
throw new RuntimeException('系统尚未安装,请访问 /install.php。');
}
date_default_timezone_set($config['timezone']);
if (PHP_SAPI !== 'cli' && !headers_sent()) {
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
}
if (session_status() !== PHP_SESSION_ACTIVE) {
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_name($config['session_name']);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
function db(): PDO
{
static $pdo;
if ($pdo instanceof PDO) {
return $pdo;
}
global $config;
$db = $config['db'];
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=%s', $db['host'], $db['port'], $db['database'], $db['charset']);
$pdo = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return $pdo;
}
function now(): string
{
return date('Y-m-d H:i:s');
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(24));
}
return $_SESSION['csrf'];
}
function verify_csrf(): void
{
$token = (string) ($_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), $token)) {
throw new RuntimeException('页面已过期,请刷新后重试。');
}
}
function setting(string $key, string $default = ''): string
{
$stmt = db()->prepare('SELECT `value` FROM settings WHERE `key` = ?');
$stmt->execute([$key]);
$value = $stmt->fetchColumn();
return $value === false ? $default : (string) $value;
}
function current_user(bool $fresh = false): ?array
{
static $cached = false;
if (!$fresh && is_array($cached)) {
return $cached;
}
$id = (int) ($_SESSION['user_id'] ?? 0);
if ($id < 1) {
return null;
}
$stmt = db()->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch();
if (!$user || (int) $user['status'] !== 1) {
unset($_SESSION['user_id']);
return null;
}
$cached = $user;
return $user;
}
function current_admin(): ?array
{
$id = (int) ($_SESSION['admin_id'] ?? 0);
if ($id < 1) {
return null;
}
$stmt = db()->prepare('SELECT id, username FROM admins WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function require_user(): array
{
$user = current_user();
if (!$user) {
header('Location: /index.php?view=login');
exit;
}
return $user;
}
function require_admin(): array
{
$admin = current_admin();
if (!$admin) {
header('Location: /admin/login.php');
exit;
}
return $admin;
}
function flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function take_flash(): ?array
{
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return is_array($flash) ? $flash : null;
}
function redirect(string $url): never
{
header('Location: ' . $url);
exit;
}
function client_ip(): string
{
return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
}
function auth_limit_key(string $identifier): string
{
return hash('sha256', mb_strtolower(trim($identifier)));
}
function assert_auth_not_limited(string $scope, string $identifier, int $maximum = 5, int $windowSeconds = 900): void
{
$cutoff = date('Y-m-d H:i:s', time() - $windowSeconds);
$stmt = db()->prepare('SELECT COUNT(*) FROM auth_attempts WHERE scope=? AND succeeded=0 AND attempted_at>=? AND (identifier_hash=? OR ip_address=?)');
$stmt->execute([$scope, $cutoff, auth_limit_key($identifier), client_ip()]);
if ((int) $stmt->fetchColumn() >= $maximum) {
throw new RuntimeException('尝试次数过多,请稍后再试。');
}
}
function record_auth_attempt(string $scope, string $identifier, bool $succeeded): void
{
$pdo = db();
$key = auth_limit_key($identifier);
$ip = client_ip();
if ($succeeded) {
$stmt = $pdo->prepare('DELETE FROM auth_attempts WHERE scope=? AND (identifier_hash=? OR ip_address=?)');
$stmt->execute([$scope, $key, $ip]);
return;
}
$stmt = $pdo->prepare('INSERT INTO auth_attempts(scope,identifier_hash,ip_address,succeeded,attempted_at) VALUES(?,?,?,0,?)');
$stmt->execute([$scope, $key, $ip, now()]);
if (random_int(1, 100) === 1) {
$pdo->prepare('DELETE FROM auth_attempts WHERE attempted_at<?')->execute([date('Y-m-d H:i:s', time() - 604800)]);
}
}
function admin_audit(PDO $pdo, string $action, string $targetType = '', string|int $targetId = '', mixed $before = null, mixed $after = null): void
{
$adminId = (int) ($_SESSION['admin_id'] ?? 0);
if ($adminId < 1) return;
$encode = static fn(mixed $value): ?string => $value === null ? null : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
$stmt = $pdo->prepare('INSERT INTO admin_audit_logs(admin_id,action,target_type,target_id,before_json,after_json,ip_address,created_at) VALUES(?,?,?,?,?,?,?,?)');
$stmt->execute([$adminId, mb_substr($action, 0, 50), mb_substr($targetType, 0, 40), mb_substr((string) $targetId, 0, 64), $encode($before), $encode($after), client_ip(), now()]);
}
function create_ledger(PDO $pdo, int $userId, string $type, float $amount, float $before, float $after, string $note, ?string $reference = null): void
{
$stmt = $pdo->prepare('INSERT INTO balance_logs (user_id, type, amount, balance_before, balance_after, note, reference_no, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([$userId, $type, $amount, $before, $after, $note, $reference, now()]);
}
function order_no(string $prefix = 'RC'): string
{
return $prefix . date('YmdHis') . strtoupper(bin2hex(random_bytes(3)));
}
function generate_invite_code(PDO $pdo): string
{
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE invite_code=?');
do {
$code = strtoupper(bin2hex(random_bytes(5)));
$stmt->execute([$code]);
} while ($stmt->fetchColumn());
return $code;
}
function redeem_recharge_card(int $userId, string $code, string $note = '充值卡兑换'): array
{
$code = strtoupper(trim($code));
if (!preg_match('/^[A-Z0-9]{4,32}$/', $code)) throw new RuntimeException('充值卡不存在或不可用。');
$pdo = db();
$pdo->beginTransaction();
try {
$lock = ' FOR UPDATE';
$stmt = $pdo->prepare('SELECT * FROM recharge_cards WHERE code=?'.$lock);
$stmt->execute([$code]);
$card = $stmt->fetch();
if (!$card) throw new RuntimeException('充值卡不存在或不可用。');
$isShared = ($card['card_type'] ?? 'single') === 'shared';
if ($isShared) {
if ($card['status'] !== 'active') throw new RuntimeException('该公共充值卡已停用。');
$stmt = $pdo->prepare('SELECT 1 FROM recharge_card_redemptions WHERE card_id=? AND user_id=?');
$stmt->execute([$card['id'], $userId]);
if ($stmt->fetchColumn()) throw new RuntimeException('您已经使用过该公共充值卡,每个账号仅可兑换一次。');
} elseif ($card['status'] !== 'unused') {
throw new RuntimeException('充值卡不存在或已使用。');
}
$stmt = $pdo->prepare('SELECT balance FROM users WHERE id=?'.$lock);
$stmt->execute([$userId]);
$balance = $stmt->fetchColumn();
if ($balance === false) throw new RuntimeException('用户不存在。');
$amount = round((float)$card['amount'], 2);
if ($amount <= 0) throw new RuntimeException('充值卡金额无效。');
$before = (float)$balance;
$after = round($before + $amount, 2);
if ($isShared) {
try {
$pdo->prepare('INSERT INTO recharge_card_redemptions(card_id,user_id,amount,created_at) VALUES(?,?,?,?)')->execute([$card['id'], $userId, $amount, now()]);
} catch (PDOException $e) {
if ((string)$e->getCode() === '23000') throw new RuntimeException('您已经使用过该公共充值卡,每个账号仅可兑换一次。');
throw $e;
}
} else {
$pdo->prepare("UPDATE recharge_cards SET status='used',used_by=?,used_at=? WHERE id=? AND status='unused'")->execute([$userId, now(), $card['id']]);
}
$pdo->prepare('UPDATE users SET balance=?,updated_at=? WHERE id=?')->execute([$after, now(), $userId]);
$reference = $isShared ? 'SC'.$card['id'].'U'.$userId : $code;
create_ledger($pdo, $userId, $isShared ? 'shared_card_recharge' : 'card_recharge', $amount, $before, $after, $note, $reference);
credit_recharge_commissions($pdo, $userId, $amount, $isShared ? 'shared_card' : 'card', $reference);
$pdo->commit();
return ['amount'=>$amount, 'card_type'=>$isShared ? 'shared' : 'single'];
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
function create_user_account(string $username, string $password, string $phone = '', string $inviteCode = ''): array
{
$pdo = db();
$ip = client_ip();
$inviteCode = strtoupper(trim($inviteCode));
$pdo->beginTransaction();
try {
$lock = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? ' FOR UPDATE' : '';
$inviter = null;
if ($inviteCode !== '') {
$stmt = $pdo->prepare('SELECT * FROM users WHERE invite_code=? AND status=1'.$lock);
$stmt->execute([$inviteCode]);
$inviter = $stmt->fetch();
if (!$inviter) throw new RuntimeException('邀请码无效,请核对后重试。');
}
$bonus = max(0, (float)setting('register_bonus', '0'));
$code = generate_invite_code($pdo);
try {
$stmt = $pdo->prepare('INSERT INTO users(username,password_hash,phone,invite_code,inviter_id,register_ip,balance,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,1,?,?)');
$stmt->execute([$username,password_hash($password,PASSWORD_DEFAULT),$phone,$code,$inviter['id']??null,$ip,$bonus,now(),now()]);
} catch (PDOException $e) {
if ((string)$e->getCode() === '23000') throw new RuntimeException('该账号已被注册。');
throw $e;
}
$userId = (int)$pdo->lastInsertId();
if ($bonus > 0) create_ledger($pdo,$userId,'register_bonus',$bonus,0,$bonus,'新用户注册奖励');
$referralStatus = null;
if ($inviter) {
$reward = round(max(0,(float)setting('invite_reward','0')),2);
$inviterIp = (string)($inviter['register_ip'] ?: $inviter['last_login_ip']);
$status = $reward > 0 ? 'rewarded' : 'qualified';
$reason = '';
if ($ip === '') {
$status = 'rejected'; $reason = '无法识别注册 IP';
} elseif ($inviterIp !== '' && hash_equals($inviterIp,$ip)) {
$status = 'rejected'; $reason = '邀请人与被邀请人 IP 相同';
} elseif ($reward <= 0) {
$reason = '注册奖励未启用';
}
$qualified = false;
if (in_array($status,['rewarded','qualified'],true)) {
try {
$pdo->prepare('INSERT INTO referral_rewards(inviter_id,invitee_id,invitee_ip,reward_ip,reward_amount,status,reason,created_at) VALUES(?,?,?,?,?,?,?,?)')
->execute([$inviter['id'],$userId,$ip,$ip,$status === 'rewarded' ? $reward : 0,$status,$reason,now()]);
$qualified = true;
} catch (PDOException $e) {
if ((string)$e->getCode() !== '23000') throw $e;
$status = 'rejected'; $reason = '该 IP 已建立过有效邀请关系';
}
}
if (!$qualified) {
$pdo->prepare('INSERT INTO referral_rewards(inviter_id,invitee_id,invitee_ip,reward_ip,reward_amount,status,reason,created_at) VALUES(?,?,?,NULL,0,?,?,?)')
->execute([$inviter['id'],$userId,$ip,$status,$reason,now()]);
} elseif ($status === 'rewarded') {
$before = (float)$inviter['balance'];
$after = $before + $reward;
$pdo->prepare('UPDATE users SET balance=?,updated_at=? WHERE id=?')->execute([$after,now(),$inviter['id']]);
create_ledger($pdo,(int)$inviter['id'],'invite_reward',$reward,$before,$after,'成功邀请用户 '.$username,'INV'.$userId);
}
$referralStatus = $status;
}
$pdo->commit();
return ['user_id'=>$userId,'invite_code'=>$code,'referral_status'=>$referralStatus];
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
function referral_link_qualified(PDO $pdo, int $inviteeId, int $inviterId): bool
{
$stmt = $pdo->prepare("SELECT status FROM referral_rewards WHERE invitee_id=? AND inviter_id=? AND status IN ('rewarded','qualified')");
$stmt->execute([$inviteeId,$inviterId]);
return (bool)$stmt->fetchColumn();
}
function credit_recharge_commissions(PDO $pdo, int $userId, float $amount, string $sourceType, string $sourceRef): void
{
if ($amount <= 0) return;
$lock = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? ' FOR UPDATE' : '';
$stmt = $pdo->prepare('SELECT id,inviter_id FROM users WHERE id=?'.$lock);
$stmt->execute([$userId]);
$rechargingUser = $stmt->fetch();
if (!$rechargingUser || !(int)$rechargingUser['inviter_id']) return;
$directId = (int)$rechargingUser['inviter_id'];
if (!referral_link_qualified($pdo,$userId,$directId)) return;
credit_recharge_commission($pdo,$userId,$directId,1,$amount,(float)setting('direct_commission_rate','10'),$sourceType,$sourceRef);
$stmt = $pdo->prepare('SELECT inviter_id FROM users WHERE id=?'.$lock);
$stmt->execute([$directId]);
$secondId = (int)$stmt->fetchColumn();
if ($secondId > 0 && referral_link_qualified($pdo,$directId,$secondId)) {
credit_recharge_commission($pdo,$userId,$secondId,2,$amount,(float)setting('second_commission_rate','1'),$sourceType,$sourceRef);
}
}
function credit_recharge_commission(PDO $pdo, int $rechargingUserId, int $beneficiaryId, int $level, float $rechargeAmount, float $rate, string $sourceType, string $sourceRef): void
{
$rate = min(100.0,max(0.0,$rate));
$commission = round($rechargeAmount * $rate / 100,2);
if ($commission <= 0) return;
try {
$stmt = $pdo->prepare('INSERT INTO recharge_commissions(source_type,source_ref,recharging_user_id,beneficiary_user_id,level,rate,recharge_amount,commission_amount,created_at) VALUES(?,?,?,?,?,?,?,?,?)');
$stmt->execute([$sourceType,mb_substr($sourceRef,0,40),$rechargingUserId,$beneficiaryId,$level,$rate,$rechargeAmount,$commission,now()]);
} catch (PDOException $e) {
if ((string)$e->getCode() === '23000') return;
throw $e;
}
$commissionId = (int)$pdo->lastInsertId();
$lock = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? ' FOR UPDATE' : '';
$stmt = $pdo->prepare('SELECT balance FROM users WHERE id=?'.$lock);
$stmt->execute([$beneficiaryId]);
$before = (float)$stmt->fetchColumn();
$after = $before + $commission;
$pdo->prepare('UPDATE users SET balance=?,updated_at=? WHERE id=?')->execute([$after,now(),$beneficiaryId]);
create_ledger($pdo,$beneficiaryId,'recharge_commission_l'.$level,$commission,$before,$after,$level===1?'一级下线充值佣金':'二级下线充值佣金','COM'.$commissionId);
}
function reverse_recharge_order(PDO $pdo, int $orderId, string $note): void
{
$note = mb_substr(trim($note), 0, 200);
if ($note === '') throw new RuntimeException('请填写冲正原因。');
$pdo->beginTransaction();
try {
$lock = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? ' FOR UPDATE' : '';
$stmt = $pdo->prepare('SELECT * FROM recharge_orders WHERE id=?'.$lock);
$stmt->execute([$orderId]);
$order = $stmt->fetch();
if (!$order || $order['status'] !== 'approved') throw new RuntimeException('仅可冲正已到账且未冲正的订单。');
$stmt = $pdo->prepare("SELECT * FROM recharge_commissions WHERE source_type='order' AND source_ref=? AND reversed_at IS NULL ORDER BY id".$lock);
$stmt->execute([$order['order_no']]);
$commissions = $stmt->fetchAll();
$deductions = [(int) $order['user_id'] => (float) $order['amount']];
foreach ($commissions as $commission) {
$beneficiaryId = (int) $commission['beneficiary_user_id'];
$deductions[$beneficiaryId] = ($deductions[$beneficiaryId] ?? 0) + (float) $commission['commission_amount'];
}
ksort($deductions);
$ids = array_keys($deductions);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare('SELECT id,balance FROM users WHERE id IN ('.$placeholders.') ORDER BY id'.$lock);
$stmt->execute($ids);
$balances = [];
foreach ($stmt->fetchAll() as $row) $balances[(int) $row['id']] = (float) $row['balance'];
foreach ($deductions as $userId => $amount) {
if (!array_key_exists($userId, $balances) || $balances[$userId] + 0.00001 < $amount) {
throw new RuntimeException('相关用户余额不足,无法自动冲正,请先核对资金。');
}
}
foreach ($commissions as $commission) {
$userId = (int) $commission['beneficiary_user_id'];
$amount = (float) $commission['commission_amount'];
$before = $balances[$userId];
$after = round($before - $amount, 2);
$pdo->prepare('UPDATE users SET balance=?,updated_at=? WHERE id=?')->execute([$after, now(), $userId]);
$pdo->prepare('UPDATE recharge_commissions SET reversed_by=?,reversed_at=? WHERE id=? AND reversed_at IS NULL')->execute([(int) $_SESSION['admin_id'], now(), $commission['id']]);
create_ledger($pdo, $userId, 'commission_reversal', -$amount, $before, $after, '充值佣金冲正', 'RVC'.$commission['id']);
$balances[$userId] = $after;
}
$userId = (int) $order['user_id'];
$amount = (float) $order['amount'];
$before = $balances[$userId];
$after = round($before - $amount, 2);
$pdo->prepare('UPDATE users SET balance=?,updated_at=? WHERE id=?')->execute([$after, now(), $userId]);
create_ledger($pdo, $userId, 'recharge_reversal', -$amount, $before, $after, '充值订单冲正:'.$note, 'RV'.$order['order_no']);
$pdo->prepare("UPDATE recharge_orders SET status='reversed',reversed_by=?,reversed_at=?,reversal_note=? WHERE id=? AND status='approved'")->execute([(int) $_SESSION['admin_id'], now(), $note, $orderId]);
admin_audit($pdo, 'recharge_reversal', 'recharge_order', $orderId, ['status' => 'approved', 'amount' => $amount], ['status' => 'reversed', 'reason' => $note]);
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $exception;
}
}
function settings_map(): array
{
$rows = db()->query('SELECT `key`, `value` FROM settings')->fetchAll();
$settings = [];
foreach ($rows as $row) {
$settings[(string) $row['key']] = (string) $row['value'];
}
return $settings;
}