231 lines
8.1 KiB
PHP
231 lines
8.1 KiB
PHP
<?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);
|
||
}
|
||
}
|