order('id', 'desc') ->find(); if ($existing && in_array($existing->status, ['pending', 'paid', 'generating', 'done'])) { // 已完成的报告允许用户再做一份?按默认:只有 done 才允许再发起新的 if ($existing->status !== 'done') { return self::toArray($existing); } } $orderSn = 'AIR' . date('YmdHis') . mt_rand(1000, 9999); $model = AiReportModel::create([ 'userId' => $userId, 'conversationId' => $conversationId ?: null, 'mbtiType' => $mbtiType ?: null, 'orderSn' => $orderSn, 'priceFen' => self::PRICE_FEN, 'status' => 'pending', ]); return self::toArray($model); } public static function myLatest(int $userId): ?array { $m = AiReportModel::where('userId', $userId)->order('id', 'desc')->find(); return $m ? self::toArray($m) : null; } public static function get(int $id, int $userId): ?array { $m = AiReportModel::where('id', $id)->where('userId', $userId)->find(); return $m ? self::toArray($m) : null; } /** * 支付成功回调:把 report 置为 paid,并触发异步生成 * 同时执行订单分账(走 ProfitSharingService) */ public static function markPaid(string $orderSn): array { $m = AiReportModel::where('orderSn', $orderSn)->find(); if (!$m) return ['status' => 'not-found']; if ($m->status !== 'pending') { // 已处理过 return ['status' => 'already', 'reportStatus' => $m->status]; } $m->status = 'paid'; $m->paidAt = time(); $m->save(); // 分账(idempotent) try { ProfitSharingService::executeSharing( $m->orderSn, 'ai_deep_report', (int) $m->priceFen, ['userId' => (int) $m->userId, 'orderId' => (int) $m->id] ); } catch (\Throwable $e) { Log::warning('AiReportService profit-sharing failed: ' . $e->getMessage()); } // 同步生成报告(若耗时 >30s 可改异步队列,此处 PHP fpm 环境先同步,失败自动降级) self::generate((int) $m->id); $fresh = AiReportModel::find($m->id); return ['status' => 'ok', 'report' => self::toArray($fresh)]; } /** 调试/内部:跳过支付直接 markPaid */ public static function markPaidDev(string $orderSn): array { return self::markPaid($orderSn); } /** * 生成报告正文(调 AI) */ public static function generate(int $reportId): void { $m = AiReportModel::find($reportId); if (!$m) return; if (!in_array($m->status, ['paid', 'failed', 'generating'])) return; $m->status = 'generating'; $m->retryCount = (int) $m->retryCount + 1; $m->save(); try { $user = Db::name('user')->where('id', (int) $m->userId)->find(); $mbti = $m->mbtiType ?: ($user['mbti_type'] ?? '未知'); $nickname = $user['nickname'] ?? '朋友'; // 拉取最近对话作为输入素材 $history = []; if (!empty($m->conversationId)) { $msgs = Db::name('ai_messages') ->where('conversationId', (int) $m->conversationId) ->order('id', 'asc') ->limit(40) ->select() ->toArray(); foreach ($msgs as $msg) { if ($msg['role'] === 'system') continue; $history[] = ($msg['role'] === 'user' ? '【你】' : '【神仙AI】') . ":" . $msg['content']; } } $system = "你是「神仙 AI」,要基于用户的 MBTI 类型与最近的对话,输出一份具个性化的深度画像报告。\n" . "报告要求:\n" . "1) 长度 1200-1800 字,分 5 个章节(性格内核 / 优势雷达 / 潜在盲点 / 亲密关系建议 / 下一步成长路径)\n" . "2) 每章 2-3 段,语气温暖不说教;禁止玄学与迷信\n" . "3) 首段总结一句,用一条「超短金句」概括这个人\n" . "4) 使用第二人称「你」,称呼:{$nickname}\n" . "5) Markdown 纯文本,章节用 「## 章节标题」 " . ""; $prompt = "【用户 MBTI】{$mbti}\n【最近对话】\n" . (empty($history) ? '(无历史对话)' : implode("\n", $history)); $r = AiCallService::chat([ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt], ], ['maxTokens' => 3000, 'temperature' => 0.75]); $content = (string) ($r['content'] ?? ''); if ($content === '' || !empty($r['isDegraded'])) { throw new \RuntimeException('AI 生成失败或降级:' . (string) ($r['content'] ?? '')); } $summary = self::extractSummary($content); $title = self::extractTitle($content, $mbti); $m->title = $title; $m->summary = $summary; $m->content = $content; $m->status = 'done'; $m->generatedAt = time(); $m->lastError = null; $m->save(); } catch (\Throwable $e) { Log::warning('AiReportService generate fail: ' . $e->getMessage()); $m->status = $m->retryCount >= 3 ? 'failed' : 'paid'; $m->lastError = mb_substr($e->getMessage(), 0, 480); $m->save(); } } private static function extractSummary(string $md): string { $plain = preg_replace('/[#>*_`\[\]]+/', '', $md); $plain = trim((string) $plain); return mb_substr($plain, 0, 180); } private static function extractTitle(string $md, string $mbti): string { if (preg_match('/^\s*#+\s*(.+)$/m', $md, $mch)) { return mb_substr(trim($mch[1]), 0, 80); } return "{$mbti} · 神仙 AI 深度画像"; } private static function toArray(AiReportModel $m): array { return [ 'id' => (int) $m->id, 'userId' => (int) $m->userId, 'conversationId' => (int) $m->conversationId, 'mbtiType' => $m->mbtiType, 'orderSn' => $m->orderSn, 'priceFen' => (int) $m->priceFen, 'priceYuan' => round($m->priceFen / 100, 2), 'status' => $m->status, 'title' => $m->title, 'summary' => $m->summary, 'content' => $m->content, 'posterUrl' => $m->posterUrl, 'paidAt' => (int) $m->paidAt, 'generatedAt' => (int) $m->generatedAt, 'createdAt' => (int) $m->createdAt, ]; } }