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

262 lines
9.9 KiB
PHP
Raw Normal View History

2026-08-10 15:51:00 +08:00
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use 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;
}
}
}