no message

This commit is contained in:
hajimi
2026-06-11 12:15:29 +08:00
parent 10ebe39c30
commit 96efa1d905
5859 changed files with 815501 additions and 5 deletions
+100
View File
@@ -0,0 +1,100 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service;
use app\common\model\Config;
class ConfigService
{
/**
* @notes 设置配置值
* @param $type
* @param $name
* @param $value
* @return mixed
* @author 段誉
* @date 2021/12/27 15:00
*/
public static function set(string $type, string $name, $value)
{
$original = $value;
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
$data = Config::where(['type' => $type, 'name' => $name])->findOrEmpty();
if ($data->isEmpty()) {
Config::create([
'type' => $type,
'name' => $name,
'value' => $value,
]);
} else {
$data->value = $value;
$data->save();
}
// 返回原始值
return $original;
}
/**
* @notes 获取配置值
* @param $type
* @param string $name
* @param null $default_value
* @return array|int|mixed|string
* @author Tab
* @date 2021/7/15 15:16
*/
public static function get(string $type, string $name = '', $default_value = null)
{
if (!empty($name)) {
$value = Config::where(['type' => $type, 'name' => $name])->value('value');
if (!is_null($value)) {
$json = json_decode($value, true);
$value = json_last_error() === JSON_ERROR_NONE ? $json : $value;
}
if ($value) {
return $value;
}
// 返回特殊值 0 '0'
if ($value === 0 || $value === '0') {
return $value;
}
// 返回默认值
if ($default_value !== null) {
return $default_value;
}
// 返回本地配置文件中的值
return config('project.' . $type . '.' . $name);
}
// 取某个类型下的所有name的值
$data = Config::where(['type' => $type])->column('value', 'name');
foreach ($data as $k => $v) {
$json = json_decode($v, true);
if (json_last_error() === JSON_ERROR_NONE) {
$data[$k] = $json;
}
}
if ($data) {
return $data;
}
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service;
use think\facade\Cache;
class FileService
{
/**
* @notes 补全路径
* @param string $uri
* @param string $type
* @return string
* @author 段誉
* @date 2021/12/28 15:19
* @remark
* 场景一:补全域名路径,仅传参$uri;
* 例: FileService::getFileUrl('uploads/img.png');
* 返回 http://www.likeadmin.localhost/uploads/img.png
*
* 场景二:补全获取web根目录路径, 传参$uri 和 $type = public_path;
* 例: FileService::getFileUrl('uploads/img.png', 'public_path');
* 返回 /project-services/likeadmin/server/public/uploads/img.png
*
* 场景三:获取当前储存方式的域名
* 例: FileService::getFileUrl();
* 返回 http://www.likeadmin.localhost/
*/
public static function getFileUrl(string $uri = '', string $type = '') : string
{
if (strstr($uri, 'http://')) return $uri;
if (strstr($uri, 'https://')) return $uri;
$default = Cache::get('STORAGE_DEFAULT');
if (!$default) {
$default = ConfigService::get('storage', 'default', 'local');
Cache::set('STORAGE_DEFAULT', $default);
}
if ($default === 'local') {
if($type == 'public_path') {
return public_path(). $uri;
}
$domain = request()->domain();
} else {
$storage = Cache::get('STORAGE_ENGINE');
if (!$storage) {
$storage = ConfigService::get('storage', $default);
Cache::set('STORAGE_ENGINE', $storage);
}
$domain = $storage ? $storage['domain'] : '';
}
return self::format($domain, $uri);
}
/**
* @notes 转相对路径
* @param $uri
* @return mixed
* @author 张无忌
* @date 2021/7/28 15:09
*/
public static function setFileUrl($uri)
{
$default = ConfigService::get('storage', 'default', 'local');
if ($default === 'local') {
$domain = request()->domain();
return str_replace($domain.'/', '', $uri);
} else {
$storage = ConfigService::get('storage', $default);
return str_replace($storage['domain'].'/', '', $uri);
}
}
/**
* @notes 格式化url
* @param $domain
* @param $uri
* @return string
* @author 段誉
* @date 2022/7/11 10:36
*/
public static function format($domain, $uri)
{
// 处理域名
$domainLen = strlen($domain);
$domainRight = substr($domain, $domainLen -1, 1);
if ('/' == $domainRight) {
$domain = substr_replace($domain,'',$domainLen -1, 1);
}
// 处理uri
$uriLeft = substr($uri, 0, 1);
if('/' == $uriLeft) {
$uri = substr_replace($uri,'',0, 1);
}
return trim($domain) . '/' . trim($uri);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service;
use app\common\enum\ExportEnum;
use app\common\lists\BaseDataLists;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsExtendInterface;
use think\Response;
use think\response\Json;
use think\exception\HttpResponseException;
class JsonService
{
/**
* @notes 接口操作成功,返回信息
* @param string $msg
* @param array $data
* @param int $code
* @param int $show
* @return Json
* @author 段誉
* @date 2021/12/24 18:28
*/
public static function success(string $msg = 'success', array $data = [], int $code = 1, int $show = 1): Json
{
return self::result($code, $show, $msg, $data);
}
/**
* @notes 接口操作失败,返回信息
* @param string $msg
* @param array $data
* @param int $code
* @param int $show
* @return Json
* @author 段誉
* @date 2021/12/24 18:28
*/
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
{
return self::result($code, $show, $msg, $data);
}
/**
* @notes 接口返回数据
* @param $data
* @return Json
* @author 段誉
* @date 2021/12/24 18:29
*/
public static function data($data): Json
{
return self::success('', $data, 1, 0);
}
/**
* @notes 接口返回信息
* @param int $code
* @param int $show
* @param string $msg
* @param array $data
* @param int $httpStatus
* @return Json
* @author 段誉
* @date 2021/12/24 18:29
*/
private static function result(int $code, int $show, string $msg = 'OK', array $data = [], int $httpStatus = 200): Json
{
$result = compact('code', 'show', 'msg', 'data');
return json($result, $httpStatus);
}
/**
* @notes 抛出异常json
* @param string $msg
* @param array $data
* @param int $code
* @param int $show
* @return Json
* @author 段誉
* @date 2021/12/24 18:29
*/
public static function throw(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
{
$data = compact('code', 'show', 'msg', 'data');
$response = Response::create($data, 'json', 200);
throw new HttpResponseException($response);
}
/**
* @notes 数据列表
* @param \app\common\lists\BaseDataLists $lists
* @return \think\response\Json
* @author 令狐冲
* @date 2021/7/28 11:15
*/
public static function dataLists(BaseDataLists $lists): Json
{
//获取导出信息
if ($lists->export == ExportEnum::INFO && $lists instanceof ListsExcelInterface) {
return self::data($lists->excelInfo());
}
//获取导出文件的下载链接
if ($lists->export == ExportEnum::EXPORT && $lists instanceof ListsExcelInterface) {
$exportDownloadUrl = $lists->createExcel($lists->setExcelFields(), $lists->lists());
return self::success('', ['url' => $exportDownloadUrl], 2);
}
$data = [
'lists' => $lists->lists(),
'count' => $lists->count(),
'page_no' => $lists->pageNo,
'page_size' => $lists->pageSize,
];
$data['extend'] = [];
if ($lists instanceof ListsExtendInterface) {
$data['extend'] = $lists->extend();
}
return self::success('', $data, 1, 0);
}
}
@@ -0,0 +1,70 @@
<?php
namespace app\common\service;
use app\common\model\ai\AiConfig;
class MTranServerService
{
public static function translate(string $text, string $target = 'zh-Hans', string $source = 'auto'): array
{
$text = trim($text);
if ($text === '') {
return ['success' => false, 'error' => '翻译内容为空'];
}
$baseUrl = rtrim(AiConfig::getVal('mtranserver_base_url', 'http://127.0.0.1:8989'), '/');
$apiToken = trim(AiConfig::getVal('mtranserver_api_token', ''));
$payload = [
'from' => $source ?: 'auto',
'to' => $target ?: 'zh-Hans',
'text' => $text,
];
$headers = ['Content-Type: application/json'];
if ($apiToken !== '') {
$headers[] = 'Authorization: Bearer ' . $apiToken;
}
$startTime = microtime(true);
$ch = curl_init($baseUrl . '/translate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$costMs = (int) ((microtime(true) - $startTime) * 1000);
if ($error) {
return ['success' => false, 'error' => 'MTranServer 请求失败: ' . $error, 'tokens' => 0, 'cost_ms' => $costMs];
}
$data = json_decode((string) $response, true);
if ($httpCode !== 200) {
$message = is_array($data) ? ($data['message'] ?? $data['error'] ?? ('HTTP ' . $httpCode)) : ('HTTP ' . $httpCode);
return ['success' => false, 'error' => 'MTranServer 返回异常: ' . $message, 'tokens' => 0, 'cost_ms' => $costMs];
}
$result = trim((string) ($data['result'] ?? ''));
if ($result === '') {
return ['success' => false, 'error' => 'MTranServer 翻译结果为空', 'tokens' => 0, 'cost_ms' => $costMs];
}
return [
'success' => true,
'content' => $result,
'tokens' => 0,
'cost_ms' => $costMs,
];
}
}
@@ -0,0 +1,110 @@
<?php
namespace app\common\service;
use app\common\model\user\User;
use think\facade\Db;
/**
* 短链接分享海报文本与素材的统一构造服务
*/
class ShortLinkPosterDataService
{
/**
* 获取海报展示用的全部文本与素材
*/
public static function build(string $pageType, $pageId, ?int $userId, string $clientTitle = '', string $clientDesc = ''): array
{
$title = trim($clientTitle);
$description = trim($clientDesc);
if ($title === '' || $description === '') {
$detail = self::resolvePageDetail($pageType, $pageId);
if ($title === '') {
$title = $detail['title'];
}
if ($description === '') {
$description = $detail['description'];
}
}
$sharer = ['nickname' => '', 'avatar' => ''];
if (!empty($userId)) {
$user = User::field('nickname,avatar')->findOrEmpty($userId);
if (!$user->isEmpty()) {
$sharer['nickname'] = (string) $user->getData('nickname');
$sharer['avatar'] = (string) $user->avatar;
}
}
$posterImageUri = (string) ConfigService::get('website', 'share_poster', '');
$posterImage = $posterImageUri ? FileService::getFileUrl($posterImageUri) : '';
$posterTitle = (string) ConfigService::get('website', 'share_poster_title', '');
return [
'poster_image_uri' => $posterImageUri,
'poster_image' => $posterImage,
'poster_title' => $posterTitle,
'title' => $title,
'description' => $description,
'sharer' => $sharer,
];
}
/**
* 根据 page_type / page_id 自动取页面标题与描述
*/
private static function resolvePageDetail(string $pageType, $pageId): array
{
$empty = ['title' => '', 'description' => ''];
if ($pageId === '' || $pageId === null) {
return $empty;
}
switch ($pageType) {
case 'news':
$row = Db::name('article')
->field('title,desc,abstract')
->where('id', (int) $pageId)
->find();
if (empty($row)) {
return $empty;
}
$desc = $row['desc'] ?: mb_substr(strip_tags((string) $row['abstract']), 0, 80);
return ['title' => (string) $row['title'], 'description' => (string) $desc];
case 'match':
$row = Db::name('match')
->field('home_team,away_team,league_name')
->where('id', (int) $pageId)
->find();
if (empty($row)) {
return $empty;
}
return [
'title' => $row['home_team'] . ' vs ' . $row['away_team'],
'description' => (string) $row['league_name'],
];
case 'post':
case 'community':
$row = Db::name('community_post')
->field('content')
->where('id', (int) $pageId)
->find();
if (empty($row)) {
return $empty;
}
$text = mb_substr(strip_tags((string) $row['content']), 0, 80);
return ['title' => $text ?: '社区帖子', 'description' => ''];
case 'crypto':
return ['title' => $pageId . '/USDT 行情', 'description' => '加密货币实时行情'];
case 'invite':
return ['title' => '邀请你加入我的团队', 'description' => ''];
}
return $empty;
}
}
@@ -0,0 +1,667 @@
<?php
namespace app\common\service;
class ShortLinkQrCodeService
{
private const DATA_CODEWORDS = [1 => 19, 2 => 34, 3 => 55, 4 => 80, 5 => 108];
private const ECC_CODEWORDS = [1 => 7, 2 => 10, 3 => 15, 4 => 20, 5 => 26];
private const ALIGNMENT_POSITIONS = [1 => [], 2 => [6, 18], 3 => [6, 22], 4 => [6, 26], 5 => [6, 30]];
private static array $exp = [];
private static array $log = [];
public static function create(string $content, string $code, string $posterUri = '', array $textData = []): array
{
$matrix = self::encode($content);
$svg = self::toSvg($matrix);
$dirUri = 'uploads/qrcode/shortlink';
$dir = public_path() . $dirUri;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$svgUri = $dirUri . '/' . $code . '.svg';
$svgPath = public_path() . $svgUri;
file_put_contents($svgPath, $svg);
$qrUri = $svgUri;
$posterCompositeUri = '';
if (function_exists('imagecreatetruecolor')) {
$pngUri = $dirUri . '/' . $code . '.png';
self::toPng($matrix, public_path() . $pngUri);
$qrUri = $pngUri;
// 有海报底图时合成完整海报:背景 + 文字层 + 二维码白底
if ($posterUri !== '' && self::isLocalImage($posterUri)) {
$posterPath = public_path() . ltrim($posterUri, '/');
if (is_file($posterPath)) {
$compositeUri = $dirUri . '/' . $code . '_poster.png';
if (self::composePoster($posterPath, public_path() . $pngUri, public_path() . $compositeUri, $textData)) {
$posterCompositeUri = $compositeUri;
}
}
}
}
// uri 用于落库 qrcode 字段:合成海报存在则取合成图,否则取纯二维码
$uri = $posterCompositeUri !== '' ? $posterCompositeUri : $qrUri;
return [
'uri' => $uri,
'url' => FileService::getFileUrl($uri),
'qr_uri' => $qrUri,
'qr_url' => FileService::getFileUrl($qrUri),
'poster_uri' => $posterCompositeUri,
'poster_url' => $posterCompositeUri !== '' ? FileService::getFileUrl($posterCompositeUri) : '',
'data_url' => 'data:image/svg+xml;base64,' . base64_encode($svg),
];
}
private static function isLocalImage(string $uri): bool
{
$ext = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png'], true);
}
/**
* 合成海报:背景图 + 文字层 + 二维码(带白底圆角)
*/
private static function composePoster(string $posterPath, string $qrPath, string $outPath, array $textData = []): bool
{
$ext = strtolower(pathinfo($posterPath, PATHINFO_EXTENSION));
$bg = match ($ext) {
'jpg', 'jpeg' => @imagecreatefromjpeg($posterPath),
'png' => @imagecreatefrompng($posterPath),
default => false,
};
if (!$bg) {
return false;
}
$qr = @imagecreatefrompng($qrPath);
if (!$qr) {
imagedestroy($bg);
return false;
}
$bgW = imagesx($bg);
$bgH = imagesy($bg);
// 二维码尺寸:背景宽度的 32%,最小 200,最大 360
$qrSize = (int) max(200, min(360, $bgW * 0.32));
$padding = 3;
$boxSize = $qrSize + $padding * 2;
// 右下角,距离右边和底部各 5%
$margin = (int) ($bgW * 0.05);
$boxX = (int) ($bgW - $boxSize - $margin);
$boxY = (int) ($bgH - $boxSize - $margin);
// 文字层:在二维码上方区域绘制
$font = self::resolveFont();
if ($font !== null && !empty($textData)) {
self::drawTextLayer($bg, $font, $bgW, $boxY, $textData);
}
// 白色底
$white = imagecolorallocate($bg, 255, 255, 255);
imagefilledrectangle($bg, $boxX, $boxY, $boxX + $boxSize, $boxY + $boxSize, $white);
// 二维码缩放后贴到底框内
$qrW = imagesx($qr);
$qrH = imagesy($qr);
imagecopyresampled(
$bg,
$qr,
$boxX + $padding,
$boxY + $padding,
0,
0,
$qrSize,
$qrSize,
$qrW,
$qrH
);
$ok = imagepng($bg, $outPath);
imagedestroy($bg);
imagedestroy($qr);
return $ok;
}
/**
* 探测可用的中文字体路径,找不到返回 null(此时文字层会被跳过)
* 注意:不能落到不支持中文的字体(如 DejaVuSans),否则会渲染成豆腐块
*/
private static function resolveFont(): ?string
{
$candidates = [
public_path() . 'resource/front/cjk.ttf',
public_path() . 'resource/front/cjk.otf',
public_path() . 'resource/front/cjk.ttc',
];
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* 在海报左上区域绘制文字:标题(加粗+4px)→ 分享内容(+4px)
* $boundsBottom 为文字区域可使用的底部 y 坐标(即二维码框顶部)
*/
private static function drawTextLayer($img, string $font, int $bgW, int $boundsBottom, array $textData): void
{
$padding = (int) ($bgW * 0.06);
$maxW = (int) ($bgW - $padding * 2); // 文字区域:全宽(左右留 padding)
$cursorY = (int) ($bgW * 0.06);
$textColor = imagecolorallocate($img, 255, 255, 255);
$shadow = imagecolorallocatealpha($img, 0, 0, 0, 70);
// 海报标题(居中显示)
$posterTitle = (string) ($textData['poster_title'] ?? '');
if ($posterTitle !== '') {
$size = (int) max(34, $bgW * 0.032 + 18);
$cursorY = self::drawBoldCenteredLine($img, $font, $size, $textColor, $shadow, $posterTitle, $bgW, $cursorY);
$cursorY += (int) max(70, $size * 2.0);
}
// 分享人
$nickname = (string) ($textData['sharer']['nickname'] ?? '');
if ($nickname !== '') {
$size = (int) max(8, $bgW * 0.022 - 4);
$cursorY = self::drawBoldLeftLine($img, $font, $size, $textColor, $shadow, '@' . $nickname, $padding, $cursorY);
$cursorY += (int) max(70, $size * 2.4);
}
// 标题:左上角,加粗(双层绘制模拟),字体 +16px
$title = (string) ($textData['title'] ?? '');
if ($title !== '') {
$size = (int) max(32, $bgW * 0.028 + 18);
$cursorY = self::drawBoldLeftWrappedLines($img, $font, $size, $textColor, $shadow, $title, $maxW, $padding, $cursorY, 3, $boundsBottom);
$cursorY += (int) max(70, $size * 1.6);
}
// 分享内容:在标题下方,字体 +8px,加粗
$description = (string) ($textData['description'] ?? '');
if ($description !== '' && $cursorY + 60 < $boundsBottom) {
$size = (int) max(13, $bgW * 0.02 + 2);
self::drawBoldLeftWrappedLines($img, $font, $size, $textColor, $shadow, $description, $maxW, $padding, $cursorY, 4, $boundsBottom);
}
}
/**
* 左对齐单行文字绘制
*/
private static function drawLeftLine($img, string $font, int $size, int $color, int $shadow, string $text, int $x, int $y): int
{
$baseline = $y + $size;
imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text);
imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text);
return $baseline;
}
/**
* 左对齐加粗单行文字(三层绘制模拟加粗)
*/
private static function drawBoldLeftLine($img, string $font, int $size, int $color, int $shadow, string $text, int $x, int $y): int
{
$baseline = $y + $size;
imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text);
imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text);
imagettftext($img, $size, 0, $x + 1, $baseline, $color, $font, $text);
imagettftext($img, $size, 0, $x + 2, $baseline, $color, $font, $text);
return $baseline;
}
/**
* 左对齐换行文字
*/
private static function drawLeftWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $padding, int $y, int $maxLines, int $maxBottom): int
{
$lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines);
$lineHeight = (int) ($size * 1.4);
foreach ($lines as $line) {
if ($y + $lineHeight > $maxBottom) {
break;
}
$y = self::drawLeftLine($img, $font, $size, $color, $shadow, $line, $padding, $y);
$y += (int) ($size * 0.4);
}
return $y;
}
/**
* 左对齐加粗换行文字
*/
private static function drawBoldLeftWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $padding, int $y, int $maxLines, int $maxBottom): int
{
$lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines);
$lineHeight = (int) ($size * 1.6);
foreach ($lines as $line) {
if ($y + $lineHeight > $maxBottom) {
break;
}
$y = self::drawBoldLeftLine($img, $font, $size, $color, $shadow, $line, $padding, $y);
$y += (int) ($size * 1.6);
}
return $y;
}
private static function drawCenteredLine($img, string $font, int $size, int $color, int $shadow, string $text, int $bgW, int $y): int
{
$box = imagettfbbox($size, 0, $font, $text);
$w = abs($box[2] - $box[0]);
$x = (int) (($bgW - $w) / 2);
$baseline = $y + $size;
imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text);
imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text);
return $baseline;
}
/**
* 居中加粗单行文字(三层绘制模拟加粗)
*/
private static function drawBoldCenteredLine($img, string $font, int $size, int $color, int $shadow, string $text, int $bgW, int $y): int
{
$box = imagettfbbox($size, 0, $font, $text);
$w = abs($box[2] - $box[0]);
$x = (int) (($bgW - $w) / 2);
$baseline = $y + $size;
imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text);
imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text);
imagettftext($img, $size, 0, $x + 1, $baseline, $color, $font, $text);
imagettftext($img, $size, 0, $x + 2, $baseline, $color, $font, $text);
return $baseline;
}
private static function drawWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $bgW, int $y, int $maxLines, int $maxBottom): int
{
$lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines);
$lineHeight = (int) ($size * 1.4);
foreach ($lines as $line) {
if ($y + $lineHeight > $maxBottom) {
break;
}
$y = self::drawCenteredLine($img, $font, $size, $color, $shadow, $line, $bgW, $y);
$y += (int) ($size * 0.4);
}
return $y;
}
/**
* 按像素宽度对文本断行,超过 $maxLines 时末尾追加省略号
*/
private static function wrapByPixel(string $text, string $font, int $size, int $maxW, int $maxLines): array
{
$chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$lines = [];
$current = '';
foreach ($chars as $ch) {
$candidate = $current . $ch;
$box = imagettfbbox($size, 0, $font, $candidate);
$w = abs($box[2] - $box[0]);
if ($w > $maxW) {
if ($current !== '') {
$lines[] = $current;
}
$current = $ch;
if (count($lines) >= $maxLines) {
$lastIdx = count($lines) - 1;
$lines[$lastIdx] = mb_substr($lines[$lastIdx], 0, max(0, mb_strlen($lines[$lastIdx]) - 1)) . '…';
return $lines;
}
} else {
$current = $candidate;
}
}
if ($current !== '') {
$lines[] = $current;
}
return array_slice($lines, 0, $maxLines);
}
private static function toPng(array $matrix, string $path): void
{
$quiet = 1;
$scale = 10;
$size = count($matrix);
$imageSize = ($size + $quiet * 2) * $scale;
$image = imagecreatetruecolor($imageSize, $imageSize);
$white = imagecolorallocate($image, 255, 255, 255);
$black = imagecolorallocate($image, 0, 0, 0);
imagefill($image, 0, 0, $white);
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if ($matrix[$y][$x]) {
imagefilledrectangle(
$image,
($x + $quiet) * $scale,
($y + $quiet) * $scale,
($x + $quiet + 1) * $scale - 1,
($y + $quiet + 1) * $scale - 1,
$black
);
}
}
}
imagepng($image, $path);
imagedestroy($image);
}
public static function get(string $content, string $code): array
{
$pngUri = 'uploads/qrcode/shortlink/' . $code . '.png';
$svgUri = 'uploads/qrcode/shortlink/' . $code . '.svg';
if (is_file(public_path() . $pngUri)) {
return [
'uri' => $pngUri,
'url' => FileService::getFileUrl($pngUri),
'data_url' => '',
];
}
if (is_file(public_path() . $svgUri)) {
return [
'uri' => $svgUri,
'url' => FileService::getFileUrl($svgUri),
'data_url' => '',
];
}
return self::create($content, $code);
}
private static function encode(string $content): array
{
$version = self::getVersion(strlen($content));
$data = self::buildData($content, $version);
$codewords = array_merge($data, self::reedSolomonRemainder($data, self::ECC_CODEWORDS[$version]));
$size = 17 + 4 * $version;
$modules = array_fill(0, $size, array_fill(0, $size, false));
$isFunction = array_fill(0, $size, array_fill(0, $size, false));
self::drawFunctionPatterns($modules, $isFunction, $version);
self::drawCodewords($modules, $isFunction, $codewords);
self::applyMask($modules, $isFunction);
self::drawFormatBits($modules, $isFunction);
return $modules;
}
private static function getVersion(int $length): int
{
foreach (self::DATA_CODEWORDS as $version => $capacity) {
if ($length <= $capacity) {
return $version;
}
}
return 5;
}
private static function buildData(string $content, int $version): array
{
$bits = [];
self::appendBits($bits, 0x4, 4);
self::appendBits($bits, strlen($content), 8);
foreach (unpack('C*', $content) as $byte) {
self::appendBits($bits, $byte, 8);
}
$capacity = self::DATA_CODEWORDS[$version] * 8;
self::appendBits($bits, 0, min(4, $capacity - count($bits)));
while (count($bits) % 8 !== 0) {
$bits[] = 0;
}
$data = self::bitsToBytes($bits);
for ($pad = 0xec; count($data) < self::DATA_CODEWORDS[$version]; $pad ^= 0xfd) {
$data[] = $pad;
}
return $data;
}
private static function appendBits(array &$bits, int $value, int $length): void
{
for ($i = $length - 1; $i >= 0; $i--) {
$bits[] = ($value >> $i) & 1;
}
}
private static function bitsToBytes(array $bits): array
{
$bytes = [];
for ($i = 0; $i < count($bits); $i += 8) {
$byte = 0;
for ($j = 0; $j < 8; $j++) {
$byte = ($byte << 1) | ($bits[$i + $j] ?? 0);
}
$bytes[] = $byte;
}
return $bytes;
}
private static function initGalois(): void
{
if (self::$exp) {
return;
}
self::$exp = array_fill(0, 512, 0);
self::$log = array_fill(0, 256, 0);
$x = 1;
for ($i = 0; $i < 255; $i++) {
self::$exp[$i] = $x;
self::$log[$x] = $i;
$x <<= 1;
if ($x & 0x100) {
$x ^= 0x11d;
}
}
for ($i = 255; $i < 512; $i++) {
self::$exp[$i] = self::$exp[$i - 255];
}
}
private static function multiply(int $x, int $y): int
{
if ($x === 0 || $y === 0) {
return 0;
}
self::initGalois();
return self::$exp[self::$log[$x] + self::$log[$y]];
}
private static function reedSolomonDivisor(int $degree): array
{
self::initGalois();
$result = [1];
for ($i = 0; $i < $degree; $i++) {
$next = array_fill(0, count($result) + 1, 0);
foreach ($result as $j => $coefficient) {
$next[$j] ^= self::multiply($coefficient, 1);
$next[$j + 1] ^= self::multiply($coefficient, self::$exp[$i]);
}
$result = $next;
}
return $result;
}
private static function reedSolomonRemainder(array $data, int $degree): array
{
$divisor = self::reedSolomonDivisor($degree);
$result = array_fill(0, $degree, 0);
foreach ($data as $byte) {
$factor = $byte ^ $result[0];
array_shift($result);
$result[] = 0;
for ($i = 0; $i < $degree; $i++) {
$result[$i] ^= self::multiply($divisor[$i + 1], $factor);
}
}
return $result;
}
private static function drawFunctionPatterns(array &$modules, array &$isFunction, int $version): void
{
$size = count($modules);
self::drawFinderPattern($modules, $isFunction, 3, 3);
self::drawFinderPattern($modules, $isFunction, $size - 4, 3);
self::drawFinderPattern($modules, $isFunction, 3, $size - 4);
for ($i = 8; $i < $size - 8; $i++) {
self::setFunctionModule($modules, $isFunction, 6, $i, $i % 2 === 0);
self::setFunctionModule($modules, $isFunction, $i, 6, $i % 2 === 0);
}
foreach (self::ALIGNMENT_POSITIONS[$version] as $x) {
foreach (self::ALIGNMENT_POSITIONS[$version] as $y) {
if (!$isFunction[$y][$x]) {
self::drawAlignmentPattern($modules, $isFunction, $x, $y);
}
}
}
for ($i = 0; $i <= 8; $i++) {
self::setFunctionModule($modules, $isFunction, 8, $i, false);
self::setFunctionModule($modules, $isFunction, $i, 8, false);
}
for ($i = 0; $i < 8; $i++) {
self::setFunctionModule($modules, $isFunction, $size - 1 - $i, 8, false);
self::setFunctionModule($modules, $isFunction, 8, $size - 1 - $i, false);
}
self::setFunctionModule($modules, $isFunction, 8, $size - 8, true);
}
private static function drawFinderPattern(array &$modules, array &$isFunction, int $x, int $y): void
{
$size = count($modules);
for ($dy = -4; $dy <= 4; $dy++) {
for ($dx = -4; $dx <= 4; $dx++) {
$xx = $x + $dx;
$yy = $y + $dy;
if ($xx < 0 || $xx >= $size || $yy < 0 || $yy >= $size) {
continue;
}
$distance = max(abs($dx), abs($dy));
self::setFunctionModule($modules, $isFunction, $xx, $yy, $distance !== 2 && $distance !== 4);
}
}
}
private static function drawAlignmentPattern(array &$modules, array &$isFunction, int $x, int $y): void
{
for ($dy = -2; $dy <= 2; $dy++) {
for ($dx = -2; $dx <= 2; $dx++) {
self::setFunctionModule($modules, $isFunction, $x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1);
}
}
}
private static function setFunctionModule(array &$modules, array &$isFunction, int $x, int $y, bool $dark): void
{
$modules[$y][$x] = $dark;
$isFunction[$y][$x] = true;
}
private static function drawCodewords(array &$modules, array $isFunction, array $codewords): void
{
$size = count($modules);
$bitIndex = 0;
$totalBits = count($codewords) * 8;
$upward = true;
for ($right = $size - 1; $right >= 1; $right -= 2) {
if ($right === 6) {
$right--;
}
for ($vert = 0; $vert < $size; $vert++) {
$y = $upward ? $size - 1 - $vert : $vert;
for ($j = 0; $j < 2; $j++) {
$x = $right - $j;
if ($isFunction[$y][$x]) {
continue;
}
$dark = false;
if ($bitIndex < $totalBits) {
$dark = (($codewords[intdiv($bitIndex, 8)] >> (7 - ($bitIndex % 8))) & 1) !== 0;
}
$modules[$y][$x] = $dark;
$bitIndex++;
}
}
$upward = !$upward;
}
}
private static function applyMask(array &$modules, array $isFunction): void
{
$size = count($modules);
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if (!$isFunction[$y][$x] && (($x + $y) % 2 === 0)) {
$modules[$y][$x] = !$modules[$y][$x];
}
}
}
}
private static function drawFormatBits(array &$modules, array &$isFunction): void
{
$size = count($modules);
$bits = self::getFormatBits();
for ($i = 0; $i <= 5; $i++) {
self::setFunctionModule($modules, $isFunction, 8, $i, self::getBit($bits, $i));
}
self::setFunctionModule($modules, $isFunction, 8, 7, self::getBit($bits, 6));
self::setFunctionModule($modules, $isFunction, 8, 8, self::getBit($bits, 7));
self::setFunctionModule($modules, $isFunction, 7, 8, self::getBit($bits, 8));
for ($i = 9; $i < 15; $i++) {
self::setFunctionModule($modules, $isFunction, 14 - $i, 8, self::getBit($bits, $i));
}
for ($i = 0; $i < 8; $i++) {
self::setFunctionModule($modules, $isFunction, $size - 1 - $i, 8, self::getBit($bits, $i));
}
for ($i = 8; $i < 15; $i++) {
self::setFunctionModule($modules, $isFunction, 8, $size - 15 + $i, self::getBit($bits, $i));
}
self::setFunctionModule($modules, $isFunction, 8, $size - 8, true);
}
private static function getFormatBits(): int
{
$data = 1 << 3;
$remainder = $data << 10;
for ($i = 14; $i >= 10; $i--) {
if ((($remainder >> $i) & 1) !== 0) {
$remainder ^= 0x537 << ($i - 10);
}
}
return (($data << 10) | $remainder) ^ 0x5412;
}
private static function getBit(int $value, int $index): bool
{
return (($value >> $index) & 1) !== 0;
}
private static function toSvg(array $matrix): string
{
$quiet = 1;
$size = count($matrix);
$viewBox = $size + $quiet * 2;
$path = '';
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if ($matrix[$y][$x]) {
$path .= 'M' . ($x + $quiet) . ',' . ($y + $quiet) . 'h1v1h-1z';
}
}
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $viewBox . ' ' . $viewBox . '" width="360" height="360" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="#fff"/><path d="' . $path . '" fill="#000"/></svg>';
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service;
use app\common\enum\FileEnum;
use app\common\model\file\File;
use app\common\service\storage\Driver as StorageDriver;
use Exception;
class UploadService
{
/**
* @notes 上传图片
* @param $cid
* @param int $user_id
* @param string $saveDir
* @return array
* @throws Exception
* @author 段誉
* @date 2021/12/29 16:30
*/
public static function image($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/images')
{
try {
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local'=>[]],
];
// 2、执行文件上传
$StorageDriver = new StorageDriver($config);
$StorageDriver->setUploadFile('file');
$fileName = $StorageDriver->getFileName();
$fileInfo = $StorageDriver->getFileInfo();
// 校验上传文件后缀
if (!in_array(strtolower($fileInfo['ext']), config('project.file_image'))) {
throw new Exception("上传图片不允许上传". $fileInfo['ext'] . "文件");
}
// 上传文件
$saveDir = self::getUploadUrl($saveDir);
if (!$StorageDriver->upload($saveDir)) {
throw new Exception($StorageDriver->getError());
}
// 3、处理文件名称
if (strlen($fileInfo['name']) > 128) {
$name = substr($fileInfo['name'], 0, 123);
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name'])-5, strlen($fileInfo['name']));
$fileInfo['name'] = $name . $nameEnd;
}
// 4、写入数据库中
$file = File::create([
'cid' => $cid,
'type' => FileEnum::IMAGE_TYPE,
'name' => $fileInfo['name'],
'uri' => $saveDir . '/' . str_replace("\\","/", $fileName),
'source' => $source,
'source_id' => $sourceId,
'create_time' => time(),
]);
// 5、返回结果
return [
'id' => $file['id'],
'cid' => $file['cid'],
'type' => $file['type'],
'name' => $file['name'],
'uri' => FileService::getFileUrl($file['uri']),
'url' => $file['uri']
];
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
/**
* @notes 视频上传
* @param $cid
* @param int $user_id
* @param string $saveDir
* @return array
* @throws Exception
* @author 段誉
* @date 2021/12/29 16:32
*/
public static function video($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/video')
{
try {
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local'=>[]],
];
// 2、执行文件上传
$StorageDriver = new StorageDriver($config);
$StorageDriver->setUploadFile('file');
$fileName = $StorageDriver->getFileName();
$fileInfo = $StorageDriver->getFileInfo();
// 校验上传文件后缀
if (!in_array(strtolower($fileInfo['ext']), config('project.file_video'))) {
throw new Exception("上传视频不允许上传". $fileInfo['ext'] . "文件");
}
// 上传文件
$saveDir = self::getUploadUrl($saveDir);
if (!$StorageDriver->upload($saveDir)) {
throw new Exception($StorageDriver->getError());
}
// 3、处理文件名称
if (strlen($fileInfo['name']) > 128) {
$name = substr($fileInfo['name'], 0, 123);
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name'])-5, strlen($fileInfo['name']));
$fileInfo['name'] = $name . $nameEnd;
}
// 4、写入数据库中
$file = File::create([
'cid' => $cid,
'type' => FileEnum::VIDEO_TYPE,
'name' => $fileInfo['name'],
'uri' => $saveDir . '/' . str_replace("\\","/", $fileName),
'source' => $source,
'source_id' => $sourceId,
'create_time' => time(),
]);
// 5、返回结果
return [
'id' => $file['id'],
'cid' => $file['cid'],
'type' => $file['type'],
'name' => $file['name'],
'uri' => FileService::getFileUrl($file['uri']),
'url' => $file['uri']
];
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
/**
* @notes 上传文件
* @param $cid
* @param int $sourceId
* @param int $source
* @param string $saveDir
* @return array
* @throws Exception
* @author dw
* @date 2023/06/26
*/
public static function file($cid, int $sourceId = 0, int $source = FileEnum::SOURCE_ADMIN, string $saveDir = 'uploads/file')
{
try {
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? [ 'local' => [] ],
];
// 2、执行文件上传
$StorageDriver = new StorageDriver($config);
$StorageDriver->setUploadFile('file');
$fileName = $StorageDriver->getFileName();
$fileInfo = $StorageDriver->getFileInfo();
// 校验上传文件后缀
if (!in_array(strtolower($fileInfo['ext']), config('project.file_file'))) {
throw new Exception("上传文件不允许上传" . $fileInfo['ext'] . "文件");
}
// 上传文件
$saveDir = self::getUploadUrl($saveDir);
if (!$StorageDriver->upload($saveDir)) {
throw new Exception($StorageDriver->getError());
}
// 3、处理文件名称
if (strlen($fileInfo['name']) > 128) {
$name = substr($fileInfo['name'], 0, 123);
$nameEnd = substr($fileInfo['name'], strlen($fileInfo['name']) - 5, strlen($fileInfo['name']));
$fileInfo['name'] = $name . $nameEnd;
}
// 4、写入数据库中
$file = File::create([
'cid' => $cid,
'type' => FileEnum::FILE_TYPE,
'name' => $fileInfo['name'],
'uri' => $saveDir . '/' . str_replace("\\", "/", $fileName),
'source' => $source,
'source_id' => $sourceId,
'create_time' => time(),
]);
// 5、返回结果
return [
'id' => $file['id'],
'cid' => $file['cid'],
'type' => $file['type'],
'name' => $file['name'],
'uri' => FileService::getFileUrl($file['uri']),
'url' => $file['uri']
];
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
/**
* @notes 上传地址
* @param $saveDir
* @return string
* @author dw
* @date 2023/06/26
*/
private static function getUploadUrl($saveDir):string
{
return $saveDir . '/' . date('Ymd');
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace app\common\service;
use app\common\model\user\User;
use app\common\model\vip\VipLevel;
class VipService
{
/**
* @notes 检查用户是否为有效VIP
*/
public static function isVip(int $userId): bool
{
$user = User::findOrEmpty($userId);
if ($user->isEmpty()) {
return false;
}
return $user->is_vip == 1 && $user->vip_expire_time > time();
}
/**
* @notes 获取用户当前VIP等级配置(未过期才返回)
*/
public static function getUserVipLevel(int $userId): ?array
{
$user = User::findOrEmpty($userId);
if ($user->isEmpty() || $user->is_vip != 1 || $user->vip_expire_time <= time()) {
return null;
}
$level = VipLevel::where('level', $user->vip_level)->where('status', 1)->findOrEmpty();
if ($level->isEmpty()) {
return null;
}
return $level->toArray();
}
/**
* @notes 检查VIP用户是否享有翻译免费权益
*/
public static function hasTranslateFree(int $userId): bool
{
$level = self::getUserVipLevel($userId);
return $level && !empty($level['translate_free']);
}
/**
* @notes 检查VIP用户是否享有解锁帖子免费权益
*/
public static function hasUnlockFree(int $userId): bool
{
$level = self::getUserVipLevel($userId);
return $level && !empty($level['unlock_free']);
}
}
@@ -0,0 +1,302 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
use app\common\model\match\MatchEvent;
use think\facade\Cache;
class AiAssistantContextService
{
private const CRYPTO_SYMBOLS = [
'BTC' => ['BTC', '比特币', 'Bitcoin'],
'ETH' => ['ETH', '以太坊', 'Ethereum'],
'BNB' => ['BNB'],
'SOL' => ['SOL', 'Solana'],
'XRP' => ['XRP', 'Ripple'],
'DOGE' => ['DOGE', '狗狗币', 'Dogecoin'],
'TON' => ['TON', 'Toncoin'],
'TRX' => ['TRX', 'TRON', '波场'],
'ADA' => ['ADA', 'Cardano'],
'AVAX' => ['AVAX', 'Avalanche'],
];
public static function build(string $message, int $userId, int $sessionId): array
{
$topK = max(1, min(12, (int) AiConfig::getVal('assistant_kb_topk', '6')));
$hits = self::retrieveKnowledge($message, $userId, $sessionId, $topK);
$matchItems = self::searchMatches($message);
$cryptoItems = self::resolveCryptoContext($message);
$sources = [];
foreach ($hits as $hit) {
$sources[] = self::sourceFromKbHit($hit);
}
foreach ($matchItems as $item) {
$sources[] = $item['source'];
}
foreach ($cryptoItems as $item) {
$sources[] = $item['source'];
}
$sources = self::uniqueSources($sources);
$context = [
'knowledge_hits' => array_map([self::class, 'compactHit'], $hits),
'match_context' => array_column($matchItems, 'context'),
'crypto_realtime' => array_column($cryptoItems, 'context'),
];
return [
'context_text' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
'sources' => array_values($sources),
'has_context' => !empty($hits) || !empty($matchItems) || !empty($cryptoItems),
];
}
private static function retrieveKnowledge(string $message, int $userId, int $sessionId, int $topK): array
{
$result = KbService::retrieve(
'assistant_chat',
$message,
[
'user_id' => $userId,
'ref_id' => $sessionId,
'domains' => ['article', 'post', 'lottery', 'match'],
],
['title' => $message],
$topK
);
if (empty($result['success']) || empty($result['hits']) || !is_array($result['hits'])) {
return [];
}
return $result['hits'];
}
private static function searchMatches(string $message): array
{
$terms = self::queryTerms($message);
if (empty($terms)) {
return [];
}
try {
$query = MatchEvent::where('is_show', 1);
$query->where(function ($q) use ($terms) {
foreach (array_slice($terms, 0, 5) as $term) {
$like = '%' . $term . '%';
$q->whereOr('home_team', 'like', $like)
->whereOr('away_team', 'like', $like)
->whereOr('league_name', 'like', $like);
}
});
$rows = $query->field('id,league_name,home_team,away_team,match_time,status,home_score,away_score,home_odds,draw_odds,away_odds')
->order('match_time desc')
->limit(3)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
return array_map(static function (array $row) {
$title = trim(($row['home_team'] ?? '') . ' vs ' . ($row['away_team'] ?? ''));
$matchTime = !empty($row['match_time']) && is_numeric($row['match_time'])
? date('Y-m-d H:i', (int) $row['match_time'])
: (string) ($row['match_time'] ?? '');
$summary = sprintf(
'%s%s,比分 %s-%s,赔率 主胜%s 平%s 客胜%s。',
$row['league_name'] ?? '',
$matchTime,
$row['home_score'] ?? 0,
$row['away_score'] ?? 0,
$row['home_odds'] ?? '-',
$row['draw_odds'] ?? '-',
$row['away_odds'] ?? '-'
);
return [
'context' => [
'domain' => 'match',
'title' => $title,
'summary' => $summary,
],
'source' => [
'type' => 'match',
'title' => $title,
'summary' => $summary,
'path' => '/pages/match_detail/match_detail?id=' . (int) $row['id'],
'source_id' => (int) $row['id'],
],
];
}, $rows);
}
private static function resolveCryptoContext(string $message): array
{
if ((int) AiConfig::getVal('assistant_enable_crypto_realtime', '1') !== 1) {
return [];
}
$symbols = self::detectCryptoSymbols($message);
if (empty($symbols)) {
return [];
}
$items = [];
foreach (array_slice($symbols, 0, 3) as $symbol) {
$ticker = self::fetchBinanceTicker($symbol);
if (empty($ticker)) {
continue;
}
$price = (float) ($ticker['lastPrice'] ?? 0);
$change = (float) ($ticker['priceChangePercent'] ?? 0);
$high = (float) ($ticker['highPrice'] ?? 0);
$low = (float) ($ticker['lowPrice'] ?? 0);
$summary = sprintf(
'%s/USDT 当前价格 %.8g24小时涨跌 %.2f%%24小时高低 %.8g / %.8g。',
$symbol,
$price,
$change,
$high,
$low
);
$items[] = [
'context' => [
'domain' => 'crypto',
'symbol' => $symbol,
'summary' => $summary,
'raw' => [
'price' => $price,
'change_percent' => $change,
'high' => $high,
'low' => $low,
],
],
'source' => [
'type' => 'crypto',
'title' => $symbol . '/USDT 行情',
'summary' => $summary,
'path' => '/pages/crypto/crypto_detail?symbol=' . $symbol,
'source_id' => 0,
],
];
}
return $items;
}
private static function detectCryptoSymbols(string $message): array
{
$haystack = mb_strtolower($message);
$symbols = [];
foreach (self::CRYPTO_SYMBOLS as $symbol => $aliases) {
foreach ($aliases as $alias) {
if (mb_strpos($haystack, mb_strtolower($alias)) !== false) {
$symbols[] = $symbol;
break;
}
}
}
return array_values(array_unique($symbols));
}
private static function fetchBinanceTicker(string $symbol): array
{
$cacheKey = 'assistant_crypto_ticker_' . $symbol;
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$url = 'https://api.binance.com/api/v3/ticker/24hr?symbol=' . rawurlencode($symbol . 'USDT');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = $httpCode === 200 ? json_decode((string) $body, true) : [];
if (is_array($data) && !empty($data['symbol'])) {
Cache::set($cacheKey, $data, 10);
return $data;
}
return [];
}
private static function sourceFromKbHit(array $hit): array
{
$domain = (string) ($hit['domain'] ?? '');
$sourceId = (int) ($hit['source_id'] ?? 0);
return [
'type' => $domain,
'title' => (string) ($hit['title'] ?? ''),
'summary' => self::clipText((string) ($hit['summary'] ?? $hit['chunk_text'] ?? ''), 120),
'path' => self::sourcePath($domain, $sourceId),
'source_id' => $sourceId,
];
}
private static function sourcePath(string $domain, int $sourceId): string
{
return match ($domain) {
'article' => '/pages/news_detail/news_detail?id=' . $sourceId,
'post' => '/packages_community/pages/post_detail?id=' . $sourceId,
'match' => '/pages/match_detail/match_detail?id=' . $sourceId,
'lottery' => '/pages/lottery_analysis/lottery_analysis',
default => '',
};
}
private static function compactHit(array $hit): array
{
return [
'domain' => $hit['domain'] ?? '',
'subtype' => $hit['subtype'] ?? '',
'source_id' => (int) ($hit['source_id'] ?? 0),
'title' => $hit['title'] ?? '',
'summary' => $hit['summary'] ?? '',
'content' => self::clipText((string) ($hit['chunk_text'] ?? ''), 360),
'score' => $hit['score'] ?? 0,
];
}
private static function uniqueSources(array $sources): array
{
$map = [];
foreach ($sources as $source) {
$key = ($source['type'] ?? '') . ':' . ($source['source_id'] ?? 0) . ':' . ($source['title'] ?? '');
if (!isset($map[$key]) && trim((string) ($source['title'] ?? '')) !== '') {
$map[$key] = $source;
}
}
return array_slice(array_values($map), 0, 8);
}
private static function queryTerms(string $message): array
{
$terms = preg_split('/[\s,,。!?;、:\/\\\\]+/u', trim($message)) ?: [];
$terms = array_values(array_filter(array_map('trim', $terms), static function (string $term) {
return mb_strlen($term) >= 2 && !in_array($term, ['今天', '最近', '分析', '什么', '一下'], true);
}));
return array_unique($terms);
}
private static function clipText(string $text, int $length): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length) . '...';
}
}
@@ -0,0 +1,383 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiChatMessage;
use app\common\model\ai\AiChatSession;
use app\common\model\ai\AiConfig;
use app\common\model\ai\AiLog;
use think\facade\Cache;
class AiAssistantService
{
public const AI_LOG_TYPE_ASSISTANT = 7;
private const QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
private const QWEN_MODEL = 'qwen3.7-plus';
public static function chat(string $message, int $userId, string $clientId, int $sessionId = 0, string $ip = ''): array
{
$message = trim(mb_substr($message, 0, 1000));
if ($message === '') {
return ['success' => false, 'error' => '请输入要咨询的问题'];
}
if ($userId <= 0 && $clientId === '') {
return ['success' => false, 'error' => '缺少会话标识'];
}
if (!self::checkRateLimit($userId, $clientId, $ip)) {
return ['success' => false, 'error' => '提问太频繁,请稍后再试'];
}
$session = self::resolveSession($sessionId, $userId, $clientId, $message);
if (!$session) {
return ['success' => false, 'error' => '会话不存在或无权访问'];
}
$history = self::historyMessages((int) $session->id);
$context = AiAssistantContextService::build($message, $userId, (int) $session->id);
AiChatMessage::create([
'session_id' => (int) $session->id,
'user_id' => $userId,
'client_id' => $clientId,
'role' => AiChatMessage::ROLE_USER,
'content' => $message,
'sources_json' => '[]',
'status' => AiChatMessage::STATUS_SUCCESS,
'create_time' => time(),
'update_time' => time(),
]);
$messages = self::buildModelMessages($history, $message, $context);
$result = self::callAssistantModel($messages);
$status = !empty($result['success']) ? AiChatMessage::STATUS_SUCCESS : AiChatMessage::STATUS_FAILED;
$answer = !empty($result['success'])
? trim((string) ($result['content'] ?? ''))
: 'AI助手暂时没有生成成功,请稍后再试。';
if ($answer === '') {
$answer = '我暂时没有找到足够的站内依据,请换个关键词再问一次。';
}
$assistantMessage = AiChatMessage::create([
'session_id' => (int) $session->id,
'user_id' => $userId,
'client_id' => $clientId,
'role' => AiChatMessage::ROLE_ASSISTANT,
'content' => $answer,
'sources_json' => json_encode($context['sources'] ?? [], JSON_UNESCAPED_UNICODE),
'model' => (string) ($result['model'] ?? 'gpt-5.5'),
'tokens_used' => (int) ($result['tokens'] ?? 0),
'cost_ms' => (int) ($result['cost_ms'] ?? 0),
'status' => $status,
'error_msg' => mb_substr((string) ($result['error'] ?? ''), 0, 500),
'create_time' => time(),
'update_time' => time(),
]);
self::touchSession((int) $session->id, $message, $answer);
self::recordAiLog($userId, (int) $session->id, $result, $status);
if (empty($result['success'])) {
return ['success' => false, 'error' => 'AI助手暂时不可用,请稍后再试'];
}
$session = AiChatSession::findOrEmpty((int) $session->id);
return [
'success' => true,
'data' => [
'session' => self::formatSession($session->toArray()),
'message' => self::formatMessage($assistantMessage->toArray()),
'sources' => $context['sources'] ?? [],
],
];
}
public static function sessions(int $userId, string $clientId): array
{
if ($userId <= 0 && $clientId === '') {
return [];
}
$query = AiChatSession::order('last_active_time desc')->order('id desc')->limit(30);
self::applyOwnerWhere($query, $userId, $clientId);
return array_map([self::class, 'formatSession'], $query->select()->toArray());
}
public static function messages(int $sessionId, int $userId, string $clientId): array
{
$session = self::getOwnedSession($sessionId, $userId, $clientId);
if (!$session) {
return ['success' => false, 'error' => '会话不存在或无权访问'];
}
$messages = AiChatMessage::where('session_id', $sessionId)
->order('id', 'asc')
->limit(200)
->select()
->toArray();
return [
'success' => true,
'data' => [
'session' => self::formatSession($session->toArray()),
'messages' => array_map([self::class, 'formatMessage'], $messages),
],
];
}
public static function clear(int $sessionId, int $userId, string $clientId): array
{
if ($sessionId > 0) {
$session = self::getOwnedSession($sessionId, $userId, $clientId);
if (!$session) {
return ['success' => false, 'error' => '会话不存在或无权访问'];
}
AiChatMessage::where('session_id', $sessionId)->delete();
$session->delete();
return ['success' => true];
}
if ($userId <= 0 && $clientId === '') {
return ['success' => false, 'error' => '缺少会话标识'];
}
$query = AiChatSession::field('id');
self::applyOwnerWhere($query, $userId, $clientId);
$ids = $query->column('id');
if (!empty($ids)) {
AiChatMessage::whereIn('session_id', $ids)->delete();
AiChatSession::whereIn('id', $ids)->delete();
}
return ['success' => true];
}
private static function resolveSession(int $sessionId, int $userId, string $clientId, string $message): ?AiChatSession
{
if ($sessionId > 0) {
return self::getOwnedSession($sessionId, $userId, $clientId);
}
$now = time();
return AiChatSession::create([
'user_id' => $userId,
'client_id' => $clientId,
'title' => self::makeTitle($message),
'last_message' => $message,
'last_active_time' => $now,
'message_count' => 0,
'create_time' => $now,
'update_time' => $now,
]);
}
private static function getOwnedSession(int $sessionId, int $userId, string $clientId): ?AiChatSession
{
$query = AiChatSession::where('id', $sessionId);
self::applyOwnerWhere($query, $userId, $clientId);
$session = $query->findOrEmpty();
return $session->isEmpty() ? null : $session;
}
private static function applyOwnerWhere($query, int $userId, string $clientId): void
{
if ($userId > 0) {
$query->where('user_id', $userId);
return;
}
$query->where('user_id', 0)->where('client_id', $clientId);
}
private static function historyMessages(int $sessionId): array
{
$limit = max(2, min(20, (int) AiConfig::getVal('assistant_history_limit', '8')));
$rows = AiChatMessage::where('session_id', $sessionId)
->where('status', AiChatMessage::STATUS_SUCCESS)
->order('id', 'desc')
->limit($limit)
->select()
->toArray();
return array_reverse($rows);
}
private static function buildModelMessages(array $history, string $message, array $context): array
{
$messages = [
[
'role' => 'system',
'content' => AiConfig::getVal('assistant_system_prompt', self::defaultSystemPrompt()),
],
];
foreach ($history as $row) {
$role = $row['role'] === AiChatMessage::ROLE_ASSISTANT ? 'assistant' : 'user';
$content = trim((string) ($row['content'] ?? ''));
if ($content !== '') {
$messages[] = ['role' => $role, 'content' => mb_substr($content, 0, 1000)];
}
}
$messages[] = [
'role' => 'user',
'content' => "用户问题:\n{$message}\n\n站内资料与实时上下文:\n"
. ($context['context_text'] ?? '{}')
. "\n\n请按照“直接结论、依据摘要、相关内容、风险提示”的结构回答。"
. "如果站内资料不足,请明确说明,不要编造。彩票、赛事预测、加密行情必须提示不构成投资或购彩建议。",
];
return $messages;
}
private static function callAssistantModel(array $messages): array
{
$client = new DeepSeekClient();
$optionsList = [];
$qwenOptions = self::qwenOptions();
if (!empty($qwenOptions['api_key'])) {
$optionsList[] = $qwenOptions;
}
$optionsList[] = self::defaultModelOptions();
$failedErrors = [];
$lastResult = ['success' => false, 'error' => 'AI助手模型服务暂时不可用', 'tokens' => 0];
foreach ($optionsList as $options) {
$result = $client->chatMessages($messages, $options);
if (!empty($result['success'])) {
return $result;
}
$lastResult = $result;
$provider = (string) ($result['provider_label'] ?? ($options['provider_label'] ?? 'AI模型'));
$failedErrors[] = $provider . ': ' . (string) ($result['error'] ?? '调用失败');
}
if (!empty($failedErrors)) {
$lastResult['error'] = implode(' | ', $failedErrors);
}
return $lastResult;
}
private static function qwenOptions(): array
{
$apiKey = trim((string) AiConfig::getVal('qwen_api_key', ''));
if ($apiKey === '') {
$apiKey = trim((string) env('DASHSCOPE_API_KEY', ''));
}
if ($apiKey === '') {
return [];
}
$baseUrl = trim((string) AiConfig::getVal('qwen_base_url', ''));
if ($baseUrl === '') {
$baseUrl = self::QWEN_BASE_URL;
}
$model = trim((string) AiConfig::getVal('qwen_model', ''));
if ($model === '') {
$model = self::QWEN_MODEL;
}
return self::defaultModelOptions() + [
'api_key' => $apiKey,
'base_url' => $baseUrl,
'model' => $model,
'wire_api' => 'chat',
'provider_label' => 'DashScope(Qwen)',
];
}
private static function defaultModelOptions(): array
{
return [
'max_tokens' => 1600,
'temperature' => 0.45,
];
}
private static function checkRateLimit(int $userId, string $clientId, string $ip): bool
{
$limit = max(1, (int) AiConfig::getVal('assistant_rate_limit_per_minute', '10'));
$identity = $userId > 0 ? 'u' . $userId : 'c' . md5($clientId . '|' . $ip);
$key = 'assistant_rate_' . $identity . '_' . date('YmdHi');
$count = (int) Cache::get($key);
if ($count >= $limit) {
return false;
}
Cache::set($key, $count + 1, 70);
return true;
}
private static function touchSession(int $sessionId, string $question, string $answer): void
{
AiChatSession::where('id', $sessionId)->update([
'last_message' => mb_substr($question, 0, 500),
'last_answer' => mb_substr($answer, 0, 500),
'last_active_time' => time(),
'message_count' => AiChatMessage::where('session_id', $sessionId)->count(),
'update_time' => time(),
]);
}
private static function recordAiLog(int $userId, int $sessionId, array $result, int $status): void
{
AiLog::create([
'user_id' => $userId,
'type' => self::AI_LOG_TYPE_ASSISTANT,
'ref_id' => $sessionId,
'tokens_used' => (int) ($result['tokens'] ?? 0),
'cost_ms' => (int) ($result['cost_ms'] ?? 0),
'status' => $status === AiChatMessage::STATUS_SUCCESS ? 1 : 2,
'create_time' => time(),
]);
}
private static function formatSession(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'last_message' => (string) ($row['last_message'] ?? ''),
'last_answer' => (string) ($row['last_answer'] ?? ''),
'message_count' => (int) ($row['message_count'] ?? 0),
'last_active_time' => self::formatTime($row['last_active_time'] ?? 0),
'create_time' => self::formatTime($row['create_time'] ?? 0),
];
}
private static function formatMessage(array $row): array
{
$sources = json_decode((string) ($row['sources_json'] ?? '[]'), true);
return [
'id' => (int) ($row['id'] ?? 0),
'session_id' => (int) ($row['session_id'] ?? 0),
'role' => (string) ($row['role'] ?? ''),
'content' => (string) ($row['content'] ?? ''),
'sources' => is_array($sources) ? $sources : [],
'status' => (int) ($row['status'] ?? 1),
'error_msg' => (string) ($row['error_msg'] ?? ''),
'create_time' => self::formatTime($row['create_time'] ?? 0),
];
}
private static function formatTime($value): string
{
if (is_numeric($value) && (int) $value > 0) {
return date('Y-m-d H:i:s', (int) $value);
}
return (string) ($value ?: '');
}
private static function makeTitle(string $message): string
{
$title = trim(preg_replace('/\s+/u', ' ', $message) ?: '');
return mb_substr($title ?: '新的对话', 0, 24);
}
private static function defaultSystemPrompt(): string
{
return '你是世博头条 AI 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。'
. '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。'
. '不要声称可以访问后台、源码、服务器或未提供的内部数据。'
. '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。';
}
}
+615
View File
@@ -0,0 +1,615 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiAnalysis;
use app\common\model\ai\AiConfig;
use app\common\model\ai\AiLog;
use app\common\model\lottery\LotteryAiAnalysis;
use app\common\service\FileService;
use app\common\service\MTranServerService;
class AiService
{
// 按当前业务要求固定走 GPT 中转,不再依赖后台模型配置。
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
private const GPT_PROXY_MODEL = 'gpt-5.5';
/**
* 赛事预测(完整,保留兼容)
*/
public static function predictMatch(int $matchId, array $matchData, int $userId = 0): array
{
// 检查缓存
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_MATCH_PREDICT, $matchId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzeMatch($matchId, $matchData, $userId);
self::saveResult(AiAnalysis::TYPE_MATCH_PREDICT, $matchId, '', $result, $userId);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackMatch($matchData);
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
// ── 分段赛事预测 ──
protected static array $sectionPrompts = [
'prediction' => '你是专业体育赛事分析师。请基于数据预测胜负和比分。
要求输出严格JSON
{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"简述理由"},"analysis":{"home_strength":78,"away_strength":72}}',
'factors' => '你是专业体育赛事分析师。请分析影响比赛的关键因素和风险。
要求输出严格JSON
{"key_factors":["利好因素1","利好因素2","利好因素3"],"risk_factors":["风险1","风险2"]}',
'odds' => '你是专业体育赛事分析师。请分析赔率并给出投注建议。
要求输出严格JSON
{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"赔率分析说明"}}',
'summary' => '你是专业体育赛事分析师。请给出简洁的分析总结。
要求输出严格JSON
{"summary":"完整的分析总结,200字左右"}',
];
public static function predictMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
{
$validSections = ['prediction', 'factors', 'odds', 'summary'];
if (!in_array($section, $validSections)) {
return ['success' => false, 'error' => '无效的分析模块'];
}
$cacheKey = AiAnalysis::TYPE_MATCH_PREDICT * 100 + array_search($section, $validSections);
$cache = AiAnalysis::getCache($cacheKey, $matchId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true, 'section' => $section];
}
}
$result = KbRagService::analyzeMatchSection($matchId, $section, $matchData, $userId);
self::saveResult($cacheKey, $matchId, '', $result, $userId, 6);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'], 'section' => $section];
}
$data = $result['parsed'] ?? [];
return ['success' => true, 'data' => $data, 'from_cache' => false, 'section' => $section];
}
/**
* 资讯AI分析
*/
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
{
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$result = KbRagService::analyzeArticle($articleId, $articleData, $userId);
$cacheHours = (int) AiConfig::getVal('article_analysis_cache_hours', '24');
self::saveResult(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId, '', $result, $userId, $cacheHours);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackArticle($articleData);
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 帖子AI分析
*/
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false, bool $force = false): array
{
$cache = $force ? null : AiAnalysis::getCache(AiAnalysis::TYPE_POST_ANALYSIS, $postId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isValidPostAnalysisCache($cacheData, $isLotteryPost)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzePost($postId, $postData, $userId, $isLotteryPost);
self::saveResult(AiAnalysis::TYPE_POST_ANALYSIS, $postId, '', $result, $userId, 12);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'] ?? 'AI分析失败'];
}
return [
'success' => true,
'data' => $result['parsed'] ?? self::buildFallbackPost($postData),
'from_cache' => false,
];
}
/**
* 用户可信度分析
*/
public static function analyzeCredibility(int $targetUserId, array $userData, int $requestUserId = 0): array
{
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$defaultPrompt = '你是体育社区内容审核AI。请根据用户的历史数据评估其可信度。
要求输出严格JSON格式:
{"credibility_score":75,"risk_level":"low/medium/high","is_suspicious":false,"analysis":"分析说明,100字左右","dimension_scores":{"hit_rate":80,"stability":85,"rationality":65,"activity":70},"suggestions":["建议1","建议2"]}';
$systemPrompt = AiConfig::getVal('credibility_analysis_prompt', $defaultPrompt);
$userMsg = "请分析以下用户数据并评估可信度:\n" . json_encode($userData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$client = new DeepSeekClient();
$result = $client->chatJson($systemPrompt, $userMsg);
self::saveResult(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId, '', $result, $requestUserId, 24);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackCredibility();
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 彩票概率分析
*/
public static function analyzeLottery(array $lotteryData, int $userId = 0): array
{
$refId = crc32(json_encode($lotteryData));
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$result = KbRagService::analyzeLotteryOverview($lotteryData, $userId);
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? ['disclaimer' => '分析暂时不可用,请稍后重试'];
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
public static function translateCommunityPost(string $content): array
{
if ($content === '') {
return ['success' => false, 'error' => '帖子内容为空'];
}
return MTranServerService::translate($content, 'zh-Hans', 'auto');
}
/**
* 资讯翻译
*/
public static function translateArticle(string $title, string $abstract, string $content): array
{
$title = trim($title);
$abstract = trim($abstract);
$content = trim($content);
if ($title === '' && $abstract === '' && $content === '') {
return ['success' => false, 'error' => '文章内容为空'];
}
if (mb_strlen($content) > 5000) {
$content = mb_substr($content, 0, 5000) . '...';
}
$parts = [];
if ($title !== '') {
$translatedTitle = MTranServerService::translate($title, 'zh-Hans', 'auto');
if (!$translatedTitle['success']) {
return $translatedTitle;
}
$parts[] = '标题:' . trim((string) $translatedTitle['content']);
}
if ($abstract !== '') {
$translatedAbstract = MTranServerService::translate($abstract, 'zh-Hans', 'auto');
if (!$translatedAbstract['success']) {
return $translatedAbstract;
}
$parts[] = '摘要:' . trim((string) $translatedAbstract['content']);
}
if ($content !== '') {
$translatedContentLines = [];
foreach (self::splitTextForTranslation($content) as $contentLine) {
$translatedContent = MTranServerService::translate($contentLine, 'zh-Hans', 'auto');
if (!$translatedContent['success']) {
return $translatedContent;
}
$translatedContentLines[] = trim((string) $translatedContent['content']);
}
$translatedContentLines = array_values(array_filter($translatedContentLines, static fn($line) => $line !== ''));
if (!empty($translatedContentLines)) {
$firstLine = array_shift($translatedContentLines);
$parts[] = '正文:' . $firstLine;
foreach ($translatedContentLines as $line) {
$parts[] = $line;
}
}
}
$finalText = trim(implode("\n", $parts));
if ($finalText === '') {
return ['success' => false, 'error' => '翻译结果为空'];
}
return [
'success' => true,
'content' => $finalText,
'tokens' => 0,
'cost_ms' => 0,
];
}
protected static function splitTextForTranslation(string $text): array
{
$normalized = trim(preg_replace('/\r\n|\r/u', "\n", $text));
if ($normalized === '') {
return [];
}
$manualLines = array_values(array_filter(array_map(static function ($line) {
$line = preg_replace('/\s+/u', ' ', trim($line));
return $line === '' ? '' : $line;
}, explode("\n", $normalized))));
if (count($manualLines) > 1) {
$segments = [];
foreach ($manualLines as $line) {
$segments = array_merge($segments, self::splitTextForTranslation($line));
}
return $segments;
}
$flatText = preg_replace('/\s+/u', ' ', $normalized);
preg_match_all('/[^。!?.!?]+[。!?.!?]?/u', $flatText, $matches);
$segments = array_values(array_filter(array_map('trim', $matches[0] ?? [])));
return !empty($segments) ? $segments : [$flatText];
}
/**
* 资讯评论生成
*/
public static function generateArticleComment(string $title, string $abstract, string $content, string $persona = ''): array
{
$title = trim($title);
$abstract = trim($abstract);
$content = trim($content);
if ($title === '' && $abstract === '' && $content === '') {
return ['success' => false, 'error' => '文章内容为空'];
}
if (mb_strlen($content) > 3500) {
$content = mb_substr($content, 0, 3500) . '...';
}
$systemPrompt = AiConfig::getVal(
'article_comment_prompt',
'你是一名专业、克制、真实的体育资讯评论员。请根据给定资讯内容生成1条中文评论,要求自然、具体、像真实用户发言,评论长度控制在20到60字之间。不要输出标题、前缀、解释、表情、标签、项目符号,不要提及自己是AI,不要复述全文。'
);
$personaText = $persona !== '' ? "评论视角:{$persona}\n" : '';
$userMsg = $personaText . "标题:{$title}\n摘要:{$abstract}\n正文:{$content}\n\n请输出1条适合发布在评论区的中文评论。";
$client = new DeepSeekClient();
return $client->chat($systemPrompt, $userMsg, ['max_tokens' => 200, 'temperature' => 0.75]);
}
public static function analyzeLotteryPostImages(string $content, array $images): array
{
if (empty($images)) {
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
}
$imageUrls = array_values(array_filter(array_map(function ($image) {
if (!$image) {
return '';
}
return self::prepareGptImageUrl(FileService::getFileUrl($image));
}, $images)));
if (empty($imageUrls)) {
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
}
$systemPrompt = AiConfig::getVal(
'community_lottery_image_analysis_prompt',
'你是彩票图片内容分析助手。只允许依据当前帖子图片和帖子文案中直接给出的当期信息进行分析。'
. '不要引用往期内容、历史期数、知识库信息或你自己的记忆。'
. '如果图片里没有明确展示某项信息,就直接说明无法判断。'
. '请先对图片内容做去噪过滤,只保留与当期分析直接相关的信息。'
. '有效信息包括:期号、号码、波色、单双、大小、生肖、五行、推荐组合、胆码拖码、走势箭头、统计表、图表结论、明确的标题结论。'
. '无关噪音包括:装饰背景、边框花纹、吉祥话、宣传口号、重复标题、水印、无关广告、联系方式、模板化祝福语、与分析无关的排版符号。'
. '对于重复出现的同一信息,只保留一次并合并描述;对于模糊、遮挡、低置信度识别内容,不要强行分析,可直接标记为“无法可靠识别”。'
. '请先完整识别图片中的号码、期号、版面文字、波色/单双/大小/生肖/五行、图表结论、胆码拖码、推荐组合或其他可见资料。'
. '识别出来的每一项内容,都要单独给出对应分析描述,不能只做整体概括。'
. '如果有多张图片,按图片1、图片2逐张拆开写;每张图片内再按“识别内容1/分析1、识别内容2/分析2”的方式一一对应展开。'
. '输出格式尽量清晰,推荐使用:图片X、过滤后识别内容、逐项分析、综合结论、风险提示。'
. '不要承诺中奖,不要编造图片中不存在的数据,最后给出理性购彩风险提示。'
);
$options = [
'max_tokens' => 2000,
'temperature' => 0.3,
'model' => self::GPT_PROXY_MODEL,
'base_url' => self::GPT_PROXY_BASE_URL,
'api_key' => self::GPT_PROXY_API_KEY,
'wire_api' => 'responses',
'provider_label' => 'OpenAI(GPT中转)',
];
$client = new DeepSeekClient();
$userMessage = "帖子内容:\n" . ($content ?: '无文字内容') . "\n\n请只依据当前帖子图片和上面的帖子文案输出分析。先过滤掉杂乱和无关内容,再提取图片里的有效信息,最后对提取出的每一项内容一一分析描述,不要补充任何往期推断。";
$result = $client->chatWithImages(
$systemPrompt,
$userMessage,
$imageUrls,
$options
);
return $result;
}
protected static function prepareGptImageUrl(string $imageUrl): string
{
if (!preg_match('/^https?:\/\//i', $imageUrl)) {
return $imageUrl;
}
$dataUrl = self::downloadImageAsDataUrl($imageUrl);
return $dataUrl !== '' ? $dataUrl : $imageUrl;
}
protected static function downloadImageAsDataUrl(string $imageUrl): string
{
$ch = curl_init($imageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 25,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_USERAGENT => 'Mozilla/5.0 SportEraBot/1.0',
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($httpCode !== 200 || !is_string($body) || $body === '' || strlen($body) > 8 * 1024 * 1024) {
return '';
}
$mime = strtolower(trim(explode(';', $contentType)[0] ?? ''));
if ($mime === '' || !str_starts_with($mime, 'image/')) {
$path = parse_url($imageUrl, PHP_URL_PATH) ?: '';
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
default => 'image/jpeg',
};
}
return 'data:' . $mime . ';base64,' . base64_encode($body);
}
/**
* 保存分析结果
*/
protected static function saveResult(int $type, int $refId, string $prompt, array $result, int $userId, int $cacheHours = 6): void
{
$status = $result['success'] ? AiAnalysis::STATUS_SUCCESS : AiAnalysis::STATUS_FAILED;
$resultJson = $result['success'] ? json_encode($result['parsed'] ?? $result['content'], JSON_UNESCAPED_UNICODE) : '';
AiAnalysis::create([
'type' => $type,
'ref_id' => $refId,
'prompt' => mb_substr($prompt, 0, 5000),
'result' => $resultJson,
'model' => (string) ($result['model'] ?? self::GPT_PROXY_MODEL),
'tokens_used' => $result['tokens'] ?? 0,
'status' => $status,
'error_msg' => $result['error'] ?? '',
'expire_time' => time() + $cacheHours * 3600,
]);
AiLog::create([
'user_id' => $userId,
'type' => $type,
'ref_id' => $refId,
'tokens_used' => $result['tokens'] ?? 0,
'cost_ms' => $result['cost_ms'] ?? 0,
'status' => $status,
]);
}
/**
* 彩票分模块分析(热冷号/趋势/统计)
*/
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
{
$latestIssue = $lotteryData['recent_draws'][0]['issue'] ?? '';
$cacheKey = $lotteryData['lottery_type'] . ':' . $module . ':' . $latestIssue;
$refId = crc32($cacheKey);
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && !empty($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzeLotteryModule($lotteryData, $module, $userId);
if (!$result['success']) {
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? null;
if (!is_array($data) || empty($data)) {
$result['success'] = false;
$result['error'] = 'AI返回格式异常';
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
return ['success' => false, 'error' => $result['error']];
}
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
// 持久化到彩票AI分析历史表
if ($latestIssue) {
$history = LotteryAiAnalysis::create([
'code' => $lotteryData['lottery_type'],
'issue' => $latestIssue,
'module' => $module,
'result' => json_encode($data, JSON_UNESCAPED_UNICODE),
]);
KbSyncService::enqueue('lottery', 'ai_history', (int) $history->id, 'upsert', 90);
}
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 赛事预测降级数据
*/
protected static function buildFallbackMatch(array $matchData): array
{
return [
'prediction' => [
'result' => '待分析',
'confidence' => 50,
'score_predict' => '-',
'reasoning' => 'AI分析暂时不可用,请稍后重试',
],
'analysis' => [
'home_strength' => 50,
'away_strength' => 50,
'key_factors' => ['数据加载中'],
'risk_factors' => ['AI服务响应异常'],
],
'odds_opinion' => ['value_bet' => '无', 'explanation' => '暂无数据'],
'summary' => '当前AI分析服务暂时不可用,请稍后重试。',
];
}
/**
* 资讯分析降级数据
*/
protected static function buildFallbackArticle(array $articleData): array
{
return [
'overall_score' => 60,
'summary' => $articleData['desc'] ?? '暂无摘要',
'leagues_and_teams' => ['leagues' => [], 'teams' => [], 'match_context' => ''],
'upcoming_matches' => [],
'head_to_head' => ['available' => false, 'records' => [], 'summary' => ''],
'betting_analysis' => [
'prediction' => '谨慎观望',
'confidence' => 50,
'reasoning' => 'AI分析暂时不可用,请稍后重试。',
'risk_level' => 'medium',
'key_factors' => [],
'value_bet' => '无明显价值',
],
'key_points' => ['详细分析正在生成中'],
'risk_warnings' => [],
'opposite_views' => [],
'sentiment' => ['label' => 'neutral', 'confidence' => 50],
'tags' => [],
'evidence_list' => [],
'similar_history' => [],
'from_kb' => false,
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
];
}
protected static function buildFallbackPost(array $postData): array
{
return [
'overall_score' => 60,
'summary' => self::truncatePlainText((string) ($postData['content'] ?? '当前帖子信息不足,暂时无法给出稳定结论。'), 120),
'credibility_signals' => [],
'risk_warning' => ['AI分析暂时不可用,请稍后重试。'],
'evidence_list' => [],
'similar_history' => [],
'from_kb' => false,
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
];
}
/**
* 可信度分析降级数据
*/
protected static function buildFallbackCredibility(): array
{
return [
'credibility_score' => 50,
'risk_level' => 'medium',
'is_suspicious' => false,
'analysis' => '可信度分析服务暂时不可用',
'dimension_scores' => ['hit_rate' => 50, 'stability' => 50, 'rationality' => 50, 'activity' => 50],
'suggestions' => [],
];
}
protected static function truncatePlainText(string $text, int $length): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
}
protected static function isKbEnhancedMatchPayload(array $payload): bool
{
return isset($payload['retrieval_meta']) || isset($payload['evidence_list']) || isset($payload['from_kb']);
}
protected static function isValidPostAnalysisCache(array $payload, bool $isLotteryPost): bool
{
if (empty($payload)) {
return false;
}
if (!$isLotteryPost) {
return true;
}
return self::isKbEnhancedMatchPayload($payload)
&& isset($payload['next_prediction'])
&& is_array($payload['next_prediction'])
&& ($payload['analysis_source'] ?? '') === 'image_free_v1';
}
}
@@ -0,0 +1,299 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
class DeepSeekClient
{
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
private const GPT_PROXY_MODEL = 'gpt-5.5';
private const GPT_PROXY_WIRE_API = 'responses';
protected string $apiKey;
protected string $baseUrl;
protected string $model;
protected string $providerLabel;
protected string $wireApi;
protected int $maxTokens;
protected float $temperature;
public function __construct()
{
$this->apiKey = self::GPT_PROXY_API_KEY;
$this->baseUrl = self::GPT_PROXY_BASE_URL;
$this->model = self::GPT_PROXY_MODEL;
$this->wireApi = self::GPT_PROXY_WIRE_API;
$this->providerLabel = 'OpenAI(GPT中转)';
$this->maxTokens = (int) AiConfig::getVal('max_tokens', '2000');
$this->temperature = (float) AiConfig::getVal('temperature', '0.7');
}
/**
* 发送聊天请求
*/
public function chat(string $systemPrompt, string $userMessage, array $options = []): array
{
$messages = [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userMessage],
];
return $this->request($messages, $options);
}
public function chatMessages(array $messages, array $options = []): array
{
if (empty($messages)) {
return ['success' => false, 'error' => 'messages不能为空', 'tokens' => 0];
}
return $this->request($messages, $options);
}
public function chatWithImages(string $systemPrompt, string $userMessage, array $imageUrls, array $options = []): array
{
$content = [
['type' => 'text', 'text' => $userMessage],
];
foreach ($imageUrls as $imageUrl) {
if ($imageUrl === '') {
continue;
}
$content[] = [
'type' => 'image_url',
'image_url' => ['url' => $imageUrl],
];
}
$messages = [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $content],
];
return $this->request($messages, $options);
}
protected function request(array $messages, array $options = []): array
{
$apiKey = $options['api_key'] ?? $this->apiKey;
$providerLabel = $options['provider_label'] ?? $this->providerLabel;
if (empty($apiKey)) {
return ['success' => false, 'error' => $providerLabel . ' API Key未配置', 'tokens' => 0, 'model' => $options['model'] ?? $this->model, 'provider_label' => $providerLabel];
}
$startTime = microtime(true);
$baseUrl = rtrim($options['base_url'] ?? $this->baseUrl, '/');
$wireApi = strtolower((string) ($options['wire_api'] ?? $this->wireApi));
$payload = $wireApi === 'responses'
? $this->buildResponsesPayload($messages, $options)
: $this->buildChatPayload($messages, $options);
$endpoint = $wireApi === 'responses'
? $this->buildResponsesEndpoint($baseUrl)
: $this->buildChatEndpoint($baseUrl);
$timeout = $this->resolveTimeout($options['timeout'] ?? null);
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => min(15, $timeout),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$costMs = (int) ((microtime(true) - $startTime) * 1000);
if ($error) {
return ['success' => false, 'error' => 'cURL错误: ' . $error, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
}
$data = json_decode($response, true);
$content = $wireApi === 'responses' ? $this->extractResponsesContent($data) : ($data['choices'][0]['message']['content'] ?? '');
if ($httpCode !== 200 || $content === '') {
$errMsg = $data['error']['message'] ?? ('HTTP ' . $httpCode);
return ['success' => false, 'error' => $providerLabel . ' 返回异常: ' . $errMsg, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
}
$tokens = $data['usage']['total_tokens'] ?? (($data['usage']['input_tokens'] ?? 0) + ($data['usage']['output_tokens'] ?? 0));
return [
'success' => true,
'content' => $content,
'tokens' => $tokens,
'cost_ms' => $costMs,
'model' => $payload['model'],
'provider_label' => $providerLabel,
];
}
protected function buildChatPayload(array $messages, array $options): array
{
return [
'model' => $options['model'] ?? $this->model,
'messages' => $messages,
'max_tokens' => $options['max_tokens'] ?? $this->maxTokens,
'temperature' => $options['temperature'] ?? $this->temperature,
];
}
protected function buildResponsesPayload(array $messages, array $options): array
{
$instructions = [];
$input = [];
foreach ($messages as $message) {
$role = (string) ($message['role'] ?? 'user');
$content = $message['content'] ?? '';
if ($role === 'system') {
$text = $this->contentToText($content);
if ($text !== '') {
$instructions[] = $text;
}
continue;
}
$input[] = [
'role' => $role === 'assistant' ? 'assistant' : 'user',
'content' => $this->convertResponsesContent($content),
];
}
$payload = [
'model' => $options['model'] ?? $this->model,
'input' => $input,
'max_output_tokens' => $options['max_tokens'] ?? $this->maxTokens,
'temperature' => $options['temperature'] ?? $this->temperature,
];
if (!empty($instructions)) {
$payload['instructions'] = implode("\n\n", $instructions);
}
return $payload;
}
protected function convertResponsesContent($content): array
{
if (is_string($content)) {
return [['type' => 'input_text', 'text' => $content]];
}
$rows = [];
foreach ((array) $content as $item) {
$type = (string) ($item['type'] ?? '');
if ($type === 'text') {
$rows[] = ['type' => 'input_text', 'text' => (string) ($item['text'] ?? '')];
} elseif ($type === 'image_url') {
$imageUrl = $item['image_url']['url'] ?? '';
if ($imageUrl !== '') {
$rows[] = ['type' => 'input_image', 'image_url' => (string) $imageUrl];
}
}
}
return $rows ?: [['type' => 'input_text', 'text' => '']];
}
protected function contentToText($content): string
{
if (is_string($content)) {
return trim($content);
}
$texts = [];
foreach ((array) $content as $item) {
if (($item['type'] ?? '') === 'text') {
$texts[] = (string) ($item['text'] ?? '');
}
}
return trim(implode("\n", $texts));
}
protected function extractResponsesContent(array $data): string
{
if (!empty($data['output_text'])) {
return (string) $data['output_text'];
}
$texts = [];
foreach (($data['output'] ?? []) as $output) {
foreach (($output['content'] ?? []) as $content) {
if (isset($content['text'])) {
$texts[] = (string) $content['text'];
}
}
}
return trim(implode("\n", $texts));
}
protected function buildChatEndpoint(string $baseUrl): string
{
if (str_ends_with($baseUrl, '/chat/completions')) {
return $baseUrl;
}
if (str_ends_with($baseUrl, '/v1')) {
return $baseUrl . '/chat/completions';
}
return $baseUrl . '/v1/chat/completions';
}
protected function buildResponsesEndpoint(string $baseUrl): string
{
if (str_ends_with($baseUrl, '/responses')) {
return $baseUrl;
}
if (str_ends_with($baseUrl, '/v1')) {
return $baseUrl . '/responses';
}
return $baseUrl . '/v1/responses';
}
protected function resolveTimeout($timeout): int
{
$value = (int) ($timeout ?: AiConfig::getVal('ai_request_timeout', '180'));
return max(30, min($value, 300));
}
/**
* 发送请求并解析JSON结果
*/
public function chatJson(string $systemPrompt, string $userMessage, array $options = []): array
{
$result = $this->chat($systemPrompt, $userMessage, $options);
if (!$result['success']) {
return $result;
}
$content = $result['content'];
// 尝试提取JSON块
if (preg_match('/```json\s*(.*?)\s*```/s', $content, $m)) {
$content = $m[1];
} elseif (preg_match('/\{.*\}/s', $content, $m)) {
$content = $m[0];
}
$parsed = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$result['parsed'] = null;
$result['raw_content'] = $result['content'];
} else {
$result['parsed'] = $parsed;
}
return $result;
}
}
@@ -0,0 +1,111 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
class EmbeddingClient
{
private const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
private const DASHSCOPE_EMBEDDING_MODEL = 'text-embedding-v4';
protected string $apiKey;
protected string $baseUrl;
protected string $model;
protected int $defaultDim;
public function __construct()
{
$this->apiKey = (string) (AiConfig::getVal('embedding_api_key', '') ?: env('DASHSCOPE_API_KEY', ''));
$this->baseUrl = (string) (AiConfig::getVal('embedding_base_url', '') ?: self::DASHSCOPE_BASE_URL);
$this->model = (string) (AiConfig::getVal('embedding_model', '') ?: self::DASHSCOPE_EMBEDDING_MODEL);
$this->defaultDim = (int) (AiConfig::getVal('embedding_dim', '') ?: '1024');
}
/**
* @param string|array<int, string> $input
*/
public function embed($input, array $options = []): array
{
$apiKey = $options['api_key'] ?? $this->apiKey;
$baseUrl = rtrim($options['base_url'] ?? $this->baseUrl, '/');
$model = $options['model'] ?? $this->model;
if ($apiKey === '' || $baseUrl === '' || $model === '') {
return [
'success' => false,
'error' => 'Embedding API配置未完成',
'embeddings' => [],
'cost_ms' => 0,
];
}
$payload = [
'model' => $model,
'input' => $input,
];
if (!empty($options['dimensions'])) {
$payload['dimensions'] = (int) $options['dimensions'];
} elseif ($this->defaultDim > 0) {
$payload['dimensions'] = $this->defaultDim;
}
$startTime = microtime(true);
$ch = curl_init($baseUrl . '/v1/embeddings');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$costMs = (int) ((microtime(true) - $startTime) * 1000);
if ($error) {
return [
'success' => false,
'error' => 'Embedding cURL错误: ' . $error,
'embeddings' => [],
'cost_ms' => $costMs,
];
}
$data = json_decode((string) $response, true);
if ($httpCode !== 200 || empty($data['data']) || !is_array($data['data'])) {
return [
'success' => false,
'error' => $data['error']['message'] ?? ('HTTP ' . $httpCode),
'embeddings' => [],
'cost_ms' => $costMs,
];
}
$embeddings = [];
foreach ($data['data'] as $row) {
$embedding = $row['embedding'] ?? [];
if (is_array($embedding) && !empty($embedding)) {
$embeddings[] = array_map('floatval', $embedding);
}
}
return [
'success' => !empty($embeddings),
'error' => !empty($embeddings) ? '' : 'Embedding结果为空',
'embeddings' => $embeddings,
'model' => $model,
'dimension' => count($embeddings[0] ?? []),
'tokens' => (int) ($data['usage']['total_tokens'] ?? 0),
'cost_ms' => $costMs,
];
}
}
@@ -0,0 +1,739 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
class KbRagService
{
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
private const GPT_PROXY_MODEL = 'gpt-5.5';
public static function analyzeMatch(int $matchId, array $matchData, int $userId = 0): array
{
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_analysis');
$systemPrompt = AiConfig::getVal('match_predict_prompt', self::defaultMatchPrompt());
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, true);
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
return self::normalizeMatchResult($parsed, $retrieval);
}, ['max_tokens' => 1400]);
}
public static function analyzeMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
{
$validSections = ['prediction', 'factors', 'odds', 'summary'];
if (!in_array($section, $validSections, true)) {
return ['success' => false, 'error' => '无效的分析模块'];
}
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_' . $section);
$systemPrompt = AiConfig::getVal('match_predict_' . $section . '_prompt', self::matchSectionPrompt($section));
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, false, $section);
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($section, $retrieval) {
return self::normalizeMatchSectionResult($section, $parsed, $retrieval);
}, ['max_tokens' => 1000]);
}
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
{
$retrieval = KbService::retrieve(
'article_analysis',
self::buildArticleQuery($articleData),
[
'user_id' => $userId,
'ref_id' => $articleId,
'domains' => ['article', 'post'],
'preferred_domain' => 'article',
'cid' => (int) ($articleData['cid'] ?? 0),
'exclude_source' => ['domain' => 'article', 'source_id' => $articleId],
],
[
'title' => (string) ($articleData['title'] ?? ''),
'summary' => (string) ($articleData['abstract'] ?? $articleData['desc'] ?? ''),
],
self::intConfig('kb_final_topk', 8)
);
$systemPrompt = AiConfig::getVal('article_analysis_prompt', self::defaultArticlePrompt());
$userMessage = "当前文章:\n" . json_encode([
'id' => $articleId,
'title' => $articleData['title'] ?? '',
'abstract' => $articleData['abstract'] ?? '',
'author' => $articleData['author'] ?? '',
'category' => $articleData['category'] ?? '',
'content' => self::clipText(strip_tags((string) ($articleData['content'] ?? '')), 2500),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n知识库证据:\n"
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请严格依据当前文章和知识库证据输出JSON。";
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
return self::normalizeArticleResult($parsed, $retrieval);
});
}
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false): array
{
$tags = $postData['tags'] ?? [];
if ($isLotteryPost) {
$retrieval = self::emptyRetrieval();
$systemPrompt = self::lotteryPostImagePrompt();
$userMessage = "当前六合彩帖子与图片识别内容:\n" . json_encode([
'id' => $postId,
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
'tags' => $tags,
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 2200),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请只依据当前帖子图片识别内容和帖子文案自由解读,不需要知识库证据,也不要输出“知识库证据不足”。";
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
return self::normalizePostResult($parsed, $retrieval, $postData);
}, self::gptPostOptions());
}
$domains = ['post', 'article'];
$retrieval = KbService::retrieve(
'post_analysis',
self::buildPostQuery($postData),
[
'user_id' => $userId,
'ref_id' => $postId,
'domains' => $domains,
'preferred_domain' => 'post',
'tags' => is_array($tags) ? $tags : [],
'exclude_source' => ['domain' => 'post', 'source_id' => $postId],
],
[
'title' => (string) ($postData['content'] ?? ''),
],
self::intConfig('kb_final_topk', 8)
);
$systemPrompt = AiConfig::getVal('post_analysis_prompt', self::defaultPostPrompt());
$userMessage = "当前帖子:\n" . json_encode([
'id' => $postId,
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
'tags' => $tags,
'post_type' => $postData['post_type'] ?? 0,
'is_paid' => $postData['is_paid'] ?? 0,
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 1000),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n知识库证据:\n"
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请输出JSON,不允许编造知识库里不存在的历史事实。";
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
return self::normalizePostResult($parsed, $retrieval, $postData);
}, self::gptPostOptions());
}
public static function analyzeLotteryOverview(array $lotteryData, int $userId = 0): array
{
$code = (string) ($lotteryData['lottery_type'] ?? '');
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
$retrieval = KbService::retrieve(
'lottery_analysis',
self::buildLotteryQuery($lotteryData),
[
'user_id' => $userId,
'ref_id' => 0,
'domains' => ['lottery', 'post'],
'preferred_domain' => 'lottery',
'code' => $code,
'issue' => $latestIssue,
],
[
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
],
self::intConfig('kb_final_topk', 8)
);
$systemPrompt = AiConfig::getVal('lottery_analysis_prompt', self::defaultLotteryOverviewPrompt());
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n知识库证据:\n"
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请结合历史知识库证据和当前结构化数据输出JSON。";
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
return self::normalizeLotteryOverview($parsed, $retrieval);
}, ['max_tokens' => 1800]);
}
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
{
$code = (string) ($lotteryData['lottery_type'] ?? '');
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
$retrieval = KbService::retrieve(
'lottery_' . $module,
self::buildLotteryQuery($lotteryData),
[
'user_id' => $userId,
'ref_id' => 0,
'domains' => ['lottery', 'post'],
'preferred_domain' => 'lottery',
'code' => $code,
'issue' => $latestIssue,
],
[
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
],
self::intConfig('kb_final_topk', 8)
);
$systemPrompt = self::modulePrompt($module);
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n知识库证据:\n"
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请结合结构化数据和知识库证据输出JSON。";
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($module, $retrieval) {
return self::normalizeLotteryModule($module, $parsed, $retrieval);
}, ['max_tokens' => 1200]);
}
private static function runJsonPrompt(string $systemPrompt, string $userMessage, callable $normalizer, array $options = []): array
{
$client = new DeepSeekClient();
$result = $client->chatJson($systemPrompt, $userMessage, $options);
if (!$result['success']) {
return $result;
}
$parsed = is_array($result['parsed'] ?? null) ? $result['parsed'] : [];
$normalized = $normalizer($parsed);
$result['parsed'] = $normalized;
$result['content'] = json_encode($normalized, JSON_UNESCAPED_UNICODE);
return $result;
}
private static function gptPostOptions(): array
{
return [
'model' => self::GPT_PROXY_MODEL,
'base_url' => self::GPT_PROXY_BASE_URL,
'api_key' => self::GPT_PROXY_API_KEY,
'wire_api' => 'responses',
'provider_label' => 'OpenAI(GPT中转)',
'max_tokens' => 1200,
'temperature' => 0.35,
];
}
private static function compactHits(array $hits): array
{
return array_map(static function (array $hit) {
return [
'domain' => $hit['domain'] ?? '',
'subtype' => $hit['subtype'] ?? '',
'source_id' => $hit['source_id'] ?? 0,
'title' => $hit['title'] ?? '',
'summary' => $hit['summary'] ?? '',
'content' => self::clipText((string) ($hit['chunk_text'] ?? ''), 260),
'score' => $hit['score'] ?? 0,
'metadata' => $hit['metadata'] ?? [],
];
}, $hits);
}
private static function normalizeArticleResult(array $parsed, array $retrieval): array
{
$bettingAnalysis = $parsed['betting_analysis'] ?? [
'prediction' => '谨慎观望',
'confidence' => 55,
'reasoning' => '当前证据不足,建议谨慎观望。',
'risk_level' => 'medium',
'key_factors' => [],
'value_bet' => '无明显价值',
];
$bettingAnalysis['risk_level'] = self::normalizeRiskLevel((string) ($bettingAnalysis['risk_level'] ?? 'medium'));
return [
'overall_score' => (int) ($parsed['overall_score'] ?? 68),
'summary' => (string) ($parsed['summary'] ?? '暂无明确结论,请结合原文谨慎判断。'),
'leagues_and_teams' => $parsed['leagues_and_teams'] ?? ['leagues' => [], 'teams' => [], 'match_context' => ''],
'upcoming_matches' => $parsed['upcoming_matches'] ?? [],
'head_to_head' => $parsed['head_to_head'] ?? ['available' => false, 'records' => [], 'summary' => ''],
'betting_analysis' => $bettingAnalysis,
'risk_warnings' => $parsed['risk_warnings'] ?? ['以上内容仅供参考,请理性判断。'],
'key_points' => $parsed['key_points'] ?? [],
'opposite_views' => $parsed['opposite_views'] ?? [],
'sentiment' => $parsed['sentiment'] ?? ['label' => 'neutral', 'confidence' => 55],
'tags' => $parsed['tags'] ?? [],
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
];
}
private static function normalizePostResult(array $parsed, array $retrieval, array $postData): array
{
$result = [
'overall_score' => (int) ($parsed['overall_score'] ?? 66),
'summary' => (string) ($parsed['summary'] ?? '当前帖子可参考信息有限,请谨慎判断。'),
'credibility_signals' => $parsed['credibility_signals'] ?? [],
'risk_warning' => $parsed['risk_warning'] ?? ['历史样本有限,不宜过度放大单条帖子观点。'],
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
];
if (self::isLiuhePost($postData, $retrieval['hits'] ?? [])) {
$result['summary'] = self::normalizeLiuheImageText(
$result['summary'],
'本次分析仅依据当前图片和帖子文案进行自由解读,可重点关注图片中出现的诗句、生肖、波色、尾数、胆码和旺弱号码等线索;结果仅供内容解读。'
);
$result['credibility_signals'] = self::normalizeLiuheImageList(
$result['credibility_signals'],
['当前分析基于图片识别出的可见文字、号码和图表线索。']
);
$result['risk_warning'] = self::normalizeLiuheImageList(
$result['risk_warning'],
['彩票结果具有随机性,图片解读仅供参考,不构成投注建议。']
);
$result['next_prediction'] = self::normalizeLiuhePrediction($parsed['next_prediction'] ?? []);
$result['next_prediction']['reasons'] = self::normalizeLiuheImageList(
$result['next_prediction']['reasons'],
['推荐仅来自当前图片中的诗句、胆码、波色、尾数或生肖等线索。']
);
$result['image_interpretation'] = self::normalizeStringList($parsed['image_interpretation'] ?? []);
$result['poem_analysis'] = (string) ($parsed['poem_analysis'] ?? '');
$result['analysis_source'] = (string) ($parsed['analysis_source'] ?? 'image_free_v1');
}
return $result;
}
private static function emptyRetrieval(): array
{
return [
'hits' => [],
'hit_count' => 0,
'domains' => [],
'top_scores' => [],
];
}
private static function normalizeLotteryOverview(array $parsed, array $retrieval): array
{
return [
'overall_score' => (int) ($parsed['overall_score'] ?? 62),
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于历史样本的统计参考。'),
'hot_numbers' => $parsed['hot_numbers'] ?? [],
'cold_numbers' => $parsed['cold_numbers'] ?? [],
'trend_analysis' => $parsed['trend_analysis'] ?? '',
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
'statistics' => $parsed['statistics'] ?? [],
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
'risk_level' => $parsed['risk_level'] ?? '中',
'key_evidence' => $parsed['key_evidence'] ?? [],
'similar_issues' => $parsed['similar_issues'] ?? self::similarHistory($retrieval['hits'] ?? []),
'disclaimer' => $parsed['disclaimer'] ?? '本分析仅基于历史数据与知识库证据,不保证准确性,请理性参考。',
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
];
}
private static function normalizeLotteryModule(string $module, array $parsed, array $retrieval): array
{
$base = [
'overall_score' => (int) ($parsed['overall_score'] ?? 60),
'risk_warning' => $parsed['risk_warning'] ?? ['本结果只反映历史统计规律,不保证未来表现。'],
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
];
return match ($module) {
'hot_cold' => array_merge($base, [
'hot_numbers' => $parsed['hot_numbers'] ?? [],
'cold_numbers' => $parsed['cold_numbers'] ?? [],
'hot_analysis' => $parsed['hot_analysis'] ?? '',
'cold_analysis' => $parsed['cold_analysis'] ?? '',
]),
'trend' => array_merge($base, [
'trend_analysis' => $parsed['trend_analysis'] ?? '',
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
'disclaimer' => $parsed['disclaimer'] ?? '趋势分析仅供参考。',
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
]),
'stats' => array_merge($base, [
'odd_even_ratio' => $parsed['odd_even_ratio'] ?? '',
'big_small_ratio' => $parsed['big_small_ratio'] ?? '',
'sum_range' => $parsed['sum_range'] ?? '',
'consecutive_analysis' => $parsed['consecutive_analysis'] ?? '',
'span_analysis' => $parsed['span_analysis'] ?? '',
'frequency_table' => $parsed['frequency_table'] ?? [],
'summary' => $parsed['summary'] ?? '',
]),
'color_zodiac' => array_merge($base, [
'color_analysis' => $parsed['color_analysis'] ?? [],
'zodiac_analysis' => $parsed['zodiac_analysis'] ?? [],
'recommended' => $parsed['recommended'] ?? [],
]),
default => array_merge($base, $parsed),
};
}
private static function retrievalMeta(array $retrieval): array
{
return [
'hit_count' => (int) ($retrieval['hit_count'] ?? 0),
'domains' => $retrieval['domains'] ?? [],
'top_scores' => $retrieval['top_scores'] ?? [],
];
}
private static function similarHistory(array $hits): array
{
$rows = [];
foreach ($hits as $hit) {
$metadata = $hit['metadata'] ?? [];
$rows[] = [
'domain' => $hit['domain'] ?? '',
'source_id' => $hit['source_id'] ?? 0,
'title' => $hit['title'] ?? '',
'issue' => $metadata['issue'] ?? '',
'code' => $metadata['code'] ?? '',
'score' => $hit['score'] ?? 0,
];
}
return array_slice($rows, 0, 5);
}
private static function buildArticleQuery(array $articleData): string
{
return trim(implode(' ', array_filter([
$articleData['title'] ?? '',
$articleData['abstract'] ?? '',
$articleData['category'] ?? '',
$articleData['desc'] ?? '',
])));
}
private static function buildPostQuery(array $postData): string
{
$tags = is_array($postData['tags'] ?? null) ? implode(' ', $postData['tags']) : '';
$lotteryKey = (string) (($postData['ext']['lottery_key'] ?? '') ?: '');
return trim(implode(' ', array_filter([
self::clipText((string) ($postData['content'] ?? ''), 300),
$tags,
$lotteryKey,
])));
}
private static function buildLotteryQuery(array $lotteryData): string
{
$recentIssues = array_column($lotteryData['recent_draws'] ?? [], 'issue');
return trim(implode(' ', array_filter([
$lotteryData['lottery_type'] ?? '',
$lotteryData['lottery_name'] ?? '',
implode(' ', array_slice($recentIssues, 0, 5)),
])));
}
private static function buildMatchQuery(array $matchData): string
{
$matchInfo = $matchData['match_info'] ?? [];
$headToHead = $matchData['head_to_head'] ?? [];
$keywords = [
$matchInfo['home_team'] ?? '',
$matchInfo['away_team'] ?? '',
$matchInfo['league'] ?? '',
];
foreach (array_slice($headToHead, 0, 3) as $row) {
$keywords[] = $row['home_team'] ?? '';
$keywords[] = $row['away_team'] ?? '';
$keywords[] = $row['league_name'] ?? ($row['league'] ?? '');
}
return trim(implode(' ', array_filter($keywords)));
}
private static function retrieveMatchEvidence(int $matchId, array $matchData, int $userId, string $scene): array
{
$matchInfo = $matchData['match_info'] ?? [];
return KbService::retrieve(
$scene,
self::buildMatchQuery($matchData),
[
'user_id' => $userId,
'ref_id' => $matchId,
'domains' => ['article', 'post'],
'preferred_domain' => 'article',
],
[
'title' => trim(($matchInfo['home_team'] ?? '') . ' vs ' . ($matchInfo['away_team'] ?? '')),
'league' => (string) ($matchInfo['league'] ?? ''),
'teams' => array_values(array_filter([
$matchInfo['home_team'] ?? '',
$matchInfo['away_team'] ?? '',
])),
],
self::intConfig('kb_final_topk', 8)
);
}
private static function buildMatchUserMessage(
array $matchData,
array $retrieval,
bool $full = false,
string $section = ''
): string {
$label = $full ? '完整赛事分析' : ('赛事分段分析-' . $section);
return $label . "\n当前赛事结构化数据:\n"
. json_encode([
'match_info' => $matchData['match_info'] ?? [],
'head_to_head' => self::compactMatchRows($matchData['head_to_head'] ?? [], 5),
'home_recent' => self::compactMatchRows($matchData['home_recent'] ?? [], 5),
'away_recent' => self::compactMatchRows($matchData['away_recent'] ?? [], 5),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n知识库证据(资讯/帖子):\n"
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
. "\n\n请优先依据当前赛事结构化数据与知识库证据输出,不允许编造未命中的历史事实。";
}
private static function compactMatchRows(array $rows, int $limit = 5): array
{
$compact = [];
foreach (array_slice($rows, 0, $limit) as $row) {
$compact[] = [
'match_time' => $row['match_time'] ?? '',
'league' => $row['league_name'] ?? ($row['league'] ?? ''),
'home_team' => $row['home_team'] ?? ($row['team_name'] ?? ''),
'away_team' => $row['away_team'] ?? ($row['opponent'] ?? ''),
'score' => [
'home' => $row['home_score'] ?? ($row['team_score'] ?? null),
'away' => $row['away_score'] ?? ($row['opponent_score'] ?? null),
],
'result' => $row['result'] ?? '',
];
}
return $compact;
}
private static function clipText(string $text, int $length): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
}
private static function intConfig(string $name, int $default): int
{
return (int) AiConfig::getVal($name, (string) $default);
}
private static function defaultArticlePrompt(): string
{
return '你是一位体育资讯AI研报助手。请严格根据当前文章和知识库证据生成分析,不允许虚构未命中的历史事实。'
. '必须输出JSON,字段包含:'
. '{"overall_score":72,"summary":"...","leagues_and_teams":{"leagues":[],"teams":[],"match_context":""},"upcoming_matches":[],"head_to_head":{"available":false,"records":[],"summary":""},"betting_analysis":{"prediction":"谨慎观望","confidence":55,"reasoning":"...","risk_level":"medium","key_factors":[],"value_bet":"无明显价值"},"risk_warnings":["..."],"key_points":["..."],"opposite_views":["..."],"sentiment":{"label":"neutral","confidence":60},"tags":["..."],"key_evidence":["..."]}';
}
private static function defaultMatchPrompt(): string
{
return '你是一位体育赛事AI分析师。请结合当前赛事结构化数据(交手记录、双方近期战绩、赔率)与知识库中召回的相关资讯/帖子,生成完整比赛分析。'
. '若知识库证据不足,要明确说明证据不足。'
. '必须输出JSON{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72,"key_factors":["..."],"risk_factors":["..."]},"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"summary":"...","evidence_list":[],"risk_warning":["..."]}';
}
private static function defaultPostPrompt(): string
{
return '你是一位社区内容分析助手。请根据当前帖子和知识库证据,判断帖子的核心观点、可信线索与风险。'
. '如果知识库证据不足,必须明确说明证据不足。'
. '如果帖子属于六合彩(例如旧澳六合/新澳六合)资料或开奖类帖子,请额外给出基于当前帖子内容与知识库历史样本的下一期预测,字段包含推荐号码、信心和依据。'
. '输出JSON{"overall_score":66,"summary":"...","credibility_signals":["..."],"risk_warning":["..."],"next_prediction":{"recommended_numbers":["..."],"confidence":68,"reasons":["..."]}}';
}
private static function isLiuhePost(array $postData, array $hits): bool
{
$tags = is_array($postData['tags'] ?? null) ? $postData['tags'] : [];
if (in_array('旧澳六合', $tags, true) || in_array('新澳六合', $tags, true)) {
return true;
}
$ext = is_array($postData['ext'] ?? null) ? $postData['ext'] : [];
$lotteryKey = (string) ($ext['lottery_key'] ?? '');
if (in_array($lotteryKey, ['a6', 'xa6'], true)) {
return true;
}
foreach ($hits as $hit) {
$title = (string) ($hit['title'] ?? '');
$metadata = $hit['metadata'] ?? [];
$hitLotteryKey = (string) ($metadata['lottery_key'] ?? '');
if (str_contains($title, '旧澳六合') || str_contains($title, '新澳六合') || in_array($hitLotteryKey, ['a6', 'xa6'], true)) {
return true;
}
}
return false;
}
private static function normalizeLiuhePrediction(array $prediction): array
{
$numbers = self::normalizeStringList($prediction['recommended_numbers'] ?? []);
$reasons = self::normalizeStringList($prediction['reasons'] ?? []);
return [
'recommended_numbers' => $numbers,
'confidence' => max(0, min(100, (int) ($prediction['confidence'] ?? 60))),
'reasons' => $reasons,
];
}
private static function normalizeStringList($items): array
{
if (is_string($items)) {
$items = preg_split('/[\r\n;]+/u', $items) ?: [];
}
if (!is_array($items)) {
return [];
}
return array_values(array_filter(array_map(static function ($item) {
$text = trim((string) $item);
return $text === '' ? null : $text;
}, $items)));
}
private static function normalizeLiuheImageText(string $text, string $fallback): string
{
$text = trim($text);
if ($text === '' || str_contains($text, '知识库')) {
return $fallback;
}
return $text;
}
private static function normalizeLiuheImageList($items, array $fallback): array
{
$rows = array_values(array_filter(self::normalizeStringList($items), static function (string $item) {
return !str_contains($item, '知识库');
}));
return $rows ?: $fallback;
}
private static function lotteryPostImagePrompt(): string
{
return '你是一位六合彩图片资料解读助手。请只依据当前帖子文案与图片识别内容进行分析,不需要知识库证据。'
. '如果识别内容里包含诗句、字谜、藏宝图、玄机图、生肖、波色、尾数、胆码、旺弱号码、推荐组合,请逐项自由解读它们可能指向的生肖或号码。'
. '不要因为没有历史知识库证据而给出“证据不足”的主结论;可以说明彩票随机性和不保证命中,但主体要围绕图片内容展开。'
. '不要承诺必中,不要编造图片中不存在的文字或号码。'
. '输出JSON{"overall_score":66,"summary":"基于图片内容的综合解读","image_interpretation":["逐项解读"],"poem_analysis":"诗句/字谜解读,没有则为空","credibility_signals":["来自图片的可信线索"],"risk_warning":["理性提示"],"next_prediction":{"recommended_numbers":["..."],"confidence":60,"reasons":["..."]},"analysis_source":"image_free_v1"}';
}
private static function defaultLotteryOverviewPrompt(): string
{
return '你是一位彩票数据分析助手。请结合当前结构化开奖数据与知识库中的历史开奖、历史AI分析、相关帖子证据,输出兼容旧接口的JSON。'
. '必须包含字段:{"overall_score":62,"summary":"...","hot_numbers":[],"cold_numbers":[],"trend_analysis":"...","recommended_combinations":[],"statistics":{},"trend_direction":"震荡","risk_level":"中","key_evidence":["..."],"similar_issues":[],"disclaimer":"..."}';
}
private static function matchSectionPrompt(string $module): string
{
return match ($module) {
'prediction' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,预测比赛结果。输出JSON{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72},"evidence_list":[],"risk_warning":["..."]}',
'factors' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析关键因素与风险。输出JSON{"key_factors":["..."],"risk_factors":["..."],"evidence_list":[],"risk_warning":["..."]}',
'odds' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析赔率与投注方向。输出JSON{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"evidence_list":[],"risk_warning":["..."]}',
'summary' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,给出简洁结论。输出JSON{"summary":"...","evidence_list":[],"risk_warning":["..."]}',
default => '请输出JSON。',
};
}
private static function modulePrompt(string $module): string
{
return match ($module) {
'hot_cold' => '你是一位彩票热冷号分析助手。请结合当前结构化开奖数据和知识库证据输出JSON{"overall_score":60,"hot_numbers":[],"cold_numbers":[],"hot_analysis":"...","cold_analysis":"...","risk_warning":["..."]}',
'trend' => '你是一位彩票趋势分析助手。请结合当前结构化开奖数据和知识库证据输出JSON{"overall_score":60,"trend_analysis":"...","recommended_combinations":[],"trend_direction":"震荡","disclaimer":"...","risk_warning":["..."]}',
'stats' => '你是一位彩票统计分析助手。请结合当前结构化开奖数据和知识库证据输出JSON{"overall_score":60,"odd_even_ratio":"","big_small_ratio":"","sum_range":"","consecutive_analysis":"...","span_analysis":"...","frequency_table":[],"summary":"...","risk_warning":["..."]}',
'color_zodiac' => '你是一位六合彩颜色生肖分析助手。请结合当前结构化开奖数据和知识库证据输出JSON{"overall_score":60,"color_analysis":{},"zodiac_analysis":{},"recommended":{},"risk_warning":["..."]}',
default => '请输出JSON。',
};
}
private static function normalizeRiskLevel(string $value): string
{
$value = strtolower(trim($value));
return match ($value) {
'低', 'low' => 'low',
'高', 'high' => 'high',
default => 'medium',
};
}
private static function normalizeMatchResult(array $parsed, array $retrieval): array
{
return [
'prediction' => $parsed['prediction'] ?? [
'result' => '谨慎观望',
'confidence' => 55,
'score_predict' => '-',
'reasoning' => '当前证据不足,建议谨慎观望。',
],
'analysis' => array_merge([
'home_strength' => 50,
'away_strength' => 50,
'key_factors' => [],
'risk_factors' => [],
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
'odds_opinion' => $parsed['odds_opinion'] ?? [
'value_bet' => '无',
'explanation' => '赔率证据不足,建议谨慎观望。',
],
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
];
}
private static function normalizeMatchSectionResult(string $section, array $parsed, array $retrieval): array
{
$base = [
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
'from_kb' => !empty($retrieval['hits']),
'retrieval_meta' => self::retrievalMeta($retrieval),
'risk_warning' => $parsed['risk_warning'] ?? ['以上分析仅供参考,请结合比赛临场信息谨慎判断。'],
];
return match ($section) {
'prediction' => array_merge($base, [
'prediction' => $parsed['prediction'] ?? [
'result' => '谨慎观望',
'confidence' => 55,
'score_predict' => '-',
'reasoning' => '当前证据不足,建议谨慎观望。',
],
'analysis' => array_merge([
'home_strength' => 50,
'away_strength' => 50,
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
]),
'factors' => array_merge($base, [
'key_factors' => $parsed['key_factors'] ?? [],
'risk_factors' => $parsed['risk_factors'] ?? [],
]),
'odds' => array_merge($base, [
'odds_opinion' => $parsed['odds_opinion'] ?? [
'value_bet' => '无',
'explanation' => '赔率证据不足,建议谨慎观望。',
],
]),
'summary' => array_merge($base, [
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
]),
default => array_merge($base, $parsed),
};
}
}
+813
View File
@@ -0,0 +1,813 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
use app\common\model\ai\AiKbChunk;
use app\common\model\ai\AiKbDocument;
use app\common\model\ai\AiKbQueryLog;
use app\common\model\ai\AiLog;
use app\common\model\article\Article;
use app\common\model\article\ArticleCate;
use app\common\model\community\CommunityPost;
use app\common\model\community\CommunityTag;
use app\common\model\lottery\LotteryAiAnalysis;
use app\common\model\lottery\LotteryDrawResult;
use app\common\model\lottery\LotteryGame;
use app\common\model\match\MatchEvent;
use app\common\model\match\MatchHistory;
use app\common\model\match\MatchRecent;
use think\facade\Db;
class KbService
{
public const COLLECTION_GLOBAL = 'global';
public static function upsertDocument(string $domain, string $subtype, int $sourceId): array
{
$transactionStarted = false;
try {
$payload = self::buildDocumentPayload($domain, $subtype, $sourceId);
if (empty($payload)) {
self::deleteDocument($domain, $subtype, $sourceId);
return ['success' => false, 'error' => '源内容不存在或不可用'];
}
$contentHash = md5(json_encode([
$payload['title'],
$payload['summary'],
$payload['canonical_text'],
$payload['keywords'],
$payload['metadata_json'],
], JSON_UNESCAPED_UNICODE));
$document = AiKbDocument::where([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
])->findOrEmpty();
if (
!$document->isEmpty()
&& (string) $document->content_hash === $contentHash
&& (int) $document->is_active === 1
) {
$document->save([
'title' => $payload['title'],
'summary' => $payload['summary'],
'keywords' => $payload['keywords'],
'metadata_json' => $payload['metadata_json'],
'source_created_at' => $payload['source_created_at'],
'source_updated_at' => $payload['source_updated_at'],
'update_time' => time(),
]);
return [
'success' => true,
'document_id' => (int) $document->id,
'chunk_count' => (int) AiKbChunk::where('document_id', $document->id)->count(),
'skipped' => true,
];
}
$chunks = self::splitText(
$payload['canonical_text'],
self::intConfig('kb_chunk_size', 600),
self::intConfig('kb_chunk_overlap', 80)
);
if (empty($chunks)) {
$chunks = [$payload['summary'] ?: $payload['title'] ?: 'empty'];
}
$embeddingResult = (new EmbeddingClient())->embed($chunks);
if (!$embeddingResult['success']) {
return [
'success' => false,
'error' => $embeddingResult['error'] ?? 'embedding失败',
];
}
Db::startTrans();
$transactionStarted = true;
if ($document->isEmpty()) {
$document = AiKbDocument::create([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
'title' => $payload['title'],
'summary' => $payload['summary'],
'canonical_text' => $payload['canonical_text'],
'keywords' => $payload['keywords'],
'metadata_json' => $payload['metadata_json'],
'source_created_at' => $payload['source_created_at'],
'source_updated_at' => $payload['source_updated_at'],
'content_hash' => $contentHash,
'is_active' => 1,
'create_time' => time(),
'update_time' => time(),
]);
} else {
$document->save([
'title' => $payload['title'],
'summary' => $payload['summary'],
'canonical_text' => $payload['canonical_text'],
'keywords' => $payload['keywords'],
'metadata_json' => $payload['metadata_json'],
'source_created_at' => $payload['source_created_at'],
'source_updated_at' => $payload['source_updated_at'],
'content_hash' => $contentHash,
'is_active' => 1,
'update_time' => time(),
]);
}
AiKbChunk::where('document_id', $document->id)->delete();
$dimension = (int) ($embeddingResult['dimension'] ?? 0);
foreach ($chunks as $index => $chunkText) {
$embedding = $embeddingResult['embeddings'][$index] ?? [];
AiKbChunk::create([
'document_id' => $document->id,
'collection_name' => self::COLLECTION_GLOBAL,
'chunk_index' => $index,
'chunk_text' => $chunkText,
'embedding_json' => $embedding,
'embedding_model' => $embeddingResult['model'] ?? '',
'embedding_dim' => $dimension,
'vector_norm' => self::vectorNorm($embedding),
'keyword_text' => self::buildKeywordText($payload),
'create_time' => time(),
'update_time' => time(),
]);
}
Db::commit();
return [
'success' => true,
'document_id' => (int) $document->id,
'chunk_count' => count($chunks),
];
} catch (\Throwable $e) {
if ($transactionStarted) {
Db::rollback();
}
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function deleteDocument(string $domain, string $subtype, int $sourceId): void
{
AiKbDocument::where([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
])->update([
'is_active' => 0,
'update_time' => time(),
]);
}
public static function retrieve(
string $scene,
string $queryText,
array $filters = [],
array $structuredContext = [],
int $topK = 8
): array {
try {
$topK = $topK > 0 ? $topK : self::intConfig('kb_final_topk', 8);
$fulltextTopN = self::intConfig('kb_fulltext_topn', 200);
$vectorTopN = self::intConfig('kb_vector_topn', 30);
$candidates = self::loadCandidates($queryText, $filters, $fulltextTopN);
if (empty($candidates)) {
self::recordQueryLog($filters, $scene, $queryText, [], 0, 0, AiKbQueryLog::STATUS_SUCCESS);
return [
'success' => true,
'hits' => [],
'hit_count' => 0,
'domains' => [],
'top_scores' => [],
];
}
$queryEmbedding = [];
$embeddingResult = (new EmbeddingClient())->embed($queryText);
if (!empty($embeddingResult['success'])) {
$queryEmbedding = $embeddingResult['embeddings'][0] ?? [];
}
foreach ($candidates as &$candidate) {
$candidate['metadata_json'] = is_array($candidate['metadata_json'])
? $candidate['metadata_json']
: (json_decode((string) $candidate['metadata_json'], true) ?: []);
$candidate['embedding_json'] = is_array($candidate['embedding_json'])
? $candidate['embedding_json']
: (json_decode((string) $candidate['embedding_json'], true) ?: []);
$candidate['vector_score'] = !empty($queryEmbedding)
? self::cosineSimilarity($queryEmbedding, $candidate['embedding_json'])
: max(0.0, min(1.0, (float) ($candidate['text_score'] ?? 0)));
$candidate['freshness_score'] = self::freshnessScore((int) ($candidate['source_updated_at'] ?? 0));
$candidate['domain_score'] = self::domainScore($candidate, $filters);
$candidate['match_score'] = self::matchScore($candidate, $filters, $structuredContext);
$candidate['score'] = round(
$candidate['vector_score'] * 0.65
+ $candidate['freshness_score'] * 0.15
+ $candidate['domain_score'] * 0.10
+ $candidate['match_score'] * 0.10,
6
);
}
unset($candidate);
usort($candidates, static fn(array $a, array $b) => $b['score'] <=> $a['score']);
$candidates = array_slice($candidates, 0, $vectorTopN);
$hits = array_map(static function (array $candidate) {
return [
'document_id' => (int) $candidate['document_id'],
'domain' => $candidate['domain'],
'subtype' => $candidate['subtype'],
'source_id' => (int) $candidate['source_id'],
'title' => $candidate['title'],
'summary' => $candidate['summary'],
'chunk_text' => $candidate['chunk_text'],
'score' => (float) $candidate['score'],
'vector_score' => (float) $candidate['vector_score'],
'freshness_score' => (float) $candidate['freshness_score'],
'match_score' => (float) $candidate['match_score'],
'source_updated_at' => (int) $candidate['source_updated_at'],
'metadata' => $candidate['metadata_json'],
];
}, array_slice($candidates, 0, $topK));
$recordStatus = AiKbQueryLog::STATUS_SUCCESS;
self::recordQueryLog(
$filters,
$scene,
$queryText,
$hits,
(int) ($embeddingResult['tokens'] ?? 0),
(int) ($embeddingResult['cost_ms'] ?? 0),
$recordStatus
);
return [
'success' => true,
'hits' => $hits,
'hit_count' => count($hits),
'domains' => array_values(array_unique(array_column($hits, 'domain'))),
'top_scores' => array_values(array_map(static fn(array $hit) => $hit['score'], $hits)),
];
} catch (\Throwable $e) {
self::recordQueryLog($filters, $scene, $queryText, [], 0, 0, AiKbQueryLog::STATUS_FAILED);
return [
'success' => false,
'error' => $e->getMessage(),
'hits' => [],
'hit_count' => 0,
'domains' => [],
'top_scores' => [],
];
}
}
private static function loadCandidates(string $queryText, array $filters, int $limit): array
{
$queryText = trim($queryText);
$query = Db::name('ai_kb_chunk')->alias('c')
->join('ai_kb_document d', 'd.id = c.document_id')
->where('d.is_active', 1)
->field([
'c.document_id',
'c.chunk_text',
'c.embedding_json',
'c.embedding_dim',
'c.vector_norm',
'd.domain',
'd.subtype',
'd.source_id',
'd.title',
'd.summary',
'd.metadata_json',
'd.source_created_at',
'd.source_updated_at',
]);
if (!empty($filters['domains']) && is_array($filters['domains'])) {
$query->whereIn('d.domain', $filters['domains']);
}
if (!empty($filters['subtypes']) && is_array($filters['subtypes'])) {
$query->whereIn('d.subtype', $filters['subtypes']);
}
$candidates = [];
if ($queryText !== '') {
try {
$ftQuery = clone $query;
$ftQuery->fieldRaw(
"MATCH(c.chunk_text, c.keyword_text) AGAINST ('" . addslashes($queryText) . "' IN NATURAL LANGUAGE MODE) AS text_score"
);
$ftQuery->whereRaw(
"MATCH(c.chunk_text, c.keyword_text) AGAINST ('" . addslashes($queryText) . "' IN NATURAL LANGUAGE MODE)"
);
$candidates = $ftQuery->order('text_score', 'desc')->limit($limit)->select()->toArray();
} catch (\Throwable $e) {
$candidates = [];
}
}
if (empty($candidates)) {
$keywordParts = self::splitQueryTerms($queryText);
$fallback = clone $query;
foreach (array_slice($keywordParts, 0, 3) as $term) {
$fallback->where(function ($subQuery) use ($term) {
$subQuery->whereLike('c.chunk_text', '%' . $term . '%')
->whereOrLike('d.title', '%' . $term . '%')
->whereOrLike('d.keywords', '%' . $term . '%');
});
}
$candidates = $fallback->order('d.source_updated_at', 'desc')->limit($limit)->select()->toArray();
foreach ($candidates as &$candidate) {
$candidate['text_score'] = 0.2;
}
unset($candidate);
}
if (!empty($filters['exclude_source']['domain']) && !empty($filters['exclude_source']['source_id'])) {
$excludeDomain = $filters['exclude_source']['domain'];
$excludeSourceId = (int) $filters['exclude_source']['source_id'];
$candidates = array_values(array_filter($candidates, static function (array $candidate) use ($excludeDomain, $excludeSourceId) {
return !($candidate['domain'] === $excludeDomain && (int) $candidate['source_id'] === $excludeSourceId);
}));
}
return $candidates;
}
private static function buildDocumentPayload(string $domain, string $subtype, int $sourceId): array
{
return match ($domain . ':' . $subtype) {
'article:article' => self::buildArticlePayload($sourceId),
'post:post' => self::buildPostPayload($sourceId),
'lottery:draw_result' => self::buildLotteryDrawResultPayload($sourceId),
'lottery:ai_history' => self::buildLotteryAiHistoryPayload($sourceId),
'match:event' => self::buildMatchEventPayload($sourceId),
default => [],
};
}
private static function buildArticlePayload(int $sourceId): array
{
$article = Article::where('id', $sourceId)->findOrEmpty();
if ($article->isEmpty() || (int) $article->is_show !== 1 || !empty($article->delete_time)) {
return [];
}
$data = $article->toArray();
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : (json_decode((string) ($data['ext'] ?? ''), true) ?: []);
$translated = trim((string) ($ext['translated_content'] ?? ''));
$content = self::normalizeHtmlText($translated !== '' ? $translated : (string) ($data['content'] ?? ''));
$summary = trim((string) ($data['abstract'] ?: $data['desc'] ?: ''));
$cateName = ArticleCate::where('id', $data['cid'] ?? 0)->value('name') ?: '';
$keywords = array_filter([
$data['title'] ?? '',
$cateName,
$data['author'] ?? '',
$data['category'] ?? '',
]);
return [
'title' => (string) ($data['title'] ?? ''),
'summary' => self::normalizePlainText($summary),
'canonical_text' => trim(implode("\n\n", array_filter([
$data['title'] ?? '',
$summary,
$content,
]))),
'keywords' => implode(',', array_unique($keywords)),
'metadata_json' => [
'cid' => (int) ($data['cid'] ?? 0),
'cate_name' => $cateName,
'author' => (string) ($data['author'] ?? ''),
'category' => (string) ($data['category'] ?? ''),
'article_id' => (int) ($data['article_id'] ?? 0),
'published_at' => (string) ($data['published_at'] ?? ''),
'is_video' => (int) ($data['is_video'] ?? 0),
],
'source_created_at' => (int) ($data['create_time'] ?? 0),
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
];
}
private static function buildPostPayload(int $sourceId): array
{
$post = CommunityPost::where('id', $sourceId)->findOrEmpty();
if ($post->isEmpty() || (int) $post->status !== 1 || !empty($post->delete_time)) {
return [];
}
$data = $post->toArray();
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : (json_decode((string) ($data['ext'] ?? ''), true) ?: []);
$tagIds = Db::name('community_post_tag')->where('post_id', $sourceId)->column('tag_id');
$tags = !empty($tagIds)
? CommunityTag::whereIn('id', $tagIds)->column('name')
: [];
$segments = array_filter([
(string) ($data['content'] ?? ''),
(string) ($ext['translated_content'] ?? ''),
(string) ($ext['lottery_analysis_content'] ?? ''),
implode(' ', $tags),
]);
$canonicalText = self::normalizePlainText(implode("\n\n", $segments));
$summary = self::truncateText($canonicalText, 200);
return [
'title' => self::truncateText($canonicalText, 40) ?: '社区帖子#' . $sourceId,
'summary' => $summary,
'canonical_text' => $canonicalText,
'keywords' => implode(',', array_unique($tags)),
'metadata_json' => [
'post_type' => (int) ($data['post_type'] ?? 0),
'match_id' => (int) ($data['match_id'] ?? 0),
'is_paid' => (int) ($data['is_paid'] ?? 0),
'user_id' => (int) ($data['user_id'] ?? 0),
'tags' => array_values($tags),
],
'source_created_at' => (int) ($data['create_time'] ?? 0),
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
];
}
private static function buildLotteryDrawResultPayload(int $sourceId): array
{
$row = LotteryDrawResult::where('id', $sourceId)->findOrEmpty();
if ($row->isEmpty()) {
return [];
}
$data = $row->toArray();
$game = LotteryGame::where('code', $data['code'])->field('name,template')->findOrEmpty()->toArray();
$numbers = array_filter(explode(',', (string) ($data['draw_code'] ?? '')));
$trend = is_array($data['trend'] ?? null) ? $data['trend'] : (json_decode((string) ($data['trend'] ?? ''), true) ?: []);
$trendText = self::normalizePlainText(json_encode($trend, JSON_UNESCAPED_UNICODE));
$title = ($game['name'] ?? $data['code']) . ' 第' . $data['issue'] . '期开奖';
$summary = sprintf(
'%s 于 %s 开奖,开奖号码为 %s。',
$title,
(string) ($data['draw_time'] ?? ''),
implode('、', $numbers)
);
return [
'title' => $title,
'summary' => $summary,
'canonical_text' => trim(implode("\n", array_filter([
$summary,
!empty($data['next_issue']) ? '下一期期号:' . $data['next_issue'] : '',
!empty($data['next_time']) ? '下期开奖时间:' . $data['next_time'] : '',
$trendText !== 'null' ? '走势数据:' . $trendText : '',
]))),
'keywords' => implode(',', array_filter([
$data['code'],
$game['name'] ?? '',
$game['template'] ?? '',
$data['issue'] ?? '',
])),
'metadata_json' => [
'code' => (string) ($data['code'] ?? ''),
'issue' => (string) ($data['issue'] ?? ''),
'template' => (string) ($game['template'] ?? ''),
'game_name' => (string) ($game['name'] ?? ''),
'draw_time' => (string) ($data['draw_time'] ?? ''),
],
'source_created_at' => (int) ($data['create_time'] ?? 0),
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
];
}
private static function buildMatchEventPayload(int $sourceId): array
{
$match = MatchEvent::where('id', $sourceId)->findOrEmpty();
if ($match->isEmpty() || (int) ($match->is_show ?? 1) !== 1) {
return [];
}
$data = $match->toArray();
$homeTeam = (string) ($data['home_team'] ?? '');
$awayTeam = (string) ($data['away_team'] ?? '');
$league = (string) ($data['league_name'] ?? '');
$matchTime = !empty($data['match_time']) && is_numeric($data['match_time'])
? date('Y-m-d H:i', (int) $data['match_time'])
: (string) ($data['match_time'] ?? '');
$history = MatchHistory::where(function ($query) use ($homeTeam, $awayTeam) {
$query->where([['home_team', '=', $homeTeam], ['away_team', '=', $awayTeam]])
->whereOr([['home_team', '=', $awayTeam], ['away_team', '=', $homeTeam]]);
})->order('match_time desc')->limit(5)->select()->toArray();
$homeRecent = MatchRecent::where('team_name', $homeTeam)
->order('match_time desc')->limit(5)->select()->toArray();
$awayRecent = MatchRecent::where('team_name', $awayTeam)
->order('match_time desc')->limit(5)->select()->toArray();
$title = trim($homeTeam . ' vs ' . $awayTeam);
$summary = sprintf(
'%s %s%s 对阵 %s,比赛时间 %s,当前比分 %s-%s。',
$league,
$title,
$homeTeam,
$awayTeam,
$matchTime,
$data['home_score'] ?? 0,
$data['away_score'] ?? 0
);
$canonical = [
$summary,
'赔率:主胜' . ($data['home_odds'] ?? '-') . ',平局' . ($data['draw_odds'] ?? '-') . ',客胜' . ($data['away_odds'] ?? '-') . '。',
'历史交锋:' . self::normalizePlainText(json_encode($history, JSON_UNESCAPED_UNICODE)),
$homeTeam . '近期战绩:' . self::normalizePlainText(json_encode($homeRecent, JSON_UNESCAPED_UNICODE)),
$awayTeam . '近期战绩:' . self::normalizePlainText(json_encode($awayRecent, JSON_UNESCAPED_UNICODE)),
];
return [
'title' => $title ?: '赛事#' . $sourceId,
'summary' => self::normalizePlainText($summary),
'canonical_text' => trim(implode("\n", array_filter($canonical))),
'keywords' => implode(',', array_filter([$league, $homeTeam, $awayTeam])),
'metadata_json' => [
'league' => $league,
'teams' => array_values(array_filter([$homeTeam, $awayTeam])),
'match_time' => $matchTime,
'status' => (int) ($data['status'] ?? 0),
],
'source_created_at' => (int) ($data['create_time'] ?? $data['match_time'] ?? 0),
'source_updated_at' => (int) ($data['update_time'] ?? $data['match_time'] ?? $data['create_time'] ?? 0),
];
}
private static function buildLotteryAiHistoryPayload(int $sourceId): array
{
$row = LotteryAiAnalysis::where('id', $sourceId)->findOrEmpty();
if ($row->isEmpty()) {
return [];
}
$data = $row->toArray();
$resultText = is_string($data['result'] ?? '')
? $data['result']
: json_encode($data['result'] ?? [], JSON_UNESCAPED_UNICODE);
$canonicalText = self::normalizePlainText($resultText);
$title = sprintf('%s 第%s期 %s 历史AI分析', $data['code'] ?? '彩票', $data['issue'] ?? '-', $data['module'] ?? 'module');
return [
'title' => $title,
'summary' => self::truncateText($canonicalText, 220),
'canonical_text' => $canonicalText,
'keywords' => implode(',', array_filter([
$data['code'] ?? '',
$data['issue'] ?? '',
$data['module'] ?? '',
])),
'metadata_json' => [
'code' => (string) ($data['code'] ?? ''),
'issue' => (string) ($data['issue'] ?? ''),
'module' => (string) ($data['module'] ?? ''),
],
'source_created_at' => (int) ($data['create_time'] ?? 0),
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
];
}
private static function buildKeywordText(array $payload): string
{
return implode(' ', array_filter([
$payload['title'] ?? '',
$payload['summary'] ?? '',
$payload['keywords'] ?? '',
json_encode($payload['metadata_json'] ?? [], JSON_UNESCAPED_UNICODE),
]));
}
private static function splitText(string $text, int $chunkSize, int $overlap): array
{
$text = trim($text);
if ($text === '') {
return [];
}
$length = mb_strlen($text);
if ($length <= $chunkSize) {
return [$text];
}
$chunks = [];
$start = 0;
while ($start < $length) {
$chunks[] = mb_substr($text, $start, $chunkSize);
if ($start + $chunkSize >= $length) {
break;
}
$start += max(1, $chunkSize - $overlap);
}
return array_values(array_filter(array_map('trim', $chunks)));
}
private static function cosineSimilarity(array $a, array $b): float
{
if (empty($a) || empty($b) || count($a) !== count($b)) {
return 0.0;
}
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
foreach ($a as $i => $value) {
$value = (float) $value;
$other = (float) ($b[$i] ?? 0.0);
$dot += $value * $other;
$normA += $value * $value;
$normB += $other * $other;
}
if ($normA <= 0.0 || $normB <= 0.0) {
return 0.0;
}
return max(0.0, min(1.0, $dot / (sqrt($normA) * sqrt($normB))));
}
private static function vectorNorm(array $vector): float
{
$sum = 0.0;
foreach ($vector as $value) {
$value = (float) $value;
$sum += $value * $value;
}
return $sum > 0 ? sqrt($sum) : 0.0;
}
private static function freshnessScore(int $timestamp): float
{
if ($timestamp <= 0) {
return 0.1;
}
$days = max(0, (time() - $timestamp) / 86400);
if ($days <= 1) {
return 1.0;
}
if ($days <= 7) {
return 0.85;
}
if ($days <= 30) {
return 0.65;
}
if ($days <= 180) {
return 0.4;
}
return 0.2;
}
private static function domainScore(array $candidate, array $filters): float
{
$preferred = $filters['preferred_domain'] ?? '';
if ($preferred !== '' && ($candidate['domain'] ?? '') === $preferred) {
return 1.0;
}
return 0.5;
}
private static function matchScore(array $candidate, array $filters, array $structuredContext): float
{
$score = 0.2;
$metadata = $candidate['metadata_json'] ?? [];
$title = self::normalizePlainText((string) ($candidate['title'] ?? ''));
$summary = self::normalizePlainText((string) ($candidate['summary'] ?? ''));
$chunkText = self::normalizePlainText((string) ($candidate['chunk_text'] ?? ''));
$haystack = $title . ' ' . $summary . ' ' . $chunkText;
if (!empty($filters['cid']) && !empty($metadata['cid']) && (int) $filters['cid'] === (int) $metadata['cid']) {
$score += 0.4;
}
if (!empty($filters['code']) && !empty($metadata['code']) && $filters['code'] === $metadata['code']) {
$score += 0.4;
}
if (!empty($filters['issue']) && !empty($metadata['issue']) && $filters['issue'] === $metadata['issue']) {
$score += 0.2;
}
if (!empty($filters['tags']) && !empty($metadata['tags']) && is_array($metadata['tags'])) {
$common = array_intersect($filters['tags'], $metadata['tags']);
if (!empty($common)) {
$score += 0.3;
}
}
if (!empty($structuredContext['title'])) {
$sourceTitle = self::normalizePlainText((string) $structuredContext['title']);
if ($title !== '' && $sourceTitle !== '' && mb_strpos($title, mb_substr($sourceTitle, 0, 8)) !== false) {
$score += 0.2;
}
}
if (!empty($structuredContext['league'])) {
$league = self::normalizePlainText((string) $structuredContext['league']);
if ($league !== '' && mb_strpos($haystack, $league) !== false) {
$score += 0.15;
}
}
if (!empty($structuredContext['teams']) && is_array($structuredContext['teams'])) {
$teamHits = 0;
foreach ($structuredContext['teams'] as $team) {
$team = self::normalizePlainText((string) $team);
if ($team !== '' && mb_strpos($haystack, $team) !== false) {
$teamHits++;
}
}
if ($teamHits > 0) {
$score += min(0.3, 0.12 * $teamHits);
}
}
return max(0.0, min(1.0, $score));
}
private static function recordQueryLog(
array $filters,
string $scene,
string $queryText,
array $hits,
int $tokens,
int $costMs,
int $status
): void {
try {
$userId = (int) ($filters['user_id'] ?? 0);
$refId = (int) ($filters['ref_id'] ?? 0);
AiKbQueryLog::create([
'user_id' => $userId,
'scene' => $scene,
'ref_id' => $refId,
'query_text' => $queryText,
'filters_json' => $filters,
'hit_docs_json' => $hits,
'tokens_used' => $tokens,
'cost_ms' => $costMs,
'status' => $status,
'create_time' => time(),
'update_time' => time(),
]);
AiLog::create([
'user_id' => $userId,
'type' => 6,
'ref_id' => $refId,
'tokens_used' => $tokens,
'cost_ms' => $costMs,
'status' => $status === AiKbQueryLog::STATUS_SUCCESS ? 1 : 2,
]);
} catch (\Throwable $e) {
// Ignore logging failures to avoid affecting main flow.
}
}
private static function splitQueryTerms(string $queryText): array
{
$queryText = trim(self::normalizePlainText($queryText));
if ($queryText === '') {
return [];
}
$terms = preg_split('/[\s,,。!?;、:\/\\\\]+/u', $queryText) ?: [];
$terms = array_values(array_filter(array_map('trim', $terms), static fn(string $term) => mb_strlen($term) >= 2));
return array_unique($terms);
}
private static function normalizeHtmlText(string $html): string
{
$html = str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>', '</li>'], "\n", $html);
return self::normalizePlainText(strip_tags($html));
}
private static function normalizePlainText(string $text): string
{
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', trim($text)) ?: '';
return trim($text);
}
private static function truncateText(string $text, int $length): string
{
$text = trim($text);
if ($text === '') {
return '';
}
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
}
private static function intConfig(string $name, int $default): int
{
return (int) AiConfig::getVal($name, (string) $default);
}
}
@@ -0,0 +1,104 @@
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiKbDocument;
use app\common\model\ai\AiKbSyncJob;
use think\facade\Db;
class KbSyncService
{
public static function enqueue(
string $domain,
string $subtype,
int $sourceId,
string $action = AiKbSyncJob::ACTION_UPSERT,
int $priority = 100,
?int $scheduledAt = null
): void {
if ($domain === '' || $subtype === '' || $sourceId <= 0) {
return;
}
$scheduledAt = $scheduledAt ?: time();
$pending = AiKbSyncJob::where([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
])->whereIn('status', [AiKbSyncJob::STATUS_PENDING, AiKbSyncJob::STATUS_PROCESSING])
->order('id', 'desc')
->findOrEmpty();
if (!$pending->isEmpty() && (int) $pending->status === AiKbSyncJob::STATUS_PENDING) {
$pending->action = $action;
$pending->priority = $priority;
$pending->scheduled_at = $scheduledAt;
$pending->error_msg = '';
$pending->update_time = time();
$pending->save();
return;
}
AiKbSyncJob::create([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
'action' => $action,
'priority' => $priority,
'status' => AiKbSyncJob::STATUS_PENDING,
'retry_count' => 0,
'error_msg' => '',
'scheduled_at' => $scheduledAt,
'processed_at' => 0,
'create_time' => time(),
'update_time' => time(),
]);
}
public static function markLotteryBackfillJobs(int $limit = 200): void
{
self::enqueueMissingLotteryRows('draw_result', 'lottery_draw_result', $limit);
self::enqueueMissingLotteryRows('ai_history', 'lottery_ai_analysis', $limit);
}
public static function markMatchBackfillJobs(int $limit = 200): void
{
self::enqueueMissingRows('match', 'event', 'match', $limit);
}
private static function enqueueMissingLotteryRows(string $subtype, string $table, int $limit): void
{
self::enqueueMissingRows('lottery', $subtype, $table, $limit);
}
private static function enqueueMissingRows(string $domain, string $subtype, string $table, int $limit): void
{
try {
$rows = Db::name($table)->alias('s')
->leftJoin('ai_kb_document d', "d.domain = '{$domain}' AND d.subtype = '{$subtype}' AND d.source_id = s.id")
->whereNull('d.id')
->where('s.id', '>', 0)
->order('s.id', 'desc')
->limit($limit)
->column('s.id');
foreach ($rows as $sourceId) {
self::enqueue($domain, $subtype, (int) $sourceId, AiKbSyncJob::ACTION_UPSERT, 80);
}
} catch (\Throwable $e) {
// Ignore in environments where KB tables are not ready yet.
}
}
public static function markDocumentInactive(string $domain, string $subtype, int $sourceId): void
{
AiKbDocument::where([
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $sourceId,
])->update([
'is_active' => 0,
'update_time' => time(),
]);
}
}
@@ -0,0 +1,285 @@
<?php
namespace app\common\service\article;
use app\common\model\article\Article;
use app\common\model\article\ArticleAiCommentTask;
use app\common\model\article\ArticleComment;
use app\common\model\user\User;
use app\common\service\ai\AiService;
use think\facade\Config;
use think\facade\Db;
class ArticleAiCommentService
{
private const VIRTUAL_ACCOUNT_PREFIX = 'ai_comment_';
private const VIRTUAL_NICKNAME_PREFIX = 'AI评论员';
private const VIRTUAL_POOL_SIZE = 20;
public static function tick(int $seedLimit = 10, int $processLimit = 20): array
{
$virtualUsers = self::ensureVirtualUsers(self::VIRTUAL_POOL_SIZE);
$seeded = self::seedTasks($virtualUsers, $seedLimit);
$processed = self::processDueTasks($processLimit);
return [
'virtual_user_count' => count($virtualUsers),
'seeded' => $seeded,
'processed' => $processed,
];
}
protected static function seedTasks(array $virtualUsers, int $limit = 10): array
{
if (empty($virtualUsers)) {
return ['created' => 0, 'skipped' => 0];
}
$articles = Article::field('id,title,desc,content,author,ext,click_virtual,click_actual,comment_count,create_time,published_at')
->where('is_show', 1)
->whereNull('delete_time')
->where('create_time', '>=', strtotime('-14 days'))
->orderRaw('comment_count asc, (click_actual + click_virtual) desc, id desc')
->limit($limit)
->select()
->toArray();
$created = 0;
$skipped = 0;
foreach ($articles as $index => $article) {
$articleId = (int) ($article['id'] ?? 0);
if ($articleId <= 0) {
$skipped++;
continue;
}
$existingPending = ArticleAiCommentTask::where('article_id', $articleId)
->whereIn('status', [0, 1])
->count();
if ($existingPending > 0) {
$skipped++;
continue;
}
$desiredCount = self::resolveTaskCount($article);
if ($desiredCount <= 0) {
$skipped++;
continue;
}
for ($i = 0; $i < $desiredCount; $i++) {
$virtualUserId = $virtualUsers[($articleId + $index + $i) % count($virtualUsers)];
$comment = self::generateCommentContent($article, $virtualUserId);
if ($comment === '') {
continue;
}
$runAt = time() + random_int(600, 43200);
ArticleAiCommentTask::create([
'article_id' => $articleId,
'virtual_user_id' => $virtualUserId,
'comment_content' => $comment,
'run_at' => $runAt,
'status' => 0,
'create_time' => time(),
'update_time' => time(),
]);
$created++;
}
}
return compact('created', 'skipped');
}
protected static function processDueTasks(int $limit = 20): array
{
$tasks = ArticleAiCommentTask::where('status', 0)
->where('run_at', '<=', time())
->order('run_at', 'asc')
->limit($limit)
->select()
->toArray();
$done = 0;
$failed = 0;
foreach ($tasks as $task) {
$articleId = (int) ($task['article_id'] ?? 0);
$content = trim((string) ($task['comment_content'] ?? ''));
$virtualUserId = (int) ($task['virtual_user_id'] ?? 0);
if ($articleId <= 0 || $content === '' || $virtualUserId <= 0) {
self::markTaskFailed((int) $task['id'], '任务数据不完整');
$failed++;
continue;
}
$article = Article::where('id', $articleId)
->where('is_show', 1)
->whereNull('delete_time')
->findOrEmpty();
if ($article->isEmpty()) {
self::markTaskFailed((int) $task['id'], '文章不存在或已下架');
$failed++;
continue;
}
Db::startTrans();
try {
ArticleComment::create([
'article_id' => $articleId,
'user_id' => $virtualUserId,
'parent_id' => 0,
'reply_user_id' => 0,
'content' => $content,
'is_show' => 1,
'create_time' => time(),
'update_time' => time(),
]);
Article::where('id', $articleId)->inc('comment_count')->update();
ArticleAiCommentTask::where('id', $task['id'])->update([
'status' => 1,
'update_time' => time(),
]);
Db::commit();
$done++;
} catch (\Throwable $e) {
Db::rollback();
self::markTaskFailed((int) $task['id'], $e->getMessage());
$failed++;
}
}
return compact('done', 'failed');
}
protected static function ensureVirtualUsers(int $targetCount = self::VIRTUAL_POOL_SIZE): array
{
$existing = User::where('account', 'like', self::VIRTUAL_ACCOUNT_PREFIX . '%')
->order('id', 'asc')
->column('id');
$existing = array_values(array_map('intval', $existing));
$missing = max(0, $targetCount - count($existing));
if ($missing > 0) {
$avatar = (string) Config::get('project.default_image.user_avatar', '');
$passwordSalt = (string) Config::get('project.unique_identification', 'sport-era');
for ($i = 1; $i <= $missing; $i++) {
$accountIndex = count($existing) + $i;
$sn = User::createUserSn('9');
$password = create_password(bin2hex(random_bytes(6)), $passwordSalt);
$user = User::create([
'sn' => $sn,
'avatar' => $avatar,
'real_name' => '',
'nickname' => self::VIRTUAL_NICKNAME_PREFIX . str_pad((string) $accountIndex, 2, '0', STR_PAD_LEFT),
'account' => self::VIRTUAL_ACCOUNT_PREFIX . str_pad((string) $accountIndex, 4, '0', STR_PAD_LEFT),
'password' => $password,
'mobile' => '',
'sex' => 0,
'channel' => 0,
'is_disable' => 0,
'is_new_user' => 0,
'user_money' => 0,
'total_recharge_amount' => 0,
]);
$existing[] = (int) $user->id;
}
}
return $existing;
}
protected static function resolveTaskCount(array $article): int
{
$commentCount = (int) ($article['comment_count'] ?? 0);
$clickCount = (int) ($article['click_actual'] ?? 0) + (int) ($article['click_virtual'] ?? 0);
if ($commentCount >= 20) {
return 0;
}
if ($clickCount >= 500) {
return 2;
}
return 1;
}
protected static function generateCommentContent(array $article, int $virtualUserId): string
{
$title = trim((string) ($article['title'] ?? ''));
$abstract = trim((string) ($article['desc'] ?? ''));
$content = self::normalizeContent((string) ($article['content'] ?? ''));
if ($title === '' && $abstract === '' && $content === '') {
return '';
}
$personaPool = [
'资深球迷',
'战术观察员',
'数据控',
'赛事评论员',
'球迷视角',
];
$persona = $personaPool[$virtualUserId % count($personaPool)];
$result = AiService::generateArticleComment($title, $abstract, $content, $persona);
if (empty($result['success'])) {
return self::fallbackComment($title, $abstract, $content);
}
$comment = trim((string) ($result['content'] ?? ''));
$comment = preg_replace('/^评论[:]\s*/u', '', $comment);
$comment = preg_replace('/^[“"‘’\'\']|[”"‘’\'\']$/u', '', $comment);
$comment = preg_replace('/\s+/u', ' ', $comment);
$comment = trim($comment, " \t\n\r\0\x0B,。,.!?;:");
if ($comment === '' || mb_strlen($comment) < 8) {
return self::fallbackComment($title, $abstract, $content);
}
return mb_substr($comment, 0, 120);
}
protected static function fallbackComment(string $title, string $abstract, string $content): string
{
$focus = $title ?: ($abstract ?: mb_substr($content, 0, 18));
if ($focus === '') {
return '';
}
return mb_substr($focus, 0, 18) . '这条信息量挺足,后续关键数据变化值得继续关注。';
}
protected static function markTaskFailed(int $taskId, string $message): void
{
if ($taskId <= 0) {
return;
}
ArticleAiCommentTask::where('id', $taskId)->update([
'status' => 2,
'error_msg' => mb_substr($message, 0, 255),
'update_time' => time(),
]);
}
protected static function normalizeContent(string $content): string
{
if ($content === '') {
return '';
}
$content = strip_tags($content);
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$content = preg_replace('/\s+/u', ' ', $content);
return trim($content);
}
}
@@ -0,0 +1,241 @@
<?php
namespace app\common\service\article;
use think\Response;
class ArticleImageProxyService
{
private const SOURCE_HOSTS = [
'hkjc_racingnews' => [
'res.hkjc.com',
'racingnews.hkjc.com',
],
'taiwan_lottery' => [
'cdn.taiwanlottery.com.tw',
'www.taiwanlottery.com',
'taiwanlottery.com',
],
];
private const SOURCE_REFERERS = [
'hkjc_racingnews' => 'https://racingnews.hkjc.com/',
'taiwan_lottery' => 'https://www.taiwanlottery.com/',
];
public static function rewriteArticlePayload(array $article): array
{
$ext = self::decodeExt($article['ext'] ?? []);
if (!self::shouldRewrite($ext)) {
return $article;
}
if (!empty($article['image'])) {
$article['image'] = self::buildProxyUrl((string) $article['image'], $ext);
}
if (!empty($article['content'])) {
$article['content'] = preg_replace_callback(
'/(<img\b[^>]*\bsrc=["\'])([^"\']+)(["\'][^>]*>)/i',
static function (array $matches) use ($ext) {
$proxyUrl = self::buildProxyUrl($matches[2] ?? '', $ext);
if ($proxyUrl === '' || $proxyUrl === ($matches[2] ?? '')) {
return $matches[0];
}
return $matches[1]
. htmlspecialchars($proxyUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. $matches[3];
},
(string) $article['content']
);
}
return $article;
}
public static function proxyRemoteImage(string $url): Response
{
$normalizedUrl = self::normalizeUrl($url);
if ($normalizedUrl === '' || !self::isAllowedRemoteUrl($normalizedUrl)) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$source = self::detectSourceByUrl($normalizedUrl);
$headers = [
'Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent: Mozilla/5.0 (compatible; SportEra/1.0)',
];
$referer = self::SOURCE_REFERERS[$source] ?? '';
if ($referer !== '') {
$headers[] = 'Referer: ' . $referer;
}
$ch = curl_init($normalizedUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => $headers,
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$curlError = curl_error($ch);
curl_close($ch);
if ($body === false || $curlError !== '' || $httpCode < 200 || $httpCode >= 300) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$effectiveUrl = $effectiveUrl !== '' ? $effectiveUrl : $normalizedUrl;
if (!self::isAllowedRemoteUrl($effectiveUrl)) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
$contentType = self::normalizeContentType($contentType, $effectiveUrl);
if (!str_starts_with($contentType, 'image/')) {
return response('image not found', 404)->header([
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
return response($body, 200)->header([
'Content-Type' => $contentType,
'Cache-Control' => 'public, max-age=86400',
'Access-Control-Allow-Origin' => '*',
]);
}
private static function shouldRewrite(array $ext): bool
{
$source = (string) ($ext['source'] ?? '');
return isset(self::SOURCE_HOSTS[$source]);
}
private static function buildProxyUrl(string $url, array $ext = []): string
{
$normalizedUrl = self::normalizeUrl($url);
if ($normalizedUrl === '' || self::isLocalProxyUrl($normalizedUrl)) {
return $url;
}
if (!self::isAllowedRemoteUrl($normalizedUrl, $ext)) {
return $url;
}
$path = '/api/article/imageProxy?url=' . rawurlencode($normalizedUrl);
$domain = rtrim((string) request()->domain(), '/');
return $domain !== '' ? $domain . $path : $path;
}
private static function normalizeUrl(string $url): string
{
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($url === '') {
return '';
}
if (str_starts_with($url, '//')) {
return 'https:' . $url;
}
return $url;
}
private static function isAllowedRemoteUrl(string $url, array $ext = []): bool
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if ($host === '' || !in_array($scheme, ['http', 'https'], true)) {
return false;
}
$allowedHosts = self::getAllowedHosts($ext);
foreach ($allowedHosts as $allowedHost) {
$allowedHost = strtolower($allowedHost);
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
return true;
}
}
return false;
}
private static function getAllowedHosts(array $ext = []): array
{
$source = (string) ($ext['source'] ?? '');
if ($source !== '' && isset(self::SOURCE_HOSTS[$source])) {
return self::SOURCE_HOSTS[$source];
}
$allHosts = [];
foreach (self::SOURCE_HOSTS as $hosts) {
$allHosts = array_merge($allHosts, $hosts);
}
return array_values(array_unique($allHosts));
}
private static function detectSourceByUrl(string $url): string
{
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));
foreach (self::SOURCE_HOSTS as $source => $hosts) {
foreach ($hosts as $allowedHost) {
$allowedHost = strtolower($allowedHost);
if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
return $source;
}
}
}
return '';
}
private static function isLocalProxyUrl(string $url): bool
{
return str_contains($url, '/api/article/imageProxy?');
}
private static function normalizeContentType(string $contentType, string $url): string
{
$contentType = strtolower(trim(explode(';', $contentType)[0] ?? ''));
if (str_starts_with($contentType, 'image/')) {
return $contentType;
}
$path = strtolower((string) parse_url($url, PHP_URL_PATH));
return match (pathinfo($path, PATHINFO_EXTENSION)) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
default => 'application/octet-stream',
};
}
private static function decodeExt($ext): array
{
if (is_array($ext)) {
return $ext;
}
if (is_string($ext) && $ext !== '') {
$decoded = json_decode($ext, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}
@@ -0,0 +1,301 @@
<?php
namespace app\common\service\chat;
use app\common\model\chat\PrivateChatMessage;
use app\common\model\chat\PrivateChatSession;
use app\common\model\community\CommunityFollow;
use app\common\model\user\User;
use think\facade\Db;
class PrivateChatService
{
public const MESSAGE_TYPE_TEXT = PrivateChatMessage::TYPE_TEXT;
public const MESSAGE_TYPE_IMAGE = PrivateChatMessage::TYPE_IMAGE;
public static function open(int $userId, int $targetUserId, bool $requireFollow = true): array
{
if ($userId <= 0) {
return self::fail('请先登录');
}
if ($targetUserId <= 0) {
return self::fail('请选择要私聊的用户');
}
if ($userId === $targetUserId) {
return self::fail('不能和自己私聊');
}
$target = User::field('id,sn,nickname,avatar')->where('id', $targetUserId)->findOrEmpty();
if ($target->isEmpty()) {
return self::fail('用户不存在');
}
$pair = self::pairIds($userId, $targetUserId);
$session = PrivateChatSession::where([
'user_a_id' => $pair[0],
'user_b_id' => $pair[1],
])->findOrEmpty();
if ($session->isEmpty()) {
if ($requireFollow && !self::isFollowing($userId, $targetUserId)) {
return self::fail('请先加好友后再私聊');
}
$now = time();
$session = PrivateChatSession::create([
'user_a_id' => $pair[0],
'user_b_id' => $pair[1],
'last_active_time' => $now,
'create_time' => $now,
'update_time' => $now,
]);
}
return self::success(self::formatSession($session->toArray(), $userId));
}
public static function sessions(int $userId, int $page, int $size): array
{
if ($userId <= 0) {
return ['lists' => [], 'page_no' => $page, 'page_size' => $size, 'count' => 0];
}
$page = max(1, $page);
$size = max(1, min(50, $size));
$query = PrivateChatSession::where(function ($query) use ($userId) {
$query->where('user_a_id', $userId)->whereOr('user_b_id', $userId);
});
$count = (clone $query)->count();
$rows = $query->order('last_active_time desc')
->order('id desc')
->page($page, $size)
->select()
->toArray();
return [
'lists' => array_map(fn($row) => self::formatSession($row, $userId), $rows),
'page_no' => $page,
'page_size' => $size,
'count' => $count,
];
}
public static function messages(int $userId, int $sessionId, int $afterId, int $page, int $size): array
{
$session = self::getOwnedSession($sessionId, $userId);
if (!$session) {
return self::fail('会话不存在或无权访问');
}
$page = max(1, $page);
$size = max(1, min(100, $size));
$query = PrivateChatMessage::where('session_id', $sessionId);
if ($afterId > 0) {
$query->where('id', '>', $afterId)->order('id', 'asc')->limit($size);
} else {
$query->order('id', 'desc')->page($page, $size);
}
$rows = $query->select()->toArray();
if ($afterId <= 0) {
$rows = array_reverse($rows);
}
self::markRead($session, $userId);
$session = PrivateChatSession::where('id', $sessionId)->findOrEmpty();
return self::success([
'session' => self::formatSession($session->toArray(), $userId),
'lists' => array_map(fn($row) => self::formatMessage($row, $userId), $rows),
'page_no' => $page,
'page_size' => $size,
]);
}
public static function send(int $userId, int $sessionId, string $messageType, string $content): array
{
$session = self::getOwnedSession($sessionId, $userId);
if (!$session) {
return self::fail('会话不存在或无权访问');
}
$messageType = trim($messageType) ?: self::MESSAGE_TYPE_TEXT;
if (!in_array($messageType, [self::MESSAGE_TYPE_TEXT, self::MESSAGE_TYPE_IMAGE], true)) {
return self::fail('消息类型错误');
}
$content = trim($content);
if ($content === '') {
return self::fail($messageType === self::MESSAGE_TYPE_IMAGE ? '请选择图片' : '请输入消息内容');
}
if ($messageType === self::MESSAGE_TYPE_TEXT) {
if (mb_strlen($content) > 1000) {
return self::fail('文字消息最多1000字');
}
} else {
$content = mb_substr($content, 0, 500);
}
$row = null;
Db::startTrans();
try {
$now = time();
$receiverId = self::targetUserId($session->toArray(), $userId);
$row = PrivateChatMessage::create([
'session_id' => $sessionId,
'sender_id' => $userId,
'receiver_id' => $receiverId,
'message_type' => $messageType,
'content' => $content,
'read_time' => 0,
'create_time' => $now,
'update_time' => $now,
]);
$unreadField = ((int) $session->user_a_id === $receiverId) ? 'user_a_unread' : 'user_b_unread';
PrivateChatSession::where('id', $sessionId)
->inc($unreadField)
->update([
'last_message_id' => (int) $row->id,
'last_message_type' => $messageType,
'last_message_content' => self::lastMessageContent($messageType, $content),
'last_sender_id' => $userId,
'last_active_time' => $now,
'update_time' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return self::fail('发送失败:' . $e->getMessage());
}
return self::success(self::formatMessage($row->toArray(), $userId));
}
public static function relationship(int $userId, int $targetUserId): array
{
$isFollowedByMe = $userId > 0 && $targetUserId > 0 && self::isFollowing($userId, $targetUserId);
$isFollowingMe = $userId > 0 && $targetUserId > 0 && self::isFollowing($targetUserId, $userId);
$isMutual = $isFollowedByMe && $isFollowingMe;
return [
'is_followed_by_me' => $isFollowedByMe,
'is_following_me' => $isFollowingMe,
'is_mutual' => $isMutual,
'relationship_text' => $isMutual ? '互相关注' : '不是好友',
];
}
public static function unreadCount(int $userId): int
{
if ($userId <= 0) {
return 0;
}
return (int) PrivateChatSession::where('user_a_id', $userId)->sum('user_a_unread')
+ (int) PrivateChatSession::where('user_b_id', $userId)->sum('user_b_unread');
}
private static function getOwnedSession(int $sessionId, int $userId): ?PrivateChatSession
{
if ($sessionId <= 0 || $userId <= 0) {
return null;
}
$session = PrivateChatSession::where('id', $sessionId)
->where(function ($query) use ($userId) {
$query->where('user_a_id', $userId)->whereOr('user_b_id', $userId);
})
->findOrEmpty();
return $session->isEmpty() ? null : $session;
}
private static function formatSession(array $row, int $viewerId): array
{
$targetUserId = self::targetUserId($row, $viewerId);
$target = User::field('id,sn,nickname,avatar')->where('id', $targetUserId)->findOrEmpty();
$unread = ((int) $row['user_a_id'] === $viewerId)
? (int) ($row['user_a_unread'] ?? 0)
: (int) ($row['user_b_unread'] ?? 0);
return [
'id' => (int) $row['id'],
'target_user_id' => $targetUserId,
'target_user' => $target->isEmpty() ? null : $target->toArray(),
'last_message_id' => (int) ($row['last_message_id'] ?? 0),
'last_message_type' => (string) ($row['last_message_type'] ?? self::MESSAGE_TYPE_TEXT),
'last_message_content' => (string) ($row['last_message_content'] ?? ''),
'last_sender_id' => (int) ($row['last_sender_id'] ?? 0),
'unread_count' => $unread,
'last_active_time' => $row['last_active_time'] ?? 0,
'create_time' => $row['create_time'] ?? 0,
'relationship' => self::relationship($viewerId, $targetUserId),
];
}
private static function formatMessage(array $row, int $viewerId): array
{
return [
'id' => (int) $row['id'],
'session_id' => (int) $row['session_id'],
'sender_id' => (int) $row['sender_id'],
'receiver_id' => (int) $row['receiver_id'],
'message_type' => (string) $row['message_type'],
'content' => (string) ($row['content'] ?? ''),
'is_self' => (int) $row['sender_id'] === $viewerId,
'read_time' => (int) ($row['read_time'] ?? 0),
'create_time' => $row['create_time'] ?? 0,
];
}
private static function markRead(PrivateChatSession $session, int $viewerId): void
{
$now = time();
PrivateChatMessage::where('session_id', (int) $session->id)
->where('receiver_id', $viewerId)
->where('read_time', 0)
->update(['read_time' => $now, 'update_time' => $now]);
$field = ((int) $session->user_a_id === $viewerId) ? 'user_a_unread' : 'user_b_unread';
PrivateChatSession::where('id', (int) $session->id)->update([
$field => 0,
'update_time' => $now,
]);
}
private static function targetUserId(array $session, int $viewerId): int
{
return ((int) $session['user_a_id'] === $viewerId)
? (int) $session['user_b_id']
: (int) $session['user_a_id'];
}
private static function pairIds(int $userId, int $targetUserId): array
{
return $userId < $targetUserId ? [$userId, $targetUserId] : [$targetUserId, $userId];
}
private static function isFollowing(int $userId, int $targetUserId): bool
{
return CommunityFollow::where([
'user_id' => $userId,
'follow_user_id' => $targetUserId,
])->count() > 0;
}
private static function lastMessageContent(string $messageType, string $content): string
{
if ($messageType === self::MESSAGE_TYPE_IMAGE) {
return '[图片]';
}
return mb_substr($content, 0, 200);
}
private static function success(array $data): array
{
return ['success' => true, 'data' => $data];
}
private static function fail(string $error): array
{
return ['success' => false, 'error' => $error];
}
}
@@ -0,0 +1,118 @@
<?php
namespace app\common\service\crontab;
use think\facade\Db;
class CrontabAlertService
{
private static bool $tableEnsured = false;
public static function ensureTable(): void
{
if (self::$tableEnsured) {
return;
}
$prefix = config('database.connections.mysql.prefix');
$table = $prefix . 'crontab_alert';
Db::execute(
"CREATE TABLE IF NOT EXISTS `{$table}` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`crontab_id` INT NOT NULL DEFAULT 0,
`crontab_log_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`task_name` VARCHAR(100) NOT NULL DEFAULT '',
`command` VARCHAR(100) NOT NULL DEFAULT '',
`params` VARCHAR(255) NOT NULL DEFAULT '',
`alert_type` VARCHAR(50) NOT NULL DEFAULT '',
`http_status` INT NOT NULL DEFAULT 0,
`detail` LONGTEXT NULL,
`dedupe_key` VARCHAR(191) NOT NULL DEFAULT '',
`status` VARCHAR(20) NOT NULL DEFAULT 'open',
`first_seen_at` INT UNSIGNED NOT NULL DEFAULT 0,
`last_seen_at` INT UNSIGNED NOT NULL DEFAULT 0,
`last_notified_at` INT UNSIGNED NOT NULL DEFAULT 0,
`resolved_at` INT UNSIGNED NOT NULL DEFAULT 0,
`notify_count` INT UNSIGNED NOT NULL DEFAULT 0,
`create_time` INT UNSIGNED NOT NULL DEFAULT 0,
`update_time` INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY `uk_dedupe_key` (`dedupe_key`),
KEY `idx_status_notify` (`status`, `last_notified_at`),
KEY `idx_crontab_status` (`crontab_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务告警'"
);
self::$tableEnsured = true;
}
public static function resolveByCrontabId(int $crontabId): void
{
self::ensureTable();
$now = time();
Db::name('crontab_alert')
->where('crontab_id', $crontabId)
->where('status', 'open')
->update([
'status' => 'resolved',
'resolved_at' => $now,
'update_time' => $now,
]);
}
public static function upsertCommandException(array $item, string $errorMessage, int $crontabLogId = 0): void
{
self::ensureTable();
$now = time();
$dedupeKey = self::buildDedupeKey((int) ($item['id'] ?? 0), 'command_exception');
$detail = json_encode([
'summary_text' => mb_substr($errorMessage, 0, 1000),
'error_message' => mb_substr($errorMessage, 0, 1000),
'output_excerpt' => mb_substr($errorMessage, 0, 2000),
], JSON_UNESCAPED_UNICODE);
$existing = Db::name('crontab_alert')->where('dedupe_key', $dedupeKey)->find();
if ($existing) {
$update = [
'crontab_log_id' => $crontabLogId,
'task_name' => mb_substr((string) ($item['name'] ?? ''), 0, 100),
'command' => mb_substr((string) ($item['command'] ?? ''), 0, 100),
'params' => mb_substr((string) ($item['params'] ?? ''), 0, 255),
'alert_type' => 'command_exception',
'http_status' => 0,
'detail' => $detail,
'last_seen_at' => $now,
'update_time' => $now,
];
if (($existing['status'] ?? '') === 'resolved') {
$update['status'] = 'open';
$update['first_seen_at'] = $now;
$update['last_notified_at'] = 0;
$update['resolved_at'] = 0;
$update['notify_count'] = 0;
}
Db::name('crontab_alert')->where('id', $existing['id'])->update($update);
return;
}
Db::name('crontab_alert')->insert([
'crontab_id' => (int) ($item['id'] ?? 0),
'crontab_log_id' => $crontabLogId,
'task_name' => mb_substr((string) ($item['name'] ?? ''), 0, 100),
'command' => mb_substr((string) ($item['command'] ?? ''), 0, 100),
'params' => mb_substr((string) ($item['params'] ?? ''), 0, 255),
'alert_type' => 'command_exception',
'http_status' => 0,
'detail' => $detail,
'dedupe_key' => $dedupeKey,
'status' => 'open',
'first_seen_at' => $now,
'last_seen_at' => $now,
'create_time' => $now,
'update_time' => $now,
]);
}
private static function buildDedupeKey(int $crontabId, string $alertType): string
{
return 'crontab:' . $crontabId . ':' . $alertType;
}
}
@@ -0,0 +1,230 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\generator;
use app\common\service\generator\core\ControllerGenerator;
use app\common\service\generator\core\ListsGenerator;
use app\common\service\generator\core\LogicGenerator;
use app\common\service\generator\core\ModelGenerator;
use app\common\service\generator\core\SqlGenerator;
use app\common\service\generator\core\ValidateGenerator;
use app\common\service\generator\core\VueApiGenerator;
use app\common\service\generator\core\VueEditGenerator;
use app\common\service\generator\core\VueIndexGenerator;
/**
* 生成器
* Class GenerateService
* @package app\common\service\generator
*/
class GenerateService
{
// 标记
protected $flag;
// 生成文件路径
protected $generatePath;
// runtime目录
protected $runtimePath;
// 压缩包名称
protected $zipTempName;
// 压缩包临时路径
protected $zipTempPath;
public function __construct()
{
$this->generatePath = root_path() . 'runtime/generate/';
$this->runtimePath = root_path() . 'runtime/';
}
/**
* @notes 删除生成文件夹内容
* @author 段誉
* @date 2022/6/23 18:52
*/
public function delGenerateDirContent()
{
// 删除runtime目录制定文件夹
!is_dir($this->generatePath) && mkdir($this->generatePath, 0755, true);
del_target_dir($this->generatePath, false);
}
/**
* @notes 设置生成状态
* @param $name
* @param false $status
* @author 段誉
* @date 2022/6/23 18:53
*/
public function setGenerateFlag($name, $status = false)
{
$this->flag = $name;
cache($name, (int)$status, 3600);
}
/**
* @notes 获取生成状态标记
* @return mixed|object|\think\App
* @author 段誉
* @date 2022/6/23 18:53
*/
public function getGenerateFlag()
{
return cache($this->flag);
}
/**
* @notes 删除标记时间
* @author 段誉
* @date 2022/6/23 18:53
*/
public function delGenerateFlag()
{
cache($this->flag, null);
}
/**
* @notes 生成器相关类
* @return string[]
* @author 段誉
* @date 2022/6/23 17:17
*/
public function getGeneratorClass()
{
return [
ControllerGenerator::class,
ListsGenerator::class,
ModelGenerator::class,
ValidateGenerator::class,
LogicGenerator::class,
VueApiGenerator::class,
VueIndexGenerator::class,
VueEditGenerator::class,
SqlGenerator::class,
];
}
/**
* @notes 生成文件
* @param array $tableData
* @author 段誉
* @date 2022/6/23 18:52
*/
public function generate(array $tableData)
{
foreach ($this->getGeneratorClass() as $item) {
$generator = app()->make($item);
$generator->initGenerateData($tableData);
$generator->generate();
// 是否为压缩包下载
if ($generator->isGenerateTypeZip()) {
$this->setGenerateFlag($this->flag, true);
}
// 是否构建菜单
if ($item == 'app\common\service\generator\core\SqlGenerator') {
$generator->isBuildMenu() && $generator->buildMenuHandle();
}
}
}
/**
* @notes 预览文件
* @param array $tableData
* @return array
* @author 段誉
* @date 2022/6/23 18:52
*/
public function preview(array $tableData)
{
$data = [];
foreach ($this->getGeneratorClass() as $item) {
$generator = app()->make($item);
$generator->initGenerateData($tableData);
$data[] = $generator->fileInfo();
}
return $data;
}
/**
* @notes 压缩文件
* @author 段誉
* @date 2022/6/23 19:02
*/
public function zipFile()
{
$fileName = 'curd-' . date('YmdHis') . '.zip';
$this->zipTempName = $fileName;
$this->zipTempPath = $this->generatePath . $fileName;
$zip = new \ZipArchive();
$zip->open($this->zipTempPath, \ZipArchive::CREATE);
$this->addFileZip($this->runtimePath, 'generate', $zip);
$zip->close();
}
/**
* @notes 往压缩包写入文件
* @param $basePath
* @param $dirName
* @param $zip
* @author 段誉
* @date 2022/6/23 19:02
*/
public function addFileZip($basePath, $dirName, $zip)
{
$handler = opendir($basePath . $dirName);
while (($filename = readdir($handler)) !== false) {
if ($filename != '.' && $filename != '..') {
if (is_dir($basePath . $dirName . '/' . $filename)) {
// 当前路径是文件夹
$this->addFileZip($basePath, $dirName . '/' . $filename, $zip);
} else {
// 写入文件到压缩包
$zip->addFile($basePath . $dirName . '/' . $filename, $dirName . '/' . $filename);
}
}
}
closedir($handler);
}
/**
* @notes 返回压缩包临时路径
* @return mixed
* @author 段誉
* @date 2022/6/24 9:41
*/
public function getDownloadUrl()
{
$vars = ['file' => $this->zipTempName];
cache('curd_file_name' . $this->zipTempName, $this->zipTempName, 3600);
return (string)url("adminapi/tools.generator/download", $vars, false, true);
}
}
@@ -0,0 +1,483 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use think\helper\Str;
use app\common\enum\GeneratorEnum;
/**
* 生成器基类
* Class BaseGenerator
* @package app\common\service\generator\core
*/
abstract class BaseGenerator
{
/**
* 模板文件夹
* @var string
*/
protected $templateDir;
/**
* 模块名
* @var string
*/
protected $moduleName;
/**
* 类目录
* @var string
*/
protected $classDir;
/**
* 表信息
* @var array
*/
protected $tableData;
/**
* 表字段信息
* @var array
*/
protected $tableColumn;
/**
* 文件内容
* @var string
*/
protected $content;
/**
* basePath
* @var string
*/
protected $basePath;
/**
* rootPath
* @var string
*/
protected $rootPath;
/**
* 生成的文件夹
* @var string
*/
protected $generatorDir;
/**
* 删除配置
* @var array
*/
protected $deleteConfig;
/**
* 菜单配置
* @var array
*/
protected $menuConfig;
/**
* 模型关联配置
* @var array
*/
protected $relationConfig;
/**
* 树表配置
* @var array
*/
protected $treeConfig;
public function __construct()
{
$this->basePath = base_path();
$this->rootPath = root_path();
$this->templateDir = $this->basePath . 'common/service/generator/stub/';
$this->generatorDir = $this->rootPath . 'runtime/generate/';
$this->checkDir($this->generatorDir);
}
/**
* @notes 初始化表表数据
* @param array $tableData
* @author 段誉
* @date 2022/6/22 18:03
*/
public function initGenerateData(array $tableData)
{
// 设置当前表信息
$this->setTableData($tableData);
// 设置模块名
$this->setModuleName($tableData['module_name']);
// 设置类目录
$this->setClassDir($tableData['class_dir'] ?? '');
// 替换模板变量
$this->replaceVariables();
}
/**
* @notes 菜单配置
* @author 段誉
* @date 2022/12/13 15:14
*/
public function setMenuConfig()
{
$this->menuConfig = [
'pid' => $this->tableData['menu']['pid'] ?? 0,
'type' => $this->tableData['menu']['type'] ?? GeneratorEnum::DELETE_TRUE,
'name' => $this->tableData['menu']['name'] ?? $this->tableData['table_comment']
];
}
/**
* @notes 删除配置
* @return array
* @author 段誉
* @date 2022/12/13 15:09
*/
public function setDeleteConfig()
{
$this->deleteConfig = [
'type' => $this->tableData['delete']['type'] ?? GeneratorEnum::DELETE_TRUE,
'name' => $this->tableData['delete']['name'] ?? GeneratorEnum::DELETE_NAME,
];
}
/**
* @notes 关联模型配置
* @author 段誉
* @date 2022/12/14 11:28
*/
public function setRelationConfig()
{
$this->relationConfig = empty($this->tableData['relations']) ? [] : $this->tableData['relations'];
}
/**
* @notes 设置树表配置
* @author 段誉
* @date 2022/12/20 14:30
*/
public function setTreeConfig()
{
$this->treeConfig = [
'tree_id' => $this->tableData['tree']['tree_id'] ?? '',
'tree_pid' => $this->tableData['tree']['tree_pid'] ?? '',
'tree_name' => $this->tableData['tree']['tree_name'] ?? '',
];
}
/**
* @notes 生成文件到模块或runtime目录
* @author 段誉
* @date 2022/6/22 18:03
*/
public function generate()
{
//生成方式 0-压缩包下载 1-生成到模块
if ($this->tableData['generate_type']) {
// 生成路径
$path = $this->getModuleGenerateDir() . $this->getGenerateName();
} else {
// 生成到runtime目录
$path = $this->getRuntimeGenerateDir() . $this->getGenerateName();
}
// 写入内容
file_put_contents($path, $this->content);
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return mixed
* @author 段誉
* @date 2022/6/22 18:05
*/
abstract public function getModuleGenerateDir();
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return mixed
* @author 段誉
* @date 2022/6/22 18:05
*/
abstract public function getRuntimeGenerateDir();
/**
* @notes 替换模板变量
* @return mixed
* @author 段誉
* @date 2022/6/22 18:06
*/
abstract public function replaceVariables();
/**
* @notes 生成文件名
* @return mixed
* @author 段誉
* @date 2022/6/22 18:17
*/
abstract public function getGenerateName();
/**
* @notes 文件夹不存在则创建
* @param string $path
* @author 段誉
* @date 2022/6/22 18:07
*/
public function checkDir(string $path)
{
!is_dir($path) && mkdir($path, 0755, true);
}
/**
* @notes 设置表信息
* @param $tableData
* @author 段誉
* @date 2022/6/22 18:07
*/
public function setTableData($tableData)
{
$this->tableData = !empty($tableData) ? $tableData : [];
$this->tableColumn = $tableData['table_column'] ?? [];
// 菜单配置
$this->setMenuConfig();
// 删除配置
$this->setDeleteConfig();
// 关联模型配置
$this->setRelationConfig();
// 设置树表配置
$this->setTreeConfig();
}
/**
* @notes 设置模块名
* @param string $moduleName
* @author 段誉
* @date 2022/6/22 18:07
*/
public function setModuleName(string $moduleName): void
{
$this->moduleName = strtolower($moduleName);
}
/**
* @notes 设置类目录
* @param string $classDir
* @author 段誉
* @date 2022/6/22 18:08
*/
public function setClassDir(string $classDir): void
{
$this->classDir = $classDir;
}
/**
* @notes 设置生成文件内容
* @param string $content
* @author 段誉
* @date 2022/6/22 18:08
*/
public function setContent(string $content): void
{
$this->content = $content;
}
/**
* @notes 获取模板路径
* @param string $templateName
* @return string
* @author 段誉
* @date 2022/6/22 18:09
*/
public function getTemplatePath(string $templateName): string
{
return $this->templateDir . $templateName . '.stub';
}
/**
* @notes 小驼峰命名
* @return string
* @author 段誉
* @date 2022/6/27 18:44
*/
public function getLowerCamelName()
{
return Str::camel($this->getTableName());
}
/**
* @notes 大驼峰命名
* @return string
* @author 段誉
* @date 2022/6/22 18:09
*/
public function getUpperCamelName()
{
return Str::studly($this->getTableName());
}
/**
* @notes 表名小写
* @return string
* @author 段誉
* @date 2022/7/12 10:41
*/
public function getLowerTableName()
{
return Str::lower($this->getTableName());
}
/**
* @notes 获取表名
* @return array|string|string[]
* @author 段誉
* @date 2022/6/22 18:09
*/
public function getTableName()
{
return get_no_prefix_table_name($this->tableData['table_name']);
}
/**
* @notes 获取表主键
* @return mixed|string
* @author 段誉
* @date 2022/6/22 18:09
*/
public function getPkContent()
{
$pk = 'id';
if (empty($this->tableColumn)) {
return $pk;
}
foreach ($this->tableColumn as $item) {
if ($item['is_pk']) {
$pk = $item['column_name'];
}
}
return $pk;
}
/**
* @notes 获取作者信息
* @return mixed|string
* @author 段誉
* @date 2022/6/24 10:18
*/
public function getAuthorContent()
{
return empty($this->tableData['author']) ? 'likeadmin' : $this->tableData['author'];
}
/**
* @notes 代码生成备注时间
* @return false|string
* @author 段誉
* @date 2022/6/24 10:28
*/
public function getNoteDateContent()
{
return date('Y/m/d H:i');
}
/**
* @notes 设置空额占位符
* @param $content
* @param $blankpace
* @return string
* @author 段誉
* @date 2022/6/22 18:09
*/
public function setBlankSpace($content, $blankpace)
{
$content = explode(PHP_EOL, $content);
foreach ($content as $line => $text) {
$content[$line] = $blankpace . $text;
}
return (implode(PHP_EOL, $content));
}
/**
* @notes 替换内容
* @param $needReplace
* @param $waitReplace
* @param $template
* @return array|false|string|string[]
* @author 段誉
* @date 2022/6/23 9:52
*/
public function replaceFileData($needReplace, $waitReplace, $template)
{
return str_replace($needReplace, $waitReplace, file_get_contents($template));
}
/**
* @notes 生成方式是否为压缩包
* @return bool
* @author 段誉
* @date 2022/6/23 17:02
*/
public function isGenerateTypeZip()
{
return $this->tableData['generate_type'] == GeneratorEnum::GENERATE_TYPE_ZIP;
}
/**
* @notes 是否为树表crud
* @return bool
* @author 段誉
* @date 2022/12/23 11:25
*/
public function isTreeCrud()
{
return $this->tableData['template_type'] == GeneratorEnum::TEMPLATE_TYPE_TREE;
}
}
@@ -0,0 +1,223 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
/**
* 控制器生成器
* Class ControllerGenerator
* @package app\common\service\generator\core
*/
class ControllerGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:09
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{NAMESPACE}',
'{USE}',
'{CLASS_COMMENT}',
'{UPPER_CAMEL_NAME}',
'{MODULE_NAME}',
'{PACKAGE_NAME}',
'{EXTENDS_CONTROLLER}',
'{NOTES}',
'{AUTHOR}',
'{DATE}'
];
// 等待替换的内容
$waitReplace = [
$this->getNameSpaceContent(),
$this->getUseContent(),
$this->getClassCommentContent(),
$this->getUpperCamelName(),
$this->moduleName,
$this->getPackageNameContent(),
$this->getExtendsControllerContent(),
$this->tableData['class_comment'],
$this->getAuthorContent(),
$this->getNoteDateContent(),
];
$templatePath = $this->getTemplatePath('php/controller');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 获取命名空间内容
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getNameSpaceContent()
{
if (!empty($this->classDir)) {
return "namespace app\\" . $this->moduleName . "\\controller\\" . $this->classDir . ';';
}
return "namespace app\\" . $this->moduleName . "\\controller;";
}
/**
* @notes 获取use模板内容
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getUseContent()
{
if ($this->moduleName == 'adminapi') {
$tpl = "use app\\" . $this->moduleName . "\\controller\\BaseAdminController;" . PHP_EOL;
} else {
$tpl = "use app\\common\\controller\\BaseLikeAdminController;" . PHP_EOL;
}
if (!empty($this->classDir)) {
$tpl .= "use app\\" . $this->moduleName . "\\lists\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Lists;" . PHP_EOL .
"use app\\" . $this->moduleName . "\\logic\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Logic;" . PHP_EOL .
"use app\\" . $this->moduleName . "\\validate\\" . $this->classDir . "\\" . $this->getUpperCamelName() . "Validate;";
} else {
$tpl .= "use app\\" . $this->moduleName . "\\lists\\" . $this->getUpperCamelName() . "Lists;" . PHP_EOL .
"use app\\" . $this->moduleName . "\\logic\\" . $this->getUpperCamelName() . "Logic;" . PHP_EOL .
"use app\\" . $this->moduleName . "\\validate\\" . $this->getUpperCamelName() . "Validate;";
}
return $tpl;
}
/**
* @notes 获取类描述内容
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getClassCommentContent()
{
if (!empty($this->tableData['class_comment'])) {
$tpl = $this->tableData['class_comment'] . '控制器';
} else {
$tpl = $this->getUpperCamelName() . '控制器';
}
return $tpl;
}
/**
* @notes 获取包名
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getPackageNameContent()
{
return !empty($this->classDir) ? '\\' . $this->classDir : '';
}
/**
* @notes 获取继承控制器
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getExtendsControllerContent()
{
$tpl = 'BaseAdminController';
if ($this->moduleName != 'adminapi') {
$tpl = 'BaseLikeAdminController';
}
return $tpl;
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:10
*/
public function getModuleGenerateDir()
{
$dir = $this->basePath . $this->moduleName . '/controller/';
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:11
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/controller/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 生成文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:11
*/
public function getGenerateName()
{
return $this->getUpperCamelName() . 'Controller.php';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'php',
'content' => $this->content
];
}
}
@@ -0,0 +1,23 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\generator\core;
interface GenerateInterface
{
public function generate();
public function fileInfo();
}
@@ -0,0 +1,338 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use app\common\enum\GeneratorEnum;
/**
* 列表生成器
* Class ListsGenerator
* @package app\common\service\generator\core
*/
class ListsGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:12
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{NAMESPACE}',
'{USE}',
'{CLASS_COMMENT}',
'{UPPER_CAMEL_NAME}',
'{MODULE_NAME}',
'{PACKAGE_NAME}',
'{EXTENDS_LISTS}',
'{PK}',
'{QUERY_CONDITION}',
'{FIELD_DATA}',
'{NOTES}',
'{AUTHOR}',
'{DATE}',
];
// 等待替换的内容
$waitReplace = [
$this->getNameSpaceContent(),
$this->getUseContent(),
$this->getClassCommentContent(),
$this->getUpperCamelName(),
$this->moduleName,
$this->getPackageNameContent(),
$this->getExtendsListsContent(),
$this->getPkContent(),
$this->getQueryConditionContent(),
$this->getFieldDataContent(),
$this->tableData['class_comment'],
$this->getAuthorContent(),
$this->getNoteDateContent(),
];
$templatePath = $this->getTemplatePath('php/lists');
if ($this->isTreeCrud()) {
// 插入树表相关
array_push($needReplace, '{TREE_ID}', '{TREE_PID}');
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_pid']);
$templatePath = $this->getTemplatePath('php/tree_lists');
}
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 获取命名空间内容
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getNameSpaceContent()
{
if (!empty($this->classDir)) {
return "namespace app\\" . $this->moduleName . "\\lists\\" . $this->classDir . ';';
}
return "namespace app\\" . $this->moduleName . "\\lists;";
}
/**
* @notes 获取use内容
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getUseContent()
{
if ($this->moduleName == 'adminapi') {
$tpl = "use app\\" . $this->moduleName . "\\lists\\BaseAdminDataLists;" . PHP_EOL;
} else {
$tpl = "use app\\common\\lists\\BaseDataLists;" . PHP_EOL;
}
if (!empty($this->classDir)) {
$tpl .= "use app\\common\\model\\" . $this->classDir . "\\" . $this->getUpperCamelName() . ';';
} else {
$tpl .= "use app\\common\\model\\" . $this->getUpperCamelName() . ';';
}
return $tpl;
}
/**
* @notes 获取类描述
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getClassCommentContent()
{
if (!empty($this->tableData['class_comment'])) {
$tpl = $this->tableData['class_comment'] . '列表';
} else {
$tpl = $this->getUpperCamelName() . '列表';
}
return $tpl;
}
/**
* @notes 获取包名
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getPackageNameContent()
{
return !empty($this->classDir) ? $this->classDir : '';
}
/**
* @notes 获取继承控制器
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getExtendsListsContent()
{
$tpl = 'BaseAdminDataLists';
if ($this->moduleName != 'adminapi') {
$tpl = 'BaseDataLists';
}
return $tpl;
}
/**
* @notes 获取查询条件内容
* @return string
* @author 段誉
* @date 2022/6/22 18:12
*/
public function getQueryConditionContent()
{
$columnQuery = array_column($this->tableColumn, 'query_type');
$query = array_unique($columnQuery);
$conditon = '';
$specQueryHandle = ['between', 'like'];
foreach ($query as $queryName) {
$columnValue = '';
foreach ($this->tableColumn as $column) {
if (empty($column['query_type']) || $column['is_pk']) {
continue;
}
if ($queryName == $column['query_type'] && $column['is_query'] && !in_array($queryName, $specQueryHandle)) {
$columnValue .= "'" . $column['column_name'] . "', ";
}
}
if (!empty($columnValue)) {
$columnValue = substr($columnValue, 0, -2);
$conditon .= "'$queryName' => [" . trim($columnValue) . "]," . PHP_EOL;
}
}
$likeColumn = '';
$betweenColumn = '';
$betweenTimeColumn = '';
// 另外处理between,like 等查询条件
foreach ($this->tableColumn as $item) {
if (!$item['is_query']) {
continue;
}
// like
if ($item['query_type'] == 'like') {
$likeColumn .= "'" . $item['column_name'] . "', ";
continue;
}
// between
if ($item['query_type'] == 'between') {
if ($item['view_type'] == 'datetime') {
$betweenTimeColumn .= "'" . $item['column_name'] . "', ";
} else {
$betweenColumn .= "'" . $item['column_name'] . "', ";
}
}
}
if (!empty($likeColumn)) {
$likeColumn = substr($likeColumn, 0, -2);
$conditon .= "'%like%' => " . "[" . trim($likeColumn) . "]," . PHP_EOL;
}
if (!empty($betweenColumn)) {
$betweenColumn = substr($betweenColumn, 0, -2);
$conditon .= "'between' => " . "[" . trim($betweenColumn) . "]," . PHP_EOL;
}
if (!empty($betweenTimeColumn)) {
$betweenTimeColumn = substr($betweenTimeColumn, 0, -2);
$conditon .= "'between_time' => " . "[" . trim($betweenTimeColumn) . "]," . PHP_EOL;
}
$content = substr($conditon, 0, -1);
return $this->setBlankSpace($content, " ");
}
/**
* @notes 获取查询字段
* @return false|string
* @author 段誉
* @date 2022/6/22 18:13
*/
public function getFieldDataContent()
{
$content = "'" . $this->getPkContent() . "', ";
$isExist = [$this->getPkContent()];
foreach ($this->tableColumn as $column) {
if ($column['is_lists'] && !in_array($column['column_name'], $isExist)) {
$content .= "'" . $column['column_name'] . "', ";
$isExist[] = $column['column_name'];
}
if ($this->isTreeCrud() && !in_array($column['column_name'], $isExist)
&& in_array($column['column_name'], [$this->treeConfig['tree_id'], $this->treeConfig['tree_pid']])
) {
$content .= "'" . $column['column_name'] . "', ";
}
}
return substr($content, 0, -2);
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:13
*/
public function getModuleGenerateDir()
{
$dir = $this->basePath . $this->moduleName . '/lists/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:13
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/lists/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:13
*/
public function getGenerateName()
{
return $this->getUpperCamelName() . 'Lists.php';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'php',
'content' => $this->content
];
}
}
@@ -0,0 +1,268 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
/**
* 逻辑生成器
* Class LogicGenerator
* @package app\common\service\generator\core
*/
class LogicGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:14
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{NAMESPACE}',
'{USE}',
'{CLASS_COMMENT}',
'{UPPER_CAMEL_NAME}',
'{MODULE_NAME}',
'{PACKAGE_NAME}',
'{PK}',
'{CREATE_DATA}',
'{UPDATE_DATA}',
'{NOTES}',
'{AUTHOR}',
'{DATE}'
];
// 等待替换的内容
$waitReplace = [
$this->getNameSpaceContent(),
$this->getUseContent(),
$this->getClassCommentContent(),
$this->getUpperCamelName(),
$this->moduleName,
$this->getPackageNameContent(),
$this->getPkContent(),
$this->getCreateDataContent(),
$this->getUpdateDataContent(),
$this->tableData['class_comment'],
$this->getAuthorContent(),
$this->getNoteDateContent(),
];
$templatePath = $this->getTemplatePath('php/logic');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 添加内容
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getCreateDataContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (!$column['is_insert']) {
continue;
}
$content .= $this->addEditColumn($column);
}
if (empty($content)) {
return $content;
}
$content = substr($content, 0, -2);
return $this->setBlankSpace($content, " ");
}
/**
* @notes 编辑内容
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getUpdateDataContent()
{
$columnContent = '';
foreach ($this->tableColumn as $column) {
if (!$column['is_update']) {
continue;
}
$columnContent .= $this->addEditColumn($column);
}
if (empty($columnContent)) {
return $columnContent;
}
$columnContent = substr($columnContent, 0, -2);
$content = $columnContent;
return $this->setBlankSpace($content, " ");
}
/**
* @notes 添加编辑字段内容
* @param $column
* @return mixed
* @author 段誉
* @date 2022/6/27 15:37
*/
public function addEditColumn($column)
{
if ($column['column_type'] == 'int' && $column['view_type'] == 'datetime') {
// 物理类型为int,显示类型选择日期的情况
$content = "'" . $column['column_name'] . "' => " . 'strtotime($params[' . "'" . $column['column_name'] . "'" . ']),' . PHP_EOL;
} else {
$content = "'" . $column['column_name'] . "' => " . '$params[' . "'" . $column['column_name'] . "'" . '],' . PHP_EOL;
}
return $content;
}
/**
* @notes 获取命名空间内容
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getNameSpaceContent()
{
if (!empty($this->classDir)) {
return "namespace app\\" . $this->moduleName . "\\logic\\" . $this->classDir . ';';
}
return "namespace app\\" . $this->moduleName . "\\logic;";
}
/**
* @notes 获取use内容
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getUseContent()
{
$tpl = "use app\\common\\model\\" . $this->getUpperCamelName() . ';';
if (!empty($this->classDir)) {
$tpl = "use app\\common\\model\\" . $this->classDir . "\\" . $this->getUpperCamelName() . ';';
}
return $tpl;
}
/**
* @notes 获取类描述
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getClassCommentContent()
{
if (!empty($this->tableData['class_comment'])) {
$tpl = $this->tableData['class_comment'] . '逻辑';
} else {
$tpl = $this->getUpperCamelName() . '逻辑';
}
return $tpl;
}
/**
* @notes 获取包名
* @return string
* @author 段誉
* @date 2022/6/22 18:14
*/
public function getPackageNameContent()
{
return !empty($this->classDir) ? '\\' . $this->classDir : '';
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:15
*/
public function getModuleGenerateDir()
{
$dir = $this->basePath . $this->moduleName . '/logic/';
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:15
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/logic/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:15
*/
public function getGenerateName()
{
return $this->getUpperCamelName() . 'Logic.php';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'php',
'content' => $this->content
];
}
}
@@ -0,0 +1,275 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
/**
* 模型生成器
* Class ModelGenerator
* @package app\common\service\generator\core
*/
class ModelGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:16
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{NAMESPACE}',
'{CLASS_COMMENT}',
'{UPPER_CAMEL_NAME}',
'{PACKAGE_NAME}',
'{TABLE_NAME}',
'{USE}',
'{DELETE_USE}',
'{DELETE_TIME}',
'{RELATION_MODEL}',
];
// 等待替换的内容
$waitReplace = [
$this->getNameSpaceContent(),
$this->getClassCommentContent(),
$this->getUpperCamelName(),
$this->getPackageNameContent(),
$this->getTableName(),
$this->getUseContent(),
$this->getDeleteUseContent(),
$this->getDeleteTimeContent(),
$this->getRelationModel(),
];
$templatePath = $this->getTemplatePath('php/model');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 获取命名空间模板内容
* @return string
* @author 段誉
* @date 2022/6/22 18:16
*/
public function getNameSpaceContent()
{
if (!empty($this->classDir)) {
return "namespace app\\common\\model\\" . $this->classDir . ';';
}
return "namespace app\\common\\model;";
}
/**
* @notes 获取类描述
* @return string
* @author 段誉
* @date 2022/6/22 18:16
*/
public function getClassCommentContent()
{
if (!empty($this->tableData['class_comment'])) {
$tpl = $this->tableData['class_comment'] . '模型';
} else {
$tpl = $this->getUpperCamelName() . '模型';
}
return $tpl;
}
/**
* @notes 获取包名
* @return string
* @author 段誉
* @date 2022/6/22 18:16
*/
public function getPackageNameContent()
{
return !empty($this->classDir) ? '\\' . $this->classDir : '';
}
/**
* @notes 引用内容
* @return string
* @author 段誉
* @date 2022/12/12 17:32
*/
public function getUseContent()
{
$tpl = "";
if ($this->deleteConfig['type']) {
$tpl = "use think\\model\\concern\\SoftDelete;";
}
return $tpl;
}
/**
* @notes 软删除引用
* @return string
* @author 段誉
* @date 2022/12/12 17:34
*/
public function getDeleteUseContent()
{
$tpl = "";
if ($this->deleteConfig['type']) {
$tpl = "use SoftDelete;";
}
return $tpl;
}
/**
* @notes 软删除时间字段定义
* @return string
* @author 段誉
* @date 2022/12/12 17:38
*/
public function getDeleteTimeContent()
{
$tpl = "";
if ($this->deleteConfig['type']) {
$deleteTime = $this->deleteConfig['name'];
$tpl = 'protected $deleteTime = ' . "'". $deleteTime ."';";
}
return $tpl;
}
/**
* @notes 关联模型
* @return string
* @author 段誉
* @date 2022/12/14 14:46
*/
public function getRelationModel()
{
$tpl = '';
if (empty($this->relationConfig)) {
return $tpl;
}
// 遍历关联配置
foreach ($this->relationConfig as $config) {
if (empty($config) || empty($config['name']) || empty($config['model'])) {
continue;
}
$needReplace = [
'{RELATION_NAME}',
'{AUTHOR}',
'{DATE}',
'{RELATION_MODEL}',
'{FOREIGN_KEY}',
'{LOCAL_KEY}',
];
$waitReplace = [
$config['name'],
$this->getAuthorContent(),
$this->getNoteDateContent(),
$config['model'],
$config['foreign_key'],
$config['local_key'],
];
$templatePath = $this->getTemplatePath('php/model/' . $config['type']);
if (!file_exists($templatePath)) {
continue;
}
$tpl .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
}
return $tpl;
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:16
*/
public function getModuleGenerateDir()
{
$dir = $this->basePath . 'common/model/';
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:17
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'php/app/common/model/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:17
*/
public function getGenerateName()
{
return $this->getUpperCamelName() . '.php';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'php',
'content' => $this->content
];
}
}
@@ -0,0 +1,191 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use app\common\enum\GeneratorEnum;
use think\facade\Db;
use think\helper\Str;
/**
* sql文件生成器
* Class SqlGenerator
* @package app\common\service\generator\core
*/
class SqlGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{MENU_TABLE}',
'{PARTNER_ID}',
'{LISTS_NAME}',
'{PERMS_NAME}',
'{PATHS_NAME}',
'{COMPONENT_NAME}',
'{CREATE_TIME}',
'{UPDATE_TIME}'
];
// 等待替换的内容
$waitReplace = [
$this->getMenuTableNameContent(),
$this->menuConfig['pid'],
$this->menuConfig['name'],
$this->getPermsNameContent(),
$this->getLowerTableName(),
$this->getLowerTableName(),
time(),
time()
];
$templatePath = $this->getTemplatePath('sql/sql');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 路由权限内容
* @return string
* @author 段誉
* @date 2022/8/11 17:18
*/
public function getPermsNameContent()
{
if (!empty($this->classDir)) {
return $this->classDir . '.' . Str::lower($this->getTableName());
}
return Str::lower($this->getTableName());
}
/**
* @notes 获取菜单表内容
* @return string
* @author 段誉
* @date 2022/7/7 15:57
*/
public function getMenuTableNameContent()
{
$tablePrefix = config('database.connections.mysql.prefix');
return $tablePrefix . 'system_menu';
}
/**
* @notes 是否构建菜单
* @return bool
* @author 段誉
* @date 2022/7/8 14:24
*/
public function isBuildMenu()
{
return $this->menuConfig['type'] == GeneratorEnum::GEN_AUTO;
}
/**
* @notes 构建菜单
* @return bool
* @author 段誉
* @date 2022/7/8 15:27
*/
public function buildMenuHandle()
{
if (empty($this->content)) {
return false;
}
$sqls = explode(';', trim($this->content));
//执行sql
foreach ($sqls as $sql) {
if (!empty(trim($sql))) {
Db::execute($sql . ';');
}
}
return true;
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getModuleGenerateDir()
{
$dir = $this->generatorDir . 'sql/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'sql/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getGenerateName()
{
return 'menu.sql';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'sql',
'content' => $this->content
];
}
}
@@ -0,0 +1,278 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
/**
* 验证器生成器
* Class ValidateGenerator
* @package app\common\service\generator\core
*/
class ValidateGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:18
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{NAMESPACE}',
'{CLASS_COMMENT}',
'{UPPER_CAMEL_NAME}',
'{MODULE_NAME}',
'{PACKAGE_NAME}',
'{PK}',
'{RULE}',
'{NOTES}',
'{AUTHOR}',
'{DATE}',
'{ADD_PARAMS}',
'{EDIT_PARAMS}',
'{FIELD}',
];
// 等待替换的内容
$waitReplace = [
$this->getNameSpaceContent(),
$this->getClassCommentContent(),
$this->getUpperCamelName(),
$this->moduleName,
$this->getPackageNameContent(),
$this->getPkContent(),
$this->getRuleContent(),
$this->tableData['class_comment'],
$this->getAuthorContent(),
$this->getNoteDateContent(),
$this->getAddParamsContent(),
$this->getEditParamsContent(),
$this->getFiledContent(),
];
$templatePath = $this->getTemplatePath('php/validate');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 验证规则
* @return mixed|string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getRuleContent()
{
$content = "'" . $this->getPkContent() . "' => 'require'," . PHP_EOL;
foreach ($this->tableColumn as $column) {
if ($column['is_required'] == 1) {
$content .= "'" . $column['column_name'] . "' => 'require'," . PHP_EOL;
}
}
$content = substr($content, 0, -1);
return $this->setBlankSpace($content, " ");
}
/**
* @notes 添加场景验证参数
* @return string
* @author 段誉
* @date 2022/12/7 15:26
*/
public function getAddParamsContent()
{
$content = "";
foreach ($this->tableColumn as $column) {
if ($column['is_required'] == 1 && $column['column_name'] != $this->getPkContent()) {
$content .= "'" . $column['column_name'] . "',";
}
}
$content = substr($content, 0, -1);
// 若无设置添加场景校验字段时, 排除主键
if (!empty($content)) {
$content = 'return $this->only([' . $content . ']);';
} else {
$content = 'return $this->remove(' . "'". $this->getPkContent() . "'" . ', true);';
}
return $this->setBlankSpace($content, "");
}
/**
* @notes 编辑场景验证参数
* @return string
* @author 段誉
* @date 2022/12/7 15:20
*/
public function getEditParamsContent()
{
$content = "'" . $this->getPkContent() . "'," ;
foreach ($this->tableColumn as $column) {
if ($column['is_required'] == 1) {
$content .= "'" . $column['column_name'] . "',";
}
}
$content = substr($content, 0, -1);
if (!empty($content)) {
$content = 'return $this->only([' . $content . ']);';
}
return $this->setBlankSpace($content, "");
}
/**
* @notes 验证字段描述
* @return string
* @author 段誉
* @date 2022/12/9 15:09
*/
public function getFiledContent()
{
$content = "'" . $this->getPkContent() . "' => '" . $this->getPkContent() . "'," . PHP_EOL;
foreach ($this->tableColumn as $column) {
if ($column['is_required'] == 1) {
$columnComment = $column['column_comment'];
if (empty($column['column_comment'])) {
$columnComment = $column['column_name'];
}
$content .= "'" . $column['column_name'] . "' => '" . $columnComment . "'," . PHP_EOL;
}
}
$content = substr($content, 0, -1);
return $this->setBlankSpace($content, " ");
}
/**
* @notes 获取命名空间模板内容
* @return string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getNameSpaceContent()
{
if (!empty($this->classDir)) {
return "namespace app\\" . $this->moduleName . "\\validate\\" . $this->classDir . ';';
}
return "namespace app\\" . $this->moduleName . "\\validate;";
}
/**
* @notes 获取类描述
* @return string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getClassCommentContent()
{
if (!empty($this->tableData['class_comment'])) {
$tpl = $this->tableData['class_comment'] . '验证器';
} else {
$tpl = $this->getUpperCamelName() . '验证器';
}
return $tpl;
}
/**
* @notes 获取包名
* @return string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getPackageNameContent()
{
return !empty($this->classDir) ? '\\' . $this->classDir : '';
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getModuleGenerateDir()
{
$dir = $this->basePath . $this->moduleName . '/validate/';
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:18
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'php/app/' . $this->moduleName . '/validate/';
$this->checkDir($dir);
if (!empty($this->classDir)) {
$dir .= $this->classDir . '/';
$this->checkDir($dir);
}
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getGenerateName()
{
return $this->getUpperCamelName() . 'Validate.php';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'php',
'content' => $this->content
];
}
}
@@ -0,0 +1,144 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use think\helper\Str;
/**
* vue-api生成器
* Class VueApiGenerator
* @package app\common\service\generator\core
*/
class VueApiGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{COMMENT}',
'{UPPER_CAMEL_NAME}',
'{ROUTE}'
];
// 等待替换的内容
$waitReplace = [
$this->getCommentContent(),
$this->getUpperCamelName(),
$this->getRouteContent(),
];
$templatePath = $this->getTemplatePath('vue/api');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 描述
* @return mixed
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getCommentContent()
{
return $this->tableData['table_comment'];
}
/**
* @notes 路由名称
* @return array|string|string[]
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getRouteContent()
{
$content = $this->getTableName();
if (!empty($this->classDir)) {
$content = $this->classDir . '.' . $this->getTableName();
}
return Str::lower($content);
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getModuleGenerateDir()
{
$dir = dirname(app()->getRootPath()) . '/admin/src/api/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'vue/src/api/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getGenerateName()
{
return $this->getLowerTableName() . '.ts';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'ts',
'content' => $this->content
];
}
}
@@ -0,0 +1,493 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use app\common\enum\GeneratorEnum;
/**
* vue-edit生成器
* Class VueEditGenerator
* @package app\common\service\generator\core
*/
class VueEditGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{FORM_VIEW}',
'{UPPER_CAMEL_NAME}',
'{DICT_DATA}',
'{DICT_DATA_API}',
'{FORM_DATA}',
'{FORM_VALIDATE}',
'{TABLE_COMMENT}',
'{PK}',
'{API_DIR}',
'{CHECKBOX_JOIN}',
'{CHECKBOX_SPLIT}',
'{FORM_DATE}',
'{SETUP_NAME}',
'{IMPORT_LISTS}',
'{TREE_CONST}',
'{GET_TREE_LISTS}'
];
// 等待替换的内容
$waitReplace = [
$this->getFormViewContent(),
$this->getUpperCamelName(),
$this->getDictDataContent(),
$this->getDictDataApiContent(),
$this->getFormDataContent(),
$this->getFormValidateContent(),
$this->tableData['table_comment'],
$this->getPkContent(),
$this->getTableName(),
$this->getCheckBoxJoinContent(),
$this->getCheckBoxSplitContent(),
$this->getFormDateContent(),
$this->getLowerCamelName(),
$this->getImportListsContent(),
$this->getTreeConstContent(),
$this->getTreeListsContent(),
];
$templatePath = $this->getTemplatePath('vue/edit');
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 复选框处理
* @return string
* @author 段誉
* @date 2022/6/24 19:30
*/
public function getCheckBoxJoinContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (empty($column['view_type']) || $column['is_pk']) {
continue;
}
if ($column['view_type'] != 'checkbox') {
continue;
}
$content .= $column['column_name'] . ': formData.' . $column['column_name'] . '.join(",")' . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $content;
}
/**
* @notes 复选框处理
* @return string
* @author 段誉
* @date 2022/6/24 19:30
*/
public function getCheckBoxSplitContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (empty($column['view_type']) || $column['is_pk']) {
continue;
}
if ($column['view_type'] != 'checkbox') {
continue;
}
$content .= '//@ts-ignore' . PHP_EOL;
$content .= 'data.' . $column['column_name'] . ' && ' .'(formData.' . $column['column_name'] . ' = String(data.' . $column['column_name'] . ').split(","))' . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 树表contst
* @return string
* @author 段誉
* @date 2022/12/22 18:19
*/
public function getTreeConstContent()
{
$content = "";
if ($this->isTreeCrud()) {
$content = file_get_contents($this->getTemplatePath('vue/other_item/editTreeConst'));
}
return $content;
}
/**
* @notes 获取树表列表
* @return string
* @author 段誉
* @date 2022/12/22 18:26
*/
public function getTreeListsContent()
{
$content = '';
if (!$this->isTreeCrud()) {
return $content;
}
$needReplace = [
'{TREE_ID}',
'{TREE_NAME}',
'{UPPER_CAMEL_NAME}',
];
$waitReplace = [
$this->treeConfig['tree_id'],
$this->treeConfig['tree_name'],
$this->getUpperCamelName(),
];
$templatePath = $this->getTemplatePath('vue/other_item/editTreeLists');
if (file_exists($templatePath)) {
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
}
return $content;
}
/**
* @notes 表单日期处理
* @return string
* @author 段誉
* @date 2022/6/27 16:45
*/
public function getFormDateContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (empty($column['view_type']) || $column['is_pk']) {
continue;
}
if ($column['view_type'] != 'datetime' || $column['column_type'] != 'int') {
continue;
}
$content .= '//@ts-ignore' . PHP_EOL;
$content .= 'formData.' . $column['column_name'] . ' = timeFormat(formData.' . $column['column_name'] . ','."'yyyy-mm-dd hh:MM:ss'".') ' . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 获取表单内容
* @return string
* @author 段誉
* @date 2022/6/23 11:57
*/
public function getFormViewContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (!$column['is_insert'] || !$column['is_update'] || $column['is_pk']) {
continue;
}
$needReplace = [
'{COLUMN_COMMENT}',
'{COLUMN_NAME}',
'{DICT_TYPE}',
];
$waitReplace = [
$column['column_comment'],
$column['column_name'],
$column['dict_type'],
];
$viewType = $column['view_type'];
// 树表,树状结构下拉框
if ($this->isTreeCrud() && $column['column_name'] == $this->treeConfig['tree_pid']) {
$viewType = 'treeSelect';
array_push($needReplace, '{TREE_ID}', '{TREE_NAME}');
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_name']);
}
$templatePath = $this->getTemplatePath('vue/form_item/' . $viewType);
if (!file_exists($templatePath)) {
continue;
}
// 单选框值处理
if ($column['view_type'] == 'radio' || $column['view_type'] == 'select') {
$stubItemValue = 'item.value';
$intFieldValue = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint'];
if (in_array($column['column_type'], $intFieldValue)) {
$stubItemValue = 'parseInt(item.value)';
}
array_push($needReplace, '{ITEM_VALUE}');
array_push($waitReplace, $stubItemValue);
}
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
$content = $this->setBlankSpace($content, ' ');
return $content;
}
/**
* @notes 获取字典数据内容
* @return string
* @author 段誉
* @date 2022/6/23 11:58
*/
public function getDictDataContent()
{
$content = '';
$isExist = [];
foreach ($this->tableColumn as $column) {
if (empty($column['dict_type']) || $column['is_pk']) {
continue;
}
if (in_array($column['dict_type'], $isExist)) {
continue;
}
$content .= $column['dict_type'] . ': ' . "[]," . PHP_EOL;
$isExist[] = $column['dict_type'];
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 获取字典数据api内容
* @return false|string
* @author 段誉
* @date 2022/6/23 11:58
*/
public function getDictDataApiContent()
{
$content = '';
$isExist = [];
foreach ($this->tableColumn as $column) {
if (empty($column['dict_type']) || $column['is_pk']) {
continue;
}
if (in_array($column['dict_type'], $isExist)) {
continue;
}
$needReplace = [
'{UPPER_CAMEL_NAME}',
'{DICT_TYPE}',
];
$waitReplace = [
$this->getUpperCamelName(),
$column['dict_type'],
];
$templatePath = $this->getTemplatePath('vue/other_item/dictDataApi');
if (!file_exists($templatePath)) {
continue;
}
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . '' . PHP_EOL;
$isExist[] = $column['dict_type'];
}
$content = substr($content, 0, -1);
return $content;
}
/**
* @notes 获取表单默认字段内容
* @return string
* @author 段誉
* @date 2022/6/23 15:15
*/
public function getFormDataContent()
{
$content = '';
$isExist = [];
foreach ($this->tableColumn as $column) {
if (!$column['is_insert'] || !$column['is_update'] || $column['is_pk']) {
continue;
}
if (in_array($column['column_name'], $isExist)) {
continue;
}
// 复选框类型返回数组
if ($column['view_type'] == 'checkbox') {
$content .= $column['column_name'] . ': ' . "[]," . PHP_EOL;
} else {
$content .= $column['column_name'] . ': ' . "''," . PHP_EOL;
}
$isExist[] = $column['column_name'];
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 表单验证内容
* @return false|string
* @author 段誉
* @date 2022/6/23 15:16
*/
public function getFormValidateContent()
{
$content = '';
$isExist = [];
$specDictType = ['input', 'textarea', 'editor'];
foreach ($this->tableColumn as $column) {
if (!$column['is_required'] || $column['is_pk']) {
continue;
}
if (in_array($column['column_name'], $isExist)) {
continue;
}
$validateMsg = in_array($column['view_type'], $specDictType) ? '请输入' : '请选择';
$validateMsg .= $column['column_comment'];
$needReplace = [
'{COLUMN_NAME}',
'{VALIDATE_MSG}',
];
$waitReplace = [
$column['column_name'],
$validateMsg,
];
$templatePath = $this->getTemplatePath('vue/other_item/formValidate');
if (!file_exists($templatePath)) {
continue;
}
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . ',' . PHP_EOL;
$isExist[] = $column['column_name'];
}
$content = substr($content, 0, -2);
return $content;
}
/**
* @notes 树表时导入列表
* @author 段誉
* @date 2022/12/23 9:56
*/
public function getImportListsContent()
{
$content = "";
if ($this->isTreeCrud()) {
$content = "api". $this->getUpperCamelName(). 'Lists,';
}
if (empty($content)) {
return $content;
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getModuleGenerateDir()
{
$dir = dirname(app()->getRootPath()) . '/admin/src/views/' . $this->getTableName() . '/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'vue/src/views/' . $this->getTableName() . '/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getGenerateName()
{
return 'edit.vue';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'vue',
'content' => $this->content
];
}
}
@@ -0,0 +1,308 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\common\service\generator\core;
use app\common\enum\GeneratorEnum;
/**
* vue-index生成器
* Class VueIndexGenerator
* @package app\common\service\generator\core
*/
class VueIndexGenerator extends BaseGenerator implements GenerateInterface
{
/**
* @notes 替换变量
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function replaceVariables()
{
// 需要替换的变量
$needReplace = [
'{SEARCH_VIEW}',
'{LISTS_VIEW}',
'{UPPER_CAMEL_NAME}',
'{QUERY_PARAMS}',
'{DICT_DATA}',
'{PK}',
'{API_DIR}',
'{PERMS_ADD}',
'{PERMS_EDIT}',
'{PERMS_DELETE}',
'{SETUP_NAME}'
];
// 等待替换的内容
$waitReplace = [
$this->getSearchViewContent(),
$this->getListsViewContent(),
$this->getUpperCamelName(),
$this->getQueryParamsContent(),
$this->getDictDataContent(),
$this->getPkContent(),
$this->getTableName(),
$this->getPermsContent(),
$this->getPermsContent('edit'),
$this->getPermsContent('delete'),
$this->getLowerCamelName()
];
$templatePath = $this->getTemplatePath('vue/index');
if ($this->isTreeCrud()) {
// 插入树表相关
array_push($needReplace, '{TREE_ID}', '{TREE_PID}');
array_push($waitReplace, $this->treeConfig['tree_id'], $this->treeConfig['tree_pid']);
$templatePath = $this->getTemplatePath('vue/index-tree');
}
// 替换内容
$content = $this->replaceFileData($needReplace, $waitReplace, $templatePath);
$this->setContent($content);
}
/**
* @notes 获取搜索内容
* @return string
* @author 段誉
* @date 2022/6/23 11:57
*/
public function getSearchViewContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (!$column['is_query'] || $column['is_pk']) {
continue;
}
$needReplace = [
'{COLUMN_COMMENT}',
'{COLUMN_NAME}',
'{DICT_TYPE}',
];
$waitReplace = [
$column['column_comment'],
$column['column_name'],
$column['dict_type'],
];
$searchStubType = $column['view_type'];
if ($column['view_type'] == 'radio') {
$searchStubType = 'select';
}
$templatePath = $this->getTemplatePath('vue/search_item/' . $searchStubType);
if (!file_exists($templatePath)) {
continue;
}
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
$content = $this->setBlankSpace($content, ' ');
return $content;
}
/**
* @notes 获取列表内容
* @return string
* @author 段誉
* @date 2022/6/23 11:57
*/
public function getListsViewContent()
{
$content = '';
foreach ($this->tableColumn as $column) {
if (!$column['is_lists']) {
continue;
}
$needReplace = [
'{COLUMN_COMMENT}',
'{COLUMN_NAME}',
'{DICT_TYPE}',
];
$waitReplace = [
$column['column_comment'],
$column['column_name'],
$column['dict_type'],
];
$templatePath = $this->getTemplatePath('vue/table_item/default');
if ($column['view_type'] == 'imageSelect') {
$templatePath = $this->getTemplatePath('vue/table_item/image');
}
if (in_array($column['view_type'], ['select', 'radio', 'checkbox'])) {
$templatePath = $this->getTemplatePath('vue/table_item/options');
}
if ($column['column_type'] == 'int' && $column['view_type'] == 'datetime') {
$templatePath = $this->getTemplatePath('vue/table_item/datetime');
}
if (!file_exists($templatePath)) {
continue;
}
$content .= $this->replaceFileData($needReplace, $waitReplace, $templatePath) . PHP_EOL;
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 获取查询条件内容
* @return string
* @author 段誉
* @date 2022/6/23 11:57
*/
public function getQueryParamsContent()
{
$content = '';
$queryDate = false;
foreach ($this->tableColumn as $column) {
if (!$column['is_query'] || $column['is_pk']) {
continue;
}
$content .= $column['column_name'] . ": ''," . PHP_EOL;
if ($column['query_type'] == 'between' && $column['view_type'] == 'datetime') {
$queryDate = true;
}
}
if ($queryDate) {
$content .= "start_time: ''," . PHP_EOL;
$content .= "end_time: ''," . PHP_EOL;
}
$content = substr($content, 0, -2);
return $this->setBlankSpace($content, ' ');
}
/**
* @notes 获取字典数据内容
* @return string
* @author 段誉
* @date 2022/6/23 11:58
*/
public function getDictDataContent()
{
$content = '';
$isExist = [];
foreach ($this->tableColumn as $column) {
if (empty($column['dict_type']) || $column['is_pk']) {
continue;
}
if (in_array($column['dict_type'], $isExist)) {
continue;
}
$content .= $column['dict_type'] .",";
$isExist[] = $column['dict_type'];
}
if (!empty($content)) {
$content = substr($content, 0, -1);
}
return $this->setBlankSpace($content, '');
}
/**
* @notes 权限规则
* @param string $type
* @return string
* @author 段誉
* @date 2022/7/7 9:47
*/
public function getPermsContent($type = 'add')
{
if (!empty($this->classDir)) {
$classDir = $this->classDir . '.';
} else {
$classDir = '';
}
return trim($classDir . $this->getLowerTableName() . '/' . $type);
}
/**
* @notes 获取文件生成到模块的文件夹路径
* @return mixed|void
* @author 段誉
* @date 2022/6/22 18:19
*/
public function getModuleGenerateDir()
{
$dir = dirname(app()->getRootPath()) . '/admin/src/views/' . $this->getLowerTableName() . '/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 获取文件生成到runtime的文件夹路径
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getRuntimeGenerateDir()
{
$dir = $this->generatorDir . 'vue/src/views/' . $this->getLowerTableName() . '/';
$this->checkDir($dir);
return $dir;
}
/**
* @notes 生成的文件名
* @return string
* @author 段誉
* @date 2022/6/22 18:20
*/
public function getGenerateName()
{
return 'index.vue';
}
/**
* @notes 文件信息
* @return array
* @author 段誉
* @date 2022/6/23 15:57
*/
public function fileInfo(): array
{
return [
'name' => $this->getGenerateName(),
'type' => 'vue',
'content' => $this->content
];
}
}
@@ -0,0 +1,105 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
{USE}
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}Controller
* @package app\{MODULE_NAME}\controller{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME}Controller extends {EXTENDS_CONTROLLER}
{
/**
* @notes 获取{NOTES}列表
* @return \think\response\Json
* @author {AUTHOR}
* @date {DATE}
*/
public function lists()
{
return $this->dataLists(new {UPPER_CAMEL_NAME}Lists());
}
/**
* @notes 添加{NOTES}
* @return \think\response\Json
* @author {AUTHOR}
* @date {DATE}
*/
public function add()
{
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('add');
$result = {UPPER_CAMEL_NAME}Logic::add($params);
if (true === $result) {
return $this->success('添加成功', [], 1, 1);
}
return $this->fail({UPPER_CAMEL_NAME}Logic::getError());
}
/**
* @notes 编辑{NOTES}
* @return \think\response\Json
* @author {AUTHOR}
* @date {DATE}
*/
public function edit()
{
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('edit');
$result = {UPPER_CAMEL_NAME}Logic::edit($params);
if (true === $result) {
return $this->success('编辑成功', [], 1, 1);
}
return $this->fail({UPPER_CAMEL_NAME}Logic::getError());
}
/**
* @notes 删除{NOTES}
* @return \think\response\Json
* @author {AUTHOR}
* @date {DATE}
*/
public function delete()
{
$params = (new {UPPER_CAMEL_NAME}Validate())->post()->goCheck('delete');
{UPPER_CAMEL_NAME}Logic::delete($params);
return $this->success('删除成功', [], 1, 1);
}
/**
* @notes 获取{NOTES}详情
* @return \think\response\Json
* @author {AUTHOR}
* @date {DATE}
*/
public function detail()
{
$params = (new {UPPER_CAMEL_NAME}Validate())->goCheck('detail');
$result = {UPPER_CAMEL_NAME}Logic::detail($params);
return $this->data($result);
}
}
@@ -0,0 +1,76 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
{USE}
use app\common\lists\ListsSearchInterface;
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}Lists
* @package app\{MODULE_NAME}\lists{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME}Lists extends {EXTENDS_LISTS} implements ListsSearchInterface
{
/**
* @notes 设置搜索条件
* @return \string[][]
* @author {AUTHOR}
* @date {DATE}
*/
public function setSearch(): array
{
return [
{QUERY_CONDITION}
];
}
/**
* @notes 获取{NOTES}列表
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author {AUTHOR}
* @date {DATE}
*/
public function lists(): array
{
return {UPPER_CAMEL_NAME}::where($this->searchWhere)
->field([{FIELD_DATA}])
->limit($this->limitOffset, $this->limitLength)
->order(['{PK}' => 'desc'])
->select()
->toArray();
}
/**
* @notes 获取{NOTES}数量
* @return int
* @author {AUTHOR}
* @date {DATE}
*/
public function count(): int
{
return {UPPER_CAMEL_NAME}::where($this->searchWhere)->count();
}
}
@@ -0,0 +1,106 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
{USE}
use app\common\logic\BaseLogic;
use think\facade\Db;
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}Logic
* @package app\{MODULE_NAME}\logic{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME}Logic extends BaseLogic
{
/**
* @notes 添加{NOTES}
* @param array $params
* @return bool
* @author {AUTHOR}
* @date {DATE}
*/
public static function add(array $params): bool
{
Db::startTrans();
try {
{UPPER_CAMEL_NAME}::create([
{CREATE_DATA}
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 编辑{NOTES}
* @param array $params
* @return bool
* @author {AUTHOR}
* @date {DATE}
*/
public static function edit(array $params): bool
{
Db::startTrans();
try {
{UPPER_CAMEL_NAME}::where('{PK}', $params['{PK}'])->update([
{UPDATE_DATA}
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 删除{NOTES}
* @param array $params
* @return bool
* @author {AUTHOR}
* @date {DATE}
*/
public static function delete(array $params): bool
{
return {UPPER_CAMEL_NAME}::destroy($params['{PK}']);
}
/**
* @notes 获取{NOTES}详情
* @param $params
* @return array
* @author {AUTHOR}
* @date {DATE}
*/
public static function detail($params): array
{
return {UPPER_CAMEL_NAME}::findOrEmpty($params['{PK}'])->toArray();
}
}
@@ -0,0 +1,34 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
use app\common\model\BaseModel;
{USE}
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}
* @package app\common\model{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME} extends BaseModel
{
{DELETE_USE}
protected $name = '{TABLE_NAME}';
{DELETE_TIME}
{RELATION_MODEL}
}
@@ -0,0 +1,11 @@
/**
* @notes 关联{RELATION_NAME}
* @return \think\model\relation\HasMany
* @author {AUTHOR}
* @date {DATE}
*/
public function {RELATION_NAME}()
{
return $this->hasMany({RELATION_MODEL}::class, '{FOREIGN_KEY}', '{LOCAL_KEY}');
}
@@ -0,0 +1,11 @@
/**
* @notes 关联{RELATION_NAME}
* @return \think\model\relation\HasOne
* @author {AUTHOR}
* @date {DATE}
*/
public function {RELATION_NAME}()
{
return $this->hasOne({RELATION_MODEL}::class, '{FOREIGN_KEY}', '{LOCAL_KEY}');
}
@@ -0,0 +1,77 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
{USE}
use app\common\lists\ListsSearchInterface;
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}Lists
* @package app\{MODULE_NAME}\lists{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME}Lists extends {EXTENDS_LISTS} implements ListsSearchInterface
{
/**
* @notes 设置搜索条件
* @return \string[][]
* @author {AUTHOR}
* @date {DATE}
*/
public function setSearch(): array
{
return [
{QUERY_CONDITION}
];
}
/**
* @notes 获取{NOTES}列表
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author {AUTHOR}
* @date {DATE}
*/
public function lists(): array
{
$lists = {UPPER_CAMEL_NAME}::where($this->searchWhere)
->field([{FIELD_DATA}])
->order(['{PK}' => 'desc'])
->select()
->toArray();
return linear_to_tree($lists, 'children', '{TREE_ID}', '{TREE_PID}');
}
/**
* @notes 获取{NOTES}数量
* @return int
* @author {AUTHOR}
* @date {DATE}
*/
public function count(): int
{
return {UPPER_CAMEL_NAME}::where($this->searchWhere)->count();
}
}
@@ -0,0 +1,94 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
{NAMESPACE}
use app\common\validate\BaseValidate;
/**
* {CLASS_COMMENT}
* Class {UPPER_CAMEL_NAME}Validate
* @package app\{MODULE_NAME}\validate{PACKAGE_NAME}
*/
class {UPPER_CAMEL_NAME}Validate extends BaseValidate
{
/**
* 设置校验规则
* @var string[]
*/
protected $rule = [
{RULE}
];
/**
* 参数描述
* @var string[]
*/
protected $field = [
{FIELD}
];
/**
* @notes 添加场景
* @return {UPPER_CAMEL_NAME}Validate
* @author {AUTHOR}
* @date {DATE}
*/
public function sceneAdd()
{
{ADD_PARAMS}
}
/**
* @notes 编辑场景
* @return {UPPER_CAMEL_NAME}Validate
* @author {AUTHOR}
* @date {DATE}
*/
public function sceneEdit()
{
{EDIT_PARAMS}
}
/**
* @notes 删除场景
* @return {UPPER_CAMEL_NAME}Validate
* @author {AUTHOR}
* @date {DATE}
*/
public function sceneDelete()
{
return $this->only(['{PK}']);
}
/**
* @notes 详情场景
* @return {UPPER_CAMEL_NAME}Validate
* @author {AUTHOR}
* @date {DATE}
*/
public function sceneDetail()
{
return $this->only(['{PK}']);
}
}
@@ -0,0 +1,26 @@
import request from '@/utils/request'
// {COMMENT}列表
export function api{UPPER_CAMEL_NAME}Lists(params: any) {
return request.get({ url: '/{ROUTE}/lists', params })
}
// 添加{COMMENT}
export function api{UPPER_CAMEL_NAME}Add(params: any) {
return request.post({ url: '/{ROUTE}/add', params })
}
// 编辑{COMMENT}
export function api{UPPER_CAMEL_NAME}Edit(params: any) {
return request.post({ url: '/{ROUTE}/edit', params })
}
// 删除{COMMENT}
export function api{UPPER_CAMEL_NAME}Delete(params: any) {
return request.post({ url: '/{ROUTE}/delete', params })
}
// {COMMENT}详情
export function api{UPPER_CAMEL_NAME}Detail(params: any) {
return request.get({ url: '/{ROUTE}/detail', params })
}
@@ -0,0 +1,103 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="550px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="140px" :rules="formRules">
{FORM_VIEW}
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup name="{SETUP_NAME}Edit">
import type { FormInstance } from 'element-plus'
import Popup from '@/components/popup/index.vue'
import {{IMPORT_LISTS} api{UPPER_CAMEL_NAME}Add, api{UPPER_CAMEL_NAME}Edit, api{UPPER_CAMEL_NAME}Detail } from '@/api/{API_DIR}'
import { timeFormat } from '@/utils/util'
import type { PropType } from 'vue'
defineProps({
dictData: {
type: Object as PropType<Record<string, any[]>>,
default: () => ({})
}
})
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref('add')
{TREE_CONST}
// 弹窗标题
const popupTitle = computed(() => {
return mode.value == 'edit' ? '编辑{TABLE_COMMENT}' : '新增{TABLE_COMMENT}'
})
// 表单数据
const formData = reactive({
{PK}: '',
{FORM_DATA}
})
// 表单验证
const formRules = reactive<any>({
{FORM_VALIDATE}
})
// 获取详情
const setFormData = async (data: Record<any, any>) => {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
//@ts-ignore
formData[key] = data[key]
}
}
{CHECKBOX_SPLIT}
{FORM_DATE}
}
const getDetail = async (row: Record<string, any>) => {
const data = await api{UPPER_CAMEL_NAME}Detail({
{PK}: row.{PK}
})
setFormData(data)
}
// 提交按钮
const handleSubmit = async () => {
await formRef.value?.validate()
const data = { ...formData, {CHECKBOX_JOIN} }
mode.value == 'edit'
? await api{UPPER_CAMEL_NAME}Edit(data)
: await api{UPPER_CAMEL_NAME}Add(data)
popupRef.value?.close()
emit('success')
}
//打开弹窗
const open = (type = 'add') => {
mode.value = type
popupRef.value?.open()
}
// 关闭回调
const handleClose = () => {
emit('close')
}
{GET_TREE_LISTS}
defineExpose({
open,
setFormData,
getDetail
})
</script>
@@ -0,0 +1,11 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-checkbox-group v-model="formData.{COLUMN_NAME}" placeholder="请选择{COLUMN_COMMENT}">
<el-checkbox
v-for="(item, index) in dictData.{DICT_TYPE}"
:key="index"
:label="item.value"
>
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
@@ -0,0 +1,10 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-date-picker
class="flex-1 !flex"
v-model="formData.{COLUMN_NAME}"
clearable
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择{COLUMN_COMMENT}">
</el-date-picker>
</el-form-item>
@@ -0,0 +1,6 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<daterange-picker
v-model:startTime="formData.start_{COLUMN_NAME}"
v-model:endTime="formData.end_{COLUMN_NAME}"
/>
</el-form-item>
@@ -0,0 +1,3 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<editor class="flex-1" v-model="formData.{COLUMN_NAME}" :height="500" />
</el-form-item>
@@ -0,0 +1,3 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<material-picker v-model="formData.{COLUMN_NAME}" />
</el-form-item>
@@ -0,0 +1,3 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-input v-model="formData.{COLUMN_NAME}" clearable placeholder="请输入{COLUMN_COMMENT}" />
</el-form-item>
@@ -0,0 +1,11 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-radio-group v-model="formData.{COLUMN_NAME}" placeholder="请选择{COLUMN_COMMENT}">
<el-radio
v-for="(item, index) in dictData.{DICT_TYPE}"
:key="index"
:label="{ITEM_VALUE}"
>
{{ item.name }}
</el-radio>
</el-radio-group>
</el-form-item>
@@ -0,0 +1,10 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-select class="flex-1" v-model="formData.{COLUMN_NAME}" clearable placeholder="请选择{COLUMN_COMMENT}">
<el-option
v-for="(item, index) in dictData.{DICT_TYPE}"
:key="index"
:label="item.name"
:value="{ITEM_VALUE}"
/>
</el-select>
</el-form-item>
@@ -0,0 +1,3 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-input class="flex-1" v-model="formData.{COLUMN_NAME}" type="textarea" rows="4" clearable placeholder="请输入{COLUMN_COMMENT}" />
</el-form-item>
@@ -0,0 +1,13 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-tree-select
class="flex-1"
v-model="formData.{COLUMN_NAME}"
:data="treeList"
clearable
node-key="{TREE_ID}"
:props="{ label: '{TREE_NAME}', value: '{TREE_ID}', children: 'children' }"
:default-expand-all="true"
placeholder="请选择{COLUMN_COMMENT}"
check-strictly
/>
</el-form-item>
@@ -0,0 +1,167 @@
<template>
<div>
<el-card class="!border-none mb-4" shadow="never">
<el-form
ref="formRef"
class="mb-[-16px]"
:model="queryParams"
inline
>
{SEARCH_VIEW}
<el-form-item>
<el-button type="primary" @click="getLists">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none" shadow="never">
<div>
<el-button v-perms="['{PERMS_ADD}']" type="primary" @click="handleAdd()">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
<el-button @click="handleExpand"> 展开/折叠 </el-button>
</div>
<div class="mt-4">
<el-table
v-loading="loading"
ref="tableRef"
class="mt-4"
size="large"
:data="lists"
row-key="{TREE_ID}"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
{LISTS_VIEW}
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['{PERMS_ADD}']"
type="primary"
link
@click="handleAdd(row.{TREE_ID})"
>
新增
</el-button>
<el-button
v-perms="['{PERMS_EDIT}']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['{PERMS_DELETE}']"
type="danger"
link
@click="handleDelete(row.{PK})"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" :dict-data="dictData" @success="getLists" @close="showEdit = false" />
</div>
</template>
<script lang="ts" setup name="{SETUP_NAME}Lists">
import { timeFormat } from '@/utils/util'
import { useDictData } from '@/hooks/useDictOptions'
import { api{UPPER_CAMEL_NAME}Lists, api{UPPER_CAMEL_NAME}Delete } from '@/api/{API_DIR}'
import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
import type { ElTable, FormInstance } from 'element-plus'
const tableRef = shallowRef<InstanceType<typeof ElTable>>()
const formRef = shallowRef<FormInstance>()
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
let isExpand = false
// 是否显示编辑框
const showEdit = ref(false)
const loading = ref(false)
const lists = ref<any[]>([])
// 查询条件
const queryParams = reactive({
{QUERY_PARAMS}
})
const resetParams = () => {
formRef.value?.resetFields()
getLists()
}
const getLists = async () => {
loading.value = true
try {
const data = await api{UPPER_CAMEL_NAME}Lists(queryParams)
lists.value = data.lists
loading.value = false
} catch (error) {
loading.value = false
}
}
// 选中数据
const selectData = ref<any[]>([])
// 表格选择后回调事件
const handleSelectionChange = (val: any[]) => {
selectData.value = val.map(({ {PK} }) => {PK})
}
// 获取字典数据
const { dictData } = useDictData('{DICT_DATA}')
// 添加
const handleAdd = async ({TREE_ID}?: number) => {
showEdit.value = true
await nextTick()
if ({TREE_ID}) {
editRef.value?.setFormData({
{TREE_PID}: {TREE_ID}
})
}
editRef.value?.open('add')
}
// 编辑
const handleEdit = async (data: any) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(data)
}
// 删除
const handleDelete = async ({PK}: number | any[]) => {
await feedback.confirm('确定要删除?')
await api{UPPER_CAMEL_NAME}Delete({ {PK} })
getLists()
}
const handleExpand = () => {
isExpand = !isExpand
toggleExpand(lists.value, isExpand)
}
const toggleExpand = (children: any[], unfold = true) => {
for (const key in children) {
tableRef.value?.toggleRowExpansion(children[key], unfold)
if (children[key].children) {
toggleExpand(children[key].children!, unfold)
}
}
}
getLists()
</script>
@@ -0,0 +1,123 @@
<template>
<div>
<el-card class="!border-none mb-4" shadow="never">
<el-form
class="mb-[-16px]"
:model="queryParams"
inline
>
{SEARCH_VIEW}
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none" v-loading="pager.loading" shadow="never">
<el-button v-perms="['{PERMS_ADD}']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
<el-button
v-perms="['{PERMS_DELETE}']"
:disabled="!selectData.length"
@click="handleDelete(selectData)"
>
删除
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
{LISTS_VIEW}
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['{PERMS_EDIT}']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['{PERMS_DELETE}']"
type="danger"
link
@click="handleDelete(row.{PK})"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" :dict-data="dictData" @success="getLists" @close="showEdit = false" />
</div>
</template>
<script lang="ts" setup name="{SETUP_NAME}Lists">
import { usePaging } from '@/hooks/usePaging'
import { useDictData } from '@/hooks/useDictOptions'
import { api{UPPER_CAMEL_NAME}Lists, api{UPPER_CAMEL_NAME}Delete } from '@/api/{API_DIR}'
import { timeFormat } from '@/utils/util'
import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
// 是否显示编辑框
const showEdit = ref(false)
// 查询条件
const queryParams = reactive({
{QUERY_PARAMS}
})
// 选中数据
const selectData = ref<any[]>([])
// 表格选择后回调事件
const handleSelectionChange = (val: any[]) => {
selectData.value = val.map(({ {PK} }) => {PK})
}
// 获取字典数据
const { dictData } = useDictData('{DICT_DATA}')
// 分页相关
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: api{UPPER_CAMEL_NAME}Lists,
params: queryParams
})
// 添加
const handleAdd = async () => {
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
// 编辑
const handleEdit = async (data: any) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(data)
}
// 删除
const handleDelete = async ({PK}: number | any[]) => {
await feedback.confirm('确定要删除?')
await api{UPPER_CAMEL_NAME}Delete({ {PK} })
getLists()
}
getLists()
</script>
@@ -0,0 +1,6 @@
dictDataLists({
type_value: '{DICT_TYPE}',
page_type: 0
}).then((res: any) => {
dictData.{DICT_TYPE} = res.lists
})
@@ -0,0 +1 @@
const treeList = ref<any[]>([])
@@ -0,0 +1,8 @@
const getLists = async () => {
const data: any = await api{UPPER_CAMEL_NAME}Lists()
const item = { {TREE_ID}: 0, {TREE_NAME}: '顶级', children: [] }
item.children = data.lists
treeList.value.push(item)
}
getLists()
@@ -0,0 +1,5 @@
{COLUMN_NAME}: [{
required: true,
message: '{VALIDATE_MSG}',
trigger: ['blur']
}]
@@ -0,0 +1,6 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<daterange-picker
v-model:startTime="queryParams.start_time"
v-model:endTime="queryParams.end_time"
/>
</el-form-item>
@@ -0,0 +1,3 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-input class="w-[280px]" v-model="queryParams.{COLUMN_NAME}" clearable placeholder="请输入{COLUMN_COMMENT}" />
</el-form-item>
@@ -0,0 +1,11 @@
<el-form-item label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<el-select class="w-[280px]" v-model="queryParams.{COLUMN_NAME}" clearable placeholder="请选择{COLUMN_COMMENT}">
<el-option label="全部" value=""></el-option>
<el-option
v-for="(item, index) in dictData.{DICT_TYPE}"
:key="index"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
@@ -0,0 +1,5 @@
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<template #default="{ row }">
<span>{{ row.{COLUMN_NAME} ? timeFormat(row.{COLUMN_NAME}, 'yyyy-mm-dd hh:MM:ss') : '' }}</span>
</template>
</el-table-column>
@@ -0,0 +1 @@
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}" show-overflow-tooltip />
@@ -0,0 +1,5 @@
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<template #default="{ row }">
<el-image style="width:50px;height:50px;" :src="row.{COLUMN_NAME}" />
</template>
</el-table-column>
@@ -0,0 +1,5 @@
<el-table-column label="{COLUMN_COMMENT}" prop="{COLUMN_NAME}">
<template #default="{ row }">
<dict-value :options="dictData.{DICT_TYPE}" :value="row.{COLUMN_NAME}" />
</template>
</el-table-column>
@@ -0,0 +1,372 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\pay;
use Alipay\EasySDK\Kernel\Factory;
use Alipay\EasySDK\Kernel\Config;
use app\common\enum\PayEnum;
use app\common\enum\user\UserTerminalEnum;
use app\common\logic\PayNotifyLogic;
use app\common\model\member\MemberOrder;
use app\common\model\pay\PayConfig;
use app\common\model\recharge\RechargeOrder;
use think\facade\Log;
/**
* 支付宝支付
* Class AliPlsayService
* @package app\common\server
*/
class AliPayService extends BasePayService
{
/**
* 用户客户端
* @var
*/
protected $terminal;
/**
* 支付实例
* @var
*/
protected $pay;
/**
* 初始化设置
* AliPayService constructor.
* @throws \Exception
*/
public function __construct($terminal = null)
{
//设置用户终端
$this->terminal = $terminal;
//初始化支付配置
Factory::setOptions($this->getOptions());
$this->pay = Factory::payment();
}
/**
* @notes 支付设置
* @return Config
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author 段誉
* @date 2021/7/28 17:43
*/
public function getOptions()
{
$config = (new PayConfig())->where(['pay_way' => PayEnum::ALI_PAY])->find();
if (empty($config)) {
throw new \Exception('请配置好支付设置');
}
$options = new Config();
$options->protocol = 'https';
$options->gatewayHost = 'openapi.alipay.com';
// $options->gatewayHost = 'openapi.alipaydev.com'; //测试沙箱地址
$options->signType = 'RSA2';
$options->appId = $config['config']['app_id'] ?? '';
// 应用私钥
$options->merchantPrivateKey = $config['config']['private_key'] ?? '';
//接口加签方式
// 秘钥模式
if ($config['config']['mode'] == 'normal_mode') {
//支付宝公钥
$options->alipayPublicKey = $config['config']['ali_public_key'] ?? '';
}
//证书模式
if ($config['config']['mode'] == 'certificate') {
//判断是否已经存在证书文件夹,不存在则新建
if (!file_exists(app()->getRootPath() . 'runtime/certificate')) {
mkdir(app()->getRootPath() . 'runtime/certificate', 0775, true);
}
//写入文件
$publicCert = $config['config']['public_cert'] ?? '';
$aliPublicCert = $config['config']['ali_public_cert'] ?? '';
$aliRootCert = $config['config']['ali_root_cert'] ?? '';
$publicCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($publicCert) . '.crt';
$aliPublicCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($aliPublicCert) . '.crt';
$aliRootCertPath = app()->getRootPath() . 'runtime/certificate/' . md5($aliRootCert) . '.crt';
if (!file_exists($publicCertPath)) {
$fopenPublicCertPath = fopen($publicCertPath, 'w');
fwrite($fopenPublicCertPath, $publicCert);
fclose($fopenPublicCertPath);
}
if (!file_exists($aliPublicCertPath)) {
$fopenAliPublicCertPath = fopen($aliPublicCertPath, 'w');
fwrite($fopenAliPublicCertPath, $aliPublicCert);
fclose($fopenAliPublicCertPath);
}
if (!file_exists($aliRootCertPath)) {
$fopenAliRootCertPath = fopen($aliRootCertPath, 'w');
fwrite($fopenAliRootCertPath, $aliRootCert);
fclose($fopenAliRootCertPath);
}
//应用公钥证书路径
$options->merchantCertPath = $publicCertPath;
//支付宝公钥证书路径
$options->alipayCertPath = $aliPublicCertPath;
//支付宝根证书路径
$options->alipayRootCertPath = $aliRootCertPath;
}
//回调地址
$options->notifyUrl = (string)url('pay/aliNotify', [], false, true);
return $options;
}
/**
* @notes 支付
* @param $from //订单来源;order-商品订单;recharge-充值订单
* @param $order //订单信息
* @return false|string[]
* @author 段誉
* @date 2021/8/13 17:08
*/
public function pay($from, $order): array|bool
{
try {
$result = match ($this->terminal) {
UserTerminalEnum::PC => $this->pagePay($from, $order),
UserTerminalEnum::IOS, UserTerminalEnum::ANDROID => $this->appPay($from, $order),
UserTerminalEnum::WECHAT_OA, UserTerminalEnum::H5 => $this->wapPay($from, $order),
default => throw new \Exception('支付方式错误'),
};
return [
'config' => $result,
'pay_way' => PayEnum::ALI_PAY
];
} catch (\Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* Notes: 支付回调
* @param $data
* @return bool
* @author 段誉(2021/3/22 17:22)
*/
public function notify($data)
{
try {
$verify = $this->pay->common()->verifyNotify($data);
if (false === $verify) {
throw new \Exception('异步通知验签失败');
}
if (!in_array($data['trade_status'], ['TRADE_SUCCESS', 'TRADE_FINISHED'])) {
return true;
}
$extra['transaction_id'] = $data['trade_no'];
//验证订单是否已支付
switch ($data['passback_params']) {
case 'recharge':
$order = RechargeOrder::where(['sn' => $data['out_trade_no']])->findOrEmpty();
if ($order->isEmpty() || $order->pay_status == PayEnum::ISPAID) {
return true;
}
PayNotifyLogic::handle('recharge', $data['out_trade_no'], $extra);
break;
}
return true;
} catch (\Exception $e) {
$record = [
__CLASS__,
__FUNCTION__,
$e->getFile(),
$e->getLine(),
$e->getMessage()
];
Log::write(implode('-', $record));
$this->setError($e->getMessage());
return false;
}
}
/**
* @notes PC支付
* @param $attach //附加参数(在回调时会返回)
* @param $order //订单信息
* @return string
* @author 段誉
* @date 2021/7/28 17:34
*/
public function pagePay($attach, $order)
{
$domain = request()->domain();
$result = $this->pay->page()->optional('passback_params', $attach)->pay(
'订单:' . $order['sn'],
$order['sn'],
$order['order_amount'],
$domain . $order['redirect_url']
);
return $result->body;
}
/**
* @notes APP支付
* @param $attach //附加参数(在回调时会返回)
* @param $order //订单信息
* @return string
* @author 段誉
* @date 2021/7/28 17:34
*/
public function appPay($attach, $order)
{
$result = $this->pay->app()->optional('passback_params', $attach)->pay(
$order['sn'],
$order['sn'],
$order['order_amount']
);
return $result->body;
}
/**
* @notes 手机网页支付
* @param $attach //附加参数(在回调时会返回)
* @param $order //订单信息
* @return string
* @author 段誉
* @date 2021/7/28 17:34
*/
public function wapPay($attach, $order)
{
$domain = request()->domain();
$url = $domain . '/mobile' . $order['redirect_url'] .'?id=' . $order['id'] . '&from='. $attach . '&checkPay=true';;
$result = $this->pay->wap()->optional('passback_params', $attach)->pay(
'订单:' . $order['sn'],
$order['sn'],
$order['order_amount'],
$url,
$url
);
return $result->body;
}
/**
* @notes 查询订单
* @param $orderSn
* @return \Alipay\EasySDK\Payment\Common\Models\AlipayTradeQueryResponse
* @throws \Exception
* @author 段誉
* @date 2021/7/28 17:36
*/
public function checkPay($orderSn)
{
return $this->pay->common()->query($orderSn);
}
/**
* @notes 退款
* @param $orderSn
* @param $orderAmount
* @param $outRequestNo
* @return \Alipay\EasySDK\Payment\Common\Models\AlipayTradeRefundResponse
* @throws \Exception
* @author 段誉
* @date 2021/7/28 17:37
*/
public function refund($orderSn, $orderAmount, $outRequestNo)
{
return $this->pay->common()->optional('out_request_no', $outRequestNo)->refund($orderSn, $orderAmount);
}
/**
* @notes 查询退款
* @author Tab
* @date 2021/9/13 11:38
*/
public function queryRefund($orderSn, $refundSn)
{
return $this->pay->common()->queryRefund($orderSn, $refundSn);
}
/**
* @notes 捕获错误
* @param $result
* @throws \Exception
* @author 段誉
* @date 2023/2/28 12:09
*/
public function checkResultFail($result)
{
if (isset($result['alipay_trade_precreate_response']['code']) && 10000 != $result['alipay_trade_precreate_response']['code']) {
throw new \Exception('支付宝:' . $result['alipay_trade_precreate_response']['msg']);
}
}
/**
* @notes 转账到支付宝账号
* @param $withdraw
* @return mixed
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author ljj
* @date 2023/10/9 10:58 上午
*/
public function transfer($withdraw)
{
//请求参数
$data = [
'out_biz_no' => $withdraw['sn'],//商家侧唯一订单号,由商家自定义。对于不同转账请求,商家需保证该订单号在自身系统唯一。
'trans_amount' => $withdraw['left_money'],//订单总金额,单位为元,不支持千位分隔符,精确到小数点后两位
'product_code' => 'TRANS_ACCOUNT_NO_PWD',//销售产品码。单笔无密转账固定为 TRANS_ACCOUNT_NO_PWD。
'biz_scene' => 'DIRECT_TRANSFER',//业务场景。单笔无密转账固定为 DIRECT_TRANSFER。
'order_title' => '佣金提现',//转账业务的标题
'payee_info' => [//收款方信息
'identity' => $withdraw['account'],//参与方的标识 ID。当 identity_type=ALIPAY_USER_ID 时,填写支付宝用户 UID;当 identity_type=ALIPAY_LOGON_ID 时,填写支付宝登录号。
'identity_type' => 'ALIPAY_LOGON_ID',//参与方的标识类型。ALIPAY_USER_ID:支付宝会员的用户 IDALIPAY_LOGON_ID:支付宝登录号;
'name' => $withdraw['real_name'],//参与方真实姓名。如果非空,将校验收款支付宝账号姓名一致性。当 identity_type=ALIPAY_LOGON_ID 时,本字段必填。
],
'remark' => '',//业务备注
];
$result = Factory::util()->generic()->execute("alipay.fund.trans.uni.transfer", [], $data);
$result = json_decode($result->httpBody, true);
$result = $result['alipay_fund_trans_uni_transfer_response'] ?? [];
if ($result['code'] != 10000) {//接口调用失败
throw new \Exception($result['sub_msg'] ?? $result['msg']);
}
return $result;
}
/**
* @notes 转账查询
* @param $withdraw
* @return mixed
* @throws \Exception
* @author ljj
* @date 2023/10/9 11:47 上午
*/
public function transferQuery($withdraw)
{
//请求参数
$data = [
'out_biz_no' => $withdraw['sn'],//商户转账唯一订单号:发起转账来源方定义的转账单据 ID。
'product_code' => 'TRANS_ACCOUNT_NO_PWD',//销售产品码,如果传了 out_biz_no,则该字段必传。单笔无密转账固定为TRANS_ACCOUNT_NO_PWD。
'biz_scene' => 'DIRECT_TRANSFER',//描述特定的业务场景,如果传递了out_biz_no 则该字段为必传。单笔无密转账固定为DIRECT_TRANSFER。
];
$result = Factory::util()->generic()->execute("alipay.fund.trans.common.query", [], $data);
$result = json_decode($result->httpBody, true);
return $result['alipay_fund_trans_common_query_response'] ?? [];
}
}
@@ -0,0 +1,98 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\pay;
use think\facade\Log;
class BasePayService
{
/**
* 错误信息
* @var string
*/
protected $error;
/**
* 返回状态码
* @var int
*/
protected $returnCode = 0;
/**
* @notes 获取错误信息
* @return string
* @author 段誉
* @date 2021/7/21 18:23
*/
public function getError()
{
if (false === self::hasError()) {
return '系统错误';
}
return $this->error;
}
/**
* @notes 设置错误信息
* @param $error
* @author 段誉
* @date 2021/7/21 18:20
*/
public function setError($error)
{
$this->error = $error;
}
/**
* @notes 是否存在错误
* @return bool
* @author 段誉
* @date 2021/7/21 18:32
*/
public function hasError()
{
return !empty($this->error);
}
/**
* @notes 设置状态码
* @param $code
* @author 段誉
* @date 2021/7/28 17:05
*/
public function setReturnCode($code)
{
$this->returnCode = $code;
}
/**
* @notes 特殊场景返回指定状态码,默认为0
* @return int
* @author 段誉
* @date 2021/7/28 15:14
*/
public function getReturnCode()
{
return $this->returnCode;
}
}
@@ -0,0 +1,402 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\pay;
use app\common\enum\PayEnum;
use app\common\enum\user\UserTerminalEnum;
use app\common\logic\PayNotifyLogic;
use app\common\model\recharge\RechargeOrder;
use app\common\model\user\UserAuth;
use app\common\service\wechat\WeChatConfigService;
use EasyWeChat\Pay\Application;
use EasyWeChat\Pay\Message;
/**
* 微信支付
* Class WeChatPayService
* @package app\common\server
*/
class WeChatPayService extends BasePayService
{
/**
* 授权信息
* @var UserAuth|array|\think\Model
*/
protected $auth;
/**
* 微信配置
* @var
*/
protected $config;
/**
* easyWeChat实例
* @var
*/
protected $app;
/**
* 当前使用客户端
* @var
*/
protected $terminal;
/**
* 初始化微信支付配置
* @param $terminal //用户终端
* @param null $userId //用户id(获取授权openid)
*/
public function __construct($terminal, $userId = null)
{
$this->terminal = $terminal;
$this->config = WeChatConfigService::getPayConfigByTerminal($terminal);
$this->app = new Application($this->config);
if ($userId !== null) {
$this->auth = UserAuth::where(['user_id' => $userId, 'terminal' => $terminal])->findOrEmpty();
}
}
/**
* @notes 发起微信支付统一下单
* @param $from
* @param $order
* @return array|false|string
* @author 段誉
* @date 2021/8/4 15:05
*/
public function pay($from, $order)
{
try {
switch ($this->terminal) {
case UserTerminalEnum::WECHAT_MMP:
$config = WeChatConfigService::getMnpConfig();
$result = $this->jsapiPay($from, $order, $config['app_id']);
break;
case UserTerminalEnum::WECHAT_OA:
$config = WeChatConfigService::getOaConfig();
$result = $this->jsapiPay($from, $order, $config['app_id']);
break;
case UserTerminalEnum::IOS:
case UserTerminalEnum::ANDROID:
$config = WeChatConfigService::getOpConfig();
$result = $this->appPay($from, $order, $config['app_id']);
break;
case UserTerminalEnum::H5:
$config = WeChatConfigService::getOaConfig();
$result = $this->mwebPay($from, $order, $config['app_id']);
break;
case UserTerminalEnum::PC:
$config = WeChatConfigService::getOaConfig();
$result = $this->nativePay($from, $order, $config['app_id']);
break;
default:
throw new \Exception('支付方式错误');
}
return [
'config' => $result,
'pay_way' => PayEnum::WECHAT_PAY
];
} catch (\Exception $e) {
$this->setError($e->getMessage());
return false;
}
}
/**
* @notes jsapiPay
* @param $from
* @param $order
* @param $appId
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 12:12
*/
public function jsapiPay($from, $order, $appId)
{
$response = $this->app->getClient()->postJson("v3/pay/transactions/jsapi", [
"appid" => $appId,
"mchid" => $this->config['mch_id'],
"description" => $this->payDesc($from),
"out_trade_no" => $order['pay_sn'],
"notify_url" => $this->config['notify_url'],
"amount" => [
"total" => intval($order['order_amount'] * 100),
],
"payer" => [
"openid" => $this->auth['openid']
],
'attach' => $from
]);
$result = $response->toArray(false);
$this->checkResultFail($result);
return $this->getPrepayConfig($result['prepay_id'], $appId);
}
/**
* @notes 网站native
* @param $from
* @param $order
* @param $appId
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 12:12
*/
public function nativePay($from, $order, $appId)
{
$response = $this->app->getClient()->postJson('v3/pay/transactions/native', [
'appid' => $appId,
'mchid' => $this->config['mch_id'],
'description' => $this->payDesc($from),
'out_trade_no' => $order['pay_sn'],
'notify_url' => $this->config['notify_url'],
'amount' => [
'total' => intval($order['order_amount'] * 100),
],
'attach' => $from
]);
$result = $response->toArray(false);
$this->checkResultFail($result);
return $result['code_url'];
}
/**
* @notes appPay
* @param $from
* @param $order
* @param $appId
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 12:12
*/
public function appPay($from, $order, $appId)
{
$response = $this->app->getClient()->postJson('v3/pay/transactions/app', [
'appid' => $appId,
'mchid' => $this->config['mch_id'],
'description' => $this->payDesc($from),
'out_trade_no' => $order['pay_sn'],
'notify_url' => $this->config['notify_url'],
'amount' => [
'total' => intval($order['order_amount'] * 100),
],
'attach' => $from
]);
$result = $response->toArray(false);
$this->checkResultFail($result);
return $result['prepay_id'];
}
/**
* @notes h5
* @param $from
* @param $order
* @param $appId
* @param $redirectUrl
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 12:13
*/
public function mwebPay($from, $order, $appId)
{
$ip = request()->ip();
if (!empty(env('project.test_web_ip')) && env('APP_DEBUG')) {
$ip = env('project.test_web_ip');
}
$response = $this->app->getClient()->postJson('v3/pay/transactions/h5', [
'appid' => $appId,
'mchid' => $this->config['mch_id'],
'description' => $this->payDesc($from),
'out_trade_no' => $order['pay_sn'],
'notify_url' => $this->config['notify_url'],
'amount' => [
'total' => intval(strval($order['order_amount'] * 100)),
],
'attach' => $from,
'scene_info' => [
'payer_client_ip' => $ip,
'h5_info' => [
'type' => 'Wap',
]
]
]);
$result = $response->toArray(false);
$this->checkResultFail($result);
$domain = request()->domain();
if (!empty(env('project.test_web_domain')) && env('APP_DEBUG')) {
$domain = env('project.test_web_domain');
}
$redirectUrl = $domain . '/mobile'. $order['redirect_url'] .'?id=' . $order['id'] . '&from='. $from . '&checkPay=true';
return $result['h5_url'] . '&redirect_url=' . urlencode($redirectUrl);
}
/**
* @notes 退款
* @param array $refundData
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 16:53
*/
public function refund(array $refundData)
{
$response = $this->app->getClient()->postJson('v3/refund/domestic/refunds', [
'transaction_id' => $refundData['transaction_id'],
'out_refund_no' => $refundData['refund_sn'],
'amount' => [
'refund' => intval($refundData['refund_amount'] * 100),
'total' => intval($refundData['total_amount'] * 100),
'currency' => 'CNY',
]
]);
$result = $response->toArray(false);
$this->checkResultFail($result);
return $result;
}
/**
* @notes 查询退款
* @param $refundSn
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/3/1 11:16
*/
public function queryRefund($refundSn)
{
$response = $this->app->getClient()->get("v3/refund/domestic/refunds/{$refundSn}");
return $response->toArray(false);
}
/**
* @notes 支付描述
* @param $from
* @return string
* @author 段誉
* @date 2023/2/27 17:54
*/
public function payDesc($from)
{
$desc = [
'order' => '商品',
'recharge' => '充值',
];
return $desc[$from] ?? '商品';
}
/**
* @notes 捕获错误
* @param $result
* @throws \Exception
* @author 段誉
* @date 2023/2/28 12:09
*/
public function checkResultFail($result)
{
if (!empty($result['code']) || !empty($result['message'])) {
throw new \Exception('微信:'. $result['code'] . '-' . $result['message']);
}
}
/**
* @notes 预支付配置
* @param $prepayId
* @param $appId
* @return mixed[]
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/2/28 17:38
*/
public function getPrepayConfig($prepayId, $appId)
{
return $this->app->getUtils()->buildBridgeConfig($prepayId, $appId);
}
/**
* @notes 支付回调
* @return \Psr\Http\Message\ResponseInterface
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\RuntimeException
* @throws \ReflectionException
* @throws \Throwable
* @author 段誉
* @date 2023/2/28 14:20
*/
public function notify()
{
$server = $this->app->getServer();
// 支付通知
$server->handlePaid(function (Message $message) {
if ($message['trade_state'] === 'SUCCESS') {
$extra['transaction_id'] = $message['transaction_id'];
$attach = $message['attach'];
$message['out_trade_no'] = mb_substr($message['out_trade_no'], 0, 18);
switch ($attach) {
case 'recharge':
$order = RechargeOrder::where(['sn' => $message['out_trade_no']])->findOrEmpty();
if($order->isEmpty() || $order->pay_status == PayEnum::ISPAID) {
return true;
}
PayNotifyLogic::handle('recharge', $message['out_trade_no'], $extra);
break;
}
}
return true;
});
// 退款通知
$server->handleRefunded(function (Message $message) {
return true;
});
return $server->serve();
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\sms;
use app\common\enum\notice\NoticeEnum;
use app\common\enum\notice\SmsEnum;
use app\common\enum\YesNoEnum;
use app\common\model\Notice;
use app\common\model\notice\SmsLog;
use app\common\service\ConfigService;
/**
* 短信驱动
* Class SmsDriver
* @package app\common\service\sms
*/
class SmsDriver
{
/**
* 错误信息
* @var
*/
protected $error = null;
/**
* 默认短信引擎
* @var
*/
protected $defaultEngine;
/**
* 短信引擎
* @var
*/
protected $engine;
/**
* 架构方法
* SmsDriver constructor.
*/
public function __construct()
{
// 初始化
$this->initialize();
}
/**
* @notes 初始化
* @return bool
* @author 段誉
* @date 2022/9/15 16:29
*/
public function initialize()
{
try {
$defaultEngine = ConfigService::get('sms', 'engine', false);
if ($defaultEngine === false) {
throw new \Exception('请开启短信配置');
}
$this->defaultEngine = $defaultEngine;
$classSpace = __NAMESPACE__ . '\\engine\\' . ucfirst(strtolower($defaultEngine)) . 'Sms';
if (!class_exists($classSpace)) {
throw new \Exception('没有相应的短信驱动类');
}
$engineConfig = ConfigService::get('sms', strtolower($defaultEngine), false);
if ($engineConfig === false) {
throw new \Exception($defaultEngine . '未配置');
}
if ($engineConfig['status'] != 1) {
throw new \Exception('短信服务未开启');
}
$this->engine = new $classSpace($engineConfig);
if (!is_null($this->engine->getError())) {
throw new \Exception($this->engine->getError());
}
return true;
} catch (\Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* @notes 获取错误信息
* @return null
* @author 段誉
* @date 2022/9/15 16:29
*/
public function getError()
{
return $this->error;
}
/**
* @notes 发送短信
* @param $mobile
* @param $data
* @return false
* @author 段誉
* @date 2022/9/15 16:29
*/
public function send($mobile, $data)
{
try {
// 发送频率限制
$this->sendLimit($mobile);
// 开始发送
$result = $this->engine
->setMobile($mobile)
->setTemplateId($data['template_id'])
->setTemplateParams($data['params'])
->send();
if (false === $result) {
throw new \Exception($this->engine->getError());
}
return $result;
} catch (\Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* @notes 发送频率限制
* @param $mobile
* @throws \Exception
* @author 段誉
* @date 2022/9/15 16:29
*/
public function sendLimit($mobile)
{
$smsLog = SmsLog::where([
['mobile', '=', $mobile],
['send_status', '=', SmsEnum::SEND_SUCCESS],
['scene_id', 'in', NoticeEnum::SMS_SCENE],
])
->order('send_time', 'desc')
->findOrEmpty()
->toArray();
if (!empty($smsLog) && ($smsLog['send_time'] > time() - 60)) {
throw new \Exception('同一手机号1分钟只能发送1条短信');
}
}
/**
* @notes 校验手机验证码
* @param $mobile
* @param $code
* @return bool
* @author 段誉
* @date 2022/9/15 16:29
*/
public function verify($mobile, $code, $sceneId = 0)
{
// 测试通用验证码
if ($code === '0000') {
return true;
}
$where = [
['mobile', '=', $mobile],
['send_status', '=', SmsEnum::SEND_SUCCESS],
['scene_id', 'in', NoticeEnum::SMS_SCENE],
['is_verify', '=', YesNoEnum::NO],
];
if (!empty($sceneId)) {
$where[] = ['scene_id', '=', $sceneId];
}
$smsLog = SmsLog::where($where)
->order('send_time', 'desc')
->findOrEmpty();
// 没有验证码 或 最新验证码已校验 或 已过期(有效期:5分钟)
if ($smsLog->isEmpty() || $smsLog->is_verify || ($smsLog->send_time < time() - 5 * 60)) {
return false;
}
// 更新校验状态
if ($smsLog->code == $code) {
$smsLog->check_num = $smsLog->check_num + 1;
$smsLog->is_verify = YesNoEnum::YES;
$smsLog->save();
return true;
}
// 更新验证次数
$smsLog->check_num = $smsLog->check_num + 1;
$smsLog->save();
return false;
}
}
@@ -0,0 +1,180 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\sms;
use app\common\enum\notice\NoticeEnum;
use app\common\enum\notice\SmsEnum;
use app\common\logic\NoticeLogic;
use app\common\model\notice\NoticeSetting;
use app\common\model\notice\SmsLog;
use app\common\service\ConfigService;
/**
* 短信服务
* Class SmsMessageService
* @package app\common\service
*/
class SmsMessageService
{
protected $notice;
protected $smsLog;
public function send($params)
{
try {
// 通知设置
$noticeSetting = NoticeSetting::where('scene_id', $params['scene_id'])->findOrEmpty()->toArray();
// 替换通知模板参数
$content = $this->contentFormat($noticeSetting, $params);
// 添加短信记录
$this->smsLog = $this->addSmsLog($params, $content);
// 添加通知记录
$this->notice = NoticeLogic::addNotice($params, $noticeSetting, NoticeEnum::SMS, $content);
// 发送短信
$smsDriver = new SmsDriver();
if(!is_null($smsDriver->getError())) {
throw new \Exception($smsDriver->getError());
}
$result = $smsDriver->send($params['params']['mobile'], [
'template_id' => $noticeSetting['sms_notice']['template_id'],
'params' => $this->setSmsParams($noticeSetting, $params)
]);
if ($result === false) {
// 发送失败更新短信记录
$this->updateSmsLog($this->smsLog['id'], SmsEnum::SEND_FAIL, $smsDriver->getError());
throw new \Exception($smsDriver->getError());
}
// 发送成功更新短信记录
$this->updateSmsLog($this->smsLog['id'], SmsEnum::SEND_SUCCESS, $result);
return true;
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
/**
* @notes 格式化消息内容
* @param $noticeSetting
* @param $params
* @return array|mixed|string|string[]
* @author 段誉
* @date 2022/9/15 16:24
*/
public function contentFormat($noticeSetting, $params)
{
$content = $noticeSetting['sms_notice']['content'];
foreach($params['params'] as $k => $v) {
$search = '${' . $k . '}';
$content = str_replace($search, $v, $content);
}
return $content;
}
/**
* @notes 添加短信记录
* @param $params
* @param $content
* @return SmsLog|\think\Model
* @author 段誉
* @date 2022/9/15 16:24
*/
public function addSmsLog($params, $content)
{
$data = [
'scene_id' => $params['scene_id'],
'mobile' => $params['params']['mobile'],
'content' => $content,
'code' => $params['params']['code'] ?? '',
'send_status' => SmsEnum::SEND_ING,
'send_time' => time(),
];
return SmsLog::create($data);
}
/**
* @notes 处理腾讯云短信参数
* @param $noticeSetting
* @param $params
* @return array|mixed
* @author 段誉
* @date 2022/9/15 16:25
*/
public function setSmsParams($noticeSetting, $params)
{
$defaultEngine = ConfigService::get('sms', 'engine', false);
// 阿里云 且是 验证码类型
if($defaultEngine != 'TENCENT' && in_array($params['scene_id'], NoticeEnum::SMS_SCENE)) {
return ['code' => $params['params']['code']];
}
if($defaultEngine != 'TENCENT') {
return $params['params'];
}
//腾讯云特殊处理
$arr = [];
$content = $noticeSetting['sms_notice']['content'];
foreach ($params['params'] as $item => $val) {
$search = '${' . $item . '}';
if(strpos($content, $search) !== false && !in_array($item, $arr)) {
//arr => 获的数组[nickname, order_sn] //顺序可能是乱的
$arr[] = $item;
}
}
//arr2 => 获得数组[nickname, order_sn] //调整好顺序的变量名数组
$arr2 = [];
if (!empty($arr)) {
foreach ($arr as $v) {
$key = strpos($content, $v);
$arr2[$key] = $v;
}
}
//格式化 arr2 => 以小到大的排序的数组
ksort($arr2);
$arr3 = array_values($arr2);
//arr4 => 获取到变量数组的对应的值 [mofung, 123456789]
$arr4 = [];
foreach ($arr3 as $v2) {
if(isset($params['params'][$v2])) {
$arr4[] = $params['params'][$v2] . "";
}
}
return $arr4;
}
/**
* @notes 更新短信记录
* @param $id
* @param $status
* @param $result
* @author 段誉
* @date 2022/9/15 16:25
*/
public function updateSmsLog($id, $status, $result)
{
SmsLog::update([
'id' => $id,
'send_status' => $status,
'results' => json_encode($result, JSON_UNESCAPED_UNICODE)
]);
}
}
@@ -0,0 +1,135 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\sms\engine;
use AlibabaCloud\Client\AlibabaCloud;
/**
* 阿里云短信
* Class AliSms
* @package app\common\service\sms\engine
*/
class AliSms
{
protected $error = null;
protected $config;
protected $mobile;
protected $templateId;
protected $templateParams;
public function __construct($config)
{
if(empty($config)) {
$this->error = '请联系管理员配置参数';
return false;
}
$this->config = $config;
}
/**
* @notes 设置手机号
* @param $mobile
* @return $this
* @author 段誉
* @date 2022/9/15 16:28
*/
public function setMobile($mobile)
{
$this->mobile = $mobile;
return $this;
}
/**
* @notes 设置模板id
* @param $templateId
* @return $this
* @author 段誉
* @date 2022/9/15 16:28
*/
public function setTemplateId($templateId)
{
$this->templateId = $templateId;
return $this;
}
/**
* @notes 设置模板参数
* @param $templateParams
* @return $this
* @author 段誉
* @date 2022/9/15 16:28
*/
public function setTemplateParams($templateParams)
{
$this->templateParams = json_encode($templateParams, JSON_UNESCAPED_UNICODE);
return $this;
}
/**
* @notes 错误信息
* @return string|null
* @author 段誉
* @date 2022/9/15 16:27
*/
public function getError()
{
return $this->error;
}
/**
* @notes 发送短信
* @return array|false
* @author 段誉
* @date 2022/9/15 16:27
*/
public function send()
{
try {
AlibabaCloud::accessKeyClient($this->config['app_key'], $this->config['secret_key'])
->regionId('cn-hangzhou')
->asDefaultClient();
$result = AlibabaCloud::rpcRequest()
->product('Dysmsapi')
->host('dysmsapi.aliyuncs.com')
->version('2017-05-25')
->action('SendSms')
->method('POST')
->options([
'query' => [
'PhoneNumbers' => $this->mobile, //发送手机号
'SignName' => $this->config['sign'], //短信签名
'TemplateCode' => $this->templateId, //短信模板CODE
'TemplateParam' => $this->templateParams, //自定义随机数
],
])
->request();
$res = $result->toArray();
if (isset($res['Code']) && $res['Code'] == 'OK') {
return $res;
}
$message = $res['Message'] ?? $res;
throw new \Exception('阿里云短信错误:' . $message);
} catch(\Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
}
@@ -0,0 +1,138 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\sms\engine;
use TencentCloud\Sms\V20190711\SmsClient;
use TencentCloud\Sms\V20190711\Models\SendSmsRequest;
use TencentCloud\Common\Exception\TencentCloudSDKException;
use TencentCloud\Common\Credential;
use TencentCloud\Common\Profile\ClientProfile;
use TencentCloud\Common\Profile\HttpProfile;
/**
* 腾讯云短信
* Class TencentSms
* @package app\common\service\sms\engine
*/
class TencentSms
{
protected $error = null;
protected $config;
protected $mobile;
protected $templateId;
protected $templateParams;
public function __construct($config)
{
if(empty($config)) {
$this->error = '请联系管理员配置参数';
return false;
}
$this->config = $config;
}
/**
* @notes 设置手机号
* @param $mobile
* @return $this
* @author 段誉
* @date 2022/9/15 16:26
*/
public function setMobile($mobile)
{
$this->mobile = $mobile;
return $this;
}
/**
* @notes 设置模板id
* @param $templateId
* @return $this
* @author 段誉
* @date 2022/9/15 16:26
*/
public function setTemplateId($templateId)
{
$this->templateId = $templateId;
return $this;
}
/**
* @notes 设置模板参数
* @param $templateParams
* @return $this
* @author 段誉
* @date 2022/9/15 16:27
*/
public function setTemplateParams($templateParams)
{
$this->templateParams = $templateParams;
return $this;
}
/**
* @notes 获取错误信息
* @return string|null
* @author 段誉
* @date 2022/9/15 16:27
*/
public function getError()
{
return $this->error;
}
/**
* @notes 发送短信
* @return false|mixed
* @author 段誉
* @date 2022/9/15 16:27
*/
public function send()
{
try {
$cred = new Credential($this->config['secret_id'], $this->config['secret_key']);
$httpProfile = new HttpProfile();
$httpProfile->setEndpoint("sms.tencentcloudapi.com");
$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
$client = new SmsClient($cred, 'ap-guangzhou', $clientProfile);
$params = [
'PhoneNumberSet' => ['+86' . $this->mobile],
'TemplateID' => $this->templateId,
'Sign' => $this->config['sign'],
'TemplateParamSet' => $this->templateParams,
'SmsSdkAppid' => $this->config['app_id'],
];
$req = new SendSmsRequest();
$req->fromJsonString(json_encode($params));
$resp = json_decode($client->SendSms($req)->toJsonString(), true);
if (isset($resp['SendStatusSet']) && $resp['SendStatusSet'][0]['Code'] == 'Ok') {
return $resp;
} else {
$message = $res['SendStatusSet'][0]['Message'] ?? json_encode($resp);
throw new \Exception('腾讯云短信错误:' . $message);
}
} catch(\Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
}
@@ -0,0 +1,128 @@
<?php
namespace app\common\service\storage;
use think\Exception;
/**
* 存储模块驱动
* Class driver
* @package app\common\library\storage
*/
class Driver
{
private $config; // upload 配置
private $engine; // 当前存储引擎类
/**
* 构造方法
* Driver constructor.
* @param $config
* @param null|string $storage 指定存储方式,如不指定则为系统默认
* @throws Exception
*/
public function __construct($config, $storage = null)
{
$this->config = $config;
$this->engine = $this->getEngineClass($storage);
}
/**
* 设置上传的文件信息
* @param string $name
* @return mixed
*/
public function setUploadFile($name = 'iFile')
{
return $this->engine->setUploadFile($name);
}
/**
* 设置上传的文件信息
* @param string $filePath
* @return mixed
*/
public function setUploadFileByReal($filePath)
{
return $this->engine->setUploadFileByReal($filePath);
}
/**
* 执行文件上传
* @param $save_dir (保存路径)
* @return mixed
*/
public function upload($save_dir)
{
return $this->engine->upload($save_dir);
}
/**
* Notes: 抓取网络资源
* @param $url
* @param $key
* @author 张无忌(2021/3/2 14:16)
* @return mixed
*/
public function fetch($url, $key) {
return $this->engine->fetch($url, $key);
}
/**
* 执行文件删除
* @param $fileName
* @return mixed
*/
public function delete($fileName)
{
return $this->engine->delete($fileName);
}
/**
* 获取错误信息
* @return mixed
*/
public function getError()
{
return $this->engine->getError();
}
/**
* 获取文件路径
* @return mixed
*/
public function getFileName()
{
return $this->engine->getFileName();
}
/**
* 返回文件信息
* @return mixed
*/
public function getFileInfo()
{
return $this->engine->getFileInfo();
}
/**
* 获取当前的存储引擎
* @param null|string $storage 指定存储方式,如不指定则为系统默认
* @return mixed
* @throws Exception
*/
private function getEngineClass($storage = null)
{
$engineName = is_null($storage) ? $this->config['default'] : $storage;
$classSpace = __NAMESPACE__ . '\\engine\\' . ucfirst($engineName);
if (!class_exists($classSpace)) {
throw new Exception('未找到存储引擎类: ' . $engineName);
}
if($engineName == 'local') {
return new $classSpace();
}
return new $classSpace($this->config['engine'][$engineName]);
}
}
@@ -0,0 +1,115 @@
<?php
namespace app\common\service\storage\engine;
use OSS\OssClient;
use OSS\Core\OssException;
/**
* 阿里云存储引擎 (OSS)
* Class Qiniu
* @package app\common\library\storage\engine
*/
class Aliyun extends Server
{
private $config;
/**
* 构造方法
* Aliyun constructor.
* @param $config
*/
public function __construct($config)
{
parent::__construct();
$this->config = $config;
}
/**
* 执行上传
* @param $save_dir (保存路径)
* @return bool|mixed
*/
public function upload($save_dir)
{
try {
$ossClient = new OssClient(
$this->config['access_key'],
$this->config['secret_key'],
$this->config['domain'],
true
);
$ossClient->uploadFile(
$this->config['bucket'],
$save_dir . '/' . $this->fileName,
$this->getRealPath()
);
} catch (OssException $e) {
$this->error = $e->getMessage();
return false;
}
return true;
}
/**
* Notes: 抓取远程资源
* @param $url
* @param null $key
* @return mixed|void
* @author 张无忌(2021/3/2 14:36)
*/
public function fetch($url, $key = null)
{
try {
$ossClient = new OssClient(
$this->config['access_key'],
$this->config['secret_key'],
$this->config['domain'],
true
);
$content = file_get_contents($url);
$ossClient->putObject(
$this->config['bucket'],
$key,
$content
);
} catch (OssException $e) {
$this->error = $e->getMessage();
return false;
}
return true;
}
/**
* 删除文件
* @param $fileName
* @return bool|mixed
*/
public function delete($fileName)
{
try {
$ossClient = new OssClient(
$this->config['access_key'],
$this->config['secret_key'],
$this->config['domain'],
true
);
$ossClient->deleteObject($this->config['bucket'], $fileName);
} catch (OssException $e) {
$this->error = $e->getMessage();
return false;
}
return true;
}
/**
* 返回文件路径
* @return mixed
*/
public function getFileName()
{
return $this->fileName;
}
}
@@ -0,0 +1,60 @@
<?php
namespace app\common\service\storage\engine;
/**
* 本地文件驱动
* Class Local
* @package app\common\library\storage\drivers
*/
class Local extends Server
{
public function __construct()
{
parent::__construct();
}
/**
* 上传
* @param $save_dir (保存路径)
* @return bool
*/
public function upload($save_dir)
{
// 验证文件并上传
$info = $this->file->move($save_dir, $this->fileName);
if (empty($info)) {
$this->error = $this->file->getError();
return false;
}
return true;
}
public function fetch($url, $key=null) {}
/**
* 删除文件
* @param $fileName
* @return bool|mixed
*/
public function delete($fileName)
{
$check = strpos($fileName, '/');
if ($check !== false && $check == 0) {
// 文件所在目录
$fileName = substr_replace($fileName,"",0,1);
}
$filePath = public_path() . "{$fileName}";
return !file_exists($filePath) ?: unlink($filePath);
}
/**
* 返回文件路径
* @return mixed
*/
public function getFileName()
{
return $this->fileName;
}
}
@@ -0,0 +1,116 @@
<?php
namespace app\common\service\storage\engine;
use Exception;
use Qcloud\Cos\Client;
/**
* 腾讯云存储引擎 (COS)
* Class Qiniu
* @package app\common\library\storage\engine
*/
class Qcloud extends Server
{
private $config;
private $cosClient;
/**
* 构造方法
* Qcloud constructor.
* @param $config
*/
public function __construct($config)
{
parent::__construct();
$this->config = $config;
// 创建COS控制类
$this->createCosClient();
}
/**
* 创建COS控制类
*/
private function createCosClient()
{
$this->cosClient = new Client([
'region' => $this->config['region'],
'credentials' => [
'secretId' => $this->config['access_key'],
'secretKey' => $this->config['secret_key'],
],
]);
}
/**
* 执行上传
* @param $save_dir (保存路径)
* @return bool|mixed
*/
public function upload($save_dir)
{
// 上传文件
// putObject(上传接口,最大支持上传5G文件)
try {
$result = $this->cosClient->putObject([
'Bucket' => $this->config['bucket'],
'Key' => $save_dir . '/' . $this->fileName,
'Body' => fopen($this->getRealPath(), 'rb')
]);
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* notes: 抓取远程资源(最大支持上传5G文件)
* @param $url
* @param null $key
* @author 张无忌(2021/3/2 14:36)
* @return mixed|void
*/
public function fetch($url, $key=null) {
try {
$this->cosClient->putObject([
'Bucket' => $this->config['bucket'],
'Key' => $key,
'Body' => fopen($url, 'rb')
]);
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* 删除文件
* @param $fileName
* @return bool|mixed
*/
public function delete($fileName)
{
try {
$this->cosClient->deleteObject(array(
'Bucket' => $this->config['bucket'],
'Key' => $fileName
));
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* 返回文件路径
* @return mixed
*/
public function getFileName()
{
return $this->fileName;
}
}
@@ -0,0 +1,136 @@
<?php
namespace app\common\service\storage\engine;
use Exception;
use Qiniu\Auth;
use Qiniu\Storage\UploadManager;
use Qiniu\Storage\BucketManager;
/**
* 七牛云存储引擎
* Class Qiniu
* @package app\common\library\storage\engine
*/
class Qiniu extends Server
{
private $config;
/**
* 构造方法
* Qiniu constructor.
* @param $config
*/
public function __construct($config)
{
parent::__construct();
$this->config = $config;
}
/**
* @notes 执行上传
* @param $save_dir
* @return bool|mixed
* @author 张无忌
* @date 2021/7/27 16:02
*/
public function upload($save_dir)
{
// 要上传图片的本地路径
$realPath = $this->getRealPath();
// 构建鉴权对象
$auth = new Auth($this->config['access_key'], $this->config['secret_key']);
// 要上传的空间
$token = $auth->uploadToken($this->config['bucket']);
// 初始化 UploadManager 对象并进行文件的上传
$uploadMgr = new UploadManager();
try {
// 调用 UploadManager 的 putFile 方法进行文件的上传
$key = $save_dir . '/' . $this->fileName;
list(, $error) = $uploadMgr->putFile($token, $key, $realPath);
if ($error !== null) {
$this->error = $error->message();
return false;
}
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* @notes 抓取远程资源
* @param $url
* @param null $key
* @return bool|mixed
* @author 张无忌
* @date 2021/7/27 16:02
*/
public function fetch($url, $key=null)
{
try {
if (substr($url, 0, 1) !== '/' || strstr($url, 'http://') || strstr($url, 'https://')) {
$auth = new Auth($this->config['access_key'], $this->config['secret_key']);
$bucketManager = new BucketManager($auth);
list(, $err) = $bucketManager->fetch($url, $this->config['bucket'], $key);
} else {
$auth = new Auth($this->config['access_key'], $this->config['secret_key']);
$token = $auth->uploadToken($this->config['bucket']);
$uploadMgr = new UploadManager();
list(, $err) = $uploadMgr->putFile($token, $key, $url);
}
if ($err !== null) {
$this->error = $err->message();
return false;
}
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* @notes 删除文件
* @param $fileName
* @return bool|mixed
* @author 张无忌
* @date 2021/7/27 16:02
*/
public function delete($fileName)
{
// 构建鉴权对象
$auth = new Auth($this->config['access_key'], $this->config['secret_key']);
// 初始化 UploadManager 对象并进行文件的上传
$bucketMgr = new BucketManager($auth);
try {
list($res, $error) = $bucketMgr->delete($this->config['bucket'], $fileName);
if ($error !== null) {
$this->error = $error->message();
return false;
}
return true;
} catch (Exception $e) {
$this->error = $e->getMessage();
return false;
}
}
/**
* 返回文件路径
* @return mixed
*/
public function getFileName()
{
return $this->fileName;
}
}
@@ -0,0 +1,147 @@
<?php
namespace app\common\service\storage\engine;
use think\Request;
use think\Exception;
/**
* 存储引擎抽象类
* Class server
* @package app\common\library\storage\drivers
*/
abstract class Server
{
protected $file;
protected $error;
protected $fileName;
protected $fileInfo;
// 是否为内部上传
protected $isInternal = false;
/**
* 构造函数
* Server constructor.
*/
protected function __construct()
{
}
/**
* 设置上传的文件信息
* @param string $name
* @throws Exception
*/
public function setUploadFile($name)
{
// 接收上传的文件
$this->file = request()->file($name);
if (empty($this->file)) {
throw new Exception('未找到上传文件的信息');
}
// 校验上传文件后缀
$limit = array_merge(config('project.file_image'), config('project.file_video'), config('project.file_file'));
if (!in_array(strtolower($this->file->extension()), $limit)) {
throw new Exception('不允许上传' . $this->file->extension() . '后缀文件');
}
// 文件信息
$this->fileInfo = [
'ext' => $this->file->extension(),
'size' => $this->file->getSize(),
'mime' => $this->file->getMime(),
'name' => $this->file->getOriginalName(),
'realPath' => $this->file->getRealPath(),
];
// 生成保存文件名
$this->fileName = $this->buildSaveName();
}
/**
* 设置上传的文件信息
* @param string $filePath
*/
public function setUploadFileByReal($filePath)
{
// 设置为系统内部上传
$this->isInternal = true;
// 文件信息
$this->fileInfo = [
'name' => basename($filePath),
'size' => filesize($filePath),
'tmp_name' => $filePath,
'error' => 0,
];
// 生成保存文件名
$this->fileName = $this->buildSaveName();
}
/**
* Notes: 抓取网络资源
* @param $url
* @param $key
* @author 张无忌(2021/3/2 14:15)
* @return mixed
*/
abstract protected function fetch($url, $key);
/**
* 文件上传
* @param $save_dir (保存路径)
* @return mixed
*/
abstract protected function upload($save_dir);
/**
* 文件删除
* @param $fileName
* @return mixed
*/
abstract protected function delete($fileName);
/**
* 返回上传后文件路径
* @return mixed
*/
abstract public function getFileName();
/**
* 返回文件信息
* @return mixed
*/
public function getFileInfo()
{
return $this->fileInfo;
}
protected function getRealPath()
{
return $this->fileInfo['realPath'];
}
/**
* 返回错误信息
* @return mixed
*/
public function getError()
{
return $this->error;
}
/**
* 生成保存文件名
*/
private function buildSaveName()
{
// 要上传图片的本地路径
$realPath = $this->getRealPath();
// 扩展名
$ext = pathinfo($this->getFileInfo()['name'], PATHINFO_EXTENSION);
// 自动生成文件名
return date('YmdHis') . substr(md5($realPath), 0, 5)
. str_pad(rand(0, 9999), 4, '0', STR_PAD_LEFT) . ".{$ext}";
}
}
@@ -0,0 +1,165 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\wechat;
use app\common\enum\PayEnum;
use app\common\enum\user\UserTerminalEnum;
use app\common\model\pay\PayConfig;
use app\common\service\ConfigService;
/**
* 微信配置类
* Class WeChatConfigService
* @package app\common\service
*/
class WeChatConfigService
{
/**
* @notes 获取小程序配置
* @return array
* @author 段誉
* @date 2022/9/6 19:49
*/
public static function getMnpConfig()
{
return [
'app_id' => ConfigService::get('mnp_setting', 'app_id'),
'secret' => ConfigService::get('mnp_setting', 'app_secret'),
'response_type' => 'array',
'log' => [
'level' => 'debug',
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
],
];
}
/**
* @notes 获取微信公众号配置
* @return array
* @author 段誉
* @date 2022/9/6 19:49
*/
public static function getOaConfig()
{
return [
'app_id' => ConfigService::get('oa_setting', 'app_id'),
'secret' => ConfigService::get('oa_setting', 'app_secret'),
'token' => ConfigService::get('oa_setting', 'token'),
'response_type' => 'array',
'log' => [
'level' => 'debug',
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
],
];
}
/**
* @notes 获取微信开放平台配置
* @return array
* @author 段誉
* @date 2022/10/20 15:51
*/
public static function getOpConfig()
{
return [
'app_id' => ConfigService::get('open_platform', 'app_id'),
'secret' => ConfigService::get('open_platform', 'app_secret'),
'response_type' => 'array',
'log' => [
'level' => 'debug',
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
],
];
}
/**
* @notes 根据终端获取支付配置
* @param $terminal
* @return array
* @author 段誉
* @date 2023/2/27 15:45
*/
public static function getPayConfigByTerminal($terminal)
{
switch ($terminal) {
case UserTerminalEnum::WECHAT_MMP:
$notifyUrl = (string)url('pay/notifyMnp', [], false, true);
break;
case UserTerminalEnum::WECHAT_OA:
case UserTerminalEnum::PC:
case UserTerminalEnum::H5:
$notifyUrl = (string)url('pay/notifyOa', [], false, true);
break;
case UserTerminalEnum::ANDROID:
case UserTerminalEnum::IOS:
$notifyUrl = (string)url('pay/notifyApp', [], false, true);
break;
}
$pay = PayConfig::where(['pay_way' => PayEnum::WECHAT_PAY])->findOrEmpty()->toArray();
//判断是否已经存在证书文件夹,不存在则新建
if (!file_exists(app()->getRootPath() . 'runtime/cert')) {
mkdir(app()->getRootPath() . 'runtime/cert', 0775, true);
}
//写入文件
$apiclientCert = $pay['config']['apiclient_cert'] ?? '';
$apiclientKey = $pay['config']['apiclient_key'] ?? '';
$certPath = app()->getRootPath() . 'runtime/cert/' . md5($apiclientCert) . '.pem';
$keyPath = app()->getRootPath() . 'runtime/cert/' . md5($apiclientKey) . '.pem';
if (!empty($apiclientCert) && !file_exists($certPath)) {
static::setCert($certPath, trim($apiclientCert));
}
if (!empty($apiclientKey) && !file_exists($keyPath)) {
static::setCert($keyPath, trim($apiclientKey));
}
return [
// 商户号
'mch_id' => $pay['config']['mch_id'] ?? '',
// 商户证书
'private_key' => $keyPath,
'certificate' => $certPath,
// v3 API 秘钥
'secret_key' => $pay['config']['pay_sign_key'] ?? '',
'notify_url' => $notifyUrl,
'http' => [
'throw' => true, // 状态码非 200、300 时是否抛出异常,默认为开启
'timeout' => 5.0,
]
];
}
/**
* @notes 临时写入证书
* @param $path
* @param $cert
* @author 段誉
* @date 2023/2/27 15:48
*/
public static function setCert($path, $cert)
{
$fopenPath = fopen($path, 'w');
fwrite($fopenPath, $cert);
fclose($fopenPath);
}
}
@@ -0,0 +1,101 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\wechat;
use EasyWeChat\Kernel\Exceptions\Exception;
use EasyWeChat\MiniApp\Application;
/**
* 微信功能类
* Class WeChatMnpService
* @package app\common\service
*/
class WeChatMnpService
{
protected $app;
protected $config;
public function __construct()
{
$this->config = $this->getConfig();
$this->app = new Application($this->config);
}
/**
* @notes 配置
* @return array
* @throws \Exception
* @author 段誉
* @date 2023/2/27 12:03
*/
protected function getConfig()
{
$config = WeChatConfigService::getMnpConfig();
if (empty($config['app_id']) || empty($config['secret'])) {
throw new \Exception('请先设置小程序配置');
}
return $config;
}
/**
* @notes 小程序-根据code获取微信信息
* @param string $code
* @return array
* @throws Exception
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @author 段誉
* @date 2023/2/27 11:03
*/
public function getMnpResByCode(string $code)
{
$utils = $this->app->getUtils();
$response = $utils->codeToSession($code);
if (!isset($response['openid']) || empty($response['openid'])) {
throw new Exception('获取openID失败');
}
return $response;
}
/**
* @notes 获取手机号
* @param string $code
* @return \EasyWeChat\Kernel\HttpClient\Response|\Symfony\Contracts\HttpClient\ResponseInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @author 段誉
* @date 2023/2/27 11:46
*/
public function getUserPhoneNumber(string $code)
{
return $this->app->getClient()->postJson('wxa/business/getuserphonenumber', [
'code' => $code,
]);
}
}
@@ -0,0 +1,157 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\wechat;
use EasyWeChat\Kernel\Exceptions\Exception;
use EasyWeChat\OfficialAccount\Application;
/**
* 公众号相关
* Class WeChatOaService
* @package app\common\service\wechat
*/
class WeChatOaService
{
protected $app;
protected $config;
public function __construct()
{
$this->config = $this->getConfig();
$this->app = new Application($this->config);
}
/**
* @notes easywechat服务端
* @return \EasyWeChat\Kernel\Contracts\Server|\EasyWeChat\OfficialAccount\Server
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \ReflectionException
* @throws \Throwable
* @author 段誉
* @date 2023/2/27 14:22
*/
public function getServer()
{
return $this->app->getServer();
}
/**
* @notes 配置
* @return array
* @throws Exception
* @author 段誉
* @date 2023/2/27 12:03
*/
protected function getConfig()
{
$config = WeChatConfigService::getOaConfig();
if (empty($config['app_id']) || empty($config['secret'])) {
throw new Exception('请先设置公众号配置');
}
return $config;
}
/**
* @notes 公众号-根据code获取微信信息
* @param string $code
* @return mixed
* @throws Exception
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @author 段誉
* @date 2023/2/27 11:04
*/
public function getOaResByCode(string $code)
{
$response = $this->app->getOAuth()
->scopes(['snsapi_userinfo'])
->userFromCode($code)
->getRaw();
if (!isset($response['openid']) || empty($response['openid'])) {
throw new Exception('获取openID失败');
}
return $response;
}
/**
* @notes 公众号跳转url
* @param string $url
* @return mixed
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @author 段誉
* @date 2023/2/27 10:35
*/
public function getCodeUrl(string $url)
{
return $this->app->getOAuth()
->scopes(['snsapi_userinfo'])
->redirect($url);
}
/**
* @notes 创建公众号菜单
* @param array $buttons
* @param array $matchRule
* @return \EasyWeChat\Kernel\HttpClient\Response|\Symfony\Contracts\HttpClient\ResponseInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @author 段誉
* @date 2023/2/27 12:07
*/
public function createMenu(array $buttons, array $matchRule = [])
{
if (!empty($matchRule)) {
return $this->app->getClient()->postJson('cgi-bin/menu/addconditional', [
'button' => $buttons,
'matchrule' => $matchRule,
]);
}
return $this->app->getClient()->postJson('cgi-bin/menu/create', ['button' => $buttons]);
}
/**
* @notes 获取jssdkConfig
* @param $url
* @param $jsApiList
* @param array $openTagList
* @param false $debug
* @return mixed[]
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
* @throws \Psr\SimpleCache\InvalidArgumentException
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @author 段誉
* @date 2023/3/1 11:46
*/
public function getJsConfig($url, $jsApiList, $openTagList = [], $debug = false)
{
return $this->app->getUtils()->buildJsSdkConfig($url, $jsApiList, $openTagList, $debug);
}
}
@@ -0,0 +1,82 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\service\wechat;
use app\common\logic\BaseLogic;
use WpOrg\Requests\Requests;
/**
* 自定义微信请求
* Class WeChatRequestService
* @package app\common\service\wechat
*/
class WeChatRequestService extends BaseLogic
{
/**
* @notes 获取网站扫码登录地址
* @param $appId
* @param $redirectUri
* @param $state
* @return string
* @author 段誉
* @date 2022/10/20 18:20
*/
public static function getScanCodeUrl($appId, $redirectUri, $state)
{
$url = 'https://open.weixin.qq.com/connect/qrconnect?';
$url .= 'appid=' . $appId . '&redirect_uri=' . $redirectUri . '&response_type=code&scope=snsapi_login';
$url .= '&state=' . $state . '#wechat_redirect';
return $url;
}
/**
* @notes 通过code获取用户信息(access_token,openid,unionid等)
* @param $code
* @return mixed
* @author 段誉
* @date 2022/10/21 10:16
*/
public static function getUserAuthByCode($code)
{
$config = WeChatConfigService::getOpConfig();
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
$url .= '?appid=' . $config['app_id'] . '&secret=' . $config['secret'] . '&code=' . $code;
$url .= '&grant_type=authorization_code';
$requests = Requests::get($url);
return json_decode($requests->body, true);
}
/**
* @notes 通过授权信息获取用户信息
* @param $accessToken
* @param $openId
* @return mixed
* @author 段誉
* @date 2022/10/21 10:21
*/
public static function getUserInfoByAuth($accessToken, $openId)
{
$url = 'https://api.weixin.qq.com/sns/userinfo';
$url .= '?access_token=' . $accessToken . '&openid=' . $openId;
$response = Requests::get($url);
return json_decode($response->body, true);
}
}