no message
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\article;
|
||||
|
||||
use think\Response;
|
||||
|
||||
class ArticleImageProxyService
|
||||
{
|
||||
private const SOURCE_HOSTS = [
|
||||
'hkjc_racingnews' => [
|
||||
'res.hkjc.com',
|
||||
'racingnews.hkjc.com',
|
||||
],
|
||||
'taiwan_lottery' => [
|
||||
'cdn.taiwanlottery.com.tw',
|
||||
'www.taiwanlottery.com',
|
||||
'taiwanlottery.com',
|
||||
],
|
||||
];
|
||||
|
||||
private const SOURCE_REFERERS = [
|
||||
'hkjc_racingnews' => 'https://racingnews.hkjc.com/',
|
||||
'taiwan_lottery' => 'https://www.taiwanlottery.com/',
|
||||
];
|
||||
|
||||
public static function rewriteArticlePayload(array $article): array
|
||||
{
|
||||
$ext = self::decodeExt($article['ext'] ?? []);
|
||||
if (!self::shouldRewrite($ext)) {
|
||||
return $article;
|
||||
}
|
||||
|
||||
if (!empty($article['image'])) {
|
||||
$article['image'] = self::buildProxyUrl((string) $article['image'], $ext);
|
||||
}
|
||||
|
||||
if (!empty($article['content'])) {
|
||||
$article['content'] = preg_replace_callback(
|
||||
'/(<img\b[^>]*\bsrc=["\'])([^"\']+)(["\'][^>]*>)/i',
|
||||
static function (array $matches) use ($ext) {
|
||||
$proxyUrl = self::buildProxyUrl($matches[2] ?? '', $ext);
|
||||
if ($proxyUrl === '' || $proxyUrl === ($matches[2] ?? '')) {
|
||||
return $matches[0];
|
||||
}
|
||||
return $matches[1]
|
||||
. htmlspecialchars($proxyUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. $matches[3];
|
||||
},
|
||||
(string) $article['content']
|
||||
);
|
||||
}
|
||||
|
||||
return $article;
|
||||
}
|
||||
|
||||
public static function proxyRemoteImage(string $url): Response
|
||||
{
|
||||
$normalizedUrl = self::normalizeUrl($url);
|
||||
if ($normalizedUrl === '' || !self::isAllowedRemoteUrl($normalizedUrl)) {
|
||||
return response('image not found', 404)->header([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=300',
|
||||
]);
|
||||
}
|
||||
|
||||
$source = self::detectSourceByUrl($normalizedUrl);
|
||||
$headers = [
|
||||
'Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'User-Agent: Mozilla/5.0 (compatible; SportEra/1.0)',
|
||||
];
|
||||
$referer = self::SOURCE_REFERERS[$source] ?? '';
|
||||
if ($referer !== '') {
|
||||
$headers[] = 'Referer: ' . $referer;
|
||||
}
|
||||
|
||||
$ch = curl_init($normalizedUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $curlError !== '' || $httpCode < 200 || $httpCode >= 300) {
|
||||
return response('image not found', 404)->header([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=300',
|
||||
]);
|
||||
}
|
||||
|
||||
$effectiveUrl = $effectiveUrl !== '' ? $effectiveUrl : $normalizedUrl;
|
||||
if (!self::isAllowedRemoteUrl($effectiveUrl)) {
|
||||
return response('image not found', 404)->header([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=300',
|
||||
]);
|
||||
}
|
||||
|
||||
$contentType = self::normalizeContentType($contentType, $effectiveUrl);
|
||||
if (!str_starts_with($contentType, 'image/')) {
|
||||
return response('image not found', 404)->header([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=300',
|
||||
]);
|
||||
}
|
||||
|
||||
return response($body, 200)->header([
|
||||
'Content-Type' => $contentType,
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
|
||||
private static function shouldRewrite(array $ext): bool
|
||||
{
|
||||
$source = (string) ($ext['source'] ?? '');
|
||||
return isset(self::SOURCE_HOSTS[$source]);
|
||||
}
|
||||
|
||||
private static function buildProxyUrl(string $url, array $ext = []): string
|
||||
{
|
||||
$normalizedUrl = self::normalizeUrl($url);
|
||||
if ($normalizedUrl === '' || self::isLocalProxyUrl($normalizedUrl)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
if (!self::isAllowedRemoteUrl($normalizedUrl, $ext)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$path = '/api/article/imageProxy?url=' . rawurlencode($normalizedUrl);
|
||||
$domain = rtrim((string) request()->domain(), '/');
|
||||
return $domain !== '' ? $domain . $path : $path;
|
||||
}
|
||||
|
||||
private static function normalizeUrl(string $url): string
|
||||
{
|
||||
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (str_starts_with($url, '//')) {
|
||||
return 'https:' . $url;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
private static function isAllowedRemoteUrl(string $url, array $ext = []): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
if ($host === '' || !in_array($scheme, ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedHosts = self::getAllowedHosts($ext);
|
||||
foreach ($allowedHosts as $allowedHost) {
|
||||
$allowedHost = strtolower($allowedHost);
|
||||
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function getAllowedHosts(array $ext = []): array
|
||||
{
|
||||
$source = (string) ($ext['source'] ?? '');
|
||||
if ($source !== '' && isset(self::SOURCE_HOSTS[$source])) {
|
||||
return self::SOURCE_HOSTS[$source];
|
||||
}
|
||||
|
||||
$allHosts = [];
|
||||
foreach (self::SOURCE_HOSTS as $hosts) {
|
||||
$allHosts = array_merge($allHosts, $hosts);
|
||||
}
|
||||
return array_values(array_unique($allHosts));
|
||||
}
|
||||
|
||||
private static function detectSourceByUrl(string $url): string
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
foreach (self::SOURCE_HOSTS as $source => $hosts) {
|
||||
foreach ($hosts as $allowedHost) {
|
||||
$allowedHost = strtolower($allowedHost);
|
||||
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function isLocalProxyUrl(string $url): bool
|
||||
{
|
||||
return str_contains($url, '/api/article/imageProxy?');
|
||||
}
|
||||
|
||||
private static function normalizeContentType(string $contentType, string $url): string
|
||||
{
|
||||
$contentType = strtolower(trim(explode(';', $contentType)[0] ?? ''));
|
||||
if (str_starts_with($contentType, 'image/')) {
|
||||
return $contentType;
|
||||
}
|
||||
|
||||
$path = strtolower((string) parse_url($url, PHP_URL_PATH));
|
||||
return match (pathinfo($path, PATHINFO_EXTENSION)) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
private static function decodeExt($ext): array
|
||||
{
|
||||
if (is_array($ext)) {
|
||||
return $ext;
|
||||
}
|
||||
if (is_string($ext) && $ext !== '') {
|
||||
$decoded = json_decode($ext, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user