feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

View File

@@ -0,0 +1,206 @@
<?php
namespace app\common\service;
use app\model\AiReport as AiReportModel;
use think\facade\Db;
use think\facade\Log;
/**
* AI 深度画像报告服务
*
* 生命周期:
* pending创建→ paid支付成功 / devmarkPaidDev
* → generating后台开始调 AI → done生成完成
* 失败时置 failed + 自动重试retryCount<=3
*/
class AiReportService
{
const PRICE_FEN = 990; // 9.9 元
public static function createOrGetPending(int $userId, int $conversationId = 0, string $mbtiType = ''): array
{
// 已有 pending/paid/generating/done → 返回最新那条
$existing = AiReportModel::where('userId', $userId)
->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,
];
}
}