40069-vm/app.php
2026-05-25 13:11:32 +00:00

1905 lines
63 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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
}