Files
nl-admin-api/app/Service/common/ai/AiGenerationLogService.php

231 lines
8.1 KiB
PHP
Raw Normal View History

2026-08-10 15:51:00 +08:00
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use App\Enum\AiGenerationStatusEnum;
use App\Models\ai\AiGenerationModel;
/**
* AI 生成历史落库:开始 / 成功 / 失败
* 为什么单独抽:多场景复用同一流水表,业务只关心 begin/finish
*/
class AiGenerationLogService extends BaseService
{
/**
* 创建一条「进行中」记录,返回主键 ID
*
* @param array{
* scene?:string,
* name?:string,
* provider?:string,
* model?:string,
* api_key_id?:int,
* input_snapshot?:array|null,
* admin_id?:int
* } $params
*/
public function begin(array $params): int
{
$now = time();
$scene = trim((string) ($params['scene'] ?? AiGenerationSceneEnum::CODE_GENERATION->value));
$adminId = (int) ($params['admin_id'] ?? $this->userId);
return (int) AiGenerationModel::insertGetId([
'admin_id' => $adminId,
'scene' => $scene,
'name' => mb_substr(trim((string) ($params['name'] ?? '')), 0, 128),
'provider' => trim((string) ($params['provider'] ?? '')),
'model' => trim((string) ($params['model'] ?? '')),
'status' => AiGenerationStatusEnum::RUNNING->value,
'api_key_id' => (int) ($params['api_key_id'] ?? 0),
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
'usage_json' => null,
'started_at' => $now,
'finished_at' => 0,
'duration_ms' => 0,
'error_msg' => '',
'input_snapshot' => $this->encodeJson($params['input_snapshot'] ?? null),
'result_json' => null,
'raw_response' => null,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 标记成功并写入结果 / token
*
* @param array{
* provider?:string,
* model?:string,
* api_key_id?:int,
* usage?:array,
* result_json?:array|null,
* raw_response?:string|null,
* name?:string
* } $payload
*/
public function markSuccess(int $id, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::SUCCESS->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'prompt_tokens' => (int) ($usage['prompt_tokens'] ?? 0),
'completion_tokens' => (int) ($usage['completion_tokens'] ?? 0),
'total_tokens' => (int) ($usage['total_tokens'] ?? 0),
'usage_json' => $this->encodeJson($usage ?: null),
'result_json' => $this->encodeJson($payload['result_json'] ?? null),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'error_msg' => '',
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
if (!empty($payload['name'])) {
$data['name'] = mb_substr(trim((string) $payload['name']), 0, 128);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 标记失败
*/
public function markFail(int $id, string $errorMsg, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::FAIL->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'error_msg' => mb_substr(trim($errorMsg), 0, 1000),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
if (!empty($usage)) {
$data['prompt_tokens'] = (int) ($usage['prompt_tokens'] ?? 0);
$data['completion_tokens'] = (int) ($usage['completion_tokens'] ?? 0);
$data['total_tokens'] = (int) ($usage['total_tokens'] ?? 0);
$data['usage_json'] = $this->encodeJson($usage);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 分页列表(管理端生成记录)
*/
public function getPageListForAdmin(): array
{
$this->model = AiGenerationModel::class;
$this->selectField = [
'id', 'admin_id', 'scene', 'name', 'provider', 'model', 'status',
'api_key_id', 'prompt_tokens', 'completion_tokens', 'total_tokens',
'started_at', 'finished_at', 'duration_ms', 'error_msg', 'created_at',
];
$this->queryField = [
'scene' => '=',
'status' => '=',
'provider' => '=',
'name' => 'like',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
// 重置 where避免单例污染
$this->where = [['deleted_at', '=', 0]];
$list = $this->getPageList();
foreach ($list['items'] as &$item) {
$status = (int) ($item['status'] ?? 0);
$scene = (string) ($item['scene'] ?? '');
$item['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$item['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
}
unset($item);
return $list;
}
/**
* 详情(含 input/result不含超大 raw 时可按需)
*/
public function getDetailForAdmin(int $id): array
{
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
$this->utils->notFound('记录不存在');
}
$data = $row->toArray();
$status = (int) ($data['status'] ?? 0);
$scene = (string) ($data['scene'] ?? '');
$data['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$data['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
return $data;
}
private function calcDurationMs(int $startedAt, int $finishedAt): int
{
if ($startedAt <= 0 || $finishedAt <= 0 || $finishedAt < $startedAt) {
return 0;
}
return ($finishedAt - $startedAt) * 1000;
}
private function encodeJson(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
private function clipText(string $text, int $max): string
{
if ($text === '' || mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max);
}
}