262 lines
9.9 KiB
PHP
262 lines
9.9 KiB
PHP
|
|
<?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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|