Files
nl-admin-api/app/Service/common/ai/AiCodeGenService.php
2026-08-10 15:51:00 +08:00

262 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}