0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => '关键词不能为空']; } $cfg = self::resolveConfig(); if ($cfg['baseUrl'] === '') { return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置']; } $base = rtrim($cfg['baseUrl'], '/'); $headers = ['Accept: application/json']; if ($cfg['token'] !== '') { $headers[] = 'Authorization: Bearer ' . $cfg['token']; } // 优先尝试通用文章接口(若支持 keyword) $url = $base . '/api/articles?' . http_build_query([ 'keyword' => $keyword, 'limit' => $limit, 'sort' => 'publishedAt:desc', ]); [$response] = self::httpGet($url, $headers); $data = is_string($response) ? json_decode($response, true) : null; $list = []; if (is_array($data)) { if (isset($data['data']['list']) && is_array($data['data']['list'])) { $list = $data['data']['list']; } elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) { $list = $data['data']; } elseif (isset($data['items']) && is_array($data['items'])) { $list = $data['items']; } elseif (isset($data[0])) { $list = $data; } } // 若无结果,回退到 book API 全量抓取后关键词过滤 if (empty($list)) { $list = self::fetchFromSoulBookApi($base, max($limit * 3, 30), $tag, $keyword); } return self::upsertArticles($list, $tag, $limit); } /** * 从 Soul 后台拉取最新 N 篇 MBTI 主题文章并写入本地 soul_articles 表 * * @return array ['fetched'=>int, 'created'=>int, 'updated'=>int, 'items'=>[…]] */ public static function syncLatest(int $limit = 10, string $tag = 'MBTI'): array { $cfg = self::resolveConfig(); if ($cfg['baseUrl'] === '') { return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置']; } $base = rtrim($cfg['baseUrl'], '/'); $url = $base . '/api/articles'; $params = [ 'tag' => $tag, 'limit' => $limit, 'sort' => 'publishedAt:desc', ]; $url .= '?' . http_build_query($params); $headers = ['Accept: application/json']; if ($cfg['token'] !== '') { $headers[] = 'Authorization: Bearer ' . $cfg['token']; } [$response, $httpCode, $err] = self::httpGet($url, $headers); if ($response === false || $response === '') { Log::warning("SoulArticleService sync failed: http={$httpCode} err={$err}"); } $data = json_decode($response, true); if (!is_array($data)) { $data = []; } // 兼容常见返回结构:{data:{list:[]}} / {data:[]} / {items:[]} $list = []; if (isset($data['data']['list']) && is_array($data['data']['list'])) { $list = $data['data']['list']; } elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) { $list = $data['data']; } elseif (isset($data['items']) && is_array($data['items'])) { $list = $data['items']; } elseif (isset($data[0])) { $list = $data; } // 兼容「一场 soul 创业实验」新接口:/api/miniprogram/book/* if (empty($list)) { $list = self::fetchFromSoulBookApi($base, $limit, $tag); } return self::upsertArticles($list, $tag, $limit); } /** * 自动推送(傻瓜模式):检查 last_sync_at,超过 intervalSec 则自动执行一次采集 * - 非阻塞:调用方可忽略返回值 * - 配置:system_config.key='soul_article_auto_sync' {enabled, intervalSec, limit} * @return array ['didSync'=>bool, ...syncResult] */ public static function autoSyncIfStale(): array { try { $cfgRow = SystemConfigModel::where('key', 'soul_article_auto_sync')->find(); $enabled = true; $intervalSec = 3600; $limit = 10; if ($cfgRow && !empty($cfgRow->value)) { $v = $cfgRow->value; if (is_string($v)) { $d = json_decode($v, true); if (is_array($d)) $v = $d; } if (is_array($v)) { $enabled = !isset($v['enabled']) ? true : (bool) $v['enabled']; $intervalSec = (int) ($v['intervalSec'] ?? 3600); $limit = (int) ($v['limit'] ?? 10); } } if (!$enabled) return ['didSync' => false, 'reason' => 'disabled']; $lastRow = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find(); $last = 0; if ($lastRow && !empty($lastRow->value)) { $last = is_numeric($lastRow->value) ? (int) $lastRow->value : (int) strtotime($lastRow->value); } $now = time(); if ($last > 0 && ($now - $last) < $intervalSec) { return ['didSync' => false, 'lastAt' => $last, 'reason' => 'fresh']; } $r = self::syncLatest($limit, 'MBTI'); // 写回 last sync(即使采集 0 篇也写回,避免高频重试) $val = (string) $now; $exist = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find(); if ($exist) { $exist->value = $val; $exist->save(); } else { SystemConfigModel::create([ 'key' => 'soul_article_last_sync_at', 'value' => $val, 'description' => 'Soul 文章最近一次自动采集时间戳', ]); } return array_merge(['didSync' => true, 'lastAt' => $now], $r); } catch (\Throwable $e) { Log::warning('SoulArticleService autoSync failed: ' . $e->getMessage()); return ['didSync' => false, 'error' => $e->getMessage()]; } } /** * 返回当前已推荐(最多 3 篇) */ public static function getRecommended(int $limit = 3): array { // 仅返回后台「推荐位」文章,不用候选池补齐(与超管「当前推荐」严格一致) $rows = SoulArticleModel::where('isRecommended', 1) ->order('recommendedOrder', 'asc') ->order('publishedAt', 'desc') ->limit($limit) ->select() ->toArray(); return array_map(function ($r) { $pub = (int) ($r['publishedAt'] ?? 0); $upd = (int) ($r['updatedAt'] ?? 0); // 避免 0 / 无效时间戳被格式化成 1970-01-01 $dateStr = ''; if ($pub > 946684800) { $dateStr = date('Y-m-d', $pub); } elseif ($upd > 946684800) { $dateStr = date('Y-m-d', $upd); } return [ 'id' => $r['id'], 'sourceId' => $r['sourceId'], 'title' => $r['title'], 'cover' => $r['cover'], 'url' => $r['url'], 'summary' => $r['summary'], 'author' => $r['author'], 'tag' => $r['tag'], 'publishedAt' => $dateStr, ]; }, $rows); } /** * 把某篇文章设为推荐(至多 3 篇;超出时自动顶掉最老的一篇) */ public static function recommend(int $articleId): array { $article = SoulArticleModel::find($articleId); if (!$article) { return ['ok' => false, 'message' => '文章不存在']; } if ($article->isRecommended) { $article->isRecommended = 0; $article->save(); return ['ok' => true, 'message' => '已取消推荐', 'isRecommended' => false]; } $current = SoulArticleModel::where('isRecommended', 1)->order('recommendedOrder', 'asc')->select()->toArray(); if (count($current) >= 3) { // 顶掉最老的一篇 $oldest = end($current); $old = SoulArticleModel::find($oldest['id']); if ($old) { $old->isRecommended = 0; $old->save(); } } $maxOrder = (int) SoulArticleModel::where('isRecommended', 1)->max('recommendedOrder'); $article->isRecommended = 1; $article->recommendedOrder = $maxOrder + 1; $article->save(); return ['ok' => true, 'message' => '已设为推荐', 'isRecommended' => true]; } /** * soul_api 配置:system_config.key='soul_api' / env 兜底 */ private static function resolveConfig(): array { $baseUrl = trim((string) getenv('SOUL_API_BASE')); $token = trim((string) getenv('SOUL_API_TOKEN')); $row = SystemConfigModel::where('key', 'soul_api')->find(); if ($row && !empty($row->value)) { $v = $row->value; if (is_string($v)) { $decoded = json_decode($v, true); if (is_array($decoded)) $v = $decoded; } if (is_array($v)) { if (!empty($v['baseUrl'])) $baseUrl = (string) $v['baseUrl']; if (!empty($v['token'])) $token = (string) $v['token']; } } if ($baseUrl === '') { $baseUrl = 'https://soulapi.quwanzhi.com'; } return ['baseUrl' => $baseUrl, 'token' => $token]; } /** * 从 Soul 小程序图书接口拉取最新章节,映射成文章池结构(用于 /api/articles 不可用时兜底) */ private static function fetchFromSoulBookApi(string $baseUrl, int $limit, string $tag, string $keyword = ''): array { $partsUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/parts'; [$rawParts] = self::httpGet($partsUrl, ['Accept: application/json']); $partsData = is_string($rawParts) ? json_decode($rawParts, true) : null; if (!is_array($partsData)) return []; $parts = $partsData['parts'] ?? $partsData['data'] ?? []; if (!is_array($parts) || empty($parts)) return []; $chapters = []; foreach ($parts as $p) { $partId = (string) ($p['id'] ?? ''); if ($partId === '') continue; $chaptersUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/chapters-by-part?partId=' . rawurlencode($partId); [$rawRows] = self::httpGet($chaptersUrl, ['Accept: application/json']); $rowsData = is_string($rawRows) ? json_decode($rawRows, true) : null; if (!is_array($rowsData)) continue; $rows = $rowsData['data'] ?? $rowsData['list'] ?? []; if (!is_array($rows)) continue; foreach ($rows as $row) { $id = (string) ($row['id'] ?? ''); $title = trim((string) ($row['sectionTitle'] ?? $row['title'] ?? '')); if ($id === '' || $title === '') continue; // 只抓 MBTI 相关,减少噪音;标题无 MBTI 时保留“第x场”高频经营内容 $lc = mb_strtolower($title, 'UTF-8'); if (mb_strpos($lc, 'mbti') === false && mb_strpos($title, '第') === false) { continue; } if ($keyword !== '') { $kw = mb_strtolower($keyword, 'UTF-8'); $summary = trim((string) ($row['summary'] ?? '')); $haystack = mb_strtolower($title . ' ' . $summary, 'UTF-8'); if (mb_strpos($haystack, $kw) === false) { continue; } } $publishedAt = 0; $pubRaw = $row['updatedAt'] ?? ($row['createdAt'] ?? 0); if (is_numeric($pubRaw)) { $publishedAt = (int) $pubRaw; } elseif (is_string($pubRaw) && trim($pubRaw) !== '') { $t = strtotime((string) $pubRaw); if ($t) $publishedAt = (int) $t; } // 异常时间戳(如 0 / 1970)兜底当前时间 if ($publishedAt < 946684800) { $publishedAt = time(); } $cover = (string) ($row['coverUrl'] ?? $row['cover'] ?? ''); $summary = trim((string) ($row['summary'] ?? '')); $chapters[] = [ 'id' => $id, 'title' => $title, 'url' => rtrim($baseUrl, '/') . '/read/' . rawurlencode($id), 'cover' => $cover, 'summary' => $summary, 'author' => '一场 soul 创业实验', 'tag' => $tag, 'publishedAt' => $publishedAt ?: time(), ]; } } usort($chapters, function ($a, $b) { return (int)($b['publishedAt'] ?? 0) <=> (int)($a['publishedAt'] ?? 0); }); if (count($chapters) > $limit) { $chapters = array_slice($chapters, 0, $limit); } return $chapters; } /** * 统一写入文章池(幂等按 sourceId) */ private static function upsertArticles(array $list, string $tag, int $limit): array { $created = 0; $updated = 0; $items = []; $now = time(); if (!empty($list) && count($list) > $limit) { $list = array_slice($list, 0, $limit); } foreach ($list as $raw) { $sourceId = (string) ($raw['id'] ?? $raw['sourceId'] ?? $raw['articleId'] ?? ''); $title = trim((string) ($raw['title'] ?? '')); $url2 = trim((string) ($raw['url'] ?? $raw['link'] ?? $raw['shareUrl'] ?? '')); if ($sourceId === '' || $title === '' || $url2 === '') { continue; } $cover = trim((string) ($raw['cover'] ?? $raw['coverUrl'] ?? $raw['thumbnail'] ?? '')); $summary = trim((string) ($raw['summary'] ?? $raw['excerpt'] ?? $raw['description'] ?? '')); $author = trim((string) ($raw['author'] ?? ($raw['authorName'] ?? ''))); $publishedAt = 0; $pub = $raw['publishedAt'] ?? ($raw['createdAt'] ?? null); if (is_numeric($pub)) { $publishedAt = (int) $pub > 2000000000 ? (int)($pub / 1000) : (int) $pub; } elseif (is_string($pub) && $pub !== '') { $t = strtotime($pub); if ($t) $publishedAt = $t; } if ($publishedAt < 946684800) { $publishedAt = $now; } $payload = [ 'title' => mb_substr($title, 0, 250, 'UTF-8'), 'cover' => $cover, 'url' => $url2, 'summary' => mb_substr($summary, 0, 500, 'UTF-8'), 'author' => mb_substr($author, 0, 60, 'UTF-8'), 'tag' => $tag, 'publishedAt' => $publishedAt ?: $now, ]; $existed = SoulArticleModel::where('sourceId', $sourceId)->find(); if ($existed) { $existed->save($payload); $updated++; } else { $payload['sourceId'] = $sourceId; SoulArticleModel::create($payload); $created++; } $items[] = array_merge(['sourceId' => $sourceId], $payload); } return ['fetched' => count($list), 'created' => $created, 'updated' => $updated, 'items' => $items]; } /** * 统一 HTTP GET(返回 body/httpCode/error) */ private static function httpGet(string $url, array $headers = []): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); return [$response, $httpCode, $err]; } }