286 lines
9.6 KiB
PHP
286 lines
9.6 KiB
PHP
<?php
|
||
|
||
namespace app\common\service\article;
|
||
|
||
use app\common\model\article\Article;
|
||
use app\common\model\article\ArticleAiCommentTask;
|
||
use app\common\model\article\ArticleComment;
|
||
use app\common\model\user\User;
|
||
use app\common\service\ai\AiService;
|
||
use think\facade\Config;
|
||
use think\facade\Db;
|
||
|
||
class ArticleAiCommentService
|
||
{
|
||
private const VIRTUAL_ACCOUNT_PREFIX = 'ai_comment_';
|
||
private const VIRTUAL_NICKNAME_PREFIX = 'AI评论员';
|
||
private const VIRTUAL_POOL_SIZE = 20;
|
||
|
||
public static function tick(int $seedLimit = 10, int $processLimit = 20): array
|
||
{
|
||
$virtualUsers = self::ensureVirtualUsers(self::VIRTUAL_POOL_SIZE);
|
||
$seeded = self::seedTasks($virtualUsers, $seedLimit);
|
||
$processed = self::processDueTasks($processLimit);
|
||
|
||
return [
|
||
'virtual_user_count' => count($virtualUsers),
|
||
'seeded' => $seeded,
|
||
'processed' => $processed,
|
||
];
|
||
}
|
||
|
||
protected static function seedTasks(array $virtualUsers, int $limit = 10): array
|
||
{
|
||
if (empty($virtualUsers)) {
|
||
return ['created' => 0, 'skipped' => 0];
|
||
}
|
||
|
||
$articles = Article::field('id,title,desc,content,author,ext,click_virtual,click_actual,comment_count,create_time,published_at')
|
||
->where('is_show', 1)
|
||
->whereNull('delete_time')
|
||
->where('create_time', '>=', strtotime('-14 days'))
|
||
->orderRaw('comment_count asc, (click_actual + click_virtual) desc, id desc')
|
||
->limit($limit)
|
||
->select()
|
||
->toArray();
|
||
|
||
$created = 0;
|
||
$skipped = 0;
|
||
foreach ($articles as $index => $article) {
|
||
$articleId = (int) ($article['id'] ?? 0);
|
||
if ($articleId <= 0) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$existingPending = ArticleAiCommentTask::where('article_id', $articleId)
|
||
->whereIn('status', [0, 1])
|
||
->count();
|
||
if ($existingPending > 0) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$desiredCount = self::resolveTaskCount($article);
|
||
if ($desiredCount <= 0) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
for ($i = 0; $i < $desiredCount; $i++) {
|
||
$virtualUserId = $virtualUsers[($articleId + $index + $i) % count($virtualUsers)];
|
||
$comment = self::generateCommentContent($article, $virtualUserId);
|
||
if ($comment === '') {
|
||
continue;
|
||
}
|
||
|
||
$runAt = time() + random_int(600, 43200);
|
||
ArticleAiCommentTask::create([
|
||
'article_id' => $articleId,
|
||
'virtual_user_id' => $virtualUserId,
|
||
'comment_content' => $comment,
|
||
'run_at' => $runAt,
|
||
'status' => 0,
|
||
'create_time' => time(),
|
||
'update_time' => time(),
|
||
]);
|
||
$created++;
|
||
}
|
||
}
|
||
|
||
return compact('created', 'skipped');
|
||
}
|
||
|
||
protected static function processDueTasks(int $limit = 20): array
|
||
{
|
||
$tasks = ArticleAiCommentTask::where('status', 0)
|
||
->where('run_at', '<=', time())
|
||
->order('run_at', 'asc')
|
||
->limit($limit)
|
||
->select()
|
||
->toArray();
|
||
|
||
$done = 0;
|
||
$failed = 0;
|
||
|
||
foreach ($tasks as $task) {
|
||
$articleId = (int) ($task['article_id'] ?? 0);
|
||
$content = trim((string) ($task['comment_content'] ?? ''));
|
||
$virtualUserId = (int) ($task['virtual_user_id'] ?? 0);
|
||
|
||
if ($articleId <= 0 || $content === '' || $virtualUserId <= 0) {
|
||
self::markTaskFailed((int) $task['id'], '任务数据不完整');
|
||
$failed++;
|
||
continue;
|
||
}
|
||
|
||
$article = Article::where('id', $articleId)
|
||
->where('is_show', 1)
|
||
->whereNull('delete_time')
|
||
->findOrEmpty();
|
||
|
||
if ($article->isEmpty()) {
|
||
self::markTaskFailed((int) $task['id'], '文章不存在或已下架');
|
||
$failed++;
|
||
continue;
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
ArticleComment::create([
|
||
'article_id' => $articleId,
|
||
'user_id' => $virtualUserId,
|
||
'parent_id' => 0,
|
||
'reply_user_id' => 0,
|
||
'content' => $content,
|
||
'is_show' => 1,
|
||
'create_time' => time(),
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
Article::where('id', $articleId)->inc('comment_count')->update();
|
||
ArticleAiCommentTask::where('id', $task['id'])->update([
|
||
'status' => 1,
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
Db::commit();
|
||
$done++;
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
self::markTaskFailed((int) $task['id'], $e->getMessage());
|
||
$failed++;
|
||
}
|
||
}
|
||
|
||
return compact('done', 'failed');
|
||
}
|
||
|
||
protected static function ensureVirtualUsers(int $targetCount = self::VIRTUAL_POOL_SIZE): array
|
||
{
|
||
$existing = User::where('account', 'like', self::VIRTUAL_ACCOUNT_PREFIX . '%')
|
||
->order('id', 'asc')
|
||
->column('id');
|
||
|
||
$existing = array_values(array_map('intval', $existing));
|
||
$missing = max(0, $targetCount - count($existing));
|
||
if ($missing > 0) {
|
||
$avatar = (string) Config::get('project.default_image.user_avatar', '');
|
||
$passwordSalt = (string) Config::get('project.unique_identification', 'sport-era');
|
||
|
||
for ($i = 1; $i <= $missing; $i++) {
|
||
$accountIndex = count($existing) + $i;
|
||
$sn = User::createUserSn('9');
|
||
$password = create_password(bin2hex(random_bytes(6)), $passwordSalt);
|
||
|
||
$user = User::create([
|
||
'sn' => $sn,
|
||
'avatar' => $avatar,
|
||
'real_name' => '',
|
||
'nickname' => self::VIRTUAL_NICKNAME_PREFIX . str_pad((string) $accountIndex, 2, '0', STR_PAD_LEFT),
|
||
'account' => self::VIRTUAL_ACCOUNT_PREFIX . str_pad((string) $accountIndex, 4, '0', STR_PAD_LEFT),
|
||
'password' => $password,
|
||
'mobile' => '',
|
||
'sex' => 0,
|
||
'channel' => 0,
|
||
'is_disable' => 0,
|
||
'is_new_user' => 0,
|
||
'user_money' => 0,
|
||
'total_recharge_amount' => 0,
|
||
]);
|
||
|
||
$existing[] = (int) $user->id;
|
||
}
|
||
}
|
||
|
||
return $existing;
|
||
}
|
||
|
||
protected static function resolveTaskCount(array $article): int
|
||
{
|
||
$commentCount = (int) ($article['comment_count'] ?? 0);
|
||
$clickCount = (int) ($article['click_actual'] ?? 0) + (int) ($article['click_virtual'] ?? 0);
|
||
|
||
if ($commentCount >= 20) {
|
||
return 0;
|
||
}
|
||
|
||
if ($clickCount >= 500) {
|
||
return 2;
|
||
}
|
||
|
||
return 1;
|
||
}
|
||
|
||
protected static function generateCommentContent(array $article, int $virtualUserId): string
|
||
{
|
||
$title = trim((string) ($article['title'] ?? ''));
|
||
$abstract = trim((string) ($article['desc'] ?? ''));
|
||
$content = self::normalizeContent((string) ($article['content'] ?? ''));
|
||
if ($title === '' && $abstract === '' && $content === '') {
|
||
return '';
|
||
}
|
||
|
||
$personaPool = [
|
||
'资深球迷',
|
||
'战术观察员',
|
||
'数据控',
|
||
'赛事评论员',
|
||
'球迷视角',
|
||
];
|
||
$persona = $personaPool[$virtualUserId % count($personaPool)];
|
||
|
||
$result = AiService::generateArticleComment($title, $abstract, $content, $persona);
|
||
if (empty($result['success'])) {
|
||
return self::fallbackComment($title, $abstract, $content);
|
||
}
|
||
|
||
$comment = trim((string) ($result['content'] ?? ''));
|
||
$comment = preg_replace('/^评论[::]\s*/u', '', $comment);
|
||
$comment = preg_replace('/^[“"‘’\'\']|[”"‘’\'\']$/u', '', $comment);
|
||
$comment = preg_replace('/\s+/u', ' ', $comment);
|
||
$comment = trim($comment, " \t\n\r\0\x0B,。,.!!??;;::");
|
||
|
||
if ($comment === '' || mb_strlen($comment) < 8) {
|
||
return self::fallbackComment($title, $abstract, $content);
|
||
}
|
||
|
||
return mb_substr($comment, 0, 120);
|
||
}
|
||
|
||
protected static function fallbackComment(string $title, string $abstract, string $content): string
|
||
{
|
||
$focus = $title ?: ($abstract ?: mb_substr($content, 0, 18));
|
||
if ($focus === '') {
|
||
return '';
|
||
}
|
||
|
||
return mb_substr($focus, 0, 18) . '这条信息量挺足,后续关键数据变化值得继续关注。';
|
||
}
|
||
|
||
protected static function markTaskFailed(int $taskId, string $message): void
|
||
{
|
||
if ($taskId <= 0) {
|
||
return;
|
||
}
|
||
|
||
ArticleAiCommentTask::where('id', $taskId)->update([
|
||
'status' => 2,
|
||
'error_msg' => mb_substr($message, 0, 255),
|
||
'update_time' => time(),
|
||
]);
|
||
}
|
||
|
||
protected static function normalizeContent(string $content): string
|
||
{
|
||
if ($content === '') {
|
||
return '';
|
||
}
|
||
|
||
$content = strip_tags($content);
|
||
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
$content = preg_replace('/\s+/u', ' ', $content);
|
||
return trim($content);
|
||
}
|
||
}
|