AI代码生成工具
This commit is contained in:
72
app/Service/common/FieldEncryptService.php
Normal file
72
app/Service/common/FieldEncryptService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common;
|
||||
|
||||
use App\BaseApp\BaseNotAuthService;
|
||||
use App\Core\DatabaseEncryptor;
|
||||
|
||||
/**
|
||||
* 库内敏感字段加解密服务(API Key 等)
|
||||
* 入库写 nl_ase_256_;读取兼容历史 ***
|
||||
*/
|
||||
class FieldEncryptService extends BaseNotAuthService
|
||||
{
|
||||
private ?DatabaseEncryptor $encryptor = null;
|
||||
|
||||
/**
|
||||
* 获取加解密器(懒加载,密钥来自 config/nl.php)
|
||||
*/
|
||||
private function encryptor(): DatabaseEncryptor
|
||||
{
|
||||
if ($this->encryptor === null) {
|
||||
$key = (string) config('nl.encrypt_key', '');
|
||||
if ($key === '') {
|
||||
$this->utils->errorThrow('未配置 ENCRYPT_KEY,无法加解密敏感字段');
|
||||
}
|
||||
$this->encryptor = new DatabaseEncryptor($key);
|
||||
}
|
||||
return $this->encryptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入库前加密:明文 → nl_ase_256_...
|
||||
*
|
||||
* @param string|null $plain 明文
|
||||
*/
|
||||
public function encryptForStorage(?string $plain): string
|
||||
{
|
||||
try {
|
||||
return $this->encryptor()->encrypt($plain);
|
||||
} catch (\Throwable $e) {
|
||||
$this->utils->errorThrow('敏感字段加密失败:' . $e->getMessage());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取后解密:nl_ase_256_ / 历史 *** → 明文
|
||||
*
|
||||
* @param string|null $cipher 库内值
|
||||
* @param bool $silentFail true 时解密失败返回空串;false 抛业务异常
|
||||
*/
|
||||
public function decryptFromStorage(?string $cipher, bool $silentFail = false): string
|
||||
{
|
||||
try {
|
||||
return $this->encryptor()->decrypt($cipher);
|
||||
} catch (\Throwable $e) {
|
||||
if ($silentFail) {
|
||||
return '';
|
||||
}
|
||||
$this->utils->errorThrow('敏感字段解密失败:' . $e->getMessage());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已是库内密文
|
||||
*/
|
||||
public function isEncrypted(?string $value): bool
|
||||
{
|
||||
return DatabaseEncryptor::isEncrypted($value);
|
||||
}
|
||||
}
|
||||
41
app/Service/common/ai/AiAgentFactory.php
Normal file
41
app/Service/common/ai/AiAgentFactory.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
|
||||
/**
|
||||
* AI Agent 工厂:按系统配置(nl_system_config + 平台表)选择供应商
|
||||
* 未配置时回落 env(config/ai.php);目前支持讯飞星火 / DeepSeek
|
||||
*/
|
||||
class AiAgentFactory extends BaseService
|
||||
{
|
||||
public const PROVIDER_SPARK = 'spark';
|
||||
public const PROVIDER_DEEPSEEK = 'deepseek';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 工厂解析不强制登录(内部调用)
|
||||
$this->isAuth = false;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 实例
|
||||
*
|
||||
* @param string|null $provider 为空则用系统配置 ai_active_provider / env 默认
|
||||
*/
|
||||
public function make(?string $provider = null): AiAgentInterface
|
||||
{
|
||||
$resolved = AiRuntimeConfigService::getInstance()->resolve($provider);
|
||||
$name = strtolower(trim((string) ($resolved['provider'] ?? self::PROVIDER_DEEPSEEK)));
|
||||
if ($name === self::PROVIDER_DEEPSEEK) {
|
||||
return DeepSeekAiAgent::getInstance();
|
||||
}
|
||||
if ($name === self::PROVIDER_SPARK) {
|
||||
return SparkAiAgent::getInstance();
|
||||
}
|
||||
$this->utils->errorThrow('不支持的 AI 供应商:' . $name);
|
||||
return SparkAiAgent::getInstance();
|
||||
}
|
||||
}
|
||||
23
app/Service/common/ai/AiAgentInterface.php
Normal file
23
app/Service/common/ai/AiAgentInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
/**
|
||||
* AI Agent 统一接口(OpenAI 兼容 chat completions)
|
||||
* 各供应商(讯飞 Spark / DeepSeek 等)实现本接口,业务层只依赖工厂
|
||||
*/
|
||||
interface AiAgentInterface
|
||||
{
|
||||
/**
|
||||
* 发起对话补全
|
||||
*
|
||||
* @param array $messages OpenAI 格式 messages:[['role'=>'system|user|assistant','content'=>'...'], ...]
|
||||
* @param array $options 可选覆盖:model / temperature / max_tokens 等
|
||||
*/
|
||||
public function chatCompletions(array $messages, array $options = []): AiChatResult;
|
||||
|
||||
/**
|
||||
* 供应商标识(spark / deepseek)
|
||||
*/
|
||||
public function provider(): string;
|
||||
}
|
||||
66
app/Service/common/ai/AiChatResult.php
Normal file
66
app/Service/common/ai/AiChatResult.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
/**
|
||||
* AI 对话结果 DTO
|
||||
* 统一各供应商响应,便于落库与业务解析
|
||||
*/
|
||||
class AiChatResult
|
||||
{
|
||||
/** 助手文本内容(已从 choices[0].message.content 取出) */
|
||||
public string $content = '';
|
||||
|
||||
/** 供应商原始响应数组(便于排障) */
|
||||
public array $raw = [];
|
||||
|
||||
/** 供应商标识 */
|
||||
public string $provider = '';
|
||||
|
||||
/** 实际使用的模型名 */
|
||||
public string $model = '';
|
||||
|
||||
/** token 用量(若供应商返回) */
|
||||
public array $usage = [];
|
||||
|
||||
/** 本次调用使用的 API Key ID(库内密钥;env 回落为 0) */
|
||||
public int $apiKeyId = 0;
|
||||
|
||||
/**
|
||||
* @param string $content 助手文本
|
||||
* @param array $raw 原始响应
|
||||
* @param string $provider 供应商
|
||||
* @param string $model 模型
|
||||
* @param array $usage usage 字段
|
||||
* @param int $apiKeyId 密钥 ID
|
||||
*/
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $raw = [],
|
||||
string $provider = '',
|
||||
string $model = '',
|
||||
array $usage = [],
|
||||
int $apiKeyId = 0
|
||||
) {
|
||||
$this->content = $content;
|
||||
$this->raw = $raw;
|
||||
$this->provider = $provider;
|
||||
$this->model = $model;
|
||||
$this->usage = $usage;
|
||||
$this->apiKeyId = $apiKeyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为数组(落库 / 接口返回用)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'content' => $this->content,
|
||||
'provider' => $this->provider,
|
||||
'model' => $this->model,
|
||||
'usage' => $this->usage,
|
||||
'api_key_id' => $this->apiKeyId,
|
||||
];
|
||||
}
|
||||
}
|
||||
261
app/Service/common/ai/AiCodeGenService.php
Normal file
261
app/Service/common/ai/AiCodeGenService.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Enum\AiGenerationSceneEnum;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 代码生成 AI 助手:根据模块名+功能描述,产出可直接灌入代码生成表单的 JSON
|
||||
* 每次调用写入 nl_ai_generation,便于排查用量与失败原因
|
||||
*/
|
||||
class AiCodeGenService extends BaseService
|
||||
{
|
||||
/** 允许的 MySQL 字段类型(与代码生成器一致) */
|
||||
private const ALLOWED_TYPES = [
|
||||
'varchar', 'int', 'tinyint', 'text', 'decimal', 'bigint', 'char', 'json',
|
||||
];
|
||||
|
||||
/** 系统自动维护的字段,禁止 AI 再生成 */
|
||||
private const RESERVED_FIELDS = [
|
||||
'id', 'created_at', 'updated_at', 'deleted_at', 'status',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用当前默认 AI,生成代码生成标准结构
|
||||
*
|
||||
* @param array{module_name?:string,description?:string,pid?:int} $params
|
||||
* @return array 与前端 CodeGenData 对齐的数组
|
||||
*/
|
||||
public function generate(array $params): array
|
||||
{
|
||||
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
|
||||
$description = trim((string) ($params['description'] ?? ''));
|
||||
$pid = (int) ($params['pid'] ?? 0);
|
||||
if ($moduleName === '') {
|
||||
$this->utils->errorThrow('请输入模块名');
|
||||
}
|
||||
if ($description === '') {
|
||||
$this->utils->errorThrow('请输入功能描述');
|
||||
}
|
||||
|
||||
$runtime = AiRuntimeConfigService::getInstance()->resolve();
|
||||
$log = AiGenerationLogService::getInstance();
|
||||
$generationId = $log->begin([
|
||||
'scene' => AiGenerationSceneEnum::CODE_GENERATION->value,
|
||||
'name' => $moduleName,
|
||||
'provider' => (string) ($runtime['provider'] ?? ''),
|
||||
'model' => (string) ($runtime['model'] ?? ''),
|
||||
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
|
||||
'admin_id' => $this->userId,
|
||||
'input_snapshot' => [
|
||||
'module_name' => $moduleName,
|
||||
'description' => $description,
|
||||
'pid' => $pid,
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
$agent = AiAgentFactory::getInstance()->make();
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $this->buildSystemPrompt()],
|
||||
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $pid)],
|
||||
];
|
||||
$result = $agent->chatCompletions($messages, [
|
||||
'temperature' => 0.2,
|
||||
'max_tokens' => 4096,
|
||||
]);
|
||||
$parsed = $this->parseJsonContent($result->content);
|
||||
// 强制回填用户选择的父级菜单,避免模型改写 pid
|
||||
$parsed['pid'] = $pid;
|
||||
if (empty($parsed['class_comment'])) {
|
||||
$parsed['class_comment'] = $moduleName;
|
||||
}
|
||||
if (empty($parsed['icon'])) {
|
||||
$parsed['icon'] = 'lucide:box';
|
||||
}
|
||||
if (!isset($parsed['sort']) || $parsed['sort'] === '') {
|
||||
$parsed['sort'] = 9999;
|
||||
}
|
||||
$this->validateResult($parsed);
|
||||
$log->markSuccess($generationId, [
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'api_key_id' => $result->apiKeyId,
|
||||
'usage' => $result->usage,
|
||||
'result_json' => $parsed,
|
||||
'raw_response' => $result->content,
|
||||
'name' => (string) ($parsed['class_comment'] ?? $moduleName),
|
||||
]);
|
||||
return $parsed;
|
||||
} catch (Throwable $e) {
|
||||
$log->markFail($generationId, $e->getMessage(), [
|
||||
'provider' => (string) ($runtime['provider'] ?? ''),
|
||||
'model' => (string) ($runtime['model'] ?? ''),
|
||||
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统提示词:用「完整示例」约束输出,避免模型把说明文字当成字段值
|
||||
* 为什么不用 schema 占位描述:模型常把 "snake_case 字段名,不要 id..." 原样抄进 name,导致建表 SQL 语法错误
|
||||
*/
|
||||
private function buildSystemPrompt(): string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
你是 nl-admin 后台代码生成助手。根据用户的模块名与功能描述,输出【仅一个】合法 JSON 对象。
|
||||
禁止输出 markdown、代码围栏、解释文字。禁止把规则说明文字当作字段值。
|
||||
|
||||
【输出示例】(请按同样结构填写真实业务字段,不要照抄示例字段名):
|
||||
{
|
||||
"class_name": "VipMember",
|
||||
"class_comment": "VIP会员",
|
||||
"icon": "lucide:crown",
|
||||
"sort": 9999,
|
||||
"pid": 0,
|
||||
"field": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "varchar",
|
||||
"type_length": "64",
|
||||
"default": "",
|
||||
"comment": "标题",
|
||||
"not_null": true,
|
||||
"formShow": true,
|
||||
"tableShow": true,
|
||||
"formType": "VbenInput",
|
||||
"search": true,
|
||||
"searchValue": "like"
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"type": "tinyint",
|
||||
"type_length": "1",
|
||||
"default": "0",
|
||||
"comment": "等级",
|
||||
"not_null": true,
|
||||
"formShow": true,
|
||||
"tableShow": true,
|
||||
"formType": "InputNumber",
|
||||
"search": true,
|
||||
"searchValue": "="
|
||||
},
|
||||
{
|
||||
"name": "remark",
|
||||
"type": "varchar",
|
||||
"type_length": "255",
|
||||
"default": "",
|
||||
"comment": "备注",
|
||||
"not_null": false,
|
||||
"formShow": true,
|
||||
"tableShow": false,
|
||||
"formType": "Textarea",
|
||||
"search": false,
|
||||
"searchValue": "="
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
硬性规则:
|
||||
1. class_name:仅英文 PascalCase(如 User、OrderItem、VipMember),禁止中文、空格、下划线、连字符。
|
||||
2. field[].name:仅小写 snake_case(如 user_name、phone),禁止中文、空格、说明性长句。
|
||||
3. 禁止生成这些字段名:id、status、created_at、updated_at、deleted_at(系统自动加)。
|
||||
4. type 只能是:varchar、int、tinyint、text、decimal、bigint、char、json。
|
||||
5. formType 只能是:VbenInput、Textarea、InputNumber、VbenSelect、Upload、Avatar。
|
||||
6. searchValue 只能是:=、like、>、<、between。
|
||||
7. field 至少 2 个、最多 20 个业务字段;按描述合理推断,不要编造无关字段。
|
||||
8. 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提示词:带上模块上下文
|
||||
*/
|
||||
private function buildUserPrompt(string $moduleName, string $description, int $pid): string
|
||||
{
|
||||
return "模块名:{$moduleName}\n功能描述:{$description}\n父级菜单ID:{$pid}\n请按系统示例结构输出 JSON(填真实业务字段,不要照抄示例)。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型回复中提取 JSON(兼容偶发的 ```json 包裹)
|
||||
*/
|
||||
private function parseJsonContent(string $content): array
|
||||
{
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
$this->utils->errorThrow('AI 未返回内容');
|
||||
}
|
||||
if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $content, $m)) {
|
||||
$content = trim($m[1]);
|
||||
}
|
||||
$start = strpos($content, '{');
|
||||
$end = strrpos($content, '}');
|
||||
if ($start === false || $end === false || $end <= $start) {
|
||||
$this->utils->errorThrow('AI 返回不是有效 JSON');
|
||||
}
|
||||
$json = substr($content, $start, $end - $start + 1);
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) {
|
||||
$this->utils->errorThrow('AI 返回 JSON 解析失败');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格校验 AI 结果,避免脏数据进入建表 SQL
|
||||
* 为什么要严:模型偶发把提示词说明抄进 name/class_name,会导致 CREATE TABLE 语法错误
|
||||
*/
|
||||
private function validateResult(array $data): void
|
||||
{
|
||||
$className = trim((string) ($data['class_name'] ?? ''));
|
||||
if ($className === '') {
|
||||
$this->utils->errorThrow('AI 未生成有效类名');
|
||||
}
|
||||
if (!preg_match('/^[A-Z][A-Za-z0-9]{0,63}$/', $className)) {
|
||||
$this->utils->errorThrow("类名非法「{$className}」,须为英文 PascalCase(如 VipMember)");
|
||||
}
|
||||
if (empty($data['field']) || !is_array($data['field'])) {
|
||||
$this->utils->errorThrow('AI 未生成字段列表');
|
||||
}
|
||||
if (count($data['field']) < 1) {
|
||||
$this->utils->errorThrow('AI 字段列表为空');
|
||||
}
|
||||
|
||||
$names = [];
|
||||
foreach ($data['field'] as $i => $field) {
|
||||
if (!is_array($field)) {
|
||||
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项格式错误');
|
||||
}
|
||||
$name = trim((string) ($field['name'] ?? ''));
|
||||
$type = strtolower(trim((string) ($field['type'] ?? '')));
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项缺少 name');
|
||||
}
|
||||
// 过滤模型把整段说明塞进 name 的情况
|
||||
if (mb_strlen($name) > 64 || preg_match('/\s|,|。|:|:|\||(|)|\(|\)/u', $name)) {
|
||||
$this->utils->errorThrow("字段名非法「{$name}」,须为短小写 snake_case");
|
||||
}
|
||||
if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $name)) {
|
||||
$this->utils->errorThrow("字段名非法「{$name}」,须为小写 snake_case(如 user_name)");
|
||||
}
|
||||
if (in_array($name, self::RESERVED_FIELDS, true)) {
|
||||
$this->utils->errorThrow("字段「{$name}」为系统保留字段,请勿生成");
|
||||
}
|
||||
if (!in_array($type, self::ALLOWED_TYPES, true)) {
|
||||
$this->utils->errorThrow("字段「{$name}」类型非法「{$type}」");
|
||||
}
|
||||
if (isset($names[$name])) {
|
||||
$this->utils->errorThrow("字段名重复「{$name}」");
|
||||
}
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
197
app/Service/common/ai/AiDisclaimerService.php
Normal file
197
app/Service/common/ai/AiDisclaimerService.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\new\ai\AiDoctorConsentModel;
|
||||
use App\Models\new\ai\AiFeatureCopyModel;
|
||||
use App\Models\new\vip\VipFeatureModel;
|
||||
|
||||
/**
|
||||
* AI 醒目提示文案 + 首次知情同意
|
||||
* 文案按 VIP 功能码隔离;医生签署后仅该功能无需再次确认
|
||||
*/
|
||||
class AiDisclaimerService extends BaseService
|
||||
{
|
||||
public const FEATURE_MR = 'ai_medical_record';
|
||||
public const FEATURE_RX = 'ai_prescription';
|
||||
|
||||
/**
|
||||
* 系统配置:启用中的文案列表(含 VIP 功能名)
|
||||
*/
|
||||
public function listCopiesForConfig(): array
|
||||
{
|
||||
$rows = AiFeatureCopyModel::where('deleted_at', 0)
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->toArray();
|
||||
$nameMap = VipFeatureModel::where('deleted_at', 0)
|
||||
->whereIn('code', array_column($rows, 'feature_code'))
|
||||
->get(['code', 'name'])
|
||||
->keyBy('code')
|
||||
->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$code = (string) ($row['feature_code'] ?? '');
|
||||
$row['feature_name'] = $nameMap[$code]['name'] ?? $code;
|
||||
$row['disclaimer_text'] = (string) ($row['disclaimer_text'] ?? '');
|
||||
$row['consent_text'] = (string) ($row['consent_text'] ?? '');
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存文案(系统配置 AI 模型 Tab)
|
||||
*
|
||||
* @param list<array{id?:int,feature_code?:string,record_label?:string,disclaimer_text?:string,consent_text?:string}> $items
|
||||
*/
|
||||
public function saveCopies(array $items): bool
|
||||
{
|
||||
if (empty($items)) {
|
||||
$this->utils->errorThrow('无保存内容');
|
||||
}
|
||||
$now = time();
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($item['id'] ?? 0);
|
||||
$code = trim((string) ($item['feature_code'] ?? ''));
|
||||
$row = null;
|
||||
if ($id > 0) {
|
||||
$row = AiFeatureCopyModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
}
|
||||
if (!$row && $code !== '') {
|
||||
$row = AiFeatureCopyModel::where('feature_code', $code)->where('deleted_at', 0)->first();
|
||||
}
|
||||
if (!$row) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('record_label', $item)) {
|
||||
$row->record_label = trim((string) $item['record_label']);
|
||||
}
|
||||
if (array_key_exists('disclaimer_text', $item)) {
|
||||
$row->disclaimer_text = (string) $item['disclaimer_text'];
|
||||
}
|
||||
if (array_key_exists('consent_text', $item)) {
|
||||
$row->consent_text = (string) $item['consent_text'];
|
||||
}
|
||||
$row->updated_at = $now;
|
||||
$row->save();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按功能码取文案;不存在则回落内置默认
|
||||
*/
|
||||
public function getCopyByFeature(string $featureCode): array
|
||||
{
|
||||
$featureCode = trim($featureCode);
|
||||
if ($featureCode === '') {
|
||||
$this->utils->errorThrow('功能码不能为空');
|
||||
}
|
||||
$row = AiFeatureCopyModel::where('feature_code', $featureCode)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
if ($row) {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'feature_code' => (string) $row->feature_code,
|
||||
'record_label' => (string) ($row->record_label ?: $this->defaultRecordLabel($featureCode)),
|
||||
'disclaimer_text' => (string) ($row->disclaimer_text ?: $this->defaultDisclaimer($featureCode)),
|
||||
'consent_text' => (string) ($row->consent_text ?: $this->defaultConsent()),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'id' => 0,
|
||||
'feature_code' => $featureCode,
|
||||
'record_label' => $this->defaultRecordLabel($featureCode),
|
||||
'disclaimer_text' => $this->defaultDisclaimer($featureCode),
|
||||
'consent_text' => $this->defaultConsent(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 门闸:文案 + 是否已签署(按医生+功能码)
|
||||
*/
|
||||
public function getGate(string $featureCode, int $doctorId): array
|
||||
{
|
||||
if ($doctorId <= 0) {
|
||||
$this->utils->errorThrow('无法识别医生身份');
|
||||
}
|
||||
$copy = $this->getCopyByFeature($featureCode);
|
||||
$consented = AiDoctorConsentModel::where('doctor_id', $doctorId)
|
||||
->where('feature_code', $featureCode)
|
||||
->where('deleted_at', 0)
|
||||
->exists();
|
||||
return array_merge($copy, [
|
||||
'consented' => $consented,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生签署知情同意(同一功能幂等)
|
||||
*/
|
||||
public function agree(string $featureCode, int $doctorId, int $storeId = 0): array
|
||||
{
|
||||
if ($doctorId <= 0) {
|
||||
$this->utils->errorThrow('无法识别医生身份');
|
||||
}
|
||||
$featureCode = trim($featureCode);
|
||||
if ($featureCode === '') {
|
||||
$this->utils->errorThrow('功能码不能为空');
|
||||
}
|
||||
$copy = $this->getCopyByFeature($featureCode);
|
||||
$exists = AiDoctorConsentModel::where('doctor_id', $doctorId)
|
||||
->where('feature_code', $featureCode)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if ($exists) {
|
||||
return [
|
||||
'consented' => true,
|
||||
'feature_code' => $featureCode,
|
||||
'already' => true,
|
||||
];
|
||||
}
|
||||
$now = time();
|
||||
AiDoctorConsentModel::query()->insert([
|
||||
'doctor_id' => $doctorId,
|
||||
'feature_code' => $featureCode,
|
||||
'copy_id' => (int) ($copy['id'] ?? 0),
|
||||
'store_id' => $storeId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => 0,
|
||||
'deleted_at' => 0,
|
||||
]);
|
||||
return [
|
||||
'consented' => true,
|
||||
'feature_code' => $featureCode,
|
||||
'already' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function defaultRecordLabel(string $featureCode): string
|
||||
{
|
||||
return $featureCode === self::FEATURE_RX ? '处方' : '病历';
|
||||
}
|
||||
|
||||
private function defaultDisclaimer(string $featureCode): string
|
||||
{
|
||||
$label = $this->defaultRecordLabel($featureCode);
|
||||
return '本' . $label . '由 AI 根据问诊/语音转写内容辅助生成,仅供起草参考。'
|
||||
. "\nAI 可能存在漏记、误读或“幻觉”,不能替代医师专业判断。"
|
||||
. "\n诊断、处置、用药、知情告知等关键内容须由你逐条核对、修改后确认。";
|
||||
}
|
||||
|
||||
private function defaultConsent(): string
|
||||
{
|
||||
return "我已知悉:\n"
|
||||
. "1. 本平台 AI 仅用于病历起草、信息归纳、术语规范化;\n"
|
||||
. "2. 不得用于自动诊断、自动开嘱、自动出报告;\n"
|
||||
. "3. 所有 AI 输出须由具备资质的医师审核、修改、电子签名;\n"
|
||||
. "4. 因未审核直接使用 AI 内容导致的医疗差错,由操作医师及医疗机构承担责任。";
|
||||
}
|
||||
}
|
||||
230
app/Service/common/ai/AiGenerationLogService.php
Normal file
230
app/Service/common/ai/AiGenerationLogService.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
3019
app/Service/common/ai/AiMedicalAssistService.php
Normal file
3019
app/Service/common/ai/AiMedicalAssistService.php
Normal file
File diff suppressed because it is too large
Load Diff
162
app/Service/common/ai/AiRequirementPromptService.php
Normal file
162
app/Service/common/ai/AiRequirementPromptService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Enum\AiGenerationSceneEnum;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 需求概览 / 提示词助手:按模块名生成或优化「适配 nl-admin 代码生成器」的中文文案
|
||||
* 模型只输出纯文本(不要 JSON);落库时仍包一层 result_json 方便历史查询
|
||||
*/
|
||||
class AiRequirementPromptService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成或优化功能描述文案(纯文本)
|
||||
*
|
||||
* @param array{module_name?:string,description?:string,mode?:string} $params
|
||||
* mode: generate=仅模块名生成;optimize=在已有描述上优化;空则自动判断
|
||||
* @return array{text:string,prompt:string,overview:string,mode:string,module_name:string}
|
||||
*/
|
||||
public function generate(array $params): array
|
||||
{
|
||||
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
|
||||
$description = trim((string) ($params['description'] ?? ''));
|
||||
$mode = trim((string) ($params['mode'] ?? ''));
|
||||
if ($moduleName === '') {
|
||||
$this->utils->errorThrow('请输入模块名');
|
||||
}
|
||||
if ($mode === '') {
|
||||
$mode = $description !== '' ? 'optimize' : 'generate';
|
||||
}
|
||||
if (!in_array($mode, ['generate', 'optimize'], true)) {
|
||||
$this->utils->errorThrow('mode 仅支持 generate 或 optimize');
|
||||
}
|
||||
if ($mode === 'optimize' && $description === '') {
|
||||
$this->utils->errorThrow('优化模式请先填写当前功能描述');
|
||||
}
|
||||
|
||||
$runtime = AiRuntimeConfigService::getInstance()->resolve();
|
||||
$log = AiGenerationLogService::getInstance();
|
||||
$generationId = $log->begin([
|
||||
'scene' => AiGenerationSceneEnum::REQUIREMENT_PROMPT->value,
|
||||
'name' => $moduleName,
|
||||
'provider' => (string) ($runtime['provider'] ?? ''),
|
||||
'model' => (string) ($runtime['model'] ?? ''),
|
||||
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
|
||||
'admin_id' => $this->userId,
|
||||
'input_snapshot' => [
|
||||
'module_name' => $moduleName,
|
||||
'description' => $description,
|
||||
'mode' => $mode,
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
$agent = AiAgentFactory::getInstance()->make();
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $this->buildSystemPrompt()],
|
||||
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $mode)],
|
||||
];
|
||||
$result = $agent->chatCompletions($messages, [
|
||||
'temperature' => 0.35,
|
||||
'max_tokens' => 2048,
|
||||
]);
|
||||
$text = $this->normalizePlainText($result->content);
|
||||
$normalized = [
|
||||
// text 为主字段;prompt/overview 兼容旧前端「替换导入」
|
||||
'text' => $text,
|
||||
'prompt' => $text,
|
||||
'overview' => $text,
|
||||
'mode' => $mode,
|
||||
'module_name' => $moduleName,
|
||||
];
|
||||
$log->markSuccess($generationId, [
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'api_key_id' => $result->apiKeyId,
|
||||
'usage' => $result->usage,
|
||||
'result_json' => $normalized,
|
||||
'raw_response' => $result->content,
|
||||
'name' => $moduleName,
|
||||
]);
|
||||
return $normalized;
|
||||
} catch (Throwable $e) {
|
||||
$log->markFail($generationId, $e->getMessage(), [
|
||||
'provider' => (string) ($runtime['provider'] ?? ''),
|
||||
'model' => (string) ($runtime['model'] ?? ''),
|
||||
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统提示:只要中文功能描述文案,禁止 JSON / markdown 代码块
|
||||
*/
|
||||
private function buildSystemPrompt(): string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
你是 nl-admin(Vben Admin + Laravel 三层 CRUD 代码生成器)的需求分析助手。
|
||||
任务:写出一段可直接填入「功能描述」输入框的中文文案,供下一步 AI 生成表结构与 CRUD 代码使用。
|
||||
|
||||
必须贴合本系统:
|
||||
1. 面向后台管理 CRUD:列表、搜索、新增/编辑、软删除、启停。
|
||||
2. 不要要求 id / status / created_at / updated_at / deleted_at(系统自动加)。
|
||||
3. 说明要管理哪些业务数据、建议字段(用中文含义即可)、哪些适合检索、哪些适合下拉/上传等。
|
||||
4. 不要写接口、鉴权、微服务、路由实现细节。
|
||||
5. 200~500 字为宜,条理清晰,可用分点,但不要标题堆砌。
|
||||
|
||||
输出要求(非常重要):
|
||||
- 只输出中文正文文案本身。
|
||||
- 禁止输出 JSON、禁止用 ``` 代码块、禁止前言后语(如「好的」「如下」)。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提示:生成 vs 优化
|
||||
*/
|
||||
private function buildUserPrompt(string $moduleName, string $description, string $mode): string
|
||||
{
|
||||
if ($mode === 'optimize') {
|
||||
return "请优化下面的功能描述,使其更适合 nl-admin 代码生成(保留原意,补全字段与管理动作)。\n模块名:{$moduleName}\n当前描述:\n{$description}";
|
||||
}
|
||||
return "请根据模块名「{$moduleName}」写一段后台管理功能描述文案(适配 nl-admin 代码生成)。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗模型回复为纯文案(去掉偶发的 markdown / JSON 包裹)
|
||||
*/
|
||||
private function normalizePlainText(string $content): string
|
||||
{
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
$this->utils->errorThrow('AI 未返回内容');
|
||||
}
|
||||
// 去掉 ``` 包裹
|
||||
if (preg_match('/```(?:\w+)?\s*([\s\S]*?)```/i', $content, $m)) {
|
||||
$content = trim($m[1]);
|
||||
}
|
||||
// 若模型仍返回 JSON,尽量抽出 prompt/text/overview
|
||||
if (str_starts_with($content, '{')) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$content = trim((string) ($decoded['text'] ?? $decoded['prompt'] ?? $decoded['overview'] ?? ''));
|
||||
}
|
||||
}
|
||||
$content = trim($content);
|
||||
// 去掉常见客套开头
|
||||
$content = preg_replace('/^(好的[,,。]?|如下[::]?|以下是[^\\n]*[::]?)\\s*/u', '', $content) ?? $content;
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
$this->utils->errorThrow('AI 未生成有效文案');
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
286
app/Service/common/ai/AiRuntimeConfigService.php
Normal file
286
app/Service/common/ai/AiRuntimeConfigService.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\ai\AiApiKeyModel;
|
||||
use App\Models\ai\AiModelModel;
|
||||
use App\Models\ai\AiPlatformModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\SystemConfigService;
|
||||
|
||||
/**
|
||||
* AI 运行时配置:从系统配置 + 平台/模型/密钥表解析当前要用的供应商与明文密钥
|
||||
* 优先数据库配置;未配置时回落 env(config/ai.php)
|
||||
*/
|
||||
class AiRuntimeConfigService extends BaseService
|
||||
{
|
||||
public const CFG_PROVIDER = 'ai_active_provider';
|
||||
public const CFG_API_KEY_ID = 'ai_active_api_key_id';
|
||||
public const CFG_MODEL = 'ai_active_model';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 工厂解析不依赖登录态
|
||||
$this->isAuth = false;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析指定供应商的调用配置
|
||||
*
|
||||
* @return array{provider:string,api_url:string,api_key:string,model:string,timeout:float,platform_id:int,api_key_id:int}
|
||||
*/
|
||||
public function resolve(?string $providerOverride = null): array
|
||||
{
|
||||
$sys = SystemConfigService::getInstance();
|
||||
$provider = strtolower(trim((string) (
|
||||
$providerOverride
|
||||
?: $sys->getValue(self::CFG_PROVIDER, '')
|
||||
?: config('ai.default_provider', 'deepseek')
|
||||
)));
|
||||
$modelOverride = trim((string) $sys->getValue(self::CFG_MODEL, ''));
|
||||
$keyId = (int) $sys->getValue(self::CFG_API_KEY_ID, '0');
|
||||
|
||||
$platform = AiPlatformModel::where('code', $provider)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
|
||||
$apiUrl = '';
|
||||
$model = '';
|
||||
$apiKey = '';
|
||||
$platformId = 0;
|
||||
$timeout = 60.0;
|
||||
|
||||
if ($platform) {
|
||||
$platformId = (int) $platform->id;
|
||||
$apiUrl = trim((string) $platform->api_url);
|
||||
// 模型:系统配置覆盖 → 模型表默认 → 模型表首个启用
|
||||
if ($modelOverride !== '') {
|
||||
$model = $modelOverride;
|
||||
} else {
|
||||
$defaultModel = AiModelModel::where('platform_id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->where('is_default', 1)
|
||||
->value('code');
|
||||
if ($defaultModel) {
|
||||
$model = trim((string) $defaultModel);
|
||||
} else {
|
||||
$firstModel = AiModelModel::where('platform_id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->value('code');
|
||||
$model = trim((string) ($firstModel ?: ''));
|
||||
}
|
||||
}
|
||||
// 指定密钥优先;否则默认密钥;再否则取该平台启用中的第一条
|
||||
$keyQuery = AiApiKeyModel::where('platform_id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1);
|
||||
if ($keyId > 0) {
|
||||
$keyRow = (clone $keyQuery)->where('id', $keyId)->first();
|
||||
} else {
|
||||
$keyRow = null;
|
||||
}
|
||||
if (!$keyRow) {
|
||||
$keyRow = (clone $keyQuery)->where('is_default', 1)->first()
|
||||
?: $keyQuery->orderByDesc('sort')->orderBy('id')->first();
|
||||
}
|
||||
if ($keyRow) {
|
||||
$apiKey = $this->decryptApiKey((string) $keyRow->api_key);
|
||||
$keyId = (int) $keyRow->id;
|
||||
}
|
||||
}
|
||||
|
||||
// 回落 env,保证未录入密钥时仍可用旧配置
|
||||
$envCfg = config('ai.' . $provider, []);
|
||||
if ($apiUrl === '') {
|
||||
$apiUrl = (string) ($envCfg['api_url'] ?? '');
|
||||
}
|
||||
if ($model === '') {
|
||||
$model = (string) ($envCfg['model'] ?? '');
|
||||
}
|
||||
if ($apiKey === '') {
|
||||
if ($provider === 'spark') {
|
||||
$apiKey = (string) ($envCfg['password'] ?? '');
|
||||
} else {
|
||||
$apiKey = (string) ($envCfg['api_key'] ?? '');
|
||||
}
|
||||
}
|
||||
$timeout = (float) ($envCfg['timeout'] ?? 60);
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'api_url' => $apiUrl,
|
||||
'api_key' => $apiKey,
|
||||
'model' => $model,
|
||||
'timeout' => $timeout,
|
||||
'platform_id' => $platformId,
|
||||
'api_key_id' => $keyId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 库内 AES 解密 api_key;非加密串原样返回(兼容过渡)
|
||||
* 委托 FieldEncryptService,与 nl_ai_api_key 密文格式一致
|
||||
*/
|
||||
public function decryptApiKey(string $cipher): string
|
||||
{
|
||||
return FieldEncryptService::getInstance()->decryptFromStorage($cipher, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 明文入库前 AES 加密
|
||||
* 委托 FieldEncryptService,写出 nl_ase_256_ 前缀密文
|
||||
*/
|
||||
public function encryptApiKey(string $plain): string
|
||||
{
|
||||
return FieldEncryptService::getInstance()->encryptForStorage($plain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统配置页:启用中的平台卡片 + 模型 + 密钥选项
|
||||
* 为什么组装成卡片结构:前端 C 端风格按平台展示,减少二次请求
|
||||
*/
|
||||
public function optionsForConfig(): array
|
||||
{
|
||||
$platforms = AiPlatformModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->toArray();
|
||||
$models = AiModelModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'platform_id', 'code', 'name', 'description', 'is_default'])
|
||||
->toArray();
|
||||
$keys = AiApiKeyModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'platform_id', 'name', 'is_default'])
|
||||
->toArray();
|
||||
$modelMap = [];
|
||||
foreach ($models as $m) {
|
||||
$pid = (int) $m['platform_id'];
|
||||
$modelMap[$pid][] = [
|
||||
'id' => (int) $m['id'],
|
||||
'label' => $m['name'] . '(' . $m['code'] . ')',
|
||||
'value' => (string) $m['code'],
|
||||
'code' => (string) $m['code'],
|
||||
'name' => (string) $m['name'],
|
||||
'description' => (string) ($m['description'] ?? ''),
|
||||
'is_default' => (int) ($m['is_default'] ?? 0),
|
||||
];
|
||||
}
|
||||
$keyMap = [];
|
||||
foreach ($keys as $k) {
|
||||
$pid = (int) $k['platform_id'];
|
||||
$name = trim((string) ($k['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = '密钥#' . (int) $k['id'];
|
||||
}
|
||||
$keyMap[$pid][] = [
|
||||
'id' => (int) $k['id'],
|
||||
'key_name' => $name,
|
||||
'name' => $name,
|
||||
'label' => $name,
|
||||
'value' => (int) $k['id'],
|
||||
'is_default' => (int) ($k['is_default'] ?? 0),
|
||||
];
|
||||
}
|
||||
$cards = [];
|
||||
foreach ($platforms as $p) {
|
||||
$id = (int) $p['id'];
|
||||
$platModels = $modelMap[$id] ?? [];
|
||||
$defaultModel = '';
|
||||
foreach ($platModels as $pm) {
|
||||
if ((int) $pm['is_default'] === 1) {
|
||||
$defaultModel = $pm['code'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($defaultModel === '' && !empty($platModels[0]['code'])) {
|
||||
$defaultModel = (string) $platModels[0]['code'];
|
||||
}
|
||||
$cards[] = [
|
||||
'id' => $id,
|
||||
'code' => $p['code'],
|
||||
'name' => $p['name'],
|
||||
'logo' => $p['logo'],
|
||||
'description' => $p['description'],
|
||||
'default_model' => $defaultModel,
|
||||
'api_url' => $p['api_url'],
|
||||
'models' => $platModels,
|
||||
'keys' => $keyMap[$id] ?? [],
|
||||
];
|
||||
}
|
||||
$sys = SystemConfigService::getInstance();
|
||||
return [
|
||||
'platforms' => $cards,
|
||||
'active' => [
|
||||
'provider' => (string) $sys->getValue(self::CFG_PROVIDER, config('ai.default_provider', 'deepseek')),
|
||||
'api_key_id' => (int) $sys->getValue(self::CFG_API_KEY_ID, '0'),
|
||||
'model' => (string) $sys->getValue(self::CFG_MODEL, ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认运行时组合(平台 / 模型 / 密钥)
|
||||
* 为什么校验平台存在:避免写入无效编码导致代码生成 AI 调用失败
|
||||
*/
|
||||
public function saveActiveConfig(string $provider, string $model, int $apiKeyId): array
|
||||
{
|
||||
$provider = strtolower(trim($provider));
|
||||
$model = trim($model);
|
||||
if ($provider === '') {
|
||||
$this->utils->errorThrow('请选择 AI 平台');
|
||||
}
|
||||
$platform = AiPlatformModel::where('code', $provider)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->first();
|
||||
if (!$platform) {
|
||||
$this->utils->errorThrow('AI 平台不存在或已禁用');
|
||||
}
|
||||
if ($model === '') {
|
||||
$this->utils->errorThrow('请选择模型');
|
||||
}
|
||||
$modelExists = AiModelModel::where('platform_id', (int) $platform->id)
|
||||
->where('code', $model)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->exists();
|
||||
if (!$modelExists) {
|
||||
$this->utils->errorThrow('模型不属于当前平台或已禁用');
|
||||
}
|
||||
if ($apiKeyId > 0) {
|
||||
$keyExists = AiApiKeyModel::where('id', $apiKeyId)
|
||||
->where('platform_id', (int) $platform->id)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->exists();
|
||||
if (!$keyExists) {
|
||||
$this->utils->errorThrow('密钥不属于当前平台或已禁用');
|
||||
}
|
||||
}
|
||||
$sys = SystemConfigService::getInstance();
|
||||
$sys->setValue(self::CFG_PROVIDER, $provider, '当前启用的 AI 平台编码');
|
||||
$sys->setValue(self::CFG_MODEL, $model, '当前启用的模型编码');
|
||||
$sys->setValue(self::CFG_API_KEY_ID, (string) $apiKeyId, '当前启用的 API 密钥 ID');
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'api_key_id' => $apiKeyId,
|
||||
];
|
||||
}
|
||||
}
|
||||
95
app/Service/common/ai/DeepSeekAiAgent.php
Normal file
95
app/Service/common/ai/DeepSeekAiAgent.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* DeepSeek Agent(OpenAI 兼容协议)
|
||||
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
|
||||
*/
|
||||
class DeepSeekAiAgent extends BaseService implements AiAgentInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Agent 本身不读用户态,避免工厂链路强制鉴权
|
||||
$this->isAuth = false;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function provider(): string
|
||||
{
|
||||
return 'deepseek';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 DeepSeek chat completions
|
||||
*/
|
||||
public function chatCompletions(array $messages, array $options = []): AiChatResult
|
||||
{
|
||||
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
|
||||
$apiUrl = (string) ($cfg['api_url'] ?? '');
|
||||
$apiKey = (string) ($cfg['api_key'] ?? '');
|
||||
$defaultModel = (string) ($cfg['model'] ?? 'deepseek-chat');
|
||||
$timeout = (float) ($cfg['timeout'] ?? 60);
|
||||
if ($apiKey === '') {
|
||||
$this->utils->errorThrow('DeepSeek 暂未开通(请在「AI密钥管理」或 .env DEEPSEEK_API_KEY 配置)');
|
||||
}
|
||||
if ($apiUrl === '') {
|
||||
$this->utils->errorThrow('未配置 DeepSeek 接口地址');
|
||||
}
|
||||
if (empty($messages)) {
|
||||
$this->utils->errorThrow('AI 对话消息不能为空');
|
||||
}
|
||||
$model = (string) ($options['model'] ?? $defaultModel);
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'messages' => array_values($messages),
|
||||
'stream' => false,
|
||||
];
|
||||
foreach (['temperature', 'max_tokens', 'top_p'] as $key) {
|
||||
if (array_key_exists($key, $options)) {
|
||||
$body[$key] = $options[$key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => $timeout,
|
||||
'connect_timeout' => min(10.0, $timeout),
|
||||
]);
|
||||
$response = $client->request('POST', $apiUrl, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $body,
|
||||
]);
|
||||
$raw = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($raw)) {
|
||||
$this->utils->errorThrow('DeepSeek 返回非 JSON');
|
||||
}
|
||||
$content = (string) data_get($raw, 'choices.0.message.content', '');
|
||||
if ($content === '') {
|
||||
$errMsg = (string) ($raw['error']['message'] ?? '');
|
||||
$this->utils->errorThrow($errMsg !== '' ? ('DeepSeek 调用失败:' . $errMsg) : 'DeepSeek 未返回有效内容');
|
||||
}
|
||||
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
|
||||
return new AiChatResult(
|
||||
$content,
|
||||
$raw,
|
||||
$this->provider(),
|
||||
$model,
|
||||
$usage,
|
||||
(int) ($cfg['api_key_id'] ?? 0)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($e instanceof GuzzleException) {
|
||||
$this->utils->errorThrow('DeepSeek 请求失败:' . $e->getMessage());
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/Service/common/ai/SparkAiAgent.php
Normal file
100
app/Service/common/ai/SparkAiAgent.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\ai;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 讯飞星火 OpenAPI Agent(OpenAI 兼容 /v1/chat/completions)
|
||||
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
|
||||
*/
|
||||
class SparkAiAgent extends BaseService implements AiAgentInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Agent 本身不读用户态,避免工厂链路强制鉴权
|
||||
$this->isAuth = false;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function provider(): string
|
||||
{
|
||||
return 'spark';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用星火 chat completions
|
||||
* Authorization 使用 Bearer + API Key(与官方 OpenAPI 一致)
|
||||
*/
|
||||
public function chatCompletions(array $messages, array $options = []): AiChatResult
|
||||
{
|
||||
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
|
||||
$apiUrl = (string) ($cfg['api_url'] ?? '');
|
||||
$password = (string) ($cfg['api_key'] ?? '');
|
||||
$defaultModel = (string) ($cfg['model'] ?? 'lite');
|
||||
$timeout = (float) ($cfg['timeout'] ?? 60);
|
||||
if ($password === '') {
|
||||
$this->utils->errorThrow('未配置讯飞星火 API 密钥(请在「AI密钥管理」或 .env SPARK_API_PASSWORD 配置)');
|
||||
}
|
||||
if ($apiUrl === '') {
|
||||
$this->utils->errorThrow('未配置讯飞星火接口地址');
|
||||
}
|
||||
if (empty($messages)) {
|
||||
$this->utils->errorThrow('AI 对话消息不能为空');
|
||||
}
|
||||
$model = (string) ($options['model'] ?? $defaultModel);
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'messages' => array_values($messages),
|
||||
];
|
||||
// 可选参数透传,便于业务微调温度等
|
||||
foreach (['temperature', 'max_tokens', 'top_p', 'stream'] as $key) {
|
||||
if (array_key_exists($key, $options)) {
|
||||
$body[$key] = $options[$key];
|
||||
}
|
||||
}
|
||||
// 默认关闭流式,业务侧解析整包 JSON 更简单
|
||||
if (!isset($body['stream'])) {
|
||||
$body['stream'] = false;
|
||||
}
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => $timeout,
|
||||
'connect_timeout' => min(10.0, $timeout),
|
||||
]);
|
||||
$response = $client->request('POST', $apiUrl, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $password,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $body,
|
||||
]);
|
||||
$raw = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($raw)) {
|
||||
$this->utils->errorThrow('讯飞星火返回非 JSON');
|
||||
}
|
||||
$content = (string) data_get($raw, 'choices.0.message.content', '');
|
||||
if ($content === '') {
|
||||
$errMsg = (string) ($raw['error']['message'] ?? $raw['message'] ?? '');
|
||||
$this->utils->errorThrow($errMsg !== '' ? ('讯飞星火调用失败:' . $errMsg) : '讯飞星火未返回有效内容');
|
||||
}
|
||||
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
|
||||
return new AiChatResult(
|
||||
$content,
|
||||
$raw,
|
||||
$this->provider(),
|
||||
$model,
|
||||
$usage,
|
||||
(int) ($cfg['api_key_id'] ?? 0)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($e instanceof GuzzleException) {
|
||||
$this->utils->errorThrow('讯飞星火请求失败:' . $e->getMessage());
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user