diff --git a/app.php b/app.php new file mode 100644 index 0000000..b4b9480 --- /dev/null +++ b/app.php @@ -0,0 +1,1904 @@ +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 = '
' + . '

' . $siteName . '

' + . '

你的邮箱验证码

' + . '

请在 ' . $ttlMinutes . ' 分钟内输入下面这组 6 位数字:

' + . '
' . $safeCode . '
' + . '

如果这不是你本人操作,请直接忽略这封邮件。

' + . '
'; + $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 '' . h(status_label($status)) . ''; +} + +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' => '', + 'tasks' => '', + 'vip' => '', + 'wallet' => '', + 'profile' => '', + 'play' => '', + 'deposit' => '', + 'withdraw' => '', + 'swap' => '', + 'history' => '', + 'heart' => '', + 'spark' => '', + 'chat' => '', + 'globe' => '', + 'download' => '', + 'check' => '', + 'lock' => '', + 'support' => '', + 'chevron-right' => '', + ]; + + $paths = $icons[$icon] ?? $icons['spark']; + + return ''; +} + +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"] ?? ""; + ?> + + + + + + <?= h($fullTitle) ?> + + + + + + + + + + + + + + " rel="stylesheet" /> + + "ops", "label" => "运营台", "href" => "operations.php"], + ["key" => "health", "label" => "Healthz", "href" => "healthz.php"], + ]); + ?> +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+ + +
+ + + +
+ + + +
+ + + + +
+ + + +
+ +
+ +
+ + + + + { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + if (window.bootstrap) { + document.querySelectorAll('.toast').forEach((toastEl) => { + const toast = bootstrap.Toast.getOrCreateInstance(toastEl); + toast.show(); + }); + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; + document.querySelectorAll('[data-countdown]').forEach((node) => { + const unlockAt = Number.parseInt(node.getAttribute('data-countdown') || '0', 10); + const targetSelector = node.getAttribute('data-countdown-target'); + const targetButton = targetSelector ? document.querySelector(targetSelector) : null; + const card = node.closest('[data-countdown-card]') || node.closest('.countdown-card'); - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const updateCountdown = () => { + const remaining = Math.max(0, unlockAt - Math.floor(Date.now() / 1000)); + if (remaining > 0) { + node.textContent = `${remaining} 秒`; + node.classList.remove('ready'); + if (targetButton) { + targetButton.disabled = true; + targetButton.setAttribute('aria-disabled', 'true'); + } + if (card) { + card.classList.remove('ready'); + } + return false; + } - appendMessage(message, 'visitor'); - chatInput.value = ''; + node.textContent = '可提交'; + node.classList.add('ready'); + if (targetButton) { + targetButton.disabled = false; + targetButton.removeAttribute('aria-disabled'); + } + if (card) { + card.classList.add('ready'); + } + return true; + }; - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + const done = updateCountdown(); + if (!done) { + const interval = window.setInterval(() => { + if (updateCountdown()) { + window.clearInterval(interval); + } + }, 1000); } }); + + document.querySelectorAll('[data-confirm]').forEach((button) => { + button.addEventListener('click', (event) => { + const message = button.getAttribute('data-confirm'); + if (message && !window.confirm(message)) { + event.preventDefault(); + } + }); + }); }); diff --git a/assets/pasted-20260525-120457-0c06ca85.png b/assets/pasted-20260525-120457-0c06ca85.png new file mode 100644 index 0000000..7ec5390 Binary files /dev/null and b/assets/pasted-20260525-120457-0c06ca85.png differ diff --git a/assets/pasted-20260525-130328-7c40a29a.png b/assets/pasted-20260525-130328-7c40a29a.png new file mode 100644 index 0000000..7ec5390 Binary files /dev/null and b/assets/pasted-20260525-130328-7c40a29a.png differ diff --git a/db/migrations/20260525_add_email_verification_codes.sql b/db/migrations/20260525_add_email_verification_codes.sql new file mode 100644 index 0000000..b72c911 --- /dev/null +++ b/db/migrations/20260525_add_email_verification_codes.sql @@ -0,0 +1,20 @@ +-- Add email_verification_codes table for real email signup flow. +-- users.email_verified_at and users.last_login_at are also ensured defensively in app.php. + +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; diff --git a/db/migrations/20260525_add_vip_orders.sql b/db/migrations/20260525_add_vip_orders.sql new file mode 100644 index 0000000..f9b5151 --- /dev/null +++ b/db/migrations/20260525_add_vip_orders.sql @@ -0,0 +1,18 @@ +-- Add vip_orders table for real VIP upgrade flow. +-- The app also creates this table defensively in app.php via CREATE TABLE IF NOT EXISTS. + +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; diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..4c4aaed --- /dev/null +++ b/healthz.php @@ -0,0 +1,24 @@ +query('SELECT 1'); + echo json_encode([ + 'status' => 'ok', + 'app' => project_name(), + 'time_utc' => gmdate('c'), + 'database' => 'connected', + 'client_ip' => client_ip(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); +} catch (Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'app' => project_name(), + 'time_utc' => gmdate('c'), + 'database' => 'disconnected', + 'error' => $exception->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); +} diff --git a/index.php b/index.php index 7205f3d..582b393 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,208 @@ 5560.25, + 'frozen_balance' => 120.00, + 'total_balance' => 5680.25, + 'today_earnings' => 1000.00, + 'month_earnings' => 18560.20, + 'today_tasks' => 30, + 'active_orders' => 3, + 'completed_orders' => 12, + ]; +$recentOrders = $user ? get_user_orders((int) $user['id'], 4) : []; +$showcaseTasks = array_slice(array_values(task_catalog()), 0, 4); +$quickActions = [ + ['label' => '充值', 'icon' => 'deposit', 'href' => 'wallet.php#deposit-panel'], + ['label' => '提现', 'icon' => 'withdraw', 'href' => 'wallet.php#withdraw-panel'], + ['label' => '转账', 'icon' => 'swap', 'href' => 'wallet.php#transfer-panel'], + ['label' => '记录', 'icon' => 'history', 'href' => 'wallet.php#history-panel'], +]; +$taskShortcuts = [ + ['label' => '看视频', 'icon' => 'play', 'href' => 'task.php?category=video', 'accent' => 'accent-red'], + ['label' => '点赞', 'icon' => 'heart', 'href' => 'task.php?category=like', 'accent' => 'accent-pink'], + ['label' => '关注', 'icon' => 'spark', 'href' => 'task.php?category=social', 'accent' => 'accent-violet'], + ['label' => '网站', 'icon' => 'globe', 'href' => 'task.php?category=web', 'accent' => 'accent-blue'], + ['label' => '应用', 'icon' => 'download', 'href' => 'task.php?category=app', 'accent' => 'accent-orange'], +]; +$onboardingSteps = [ + ['step' => '01', 'title' => '启动页', 'desc' => '先看平台介绍、流程说明和开户注册入口。'], + ['step' => '02', 'title' => '邮箱注册', 'desc' => '填写邮箱和密码,系统准备发送 6 位验证码。'], + ['step' => '03', 'title' => '邮箱验证码', 'desc' => '输入邮件里的 6 位数字,验证通过后再正式建号。'], + ['step' => '04', 'title' => '账号登录', 'desc' => '用已验证邮箱登录,再进入首页 / 任务 / VIP / 钱包 / 我的。'], +]; + +render_layout_start('首页', '任务返佣平台首页,提供资产总览、快捷任务、VIP 入口与真实邮箱开户流程。', 'home'); ?> - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+
Hello,
+

任务返佣平台

+

做任务,赚佣金。先领取任务,再等待审核入账。

-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
-
-
- Page updated: (UTC) -
- - + +
+
+
+
总资产(USDT)
+
+
可用 · 冻结
+
+ +
+ +
+ + + + + + +
+
+ + +
当前显示的是参考图风格预览数据。登录后会显示你的真实余额、任务与流水。
+ + + + +
+
+
今日数据
+

账户看板

+
+
+
+
今日任务
+
/30
+
已开始任务数
+
+
+
今日收益
+
+
USDT
+
+
+
本月总收益
+
+
USDT
+
+
+
+ +
+
+
快捷任务
+

常用入口

+
+ +
+ +
+
+
热门任务
+

推荐任务

+
+ +
+ + +
+
+
最近动态
+

我的任务

+
+
+ + + +
你还没有任务记录。先进入“任务”页领取第一笔佣金。
+ +
+
+ +
+
+
邮箱入口
+

真实开户流程

+
+
+
+
+
4 步完成邮箱开户
+
现在已经拆成独立页面:启动页 → 邮箱注册 → 验证码 → 登录,避免跳过验证码直接建号。
+
+ 真实流程 +
+ +
+ +
+
STEP
+
+
+
+ +
+ + + +
验证码会发送到你填写的邮箱;如果当前 SMTP 还没配置,页面会真实提示发送失败,不会假装成功。
+
+
+ + diff --git a/login.php b/login.php new file mode 100644 index 0000000..4f48851 --- /dev/null +++ b/login.php @@ -0,0 +1,99 @@ +getMessage()); + redirect(login_page_url($redirectTarget, ['email' => $email])); + } +} + +render_layout_start('账号登录', '登录页,使用已验证邮箱与密码进入前台 5 个页面。', 'auth'); +?> +
+
+
Step 4 / 4
+

账号登录

+

这是开户链路的最后一步。登录成功后,你就能进入首页、任务、VIP、钱包、我的 5 个主页面。

+
+
+ +
+
+
+
+
+
输入邮箱和密码
+
如果你刚完成验证码验证,可以直接在这里登录。
+
+ 登录 +
+ +
+ + +
+ + +
+
+ + +
+ +
+
+ +
+
+
+
登录后你能做什么
+
这不是演示按钮,而是已经接入真实数据库的前台入口。
+
+ 进入系统 +
+ +
+
+ + 首页 + 查看真实账户余额、最近任务和快捷入口。 + +
+
+ + 任务 / VIP / 钱包 / 我的 + 这些页面都已经可以打开,任务和 VIP 也有真实数据库逻辑。 + +
+
+ + 以后还能继续扩展 + 比如充值、提现、转账、邀请、安全中心和客服系统。 + +
+
+ + +
+
+
+ diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..208d7ea --- /dev/null +++ b/logout.php @@ -0,0 +1,6 @@ +getMessage()); + redirect('operations.php'); + } +} + +$stats = get_operator_stats(); +$pendingOrders = get_operator_orders('pending_review', 20); +$recentOrders = get_recent_reviewed_orders(12); +render_layout_start('运营审核台', '任务待审核队列与资金联动。', 'ops'); +?> +
+
+
+
Ops board
+

任务审核台

+
+
这是第一版后台切片:用于验证审核动作、冻结佣金与到账流水联动。
+
+
+
+
今日新增用户
+
+
+
+
待审核订单
+
+
+
+
已通过订单
+
+
+
+
累计入账
+
+
+
+
+ +
+
+
Pending queue
+

待审核任务

+
+
+ + +
+
+
+
+
·
+
用户 · ID ·
+
+ +
+
+
+
提交时间
+
+
+
+
领取时间
+
+
+
+
任务佣金
+
+
+
+
用户 VIP
+
+
+
+
+
用户完成备注
+
+
+
+ + + +
+ + +
+
+ + + 用户订单视图 +
+
+
+
+ + +
+
+
暂无待审核订单
+

先用前台账号领取并提交一个任务,再回到这里执行审核。

+
+
+ +
+
+ +
+
+
History
+

最近已处理订单

+
+
+ +
+ +
+
+
# ·
+
·
+
+
+
+
+
+ +
+
+ +
+ +
+
暂无历史审核记录
+

处理第一笔任务后,这里会形成后台审核时间线。

+
+ +
+
+ diff --git a/order.php b/order.php new file mode 100644 index 0000000..34e7a9d --- /dev/null +++ b/order.php @@ -0,0 +1,149 @@ + +
+
404
+

订单不存在

+

请返回概览查看你的任务记录。

+ 返回概览 +
+ +
+
+
+
+
+
+
Order detail
+

订单 # ·

+
·
+
+ +
+ +
+
+
佣金
+
+
+
+
VIP 限制
+
VIP
+
+
+
倒计时
+
+
+
+ +
+
+
1
+
+
已领取
+
+
+
+
+
2
+
+
已提交审核
+
+
+
+
+
3
+
+
审核完成
+
+
+
+
+ + +
+
剩余倒计时
+
+
完成后回到任务详情页提交完成备注。
+
+ 返回任务详情提交 + +
+
佣金冻结中
+

该订单已提交审核, 已计入冻结余额,等待运营审批后转入可用余额。

+
+ +
+
佣金已入账
+

审核通过后,佣金已转入可用余额,可在钱包页查看流水。

+
+ +
+
任务未通过审核
+

冻结佣金已释放。你可以返回任务大厅领取其他任务继续测试流程。

+
+ + + +
+
完成备注
+
+
+ + + +
+
审核说明
+
+
+ +
+
+
+
+
Wallet effect
+

钱包联动结果

+
+
+
可提现余额
+
+
+
+
冻结余额
+
+
+
+
+
下一步
+

打开钱包查看资金流水,或进入运营台执行审核动作,体验“冻结 → 入账/释放”的完整链路。

+
+ +
+
+
+
+ diff --git a/profile.php b/profile.php new file mode 100644 index 0000000..02e0921 --- /dev/null +++ b/profile.php @@ -0,0 +1,120 @@ + 'example@email.com', + 'user_code' => '100086', + 'vip_level' => 3, +]; +$stats = $user + ? get_dashboard_stats((int) $user['id']) + : [ + 'available_balance' => 5560.25, + 'frozen_balance' => 120.00, + 'total_balance' => 5680.25, + 'today_earnings' => 1000.00, + 'month_earnings' => 18560.20, + 'today_tasks' => 30, + 'active_orders' => 3, + 'completed_orders' => 12, + ]; +$menuItems = [ + ['title' => '我的资产', 'desc' => '查看余额与资金流水', 'href' => 'wallet.php', 'icon' => 'wallet'], + ['title' => '我的任务', 'desc' => '查看任务大厅与订单', 'href' => 'task.php', 'icon' => 'tasks'], + ['title' => 'VIP 中心', 'desc' => '选择更高返佣等级', 'href' => 'vip.php', 'icon' => 'vip'], + ['title' => '邀请好友', 'desc' => '后续可扩展邀请返佣', 'href' => 'index.php', 'icon' => 'spark'], + ['title' => '安全中心', 'desc' => '当前账户与系统状态', 'href' => 'healthz.php', 'icon' => 'lock'], + ['title' => '联系客服', 'desc' => '下一步接入内置 IM', 'href' => '#support-panel', 'icon' => 'support'], +]; + +render_layout_start('我的', '个人中心页,展示账号信息、常用菜单入口与客服/后台预览。', 'profile'); +?> +
+
+
+
+
+
我的(个人中心)
+

+
· ID
+
+ +
+ +
当前是“我的”页面视觉预览。登录后会显示你的真实账户资料与任务状态。
+ +
+
+ +
+
+
+
总资产
+
+
USDT
+
+
+
进行中
+
+
任务
+
+
+
已完成
+
+
任务
+
+
+
+ +
+ +
+ +
+
+
+
客服 / 后台
+

下一步建议

+
+
你前面确认过要做“内置 IM 客服”。这一步我先把入口样式放好了;下一步我可以继续帮你把“联系客服 → 建会话 → 发消息 → 后台客服工作台”接成真实页面。
+ +
+
+ diff --git a/register.php b/register.php new file mode 100644 index 0000000..60489b1 --- /dev/null +++ b/register.php @@ -0,0 +1,112 @@ +getMessage()); + redirect(register_page_url($redirectTarget, ['email' => $email])); + } +} + +render_layout_start('邮箱注册', '邮箱注册页,提交邮箱与密码后发送 6 位验证码。', 'auth'); +?> +
+
+
Step 2 / 4
+

邮箱注册

+

这里不再直接创建账号。先填写邮箱和密码,系统会先发 6 位验证码,验证通过后才真正建号。

+
+
+ +
+
+
+
+
+
填写注册信息
+
验证码将发送到你的邮箱,账户创建会等到验证通过后再执行。
+
+ 注册 +
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+
注册后会发生什么
+
我把复杂流程翻成一句人话:先确认邮箱,再正式开户。
+
+ 说明 +
+ +
+
+ + 第 1 步:发送 6 位验证码 + 系统会用你填写的邮箱作为收件人,验证码有效期 10 分钟。 + +
+
+ + 第 2 步:验证通过后建号 + 数据库会真实创建用户、6 位数字 ID、钱包与 VIP0。 + +
+
+ + 第 3 步:回到登录页 + 邮箱验证通过后,再进入登录页使用密码登录。 + +
+
+ +
验证码依赖当前 SMTP 环境。如果还没有配置好发信参数,这里会真实提示失败,方便你知道问题在邮件配置而不是页面。
+ + +
+
+
+ diff --git a/start.php b/start.php new file mode 100644 index 0000000..bfc1f88 --- /dev/null +++ b/start.php @@ -0,0 +1,94 @@ + '01', 'title' => '先看启动页', 'desc' => '了解首页、任务、VIP、钱包、我的 5 个主入口怎么运作。'], + ['step' => '02', 'title' => '邮箱注册', 'desc' => '填写邮箱和密码,系统准备发送 6 位数字验证码。'], + ['step' => '03', 'title' => '输入验证码', 'desc' => '邮件验证通过后,系统才会正式创建账户、钱包和 VIP0。'], + ['step' => '04', 'title' => '登录系统', 'desc' => '使用已验证邮箱登录,再进入任务、VIP、钱包和我的。'], +]; +$highlights = [ + ['label' => '账户创建', 'value' => '真实数据库写入'], + ['label' => '验证方式', 'value' => '邮箱 6 位验证码'], + ['label' => '开户结果', 'value' => '6 位 ID + 钱包 + VIP0'], +]; + +render_layout_start('启动页', '启动页,展示真实邮箱开户注册流程入口与 4 步说明。', 'auth'); +?> +
+
+
Step 1 / 4
+

邮箱开户从这里开始

+

这一步相当于“欢迎页”。你先看清流程,再进入注册、验证码和登录页面,整个入口会比直接把表单塞进首页更清楚。

+ +
+ +
+
+
+
+ +
+ + +
+
+ +
+
+
+
开户流程
+

4 个页面都是真实页面

+
+
+ +
+
STEP
+
+
+
+ +
+
+
+ +
+
+
+
说明
+

对你来说,这一步做了什么

+
+
+
+ + 不再从首页直接建号 + 避免用户跳过验证码,减少“看起来是真的,但其实没验证邮箱”的问题。 + +
+
+ + 注册和登录拆成独立页面 + 这样更接近你给的参考图结构,也更像真实 App 的开户流程。 + +
+
+ + 验证码真的会发邮件 + 如果当前 SMTP 未配置,页面会真实提示失败,你就知道不是 UI 假动作。 + +
+
+
+
+ diff --git a/task.php b/task.php new file mode 100644 index 0000000..5acd9a4 --- /dev/null +++ b/task.php @@ -0,0 +1,282 @@ +getMessage()); + if ($taskSlug !== '') { + redirect('task.php?task=' . urlencode($taskSlug)); + } + redirect('task.php'); + } +} + +$task = $taskSlug !== '' ? task_by_slug($taskSlug) : null; +if ($taskSlug !== '' && !$task) { + http_response_code(404); + render_layout_start('任务不存在', '未找到指定任务。', 'tasks'); + ?> +
+
+
404
+

任务不存在

+

请返回任务大厅选择一个有效任务。

+ 返回任务大厅 +
+
+ +
+
+
任务大厅
+

选择你想完成的任务

+

视频、点赞、社媒、网站、应用任务都能从这里进入。

+ +
现在是游客预览模式:可以浏览 5 个页面,登录后才能正式领取任务。
+ +
+
+ +
+
+ $label): ?> + + +
+
+ +
+
+ + +
+
+ +
+
+
·
+
+
+
+
+
USDT
+
+
+ +
+ +
+
+ +
+
+
+ +
+
·
+

+

+
+
+ +
+
+
奖励金额
+
+ USDT
+
+
+
+
任务要求
+
VIP
+
+
+
倒计时
+
+
+
+
当前状态
+
+ + + + 可领取 + + 需升级 VIP + +
+
+
+
+
+
+ +
+
+
+
任务要求
+

操作步骤

+
+
+ $step): ?> +
+ +
+
步骤
+
+
+
+ +
+
+
+ +
+
+
+
开始任务
+

领取与提交

+
+ + +
你正在浏览视觉预览版。登录后才可以正式领取并提交任务。
+ 登录后开始任务 + +
当前账号为 ,该任务需要 VIP
+ 去开通 VIP + +
+ + + + +
+ +
+
倒计时剩余
+
+
时间到之后才可以提交,服务端也会再次验证。
+
+
+ + + + +
+ + +
+ +
+ +
该任务已提交审核或已处理完成,你可以查看订单详情了解最新状态。
+ 查看订单详情 + +
+
+ + +
+ +
+ + diff --git a/verify.php b/verify.php new file mode 100644 index 0000000..882ac2c --- /dev/null +++ b/verify.php @@ -0,0 +1,142 @@ + $email])); + } catch (Throwable $exception) { + flash('danger', $exception->getMessage()); + if ($email !== '') { + redirect(verify_page_url($email, $redirectTarget)); + } + redirect(register_page_url($redirectTarget)); + } +} + +$latestRequest = null; +if ($email !== '') { + try { + $latestRequest = latest_signup_verification($email, false); + } catch (Throwable $exception) { + $latestRequest = null; + } +} + +render_layout_start('邮箱验证码', '邮箱验证码页,输入 6 位数字后完成注册激活。', 'auth'); +?> +
+
+
Step 3 / 4
+

邮箱验证码

+ +

验证码已经发送到 。把邮件里的 6 位数字填进来,验证通过后系统才会真正创建账号。

+ +

这一页负责“确认你真的拥有这个邮箱”。如果你还没发验证码,请先返回注册页。

+ +
+
+ +
+
+
+
+
+
输入 6 位数字验证码
+
验证码错误次数过多或过期后,需要重新发送。
+
+ 验证 +
+ + +
还没有待验证的邮箱记录。请先回到注册页填写邮箱和密码。
+ + +
+ + + + +
+ + +
+ +
+ +
+ + + + + +
+ +
+ +
+
+
+
当前说明
+
为了让你更容易理解,我把“验证码页”翻译成了 3 句话。
+
+ 说明 +
+ +
+
+ + 为什么要这一步 + 它用来确认邮箱归属,避免随便填一个邮箱就直接生成账号。 + +
+
+ + 验证成功后会发生什么 + 系统会真实写入用户表、生成 6 位 ID、自动创建钱包并把账号标记为已验证。 + +
+
+ + 下一页是什么 + 验证通过后,会跳到登录页。你再输入密码,就能进入 5 个主页面。 + +
+
+ + +
最近一次验证码过期时间:
+ +
如果没收到邮件,请先检查垃圾箱,再点击“重新发送验证码”。
+ + + +
+
+
+ diff --git a/vip.php b/vip.php new file mode 100644 index 0000000..3944bdf --- /dev/null +++ b/vip.php @@ -0,0 +1,219 @@ +getMessage()); + redirect('vip.php' . ($targetLevel > 0 ? '?level=' . $targetLevel . '#vip-checkout' : '#vip-checkout')); + } +} + +$user = current_user(); +$previewMode = !$user; +$catalog = vip_catalog(); +$currentVip = $user ? (int) $user['vip_level'] : 3; +$defaultLevel = $currentVip > 0 ? $currentVip : 1; +$selectedLevel = (int) ($_GET['level'] ?? $defaultLevel); +if (!isset($catalog[$selectedLevel]) || $selectedLevel === 0) { + $selectedLevel = $defaultLevel; +} +$selectedVip = $catalog[$selectedLevel]; +$wallet = $user ? wallet_snapshot((int) $user['id']) : ['available_balance' => 5560.25, 'frozen_balance' => 120.00]; +$availableBalance = (float) ($wallet['available_balance'] ?? 0); +$selectedPrice = (float) $selectedVip['price']; +$balanceGap = max(0, $selectedPrice - $availableBalance); +$isCurrentOrLower = !$previewMode && $selectedLevel <= $currentVip; +$canPurchase = !$previewMode && !$isCurrentOrLower && $balanceGap <= 0; +$vipOrders = $user ? get_user_vip_orders((int) $user['id'], 8) : []; +$latestVipOrder = $vipOrders[0] ?? null; +$nextVip = (!$previewMode && isset($catalog[$currentVip + 1])) ? $catalog[$currentVip + 1] : null; + +render_layout_start('VIP', 'VIP 等级页,支持真实余额开通、写入数据库、升级等级与查看升级记录。', 'vip'); +?> +
+
+
VIP 等级
+

余额直接开通更高返佣等级

+

现在这个页面已经接上真实数据库:开通后会写入 VIP 订单记录、扣减钱包余额,并立即升级你的账号等级。

+ +
当前是视觉预览模式。登录后可以使用真实钱包余额开通 VIP,并在下方看到升级记录。
+ +
当前账号是 。如果余额充足,点一次“立即开通”就会直接完成升级。
+ +
+
+ +
+
+
+
+
当前等级
+
+
可用余额 USDT · 冻结余额 USDT
+
+ +
+ +
+
+
目标等级
+
+
+
+
开通金额
+
USDT
+
+
+
最近升级
+
+
+
+
+
+ +
+
+ $vip): ?> + +
+
+ +
+
+
+ + 当前等级 + (int) $level): ?> + 已解锁 + +
+
+
+ 单任务佣金 USDT + 每日 +
+
+
+
USDT
+ + 已选中 + + 选择 + +
+
+
+ +
+
+ +
+
+
+
开通支付
+

结算卡

+
+ +
+
+
USDT
+
单任务佣金 USDT · 每日
+
+ +
+
+
支付方式
+
钱包可用余额
+
+
+
当前可用
+
USDT
+
+
+
+
+
+
+ + +
要真正开通 VIP,需要先登录。登录后这个按钮会直接连到数据库:生成订单记录、扣款并升级你的等级。
+ + +
当前账号已经是 。无需重复开通,直接去做更高等级任务即可。
+ + 0): ?> +
当前余额还差 USDT,暂时不能直接开通。等你下一步把充值流程接上后,这里就能完整跑通。
+ + +
确认后会立刻执行 3 件事:1)写入 VIP 订单;2)从可用余额扣款;3)更新你的账号等级。
+
+ + + + + 先看资金流水 +
+ +
+
+ +
+
+
+
升级记录
+

最近 VIP 订单

+
+ + +
登录后这里会显示你的真实 VIP 订单时间线,例如“VIP0 → VIP1、扣款金额、开通时间”。
+ +
+ +
+ + + · 扣款 USDT + + + + 已升级 + - USDT + +
+ +
+ +
你还没有 VIP 升级记录。等余额充足后,第一次开通就会自动写入这里。
+ +
+
+ diff --git a/wallet.php b/wallet.php new file mode 100644 index 0000000..d272bca --- /dev/null +++ b/wallet.php @@ -0,0 +1,169 @@ + 5560.25, 'frozen_balance' => 120.00]; +$stats = $user + ? get_dashboard_stats((int) $user['id']) + : [ + 'available_balance' => 5560.25, + 'frozen_balance' => 120.00, + 'total_balance' => 5680.25, + 'today_earnings' => 1000.00, + 'month_earnings' => 18560.20, + 'today_tasks' => 30, + 'active_orders' => 3, + 'completed_orders' => 12, + ]; +$logs = $user ? get_wallet_logs((int) $user['id'], 8) : [ + ['entry_type' => 'task_credit', 'amount' => 1000.00, 'frozen_amount' => 0.00, 'note' => '任务佣金到账', 'created_at' => gmdate('Y-m-d H:i:s')], + ['entry_type' => 'task_reject', 'amount' => 0.00, 'frozen_amount' => -50.00, 'note' => '未通过审核,释放冻结金额', 'created_at' => gmdate('Y-m-d H:i:s', time() - 3600)], + ['entry_type' => 'task_freeze', 'amount' => 0.00, 'frozen_amount' => 100.00, 'note' => '任务提交后冻结佣金', 'created_at' => gmdate('Y-m-d H:i:s', time() - 7200)], +]; +$available = (float) $wallet['available_balance']; +$fee = $available > 0 ? min(1.00, $available) : 1.00; +$estimated = max(0, $available - $fee); +$quickActions = [ + ['label' => '充值', 'icon' => 'deposit', 'href' => '#deposit-panel'], + ['label' => '提现', 'icon' => 'withdraw', 'href' => '#withdraw-panel'], + ['label' => '转账', 'icon' => 'swap', 'href' => '#transfer-panel'], + ['label' => '记录', 'icon' => 'history', 'href' => '#history-panel'], +]; + +render_layout_start('钱包', '钱包页,展示可用余额、冻结余额、提现卡片与资金流水记录。', 'wallet'); +?> +
+
+
我的钱包
+

资金总览

+
+
可用余额(USDT)
+
+
总余额 · 冻结
+
+ + + + + + +
+
+ +
当前是参考图风格预览,登录后这里会显示你的真实资金流水。
+ +
+
+ +
+
+
+
可用余额
+
+
USDT
+
+
+
冻结余额
+
+
等待审核
+
+
+
累计入账
+
+
本月收益
+
+
+
+ +
+
+
+
充值
+

USDT 充值说明

+
+
+
+
网络
+
TRC20
+
+
+
到账方式
+
人工审核
+
+
+
处理时间
+
5 - 15 分钟
+
+
+
第一版先保留参考图中的充值入口样式,下一步我可以继续帮你接“充值 / 提现 → 客服工单 / 内置 IM”。
+
+
+ +
+
+
+
提现
+

提现预览

+
+
+
+ 提现地址 + USDT-TRC20 +
+
请输入 USDT 钱包地址
+
+ 提现金额 + 全部 +
+
0 ? $available : 1000, 2)) ?> USDT
+
+
手续费 USDT
+
实际到账 USDT
+
+ 联系客服提现 +
+
+
+ +
+
+
+
转账
+

账户间转账

+
+
这一块我先按照你发的设计图放了入口和卡片结构。下一步可以继续接“转账表单 + 审核记录 + 钱包扣减逻辑”。
+
+
+ +
+
+
+
资金流水
+

最近记录

+
+ +
+ + 0 ? 'is-positive' : ($delta < 0 ? 'is-negative' : ''); + ?> +
+ + + · + + USDT +
+ +
+ +
目前还没有资金流水。先完成一次任务提交与审核,就会在这里看到变化。
+ +
+
+