Files
sbnews/server/app/common/service/article/ArticleAiCommentService.php
T
2026-06-11 12:15:29 +08:00

286 lines
9.6 KiB
PHP
Raw 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
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);
}
}