AI代码生成工具

This commit is contained in:
李琦
2026-08-10 15:51:00 +08:00
parent d0018a0173
commit bd226c260f
31 changed files with 5468 additions and 31 deletions

View 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);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
/**
* AI Agent 工厂按系统配置nl_system_config + 平台表)选择供应商
* 未配置时回落 envconfig/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();
}
}

View 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;
}

View 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,
];
}
}

View 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;
}
}
}

View 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 内容导致的医疗差错,由操作医师及医疗机构承担责任。";
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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-adminVben Admin + Laravel 三层 CRUD 代码生成器)的需求分析助手。
任务:写出一段可直接填入「功能描述」输入框的中文文案,供下一步 AI 生成表结构与 CRUD 代码使用。
必须贴合本系统:
1. 面向后台管理 CRUD列表、搜索、新增/编辑、软删除、启停。
2. 不要要求 id / status / created_at / updated_at / deleted_at系统自动加
3. 说明要管理哪些业务数据、建议字段(用中文含义即可)、哪些适合检索、哪些适合下拉/上传等。
4. 不要写接口、鉴权、微服务、路由实现细节。
5. 200500 字为宜,条理清晰,可用分点,但不要标题堆砌。
输出要求(非常重要):
- 只输出中文正文文案本身。
- 禁止输出 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;
}
}

View 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 运行时配置:从系统配置 + 平台/模型/密钥表解析当前要用的供应商与明文密钥
* 优先数据库配置;未配置时回落 envconfig/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,
];
}
}

View 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 AgentOpenAI 兼容协议)
* 每次调用从 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;
}
}
}

View 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 AgentOpenAI 兼容 /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;
}
}
}