1905 lines
63 KiB
PHP
1905 lines
63 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
@date_default_timezone_set("UTC");
|
||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||
session_start();
|
||
}
|
||
|
||
require_once __DIR__ . "/db/config.php";
|
||
|
||
function schema_has_column(PDO $pdo, string $table, string $column): bool
|
||
{
|
||
$statement = $pdo->prepare(
|
||
"SELECT COUNT(*)
|
||
FROM INFORMATION_SCHEMA.COLUMNS
|
||
WHERE TABLE_SCHEMA = DATABASE()
|
||
AND TABLE_NAME = ?
|
||
AND COLUMN_NAME = ?"
|
||
);
|
||
$statement->execute([$table, $column]);
|
||
|
||
return (int) $statement->fetchColumn() > 0;
|
||
}
|
||
|
||
function ensure_schema(): void
|
||
{
|
||
static $ensured = false;
|
||
if ($ensured) {
|
||
return;
|
||
}
|
||
|
||
$pdo = db();
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS users (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
email VARCHAR(190) NOT NULL UNIQUE,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
user_code CHAR(6) NOT NULL UNIQUE,
|
||
vip_level TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||
email_verified_at DATETIME NULL,
|
||
last_login_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS wallets (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT UNSIGNED NOT NULL UNIQUE,
|
||
available_balance DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
frozen_balance DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_wallet_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS task_orders (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT UNSIGNED NOT NULL,
|
||
task_slug VARCHAR(64) NOT NULL,
|
||
task_title VARCHAR(120) NOT NULL,
|
||
task_type VARCHAR(80) NOT NULL,
|
||
platform_name VARCHAR(80) NOT NULL,
|
||
reward_usdt DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
vip_required TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||
countdown_seconds INT UNSIGNED NOT NULL DEFAULT 60,
|
||
status VARCHAR(32) NOT NULL DEFAULT 'claimed',
|
||
proof_note TEXT NULL,
|
||
claimed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
submitted_at DATETIME NULL,
|
||
reviewed_at DATETIME NULL,
|
||
review_note VARCHAR(255) NULL,
|
||
CONSTRAINT fk_task_order_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_task_orders_user_status (user_id, status),
|
||
INDEX idx_task_orders_status_review (status, reviewed_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS wallet_logs (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT UNSIGNED NOT NULL,
|
||
entry_type VARCHAR(32) NOT NULL,
|
||
amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
frozen_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
available_after DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
frozen_after DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
note VARCHAR(255) NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_wallet_log_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_wallet_logs_user_created (user_id, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS vip_orders (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT UNSIGNED NOT NULL,
|
||
from_level TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||
to_level TINYINT UNSIGNED NOT NULL,
|
||
price_usdt DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
status VARCHAR(32) NOT NULL DEFAULT 'completed',
|
||
available_after DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||
note VARCHAR(255) NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
completed_at DATETIME NULL,
|
||
CONSTRAINT fk_vip_order_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_vip_orders_user_created (user_id, created_at),
|
||
INDEX idx_vip_orders_status_created (status, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
$pdo->exec(
|
||
"CREATE TABLE IF NOT EXISTS email_verification_codes (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
email VARCHAR(190) NOT NULL,
|
||
purpose VARCHAR(32) NOT NULL DEFAULT 'signup',
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
code_hash VARCHAR(255) NOT NULL,
|
||
attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||
max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 5,
|
||
expires_at DATETIME NOT NULL,
|
||
verified_at DATETIME NULL,
|
||
consumed_at DATETIME NULL,
|
||
ip_address VARCHAR(64) NULL,
|
||
meta_note VARCHAR(255) NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_email_codes_email_purpose (email, purpose, created_at),
|
||
INDEX idx_email_codes_status (purpose, consumed_at, expires_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||
);
|
||
|
||
if (!schema_has_column($pdo, 'users', 'email_verified_at')) {
|
||
$pdo->exec("ALTER TABLE users ADD COLUMN email_verified_at DATETIME NULL AFTER vip_level");
|
||
}
|
||
if (!schema_has_column($pdo, 'users', 'last_login_at')) {
|
||
$pdo->exec("ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER email_verified_at");
|
||
}
|
||
|
||
$ensured = true;
|
||
}
|
||
|
||
ensure_schema();
|
||
|
||
function h($value): string
|
||
{
|
||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
|
||
}
|
||
|
||
function project_name(): string
|
||
{
|
||
$name = trim((string) ($_SERVER["PROJECT_NAME"] ?? ""));
|
||
return $name !== "" ? $name : "Atlas Rebate";
|
||
}
|
||
|
||
function project_description(): string
|
||
{
|
||
$description = trim((string) ($_SERVER["PROJECT_DESCRIPTION"] ?? ""));
|
||
return $description !== ""
|
||
? $description
|
||
: "Mobile-first rebate operations slice with email onboarding, task execution, manual review, and wallet ledger.";
|
||
}
|
||
|
||
function asset_version(string $relativePath): string
|
||
{
|
||
$absolutePath = __DIR__ . "/" . ltrim($relativePath, "/");
|
||
return file_exists($absolutePath) ? (string) filemtime($absolutePath) : (string) time();
|
||
}
|
||
|
||
function app_url(string $path, array $query = []): string
|
||
{
|
||
return $query ? $path . "?" . http_build_query($query) : $path;
|
||
}
|
||
|
||
function redirect(string $url): void
|
||
{
|
||
header("Location: " . $url);
|
||
exit;
|
||
}
|
||
|
||
function format_usdt($amount): string
|
||
{
|
||
return number_format((float) $amount, 2) . " USDT";
|
||
}
|
||
|
||
function format_delta($amount): string
|
||
{
|
||
$amount = (float) $amount;
|
||
$prefix = $amount > 0 ? "+" : "";
|
||
return $prefix . number_format($amount, 2);
|
||
}
|
||
|
||
function format_datetime(?string $value): string
|
||
{
|
||
if (!$value) {
|
||
return "—";
|
||
}
|
||
|
||
$timestamp = strtotime($value);
|
||
if ($timestamp === false) {
|
||
return h($value);
|
||
}
|
||
|
||
return date("Y-m-d H:i", $timestamp);
|
||
}
|
||
|
||
function client_ip(): string
|
||
{
|
||
return (string) ($_SERVER["REMOTE_ADDR"] ?? "0.0.0.0");
|
||
}
|
||
|
||
function text_length(string $value): int
|
||
{
|
||
return function_exists("mb_strlen") ? mb_strlen($value) : strlen($value);
|
||
}
|
||
|
||
function csrf_token(): string
|
||
{
|
||
if (empty($_SESSION["csrf_token"])) {
|
||
$_SESSION["csrf_token"] = bin2hex(random_bytes(16));
|
||
}
|
||
|
||
return (string) $_SESSION["csrf_token"];
|
||
}
|
||
|
||
function verify_csrf_or_fail(): void
|
||
{
|
||
$token = (string) ($_POST["csrf_token"] ?? "");
|
||
$sessionToken = (string) ($_SESSION["csrf_token"] ?? "");
|
||
|
||
if ($token === "" || $sessionToken === "" || !hash_equals($sessionToken, $token)) {
|
||
throw new RuntimeException("请求已失效,请刷新页面后重试。");
|
||
}
|
||
}
|
||
|
||
function flash(string $tone, string $message): void
|
||
{
|
||
$_SESSION["flash_messages"][] = [
|
||
"tone" => $tone,
|
||
"message" => $message,
|
||
];
|
||
}
|
||
|
||
function pull_flashes(): array
|
||
{
|
||
$messages = $_SESSION["flash_messages"] ?? [];
|
||
unset($_SESSION["flash_messages"]);
|
||
return is_array($messages) ? $messages : [];
|
||
}
|
||
|
||
function vip_catalog(): array
|
||
{
|
||
return [
|
||
0 => [
|
||
"level" => 0,
|
||
"name" => "VIP0",
|
||
"price" => 0,
|
||
"reward" => 24,
|
||
"daily_tasks" => 2,
|
||
"benefit" => "新手演示任务、自动钱包、资金流水可视化",
|
||
],
|
||
1 => [
|
||
"level" => 1,
|
||
"name" => "VIP1",
|
||
"price" => 1000,
|
||
"reward" => 200,
|
||
"daily_tasks" => 2,
|
||
"benefit" => "1000 USDT 开通,单任务佣金 200 USDT",
|
||
],
|
||
2 => [
|
||
"level" => 2,
|
||
"name" => "VIP2",
|
||
"price" => 3000,
|
||
"reward" => 600,
|
||
"daily_tasks" => 2,
|
||
"benefit" => "3000 USDT 开通,单任务佣金 600 USDT",
|
||
],
|
||
3 => [
|
||
"level" => 3,
|
||
"name" => "VIP3",
|
||
"price" => 5000,
|
||
"reward" => 1000,
|
||
"daily_tasks" => 2,
|
||
"benefit" => "5000 USDT 开通,单任务佣金 1000 USDT",
|
||
],
|
||
4 => [
|
||
"level" => 4,
|
||
"name" => "VIP4",
|
||
"price" => 10000,
|
||
"reward" => 2000,
|
||
"daily_tasks" => 2,
|
||
"benefit" => "10000 USDT 开通,单任务佣金 2000 USDT",
|
||
],
|
||
];
|
||
}
|
||
|
||
function vip_info(int $vipLevel): array
|
||
{
|
||
$catalog = vip_catalog();
|
||
return $catalog[$vipLevel] ?? $catalog[0];
|
||
}
|
||
|
||
function task_catalog(): array
|
||
{
|
||
return [
|
||
"watch-video" => [
|
||
"slug" => "watch-video",
|
||
"title" => "观看 YouTube 视频",
|
||
"platform" => "YouTube",
|
||
"type" => "视频",
|
||
"reward" => 18,
|
||
"vip_required" => 0,
|
||
"countdown" => 60,
|
||
"summary" => "打开视频并保持活跃,倒计时结束后提交完成备注。",
|
||
"steps" => [
|
||
"点击开始任务后进入倒计时。",
|
||
"保持页面停留并完成观看动作。",
|
||
"倒计时结束后提交一句完成备注。",
|
||
],
|
||
],
|
||
"social-like" => [
|
||
"slug" => "social-like",
|
||
"title" => "点赞 TikTok 视频",
|
||
"platform" => "TikTok",
|
||
"type" => "点赞",
|
||
"reward" => 24,
|
||
"vip_required" => 0,
|
||
"countdown" => 60,
|
||
"summary" => "适合新用户演示完整闭环:领取、计时、提交、审核入账。",
|
||
"steps" => [
|
||
"领取任务并进入倒计时。",
|
||
"模拟完成点赞或互动行为。",
|
||
"提交完成说明,等待后台审核。",
|
||
],
|
||
],
|
||
"follow-brand" => [
|
||
"slug" => "follow-brand",
|
||
"title" => "关注 Instagram 博主",
|
||
"platform" => "Instagram",
|
||
"type" => "社媒",
|
||
"reward" => 200,
|
||
"vip_required" => 1,
|
||
"countdown" => 90,
|
||
"summary" => "VIP1 开始解锁,高佣金任务与更强审核优先级。",
|
||
"steps" => [
|
||
"确认当前账号已开通对应 VIP。",
|
||
"完成关注动作并保持页面停留。",
|
||
"提交账户备注供后台核对。",
|
||
],
|
||
],
|
||
"comment-flow" => [
|
||
"slug" => "comment-flow",
|
||
"title" => "发布社媒评论",
|
||
"platform" => "Community Feed",
|
||
"type" => "社媒",
|
||
"reward" => 600,
|
||
"vip_required" => 2,
|
||
"countdown" => 110,
|
||
"summary" => "VIP2 解锁的中高客单价任务,用于验证冻结佣金逻辑。",
|
||
"steps" => [
|
||
"领取后完成评论发布。",
|
||
"保留评论内容关键词。",
|
||
"在系统里提交评论摘要。",
|
||
],
|
||
],
|
||
"site-browse" => [
|
||
"slug" => "site-browse",
|
||
"title" => "浏览网站 60 秒",
|
||
"platform" => "Website",
|
||
"type" => "网站",
|
||
"reward" => 1000,
|
||
"vip_required" => 3,
|
||
"countdown" => 140,
|
||
"summary" => "VIP3 任务,适合做高佣金站点浏览与停留验证。",
|
||
"steps" => [
|
||
"进入指定站点浏览多个页面。",
|
||
"保持页面活跃直到倒计时结束。",
|
||
"填写浏览到的产品或栏目名称。",
|
||
],
|
||
],
|
||
"app-download" => [
|
||
"slug" => "app-download",
|
||
"title" => "下载应用体验",
|
||
"platform" => "App Center",
|
||
"type" => "应用",
|
||
"reward" => 2000,
|
||
"vip_required" => 4,
|
||
"countdown" => 180,
|
||
"summary" => "VIP4 顶级任务,高收益、高风控、人工复核。",
|
||
"steps" => [
|
||
"下载并打开指定应用。",
|
||
"完成首次启动与页面停留。",
|
||
"提交设备备注或体验摘要。",
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
function task_by_slug(string $slug): ?array
|
||
{
|
||
$catalog = task_catalog();
|
||
return $catalog[$slug] ?? null;
|
||
}
|
||
|
||
function generate_unique_user_code(): string
|
||
{
|
||
$pdo = db();
|
||
$statement = $pdo->prepare("SELECT id FROM users WHERE user_code = ? LIMIT 1");
|
||
|
||
for ($i = 0; $i < 50; $i++) {
|
||
$candidate = str_pad((string) random_int(0, 999999), 6, "0", STR_PAD_LEFT);
|
||
$statement->execute([$candidate]);
|
||
if (!$statement->fetchColumn()) {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
throw new RuntimeException("未能生成唯一 6 位数字 ID,请重试。");
|
||
}
|
||
|
||
function fetch_user(int $userId): ?array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT u.*, w.available_balance, w.frozen_balance, w.updated_at AS wallet_updated_at
|
||
FROM users u
|
||
INNER JOIN wallets w ON w.user_id = u.id
|
||
WHERE u.id = ?
|
||
LIMIT 1"
|
||
);
|
||
$statement->execute([$userId]);
|
||
$user = $statement->fetch();
|
||
|
||
return $user ?: null;
|
||
}
|
||
|
||
function current_user(): ?array
|
||
{
|
||
static $resolved = false;
|
||
static $user = null;
|
||
|
||
if ($resolved) {
|
||
return $user;
|
||
}
|
||
|
||
$resolved = true;
|
||
|
||
if (empty($_SESSION["user_id"])) {
|
||
return null;
|
||
}
|
||
|
||
$user = fetch_user((int) $_SESSION["user_id"]);
|
||
if (!$user) {
|
||
unset($_SESSION["user_id"]);
|
||
}
|
||
|
||
return $user;
|
||
}
|
||
|
||
function safe_redirect_target(?string $target): string
|
||
{
|
||
$target = trim((string) $target);
|
||
if ($target === '') {
|
||
return 'index.php';
|
||
}
|
||
|
||
$target = str_replace(["
|
||
", "
|
||
"], '', $target);
|
||
|
||
if (preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $target) || str_starts_with($target, '//')) {
|
||
return 'index.php';
|
||
}
|
||
|
||
if (str_contains($target, '..')) {
|
||
return 'index.php';
|
||
}
|
||
|
||
if (str_starts_with($target, '/')) {
|
||
$target = ltrim($target, '/');
|
||
}
|
||
|
||
return $target !== '' ? $target : 'index.php';
|
||
}
|
||
|
||
function current_request_target(): string
|
||
{
|
||
return safe_redirect_target((string) ($_SERVER['REQUEST_URI'] ?? 'index.php'));
|
||
}
|
||
|
||
function auth_page_url(string $page, ?string $redirectTarget = null, array $query = []): string
|
||
{
|
||
if ($redirectTarget !== null && trim($redirectTarget) !== '') {
|
||
$query['redirect'] = safe_redirect_target($redirectTarget);
|
||
}
|
||
|
||
return app_url($page, $query);
|
||
}
|
||
|
||
function start_page_url(?string $redirectTarget = null, array $query = []): string
|
||
{
|
||
return auth_page_url('start.php', $redirectTarget, $query);
|
||
}
|
||
|
||
function register_page_url(?string $redirectTarget = null, array $query = []): string
|
||
{
|
||
return auth_page_url('register.php', $redirectTarget, $query);
|
||
}
|
||
|
||
function login_page_url(?string $redirectTarget = null, array $query = []): string
|
||
{
|
||
return auth_page_url('login.php', $redirectTarget, $query);
|
||
}
|
||
|
||
function verify_page_url(string $email, ?string $redirectTarget = null, array $query = []): string
|
||
{
|
||
$query['email'] = normalize_email($email);
|
||
return auth_page_url('verify.php', $redirectTarget, $query);
|
||
}
|
||
|
||
function require_user(): array
|
||
{
|
||
$user = current_user();
|
||
if (!$user) {
|
||
flash('warning', '请先使用邮箱登录后再继续。');
|
||
redirect(login_page_url(current_request_target()));
|
||
}
|
||
|
||
return $user;
|
||
}
|
||
|
||
function set_logged_in_user(int $userId): void
|
||
{
|
||
$_SESSION['user_id'] = $userId;
|
||
}
|
||
|
||
function logout_current_user(): void
|
||
{
|
||
unset($_SESSION['user_id']);
|
||
}
|
||
|
||
function normalize_email(string $email): string
|
||
{
|
||
return strtolower(trim($email));
|
||
}
|
||
|
||
function validate_email_or_fail(string $email): string
|
||
{
|
||
$email = normalize_email($email);
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
throw new RuntimeException('请输入有效邮箱地址。');
|
||
}
|
||
|
||
return $email;
|
||
}
|
||
|
||
function ensure_password_strength(string $password): void
|
||
{
|
||
if (text_length($password) < 6) {
|
||
throw new RuntimeException('密码至少需要 6 位字符。');
|
||
}
|
||
}
|
||
|
||
function verification_code_ttl_minutes(): int
|
||
{
|
||
return 10;
|
||
}
|
||
|
||
function generate_email_code(): string
|
||
{
|
||
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
function mask_email(string $email): string
|
||
{
|
||
$email = normalize_email($email);
|
||
if (!str_contains($email, '@')) {
|
||
return $email;
|
||
}
|
||
|
||
[$local, $domain] = explode('@', $email, 2);
|
||
$visible = substr($local, 0, min(2, strlen($local)));
|
||
$masked = $visible . str_repeat('*', max(2, strlen($local) - strlen($visible)));
|
||
|
||
return $masked . '@' . $domain;
|
||
}
|
||
|
||
function send_signup_verification_email(string $email, string $code): array
|
||
{
|
||
require_once __DIR__ . '/mail/MailService.php';
|
||
|
||
$siteName = h(project_name());
|
||
$safeCode = h($code);
|
||
$ttlMinutes = verification_code_ttl_minutes();
|
||
$subject = project_name() . ' 邮箱验证码';
|
||
$html = '<div style="font-family:Inter,Arial,sans-serif;color:#111827;line-height:1.7">'
|
||
. '<p style="margin:0 0 12px;font-size:14px;color:#6b7280">' . $siteName . '</p>'
|
||
. '<h1 style="margin:0 0 12px;font-size:24px;color:#111827">你的邮箱验证码</h1>'
|
||
. '<p style="margin:0 0 16px">请在 ' . $ttlMinutes . ' 分钟内输入下面这组 6 位数字:</p>'
|
||
. '<div style="margin:0 0 18px;padding:18px 20px;border-radius:16px;background:#111827;color:#ffffff;font-size:30px;font-weight:800;letter-spacing:8px;text-align:center">' . $safeCode . '</div>'
|
||
. '<p style="margin:0;color:#6b7280">如果这不是你本人操作,请直接忽略这封邮件。</p>'
|
||
. '</div>';
|
||
$text = project_name() . ' 邮箱验证码:' . $code . '。请在 ' . $ttlMinutes . ' 分钟内完成验证。若非本人操作请忽略。';
|
||
|
||
return MailService::sendMail($email, $subject, $html, $text);
|
||
}
|
||
|
||
function mail_delivery_error_message(array $mailResult): string
|
||
{
|
||
$detail = strtolower((string) ($mailResult['error'] ?? ''));
|
||
if ($detail === '') {
|
||
return '验证码发送失败,请稍后重试。';
|
||
}
|
||
if (str_contains($detail, 'recipient')) {
|
||
return '验证码发送失败:当前收件地址无效,请重新输入邮箱。';
|
||
}
|
||
|
||
return '验证码发送失败:当前 SMTP 未配置或连接失败,请先在平台环境变量里配置 MAIL_/SMTP_ 参数。';
|
||
}
|
||
|
||
function create_user_account_record(PDO $pdo, string $email, string $passwordHash, ?string $emailVerifiedAt = null): int
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
|
||
$check = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||
$check->execute([$email]);
|
||
if ($check->fetchColumn()) {
|
||
throw new RuntimeException('该邮箱已注册,请直接登录。');
|
||
}
|
||
|
||
$userCode = generate_unique_user_code();
|
||
$insertUser = $pdo->prepare(
|
||
'INSERT INTO users (email, password_hash, user_code, vip_level, email_verified_at, last_login_at) VALUES (?, ?, ?, 0, ?, NULL)'
|
||
);
|
||
$insertUser->execute([$email, $passwordHash, $userCode, $emailVerifiedAt]);
|
||
$userId = (int) $pdo->lastInsertId();
|
||
|
||
$insertWallet = $pdo->prepare(
|
||
'INSERT INTO wallets (user_id, available_balance, frozen_balance) VALUES (?, 0.00, 0.00)'
|
||
);
|
||
$insertWallet->execute([$userId]);
|
||
|
||
insert_wallet_log(
|
||
$pdo,
|
||
$userId,
|
||
'wallet_opened',
|
||
0,
|
||
0,
|
||
'邮箱注册成功,系统已自动创建钱包与 VIP0。',
|
||
0,
|
||
0
|
||
);
|
||
|
||
return $userId;
|
||
}
|
||
|
||
function register_user(string $email, string $password): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
ensure_password_strength($password);
|
||
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
try {
|
||
$userId = create_user_account_record(
|
||
$pdo,
|
||
$email,
|
||
password_hash($password, PASSWORD_DEFAULT),
|
||
date('Y-m-d H:i:s')
|
||
);
|
||
|
||
$pdo->commit();
|
||
set_logged_in_user($userId);
|
||
|
||
return fetch_user($userId) ?? [];
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
function latest_signup_verification(string $email, bool $pendingOnly = false): ?array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
$sql = 'SELECT * FROM email_verification_codes WHERE email = ? AND purpose = ?';
|
||
if ($pendingOnly) {
|
||
$sql .= ' AND consumed_at IS NULL';
|
||
}
|
||
$sql .= ' ORDER BY id DESC LIMIT 1';
|
||
|
||
$statement = db()->prepare($sql);
|
||
$statement->execute([$email, 'signup']);
|
||
$record = $statement->fetch();
|
||
|
||
return $record ?: null;
|
||
}
|
||
|
||
function issue_signup_verification_from_hash(string $email, string $passwordHash): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
|
||
$code = generate_email_code();
|
||
$expiresAt = date('Y-m-d H:i:s', time() + (verification_code_ttl_minutes() * 60));
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
|
||
try {
|
||
$resetPending = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET consumed_at = NOW(), meta_note = 'superseded by new signup code'
|
||
WHERE email = ? AND purpose = 'signup' AND consumed_at IS NULL AND verified_at IS NULL"
|
||
);
|
||
$resetPending->execute([$email]);
|
||
|
||
$insert = $pdo->prepare(
|
||
'INSERT INTO email_verification_codes (
|
||
email,
|
||
purpose,
|
||
password_hash,
|
||
code_hash,
|
||
expires_at,
|
||
ip_address,
|
||
meta_note
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||
);
|
||
$insert->execute([
|
||
$email,
|
||
'signup',
|
||
$passwordHash,
|
||
password_hash($code, PASSWORD_DEFAULT),
|
||
$expiresAt,
|
||
client_ip(),
|
||
'signup verification pending',
|
||
]);
|
||
$verificationId = (int) $pdo->lastInsertId();
|
||
$pdo->commit();
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
|
||
$mailResult = send_signup_verification_email($email, $code);
|
||
if (empty($mailResult['success'])) {
|
||
$cleanup = db()->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET consumed_at = NOW(), meta_note = 'mail delivery failed'
|
||
WHERE id = ? AND consumed_at IS NULL"
|
||
);
|
||
$cleanup->execute([$verificationId]);
|
||
error_log('Signup verification mail failed for ' . $email . ': ' . (string) ($mailResult['error'] ?? 'unknown'));
|
||
throw new RuntimeException(mail_delivery_error_message($mailResult));
|
||
}
|
||
|
||
$_SESSION['last_signup_email'] = $email;
|
||
|
||
return [
|
||
'id' => $verificationId,
|
||
'email' => $email,
|
||
'expires_at' => $expiresAt,
|
||
];
|
||
}
|
||
|
||
function begin_signup_verification(string $email, string $password): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
ensure_password_strength($password);
|
||
|
||
$check = db()->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||
$check->execute([$email]);
|
||
if ($check->fetchColumn()) {
|
||
throw new RuntimeException('该邮箱已注册,请直接登录。');
|
||
}
|
||
|
||
return issue_signup_verification_from_hash($email, password_hash($password, PASSWORD_DEFAULT));
|
||
}
|
||
|
||
function resend_signup_verification(string $email): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
|
||
$check = db()->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||
$check->execute([$email]);
|
||
if ($check->fetchColumn()) {
|
||
throw new RuntimeException('该邮箱已注册,请直接登录。');
|
||
}
|
||
|
||
$latest = latest_signup_verification($email, false);
|
||
if (!$latest || empty($latest['password_hash'])) {
|
||
throw new RuntimeException('未找到待验证的注册记录,请先返回注册页填写邮箱和密码。');
|
||
}
|
||
|
||
return issue_signup_verification_from_hash($email, (string) $latest['password_hash']);
|
||
}
|
||
|
||
function complete_signup_verification(string $email, string $code): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
$code = trim($code);
|
||
if (!preg_match('/^\d{6}$/', $code)) {
|
||
throw new RuntimeException('请输入 6 位数字验证码。');
|
||
}
|
||
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
try {
|
||
$statement = $pdo->prepare(
|
||
"SELECT *
|
||
FROM email_verification_codes
|
||
WHERE email = ?
|
||
AND purpose = 'signup'
|
||
AND consumed_at IS NULL
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
FOR UPDATE"
|
||
);
|
||
$statement->execute([$email]);
|
||
$record = $statement->fetch();
|
||
|
||
if (!$record) {
|
||
throw new RuntimeException('验证码记录不存在,请返回注册页重新发送。');
|
||
}
|
||
|
||
if ((string) ($record['verified_at'] ?? '') !== '') {
|
||
throw new RuntimeException('该验证码已经使用过,请直接登录。');
|
||
}
|
||
|
||
if (strtotime((string) $record['expires_at']) < time()) {
|
||
$expired = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET consumed_at = NOW(), meta_note = 'code expired'
|
||
WHERE id = ?"
|
||
);
|
||
$expired->execute([(int) $record['id']]);
|
||
$pdo->commit();
|
||
throw new RuntimeException('验证码已过期,请重新发送。');
|
||
}
|
||
|
||
$maxAttempts = (int) ($record['max_attempts'] ?? 5);
|
||
$attemptCount = (int) ($record['attempt_count'] ?? 0);
|
||
if ($attemptCount >= $maxAttempts) {
|
||
$locked = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET consumed_at = NOW(), meta_note = 'max attempts reached'
|
||
WHERE id = ? AND consumed_at IS NULL"
|
||
);
|
||
$locked->execute([(int) $record['id']]);
|
||
$pdo->commit();
|
||
throw new RuntimeException('验证码错误次数过多,请重新发送验证码。');
|
||
}
|
||
|
||
if (!password_verify($code, (string) $record['code_hash'])) {
|
||
$attemptCount++;
|
||
if ($attemptCount >= $maxAttempts) {
|
||
$failed = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET attempt_count = ?, consumed_at = NOW(), meta_note = 'max attempts reached'
|
||
WHERE id = ?"
|
||
);
|
||
$failed->execute([$attemptCount, (int) $record['id']]);
|
||
$pdo->commit();
|
||
throw new RuntimeException('验证码错误次数过多,请重新发送验证码。');
|
||
}
|
||
|
||
$failed = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET attempt_count = ?, meta_note = 'verification code mismatch'
|
||
WHERE id = ?"
|
||
);
|
||
$failed->execute([$attemptCount, (int) $record['id']]);
|
||
$pdo->commit();
|
||
throw new RuntimeException('验证码错误,请重新输入。');
|
||
}
|
||
|
||
$userCheck = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||
$userCheck->execute([$email]);
|
||
if ($userCheck->fetchColumn()) {
|
||
$duplicate = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET consumed_at = NOW(), meta_note = 'email already registered'
|
||
WHERE id = ?"
|
||
);
|
||
$duplicate->execute([(int) $record['id']]);
|
||
$pdo->commit();
|
||
throw new RuntimeException('该邮箱已注册,请直接登录。');
|
||
}
|
||
|
||
$userId = create_user_account_record(
|
||
$pdo,
|
||
$email,
|
||
(string) $record['password_hash'],
|
||
date('Y-m-d H:i:s')
|
||
);
|
||
|
||
$verified = $pdo->prepare(
|
||
"UPDATE email_verification_codes
|
||
SET verified_at = NOW(), consumed_at = NOW(), meta_note = 'signup completed'
|
||
WHERE id = ?"
|
||
);
|
||
$verified->execute([(int) $record['id']]);
|
||
|
||
$pdo->commit();
|
||
unset($_SESSION['last_signup_email']);
|
||
|
||
return fetch_user($userId) ?? [];
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
function authenticate_user(string $email, string $password): array
|
||
{
|
||
$email = validate_email_or_fail($email);
|
||
|
||
$statement = db()->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
|
||
$statement->execute([$email]);
|
||
$record = $statement->fetch();
|
||
|
||
if (!$record || !password_verify($password, (string) $record['password_hash'])) {
|
||
throw new RuntimeException('邮箱或密码错误,请重试。');
|
||
}
|
||
|
||
$update = db()->prepare(
|
||
'UPDATE users
|
||
SET last_login_at = NOW(), email_verified_at = COALESCE(email_verified_at, NOW())
|
||
WHERE id = ?'
|
||
);
|
||
$update->execute([(int) $record['id']]);
|
||
|
||
set_logged_in_user((int) $record['id']);
|
||
return fetch_user((int) $record['id']) ?? [];
|
||
}
|
||
|
||
function wallet_snapshot(int $userId): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT available_balance, frozen_balance FROM wallets WHERE user_id = ? LIMIT 1"
|
||
);
|
||
$statement->execute([$userId]);
|
||
$wallet = $statement->fetch();
|
||
|
||
return $wallet ?: ["available_balance" => 0, "frozen_balance" => 0];
|
||
}
|
||
|
||
function insert_wallet_log(
|
||
PDO $pdo,
|
||
int $userId,
|
||
string $entryType,
|
||
float $amount,
|
||
float $frozenAmount,
|
||
string $note,
|
||
float $availableAfter,
|
||
float $frozenAfter
|
||
): void {
|
||
$statement = $pdo->prepare(
|
||
"INSERT INTO wallet_logs (
|
||
user_id,
|
||
entry_type,
|
||
amount,
|
||
frozen_amount,
|
||
available_after,
|
||
frozen_after,
|
||
note
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||
);
|
||
$statement->execute([
|
||
$userId,
|
||
$entryType,
|
||
number_format($amount, 2, ".", ""),
|
||
number_format($frozenAmount, 2, ".", ""),
|
||
number_format($availableAfter, 2, ".", ""),
|
||
number_format($frozenAfter, 2, ".", ""),
|
||
$note,
|
||
]);
|
||
}
|
||
|
||
function get_dashboard_stats(int $userId): array
|
||
{
|
||
$wallet = wallet_snapshot($userId);
|
||
$pdo = db();
|
||
|
||
$todayStatement = $pdo->prepare(
|
||
"SELECT COALESCE(SUM(amount), 0) AS today_earnings
|
||
FROM wallet_logs
|
||
WHERE user_id = ?
|
||
AND entry_type = 'task_credit'
|
||
AND DATE(created_at) = CURDATE()"
|
||
);
|
||
$todayStatement->execute([$userId]);
|
||
$todayEarnings = (float) ($todayStatement->fetchColumn() ?: 0);
|
||
|
||
$monthStatement = $pdo->prepare(
|
||
"SELECT COALESCE(SUM(amount), 0) AS month_earnings
|
||
FROM wallet_logs
|
||
WHERE user_id = ?
|
||
AND entry_type = 'task_credit'
|
||
AND DATE_FORMAT(created_at, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')"
|
||
);
|
||
$monthStatement->execute([$userId]);
|
||
$monthEarnings = (float) ($monthStatement->fetchColumn() ?: 0);
|
||
|
||
$todayTaskStatement = $pdo->prepare(
|
||
"SELECT COUNT(*)
|
||
FROM task_orders
|
||
WHERE user_id = ?
|
||
AND DATE(claimed_at) = CURDATE()"
|
||
);
|
||
$todayTaskStatement->execute([$userId]);
|
||
$todayTasks = (int) ($todayTaskStatement->fetchColumn() ?: 0);
|
||
|
||
$activeStatement = $pdo->prepare(
|
||
"SELECT COUNT(*) FROM task_orders WHERE user_id = ? AND status IN ('claimed', 'pending_review')"
|
||
);
|
||
$activeStatement->execute([$userId]);
|
||
$activeOrders = (int) ($activeStatement->fetchColumn() ?: 0);
|
||
|
||
$completedStatement = $pdo->prepare(
|
||
"SELECT COUNT(*) FROM task_orders WHERE user_id = ? AND status = 'approved'"
|
||
);
|
||
$completedStatement->execute([$userId]);
|
||
$completedOrders = (int) ($completedStatement->fetchColumn() ?: 0);
|
||
|
||
$totalBalance = (float) $wallet["available_balance"] + (float) $wallet["frozen_balance"];
|
||
|
||
return [
|
||
"available_balance" => (float) $wallet["available_balance"],
|
||
"frozen_balance" => (float) $wallet["frozen_balance"],
|
||
"total_balance" => $totalBalance,
|
||
"today_earnings" => $todayEarnings,
|
||
"month_earnings" => $monthEarnings,
|
||
"today_tasks" => $todayTasks,
|
||
"active_orders" => $activeOrders,
|
||
"completed_orders" => $completedOrders,
|
||
];
|
||
}
|
||
|
||
function get_user_orders(int $userId, int $limit = 12): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT * FROM task_orders WHERE user_id = ? ORDER BY id DESC LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $userId, PDO::PARAM_INT);
|
||
$statement->bindValue(2, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_user_orders_for_task(int $userId, string $taskSlug, int $limit = 6): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT * FROM task_orders WHERE user_id = ? AND task_slug = ? ORDER BY id DESC LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $userId, PDO::PARAM_INT);
|
||
$statement->bindValue(2, $taskSlug, PDO::PARAM_STR);
|
||
$statement->bindValue(3, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_wallet_logs(int $userId, int $limit = 20): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT * FROM wallet_logs WHERE user_id = ? ORDER BY id DESC LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $userId, PDO::PARAM_INT);
|
||
$statement->bindValue(2, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_user_vip_orders(int $userId, int $limit = 12): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT *
|
||
FROM vip_orders
|
||
WHERE user_id = ?
|
||
ORDER BY COALESCE(completed_at, created_at) DESC, id DESC
|
||
LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $userId, PDO::PARAM_INT);
|
||
$statement->bindValue(2, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_vip_order_by_id(int $vipOrderId, ?int $userId = null): ?array
|
||
{
|
||
$sql = "SELECT * FROM vip_orders WHERE id = ?";
|
||
$params = [$vipOrderId];
|
||
|
||
if ($userId !== null) {
|
||
$sql .= " AND user_id = ?";
|
||
$params[] = $userId;
|
||
}
|
||
|
||
$sql .= " LIMIT 1";
|
||
$statement = db()->prepare($sql);
|
||
$statement->execute($params);
|
||
$order = $statement->fetch();
|
||
return $order ?: null;
|
||
}
|
||
|
||
function purchase_vip_upgrade(int $userId, int $targetLevel): array
|
||
{
|
||
$catalog = vip_catalog();
|
||
if ($targetLevel <= 0 || !isset($catalog[$targetLevel])) {
|
||
throw new RuntimeException("请选择有效的 VIP 等级。");
|
||
}
|
||
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
|
||
try {
|
||
$userStatement = $pdo->prepare(
|
||
"SELECT * FROM users WHERE id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$userStatement->execute([$userId]);
|
||
$user = $userStatement->fetch();
|
||
|
||
if (!$user) {
|
||
throw new RuntimeException("用户不存在,请重新登录后再试。");
|
||
}
|
||
|
||
$currentLevel = (int) $user["vip_level"];
|
||
if ($targetLevel <= $currentLevel) {
|
||
throw new RuntimeException("当前账号已开通 " . vip_info($currentLevel)["name"] . ",请选择更高等级。");
|
||
}
|
||
|
||
$walletStatement = $pdo->prepare(
|
||
"SELECT available_balance, frozen_balance FROM wallets WHERE user_id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$walletStatement->execute([$userId]);
|
||
$wallet = $walletStatement->fetch();
|
||
|
||
if (!$wallet) {
|
||
throw new RuntimeException("钱包不存在,请重新登录后再试。");
|
||
}
|
||
|
||
$targetVip = $catalog[$targetLevel];
|
||
$price = (float) $targetVip["price"];
|
||
$availableBefore = (float) $wallet["available_balance"];
|
||
$frozenAfter = (float) $wallet["frozen_balance"];
|
||
|
||
if ($availableBefore < $price) {
|
||
$gap = $price - $availableBefore;
|
||
throw new RuntimeException("当前可用余额不足,还差 " . number_format($gap, 2) . " USDT,请先充值后再开通。");
|
||
}
|
||
|
||
$availableAfter = $availableBefore - $price;
|
||
$note = vip_info($currentLevel)["name"] . " 升级到 " . $targetVip["name"] . ",系统已从可用余额扣款。";
|
||
|
||
$updateUser = $pdo->prepare("UPDATE users SET vip_level = ? WHERE id = ?");
|
||
$updateUser->execute([$targetLevel, $userId]);
|
||
|
||
$updateWallet = $pdo->prepare(
|
||
"UPDATE wallets SET available_balance = ?, updated_at = NOW() WHERE user_id = ?"
|
||
);
|
||
$updateWallet->execute([
|
||
number_format($availableAfter, 2, ".", ""),
|
||
$userId,
|
||
]);
|
||
|
||
$insertOrder = $pdo->prepare(
|
||
"INSERT INTO vip_orders (
|
||
user_id,
|
||
from_level,
|
||
to_level,
|
||
price_usdt,
|
||
status,
|
||
available_after,
|
||
note,
|
||
completed_at
|
||
) VALUES (?, ?, ?, ?, 'completed', ?, ?, NOW())"
|
||
);
|
||
$insertOrder->execute([
|
||
$userId,
|
||
$currentLevel,
|
||
$targetLevel,
|
||
number_format($price, 2, ".", ""),
|
||
number_format($availableAfter, 2, ".", ""),
|
||
$note,
|
||
]);
|
||
$vipOrderId = (int) $pdo->lastInsertId();
|
||
|
||
insert_wallet_log(
|
||
$pdo,
|
||
$userId,
|
||
"vip_upgrade",
|
||
-$price,
|
||
0,
|
||
$note,
|
||
$availableAfter,
|
||
$frozenAfter
|
||
);
|
||
|
||
$pdo->commit();
|
||
|
||
return get_vip_order_by_id($vipOrderId, $userId) ?? [
|
||
"id" => $vipOrderId,
|
||
"from_level" => $currentLevel,
|
||
"to_level" => $targetLevel,
|
||
"price_usdt" => $price,
|
||
"available_after" => $availableAfter,
|
||
"status" => "completed",
|
||
"note" => $note,
|
||
];
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
function active_order_for_task(int $userId, string $taskSlug): ?array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT *
|
||
FROM task_orders
|
||
WHERE user_id = ? AND task_slug = ? AND status IN ('claimed', 'pending_review')
|
||
ORDER BY id DESC
|
||
LIMIT 1"
|
||
);
|
||
$statement->execute([$userId, $taskSlug]);
|
||
$order = $statement->fetch();
|
||
return $order ?: null;
|
||
}
|
||
|
||
function get_order_by_id(int $orderId, ?int $userId = null): ?array
|
||
{
|
||
$sql = "SELECT o.*, u.email, u.user_code, u.vip_level
|
||
FROM task_orders o
|
||
INNER JOIN users u ON u.id = o.user_id
|
||
WHERE o.id = ?";
|
||
|
||
$params = [$orderId];
|
||
if ($userId !== null) {
|
||
$sql .= " AND o.user_id = ?";
|
||
$params[] = $userId;
|
||
}
|
||
|
||
$sql .= " LIMIT 1";
|
||
$statement = db()->prepare($sql);
|
||
$statement->execute($params);
|
||
$order = $statement->fetch();
|
||
return $order ?: null;
|
||
}
|
||
|
||
function can_access_task(array $user, array $task): bool
|
||
{
|
||
return (int) $user["vip_level"] >= (int) $task["vip_required"];
|
||
}
|
||
|
||
function create_task_order(int $userId, array $task): array
|
||
{
|
||
$user = fetch_user($userId);
|
||
if (!$user) {
|
||
throw new RuntimeException("用户不存在,请重新登录。");
|
||
}
|
||
|
||
if (!can_access_task($user, $task)) {
|
||
throw new RuntimeException("当前 VIP 等级不足,暂不能领取该任务。");
|
||
}
|
||
|
||
if (active_order_for_task($userId, (string) $task["slug"])) {
|
||
throw new RuntimeException("你已经有一个进行中的同类任务,请先完成当前任务。");
|
||
}
|
||
|
||
$statement = db()->prepare(
|
||
"INSERT INTO task_orders (
|
||
user_id,
|
||
task_slug,
|
||
task_title,
|
||
task_type,
|
||
platform_name,
|
||
reward_usdt,
|
||
vip_required,
|
||
countdown_seconds,
|
||
status
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'claimed')"
|
||
);
|
||
$statement->execute([
|
||
$userId,
|
||
$task["slug"],
|
||
$task["title"],
|
||
$task["type"],
|
||
$task["platform"],
|
||
number_format((float) $task["reward"], 2, ".", ""),
|
||
(int) $task["vip_required"],
|
||
(int) $task["countdown"],
|
||
]);
|
||
|
||
$orderId = (int) db()->lastInsertId();
|
||
return get_order_by_id($orderId, $userId) ?? [];
|
||
}
|
||
|
||
function is_submit_unlocked(array $order): bool
|
||
{
|
||
$claimedTimestamp = strtotime((string) $order["claimed_at"]);
|
||
if ($claimedTimestamp === false) {
|
||
return false;
|
||
}
|
||
|
||
return time() >= $claimedTimestamp + (int) $order["countdown_seconds"];
|
||
}
|
||
|
||
function submit_task_order(int $userId, int $orderId, string $proofNote): array
|
||
{
|
||
$proofNote = trim($proofNote);
|
||
if (text_length($proofNote) < 6) {
|
||
throw new RuntimeException("请填写至少 6 个字符的完成备注,方便后台审核。");
|
||
}
|
||
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
|
||
try {
|
||
$statement = $pdo->prepare(
|
||
"SELECT * FROM task_orders WHERE id = ? AND user_id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$statement->execute([$orderId, $userId]);
|
||
$order = $statement->fetch();
|
||
|
||
if (!$order) {
|
||
throw new RuntimeException("任务订单不存在。");
|
||
}
|
||
if ((string) $order["status"] !== "claimed") {
|
||
throw new RuntimeException("当前任务状态不可提交。");
|
||
}
|
||
if (!is_submit_unlocked($order)) {
|
||
throw new RuntimeException("倒计时尚未结束,请等待后再提交。");
|
||
}
|
||
|
||
$walletStatement = $pdo->prepare(
|
||
"SELECT available_balance, frozen_balance FROM wallets WHERE user_id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$walletStatement->execute([$userId]);
|
||
$wallet = $walletStatement->fetch();
|
||
if (!$wallet) {
|
||
throw new RuntimeException("钱包不存在,请重新登录。");
|
||
}
|
||
|
||
$availableAfter = (float) $wallet["available_balance"];
|
||
$frozenAfter = (float) $wallet["frozen_balance"] + (float) $order["reward_usdt"];
|
||
|
||
$updateOrder = $pdo->prepare(
|
||
"UPDATE task_orders
|
||
SET status = 'pending_review', proof_note = ?, submitted_at = NOW()
|
||
WHERE id = ?"
|
||
);
|
||
$updateOrder->execute([$proofNote, $orderId]);
|
||
|
||
$updateWallet = $pdo->prepare(
|
||
"UPDATE wallets SET frozen_balance = ?, updated_at = NOW() WHERE user_id = ?"
|
||
);
|
||
$updateWallet->execute([number_format($frozenAfter, 2, ".", ""), $userId]);
|
||
|
||
insert_wallet_log(
|
||
$pdo,
|
||
$userId,
|
||
"task_freeze",
|
||
0,
|
||
(float) $order["reward_usdt"],
|
||
"任务已提交审核,佣金进入冻结余额。",
|
||
$availableAfter,
|
||
$frozenAfter
|
||
);
|
||
|
||
$pdo->commit();
|
||
return get_order_by_id($orderId, $userId) ?? [];
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
function review_task_order(int $orderId, string $decision, string $note = ""): array
|
||
{
|
||
$decision = $decision === "approve" ? "approved" : "rejected";
|
||
$note = trim($note);
|
||
|
||
$pdo = db();
|
||
$pdo->beginTransaction();
|
||
|
||
try {
|
||
$orderStatement = $pdo->prepare(
|
||
"SELECT * FROM task_orders WHERE id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$orderStatement->execute([$orderId]);
|
||
$order = $orderStatement->fetch();
|
||
|
||
if (!$order) {
|
||
throw new RuntimeException("待审核订单不存在。");
|
||
}
|
||
if ((string) $order["status"] !== "pending_review") {
|
||
throw new RuntimeException("该订单已处理,无需重复审核。");
|
||
}
|
||
|
||
$walletStatement = $pdo->prepare(
|
||
"SELECT available_balance, frozen_balance FROM wallets WHERE user_id = ? LIMIT 1 FOR UPDATE"
|
||
);
|
||
$walletStatement->execute([(int) $order["user_id"]]);
|
||
$wallet = $walletStatement->fetch();
|
||
if (!$wallet) {
|
||
throw new RuntimeException("钱包记录不存在。");
|
||
}
|
||
|
||
$reward = (float) $order["reward_usdt"];
|
||
$availableAfter = (float) $wallet["available_balance"];
|
||
$frozenAfter = max(0, (float) $wallet["frozen_balance"] - $reward);
|
||
$entryType = "task_reject";
|
||
$logAmount = 0.0;
|
||
$logFrozen = -$reward;
|
||
$defaultNote = "任务审核未通过,冻结佣金已释放。";
|
||
|
||
if ($decision === "approved") {
|
||
$availableAfter += $reward;
|
||
$entryType = "task_credit";
|
||
$logAmount = $reward;
|
||
$defaultNote = "任务审核通过,佣金已转入可提现余额。";
|
||
}
|
||
|
||
$updateOrder = $pdo->prepare(
|
||
"UPDATE task_orders
|
||
SET status = ?, reviewed_at = NOW(), review_note = ?
|
||
WHERE id = ?"
|
||
);
|
||
$updateOrder->execute([$decision, $note !== "" ? $note : $defaultNote, $orderId]);
|
||
|
||
$updateWallet = $pdo->prepare(
|
||
"UPDATE wallets
|
||
SET available_balance = ?, frozen_balance = ?, updated_at = NOW()
|
||
WHERE user_id = ?"
|
||
);
|
||
$updateWallet->execute([
|
||
number_format($availableAfter, 2, ".", ""),
|
||
number_format($frozenAfter, 2, ".", ""),
|
||
(int) $order["user_id"],
|
||
]);
|
||
|
||
insert_wallet_log(
|
||
$pdo,
|
||
(int) $order["user_id"],
|
||
$entryType,
|
||
$logAmount,
|
||
$logFrozen,
|
||
$note !== "" ? $note : $defaultNote,
|
||
$availableAfter,
|
||
$frozenAfter
|
||
);
|
||
|
||
$pdo->commit();
|
||
return get_order_by_id($orderId, null) ?? [];
|
||
} catch (Throwable $exception) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
function get_operator_orders(string $status, int $limit = 20): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT o.*, u.email, u.user_code, u.vip_level
|
||
FROM task_orders o
|
||
INNER JOIN users u ON u.id = o.user_id
|
||
WHERE o.status = ?
|
||
ORDER BY COALESCE(o.submitted_at, o.claimed_at) DESC
|
||
LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $status, PDO::PARAM_STR);
|
||
$statement->bindValue(2, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_recent_reviewed_orders(int $limit = 12): array
|
||
{
|
||
$statement = db()->prepare(
|
||
"SELECT o.*, u.email, u.user_code, u.vip_level
|
||
FROM task_orders o
|
||
INNER JOIN users u ON u.id = o.user_id
|
||
WHERE o.status IN ('approved', 'rejected')
|
||
ORDER BY o.reviewed_at DESC, o.id DESC
|
||
LIMIT ?"
|
||
);
|
||
$statement->bindValue(1, $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
|
||
return $statement->fetchAll() ?: [];
|
||
}
|
||
|
||
function get_operator_stats(): array
|
||
{
|
||
$pdo = db();
|
||
|
||
$todayUsers = (int) ($pdo->query("SELECT COUNT(*) FROM users WHERE DATE(created_at) = CURDATE()")->fetchColumn() ?: 0);
|
||
$pending = (int) ($pdo->query("SELECT COUNT(*) FROM task_orders WHERE status = 'pending_review'")->fetchColumn() ?: 0);
|
||
$completed = (int) ($pdo->query("SELECT COUNT(*) FROM task_orders WHERE status = 'approved'")->fetchColumn() ?: 0);
|
||
$credited = (float) ($pdo->query("SELECT COALESCE(SUM(amount), 0) FROM wallet_logs WHERE entry_type = 'task_credit'")->fetchColumn() ?: 0);
|
||
|
||
return [
|
||
"today_users" => $todayUsers,
|
||
"pending_reviews" => $pending,
|
||
"approved_orders" => $completed,
|
||
"credited_amount" => $credited,
|
||
];
|
||
}
|
||
|
||
function status_label(string $status): string
|
||
{
|
||
return [
|
||
"claimed" => "进行中",
|
||
"pending_review" => "待审核",
|
||
"approved" => "已入账",
|
||
"rejected" => "已驳回",
|
||
][$status] ?? $status;
|
||
}
|
||
|
||
function status_tone(string $status): string
|
||
{
|
||
return [
|
||
"claimed" => "muted",
|
||
"pending_review" => "warning",
|
||
"approved" => "success",
|
||
"rejected" => "danger",
|
||
][$status] ?? "muted";
|
||
}
|
||
|
||
function wallet_entry_label(string $entryType): string
|
||
{
|
||
return [
|
||
"wallet_opened" => "钱包创建",
|
||
"task_freeze" => "冻结佣金",
|
||
"task_credit" => "佣金到账",
|
||
"task_reject" => "审核释放",
|
||
"vip_upgrade" => "VIP 开通",
|
||
][$entryType] ?? $entryType;
|
||
}
|
||
|
||
function render_status_badge(string $status): string
|
||
{
|
||
return '<span class="status-pill tone-' . h(status_tone($status)) . '">' . h(status_label($status)) . '</span>';
|
||
}
|
||
|
||
function front_nav_items(): array
|
||
{
|
||
return [
|
||
["key" => "home", "label" => "首页", "href" => "index.php", "icon" => "home"],
|
||
["key" => "tasks", "label" => "任务", "href" => "task.php", "icon" => "tasks"],
|
||
["key" => "vip", "label" => "VIP", "href" => "vip.php", "icon" => "vip"],
|
||
["key" => "wallet", "label" => "钱包", "href" => "wallet.php", "icon" => "wallet"],
|
||
["key" => "profile", "label" => "我的", "href" => "profile.php", "icon" => "profile"],
|
||
];
|
||
}
|
||
|
||
function is_front_app_nav(string $active): bool
|
||
{
|
||
foreach (front_nav_items() as $item) {
|
||
if ($item["key"] === $active) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function app_user_name(?array $user): string
|
||
{
|
||
if (!$user) {
|
||
return 'User';
|
||
}
|
||
|
||
$email = (string) ($user['email'] ?? '');
|
||
$local = strstr($email, '@', true);
|
||
if ($local !== false && $local !== '') {
|
||
return $local;
|
||
}
|
||
|
||
return 'ID ' . (string) ($user['user_code'] ?? '000000');
|
||
}
|
||
|
||
function app_user_initial(?array $user): string
|
||
{
|
||
$name = app_user_name($user);
|
||
$first = strtoupper(substr($name, 0, 1));
|
||
return $first !== '' ? $first : 'U';
|
||
}
|
||
|
||
function task_category_options(): array
|
||
{
|
||
return [
|
||
'all' => '全部',
|
||
'video' => '视频',
|
||
'like' => '点赞',
|
||
'social' => '社媒',
|
||
'web' => '网站',
|
||
'app' => '应用',
|
||
];
|
||
}
|
||
|
||
function task_visual_meta(array $task): array
|
||
{
|
||
$map = [
|
||
'watch-video' => [
|
||
'brand' => 'YouTube',
|
||
'icon' => 'play',
|
||
'accent' => 'accent-red',
|
||
'category' => 'video',
|
||
'tag' => '视频',
|
||
'hint' => '观看视频',
|
||
],
|
||
'social-like' => [
|
||
'brand' => 'TikTok',
|
||
'icon' => 'heart',
|
||
'accent' => 'accent-pink',
|
||
'category' => 'like',
|
||
'tag' => '点赞',
|
||
'hint' => '点赞互动',
|
||
],
|
||
'follow-brand' => [
|
||
'brand' => 'Instagram',
|
||
'icon' => 'spark',
|
||
'accent' => 'accent-violet',
|
||
'category' => 'social',
|
||
'tag' => '社媒',
|
||
'hint' => '关注账号',
|
||
],
|
||
'comment-flow' => [
|
||
'brand' => 'Community',
|
||
'icon' => 'chat',
|
||
'accent' => 'accent-cyan',
|
||
'category' => 'social',
|
||
'tag' => '社媒',
|
||
'hint' => '评论互动',
|
||
],
|
||
'site-browse' => [
|
||
'brand' => 'Website',
|
||
'icon' => 'globe',
|
||
'accent' => 'accent-blue',
|
||
'category' => 'web',
|
||
'tag' => '网站',
|
||
'hint' => '网站浏览',
|
||
],
|
||
'app-download' => [
|
||
'brand' => 'App Center',
|
||
'icon' => 'download',
|
||
'accent' => 'accent-orange',
|
||
'category' => 'app',
|
||
'tag' => '应用',
|
||
'hint' => '下载体验',
|
||
],
|
||
];
|
||
|
||
$slug = (string) ($task['slug'] ?? '');
|
||
$fallback = [
|
||
'brand' => (string) ($task['platform'] ?? 'Task'),
|
||
'icon' => 'spark',
|
||
'accent' => 'accent-violet',
|
||
'category' => 'all',
|
||
'tag' => (string) ($task['type'] ?? '任务'),
|
||
'hint' => (string) ($task['summary'] ?? '任务返佣'),
|
||
];
|
||
|
||
return $map[$slug] ?? $fallback;
|
||
}
|
||
|
||
function app_icon_svg(string $icon): string
|
||
{
|
||
$icons = [
|
||
'home' => '<path d="M3.75 10.5 12 4l8.25 6.5" /><path d="M6 9.5v9h12v-9" /><path d="M10 18.5v-4h4v4" />',
|
||
'tasks' => '<rect x="5" y="4.5" width="14" height="15" rx="2.5" /><path d="M9 4.5h6" /><path d="m9 12 2 2 4-4" /><path d="M9 16h6" />',
|
||
'vip' => '<path d="M4.5 7.5 8 11l4-5 4 5 3.5-3.5-2 8H6.5l-2-8Z" /><path d="M8.5 18.5h7" />',
|
||
'wallet' => '<path d="M4.5 7.5A2.5 2.5 0 0 1 7 5h10a2.5 2.5 0 0 1 2.5 2.5v1.25H15a2 2 0 0 0-2 2v1.5a2 2 0 0 0 2 2h4.5V16.5A2.5 2.5 0 0 1 17 19H7a2.5 2.5 0 0 1-2.5-2.5v-9Z" /><path d="M19.5 8.75v5.5" /><path d="M15.5 11.5h.01" />',
|
||
'profile' => '<path d="M12 11a3.75 3.75 0 1 0 0-7.5A3.75 3.75 0 0 0 12 11Z" /><path d="M4.5 19.5a7.5 7.5 0 0 1 15 0" />',
|
||
'play' => '<path d="M8 6.5v11l8-5.5-8-5.5Z" />',
|
||
'deposit' => '<path d="M12 4.5v12" /><path d="m7.5 12 4.5 4.5 4.5-4.5" /><path d="M5 19.5h14" />',
|
||
'withdraw' => '<path d="M12 19.5v-12" /><path d="m16.5 12-4.5-4.5L7.5 12" /><path d="M5 4.5h14" />',
|
||
'swap' => '<path d="M6 8h10" /><path d="m12.5 4.5 3.5 3.5-3.5 3.5" /><path d="M18 16H8" /><path d="m11.5 12.5-3.5 3.5 3.5 3.5" />',
|
||
'history' => '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3" /><path d="M4.5 5.5v3.5H8" /><path d="M12 8v4l2.5 1.5" />',
|
||
'heart' => '<path d="M12 19.25 5 12.75a4.1 4.1 0 0 1 5.8-5.8L12 8.15l1.2-1.2a4.1 4.1 0 0 1 5.8 5.8L12 19.25Z" />',
|
||
'spark' => '<path d="M12 3.5 13.8 8.2 18.5 10 13.8 11.8 12 16.5 10.2 11.8 5.5 10 10.2 8.2 12 3.5Z" /><path d="M18.5 16.5 19.2 18.3 21 19 19.2 19.7 18.5 21.5 17.8 19.7 16 19 17.8 18.3 18.5 16.5Z" />',
|
||
'chat' => '<path d="M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v6A2.5 2.5 0 0 1 16.5 15H11l-4.5 4v-4H7.5A2.5 2.5 0 0 1 5 12.5v-6Z" /><path d="M9 9.5h6" /><path d="M9 12h3.5" />',
|
||
'globe' => '<circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17" /><path d="M12 3.5a13 13 0 0 1 0 17" /><path d="M12 3.5a13 13 0 0 0 0 17" />',
|
||
'download' => '<path d="M12 4.5v10" /><path d="m8.5 11.5 3.5 3.5 3.5-3.5" /><path d="M5 19.5h14" />',
|
||
'check' => '<path d="m6.5 12 3.5 3.5L17.5 8" />',
|
||
'lock' => '<rect x="6" y="10" width="12" height="9" rx="2" /><path d="M8.5 10V8a3.5 3.5 0 0 1 7 0v2" />',
|
||
'support' => '<path d="M6 11.5a6 6 0 1 1 12 0v2.5A2.5 2.5 0 0 1 15.5 16.5H14" /><path d="M10 18h4" /><path d="M7 12.5h-.5A1.5 1.5 0 0 0 5 14v1.5A1.5 1.5 0 0 0 6.5 17H7" /><path d="M17 12.5h.5A1.5 1.5 0 0 1 19 14v1.5A1.5 1.5 0 0 1 17.5 17H17" />',
|
||
'chevron-right' => '<path d="m10 7 5 5-5 5" />',
|
||
];
|
||
|
||
$paths = $icons[$icon] ?? $icons['spark'];
|
||
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $paths . '</svg>';
|
||
}
|
||
|
||
function render_head(string $pageTitle, string $pageDescription = ""): void
|
||
{
|
||
$siteName = project_name();
|
||
$description = trim($pageDescription) !== "" ? $pageDescription : project_description();
|
||
$fullTitle = trim($pageTitle) !== "" ? $pageTitle . " · " . $siteName : $siteName;
|
||
$projectDescription = $_SERVER["PROJECT_DESCRIPTION"] ?? "";
|
||
$projectImageUrl = $_SERVER["PROJECT_IMAGE_URL"] ?? "";
|
||
?>
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title><?= h($fullTitle) ?></title>
|
||
<meta name="description" content="<?= h($description ?: $projectDescription) ?>" />
|
||
<meta name="theme-color" content="#0b0f14" />
|
||
<meta property="og:title" content="<?= h($fullTitle) ?>" />
|
||
<meta property="og:description" content="<?= h($description ?: $projectDescription) ?>" />
|
||
<meta property="og:type" content="website" />
|
||
<meta property="twitter:card" content="summary_large_image" />
|
||
<meta property="twitter:title" content="<?= h($fullTitle) ?>" />
|
||
<meta property="twitter:description" content="<?= h($description ?: $projectDescription) ?>" />
|
||
<?php if ($projectImageUrl): ?>
|
||
<meta property="og:image" content="<?= h($projectImageUrl) ?>" />
|
||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>" />
|
||
<?php endif; ?>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||
<link href="assets/css/custom.css?v=<?= h(asset_version("assets/css/custom.css")) ?>" rel="stylesheet" />
|
||
</head>
|
||
<?php
|
||
}
|
||
|
||
function render_header(string $active = "home"): void
|
||
{
|
||
$user = current_user();
|
||
$front = is_front_app_nav($active);
|
||
$links = array_merge(front_nav_items(), [
|
||
["key" => "ops", "label" => "运营台", "href" => "operations.php"],
|
||
["key" => "health", "label" => "Healthz", "href" => "healthz.php"],
|
||
]);
|
||
?>
|
||
<div class="app-shell<?= $front ? ' app-shell-front' : '' ?>">
|
||
<?php if ($front): ?>
|
||
<div class="front-glow front-glow-one"></div>
|
||
<div class="front-glow front-glow-two"></div>
|
||
<header class="front-topbar">
|
||
<div class="front-topbar-shell">
|
||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||
<a href="index.php" class="front-brand text-decoration-none">
|
||
<span class="brand-mark brand-mark-gradient"><?= app_icon_svg('play') ?></span>
|
||
<div>
|
||
<div class="brand-title">任务返佣平台</div>
|
||
<div class="brand-subtitle">看视频 · 点赞 · 关注 · 网站浏览</div>
|
||
</div>
|
||
</a>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<?php if ($user): ?>
|
||
<span class="mini-pill"><?= h(vip_info((int) $user['vip_level'])['name']) ?></span>
|
||
<?php endif; ?>
|
||
<a href="operations.php" class="mini-icon-btn" aria-label="后台管理预览"><?= app_icon_svg('support') ?></a>
|
||
</div>
|
||
</div>
|
||
<div class="front-user-row mt-3">
|
||
<?php if ($user): ?>
|
||
<div class="user-chip compact-chip">
|
||
<span class="mono">ID <?= h($user['user_code']) ?></span>
|
||
<span class="divider-dot"></span>
|
||
<span><?= h($user['email']) ?></span>
|
||
</div>
|
||
<a href="logout.php" class="mini-link">退出</a>
|
||
<?php else: ?>
|
||
<a href="<?= h(start_page_url()) ?>" class="mini-link">邮箱注册 / 登录</a>
|
||
<a href="healthz.php" class="mini-icon-btn" aria-label="健康检查"><?= app_icon_svg('check') ?></a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<?php else: ?>
|
||
<header class="topbar sticky-top">
|
||
<div class="container py-3">
|
||
<div class="topbar-panel">
|
||
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap">
|
||
<div>
|
||
<a href="index.php" class="brand-link text-decoration-none">
|
||
<span class="brand-mark brand-mark-gradient"><?= app_icon_svg('play') ?></span>
|
||
<div>
|
||
<div class="brand-title"><?= h(project_name()) ?></div>
|
||
<div class="brand-subtitle">Task rebate operations · mobile preview</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
<div class="d-flex flex-column align-items-start align-items-md-end gap-2 ms-auto">
|
||
<?php if ($user): ?>
|
||
<div class="user-chip">
|
||
<span class="mono">ID <?= h($user['user_code']) ?></span>
|
||
<span class="divider-dot"></span>
|
||
<span><?= h($user['email']) ?></span>
|
||
<span class="divider-dot"></span>
|
||
<span><?= h(vip_info((int) $user['vip_level'])['name']) ?></span>
|
||
</div>
|
||
<a href="logout.php" class="btn btn-outline-light btn-sm px-3">退出</a>
|
||
<?php else: ?>
|
||
<a href="<?= h(start_page_url()) ?>" class="btn btn-outline-light btn-sm px-3">邮箱注册 / 登录</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<nav class="app-nav mt-3" aria-label="Primary">
|
||
<?php foreach ($links as $link): ?>
|
||
<a href="<?= h($link['href']) ?>" class="nav-chip<?= $active === $link['key'] ? ' active' : '' ?>"><?= h($link['label']) ?></a>
|
||
<?php endforeach; ?>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<?php endif; ?>
|
||
<?php
|
||
}
|
||
|
||
function render_toasts(array $flashes): void
|
||
{
|
||
if (!$flashes) {
|
||
return;
|
||
}
|
||
?>
|
||
<div class="toast-stack position-fixed top-0 end-0 p-3">
|
||
<?php foreach ($flashes as $flash): ?>
|
||
<div class="toast align-items-center border-0 show mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4500">
|
||
<div class="toast-shell tone-<?= h((string) ($flash['tone'] ?? 'info')) ?>">
|
||
<div class="toast-body"><?= nl2br(h((string) ($flash['message'] ?? ''))) ?></div>
|
||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php
|
||
}
|
||
|
||
function render_layout_start(string $pageTitle, string $pageDescription, string $activeNav): void
|
||
{
|
||
$GLOBALS['app_active_nav'] = $activeNav;
|
||
$isFront = is_front_app_nav($activeNav);
|
||
render_head($pageTitle, $pageDescription);
|
||
?>
|
||
<body class="<?= $isFront ? 'front-app-body' : '' ?>">
|
||
<?php render_header($activeNav); ?>
|
||
<main class="<?= $isFront ? 'front-main' : 'container py-4 py-lg-5' ?>">
|
||
<?php render_toasts(pull_flashes()); ?>
|
||
<?php
|
||
}
|
||
|
||
function render_bottom_nav(string $active): void
|
||
{
|
||
?>
|
||
<nav class="bottom-app-nav" aria-label="底部导航">
|
||
<div class="bottom-app-nav-shell">
|
||
<?php foreach (front_nav_items() as $item): ?>
|
||
<a href="<?= h($item['href']) ?>" class="bottom-nav-item<?= $active === $item['key'] ? ' active' : '' ?>">
|
||
<span class="bottom-nav-icon"><?= app_icon_svg((string) $item['icon']) ?></span>
|
||
<span class="bottom-nav-label"><?= h($item['label']) ?></span>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</nav>
|
||
<?php
|
||
}
|
||
|
||
function render_layout_end(): void
|
||
{
|
||
$activeNav = (string) ($GLOBALS['app_active_nav'] ?? '');
|
||
$isFront = is_front_app_nav($activeNav);
|
||
?>
|
||
</main>
|
||
<?php if ($isFront): ?>
|
||
<?php render_bottom_nav($activeNav); ?>
|
||
<?php else: ?>
|
||
<footer class="container footer-shell pb-4 pt-2">
|
||
<div class="footer-panel d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||
<div>
|
||
<div class="footer-title">第一版可用闭环</div>
|
||
<div class="footer-copy">邮箱开户注册、任务领取、倒计时提交、运营审核、钱包流水已打通。</div>
|
||
</div>
|
||
<div class="d-flex flex-wrap gap-2">
|
||
<a href="index.php" class="footer-link">首页</a>
|
||
<a href="task.php" class="footer-link">任务</a>
|
||
<a href="vip.php" class="footer-link">VIP</a>
|
||
<a href="wallet.php" class="footer-link">钱包</a>
|
||
<a href="profile.php" class="footer-link">我的</a>
|
||
<a href="operations.php" class="footer-link">运营台</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
<?php endif; ?>
|
||
</div>
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||
<script src="assets/js/main.js?v=<?= h(asset_version('assets/js/main.js')) ?>" defer></script>
|
||
</body>
|
||
</html>
|
||
<?php
|
||
}
|