You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

179 lines
8.9 KiB

<?php
namespace App\Http\Controllers\Admin;
use App\Models\Company;
use App\Models\CompanyNews;
use App\Services\CompanyNews\NewsCollection;
use App\Services\CompanyNews\NewsEditor;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
class CompanyNewsController extends CommonController
{
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;
}
}