validateInput($r, ['page' => 'nullable|integer|min:1'])) return $error; $start = now()->startOfYear(); $news = CompanyNews::query()->select('id', 'title', 'source_url', 'source_published_at') ->where('review_status', 'retained') ->where('source_published_at', '>=', $start) ->where('source_published_at', '<', $start->copy()->addYear()) ->orderByDesc('source_published_at')->orderByDesc('id') ->paginate(6, ['*'], 'page', (int) $r->input('page', 1)); return $this->success($news); } public function index(Request $r) { if ($error = $this->validateInput($r, [ 'company_id' => 'nullable|integer', 'keyword' => 'nullable|string|max:200', 'review_status' => 'nullable|in:retained,discarded', 'category_codes' => 'nullable|array|max:500', 'category_codes.*' => 'string|max:100', 'date_from' => 'nullable|date_format:Y-m-d', 'date_to' => 'nullable|date_format:Y-m-d'.($r->filled('date_from') ? '|after_or_equal:date_from' : ''), ])) return $error; $q = CompanyNews::query()->select([ 'id', 'company_id', 'company_name_snapshot', 'title', 'source_name', 'source_url', 'source_published_at', 'review_status', 'is_visible', 'version', 'first_seen_at', 'categories', 'emotion_type', ])->selectRaw("CASE WHEN content_html IS NULL OR content_html = '' THEN 0 ELSE 1 END as has_content") ->with('company:id,company_name'); if ($r->filled('company_id')) $q->where('company_id', $r->company_id); if ($r->filled('keyword')) $q->where('title', 'like', '%'.$r->keyword.'%'); if ($r->input('review_status', 'retained') !== '') $q->where('review_status', $r->input('review_status', 'retained')); if ($r->input('category_codes')) $q->where(function ($query) use ($r) { foreach ($r->category_codes as $code) { if (DB::connection()->getDriverName() === 'sqlite') { $query->orWhereRaw("EXISTS (SELECT 1 FROM json_each(company_news.categories) WHERE json_extract(value, '$.code') = ?)", [$code]); } else { $query->orWhereJsonContains('categories', ['code' => $code]); } } }); if ($r->filled('date_from')) $q->where('source_published_at', '>=', $r->date_from.' 00:00:00'); if ($r->filled('date_to')) $q->where('source_published_at', '<', Carbon::parse($r->date_to)->addDay()->startOfDay()); return $this->success($q->orderByDesc('source_published_at')->orderByDesc('id')->paginate(20)); } public function categories() { $categories = []; foreach (CompanyNews::select('id', 'categories')->cursor() as $news) { $seen = []; foreach ($news->categories ?? [] as $category) { $code = (string) ($category['code'] ?? ''); if ($code === '' || isset($seen[$code])) continue; $seen[$code] = true; if (!isset($categories[$code])) $categories[$code] = ['code' => $code, 'name' => $category['name'] ?: $code, 'count' => 0]; $categories[$code]['count']++; } } $rows = array_values($categories); usort($rows, fn ($a, $b) => $b['count'] <=> $a['count'] ?: strcmp($a['code'], $b['code'])); return $this->success(['categories' => $rows, 'retained_categories' => json_decode(DB::table('company_news_settings')->where('id', 1)->value('retained_categories') ?? '[]', true)]); } public function saveSettings(Request $r) { if ($error = $this->validateInput($r, [ 'retained_categories' => 'present|array|max:500', 'retained_categories.*' => 'required|string|max:100|distinct', ])) return $error; DB::table('company_news_settings')->where('id', 1)->update([ 'retained_categories' => json_encode(array_values($r->retained_categories)), 'updated_by' => $this->getUserId(), 'updated_at' => now(), ]); return $this->success('分类设置已保存,从新建采集批次开始生效'); } public function companies(Request $r) { if ($error = $this->validateInput($r, ['keyword' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1'])) return $error; return $this->success(Company::query()->select('id', 'company_name') ->whereHas('qccAccount', fn ($q) => $q->whereIn('status', ['selected', 'occupied'])) ->where('company_name', 'like', '%'.mb_substr((string) $r->input('keyword', ''), 0, 100).'%') ->orderBy('company_name')->orderBy('id')->paginate(30, ['*'], 'page', (int) $r->input('page', 1))); } public function show(Request $r) { if ($error = $this->validateInput($r, ['id' => 'required|integer|min:1'])) return $error; $news = CompanyNews::with('company:id,company_name')->find($r->input('id')); if (!$news) return $this->fail([404, '新闻不存在']); return $this->success($news); } public function save(Request $r, NewsEditor $editor) { if ($error = $this->validateInput($r, [ 'id' => 'required|integer', 'version' => 'required|integer|min:1', 'title' => 'required|string|max:2000', 'summary' => 'nullable|string|max:10000', 'content_html' => 'nullable|string|max:2000000', ])) return $error; try { return $this->success($editor->save($r->id, $r->version, $r->only('title', 'summary', 'content_html'), $this->getUserId())); } catch (RuntimeException $e) { return $this->fail([409, $e->getMessage()]); } } public function review(Request $r, NewsEditor $editor) { if ($error = $this->validateInput($r, [ 'id' => 'required|integer', 'version' => 'required|integer|min:1', 'review_status' => 'required|in:retained,discarded', 'review_note' => 'nullable|required_if:review_status,discarded|string|max:2000', ])) return $error; try { return $this->success($editor->review($r->id, $r->version, $r->review_status, $r->review_note, $this->getUserId())); } catch (RuntimeException $e) { return $this->fail([409, $e->getMessage()]); } } public function reviewMany(Request $r, NewsEditor $editor) { if ($error = $this->validateInput($r, [ 'items' => 'required|array|min:1|max:100', 'items.*.id' => 'required|integer|distinct', 'items.*.version' => 'required|integer|min:1', 'review_status' => 'required|in:retained,discarded', 'review_note' => 'nullable|required_if:review_status,discarded|string|max:2000', ])) return $error; try { DB::transaction(function () use ($r, $editor) { // Deterministic row-lock order avoids deadlocks between overlapping selections. foreach (collect($r->items)->sortBy('id') as $item) { $editor->review($item['id'], $item['version'], $r->review_status, $r->review_note, $this->getUserId()); } }); return $this->success('批量审核完成,所有新闻仍不展示'); } catch (RuntimeException $e) { return $this->fail([409, $e->getMessage()]); } } public function logs(Request $r) { if ($error = $this->validateInput($r, ['id' => 'required|integer|min:1'])) return $error; return $this->success(DB::table('company_news_logs')->where('news_id', $r->input('id'))->orderByDesc('id')->paginate(20)); } public function start(Request $r, NewsCollection $collector) { if ($error = $this->validateInput($r, ['retry_of' => 'nullable|integer|min:1'])) return $error; try { return $this->success($collector->start($this->getUserId(), $r->filled('retry_of') ? (int) $r->retry_of : null)); } catch (RuntimeException $e) { return $this->fail([409, $e->getMessage()]); } } public function latest(NewsCollection $collector) { $id = DB::table('company_news_control')->where('id', 1)->value('active_batch_id') ?: DB::table('company_news_batches')->max('id'); return $this->success(['batch' => $id ? $collector->summary($id) : null, 'configuration_error' => $collector->configurationError()]); } public function batches(NewsCollection $collector) { $rows = DB::table('company_news_batches')->orderByDesc('id')->paginate(10); $rows->setCollection($rows->getCollection()->map(fn ($b) => $collector->summary($b->id))); return $this->success($rows); } public function tasks(Request $r) { if ($error = $this->validateInput($r, ['batch_id' => 'required|integer|min:1'])) return $error; $q = DB::table('company_news_tasks')->where('batch_id', $r->input('batch_id')); if ($r->input('status') === 'failed') $q->where('status', 'failed'); return $this->success($q->orderBy('company_id')->paginate(20)); } private function validateInput(Request $r, array $rules) { $v = Validator::make($r->all(), $rules); return $v->fails() ? $this->fail([422, $v->errors()->first()]) : null; } }