AI代码生成工具
This commit is contained in:
@@ -79,7 +79,12 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
BASE_OLD_API_URL=http://www.xk888.com
|
||||
BASE_OLD_SHOP_API_URL=https://shop.xiaokang88.com
|
||||
|
||||
# 密钥
|
||||
# 密钥(库内敏感字段 AES,前缀 nl_ase_256_)
|
||||
ENCRYPT_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a
|
||||
# JWT HS256 密钥(至少 32 字符;生产环境务必改成随机长串)
|
||||
JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b
|
||||
|
||||
# AI 供应商环境回落(库内未配置密钥时可用)
|
||||
AI_DEFAULT_PROVIDER=deepseek
|
||||
SPARK_API_PASSWORD=
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
128
app/Core/DatabaseEncryptor.php
Normal file
128
app/Core/DatabaseEncryptor.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 库内敏感字段 AES-256-CBC 加解密
|
||||
* 密文格式:nl_ase_256_ + base64(iv + ciphertext)
|
||||
* 密钥派生:hash('sha256', ENCRYPT_KEY, true) → 32 字节
|
||||
* 读取时兼容历史前缀 ***(迁移期)
|
||||
*/
|
||||
class DatabaseEncryptor
|
||||
{
|
||||
/** 新密文前缀(入库统一用这个) */
|
||||
public const PREFIX = 'nl_ase_256_';
|
||||
|
||||
/** 历史密文前缀(仅解密兼容) */
|
||||
public const LEGACY_PREFIX = '***';
|
||||
|
||||
private string $cipherMethod = 'AES-256-CBC';
|
||||
|
||||
private string $secretKey;
|
||||
|
||||
private int $ivLength;
|
||||
|
||||
/**
|
||||
* @param string $key 来自 config('nl.encrypt_key') / env ENCRYPT_KEY
|
||||
*/
|
||||
public function __construct(string $key)
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
throw new RuntimeException('AES 加密密钥未配置(请设置 ENCRYPT_KEY)');
|
||||
}
|
||||
$this->secretKey = hash('sha256', $key, true);
|
||||
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
|
||||
if ($ivLength === false) {
|
||||
throw new RuntimeException('不支持的加密算法:' . $this->cipherMethod);
|
||||
}
|
||||
$this->ivLength = $ivLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密后入库;空串不加密,已是密文则原样返回避免二次加密
|
||||
*
|
||||
* @param string|null $data 明文
|
||||
* @throws Exception
|
||||
*/
|
||||
public function encrypt($data): string
|
||||
{
|
||||
if ($data === null || $data === '') {
|
||||
return '';
|
||||
}
|
||||
$data = (string) $data;
|
||||
if (self::isEncrypted($data)) {
|
||||
return $data;
|
||||
}
|
||||
$iv = openssl_random_pseudo_bytes($this->ivLength);
|
||||
if ($iv === false) {
|
||||
throw new RuntimeException('生成 IV 失败');
|
||||
}
|
||||
$encryptedData = openssl_encrypt($data, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($encryptedData === false) {
|
||||
throw new RuntimeException('加密失败:' . (openssl_error_string() ?: 'unknown'));
|
||||
}
|
||||
return self::PREFIX . base64_encode($iv . $encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从库内密文解密;无已知前缀视为明文原样返回
|
||||
*
|
||||
* @param string|null $base64EncodedEncryptedData 库内值
|
||||
* @throws Exception
|
||||
*/
|
||||
public function decrypt($base64EncodedEncryptedData): string
|
||||
{
|
||||
if ($base64EncodedEncryptedData === null || $base64EncodedEncryptedData === '') {
|
||||
return '';
|
||||
}
|
||||
$base64EncodedEncryptedData = (string) $base64EncodedEncryptedData;
|
||||
$prefix = self::detectPrefix($base64EncodedEncryptedData);
|
||||
if ($prefix === null) {
|
||||
return $base64EncodedEncryptedData;
|
||||
}
|
||||
$payload = substr($base64EncodedEncryptedData, strlen($prefix));
|
||||
$decodedData = base64_decode($payload, true);
|
||||
if ($decodedData === false) {
|
||||
throw new RuntimeException('Base64 解码失败');
|
||||
}
|
||||
if (strlen($decodedData) <= $this->ivLength) {
|
||||
throw new RuntimeException('密文长度非法');
|
||||
}
|
||||
$iv = substr($decodedData, 0, $this->ivLength);
|
||||
$encryptedData = substr($decodedData, $this->ivLength);
|
||||
$decryptedData = openssl_decrypt($encryptedData, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($decryptedData === false) {
|
||||
throw new RuntimeException('解密失败:' . (openssl_error_string() ?: 'unknown'));
|
||||
}
|
||||
return $decryptedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已是库内密文(含历史前缀)
|
||||
*/
|
||||
public static function isEncrypted(?string $value): bool
|
||||
{
|
||||
return self::detectPrefix($value) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别密文前缀;新前缀优先
|
||||
*/
|
||||
public static function detectPrefix(?string $value): ?string
|
||||
{
|
||||
if (!is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (str_starts_with($value, self::PREFIX)) {
|
||||
return self::PREFIX;
|
||||
}
|
||||
if (str_starts_with($value, self::LEGACY_PREFIX)) {
|
||||
return self::LEGACY_PREFIX;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
22
app/Enum/AiGenerationSceneEnum.php
Normal file
22
app/Enum/AiGenerationSceneEnum.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* AI 生成场景(本项目可扩展,勿塞入其他业务域专有场景)
|
||||
*/
|
||||
enum AiGenerationSceneEnum: string
|
||||
{
|
||||
/** 根据描述直接生成代码结构 JSON */
|
||||
case CODE_GENERATION = 'code_generation';
|
||||
/** 根据模块名生成/优化需求概览与代码生成提示词 */
|
||||
case REQUIREMENT_PROMPT = 'requirement_prompt';
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CODE_GENERATION => '代码生成',
|
||||
self::REQUIREMENT_PROMPT => '需求概览/提示词',
|
||||
};
|
||||
}
|
||||
}
|
||||
22
app/Enum/AiGenerationStatusEnum.php
Normal file
22
app/Enum/AiGenerationStatusEnum.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* AI 生成记录状态
|
||||
*/
|
||||
enum AiGenerationStatusEnum: int
|
||||
{
|
||||
case RUNNING = 0;
|
||||
case SUCCESS = 1;
|
||||
case FAIL = 2;
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::RUNNING => '进行中',
|
||||
self::SUCCESS => '成功',
|
||||
self::FAIL => '失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
168
app/Http/Controllers/Api/AiConfigController.php
Normal file
168
app/Http/Controllers/Api/AiConfigController.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\AiConfigService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* AI 配置控制器:运行时选用 + 密钥管理
|
||||
*/
|
||||
class AiConfigController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = AiConfigService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用基类通用列表(本模块用 keyList)
|
||||
* @Method NO
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Method NO
|
||||
*/
|
||||
public function option(): mixed
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Method NO
|
||||
*/
|
||||
public function detail(): JsonResponse
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Method NO
|
||||
*/
|
||||
public function create(): JsonResponse
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Method NO
|
||||
*/
|
||||
public function update(): JsonResponse
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Method NO
|
||||
*/
|
||||
public function delete(): JsonResponse
|
||||
{
|
||||
return jerr('不支持');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 管理页选项(平台/模型/密钥卡片数据 + 当前激活项)
|
||||
* @Method GET
|
||||
*/
|
||||
public function runtimeOptions(): JsonResponse
|
||||
{
|
||||
return jok($this->service->getRuntimeOptions(), '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认 AI 平台/模型/密钥组合
|
||||
* @Method POST
|
||||
*/
|
||||
public function saveRuntime(): JsonResponse
|
||||
{
|
||||
$params = request()->post();
|
||||
return jok($this->service->saveRuntime($params), '保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥列表(卡片展示)
|
||||
* @Method GET
|
||||
*/
|
||||
public function keyList(): JsonResponse
|
||||
{
|
||||
return jok($this->service->listKeys(), '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台下拉
|
||||
* @Method GET
|
||||
*/
|
||||
public function platformOption(): JsonResponse
|
||||
{
|
||||
return jok($this->service->platformOptions(), '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增密钥
|
||||
* @Method POST
|
||||
*/
|
||||
public function createKey(): JsonResponse
|
||||
{
|
||||
$params = request()->post();
|
||||
return jok($this->service->createKey($params), '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新密钥
|
||||
* @Method POST
|
||||
*/
|
||||
public function updateKey(): JsonResponse
|
||||
{
|
||||
$params = request()->post();
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return jerr('参数错误');
|
||||
}
|
||||
unset($params['id']);
|
||||
return jok($this->service->updateKey($id, $params), '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除密钥
|
||||
* @Method POST
|
||||
*/
|
||||
public function deleteKey(): JsonResponse
|
||||
{
|
||||
$ids = request()->post('ids');
|
||||
if (empty($ids)) {
|
||||
return jerr('参数错误');
|
||||
}
|
||||
if (!is_array($ids)) {
|
||||
$ids = [$ids];
|
||||
}
|
||||
return jok($this->service->deleteKeys($ids), '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史列表
|
||||
* @Method GET
|
||||
*/
|
||||
public function generationList(): JsonResponse
|
||||
{
|
||||
return jok($this->service->generationList(), '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史详情
|
||||
* @Method GET
|
||||
*/
|
||||
public function generationDetail(): JsonResponse
|
||||
{
|
||||
$id = (int) request()->get('id', 0);
|
||||
if ($id <= 0) {
|
||||
return jerr('参数错误');
|
||||
}
|
||||
return jok($this->service->generationDetail($id), '获取成功');
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Http\Controllers\core;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\common\ai\AiCodeGenService;
|
||||
use App\Service\common\ai\AiRequirementPromptService;
|
||||
use App\Service\core\CodeGenerationService;
|
||||
use App\Service\LoginService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CodeGenerationController extends BaseController
|
||||
@@ -156,8 +157,34 @@ class CodeGenerationController extends BaseController
|
||||
}
|
||||
|
||||
$codeGenData = $this->service->convertTableToCodeGenData($tableName, $className, $classComment, $icon, $sort, $pid);
|
||||
|
||||
|
||||
return jok($codeGenData);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 提示词生成代码结构(调用工厂 Agent,返回可灌入表单的 JSON)
|
||||
* @Method POST
|
||||
*/
|
||||
public function aiGenerate(): JsonResponse
|
||||
{
|
||||
$params = request()->post();
|
||||
return jok(
|
||||
AiCodeGenService::getInstance()->generate($params),
|
||||
'AI 生成成功'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模块名生成 / 优化需求概览与代码生成提示词(写入生成历史,供前端替换导入)
|
||||
* @Method POST
|
||||
*/
|
||||
public function aiRequirementPrompt(): JsonResponse
|
||||
{
|
||||
$params = request()->post();
|
||||
return jok(
|
||||
AiRequirementPromptService::getInstance()->generate($params),
|
||||
'需求概览生成成功'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
15
app/Models/SystemConfigModel.php
Normal file
15
app/Models/SystemConfigModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* 系统配置表模型(kv 存储)
|
||||
*/
|
||||
class SystemConfigModel extends BaseModel
|
||||
{
|
||||
protected $table = 'system_config';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/ai/AiApiKeyModel.php
Normal file
15
app/Models/ai/AiApiKeyModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ai;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* AI API 密钥表模型(仅表名与字段约定,查询逻辑放 Service)
|
||||
*/
|
||||
class AiApiKeyModel extends BaseModel
|
||||
{
|
||||
protected $table = 'ai_api_key';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/ai/AiGenerationModel.php
Normal file
15
app/Models/ai/AiGenerationModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ai;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* AI 生成历史表模型(仅表名与字段约定)
|
||||
*/
|
||||
class AiGenerationModel extends BaseModel
|
||||
{
|
||||
protected $table = 'ai_generation';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/ai/AiModelModel.php
Normal file
15
app/Models/ai/AiModelModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ai;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* AI 模型表模型(仅表名与字段约定,查询逻辑放 Service)
|
||||
*/
|
||||
class AiModelModel extends BaseModel
|
||||
{
|
||||
protected $table = 'ai_model';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/ai/AiPlatformModel.php
Normal file
15
app/Models/ai/AiPlatformModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ai;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* AI 平台表模型(仅表名与字段约定,查询逻辑放 Service)
|
||||
*/
|
||||
class AiPlatformModel extends BaseModel
|
||||
{
|
||||
protected $table = 'ai_platform';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
227
app/Service/AiConfigService.php
Normal file
227
app/Service/AiConfigService.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\ai\AiApiKeyModel;
|
||||
use App\Models\ai\AiPlatformModel;
|
||||
use App\Service\common\ai\AiGenerationLogService;
|
||||
use App\Service\common\ai\AiRuntimeConfigService;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
|
||||
/**
|
||||
* AI 配置管理 Service:密钥 CRUD + 运行时选项读写
|
||||
* 平台/模型以种子数据为主,密钥需后台录入后才能真正调用
|
||||
*/
|
||||
class AiConfigService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = AiApiKeyModel::class;
|
||||
$this->selectField = ['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'created_at', 'updated_at'];
|
||||
$this->queryField = [
|
||||
'platform_id' => '=',
|
||||
'name' => 'like',
|
||||
'status' => '=',
|
||||
];
|
||||
$this->orderBy = [
|
||||
'name' => 'sort',
|
||||
'sort' => 'desc',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 管理页选项(平台卡片 + 当前激活组合)
|
||||
*/
|
||||
public function getRuntimeOptions(): array
|
||||
{
|
||||
return AiRuntimeConfigService::getInstance()->optionsForConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认平台/模型/密钥
|
||||
*/
|
||||
public function saveRuntime(array $params): array
|
||||
{
|
||||
$provider = (string) ($params['provider'] ?? '');
|
||||
$model = (string) ($params['model'] ?? '');
|
||||
$apiKeyId = (int) ($params['api_key_id'] ?? 0);
|
||||
return AiRuntimeConfigService::getInstance()->saveActiveConfig($provider, $model, $apiKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥列表(卡片展示用,不返回明文密钥)
|
||||
* 直接查表,避免单例 where 被 update 污染导致列表为空
|
||||
*/
|
||||
public function listKeys(): array
|
||||
{
|
||||
$items = AiApiKeyModel::where('deleted_at', 0)
|
||||
->orderByDesc('sort')
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'api_key', 'created_at', 'updated_at'])
|
||||
->toArray();
|
||||
$platforms = AiPlatformModel::where('deleted_at', 0)
|
||||
->get(['id', 'code', 'name', 'logo'])
|
||||
->keyBy('id')
|
||||
->toArray();
|
||||
foreach ($items as &$item) {
|
||||
$pid = (int) ($item['platform_id'] ?? 0);
|
||||
$item['platform'] = $platforms[$pid] ?? null;
|
||||
$item['has_key'] = trim((string) ($item['api_key'] ?? '')) !== '';
|
||||
unset($item['api_key']);
|
||||
}
|
||||
unset($item);
|
||||
return [
|
||||
'page' => 1,
|
||||
'size' => count($items),
|
||||
'page_count' => 1,
|
||||
'total' => count($items),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台下拉(密钥表单用)
|
||||
*/
|
||||
public function platformOptions(): array
|
||||
{
|
||||
return AiPlatformModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderByDesc('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'code', 'name', 'logo'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增密钥(明文入库前加密)
|
||||
*/
|
||||
public function createKey(array $params): mixed
|
||||
{
|
||||
$platformId = (int) ($params['platform_id'] ?? 0);
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
$apiKey = trim((string) ($params['api_key'] ?? ''));
|
||||
if ($platformId <= 0) {
|
||||
$this->utils->errorThrow('请选择所属平台');
|
||||
}
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('请填写密钥别名');
|
||||
}
|
||||
if ($apiKey === '') {
|
||||
$this->utils->errorThrow('请填写 API Key');
|
||||
}
|
||||
$platform = AiPlatformModel::where('id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (!$platform) {
|
||||
$this->utils->errorThrow('平台不存在');
|
||||
}
|
||||
$isDefault = (int) ($params['is_default'] ?? 0);
|
||||
if ($isDefault === 1) {
|
||||
// 同平台只保留一个默认密钥
|
||||
AiApiKeyModel::where('platform_id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->update(['is_default' => 0, 'updated_at' => time()]);
|
||||
}
|
||||
return $this->insert([
|
||||
'platform_id' => $platformId,
|
||||
'name' => $name,
|
||||
'api_key' => FieldEncryptService::getInstance()->encryptForStorage($apiKey),
|
||||
'remark' => trim((string) ($params['remark'] ?? '')),
|
||||
'is_default' => $isDefault,
|
||||
'status' => (int) ($params['status'] ?? 1),
|
||||
'sort' => (int) ($params['sort'] ?? 0),
|
||||
'created_at' => time(),
|
||||
'updated_at' => 0,
|
||||
'deleted_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新密钥;api_key 为空表示不改密钥本身
|
||||
*/
|
||||
public function updateKey(int $id, array $params): mixed
|
||||
{
|
||||
$info = AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (!$info) {
|
||||
$this->utils->notFound('密钥不存在');
|
||||
}
|
||||
$data = [];
|
||||
if (array_key_exists('name', $params)) {
|
||||
$name = trim((string) $params['name']);
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('密钥别名不能为空');
|
||||
}
|
||||
$data['name'] = $name;
|
||||
}
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$data['remark'] = trim((string) $params['remark']);
|
||||
}
|
||||
if (array_key_exists('status', $params)) {
|
||||
$data['status'] = (int) $params['status'];
|
||||
}
|
||||
if (array_key_exists('sort', $params)) {
|
||||
$data['sort'] = (int) $params['sort'];
|
||||
}
|
||||
if (array_key_exists('platform_id', $params)) {
|
||||
$platformId = (int) $params['platform_id'];
|
||||
if ($platformId <= 0) {
|
||||
$this->utils->errorThrow('请选择所属平台');
|
||||
}
|
||||
$data['platform_id'] = $platformId;
|
||||
}
|
||||
$platformId = (int) ($data['platform_id'] ?? $info->platform_id);
|
||||
if (array_key_exists('is_default', $params)) {
|
||||
$isDefault = (int) $params['is_default'];
|
||||
$data['is_default'] = $isDefault;
|
||||
if ($isDefault === 1) {
|
||||
AiApiKeyModel::where('platform_id', $platformId)
|
||||
->where('deleted_at', 0)
|
||||
->where('id', '<>', $id)
|
||||
->update(['is_default' => 0, 'updated_at' => time()]);
|
||||
}
|
||||
}
|
||||
$apiKey = trim((string) ($params['api_key'] ?? ''));
|
||||
if ($apiKey !== '') {
|
||||
$data['api_key'] = FieldEncryptService::getInstance()->encryptForStorage($apiKey);
|
||||
}
|
||||
if (empty($data)) {
|
||||
return true;
|
||||
}
|
||||
$data['updated_at'] = time();
|
||||
// 直接 update,避免 BaseService::save 污染单例 where 导致后续列表为空
|
||||
return AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除密钥
|
||||
*/
|
||||
public function deleteKeys(array $ids): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
return AiApiKeyModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
|
||||
'deleted_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史列表
|
||||
*/
|
||||
public function generationList(): array
|
||||
{
|
||||
return AiGenerationLogService::getInstance()->getPageListForAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史详情
|
||||
*/
|
||||
public function generationDetail(int $id): array
|
||||
{
|
||||
return AiGenerationLogService::getInstance()->getDetailForAdmin($id);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 角色服务类
|
||||
* 菜单服务:列表树、搜索(含子菜单)、下拉
|
||||
*/
|
||||
class MenuService extends BaseService
|
||||
{
|
||||
@@ -29,29 +29,122 @@ class MenuService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
* 获取菜单列表(扁平树,前端 transform)
|
||||
* 有 title 搜索时:命中节点 + 祖先 + 子孙,保证子菜单可搜到并完整展示路径
|
||||
* 无搜索时:仍按根菜单分页,再挂两级子节点
|
||||
*
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
// /order/product-order
|
||||
$title = trim((string) request()->get('title', ''));
|
||||
if ($title !== '') {
|
||||
return $this->listByTitleSearch($title);
|
||||
}
|
||||
|
||||
$this->where[] = ['pid', '=', 0];
|
||||
$list = $this->getPageList();
|
||||
$ids = array_column($list['items'], 'id');
|
||||
$items = $this->model::whereIn('pid', $ids)->get($this->selectField);
|
||||
if (empty($ids)) {
|
||||
return $list;
|
||||
}
|
||||
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $ids)->orderBy('sort')->get($this->selectField);
|
||||
$childrenIds = [];
|
||||
foreach ($items as $v) {
|
||||
$list['items'][] = $v;
|
||||
$childrenIds[] = $v['id'];
|
||||
}
|
||||
$items = $this->model::whereIn('pid', $childrenIds)->get($this->selectField);
|
||||
foreach ($items as $v) {
|
||||
$list['items'][] = $v;
|
||||
if (!empty($childrenIds)) {
|
||||
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $childrenIds)->orderBy('sort')->get($this->selectField);
|
||||
foreach ($items as $v) {
|
||||
$list['items'][] = $v;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标题模糊搜索:返回命中项及其祖先、子孙,供树表正确拼装
|
||||
* 为什么要带祖先:仅返回子节点时 pid 找不到父级,树会丢节点或挂到根外
|
||||
*/
|
||||
private function listByTitleSearch(string $title): array
|
||||
{
|
||||
$all = $this->model::where('deleted_at', 0)
|
||||
->orderBy('sort')
|
||||
->get($this->selectField)
|
||||
->keyBy('id');
|
||||
if ($all->isEmpty()) {
|
||||
return $this->emptyPageList();
|
||||
}
|
||||
|
||||
$matchedIds = [];
|
||||
foreach ($all as $id => $row) {
|
||||
$rowTitle = (string) ($row['title'] ?? '');
|
||||
if ($rowTitle !== '' && mb_stripos($rowTitle, $title) !== false) {
|
||||
$matchedIds[] = (int) $id;
|
||||
}
|
||||
}
|
||||
if (empty($matchedIds)) {
|
||||
return $this->emptyPageList();
|
||||
}
|
||||
|
||||
// 子节点按 pid 分组,便于向下展开子孙
|
||||
$childrenMap = [];
|
||||
foreach ($all as $id => $row) {
|
||||
$pid = (int) ($row['pid'] ?? 0);
|
||||
$childrenMap[$pid][] = (int) $id;
|
||||
}
|
||||
|
||||
$needIds = [];
|
||||
foreach ($matchedIds as $mid) {
|
||||
// 向上:自身 + 祖先
|
||||
$cur = $mid;
|
||||
while ($cur > 0 && $all->has($cur) && !isset($needIds[$cur])) {
|
||||
$needIds[$cur] = true;
|
||||
$cur = (int) ($all[$cur]['pid'] ?? 0);
|
||||
}
|
||||
// 向下:命中节点的全部子孙(搜父级时仍能看到子树)
|
||||
$stack = [$mid];
|
||||
while (!empty($stack)) {
|
||||
$nodeId = array_pop($stack);
|
||||
foreach ($childrenMap[$nodeId] ?? [] as $cid) {
|
||||
if (!isset($needIds[$cid])) {
|
||||
$needIds[$cid] = true;
|
||||
$stack[] = $cid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($all as $id => $row) {
|
||||
if (isset($needIds[(int) $id])) {
|
||||
$items[] = $row->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'page' => 1,
|
||||
'size' => count($items),
|
||||
'total' => count($items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 空分页结构(与 getPageList 对齐)
|
||||
*/
|
||||
private function emptyPageList(): array
|
||||
{
|
||||
return [
|
||||
'items' => [],
|
||||
'page' => 1,
|
||||
'size' => 0,
|
||||
'total' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取树形结构的菜单下拉
|
||||
* @param bool $isSelect
|
||||
@@ -119,7 +212,6 @@ class MenuService extends BaseService
|
||||
* @param $id
|
||||
* @param $params
|
||||
* @return mixed
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
@@ -131,15 +223,11 @@ class MenuService extends BaseService
|
||||
|
||||
/**
|
||||
* 删除数据
|
||||
* @param $id
|
||||
* @return mixed|true
|
||||
* @throws Exception
|
||||
* @param $ids
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
if (in_array($id, [1, 2])) {
|
||||
$this->utils->errorThrow('管理员角色禁止删除');
|
||||
}
|
||||
return $this->del($id);
|
||||
return $this->del($ids);
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Service/SystemConfigService.php
Normal file
92
app/Service/SystemConfigService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\SystemConfigModel;
|
||||
|
||||
/**
|
||||
* 系统配置 Service:按 key 读写 kv,供 AI 运行时等模块复用
|
||||
*/
|
||||
class SystemConfigService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// 配置读写不依赖登录态(工厂解析时可能被其他 Service 调用)
|
||||
$this->isAuth = false;
|
||||
parent::__construct();
|
||||
$this->model = SystemConfigModel::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置值;不存在时返回默认值
|
||||
* 为什么:业务侧只关心 value,不关心行结构
|
||||
*/
|
||||
public function getValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
return $default;
|
||||
}
|
||||
$row = SystemConfigModel::where('key', $key)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return $default;
|
||||
}
|
||||
$value = $row->value;
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入/更新配置;存在则更新,不存在则插入
|
||||
* 为什么用 upsert:配置项数量少,按 key 幂等更稳
|
||||
*/
|
||||
public function setValue(string $key, mixed $value, string $remark = ''): bool
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
$this->utils->errorThrow('配置键不能为空');
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$value = (string) $value;
|
||||
$now = time();
|
||||
$row = SystemConfigModel::where('key', $key)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if ($row) {
|
||||
$row->value = $value;
|
||||
if ($remark !== '') {
|
||||
$row->remark = $remark;
|
||||
}
|
||||
$row->updated_at = $now;
|
||||
return (bool) $row->save();
|
||||
}
|
||||
return (bool) SystemConfigModel::insert([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'remark' => $remark,
|
||||
'created_at' => $now,
|
||||
'updated_at' => 0,
|
||||
'deleted_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量写入配置
|
||||
*
|
||||
* @param array<string, mixed> $pairs key => value
|
||||
*/
|
||||
public function setValues(array $pairs): bool
|
||||
{
|
||||
foreach ($pairs as $key => $value) {
|
||||
$this->setValue((string) $key, $value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,12 +312,13 @@ class CodeGenerationService
|
||||
$v['default'] = (strpos($v['type'], 'INT') || in_array($v['type'], self::INT_TYPE)) ? 'DEFAULT ' . 0 : 'DEFAULT ' . '\'\'';
|
||||
}
|
||||
|
||||
// 字段名加反引号,避免非法标识符直接破坏 CREATE TABLE 语法
|
||||
$col = '`' . str_replace('`', '', (string) $v['name']) . '`';
|
||||
$comment = str_replace("'", "\\'", (string) $v['comment']);
|
||||
if (in_array($v['type'], self::TEXT_TYPE)) {
|
||||
// 编写sql语句
|
||||
$sql .= " {$v['name']} {$v['type']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
|
||||
$sql .= " {$col} {$v['type']} {$v['not_null']} NULL COMMENT '{$comment}', ";
|
||||
} else {
|
||||
// 编写sql语句
|
||||
$sql .= " {$v['name']} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
|
||||
$sql .= " {$col} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$comment}', ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
config/ai.php
Normal file
20
config/ai.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AI 供应商环境回落配置(库内未配置密钥/地址时使用)
|
||||
*/
|
||||
return [
|
||||
'default_provider' => env('AI_DEFAULT_PROVIDER', 'deepseek'),
|
||||
'spark' => [
|
||||
'api_url' => env('SPARK_API_URL', 'https://spark-api-open.xf-yun.com/v1/chat/completions'),
|
||||
'password' => env('SPARK_API_PASSWORD', ''),
|
||||
'model' => env('SPARK_API_MODEL', 'lite'),
|
||||
'timeout' => (float) env('SPARK_API_TIMEOUT', 60),
|
||||
],
|
||||
'deepseek' => [
|
||||
'api_url' => env('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'),
|
||||
'api_key' => env('DEEPSEEK_API_KEY', ''),
|
||||
'model' => env('DEEPSEEK_API_MODEL', 'deepseek-chat'),
|
||||
'timeout' => (float) env('DEEPSEEK_API_TIMEOUT', 60),
|
||||
],
|
||||
];
|
||||
@@ -397,7 +397,7 @@ if(!function_exists('ase_encode')) {
|
||||
function ase_encode($str)
|
||||
{
|
||||
if (empty($str)) return $str;
|
||||
return base64_encode(\Illuminate\Support\Str::random(config('ase.len')). base64_encode($str));
|
||||
return base64_encode(\Illuminate\Support\Str::random(config('nl.ase_len', 30)). base64_encode($str));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ if (!function_exists('ase_decode')) {
|
||||
{
|
||||
if (empty($str)) return $str?? '';
|
||||
$result = base64_decode($str);
|
||||
$result = base64_decode(substr($result, config('ase.len')));
|
||||
$result = base64_decode(substr($result, config('nl.ase_len', 30)));
|
||||
$pattern = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]|[\x{E000}-\x{F8FF}]/u';
|
||||
if (preg_match($pattern, $result)) return $str;
|
||||
return $result;
|
||||
|
||||
@@ -24,17 +24,26 @@ return [
|
||||
'jwt' => [
|
||||
'secret' => env('JWT_SECRET', 'nl_admin_jwt_secret_key_change_me_32b'),
|
||||
],
|
||||
/*
|
||||
* 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_,读取兼容历史 ***)
|
||||
* 对应 .env:ENCRYPT_KEY
|
||||
*/
|
||||
'encrypt_key' => env('ENCRYPT_KEY', ''),
|
||||
/*
|
||||
* 传输层 ase_encode / ase_decode 随机盐长度(与历史 helpers 兼容)
|
||||
*/
|
||||
'ase_len' => (int) env('ASE_LEN', 30),
|
||||
/*
|
||||
* redis配置
|
||||
*/
|
||||
'redis' => [
|
||||
'jwt' => 'xk_login_',
|
||||
'jwt' => 'nl_login_',
|
||||
'jwt_ttl' => 360000,
|
||||
// 'jwt_ttl' => 360,
|
||||
'email' => 'xk_email_',
|
||||
'phone' => 'xk_phone_',
|
||||
'login_out_key' => 'xk_login_out_',
|
||||
'menu_key' => 'xk_menu_',
|
||||
'email' => 'nl_email_',
|
||||
'phone' => 'nl_phone_',
|
||||
'login_out_key' => 'nl_login_out_',
|
||||
'menu_key' => 'nl_menu_',
|
||||
],
|
||||
/*
|
||||
* api白名单
|
||||
@@ -49,7 +58,7 @@ return [
|
||||
* 阿里云oss配置
|
||||
*/
|
||||
'oss' => [
|
||||
'redis_key' => 'xk_oss_url_',
|
||||
'redis_key' => 'nl_oss_url_',
|
||||
'ali' => [
|
||||
'accessKeyId' => env('OSS_ACCESS_KEY_ID'),
|
||||
'accessKeySecret' => env('OSS_ACCESS_KEY_SECRET'),
|
||||
|
||||
@@ -33,6 +33,7 @@ Route::group([], function () {
|
||||
'menu' => \App\Http\Controllers\Api\MenuController::class, // 菜单管理
|
||||
'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件
|
||||
'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理
|
||||
'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥)
|
||||
// 需要登录的路由生成地址
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user