Merge branch 'yongpxu-dev' into yongpxu-dev2
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ Store_vue/unpackage/
|
||||
Store_vue/.vscode/
|
||||
SuperAdmin/.specstory/
|
||||
Cunkebao/dist
|
||||
Touchkebao/.specstory/
|
||||
|
||||
@@ -63,7 +63,7 @@ const Login: React.FC = () => {
|
||||
// 添加typeId参数
|
||||
const loginParams = {
|
||||
...values,
|
||||
typeId: activeTab as number,
|
||||
typeId: 1,
|
||||
};
|
||||
|
||||
const response =
|
||||
|
||||
8
Moncter/.gitignore
vendored
Normal file
8
Moncter/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/runtime
|
||||
/.idea
|
||||
/.vscode
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
/tests/tmp
|
||||
/tests/.phpunit.result.cache
|
||||
21
Moncter/LICENSE
Normal file
21
Moncter/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
70
Moncter/README.md
Normal file
70
Moncter/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
|
||||
<h1>webman</h1>
|
||||
|
||||
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
|
||||
|
||||
|
||||
<h1>学习</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style="float:left;padding-bottom:30px;">
|
||||
|
||||
<h1>赞助商</h1>
|
||||
|
||||
<h4>特别赞助</h4>
|
||||
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
|
||||
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
|
||||
</a>
|
||||
|
||||
<h4>铂金赞助</h4>
|
||||
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
|
||||
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="float:left;padding-bottom:30px;clear:both">
|
||||
|
||||
<h1>请作者喝咖啡</h1>
|
||||
|
||||
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
|
||||
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
|
||||
<br>
|
||||
<b>如果您觉得webman对您有所帮助,欢迎捐赠。</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="clear: both">
|
||||
<h1>LICENSE</h1>
|
||||
The webman is open-sourced software licensed under the MIT.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
24
Moncter/app/controller/IndexController.php
Normal file
24
Moncter/app/controller/IndexController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return "我是数据中心,有何贵干?";
|
||||
}
|
||||
|
||||
public function view(Request $request)
|
||||
{
|
||||
return view('index/view', ['name' => 'webman']);
|
||||
}
|
||||
|
||||
public function json(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
}
|
||||
16
Moncter/app/controller/UserController.php
Normal file
16
Moncter/app/controller/UserController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function hello(Request $request)
|
||||
{
|
||||
$default_name = 'webman';
|
||||
// 从get请求里获得name参数,如果没有传递name参数则返回$default_name
|
||||
$name = $request->get('name', $default_name);
|
||||
// 向浏览器返回字符串
|
||||
return response('hello ' . $name);
|
||||
}
|
||||
}
|
||||
4
Moncter/app/functions.php
Normal file
4
Moncter/app/functions.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
42
Moncter/app/middleware/StaticFile.php
Normal file
42
Moncter/app/middleware/StaticFile.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* Class StaticFile
|
||||
* @package app\middleware
|
||||
*/
|
||||
class StaticFile implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
// Access to files beginning with. Is prohibited
|
||||
if (strpos($request->path(), '/.') !== false) {
|
||||
return response('<h1>403 forbidden</h1>', 403);
|
||||
}
|
||||
/** @var Response $response */
|
||||
$response = $handler($request);
|
||||
// Add cross domain HTTP header
|
||||
/*$response->withHeaders([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);*/
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
29
Moncter/app/model/Test.php
Normal file
29
Moncter/app/model/Test.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class Test extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'test';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
}
|
||||
37
Moncter/app/model/User.php
Normal file
37
Moncter/app/model/User.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// app/model/User.php
|
||||
namespace app\model;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use MongoDB\Laravel\Relations\HasMany; // 若需关联查询(可选)
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
// 对应 MongoDB 集合名(默认复数,可自定义)
|
||||
protected $collection = 'users';
|
||||
|
||||
// 主键(MongoDB 默认 _id,无需修改,自动转为字符串)
|
||||
protected $primaryKey = '_id';
|
||||
|
||||
// 主键类型(官方推荐显式声明)
|
||||
protected $keyType = 'string';
|
||||
|
||||
// 允许批量赋值的字段(白名单)
|
||||
protected $fillable = ['name', 'age', 'email', 'avatar'];
|
||||
|
||||
// 自动转换字段类型(ObjectId 转字符串、日期转 Carbon)
|
||||
protected $casts = [
|
||||
'_id' => 'string',
|
||||
'age' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'tags' => 'array', // 支持数组类型(MongoDB 原生支持数组)
|
||||
];
|
||||
|
||||
// 自动维护时间戳(created_at/updated_at,默认启用)
|
||||
// 若不需要可关闭:public $timestamps = false;
|
||||
|
||||
// 自定义时间戳字段名(可选)
|
||||
// const CREATED_AT = 'create_time';
|
||||
// const UPDATED_AT = 'update_time';
|
||||
}
|
||||
10
Moncter/app/process/Http.php
Normal file
10
Moncter/app/process/Http.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use Webman\App;
|
||||
|
||||
class Http extends App
|
||||
{
|
||||
|
||||
}
|
||||
305
Moncter/app/process/Monitor.php
Normal file
305
Moncter/app/process/Monitor.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Class FileMonitor
|
||||
* @package process
|
||||
*/
|
||||
class Monitor
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $paths = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $extensions = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $loadedFiles = [];
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected int $ppid = 0;
|
||||
|
||||
/**
|
||||
* Pause monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function pause(): void
|
||||
{
|
||||
file_put_contents(static::lockFile(), time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function resume(): void
|
||||
{
|
||||
clearstatcache();
|
||||
if (is_file(static::lockFile())) {
|
||||
unlink(static::lockFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether monitor is paused
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPaused(): bool
|
||||
{
|
||||
clearstatcache();
|
||||
return file_exists(static::lockFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock file
|
||||
* @return string
|
||||
*/
|
||||
protected static function lockFile(): string
|
||||
{
|
||||
return runtime_path('monitor.lock');
|
||||
}
|
||||
|
||||
/**
|
||||
* FileMonitor constructor.
|
||||
* @param $monitorDir
|
||||
* @param $monitorExtensions
|
||||
* @param array $options
|
||||
*/
|
||||
public function __construct($monitorDir, $monitorExtensions, array $options = [])
|
||||
{
|
||||
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
|
||||
static::resume();
|
||||
$this->paths = (array)$monitorDir;
|
||||
$this->extensions = $monitorExtensions;
|
||||
foreach (get_included_files() as $index => $file) {
|
||||
$this->loadedFiles[$file] = $index;
|
||||
if (strpos($file, 'webman-framework/src/support/App.php')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
$disableFunctions = explode(',', ini_get('disable_functions'));
|
||||
if (in_array('exec', $disableFunctions, true)) {
|
||||
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
|
||||
} else {
|
||||
if ($options['enable_file_monitor'] ?? true) {
|
||||
Timer::add(1, function () {
|
||||
$this->checkAllFilesChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
|
||||
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
|
||||
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $monitorDir
|
||||
* @return bool
|
||||
*/
|
||||
public function checkFilesChange($monitorDir): bool
|
||||
{
|
||||
static $lastMtime, $tooManyFilesCheck;
|
||||
if (!$lastMtime) {
|
||||
$lastMtime = time();
|
||||
}
|
||||
clearstatcache();
|
||||
if (!is_dir($monitorDir)) {
|
||||
if (!is_file($monitorDir)) {
|
||||
return false;
|
||||
}
|
||||
$iterator = [new SplFileInfo($monitorDir)];
|
||||
} else {
|
||||
// recursive traversal directory
|
||||
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
|
||||
$iterator = new RecursiveIteratorIterator($dirIterator);
|
||||
}
|
||||
$count = 0;
|
||||
foreach ($iterator as $file) {
|
||||
$count ++;
|
||||
/** var SplFileInfo $file */
|
||||
if (is_dir($file->getRealPath())) {
|
||||
continue;
|
||||
}
|
||||
// check mtime
|
||||
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
|
||||
$lastMtime = $file->getMTime();
|
||||
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
|
||||
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
|
||||
continue;
|
||||
}
|
||||
$var = 0;
|
||||
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
|
||||
if ($var) {
|
||||
continue;
|
||||
}
|
||||
// send SIGUSR1 signal to master process for reload
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
if ($masterPid = $this->getMasterPid()) {
|
||||
echo $file . " updated and reload\n";
|
||||
posix_kill($masterPid, SIGUSR1);
|
||||
} else {
|
||||
echo "Master process has gone away and can not reload\n";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
echo $file . " updated and reload\n";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!$tooManyFilesCheck && $count > 1000) {
|
||||
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
|
||||
$tooManyFilesCheck = 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getMasterPid(): int
|
||||
{
|
||||
if ($this->ppid === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
|
||||
echo "Master process has gone away\n";
|
||||
return $this->ppid = 0;
|
||||
}
|
||||
if (PHP_OS_FAMILY !== 'Linux') {
|
||||
return $this->ppid;
|
||||
}
|
||||
$cmdline = "/proc/$this->ppid/cmdline";
|
||||
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
|
||||
// Process not exist
|
||||
$this->ppid = 0;
|
||||
}
|
||||
return $this->ppid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function checkAllFilesChange(): bool
|
||||
{
|
||||
if (static::isPaused()) {
|
||||
return false;
|
||||
}
|
||||
foreach ($this->paths as $path) {
|
||||
if ($this->checkFilesChange($path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $memoryLimit
|
||||
* @return void
|
||||
*/
|
||||
public function checkMemory($memoryLimit): void
|
||||
{
|
||||
if (static::isPaused() || $memoryLimit <= 0) {
|
||||
return;
|
||||
}
|
||||
$masterPid = $this->getMasterPid();
|
||||
if ($masterPid <= 0) {
|
||||
echo "Master process has gone away\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
|
||||
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
|
||||
return;
|
||||
}
|
||||
foreach (explode(' ', $children) as $pid) {
|
||||
$pid = (int)$pid;
|
||||
$statusFile = "/proc/$pid/status";
|
||||
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
|
||||
continue;
|
||||
}
|
||||
$mem = 0;
|
||||
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
|
||||
$mem = $match[1];
|
||||
}
|
||||
$mem = (int)($mem / 1024);
|
||||
if ($mem >= $memoryLimit) {
|
||||
posix_kill($pid, SIGINT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory limit
|
||||
* @param $memoryLimit
|
||||
* @return int
|
||||
*/
|
||||
protected function getMemoryLimit($memoryLimit): int
|
||||
{
|
||||
if ($memoryLimit === 0) {
|
||||
return 0;
|
||||
}
|
||||
$usePhpIni = false;
|
||||
if (!$memoryLimit) {
|
||||
$memoryLimit = ini_get('memory_limit');
|
||||
$usePhpIni = true;
|
||||
}
|
||||
|
||||
if ($memoryLimit == -1) {
|
||||
return 0;
|
||||
}
|
||||
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
|
||||
$memoryLimit = (int)$memoryLimit;
|
||||
if ($unit === 'g') {
|
||||
$memoryLimit = 1024 * $memoryLimit;
|
||||
} else if ($unit === 'k') {
|
||||
$memoryLimit = ($memoryLimit / 1024);
|
||||
} else if ($unit === 'm') {
|
||||
$memoryLimit = (int)($memoryLimit);
|
||||
} else if ($unit === 't') {
|
||||
$memoryLimit = (1024 * 1024 * $memoryLimit);
|
||||
} else {
|
||||
$memoryLimit = ($memoryLimit / (1024 * 1024));
|
||||
}
|
||||
if ($memoryLimit < 50) {
|
||||
$memoryLimit = 50;
|
||||
}
|
||||
if ($usePhpIni) {
|
||||
$memoryLimit = (0.8 * $memoryLimit);
|
||||
}
|
||||
return (int)$memoryLimit;
|
||||
}
|
||||
|
||||
}
|
||||
14
Moncter/app/view/index/view.html
Normal file
14
Moncter/app/view/index/view.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="/favicon.ico"/>
|
||||
<title>webman</title>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
hello <?=htmlspecialchars($name)?>
|
||||
</body>
|
||||
</html>
|
||||
56
Moncter/composer.json
Normal file
56
Moncter/composer.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "workerman/webman",
|
||||
"type": "project",
|
||||
"keywords": [
|
||||
"high performance",
|
||||
"http service"
|
||||
],
|
||||
"homepage": "https://www.workerman.net",
|
||||
"license": "MIT",
|
||||
"description": "High performance HTTP Service Framework.",
|
||||
"authors": [
|
||||
{
|
||||
"name": "walkor",
|
||||
"email": "walkor@workerman.net",
|
||||
"homepage": "https://www.workerman.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"email": "walkor@workerman.net",
|
||||
"issues": "https://github.com/walkor/webman/issues",
|
||||
"forum": "https://wenda.workerman.net/",
|
||||
"wiki": "https://workerman.net/doc/webman",
|
||||
"source": "https://github.com/walkor/webman"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"workerman/webman-framework": "^2.1",
|
||||
"monolog/monolog": "^2.0",
|
||||
"mongodb/laravel-mongodb": "^4.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"": "./",
|
||||
"app\\": "./app",
|
||||
"App\\": "./app",
|
||||
"app\\View\\Components\\": "./app/view/components"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-package-install": [
|
||||
"support\\Plugin::install"
|
||||
],
|
||||
"post-package-update": [
|
||||
"support\\Plugin::install"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"support\\Plugin::uninstall"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
2455
Moncter/composer.lock
generated
Normal file
2455
Moncter/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Moncter/config/app.php
Normal file
26
Moncter/config/app.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Request;
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
'error_reporting' => E_ALL,
|
||||
'default_timezone' => 'Asia/Shanghai',
|
||||
'request_class' => Request::class,
|
||||
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
|
||||
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
];
|
||||
21
Moncter/config/autoload.php
Normal file
21
Moncter/config/autoload.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'files' => [
|
||||
base_path() . '/app/functions.php',
|
||||
base_path() . '/support/Request.php',
|
||||
base_path() . '/support/Response.php',
|
||||
]
|
||||
];
|
||||
17
Moncter/config/bootstrap.php
Normal file
17
Moncter/config/bootstrap.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
support\bootstrap\Session::class,
|
||||
];
|
||||
15
Moncter/config/container.php
Normal file
15
Moncter/config/container.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return new Webman\Container;
|
||||
27
Moncter/config/database.php
Normal file
27
Moncter/config/database.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
return [
|
||||
// 默认数据库连接(可选改为 mongodb)
|
||||
'default' => 'mysql', // 若需全局用 MongoDB,改为 'mongodb'
|
||||
|
||||
'connections' => [
|
||||
// ... 其他连接(如 mysql)保持不变
|
||||
|
||||
// MongoDB 官方连接配置
|
||||
'mongodb' => [
|
||||
'driver' => 'mongodb',
|
||||
'dsn' => 'mongodb://127.0.0.1:27017', // 集群可写:mongodb://node1:27017,node2:27017
|
||||
'database' => 'Moncter', // 目标数据库名
|
||||
'username' => 'Moncter', // 无认证则省略
|
||||
'password' => '123456', // 无认证则省略
|
||||
'options' => [
|
||||
'replicaSet' => '', // 副本集名称(无则留空)
|
||||
'ssl' => false, // 是否启用 SSL
|
||||
'connectTimeoutMS' => 3000, // 连接超时
|
||||
'socketTimeoutMS' => 5000, // 读写超时
|
||||
// 认证相关(若 MongoDB 启用认证)
|
||||
'authSource' => 'admin', // 认证数据库(默认 admin)
|
||||
'authMechanism' => 'SCRAM-SHA-256', // 认证机制(默认推荐)
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
15
Moncter/config/dependence.php
Normal file
15
Moncter/config/dependence.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [];
|
||||
17
Moncter/config/exception.php
Normal file
17
Moncter/config/exception.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'' => support\exception\Handler::class,
|
||||
];
|
||||
32
Moncter/config/log.php
Normal file
32
Moncter/config/log.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/webman.log',
|
||||
7, //$maxFiles
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
15
Moncter/config/middleware.php
Normal file
15
Moncter/config/middleware.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [];
|
||||
62
Moncter/config/process.php
Normal file
62
Moncter/config/process.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
use app\process\Http;
|
||||
|
||||
global $argv;
|
||||
|
||||
return [
|
||||
'webman' => [
|
||||
'handler' => Http::class,
|
||||
'listen' => 'http://0.0.0.0:8787',
|
||||
'count' => cpu_count() * 4,
|
||||
'user' => '',
|
||||
'group' => '',
|
||||
'reusePort' => false,
|
||||
'eventLoop' => '',
|
||||
'context' => [],
|
||||
'constructor' => [
|
||||
'requestClass' => Request::class,
|
||||
'logger' => Log::channel('default'),
|
||||
'appPath' => app_path(),
|
||||
'publicPath' => public_path()
|
||||
]
|
||||
],
|
||||
// File update detection and automatic reload
|
||||
'monitor' => [
|
||||
'handler' => app\process\Monitor::class,
|
||||
'reloadable' => false,
|
||||
'constructor' => [
|
||||
// Monitor these directories
|
||||
'monitorDir' => array_merge([
|
||||
app_path(),
|
||||
config_path(),
|
||||
base_path() . '/process',
|
||||
base_path() . '/support',
|
||||
base_path() . '/resource',
|
||||
base_path() . '/.env',
|
||||
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
|
||||
// Files with these suffixes will be monitored
|
||||
'monitorExtensions' => [
|
||||
'php', 'html', 'htm', 'env'
|
||||
],
|
||||
'options' => [
|
||||
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
|
||||
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
21
Moncter/config/route.php
Normal file
21
Moncter/config/route.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
23
Moncter/config/server.php
Normal file
23
Moncter/config/server.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'event_loop' => '',
|
||||
'stop_timeout' => 2,
|
||||
'pid_file' => runtime_path() . '/webman.pid',
|
||||
'status_file' => runtime_path() . '/webman.status',
|
||||
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
||||
'log_file' => runtime_path() . '/logs/workerman.log',
|
||||
'max_package_size' => 10 * 1024 * 1024
|
||||
];
|
||||
65
Moncter/config/session.php
Normal file
65
Moncter/config/session.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Session\FileSessionHandler;
|
||||
use Webman\Session\RedisSessionHandler;
|
||||
use Webman\Session\RedisClusterSessionHandler;
|
||||
|
||||
return [
|
||||
|
||||
'type' => 'file', // or redis or redis_cluster
|
||||
|
||||
'handler' => FileSessionHandler::class,
|
||||
|
||||
'config' => [
|
||||
'file' => [
|
||||
'save_path' => runtime_path() . '/sessions',
|
||||
],
|
||||
'redis' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
'auth' => '',
|
||||
'timeout' => 2,
|
||||
'database' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
],
|
||||
'redis_cluster' => [
|
||||
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
|
||||
'timeout' => 2,
|
||||
'auth' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
]
|
||||
],
|
||||
|
||||
'session_name' => 'PHPSID',
|
||||
|
||||
'auto_update_timestamp' => false,
|
||||
|
||||
'lifetime' => 7*24*60*60,
|
||||
|
||||
'cookie_lifetime' => 365*24*60*60,
|
||||
|
||||
'cookie_path' => '/',
|
||||
|
||||
'domain' => '',
|
||||
|
||||
'http_only' => true,
|
||||
|
||||
'secure' => false,
|
||||
|
||||
'same_site' => '',
|
||||
|
||||
'gc_probability' => [1, 1000],
|
||||
|
||||
];
|
||||
23
Moncter/config/static.php
Normal file
23
Moncter/config/static.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static file settings
|
||||
*/
|
||||
return [
|
||||
'enable' => true,
|
||||
'middleware' => [ // Static file Middleware
|
||||
//app\middleware\StaticFile::class,
|
||||
],
|
||||
];
|
||||
25
Moncter/config/translation.php
Normal file
25
Moncter/config/translation.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Multilingual configuration
|
||||
*/
|
||||
return [
|
||||
// Default language
|
||||
'locale' => 'zh_CN',
|
||||
// Fallback language
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
// Folder where language files are stored
|
||||
'path' => base_path() . '/resource/translations',
|
||||
];
|
||||
22
Moncter/config/view.php
Normal file
22
Moncter/config/view.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\view\Raw;
|
||||
use support\view\Twig;
|
||||
use support\view\Blade;
|
||||
use support\view\ThinkPHP;
|
||||
|
||||
return [
|
||||
'handler' => Raw::class
|
||||
];
|
||||
46
Moncter/go.sh
Executable file
46
Moncter/go.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail # 严格模式:报错立即退出、禁止未定义变量、管道错误触发退出
|
||||
|
||||
# ================= 配置项(可根据实际情况修改)=================
|
||||
# PHP 脚本路径(相对路径/绝对路径均可,推荐绝对路径更稳定)
|
||||
PHP_SCRIPT="start.php"
|
||||
# PHP 解释器路径(默认自动查找,若提示 php 未找到,手动指定如 /usr/bin/php)
|
||||
PHP_BIN=$(which php || echo "/usr/bin/php")
|
||||
# ==============================================================
|
||||
|
||||
# 1. 检查 PHP 解释器是否存在且可执行
|
||||
if [ ! -x "$PHP_BIN" ]; then
|
||||
echo -e "\033[31m错误:未找到可执行的 PHP 解释器!\033[0m"
|
||||
echo " 解决方案:"
|
||||
echo " 1. 安装 PHP:sudo apt install php-cli(Ubuntu/Debian)或 sudo dnf install php-cli(CentOS/RHEL)"
|
||||
echo " 2. 若已安装,手动修改脚本中的 PHP_BIN 为实际路径(通过 which php 查询)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 检查 PHP 脚本是否存在
|
||||
if [ ! -f "$PHP_SCRIPT" ]; then
|
||||
echo -e "\033[31m错误:未找到脚本文件 $PHP_SCRIPT!\033[0m"
|
||||
echo " 请确保脚本与 $PHP_SCRIPT 在同一目录,或修改脚本中的 PHP_SCRIPT 为绝对路径"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 给 PHP 脚本添加执行权限(自动修复权限问题)
|
||||
if [ ! -x "$PHP_SCRIPT" ]; then
|
||||
echo -e "\033[33m警告:$PHP_SCRIPT 缺少执行权限,正在自动添加...\033[0m"
|
||||
chmod u+x "$PHP_SCRIPT" || {
|
||||
echo -e "\033[31m错误:添加执行权限失败,请用 sudo 运行脚本!\033[0m"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# 4. 执行核心命令(带日志输出优化)
|
||||
echo -e "\033[32m=== 开始执行:$PHP_BIN $PHP_SCRIPT start ===\033[0m"
|
||||
$PHP_BIN "$PHP_SCRIPT" start
|
||||
|
||||
# 5. 执行结果判断
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "\033[32m=== 执行成功!===\033[0m"
|
||||
else
|
||||
echo -e "\033[31m=== 执行失败!请查看上方错误信息 ===\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
BIN
Moncter/public/favicon.ico
Normal file
BIN
Moncter/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,147 @@
|
||||
.chatFooter {
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e1e1e1;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inputToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.leftTool {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbarButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: #e6e6e6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #07c160;
|
||||
}
|
||||
}
|
||||
|
||||
.messageInput {
|
||||
width: 100%;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
}
|
||||
|
||||
.sendButtonArea {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
min-width: 60px;
|
||||
font-size: 13px;
|
||||
background: #07c160;
|
||||
border-color: #07c160;
|
||||
|
||||
&:hover {
|
||||
background: #06ad56;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #059748;
|
||||
border-color: #059748;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #b3b3b3;
|
||||
border-color: #b3b3b3;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hintButton {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.inputHint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inputToolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sendButtonArea {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
.stepContent {
|
||||
.stepHeader {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step3Content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
|
||||
.leftColumn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.rightColumn {
|
||||
width: 400px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
border: 2px dashed #52c41a;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #f6ffed;
|
||||
|
||||
.previewTitle {
|
||||
font-size: 14px;
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
min-height: 60px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
.currentEditingLabel {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.savedScriptGroups {
|
||||
.scriptGroupTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scriptGroupItem {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
|
||||
.scriptGroupHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.scriptGroupLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
:global(.ant-radio) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.scriptGroupName {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.messageCount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.scriptGroupActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.actionButton {
|
||||
padding: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scriptGroupContent {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageInputArea {
|
||||
.messageInput {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.attachmentButtons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aiRewriteSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.messageHint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
|
||||
.settingsTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settingItem {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settingLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settingControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagSection {
|
||||
.settingLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pushPreview {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #f0f7ff;
|
||||
|
||||
.previewTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.step3Content {
|
||||
.rightColumn {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.step3Content {
|
||||
flex-direction: column;
|
||||
|
||||
.leftColumn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rightColumn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import ContentSelection from "@/components/ContentSelection";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
import InputMessage from "./InputMessage/InputMessage";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
interface StepSendMessageProps {
|
||||
5
Moncter/start.php
Executable file
5
Moncter/start.php
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
support\App::run();
|
||||
24
Moncter/support/Request.php
Normal file
24
Moncter/support/Request.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace support;
|
||||
|
||||
/**
|
||||
* Class Request
|
||||
* @package support
|
||||
*/
|
||||
class Request extends \Webman\Http\Request
|
||||
{
|
||||
|
||||
}
|
||||
24
Moncter/support/Response.php
Normal file
24
Moncter/support/Response.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace support;
|
||||
|
||||
/**
|
||||
* Class Response
|
||||
* @package support
|
||||
*/
|
||||
class Response extends \Webman\Http\Response
|
||||
{
|
||||
|
||||
}
|
||||
139
Moncter/support/bootstrap.php
Normal file
139
Moncter/support/bootstrap.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use support\Log;
|
||||
use Webman\Bootstrap;
|
||||
use Webman\Config;
|
||||
use Webman\Middleware;
|
||||
use Webman\Route;
|
||||
use Webman\Util;
|
||||
use Workerman\Events\Select;
|
||||
use Workerman\Worker;
|
||||
|
||||
$worker = $worker ?? null;
|
||||
|
||||
if (empty(Worker::$eventLoopClass)) {
|
||||
Worker::$eventLoopClass = Select::class;
|
||||
}
|
||||
|
||||
set_error_handler(function ($level, $message, $file = '', $line = 0) {
|
||||
if (error_reporting() & $level) {
|
||||
throw new ErrorException($message, 0, $level, $file, $line);
|
||||
}
|
||||
});
|
||||
|
||||
if ($worker) {
|
||||
register_shutdown_function(function ($startTime) {
|
||||
if (time() - $startTime <= 0.1) {
|
||||
sleep(1);
|
||||
}
|
||||
}, time());
|
||||
}
|
||||
|
||||
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
|
||||
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
|
||||
Dotenv::createUnsafeMutable(base_path(false))->load();
|
||||
} else {
|
||||
Dotenv::createMutable(base_path(false))->load();
|
||||
}
|
||||
}
|
||||
|
||||
Config::clear();
|
||||
support\App::loadAllConfig(['route']);
|
||||
if ($timezone = config('app.default_timezone')) {
|
||||
date_default_timezone_set($timezone);
|
||||
}
|
||||
|
||||
foreach (config('autoload.files', []) as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['autoload']['files'] ?? [] as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
}
|
||||
foreach ($projects['autoload']['files'] ?? [] as $file) {
|
||||
include_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
Middleware::load(config('middleware', []));
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project) || $name === 'static') {
|
||||
continue;
|
||||
}
|
||||
Middleware::load($project['middleware'] ?? []);
|
||||
}
|
||||
Middleware::load($projects['middleware'] ?? [], $firm);
|
||||
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
|
||||
Middleware::load(['__static__' => $staticMiddlewares], $firm);
|
||||
}
|
||||
}
|
||||
Middleware::load(['__static__' => config('static.middleware', [])]);
|
||||
|
||||
foreach (config('bootstrap', []) as $className) {
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['bootstrap'] ?? [] as $className) {
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
}
|
||||
foreach ($projects['bootstrap'] ?? [] as $className) {
|
||||
/** @var string $className */
|
||||
if (!class_exists($className)) {
|
||||
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
|
||||
echo $log;
|
||||
Log::error($log);
|
||||
continue;
|
||||
}
|
||||
/** @var Bootstrap $className */
|
||||
$className::start($worker);
|
||||
}
|
||||
}
|
||||
|
||||
$directory = base_path() . '/plugin';
|
||||
$paths = [config_path()];
|
||||
foreach (Util::scanDir($directory) as $path) {
|
||||
if (is_dir($path = "$path/config")) {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
Route::load($paths);
|
||||
|
||||
3
Moncter/windows.bat
Normal file
3
Moncter/windows.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
CHCP 65001
|
||||
php windows.php
|
||||
pause
|
||||
136
Moncter/windows.php
Normal file
136
Moncter/windows.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Start file for windows
|
||||
*/
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use support\App;
|
||||
use Workerman\Worker;
|
||||
|
||||
ini_set('display_errors', 'on');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
|
||||
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
|
||||
Dotenv::createUnsafeImmutable(base_path())->load();
|
||||
} else {
|
||||
Dotenv::createMutable(base_path())->load();
|
||||
}
|
||||
}
|
||||
|
||||
App::loadAllConfig(['route']);
|
||||
|
||||
$errorReporting = config('app.error_reporting');
|
||||
if (isset($errorReporting)) {
|
||||
error_reporting($errorReporting);
|
||||
}
|
||||
|
||||
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
|
||||
$paths = [
|
||||
$runtimeProcessPath,
|
||||
runtime_path('logs'),
|
||||
runtime_path('views')
|
||||
];
|
||||
foreach ($paths as $path) {
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
$processFiles = [];
|
||||
if (config('server.listen')) {
|
||||
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
|
||||
}
|
||||
foreach (config('process', []) as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
|
||||
}
|
||||
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($project['process'] ?? [] as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
|
||||
}
|
||||
}
|
||||
foreach ($projects['process'] ?? [] as $processName => $config) {
|
||||
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
|
||||
}
|
||||
}
|
||||
|
||||
function write_process_file($runtimeProcessPath, $processName, $firm): string
|
||||
{
|
||||
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
|
||||
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
|
||||
$fileContent = <<<EOF
|
||||
<?php
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
use Workerman\Worker;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Webman\Config;
|
||||
use support\App;
|
||||
|
||||
ini_set('display_errors', 'on');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (is_callable('opcache_reset')) {
|
||||
opcache_reset();
|
||||
}
|
||||
|
||||
if (!\$appConfigFile = config_path('app.php')) {
|
||||
throw new RuntimeException('Config file not found: app.php');
|
||||
}
|
||||
\$appConfig = require \$appConfigFile;
|
||||
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
|
||||
date_default_timezone_set(\$timezone);
|
||||
}
|
||||
|
||||
App::loadAllConfig(['route']);
|
||||
|
||||
worker_start('$processParam', $configParam);
|
||||
|
||||
if (DIRECTORY_SEPARATOR != "/") {
|
||||
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
|
||||
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
|
||||
}
|
||||
|
||||
Worker::runAll();
|
||||
|
||||
EOF;
|
||||
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
|
||||
file_put_contents($processFile, $fileContent);
|
||||
return $processFile;
|
||||
}
|
||||
|
||||
if ($monitorConfig = config('process.monitor.constructor')) {
|
||||
$monitorHandler = config('process.monitor.handler');
|
||||
$monitor = new $monitorHandler(...array_values($monitorConfig));
|
||||
}
|
||||
|
||||
function popen_processes($processFiles)
|
||||
{
|
||||
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
|
||||
$descriptorspec = [STDIN, STDOUT, STDOUT];
|
||||
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
|
||||
if (!$resource) {
|
||||
exit("Can not execute $cmd\r\n");
|
||||
}
|
||||
return $resource;
|
||||
}
|
||||
|
||||
$resource = popen_processes($processFiles);
|
||||
echo "\r\n";
|
||||
while (1) {
|
||||
sleep(1);
|
||||
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
|
||||
$status = proc_get_status($resource);
|
||||
$pid = $status['pid'];
|
||||
shell_exec("taskkill /F /T /PID $pid");
|
||||
proc_close($resource);
|
||||
$resource = popen_processes($processFiles);
|
||||
}
|
||||
}
|
||||
348
Moncter/技术方案.md
Normal file
348
Moncter/技术方案.md
Normal file
@@ -0,0 +1,348 @@
|
||||
## 用户标签引擎技术方案(以身份证为主键)
|
||||
|
||||
### 一、目标与范围
|
||||
- 构建可扩展的用户标签引擎,统一以身份证为主键的人(person)进行画像与筛选。
|
||||
- 支持多数据源接入(交易、行为、社群、外呼等),并聚合多个手机号、多个微信号到同一人。
|
||||
- 提供规则驱动的人群筛选、客群快照和线索分发能力,服务销售精细化运营。
|
||||
|
||||
### 0. 需求整合清单(共识)
|
||||
- 数据接入:多数据源/多数据库连接,按“数据源-表/接口”粒度定义 Job;增量水位、批量与重试。
|
||||
- 标识治理:支持弱标识(手机号等)建“临时人”,获取强标识(身份证/unionid/客户号)后合并;全链路幂等与审计。
|
||||
- 标签体系:分通道层与人层;`tag_dict` 定义口径/类型/窗口/聚合/版本;标签写入包含 window/source/version。
|
||||
- 聚合计算:通道→人层遵循 `aggregation`(sum/max/avg/any/best_of 等),支持实时触发与离线批处理。
|
||||
- 规则与人群:DSL 配置、试算/执行、审计;Redis 维护 cohort/位图,支持快照与导出。
|
||||
- 回灌与重算:规则或口径变更可对存量回评估;任务状态、错误与执行明细可观测、可重试。
|
||||
- 安全合规:身份证只存哈希(加盐),最小化数据使用,接口鉴权与导出留痕。
|
||||
|
||||
### 二、总体架构
|
||||
- 核心理念:人层(person)是唯一真相;通道层(channel)承载具体手机号/微信号等标识。
|
||||
- 组件分层:
|
||||
- 数据接入层:标准化事件/明细,写入通道层标签。
|
||||
- 聚合计算层:将通道层指标按口径聚合到人层标签。
|
||||
- 规则/人群层:基于人层标签做筛选、快照、导出与分发。
|
||||
- 存储与缓存:MySQL(字典/事实/审计)+ Redis(cohort 人群集与位图)。
|
||||
|
||||
### 二点五、运行逻辑图(分层架构,数据流视角)
|
||||
```mermaid
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 数据源层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│交易系统│ │APP行为│ │客服系统│ │CRM │
|
||||
└─────┘ └─────┘ └─────┘ └─────┘
|
||||
│ │ │ │
|
||||
└───────┴─────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 接入层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ Job调度器 增量水位 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ 标准化 校验 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 身份层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ IdentifierService │
|
||||
│ 手机号→person_id │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ 临时人建表 强标识合并 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 标签层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ channel_tags │
|
||||
│ 通道标签存储 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ Aggregator │
|
||||
│ 聚合计算 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ person_tags │
|
||||
│ 人层标签存储 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 规则层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌───────────────────────────────────────┐
|
||||
│ RuleEngine │
|
||||
│ DSL执行 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ Redis Cohort │
|
||||
│ 人群集合 │
|
||||
└───────────────────────────────────────┘
|
||||
▼
|
||||
╔═════════════════════════════════════════════════════════════════════════╗
|
||||
║ 应用层 ║
|
||||
╚═════════════════════════════════════════════════════════════════════════╝
|
||||
┌─────┐ ┌─────┐ ┌─────┐
|
||||
│人群查询│ │快照导出│ │分发推送│
|
||||
└─────┘ └─────┘ └─────┘
|
||||
|
||||
```
|
||||
|
||||
### 二点六、运行逻辑图(时序视角)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant 业务系统 as 业务系统<br/>(交易/APP/客服)
|
||||
participant 接入服务 as 数据接入服务
|
||||
participant 身份服务 as 身份解析服务
|
||||
participant 通道标签 as channel_tags<br/>(存储)
|
||||
participant 聚合服务 as 聚合计算服务
|
||||
participant 人层标签 as person_tags<br/>(存储)
|
||||
participant 规则引擎 as 规则引擎
|
||||
participant 人群缓存 as Redis Cohort
|
||||
|
||||
业务系统->>接入服务: 1. 推送事件/批量数据
|
||||
接入服务->>接入服务: 2. 标准化、校验、去重
|
||||
接入服务->>身份服务: 3. 解析标识<br/>(手机号/微信→person_id)
|
||||
身份服务-->>接入服务: 返回person_id<br/>(不存在则建临时人)
|
||||
接入服务->>通道标签: 4. 写入通道标签<br/>(幂等、window/source/version)
|
||||
|
||||
通道标签->>聚合服务: 5. 触发聚合事件<br/>(实时/批量)
|
||||
聚合服务->>人层标签: 6. 按口径聚合<br/>(sum/max/avg/any)
|
||||
|
||||
人层标签->>规则引擎: 7. 标签变更触发<br/>(受影响person_id)
|
||||
规则引擎->>规则引擎: 8. 执行DSL规则
|
||||
规则引擎->>人群缓存: 9. 更新cohort<br/>(SADD/SINTER)
|
||||
|
||||
人群缓存-->>业务系统: 10. 人群查询/导出
|
||||
```
|
||||
|
||||
### 二点七、运行逻辑图(简化版,核心路径)
|
||||
```mermaid
|
||||
graph TB
|
||||
Start([数据源<br/>交易/行为/客服/CRM]) --> Ingest[数据接入<br/>Job调度 + 标准化]
|
||||
Ingest --> Identity[身份解析<br/>手机号→person_id]
|
||||
Identity --> Channel[通道标签<br/>channel_tags]
|
||||
Channel --> Aggregate[聚合计算<br/>通道→人层]
|
||||
Aggregate --> Person[人层标签<br/>person_tags]
|
||||
Person --> Rule[规则引擎<br/>DSL筛选]
|
||||
Rule --> Cohort[人群集合<br/>Redis Cohort]
|
||||
Cohort --> Export([应用输出<br/>查询/快照/分发])
|
||||
|
||||
Ingest -.->|状态| Audit1[(ingest_state)]
|
||||
Ingest -.->|错误| Audit2[(ingest_errors)]
|
||||
Rule -.->|执行记录| Audit3[(rule_executions)]
|
||||
|
||||
style Start fill:#e1f5ff
|
||||
style Export fill:#e1f5ff
|
||||
style Ingest fill:#fff4e1
|
||||
style Identity fill:#e8f5e9
|
||||
style Channel fill:#f3e5f5
|
||||
style Aggregate fill:#f3e5f5
|
||||
style Person fill:#f3e5f5
|
||||
style Rule fill:#fff9c4
|
||||
style Cohort fill:#fff9c4
|
||||
```
|
||||
|
||||
### 三、数据模型
|
||||
```sql
|
||||
-- 人:身份证(脱敏哈希)为主键
|
||||
CREATE TABLE person (
|
||||
person_id CHAR(32) PRIMARY KEY, -- md5(uppercase(id_card_no_without_spaces))
|
||||
id_card_hash CHAR(32) UNIQUE,
|
||||
name VARCHAR(64) NULL,
|
||||
gender TINYINT NULL,
|
||||
birthday DATE NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 标识绑定:一个人可有多个手机号/微信/外部ID
|
||||
CREATE TABLE person_identifier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
person_id CHAR(32),
|
||||
id_type ENUM('phone','wechat','external','email') NOT NULL,
|
||||
id_value VARCHAR(128) NOT NULL,
|
||||
is_primary TINYINT DEFAULT 0,
|
||||
verified TINYINT DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
UNIQUE KEY uk_type_value (id_type, id_value),
|
||||
KEY idx_person (person_id)
|
||||
);
|
||||
|
||||
-- 标签字典
|
||||
CREATE TABLE tag_dict (
|
||||
tag_code VARCHAR(128) PRIMARY KEY, -- 例:person.trade.arpu_90d
|
||||
name VARCHAR(128),
|
||||
category VARCHAR(64),
|
||||
level ENUM('person','channel') NOT NULL,
|
||||
type ENUM('int','bool','enum','set','string','float') NOT NULL,
|
||||
enum_values JSON NULL,
|
||||
unit VARCHAR(16) NULL,
|
||||
aggregation ENUM('sum','max','min','avg','any','best_of') NULL, -- 通道→人层口径
|
||||
description TEXT,
|
||||
version INT DEFAULT 1,
|
||||
status ENUM('draft','active','deprecated') DEFAULT 'active',
|
||||
owner VARCHAR(64),
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 人层标签(销售筛选用)
|
||||
CREATE TABLE person_tags (
|
||||
person_id CHAR(32),
|
||||
tag_code VARCHAR(128),
|
||||
tag_value VARCHAR(256),
|
||||
confidence TINYINT DEFAULT 100,
|
||||
source VARCHAR(64),
|
||||
window VARCHAR(32),
|
||||
version INT,
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (person_id, tag_code),
|
||||
KEY idx_tag (tag_code, tag_value)
|
||||
);
|
||||
|
||||
-- 通道层标签(手机号/微信号维度)
|
||||
CREATE TABLE channel_tags (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
id_type ENUM('phone','wechat','external') NOT NULL,
|
||||
id_value VARCHAR(128) NOT NULL,
|
||||
tag_code VARCHAR(128),
|
||||
tag_value VARCHAR(256),
|
||||
source VARCHAR(64),
|
||||
window VARCHAR(32),
|
||||
updated_at DATETIME,
|
||||
UNIQUE KEY uk_dim (id_type, id_value, tag_code),
|
||||
KEY idx_tag (tag_code, tag_value)
|
||||
);
|
||||
|
||||
-- 规则配置与执行审计
|
||||
CREATE TABLE tag_rules (
|
||||
rule_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(128),
|
||||
dsl JSON,
|
||||
status ENUM('draft','active','paused') DEFAULT 'active',
|
||||
schedule VARCHAR(64) NULL,
|
||||
output_tag VARCHAR(128) NULL,
|
||||
owner VARCHAR(64),
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE rule_executions (
|
||||
exec_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
rule_id BIGINT,
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
affected_users INT,
|
||||
status ENUM('success','failed','partial'),
|
||||
message TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### 四、标签规范
|
||||
- 分类:
|
||||
- 基础画像、行为活跃、交易能力、风险合规、社交关系、生命周期、设备画像、地域属性。
|
||||
- 命名:`{level}.{category}.{name}_{window}`
|
||||
- 人层:`person.trade.arpu_90d`,`person.behavior.active_days_30d`
|
||||
- 通道层:`channel.wechat.group_join_30d`,`channel.phone.outbound_connect_7d`
|
||||
- 口径:在 `tag_dict` 中声明 `type/enum/range/aggregation/window/update_freq/version`。
|
||||
- 聚合:
|
||||
- 风险类:any(任一通道命中→人层命中)。
|
||||
- 活跃/价值:max/sum/avg 视业务定义。
|
||||
- 可达性:best_of(如选近30日响应率最高的手机号)。
|
||||
|
||||
### 五、规则与人群(DSL)
|
||||
```json
|
||||
{
|
||||
"name": "高潜付费人群",
|
||||
"logic": "AND",
|
||||
"conditions": [
|
||||
{"tag": "person.trade.arpu_90d", "op": "gte", "value": 500},
|
||||
{"tag": "person.behavior.active_days_30d", "op": "gte", "value": 10},
|
||||
{"tag": "person.risk.blacklist", "op": "eq", "value": false}
|
||||
],
|
||||
"window": "rolling_90d",
|
||||
"ttl": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
### 六、计算与刷新策略
|
||||
- 批处理(T+1/T+0小时):生成稳定画像(交易统计、生命周期等)。
|
||||
- 准实时(秒级/分级):事件驱动更新通道层标签,触发对应人层增量聚合。
|
||||
- 回评估:字典/规则变更后对存量人群重算,并写入审计。
|
||||
|
||||
### 七、系统设计(ThinkPHP 5.1)
|
||||
- 目录结构(`application/tag`):
|
||||
- controller:`TagDictController`、`RuleController`、`SegmentController`、`IdentifierController`
|
||||
- model:`TagDict`、`Person`、`PersonIdentifier`、`PersonTags`、`ChannelTags`、`TagRules`、`RuleExecutions`
|
||||
- service:
|
||||
- `IdentifierService`(标识绑定/查找/合并)
|
||||
- `ChannelTagService`(通道标签写入)
|
||||
- `PersonTagService`(人层聚合/重算)
|
||||
- `TagQueryService`(条件解析→Redis/DB组合查询)
|
||||
- `RuleEngineService`(DSL 校验/执行/审计)
|
||||
- `CohortCacheService`(cohort 维护、集合/位图运算)
|
||||
- command:`ExecuteRule`、`RecomputeTags`、`CohortSnapshot`
|
||||
|
||||
- 路由(建议,受 `jwt` 保护):
|
||||
```php
|
||||
Route::group('v1/tag', function () {
|
||||
Route::get('dict', 'app\\tag\\controller\\TagDictController@index');
|
||||
Route::post('dict', 'app\\tag\\controller\\TagDictController@create');
|
||||
Route::post('identifier/bind', 'app\\tag\\controller\\IdentifierController@bind');
|
||||
Route::post('rule/execute/:id', 'app\\tag\\controller\\RuleController@execute');
|
||||
Route::post('segment/query', 'app\\tag\\controller\\SegmentController@query');
|
||||
Route::post('segment/snapshot', 'app\\tag\\controller\\SegmentController@snapshot');
|
||||
})->middleware(['jwt']);
|
||||
```
|
||||
|
||||
### 八、查询与筛选
|
||||
- 人层为主:`person_tags` 组合条件查询,Redis 保存常用 cohort:
|
||||
- Redis 示例:`SINTER cohort:person.trade.arpu_90d:gt500 cohort:person.active_days_30d:gte10 SDIFF cohort:person.risk.blacklist:eq1`
|
||||
- MySQL 组合查询示例:
|
||||
```sql
|
||||
SELECT DISTINCT t1.person_id
|
||||
FROM person_tags t1
|
||||
JOIN person_tags t2 ON t2.person_id=t1.person_id
|
||||
LEFT JOIN person_tags t3 ON t3.person_id=t1.person_id AND t3.tag_code='person.risk.blacklist'
|
||||
WHERE t1.tag_code='person.trade.arpu_90d' AND CAST(t1.tag_value AS DECIMAL)>=500
|
||||
AND t2.tag_code='person.behavior.active_days_30d' AND CAST(t2.tag_value AS SIGNED)>=10
|
||||
AND (t3.tag_value IS NULL OR t3.tag_value='0')
|
||||
LIMIT 50 OFFSET 0;
|
||||
```
|
||||
|
||||
### 九、关键流程
|
||||
1) 事件接入:收到 `id_type + id_value`(如手机号)→ 查 `person_identifier` → 得到 `person_id`。
|
||||
2) 通道标签更新:写 `channel_tags`,并发布“聚合任务”。
|
||||
3) 人层聚合:按 `tag_dict.aggregation` 规则,更新 `person_tags`。
|
||||
4) 规则评估:对受影响的 `person_id` 运行启用中的规则,更新 cohort/输出标签。
|
||||
5) 人群产出:支持分页查询、生成快照、导出或推送 CRM/外呼系统。
|
||||
|
||||
### 十、缓存与索引
|
||||
- Redis:
|
||||
- 集合/位图存 cohort,Key 规范:`cohort:{tag_code}:{op}{value}` 或区间桶。
|
||||
- TTL:默认 24h,可按规则 `ttl` 覆盖。
|
||||
- MySQL:
|
||||
- `person_tags(tag_code, tag_value)`、`channel_tags(tag_code, tag_value)` 倒排索引。
|
||||
- 审计表按时间分区或冷热分离。
|
||||
|
||||
### 十一、合规与安全
|
||||
- 身份证只存哈希(不可逆),不落明文;导出脱敏。
|
||||
- 最小权限访问,接口留痕审计(规则执行、导出、查看)。
|
||||
- 口径透明:标签保留来源、窗口、置信度、版本。
|
||||
|
||||
### 十二、里程碑(落地计划)
|
||||
- M1:建表与服务骨架;接入交易/行为两类数据;产出10个核心标签。
|
||||
- M2:规则试算与快照;Redis cohort;首批销售客群模板(高价值流失预警)。
|
||||
- M3:通道“最佳触达”策略;CRM/外呼对接;质量监控与看板。
|
||||
|
||||
|
||||
25
Moncter/数据库列表.md
Normal file
25
Moncter/数据库列表.md
Normal file
@@ -0,0 +1,25 @@
|
||||
KR
|
||||
KR_KR
|
||||
KR_LinkedIn
|
||||
KR_存客宝
|
||||
KR_存客宝_四表重构KR_KR版
|
||||
KR_国外
|
||||
KR_户口
|
||||
KR_京东
|
||||
KR_酒店
|
||||
KR_卡套私域
|
||||
KR_快递
|
||||
KR_魔兽世界
|
||||
KR_企业
|
||||
KR_企业名录
|
||||
KR_人才库
|
||||
KR_商城
|
||||
KR_手机
|
||||
KR_顺丰
|
||||
KR_淘宝
|
||||
KR_腾讯
|
||||
KR_投资
|
||||
KR_微博
|
||||
KR_香港在大陆投资企业名录
|
||||
KR_销售额3000万元-5000万元企业名录
|
||||
KR_游戏
|
||||
249
Server/RFM客户价值评分体系技术实施文档.md
Normal file
249
Server/RFM客户价值评分体系技术实施文档.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# RFM 客户价值评分体系技术实施文档
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档旨在明确 RFM(Recency-Frequency-Monetary)客户价值评分体系的技术实现标准,包括维度定义、评分规则、数据处理流程、参数配置及异常处理方案,为系统开发、数据分析及业务应用提供统一依据。
|
||||
|
||||
## 2. 核心术语定义
|
||||
|
||||
|
||||
|
||||
| 术语 | 英文缩写 | 定义 | 数据来源 | 统计周期说明 |
|
||||
| ------ | ------------ | ------------------------------- | --------- | ---------------------------------- |
|
||||
| 最近消费时间 | Recency(R) | 客户最后一次有效消费行为距统计截止日的时间间隔(单位:天) | 订单系统、交易日志 | 支持自定义配置(默认 3-12 个月,按业务场景调整) |
|
||||
| 消费频率 | Frequency(F) | 统计周期内客户发生有效消费行为的总次数 | 订单系统、交易日志 | 与 R 维度统计周期一致,剔除重复下单、取消订单等无效记录 |
|
||||
| 消费金额 | Monetary(M) | 统计周期内客户有效消费行为的总金额(单位:元,支持多币种换算) | 订单系统、支付日志 | 仅统计已支付完成的订单金额,剔除退款、优惠抵扣部分 |
|
||||
| RFM 总分 | RFM Score | 基于 R、F、M 三个维度的分项得分,按预设权重计算的综合得分 | 系统计算生成 | 得分范围 1-15 分(5 分制单项)或 1-100 分(标准化后) |
|
||||
|
||||
## 3. 评分规则技术规范
|
||||
|
||||
### 3.1 分项评分规则(默认 5 分制)
|
||||
|
||||
#### 3.1.1 Recency(R)评分规则
|
||||
|
||||
|
||||
|
||||
* 核心逻辑:时间间隔越短,得分越高(反向映射)
|
||||
|
||||
* 分段标准:采用**五分位法**(按数据分布自动划分区间,避免均分失真)
|
||||
|
||||
|
||||
|
||||
| 得分 | 时间间隔区间(天) | 划分逻辑 |
|
||||
| --- | ---------- | ----------------- |
|
||||
| 5 分 | \[0, T1] | 统计周期内最近消费的 20% 客户 |
|
||||
| 4 分 | (T1, T2] | 统计周期内次近消费的 20% 客户 |
|
||||
| 3 分 | (T2, T3] | 统计周期内中间消费的 20% 客户 |
|
||||
| 2 分 | (T3, T4] | 统计周期内较久消费的 20% 客户 |
|
||||
| 1 分 | (T4, Tmax] | 统计周期内最久消费的 20% 客户 |
|
||||
|
||||
|
||||
|
||||
* 区间计算方式:T1=PERCENTILE\_CONT (0.2)、T2=PERCENTILE\_CONT (0.4)、T3=PERCENTILE\_CONT (0.6)、T4=PERCENTILE\_CONT (0.8),其中 Tmax 为统计周期总天数
|
||||
|
||||
#### 3.1.2 Frequency(F)评分规则
|
||||
|
||||
|
||||
|
||||
* 核心逻辑:消费次数越多,得分越高(正向映射)
|
||||
|
||||
* 分段标准:采用**五分位法**(支持最小消费次数阈值配置)
|
||||
|
||||
|
||||
|
||||
| 得分 | 消费次数区间 | 划分逻辑 |
|
||||
| --- | ----------- | ------------------- |
|
||||
| 5 分 | \[F4, +∞) | 统计周期内消费次数最多的 20% 客户 |
|
||||
| 4 分 | \[F3, F4) | 统计周期内消费次数次多的 20% 客户 |
|
||||
| 3 分 | \[F2, F3) | 统计周期内消费次数中间的 20% 客户 |
|
||||
| 2 分 | \[F1, F2) | 统计周期内消费次数较少的 20% 客户 |
|
||||
| 1 分 | \[Fmin, F1) | 统计周期内消费次数最少的 20% 客户 |
|
||||
|
||||
|
||||
|
||||
* 区间计算方式:F1=PERCENTILE\_CONT (0.2)、F2=PERCENTILE\_CONT (0.4)、F3=PERCENTILE\_CONT (0.6)、F4=PERCENTILE\_CONT (0.8),其中 Fmin 为 1(仅统计有效消费次数≥1 的客户)
|
||||
|
||||
#### 3.1.3 Monetary(M)评分规则
|
||||
|
||||
|
||||
|
||||
* 核心逻辑:消费金额越高,得分越高(正向映射)
|
||||
|
||||
* 分段标准:采用**五分位法**(支持剔除大额异常值后划分)
|
||||
|
||||
|
||||
|
||||
| 得分 | 消费金额区间(元) | 划分逻辑 |
|
||||
| --- | ----------- | ------------------- |
|
||||
| 5 分 | \[M4, +∞) | 统计周期内消费金额最高的 20% 客户 |
|
||||
| 4 分 | \[M3, M4) | 统计周期内消费金额次高的 20% 客户 |
|
||||
| 3 分 | \[M2, M3) | 统计周期内消费金额中间的 20% 客户 |
|
||||
| 2 分 | \[M1, M2) | 统计周期内消费金额较低的 20% 客户 |
|
||||
| 1 分 | \[Mmin, M1) | 统计周期内消费金额最低的 20% 客户 |
|
||||
|
||||
|
||||
|
||||
* 区间计算方式:M1=PERCENTILE\_CONT (0.2)、M2=PERCENTILE\_CONT (0.4)、M3=PERCENTILE\_CONT (0.6)、M4=PERCENTILE\_CONT (0.8),其中 Mmin 为统计周期内最小有效订单金额
|
||||
|
||||
### 3.2 总分计算规则
|
||||
|
||||
#### 3.2.1 加权求和公式
|
||||
|
||||
$RFM_{Score} = R_{Score} \times W_R + F_{Score} \times W_F + M_{Score} \times W_M$
|
||||
|
||||
|
||||
|
||||
* 权重配置:支持自定义(默认配置:$W_R=0.4$,$W_F=0.3$,$W_M=0.3$)
|
||||
|
||||
* 权重约束:$W_R + W_F + W_M = 1.0$,且单个权重取值范围为 \[0.1, 0.8]
|
||||
|
||||
#### 3.2.2 得分标准化(可选)
|
||||
|
||||
|
||||
|
||||
* 若需将总分映射为 1-100 分,采用线性标准化公式:
|
||||
|
||||
$RFM_{StandardScore} = \frac{RFM_{Score} - RFM_{Min}}{RFM_{Max} - RFM_{Min}} \times 99 + 1$
|
||||
|
||||
* 其中:$RFM_{Min}=W_R \times 1 + W_F \times 1 + W_M \times 1$,$RFM_{Max}=W_R \times 5 + W_F \times 5 + W_M \times 5$
|
||||
|
||||
## 4. 数据处理流程
|
||||
|
||||
### 4.1 数据输入要求
|
||||
|
||||
|
||||
|
||||
| 数据项 | 数据类型 | 格式要求 | 校验规则 |
|
||||
| ------ | ------------- | ------------------- | ------------ |
|
||||
| 客户唯一标识 | String/Int | 全局唯一(如用户 ID、会员 ID) | 非空、去重 |
|
||||
| 订单唯一标识 | String/Int | 全局唯一(如订单号) | 非空、去重 |
|
||||
| 消费时间 | DateTime | yyyy-MM-dd HH:mm:ss | 需在统计周期内 |
|
||||
| 消费金额 | Decimal(18,2) | 大于 0 | 剔除负数、0 值 |
|
||||
| 订单状态 | String | 枚举值(已支付、已取消、已退款等) | 仅保留 “已支付” 状态 |
|
||||
|
||||
### 4.2 数据预处理步骤
|
||||
|
||||
|
||||
|
||||
1. **数据过滤**:
|
||||
|
||||
* 剔除统计周期外的订单数据
|
||||
|
||||
* 剔除订单状态为 “已取消”“已退款”“无效” 的记录
|
||||
|
||||
* 剔除员工内部订单、测试订单(按订单标签或用户标签过滤)
|
||||
|
||||
* 剔除单笔金额超过$M_{99分位值} \times 3$的异常大额订单(可配置开关)
|
||||
|
||||
1. **数据聚合**:
|
||||
|
||||
* 按客户唯一标识分组,计算 R、F、M 原始指标:
|
||||
|
||||
|
||||
* R:MAX (消费时间) 到统计截止日的时间间隔(天)
|
||||
|
||||
* F:COUNT (DISTINCT 订单唯一标识)
|
||||
|
||||
* M:SUM (消费金额)
|
||||
|
||||
1. **缺失值处理**:
|
||||
|
||||
* 统计周期内无消费记录的客户:R = 统计周期总天数,F=0,M=0,分项得分均为 1 分
|
||||
|
||||
* 单个指标缺失(如仅缺失 M):按 1 分计分项得分
|
||||
|
||||
### 4.3 评分计算流程
|
||||
|
||||
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[数据输入] --> B[数据过滤]
|
||||
B --> C[数据聚合计算R/F/M原始值]
|
||||
C --> D[缺失值处理]
|
||||
D --> E[按五分位法划分各维度区间]
|
||||
E --> F[计算R/F/M分项得分]
|
||||
F --> G[按权重计算RFM总分]
|
||||
G --> H[可选:标准化为1-100分]
|
||||
H --> I[输出客户RFM评分结果]
|
||||
```
|
||||
|
||||
## 5. 参数配置说明
|
||||
|
||||
|
||||
|
||||
| 参数名称 | 配置项 | 取值范围 | 默认值 | 配置方式 |
|
||||
| ------- | ---------------------- | ------------- | ------ | --------------- |
|
||||
| 统计周期 | cycle\_days | 30-365 | 180 | 系统配置页手动输入 |
|
||||
| R 维度权重 | weight\_R | 0.1-0.8 | 0.4 | 系统配置页滑动条调整 |
|
||||
| F 维度权重 | weight\_F | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 |
|
||||
| M 维度权重 | weight\_M | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 |
|
||||
| 异常金额阈值 | abnormal\_money\_ratio | 1.5-5.0 | 3.0 | 系统配置页手动输入(倍数关系) |
|
||||
| 评分分制 | score\_scale | 5 分制 / 100 分制 | 5 分制 | 系统配置页单选 |
|
||||
| 缺失值处理策略 | missing\_strategy | 按 1 分计 / 剔除客户 | 按 1 分计 | 系统配置页单选 |
|
||||
|
||||
## 6. 异常处理方案
|
||||
|
||||
### 6.1 数据异常
|
||||
|
||||
|
||||
|
||||
| 异常类型 | 表现形式 | 处理逻辑 | 影响范围 |
|
||||
| ------ | ------------------------------ | ---------------------- | --------------- |
|
||||
| 重复订单 | 同一客户同一时间相同订单号 | 去重保留 1 条有效记录 | 不影响 F、M 计算 |
|
||||
| 大额异常订单 | 单笔金额 > $M_{99分位值} \times 异常阈值$ | 自动标记,可选择剔除或保留 | 仅影响 M 维度区间划分 |
|
||||
| 消费时间异常 | 消费时间晚于统计截止日 | 视为无效数据,剔除 | 不影响最终结果 |
|
||||
| 客户标识重复 | 同一客户多个唯一标识 | 按客户合并规则(如手机号、身份证号关联)聚合 | 需提前完成客户统一 ID 映射 |
|
||||
|
||||
### 6.2 计算异常
|
||||
|
||||
|
||||
|
||||
| 异常类型 | 触发条件 | 处理逻辑 | 输出结果 |
|
||||
| -------- | --------------------- | --------------------------------------- | --------------- |
|
||||
| 维度区间为空 | 某维度所有客户数据相同(如 F 均为 1) | 强制均分 5 个区间 | 分项得分按 1-5 分依次分配 |
|
||||
| 权重总和不为 1 | 配置权重时计算错误 | 系统自动归一化处理($W'_X = W_X / (W_R+W_F+W_M)$) | 不影响总分有效性 |
|
||||
| 统计周期过短 | 小于 30 天导致数据量不足 | 系统给出警告,允许强制执行 | 区间划分可能失真,建议延长周期 |
|
||||
|
||||
## 7. 输出结果格式
|
||||
|
||||
### 7.1 单客户评分结果
|
||||
|
||||
|
||||
|
||||
| 字段名 | 数据类型 | 示例 |
|
||||
| --------- | ------------- | ----------------------- |
|
||||
| 客户 ID | String | CUST2023001 |
|
||||
| R 原始值(天) | Int | 15 |
|
||||
| R 得分 | Int | 5 |
|
||||
| F 原始值(次) | Int | 8 |
|
||||
| F 得分 | Int | 4 |
|
||||
| M 原始值(元) | Decimal(18,2) | 2560.00 |
|
||||
| M 得分 | Int | 5 |
|
||||
| RFM 总分 | Decimal(5,2) | 4.70 |
|
||||
| 标准化得分(可选) | Int | 94 |
|
||||
| 统计周期 | String | 2023-01-01 至 2023-06-30 |
|
||||
| 计算时间 | DateTime | 2023-07-01 00:30:25 |
|
||||
|
||||
### 7.2 批量输出文件格式
|
||||
|
||||
|
||||
|
||||
* 支持 CSV、Parquet、JSON 格式导出
|
||||
|
||||
* 编码格式:UTF-8
|
||||
|
||||
* 压缩方式:默认 GZIP(可配置关闭)
|
||||
|
||||
## 8. 业务适配建议
|
||||
|
||||
|
||||
|
||||
| 业务场景 | 统计周期建议 | 权重调整建议 | 特殊配置 |
|
||||
| --------------- | ------- | --------------------------- | ----------------- |
|
||||
| 快消零售 | 3-6 个月 | $W_R=0.5, W_F=0.3, W_M=0.2$ | 提高 R 维度权重,关注复购及时性 |
|
||||
| 高客单价行业(如奢侈品、家居) | 12 个月 | $W_R=0.3, W_F=0.2, W_M=0.5$ | 提高 M 维度权重,关注消费能力 |
|
||||
| 新品推广期 | 1-3 个月 | $W_R=0.6, W_F=0.2, W_M=0.2$ | 重点关注近期新客户 |
|
||||
| 会员体系运营 | 6-12 个月 | $W_R=0.4, W_F=0.4, W_M=0.2$ | 提高 F 维度权重,鼓励高频消费 |
|
||||
|
||||
> (注:文档部分内容可能由 AI 生成)
|
||||
@@ -7,7 +7,7 @@ Route::group('v1/ai', function () {
|
||||
|
||||
//openai、chatGPT
|
||||
Route::group('openai', function () {
|
||||
Route::post('text', 'app\ai\controller\OpenAi@text');
|
||||
Route::post('text', 'app\ai\controller\OpenAI@text');
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/v1/bot/create', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
|
||||
return json_encode(['code' => 200, 'msg' => '创建成功', 'data' => $result['data']]);
|
||||
@@ -136,9 +136,9 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/v1/bot/update', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '获取成功']);
|
||||
return json_encode(['code' => 200, 'msg' => '更新成功', 'data' => []]);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,9 +162,9 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/v1/bot/publish', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '发布成功']);
|
||||
return json_encode(['code' => 200, 'msg' => '发布成功', 'data' => []]);
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class CozeAI extends Controller
|
||||
$result = json_decode($result, true);
|
||||
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '创建成功','data' => $result['data']]);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/open_api/knowledge/document/create', $params, 'POST', $headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '创建成功','data' => $result['document_infos']]);
|
||||
}
|
||||
@@ -254,9 +254,9 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/open_api/knowledge/document/delete', $params, 'POST', $headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '删除成功']);
|
||||
return json_encode(['code' => 200, 'msg' => '删除成功', 'data' => []]);
|
||||
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($this->apiUrl . '/v1/conversation/create', $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '创建成功','data' => $result['data']]);
|
||||
}
|
||||
@@ -306,15 +306,15 @@ class CozeAI extends Controller
|
||||
|
||||
|
||||
if(empty($bot_id)){
|
||||
return errorJson('智能体ID不能为空');
|
||||
return json_encode(['code' => 500, 'msg' => '智能体ID不能为空', 'data' => []]);
|
||||
}
|
||||
|
||||
if(empty($conversation_id)){
|
||||
return errorJson('会话ID不能为空');
|
||||
return json_encode(['code' => 500, 'msg' => '会话ID不能为空', 'data' => []]);
|
||||
}
|
||||
|
||||
if(empty($question)){
|
||||
return errorJson('问题不能为空');
|
||||
return json_encode(['code' => 500, 'msg' => '问题不能为空', 'data' => []]);
|
||||
}
|
||||
|
||||
// 构建请求数据
|
||||
@@ -330,16 +330,21 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($url, $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '发送成功','data' => $result['data']]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return errorJson('创建对话失败:' . $e->getMessage());
|
||||
return json_encode(['code' => 500, 'msg' => '创建对话失败:' . $e->getMessage(), 'data' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 查看对话详情
|
||||
* @param $data
|
||||
* @return false|string|\think\response\Json
|
||||
*/
|
||||
public function getConversationChat($data = [])
|
||||
{
|
||||
$conversation_id = !empty($data['conversation_id']) ? $data['conversation_id'] : '';
|
||||
@@ -348,12 +353,17 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($url, [], 'GET', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '发送成功','data' => $result['data']]);
|
||||
return json_encode(['code' => 200, 'msg' => '获取成功','data' => $result['data']]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 查看对话消息详情
|
||||
* @param $data
|
||||
* @return false|string|\think\response\Json
|
||||
*/
|
||||
public function listConversationMessage($data = [])
|
||||
{
|
||||
$conversation_id = !empty($data['conversation_id']) ? $data['conversation_id'] : '';
|
||||
@@ -362,9 +372,35 @@ class CozeAI extends Controller
|
||||
$result = requestCurl($url, [], 'GET', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return errorJson($result['msg'], $result['code']);
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '发送成功','data' => $result['data']]);
|
||||
return json_encode(['code' => 200, 'msg' => '获取成功','data' => $result['data']]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 取消进行中的对话
|
||||
* @param $data
|
||||
* @return false|string|\think\response\Json
|
||||
*/
|
||||
public function cancelConversationChat($data = [])
|
||||
{
|
||||
$conversation_id = !empty($data['conversation_id']) ? $data['conversation_id'] : '';
|
||||
$chat_id = !empty($data['chat_id']) ? $data['chat_id'] : '';
|
||||
|
||||
// 构建请求数据
|
||||
$params = [
|
||||
'conversation_id' => (string) $conversation_id,
|
||||
'chat_id' => (string) $chat_id
|
||||
];
|
||||
|
||||
$url = $this->apiUrl . '/v3/chat/cancel';
|
||||
$result = requestCurl($url, $params, 'POST', $this->headers, 'json');
|
||||
$result = json_decode($result, true);
|
||||
if ($result['code'] != 0) {
|
||||
return json_encode(['code' => $result['code'], 'msg' => $result['msg'], 'data' => []]);
|
||||
}
|
||||
return json_encode(['code' => 200, 'msg' => '取消成功', 'data' => []]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,15 +4,18 @@ namespace app\ai\controller;
|
||||
|
||||
use app\common\util\JwtUtil;
|
||||
use think\facade\Env;
|
||||
use think\Controller;
|
||||
|
||||
class DouBaoAI
|
||||
class DouBaoAI extends Controller
|
||||
{
|
||||
protected $apiUrl;
|
||||
protected $apiKey;
|
||||
protected $headers;
|
||||
|
||||
public function __init()
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->apiUrl = Env::get('doubaoAi.api_url');
|
||||
$this->apiKey = Env::get('doubaoAi.api_key');
|
||||
|
||||
@@ -31,7 +34,7 @@ class DouBaoAI
|
||||
|
||||
public function text($params = [])
|
||||
{
|
||||
$this->__init();
|
||||
|
||||
if (empty($params)){
|
||||
return json_encode(['code' => 500, 'msg' => '提示词缺失']);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
namespace app\ai\controller;
|
||||
|
||||
use think\facade\Env;
|
||||
class OpenAI
|
||||
use think\Controller;
|
||||
|
||||
|
||||
class OpenAI extends Controller
|
||||
{
|
||||
protected $apiUrl;
|
||||
protected $apiKey;
|
||||
protected $headers;
|
||||
|
||||
public function __init()
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->apiUrl = Env::get('openAi.apiUrl');
|
||||
$this->apiKey = Env::get('openAi.apiKey');
|
||||
|
||||
@@ -24,7 +29,7 @@ class OpenAI
|
||||
|
||||
public function text()
|
||||
{
|
||||
$this->__init();
|
||||
|
||||
$params = [
|
||||
'model' => 'gpt-3.5-turbo-0125',
|
||||
'input' => 'DHA 从孕期到出生到老年都需要,助力大脑发育🧠/减缓脑压力有助记忆/给大脑动力#贝蒂喜藻油DHA 双标认证每粒 150毫克,高含量、高性价比从小吃到老,长期吃更健康 重写这条朋友圈 要求: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除'
|
||||
@@ -46,7 +51,6 @@ class OpenAI
|
||||
*/
|
||||
public function bedtimeStory()
|
||||
{
|
||||
$this->__init();
|
||||
|
||||
// API请求参数
|
||||
$params = [
|
||||
|
||||
@@ -67,12 +67,11 @@ class WebSocketController extends BaseController
|
||||
// 调用登录接口获取token
|
||||
$headerData = ['client:kefu-client'];
|
||||
|
||||
$headerData[] = 'verifysessionid:3f21df29-6d8a-4980-ae8a-bf15ef17955f';
|
||||
$headerData[] = 'verifycode:0k3g';
|
||||
$headerData[] = 'verifysessionid:2fbc51c9-db70-4e84-9568-21ef3667e1be';
|
||||
$headerData[] = 'verifycode:5bcd';
|
||||
$header = setHeader($headerData, '', 'plain');
|
||||
$result = requestCurl('https://kf.quwanzhi.com:9991/token', $params, 'POST', $header);
|
||||
$result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST', $header);
|
||||
$result_array = handleApiResponse($result);
|
||||
|
||||
if (isset($result_array['access_token']) && !empty($result_array['access_token'])) {
|
||||
$this->authorized = $result_array['access_token'];
|
||||
$this->accountId = $userData['accountId'];
|
||||
@@ -116,7 +115,7 @@ class WebSocketController extends BaseController
|
||||
];
|
||||
|
||||
$content = json_encode($result);
|
||||
$this->client = new Client("wss://kf.quwanzhi.com:9993",
|
||||
$this->client = new Client("wss://s2.siyuguanli.com:9993",
|
||||
[
|
||||
'filter' => ['text', 'binary', 'ping', 'pong', 'close', 'receive', 'send'],
|
||||
'context' => $context,
|
||||
@@ -669,7 +668,6 @@ class WebSocketController extends BaseController
|
||||
"wechatChatroomId" => 0,
|
||||
"wechatFriendId" => $dataArray['wechatFriendId'],
|
||||
];
|
||||
|
||||
// 发送请求
|
||||
$this->client->send(json_encode($params));
|
||||
// 接收响应
|
||||
|
||||
@@ -43,7 +43,7 @@ class WebSocketControllerCopy extends BaseController
|
||||
// 设置请求头
|
||||
$headerData = ['client:kefu-client'];
|
||||
$header = setHeader($headerData, '', 'plain');
|
||||
$result = requestCurl('https://kf.quwanzhi.com:9991/token', $params, 'POST',$header);
|
||||
$result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST',$header);
|
||||
$result_array = handleApiResponse($result);
|
||||
|
||||
if (isset($result_array['access_token']) && !empty($result_array['access_token'])) {
|
||||
@@ -81,7 +81,7 @@ class WebSocketControllerCopy extends BaseController
|
||||
|
||||
|
||||
$content = json_encode($result);
|
||||
$this->client = new Client("wss://kf.quwanzhi.com:9993",
|
||||
$this->client = new Client("wss://s2.siyuguanli.com:9993",
|
||||
[
|
||||
'filter' => ['text', 'binary', 'ping', 'pong', 'close','receive', 'send'],
|
||||
'context' => $context,
|
||||
|
||||
@@ -12,10 +12,12 @@ Route::group('v1/', function () {
|
||||
//好友相关
|
||||
Route::group('wechatFriend/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\WechatFriendController@getList'); // 获取好友列表
|
||||
Route::get('detail', 'app\chukebao\controller\WechatFriendController@getDetail'); // 获取好友详情
|
||||
});
|
||||
//群相关
|
||||
Route::group('wechatChatroom/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\WechatChatroomController@getList'); // 获取好友列表
|
||||
Route::get('detail', 'app\chukebao\controller\WechatChatroomController@getDetail'); // 获取群详情
|
||||
Route::post('aiAnnouncement', 'app\chukebao\controller\WechatChatroomController@aiAnnouncement'); // AI群公告
|
||||
});
|
||||
|
||||
@@ -139,8 +141,18 @@ Route::group('v1/', function () {
|
||||
Route::get('stats', 'app\chukebao\controller\AutoGreetingsController@stats');
|
||||
});
|
||||
|
||||
//AI智能推送
|
||||
Route::group('aiPush/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\AiPushController@getList'); // 获取推送列表
|
||||
Route::post('add', 'app\chukebao\controller\AiPushController@add'); // 添加推送
|
||||
Route::get('details', 'app\chukebao\controller\AiPushController@details'); // 推送详情
|
||||
Route::delete('del', 'app\chukebao\controller\AiPushController@del'); // 删除推送
|
||||
Route::post('update', 'app\chukebao\controller\AiPushController@update'); // 更新推送
|
||||
Route::get('setStatus', 'app\chukebao\controller\AiPushController@setStatus'); // 修改状态
|
||||
Route::get('stats', 'app\chukebao\controller\AiPushController@stats'); // 统计概览
|
||||
});
|
||||
|
||||
|
||||
//自动问候
|
||||
Route::group('notice/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\NoticeController@getList');
|
||||
Route::put('readMessage', 'app\chukebao\controller\NoticeController@readMessage');
|
||||
|
||||
@@ -11,6 +11,9 @@ use app\chukebao\model\FriendSettings;
|
||||
use app\chukebao\model\TokensCompany;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
|
||||
|
||||
/**
|
||||
* AI聊天控制器
|
||||
@@ -27,8 +30,16 @@ class AiChatController extends BaseController
|
||||
const STATUS_CANCELED = 'canceled'; // 对话已取消
|
||||
|
||||
// 轮询配置
|
||||
const MAX_RETRY_TIMES = 30; // 最大重试次数
|
||||
const RETRY_INTERVAL = 2; // 重试间隔(秒)
|
||||
const MAX_RETRY_TIMES = 1000; // 最大重试次数
|
||||
const RETRY_INTERVAL = 500000; // 重试间隔(微秒,即500毫秒)
|
||||
|
||||
// 并发控制
|
||||
const CACHE_EXPIRE = 30; // 缓存过期时间(秒)
|
||||
|
||||
// 请求唯一标识符
|
||||
private $requestKey = '';
|
||||
private $requestId = '';
|
||||
private $currentStep = 0;
|
||||
|
||||
/**
|
||||
* AI聊天主入口
|
||||
@@ -40,59 +51,219 @@ class AiChatController extends BaseController
|
||||
try {
|
||||
// 1. 参数验证和初始化
|
||||
$params = $this->validateAndInitParams();
|
||||
|
||||
if ($params === false) {
|
||||
return ResponseHelper::error('参数验证失败');
|
||||
}
|
||||
|
||||
// 并发控制:检查并处理同一用户的重复请求
|
||||
$this->requestKey = "aichat_{$params['friendId']}_{$params['wechatAccountId']}";
|
||||
$this->requestId = uniqid('req_', true);
|
||||
|
||||
$concurrentCheck = $this->handleConcurrentRequest($params);
|
||||
if ($concurrentCheck !== true) {
|
||||
return $concurrentCheck; // 返回错误响应
|
||||
}
|
||||
|
||||
$this->currentStep = 1;
|
||||
|
||||
// 2. 验证Tokens余额
|
||||
if (!$this->checkTokensBalance($params['companyId'])) {
|
||||
$this->updateRequestStep(2);
|
||||
if ($this->isRequestCanceled()) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$hasBalance = $this->checkTokensBalance($params['companyId']);
|
||||
|
||||
if (!$hasBalance) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error('Tokens余额不足,请充值后再试');
|
||||
}
|
||||
|
||||
// 3. 获取AI配置
|
||||
$this->updateRequestStep(3);
|
||||
if ($this->isRequestCanceled()) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$setting = $this->getAiSettings($params['companyId']);
|
||||
|
||||
if (!$setting) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error('未找到AI配置信息,请先配置AI策略');
|
||||
}
|
||||
|
||||
// 4. 获取好友AI设置
|
||||
$this->updateRequestStep(4);
|
||||
if ($this->isRequestCanceled()) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$friendSettings = $this->getFriendSettings($params['companyId'], $params['friendId']);
|
||||
|
||||
if (!$friendSettings) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error('该好友未配置或未开启AI功能');
|
||||
}
|
||||
|
||||
// 5. 确保会话存在
|
||||
$this->updateRequestStep(5);
|
||||
if ($this->isRequestCanceled()) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$conversationId = $this->ensureConversation($friendSettings, $setting, $params);
|
||||
if (!$conversationId) {
|
||||
|
||||
if (empty($conversationId)) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error('创建会话失败');
|
||||
}
|
||||
|
||||
// 6. 获取历史消息
|
||||
$this->updateRequestStep(6);
|
||||
if ($this->isRequestCanceled()) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$msgData = $this->getHistoryMessages($params['friendId'], $friendSettings);
|
||||
|
||||
// 7. 创建AI对话
|
||||
// 7. 创建AI对话(从这步开始需要保存对话ID以便取消)
|
||||
$this->updateRequestStep(7);
|
||||
if ($this->isRequestCanceled($conversationId, null)) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$chatId = $this->createAiChat($setting, $friendSettings, $msgData);
|
||||
if (!$chatId) {
|
||||
|
||||
if (empty($chatId)) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error('创建对话失败');
|
||||
}
|
||||
|
||||
// 保存对话ID到缓存,以便新请求可以取消
|
||||
$this->updateRequestStep(7, $conversationId, $chatId);
|
||||
|
||||
// 8. 等待AI处理完成(轮询)
|
||||
$chatResult = $this->waitForChatCompletion($conversationId, $chatId);
|
||||
if (!$chatResult) {
|
||||
return ResponseHelper::error('AI处理超时或失败');
|
||||
$this->updateRequestStep(8, $conversationId, $chatId);
|
||||
if ($this->isRequestCanceled($conversationId, $chatId)) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$chatResult = $this->waitForChatCompletion($conversationId, $chatId);
|
||||
|
||||
if (!$chatResult['success']) {
|
||||
$this->clearRequestCache();
|
||||
return ResponseHelper::error($chatResult['error']);
|
||||
}
|
||||
|
||||
$chatResult = $chatResult['data'];
|
||||
|
||||
// 9. 扣除Tokens
|
||||
$this->updateRequestStep(9, $conversationId, $chatId);
|
||||
if ($this->isRequestCanceled($conversationId, $chatId)) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$this->consumeTokens($chatResult, $params, $friendSettings);
|
||||
|
||||
// 10. 获取对话消息
|
||||
$this->updateRequestStep(10, $conversationId, $chatId);
|
||||
if ($this->isRequestCanceled($conversationId, $chatId)) {
|
||||
return ResponseHelper::error('该好友有新的AI对话请求正在处理中,当前请求已被取消');
|
||||
}
|
||||
$messages = $this->getChatMessages($conversationId, $chatId);
|
||||
|
||||
if (!$messages) {
|
||||
return ResponseHelper::error('获取对话消息失败');
|
||||
}
|
||||
return ResponseHelper::success($messages[1]['content'], '对话成功');
|
||||
|
||||
// 筛选type为answer的消息(AI回复的内容)
|
||||
$answerContent = '';
|
||||
foreach ($messages as $msg) {
|
||||
if (isset($msg['type']) && $msg['type'] === 'answer') {
|
||||
$answerContent = $msg['content'] ?? '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($answerContent)) {
|
||||
Log::warning('未找到AI回复内容,messages: ' . json_encode($messages));
|
||||
return ResponseHelper::error('未获取到AI回复内容');
|
||||
}
|
||||
|
||||
// 清理请求缓存
|
||||
$this->clearRequestCache();
|
||||
|
||||
// 返回结果
|
||||
return ResponseHelper::success(['content' => $answerContent], '对话成功');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('AI聊天异常:' . $e->getMessage());
|
||||
Log::error('AI聊天异常:' . $e->getMessage());
|
||||
|
||||
// 清理请求缓存
|
||||
$this->clearRequestCache();
|
||||
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消AI对话
|
||||
* 取消当前正在进行的AI对话请求
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function cancel()
|
||||
{
|
||||
try {
|
||||
// 获取参数
|
||||
$friendId = $this->request->param('friendId', '');
|
||||
$wechatAccountId = $this->request->param('wechatAccountId', '');
|
||||
|
||||
if (empty($wechatAccountId) || empty($friendId)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
// 生成缓存键
|
||||
$requestKey = "aichat_{$friendId}_{$wechatAccountId}";
|
||||
|
||||
// 获取缓存数据
|
||||
$cacheData = Cache::get($requestKey);
|
||||
|
||||
if (!$cacheData) {
|
||||
return ResponseHelper::error('当前没有正在进行的AI对话');
|
||||
}
|
||||
|
||||
$requestId = $cacheData['request_id'] ?? '';
|
||||
$step = $cacheData['step'] ?? 0;
|
||||
$conversationId = $cacheData['conversation_id'] ?? '';
|
||||
$chatId = $cacheData['chat_id'] ?? '';
|
||||
|
||||
Log::info("手动取消AI对话 - 请求ID: {$requestId}, 步骤: {$step}");
|
||||
|
||||
// 如果已经到达步骤7或之后,需要调用取消API
|
||||
if ($step >= 7 && !empty($conversationId) && !empty($chatId)) {
|
||||
try {
|
||||
$cozeAI = new CozeAI();
|
||||
$cancelResult = $cozeAI->cancelConversationChat([
|
||||
'conversation_id' => $conversationId,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
|
||||
$result = json_decode($cancelResult, true);
|
||||
if ($result['code'] != 200) {
|
||||
Log::error("调用取消API失败 - conversation_id: {$conversationId}, chat_id: {$chatId}, 错误: " . ($result['msg'] ?? '未知错误'));
|
||||
} else {
|
||||
Log::info("成功调用取消API - conversation_id: {$conversationId}, chat_id: {$chatId}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("调用取消API异常:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
Cache::rm($requestKey);
|
||||
Log::info("已清理AI对话缓存 - 请求ID: {$requestId}");
|
||||
|
||||
return ResponseHelper::success([
|
||||
'canceled_request_id' => $requestId,
|
||||
'step' => $step
|
||||
], 'AI对话已取消');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('取消AI对话异常:' . $e->getMessage());
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -194,7 +365,7 @@ class AiChatController extends BaseController
|
||||
$res = json_decode($res, true);
|
||||
|
||||
if ($res['code'] != 200) {
|
||||
\think\facade\Log::error('创建会话失败:' . ($res['msg'] ?? '未知错误'));
|
||||
Log::error('创建会话失败:' . ($res['msg'] ?? '未知错误'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -203,7 +374,6 @@ class AiChatController extends BaseController
|
||||
$friendSettings->conversationId = $conversationId;
|
||||
$friendSettings->conversationTime = time();
|
||||
$friendSettings->save();
|
||||
|
||||
return $conversationId;
|
||||
}
|
||||
|
||||
@@ -292,7 +462,7 @@ class AiChatController extends BaseController
|
||||
$res = json_decode($res, true);
|
||||
|
||||
if ($res['code'] != 200) {
|
||||
\think\facade\Log::error('创建对话失败:' . ($res['msg'] ?? '未知错误'));
|
||||
Log::error('创建对话失败:' . ($res['msg'] ?? '未知错误'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -304,7 +474,7 @@ class AiChatController extends BaseController
|
||||
*
|
||||
* @param string $conversationId 会话ID
|
||||
* @param string $chatId 对话ID
|
||||
* @return array|null
|
||||
* @return array ['success' => bool, 'data' => array|null, 'error' => string]
|
||||
*/
|
||||
private function waitForChatCompletion($conversationId, $chatId)
|
||||
{
|
||||
@@ -320,8 +490,9 @@ class AiChatController extends BaseController
|
||||
$res = json_decode($res, true);
|
||||
|
||||
if ($res['code'] != 200) {
|
||||
\think\facade\Log::error('获取对话状态失败:' . ($res['msg'] ?? '未知错误'));
|
||||
return null;
|
||||
$errorMsg = 'AI接口调用失败:' . ($res['msg'] ?? '未知错误');
|
||||
Log::error($errorMsg);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
}
|
||||
|
||||
$status = $res['data']['status'] ?? '';
|
||||
@@ -330,36 +501,41 @@ class AiChatController extends BaseController
|
||||
switch ($status) {
|
||||
case self::STATUS_COMPLETED:
|
||||
// 对话完成,返回结果
|
||||
return $res['data'];
|
||||
return ['success' => true, 'data' => $res['data'], 'error' => ''];
|
||||
|
||||
case self::STATUS_IN_PROGRESS:
|
||||
case self::STATUS_CREATED:
|
||||
// 继续等待
|
||||
$retryCount++;
|
||||
sleep(self::RETRY_INTERVAL);
|
||||
usleep(self::RETRY_INTERVAL);
|
||||
break;
|
||||
|
||||
case self::STATUS_FAILED:
|
||||
\think\facade\Log::error('对话失败,chat_id: ' . $chatId);
|
||||
return null;
|
||||
$errorMsg = 'AI对话处理失败';
|
||||
Log::error($errorMsg . ',chat_id: ' . $chatId);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
|
||||
case self::STATUS_CANCELED:
|
||||
\think\facade\Log::error('对话已取消,chat_id: ' . $chatId);
|
||||
return null;
|
||||
$errorMsg = 'AI对话已被取消';
|
||||
Log::error($errorMsg . ',chat_id: ' . $chatId);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
|
||||
case self::STATUS_REQUIRES_ACTION:
|
||||
\think\facade\Log::warning('对话需要进一步处理,chat_id: ' . $chatId);
|
||||
return null;
|
||||
$errorMsg = 'AI对话需要进一步处理';
|
||||
Log::warning($errorMsg . ',chat_id: ' . $chatId);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
|
||||
default:
|
||||
\think\facade\Log::error('未知状态:' . $status);
|
||||
return null;
|
||||
$errorMsg = 'AI返回未知状态:' . $status;
|
||||
Log::error($errorMsg);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
}
|
||||
}
|
||||
|
||||
// 超时
|
||||
\think\facade\Log::error('对话处理超时,chat_id: ' . $chatId);
|
||||
return null;
|
||||
$errorMsg = 'AI对话处理超时,已等待' . (self::MAX_RETRY_TIMES * self::RETRY_INTERVAL / 1000000) . '秒';
|
||||
Log::error($errorMsg . ',chat_id: ' . $chatId);
|
||||
return ['success' => false, 'data' => null, 'error' => $errorMsg];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,13 +588,154 @@ class AiChatController extends BaseController
|
||||
$res = json_decode($res, true);
|
||||
|
||||
if ($res['code'] != 200) {
|
||||
\think\facade\Log::error('获取对话消息失败:' . ($res['msg'] ?? '未知错误'));
|
||||
Log::error('获取对话消息失败:' . ($res['msg'] ?? '未知错误'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $res['data'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理并发请求
|
||||
* 检查是否有同一用户的旧请求正在处理,如果有则取消旧请求
|
||||
*
|
||||
* @param array $params 请求参数
|
||||
* @return true|\think\response\Json true表示可以继续,否则返回错误响应
|
||||
*/
|
||||
private function handleConcurrentRequest($params)
|
||||
{
|
||||
$cacheData = Cache::get($this->requestKey);
|
||||
|
||||
if ($cacheData) {
|
||||
// 有旧请求正在处理
|
||||
$oldRequestId = $cacheData['request_id'] ?? '';
|
||||
$oldStep = $cacheData['step'] ?? 0;
|
||||
$oldConversationId = $cacheData['conversation_id'] ?? '';
|
||||
$oldChatId = $cacheData['chat_id'] ?? '';
|
||||
|
||||
Log::info("检测到并发请求 - 旧请求: {$oldRequestId} (步骤{$oldStep}), 新请求: {$this->requestId}");
|
||||
|
||||
// 如果旧请求已经到达步骤7或之后,需要调用取消API
|
||||
if ($oldStep >= 7 && !empty($oldConversationId) && !empty($oldChatId)) {
|
||||
try {
|
||||
$cozeAI = new CozeAI();
|
||||
$cancelResult = $cozeAI->cancelConversationChat([
|
||||
'conversation_id' => $oldConversationId,
|
||||
'chat_id' => $oldChatId,
|
||||
]);
|
||||
Log::info("已调用取消API取消旧请求的对话 - conversation_id: {$oldConversationId}, chat_id: {$oldChatId}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("取消旧请求对话失败:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 标记旧请求为已取消(通过更新缓存的 canceled 标志)
|
||||
$cacheData['canceled'] = true;
|
||||
$cacheData['canceled_by'] = $this->requestId;
|
||||
Cache::set($this->requestKey, $cacheData, self::CACHE_EXPIRE);
|
||||
}
|
||||
|
||||
// 设置当前请求为活动请求
|
||||
$newCacheData = [
|
||||
'request_id' => $this->requestId,
|
||||
'step' => 1,
|
||||
'start_time' => time(),
|
||||
'canceled' => false,
|
||||
'conversation_id' => '',
|
||||
'chat_id' => '',
|
||||
];
|
||||
Cache::set($this->requestKey, $newCacheData, self::CACHE_EXPIRE);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前请求是否被新请求取消
|
||||
*
|
||||
* @param string $conversationId 会话ID(可选,用于取消对话)
|
||||
* @param string $chatId 对话ID(可选,用于取消对话)
|
||||
* @return bool
|
||||
*/
|
||||
private function isRequestCanceled($conversationId = '', $chatId = '')
|
||||
{
|
||||
$cacheData = Cache::get($this->requestKey);
|
||||
|
||||
if (!$cacheData) {
|
||||
// 缓存不存在,说明被清理或过期,视为被取消
|
||||
return true;
|
||||
}
|
||||
|
||||
$currentRequestId = $cacheData['request_id'] ?? '';
|
||||
$isCanceled = $cacheData['canceled'] ?? false;
|
||||
|
||||
// 如果缓存中的请求ID与当前请求ID不一致,或者被标记为取消
|
||||
if ($currentRequestId !== $this->requestId || $isCanceled) {
|
||||
Log::info("当前请求已被取消 - 请求ID: {$this->requestId}, 缓存请求ID: {$currentRequestId}, 取消标志: " . ($isCanceled ? 'true' : 'false'));
|
||||
|
||||
// 如果提供了对话ID,尝试取消对话
|
||||
if (!empty($conversationId) && !empty($chatId) && $this->currentStep >= 7) {
|
||||
try {
|
||||
$cozeAI = new CozeAI();
|
||||
$cancelResult = $cozeAI->cancelConversationChat([
|
||||
'conversation_id' => $conversationId,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
Log::info("已取消当前请求的对话 - conversation_id: {$conversationId}, chat_id: {$chatId}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("取消当前请求对话失败:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新请求步骤
|
||||
*
|
||||
* @param int $step 当前步骤
|
||||
* @param string $conversationId 会话ID(可选)
|
||||
* @param string $chatId 对话ID(可选)
|
||||
*/
|
||||
private function updateRequestStep($step, $conversationId = '', $chatId = '')
|
||||
{
|
||||
$this->currentStep = $step;
|
||||
|
||||
$cacheData = Cache::get($this->requestKey);
|
||||
|
||||
if ($cacheData && $cacheData['request_id'] === $this->requestId) {
|
||||
$cacheData['step'] = $step;
|
||||
$cacheData['update_time'] = time();
|
||||
|
||||
if (!empty($conversationId)) {
|
||||
$cacheData['conversation_id'] = $conversationId;
|
||||
}
|
||||
if (!empty($chatId)) {
|
||||
$cacheData['chat_id'] = $chatId;
|
||||
}
|
||||
|
||||
Cache::set($this->requestKey, $cacheData, self::CACHE_EXPIRE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理请求缓存
|
||||
*/
|
||||
private function clearRequestCache()
|
||||
{
|
||||
if (!empty($this->requestKey)) {
|
||||
$cacheData = Cache::get($this->requestKey);
|
||||
|
||||
// 只有当前请求才能清理自己的缓存
|
||||
if ($cacheData && isset($cacheData['request_id']) && $cacheData['request_id'] === $this->requestId) {
|
||||
Cache::rm($this->requestKey);
|
||||
Log::info("已清理请求缓存 - 请求ID: {$this->requestId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function index2222()
|
||||
{
|
||||
|
||||
505
Server/application/chukebao/controller/AiPushController.php
Normal file
505
Server/application/chukebao/controller/AiPushController.php
Normal file
@@ -0,0 +1,505 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\model\AiPush;
|
||||
use app\chukebao\model\AiPushRecord;
|
||||
use app\chukebao\model\AutoGreetings;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
class AiPushController extends BaseController
|
||||
{
|
||||
|
||||
/**
|
||||
* 获取推送列表
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
$where = [
|
||||
['companyId', '=', $companyId],
|
||||
['userId', '=', $userId],
|
||||
['isDel', '=', 0],
|
||||
];
|
||||
|
||||
if (!empty($keyword)) {
|
||||
$where[] = ['name', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
$query = AiPush::where($where);
|
||||
$total = $query->count();
|
||||
$list = $query->where($where)->page($page, $limit)->order('id desc')->select();
|
||||
|
||||
// 处理数据
|
||||
$list = is_array($list) ? $list : $list->toArray();
|
||||
foreach ($list as &$item) {
|
||||
// 解析标签数组
|
||||
$item['tags'] = json_decode($item['tags'], true);
|
||||
if (!is_array($item['tags'])) {
|
||||
$item['tags'] = [];
|
||||
}
|
||||
// 格式化推送时机显示文本
|
||||
$timingTypes = [
|
||||
1 => '立即推送',
|
||||
2 => 'AI最佳时机',
|
||||
3 => '定时推送'
|
||||
];
|
||||
$item['timingText'] = $timingTypes[$item['pushTiming']] ?? '未知';
|
||||
// 处理定时推送时间
|
||||
if ($item['pushTiming'] == 3 && !empty($item['scheduledTime'])) {
|
||||
$item['scheduledTime'] = date('Y-m-d H:i:s', $item['scheduledTime']);
|
||||
} else {
|
||||
$item['scheduledTime'] = '';
|
||||
}
|
||||
// 从记录表计算实际成功率
|
||||
$pushId = $item['id'];
|
||||
$totalCount = Db::name('kf_ai_push_record')
|
||||
->where('pushId', $pushId)
|
||||
->count();
|
||||
$sendCount = Db::name('kf_ai_push_record')
|
||||
->where('pushId', $pushId)
|
||||
->where('isSend', 1)
|
||||
->count();
|
||||
$item['successRate'] = $totalCount > 0 ? round(($sendCount * 100) / $totalCount, 1) : 0;
|
||||
$item['totalPushCount'] = $totalCount; // 推送总数
|
||||
$item['sendCount'] = $sendCount; // 成功发送数
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return ResponseHelper::success(['list' => $list, 'total' => $total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$name = $this->request->param('name', '');
|
||||
$tags = $this->request->param('tags', ''); // 标签,支持逗号分隔的字符串或数组
|
||||
$content = $this->request->param('content', '');
|
||||
$pushTiming = $this->request->param('pushTiming', 1); // 1=立即推送,2=最佳时机(AI决定),3=定时推送
|
||||
$scheduledTime = $this->request->param('scheduledTime', ''); // 定时推送的时间
|
||||
$status = $this->request->param('status', 1);
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($name) || empty($content)) {
|
||||
return ResponseHelper::error('推送名称和推送内容不能为空');
|
||||
}
|
||||
|
||||
// 验证推送时机
|
||||
if (!in_array($pushTiming, [1, 2, 3])) {
|
||||
return ResponseHelper::error('无效的推送时机类型');
|
||||
}
|
||||
|
||||
// 如果是定时推送,需要验证时间
|
||||
if ($pushTiming == 3) {
|
||||
if (empty($scheduledTime)) {
|
||||
return ResponseHelper::error('定时推送需要设置推送时间');
|
||||
}
|
||||
// 验证时间格式
|
||||
$timestamp = strtotime($scheduledTime);
|
||||
if ($timestamp === false || $timestamp <= time()) {
|
||||
return ResponseHelper::error('定时推送时间格式不正确或必须大于当前时间');
|
||||
}
|
||||
} else {
|
||||
$scheduledTime = '';
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
$tagsArray = [];
|
||||
if (!empty($tags)) {
|
||||
if (is_string($tags)) {
|
||||
// 如果是字符串,按逗号分割
|
||||
$tagsArray = array_filter(array_map('trim', explode(',', $tags)));
|
||||
} elseif (is_array($tags)) {
|
||||
$tagsArray = array_filter(array_map('trim', $tags));
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagsArray)) {
|
||||
return ResponseHelper::error('目标用户标签不能为空');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$aiPush = new AiPush();
|
||||
$aiPush->name = $name;
|
||||
$aiPush->tags = json_encode($tagsArray, JSON_UNESCAPED_UNICODE);
|
||||
$aiPush->content = $content;
|
||||
$aiPush->pushTiming = $pushTiming;
|
||||
$aiPush->scheduledTime = $pushTiming == 3 && !empty($scheduledTime) ? strtotime($scheduledTime) : 0;
|
||||
$aiPush->status = $status;
|
||||
$aiPush->successRate = 0; // 初始成功率为0
|
||||
$aiPush->userId = $userId;
|
||||
$aiPush->companyId = $companyId;
|
||||
$aiPush->createTime = time();
|
||||
$aiPush->updateTime = time();
|
||||
$aiPush->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success(['id' => $aiPush->id], '创建成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('创建失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function details()
|
||||
{
|
||||
$id = $this->request->param('id', '');
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
$data = AiPush::where(['id' => $id, 'isDel' => 0, 'userId' => $userId, 'companyId' => $companyId])->find();
|
||||
if (empty($data)) {
|
||||
return ResponseHelper::error('该推送已被删除或者不存在');
|
||||
}
|
||||
|
||||
$data = $data->toArray();
|
||||
// 解析标签数组
|
||||
$data['tags'] = json_decode($data['tags'], true);
|
||||
if (!is_array($data['tags'])) {
|
||||
$data['tags'] = [];
|
||||
}
|
||||
// 标签转为逗号分隔的字符串(用于编辑时回显)
|
||||
$data['tagsString'] = implode(',', $data['tags']);
|
||||
|
||||
// 处理定时推送时间
|
||||
if ($data['pushTiming'] == 3 && !empty($data['scheduledTime'])) {
|
||||
$data['scheduledTime'] = date('Y-m-d H:i:s', $data['scheduledTime']);
|
||||
} else {
|
||||
$data['scheduledTime'] = '';
|
||||
}
|
||||
|
||||
// 成功率保留一位小数
|
||||
$data['successRate'] = isset($data['successRate']) ? round($data['successRate'], 1) : 0;
|
||||
|
||||
return ResponseHelper::success($data, '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function del()
|
||||
{
|
||||
$id = $this->request->param('id', '');
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
$data = AiPush::where(['id' => $id, 'isDel' => 0, 'userId' => $userId, 'companyId' => $companyId])->find();
|
||||
if (empty($data)) {
|
||||
return ResponseHelper::error('该推送已被删除或者不存在');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$data->isDel = 1;
|
||||
$data->delTime = time();
|
||||
$data->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', '删除成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('删除失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id', '');
|
||||
$name = $this->request->param('name', '');
|
||||
$tags = $this->request->param('tags', '');
|
||||
$content = $this->request->param('content', '');
|
||||
$pushTiming = $this->request->param('pushTiming', 1);
|
||||
$scheduledTime = $this->request->param('scheduledTime', '');
|
||||
$status = $this->request->param('status', 1);
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($id) || empty($name) || empty($content)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
// 验证推送时机
|
||||
if (!in_array($pushTiming, [1, 2, 3])) {
|
||||
return ResponseHelper::error('无效的推送时机类型');
|
||||
}
|
||||
|
||||
// 如果是定时推送,需要验证时间
|
||||
if ($pushTiming == 3) {
|
||||
if (empty($scheduledTime)) {
|
||||
return ResponseHelper::error('定时推送需要设置推送时间');
|
||||
}
|
||||
// 验证时间格式
|
||||
$timestamp = strtotime($scheduledTime);
|
||||
if ($timestamp === false || $timestamp <= time()) {
|
||||
return ResponseHelper::error('定时推送时间格式不正确或必须大于当前时间');
|
||||
}
|
||||
} else {
|
||||
$scheduledTime = '';
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
$tagsArray = [];
|
||||
if (!empty($tags)) {
|
||||
if (is_string($tags)) {
|
||||
$tagsArray = array_filter(array_map('trim', explode(',', $tags)));
|
||||
} elseif (is_array($tags)) {
|
||||
$tagsArray = array_filter(array_map('trim', $tags));
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagsArray)) {
|
||||
return ResponseHelper::error('目标用户标签不能为空');
|
||||
}
|
||||
|
||||
$query = AiPush::where(['id' => $id, 'isDel' => 0, 'userId' => $userId, 'companyId' => $companyId])->find();
|
||||
if (empty($query)) {
|
||||
return ResponseHelper::error('该推送已被删除或者不存在');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$query->name = $name;
|
||||
$query->tags = json_encode($tagsArray, JSON_UNESCAPED_UNICODE);
|
||||
$query->content = $content;
|
||||
$query->pushTiming = $pushTiming;
|
||||
$query->scheduledTime = $pushTiming == 3 && !empty($scheduledTime) ? strtotime($scheduledTime) : 0;
|
||||
$query->status = $status;
|
||||
$query->updateTime = time();
|
||||
$query->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', '修改成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('修改失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function setStatus()
|
||||
{
|
||||
$id = $this->request->param('id', '');
|
||||
$status = $this->request->param('status', 1);
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
if (!in_array($status, [0, 1])) {
|
||||
return ResponseHelper::error('状态值无效');
|
||||
}
|
||||
|
||||
$data = AiPush::where(['id' => $id, 'isDel' => 0, 'userId' => $userId, 'companyId' => $companyId])->find();
|
||||
if (empty($data)) {
|
||||
return ResponseHelper::error('该推送已被删除或者不存在');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$data->status = $status;
|
||||
$data->updateTime = time();
|
||||
$data->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', $status == 1 ? '启用成功' : '禁用成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('操作失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计概览(整合自动问候和AI推送)
|
||||
* - 活跃规则(自动问候规则,近30天)
|
||||
* - 总触发次数(自动问候记录总数)
|
||||
* - AI推送成功率(AI推送的成功率)
|
||||
* - AI智能推送(AI推送规则,近30天活跃)
|
||||
* - 规则效果排行(自动问候规则,按使用次数排序)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$userId = $this->getUserInfo('id');
|
||||
|
||||
$start30d = time() - 30 * 24 * 3600;
|
||||
|
||||
try {
|
||||
// 公司维度(用于除排行外的统计)
|
||||
$companyWhere = [
|
||||
['companyId', '=', $companyId],
|
||||
];
|
||||
// 排行维度(限定个人)
|
||||
$rankingWhere = [
|
||||
['companyId', '=', $companyId],
|
||||
['userId', '=', $userId],
|
||||
];
|
||||
|
||||
// ========== 自动问候统计 ==========
|
||||
|
||||
// 1) 活跃规则(自动问候规则,近30天有记录的)
|
||||
$activeRules = Db::name('kf_auto_greetings_record')
|
||||
->where($companyWhere)
|
||||
->where('createTime', '>=', $start30d)
|
||||
->distinct(true)
|
||||
->count('autoId');
|
||||
|
||||
// 2) 总触发次数(自动问候记录总数)
|
||||
$totalTriggers = Db::name('kf_auto_greetings_record')
|
||||
->where($companyWhere)
|
||||
->count();
|
||||
|
||||
// ========== AI推送统计 ==========
|
||||
|
||||
// 3) AI推送成功率
|
||||
$totalPushes = Db::name('kf_ai_push_record')
|
||||
->where($companyWhere)
|
||||
->count();
|
||||
$sendCount = Db::name('kf_ai_push_record')
|
||||
->where($companyWhere)
|
||||
->where('isSend', '=', 1)
|
||||
->count();
|
||||
// 成功率:百分比,保留整数(75%)
|
||||
$aiPushSuccessRate = $totalPushes > 0 ? round(($sendCount * 100) / $totalPushes, 0) : 0;
|
||||
|
||||
// 4) AI智能推送(AI推送规则,近30天活跃的)
|
||||
$aiPushCount = Db::name('kf_ai_push_record')
|
||||
->where($companyWhere)
|
||||
->where('createTime', '>=', $start30d)
|
||||
->distinct(true)
|
||||
->count('pushId');
|
||||
|
||||
// ========== 规则效果排行(自动问候规则,按使用次数排序)==========
|
||||
$ruleRanking = Db::name('kf_auto_greetings_record')
|
||||
->where($rankingWhere)
|
||||
->field([
|
||||
'autoId AS id',
|
||||
'COUNT(*) AS usageCount'
|
||||
])
|
||||
->group('autoId')
|
||||
->order('usageCount DESC')
|
||||
->limit(20)
|
||||
->select();
|
||||
|
||||
// 附加规则名称和触发类型
|
||||
$autoIds = array_values(array_unique(array_column($ruleRanking, 'id')));
|
||||
$autoIdToRule = [];
|
||||
if (!empty($autoIds)) {
|
||||
$rules = AutoGreetings::where([['id', 'in', $autoIds]])
|
||||
->field('id,name,trigger')
|
||||
->select();
|
||||
foreach ($rules as $rule) {
|
||||
$triggerTypes = [
|
||||
1 => '新好友',
|
||||
2 => '首次发消息',
|
||||
3 => '时间触发',
|
||||
4 => '关键词',
|
||||
5 => '生日触发',
|
||||
6 => '自定义'
|
||||
];
|
||||
$autoIdToRule[$rule['id']] = [
|
||||
'name' => $rule['name'],
|
||||
'trigger' => $rule['trigger'],
|
||||
'triggerText' => $triggerTypes[$rule['trigger']] ?? '未知',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ruleRanking as &$row) {
|
||||
$row['usageCount'] = (int)($row['usageCount'] ?? 0);
|
||||
$row['name'] = $autoIdToRule[$row['id']]['name'] ?? '';
|
||||
$row['trigger'] = $autoIdToRule[$row['id']]['trigger'] ?? null;
|
||||
$row['triggerText'] = $autoIdToRule[$row['id']]['triggerText'] ?? '';
|
||||
// 格式化使用次数显示
|
||||
$row['usageCountText'] = $row['usageCount'] . ' 次';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// 更新主表中的成功率字段(异步或定期更新)
|
||||
$this->updatePushSuccessRate($companyId);
|
||||
|
||||
return ResponseHelper::success([
|
||||
'activeRules' => (int)$activeRules,
|
||||
'totalTriggers' => (int)$totalTriggers,
|
||||
'aiPushSuccessRate' => (int)$aiPushSuccessRate,
|
||||
'aiPushCount' => (int)$aiPushCount,
|
||||
'ruleRanking' => $ruleRanking,
|
||||
], '统计成功');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('统计失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新推送表的成功率字段
|
||||
* @param int $companyId
|
||||
* @return void
|
||||
*/
|
||||
private function updatePushSuccessRate($companyId)
|
||||
{
|
||||
try {
|
||||
// 获取所有启用的推送
|
||||
$pushes = AiPush::where([
|
||||
['companyId', '=', $companyId],
|
||||
['isDel', '=', 0]
|
||||
])->field('id')->select();
|
||||
|
||||
foreach ($pushes as $push) {
|
||||
$pushId = $push['id'];
|
||||
$totalCount = Db::name('kf_ai_push_record')
|
||||
->where('pushId', $pushId)
|
||||
->count();
|
||||
$sendCount = Db::name('kf_ai_push_record')
|
||||
->where('pushId', $pushId)
|
||||
->where('isSend', 1)
|
||||
->count();
|
||||
|
||||
$successRate = $totalCount > 0 ? round(($sendCount * 100) / $totalCount, 2) : 0.00;
|
||||
|
||||
AiPush::where('id', $pushId)->update([
|
||||
'successRate' => $successRate,
|
||||
'updateTime' => time()
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,19 @@ use think\Db;
|
||||
class AutoGreetingsController extends BaseController
|
||||
{
|
||||
|
||||
/**
|
||||
* 获取问候规则列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getList(){
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
$is_template = $this->request->param('is_template', 0);
|
||||
$triggerType = $this->request->param('triggerType', ''); // 触发类型筛选
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
|
||||
if($is_template == 1){
|
||||
$where = [
|
||||
['is_template','=',1],
|
||||
@@ -31,20 +35,46 @@ class AutoGreetingsController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if(!empty($keyword)){
|
||||
$where[] = ['name','like','%'.$keyword.'%'];
|
||||
}
|
||||
|
||||
if(!empty($triggerType)){
|
||||
$where[] = ['trigger','=',$triggerType];
|
||||
}
|
||||
|
||||
$query = AutoGreetings::where($where);
|
||||
$total = $query->count();
|
||||
$list = $query->where($where)->page($page,$limit)->order('id desc')->select();
|
||||
$list = $query->where($where)->page($page,$limit)->order('level asc,id desc')->select();
|
||||
|
||||
// 获取使用次数
|
||||
$list = is_array($list) ? $list : $list->toArray();
|
||||
$ids = array_column($list, 'id');
|
||||
$usageCounts = [];
|
||||
if (!empty($ids)) {
|
||||
$counts = Db::name('kf_auto_greetings_record')
|
||||
->where('autoId', 'in', $ids)
|
||||
->field('autoId, COUNT(*) as count')
|
||||
->group('autoId')
|
||||
->select();
|
||||
foreach ($counts as $count) {
|
||||
$usageCounts[$count['autoId']] = (int)$count['count'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$item['trigger'] = json_decode($item['trigger'],true);
|
||||
$item['condition'] = json_decode($item['condition'], true);
|
||||
$item['usageCount'] = $usageCounts[$item['id']] ?? 0;
|
||||
// 格式化触发类型显示文本
|
||||
$triggerTypes = [
|
||||
1 => '新好友',
|
||||
2 => '首次发消息',
|
||||
3 => '时间触发',
|
||||
4 => '关键词触发',
|
||||
5 => '生日触发',
|
||||
6 => '自定义'
|
||||
];
|
||||
$item['triggerText'] = $triggerTypes[$item['trigger']] ?? '未知';
|
||||
}
|
||||
unset($item);
|
||||
|
||||
@@ -52,6 +82,315 @@ class AutoGreetingsController extends BaseController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 校验trigger类型对应的condition
|
||||
* @param int $trigger 触发类型
|
||||
* @param mixed $condition 条件参数
|
||||
* @return array|string 返回处理后的condition数组,或错误信息字符串
|
||||
*/
|
||||
private function validateTriggerCondition($trigger, $condition)
|
||||
{
|
||||
// trigger类型:1=新好友,2=首次发消息,3=时间触发,4=关键词触发,5=生日触发,6=自定义
|
||||
switch ($trigger) {
|
||||
case 1: // 新好友
|
||||
// 不需要condition
|
||||
return [];
|
||||
|
||||
case 2: // 首次发消息
|
||||
// 不需要condition
|
||||
return [];
|
||||
|
||||
case 3: // 时间触发
|
||||
// 需要condition,格式为:{"type": "daily_time|yearly_datetime|fixed_range|workday", "value": "..."}
|
||||
if (empty($condition)) {
|
||||
return '时间触发类型需要配置具体的触发条件';
|
||||
}
|
||||
$condition = is_array($condition) ? $condition : json_decode($condition, true);
|
||||
if (empty($condition) || !is_array($condition)) {
|
||||
return '时间触发类型的条件格式不正确,应为数组格式';
|
||||
}
|
||||
|
||||
// 验证必须包含type字段
|
||||
if (!isset($condition['type']) || empty($condition['type'])) {
|
||||
return '时间触发类型必须指定触发方式:daily_time(每天固定时间)、yearly_datetime(每年固定日期时间)、fixed_range(固定时间段)、workday(工作日)';
|
||||
}
|
||||
|
||||
$timeType = $condition['type'];
|
||||
$allowedTypes = ['daily_time', 'yearly_datetime', 'fixed_range', 'workday'];
|
||||
// 兼容旧版本的 fixed_time,自动转换为 daily_time
|
||||
if ($timeType === 'fixed_time') {
|
||||
$timeType = 'daily_time';
|
||||
}
|
||||
if (!in_array($timeType, $allowedTypes)) {
|
||||
return '时间触发类型无效,必须为:daily_time(每天固定时间)、yearly_datetime(每年固定日期时间)、fixed_range(固定时间段)、workday(工作日)';
|
||||
}
|
||||
|
||||
// 根据不同的type验证value
|
||||
switch ($timeType) {
|
||||
case 'daily_time': // 每天固定时间(每天的几点几分)
|
||||
// value应该是时间字符串,格式:HH:mm,如 "14:30"
|
||||
if (!isset($condition['value']) || empty($condition['value'])) {
|
||||
return '每天固定时间类型需要配置具体时间,格式:HH:mm(如 14:30)';
|
||||
}
|
||||
$timeValue = $condition['value'];
|
||||
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $timeValue)) {
|
||||
return '每天固定时间格式不正确,应为 HH:mm 格式(如 14:30)';
|
||||
}
|
||||
return [
|
||||
'type' => 'daily_time',
|
||||
'value' => $timeValue
|
||||
];
|
||||
|
||||
case 'yearly_datetime': // 每年固定日期时间(每年的几月几号几点几分)
|
||||
// value应该是日期时间字符串,格式:MM-dd HH:mm,如 "12-25 14:30"
|
||||
if (!isset($condition['value']) || empty($condition['value'])) {
|
||||
return '每年固定日期时间类型需要配置具体日期和时间,格式:MM-dd HH:mm(如 12-25 14:30)';
|
||||
}
|
||||
$datetimeValue = $condition['value'];
|
||||
// 验证格式:MM-dd HH:mm
|
||||
if (!preg_match('/^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $datetimeValue)) {
|
||||
return '每年固定日期时间格式不正确,应为 MM-dd HH:mm 格式(如 12-25 14:30)';
|
||||
}
|
||||
// 进一步验证日期是否有效(例如2月30日不存在)
|
||||
list($datePart, $timePart) = explode(' ', $datetimeValue);
|
||||
list($month, $day) = explode('-', $datePart);
|
||||
if (!checkdate((int)$month, (int)$day, 2000)) { // 使用2000年作为参考年份验证日期有效性
|
||||
return '日期无效,请检查月份和日期是否正确(如2月不能有30日)';
|
||||
}
|
||||
return [
|
||||
'type' => 'yearly_datetime',
|
||||
'value' => $datetimeValue
|
||||
];
|
||||
|
||||
case 'fixed_range': // 固定时间段
|
||||
// value应该是时间段数组,格式:["09:00", "18:00"]
|
||||
if (!isset($condition['value']) || !is_array($condition['value'])) {
|
||||
return '固定时间段类型需要配置时间段,格式:["开始时间", "结束时间"](如 ["09:00", "18:00"])';
|
||||
}
|
||||
$rangeValue = $condition['value'];
|
||||
if (count($rangeValue) !== 2) {
|
||||
return '固定时间段应为包含两个时间点的数组,格式:["09:00", "18:00"]';
|
||||
}
|
||||
// 验证时间格式
|
||||
foreach ($rangeValue as $time) {
|
||||
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $time)) {
|
||||
return '时间段格式不正确,应为 HH:mm 格式(如 09:00)';
|
||||
}
|
||||
}
|
||||
// 验证开始时间小于结束时间
|
||||
$startTime = strtotime('2000-01-01 ' . $rangeValue[0]);
|
||||
$endTime = strtotime('2000-01-01 ' . $rangeValue[1]);
|
||||
if ($startTime >= $endTime) {
|
||||
return '开始时间必须小于结束时间';
|
||||
}
|
||||
return [
|
||||
'type' => 'fixed_range',
|
||||
'value' => $rangeValue
|
||||
];
|
||||
|
||||
case 'workday': // 工作日
|
||||
// 工作日需要配置时间,格式:HH:mm(如 09:00)
|
||||
if (!isset($condition['value']) || empty($condition['value'])) {
|
||||
return '工作日触发类型需要配置时间,格式:HH:mm(如 09:00)';
|
||||
}
|
||||
$timeValue = trim($condition['value']);
|
||||
// 验证格式:HH:mm
|
||||
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $timeValue)) {
|
||||
return '工作日时间格式不正确,应为 HH:mm 格式(如 09:00)';
|
||||
}
|
||||
return [
|
||||
'type' => 'workday',
|
||||
'value' => $timeValue
|
||||
];
|
||||
|
||||
default:
|
||||
return '时间触发类型无效';
|
||||
}
|
||||
|
||||
case 4: // 关键词触发
|
||||
// 需要condition,格式:{"keywords": ["关键词1", "关键词2"], "match_type": "exact|fuzzy"}
|
||||
if (empty($condition)) {
|
||||
return '关键词触发类型需要配置至少一个关键词';
|
||||
}
|
||||
|
||||
// 如果是字符串,尝试解析JSON
|
||||
if (is_string($condition)) {
|
||||
$decoded = json_decode($condition, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$condition = $decoded;
|
||||
} else {
|
||||
return '关键词触发类型格式错误,应为对象格式:{"keywords": ["关键词1", "关键词2"], "match_type": "exact|fuzzy"}';
|
||||
}
|
||||
}
|
||||
|
||||
// 必须是对象格式
|
||||
if (!is_array($condition) || !isset($condition['keywords'])) {
|
||||
return '关键词触发类型格式错误,应为对象格式:{"keywords": ["关键词1", "关键词2"], "match_type": "exact|fuzzy"}';
|
||||
}
|
||||
|
||||
$keywords = $condition['keywords'];
|
||||
$matchType = isset($condition['match_type']) ? $condition['match_type'] : 'fuzzy';
|
||||
|
||||
// 验证match_type
|
||||
if (!in_array($matchType, ['exact', 'fuzzy'])) {
|
||||
return '匹配类型无效,必须为:exact(精准匹配)或 fuzzy(模糊匹配)';
|
||||
}
|
||||
|
||||
// 处理keywords
|
||||
if (is_string($keywords)) {
|
||||
$keywords = explode(',', $keywords);
|
||||
}
|
||||
if (!is_array($keywords)) {
|
||||
return '关键词格式不正确,应为数组格式';
|
||||
}
|
||||
|
||||
// 过滤空值并去重
|
||||
$keywords = array_filter(array_map('trim', $keywords));
|
||||
if (empty($keywords)) {
|
||||
return '关键词触发类型需要配置至少一个关键词';
|
||||
}
|
||||
|
||||
// 验证每个关键词不为空
|
||||
foreach ($keywords as $keyword) {
|
||||
if (empty($keyword)) {
|
||||
return '关键词不能为空';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'keywords' => array_values($keywords),
|
||||
'match_type' => $matchType
|
||||
];
|
||||
|
||||
case 5: // 生日触发
|
||||
// 需要condition,格式支持:
|
||||
// 1. 月日字符串:'10-10' 或 '10-10 09:00'(MM-DD格式,不包含年份)
|
||||
// 2. 对象格式:{'month': 10, 'day': 10, 'time': '09:00'} 或 {'month': '10', 'day': '10', 'time_range': ['09:00', '10:00']}
|
||||
if (empty($condition)) {
|
||||
return '生日触发类型需要配置日期条件';
|
||||
}
|
||||
|
||||
// 如果是字符串,只接受 MM-DD 格式(不包含年份)
|
||||
if (is_string($condition)) {
|
||||
// 检查是否包含时间部分
|
||||
if (preg_match('/^(\d{1,2})-(\d{1,2})\s+(\d{2}:\d{2})$/', $condition, $matches)) {
|
||||
// 格式:'10-10 09:00'
|
||||
$month = (int)$matches[1];
|
||||
$day = (int)$matches[2];
|
||||
if ($month < 1 || $month > 12 || $day < 1 || $day > 31) {
|
||||
return '生日日期格式不正确,月份应为1-12,日期应为1-31';
|
||||
}
|
||||
return [
|
||||
'month' => $month,
|
||||
'day' => $day,
|
||||
'time' => $matches[3]
|
||||
];
|
||||
} elseif (preg_match('/^(\d{1,2})-(\d{1,2})$/', $condition, $matches)) {
|
||||
// 格式:'10-10'(不指定时间,当天任何时间都可以触发)
|
||||
$month = (int)$matches[1];
|
||||
$day = (int)$matches[2];
|
||||
if ($month < 1 || $month > 12 || $day < 1 || $day > 31) {
|
||||
return '生日日期格式不正确,月份应为1-12,日期应为1-31';
|
||||
}
|
||||
return [
|
||||
'month' => $month,
|
||||
'day' => $day
|
||||
];
|
||||
} else {
|
||||
return '生日日期格式不正确,应为 MM-DD 或 MM-DD HH:mm 格式(如 10-10 或 10-10 09:00),不包含年份';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是数组,可能是对象格式或旧格式
|
||||
if (is_array($condition)) {
|
||||
// 检查是否是旧格式(仅兼容 MM-DD 格式的数组)
|
||||
if (isset($condition[0]) && is_string($condition[0])) {
|
||||
$dateStr = $condition[0];
|
||||
// 只接受 MM-DD 格式:'10-10' 或 '10-10 09:00'
|
||||
if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $dateStr, $matches)) {
|
||||
$month = (int)$matches[1];
|
||||
$day = (int)$matches[2];
|
||||
if ($month < 1 || $month > 12 || $day < 1 || $day > 31) {
|
||||
return '生日日期格式不正确,月份应为1-12,日期应为1-31';
|
||||
}
|
||||
if (isset($matches[3])) {
|
||||
return [
|
||||
'month' => $month,
|
||||
'day' => $day,
|
||||
'time' => $matches[3]
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'month' => $month,
|
||||
'day' => $day
|
||||
];
|
||||
}
|
||||
} else {
|
||||
return '生日日期格式不正确,应为 MM-DD 格式(如 10-10),不包含年份';
|
||||
}
|
||||
}
|
||||
|
||||
// 新格式:{'month': 10, 'day': 10, 'time': '09:00'}
|
||||
if (isset($condition['month']) && isset($condition['day'])) {
|
||||
$month = (int)$condition['month'];
|
||||
$day = (int)$condition['day'];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
return '生日月份格式不正确,应为1-12';
|
||||
}
|
||||
if ($day < 1 || $day > 31) {
|
||||
return '生日日期格式不正确,应为1-31';
|
||||
}
|
||||
|
||||
$result = [
|
||||
'month' => $month,
|
||||
'day' => $day
|
||||
];
|
||||
|
||||
// 检查是否配置了时间
|
||||
if (isset($condition['time']) && !empty($condition['time'])) {
|
||||
$time = trim($condition['time']);
|
||||
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $time)) {
|
||||
return '生日时间格式不正确,应为 HH:mm 格式(如 09:00)';
|
||||
}
|
||||
$result['time'] = $time;
|
||||
}
|
||||
|
||||
// 检查是否配置了时间范围
|
||||
if (isset($condition['time_range']) && is_array($condition['time_range']) && count($condition['time_range']) === 2) {
|
||||
$startTime = trim($condition['time_range'][0]);
|
||||
$endTime = trim($condition['time_range'][1]);
|
||||
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $startTime) ||
|
||||
!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $endTime)) {
|
||||
return '生日时间范围格式不正确,应为 ["HH:mm", "HH:mm"] 格式';
|
||||
}
|
||||
$result['time_range'] = [$startTime, $endTime];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return '生日触发条件格式不正确,需要提供month和day字段';
|
||||
}
|
||||
|
||||
return '生日触发条件格式不正确';
|
||||
|
||||
case 6: // 自定义
|
||||
// 自定义类型,condition可选,如果有则必须是数组格式
|
||||
if (!empty($condition)) {
|
||||
$condition = is_array($condition) ? $condition : json_decode($condition, true);
|
||||
if (!is_array($condition)) {
|
||||
return '自定义类型的条件格式不正确,应为数组格式';
|
||||
}
|
||||
return $condition;
|
||||
}
|
||||
return [];
|
||||
|
||||
default:
|
||||
return '无效的触发类型';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加
|
||||
* @return \think\response\Json
|
||||
@@ -71,17 +410,18 @@ class AutoGreetingsController extends BaseController
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
if (in_array($trigger,[2,3]) && empty($condition)){
|
||||
return ResponseHelper::error('具体条件不能为空');
|
||||
// 校验trigger类型
|
||||
if (!in_array($trigger, [1, 2, 3, 4, 5, 6])) {
|
||||
return ResponseHelper::error('无效的触发类型');
|
||||
}
|
||||
|
||||
if ($trigger == 2){
|
||||
$condition = !empty($condition) ? $condition : [];
|
||||
}
|
||||
|
||||
if ($trigger == 3){
|
||||
$condition = explode(',',$condition);
|
||||
// 校验并处理condition
|
||||
$conditionResult = $this->validateTriggerCondition($trigger, $condition);
|
||||
if (is_string($conditionResult)) {
|
||||
// 返回的是错误信息
|
||||
return ResponseHelper::error($conditionResult);
|
||||
}
|
||||
$condition = $conditionResult;
|
||||
|
||||
|
||||
Db::startTrans();
|
||||
@@ -97,9 +437,10 @@ class AutoGreetingsController extends BaseController
|
||||
$AutoGreetings->companyId = $companyId;
|
||||
$AutoGreetings->updateTime = time();
|
||||
$AutoGreetings->createTime = time();
|
||||
$AutoGreetings->usageCount = 0; // 初始化使用次数为0
|
||||
$AutoGreetings->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success(' ','创建成功');
|
||||
return ResponseHelper::success(['id' => $AutoGreetings->id],'创建成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('创建失败:'.$e->getMessage());
|
||||
@@ -128,11 +469,13 @@ class AutoGreetingsController extends BaseController
|
||||
|
||||
|
||||
$data['condition'] = json_decode($data['condition'],true);
|
||||
if ($data['trigger'] == 3){
|
||||
$data['condition'] = implode(',',$data['condition']);
|
||||
}
|
||||
|
||||
// 获取使用次数
|
||||
$usageCount = Db::name('kf_auto_greetings_record')
|
||||
->where('autoId', $id)
|
||||
->count();
|
||||
$data['usageCount'] = (int)$usageCount;
|
||||
|
||||
unset($data['createTime'],$data['updateTime'],$data['isDel'],$data['delTime']);
|
||||
return ResponseHelper::success($data,'获取成功');
|
||||
}
|
||||
|
||||
@@ -175,7 +518,7 @@ class AutoGreetingsController extends BaseController
|
||||
$id = $this->request->param('id', '');
|
||||
$name = $this->request->param('name', '');
|
||||
$trigger = $this->request->param('trigger', 0);
|
||||
$condition = $this->request->param('condition', []);
|
||||
$condition = $this->request->param('condition', '');
|
||||
$content = $this->request->param('content', '');
|
||||
$level = $this->request->param('level', 0);
|
||||
$status = $this->request->param('status', 1);
|
||||
@@ -186,17 +529,18 @@ class AutoGreetingsController extends BaseController
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
if (in_array($trigger,[2,3]) && empty($condition)){
|
||||
return ResponseHelper::error('具体条件不能为空');
|
||||
// 校验trigger类型
|
||||
if (!in_array($trigger, [1, 2, 3, 4, 5, 6])) {
|
||||
return ResponseHelper::error('无效的触发类型');
|
||||
}
|
||||
|
||||
if ($trigger == 2){
|
||||
$condition = !empty($condition) ? $condition : [];
|
||||
}
|
||||
|
||||
if ($trigger == 3){
|
||||
$condition = explode(',',$condition);
|
||||
// 校验并处理condition
|
||||
$conditionResult = $this->validateTriggerCondition($trigger, $condition);
|
||||
if (is_string($conditionResult)) {
|
||||
// 返回的是错误信息
|
||||
return ResponseHelper::error($conditionResult);
|
||||
}
|
||||
$condition = $conditionResult;
|
||||
|
||||
|
||||
$query = AutoGreetings::where(['id'=>$id,'isDel' => 0,'userId' => $userId,'companyId' => $companyId])->find();
|
||||
@@ -244,11 +588,16 @@ class AutoGreetingsController extends BaseController
|
||||
}
|
||||
Db::startTrans();
|
||||
try {
|
||||
$query->status = !empty($query['status']) ? 0 : 1;
|
||||
$status = $this->request->param('status', '');
|
||||
if ($status !== '') {
|
||||
$query->status = (int)$status;
|
||||
} else {
|
||||
$query->status = $query->status == 1 ? 0 : 1;
|
||||
}
|
||||
$query->updateTime = time();
|
||||
$query->save();
|
||||
Db::commit();
|
||||
return ResponseHelper::success(' ','修改成功');
|
||||
return ResponseHelper::success(['status' => $query->status],'修改成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('修改失败:'.$e->getMessage());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\chukebao\model\FriendSettings;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
@@ -30,7 +31,7 @@ class MessageController extends BaseController
|
||||
|
||||
// 优化后的查询:使用MySQL兼容的查询方式
|
||||
$unionQuery = "
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId
|
||||
FROM s2_wechat_chatroom wc
|
||||
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
|
||||
INNER JOIN (
|
||||
@@ -42,7 +43,7 @@ class MessageController extends BaseController
|
||||
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
|
||||
)
|
||||
UNION ALL
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId
|
||||
FROM s2_wechat_message m
|
||||
INNER JOIN (
|
||||
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
|
||||
@@ -98,6 +99,10 @@ class MessageController extends BaseController
|
||||
->column('COUNT(*) AS cnt', 'wechatChatroomId');
|
||||
}
|
||||
|
||||
$aiTypeData = [];
|
||||
if (!empty($friendIds)) {
|
||||
$aiTypeData = FriendSettings::where('friendId', 'in', $friendIds)->column('friendId,type');
|
||||
}
|
||||
|
||||
foreach ($list as $k => &$v) {
|
||||
|
||||
@@ -106,6 +111,7 @@ class MessageController extends BaseController
|
||||
|
||||
|
||||
$unreadCount = 0;
|
||||
$v['aiType'] = 0;
|
||||
if (!empty($v['wechatFriendId'])) {
|
||||
$v['nickname'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['nickname'] : '';
|
||||
$v['avatar'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['avatar'] : '';
|
||||
@@ -115,6 +121,7 @@ class MessageController extends BaseController
|
||||
$v['wechatId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['wechatId'] : '';
|
||||
$v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : [];
|
||||
$unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0;
|
||||
$v['aiType'] = isset($aiTypeData[$v['wechatFriendId']]) ? $aiTypeData[$v['wechatFriendId']] : 0;
|
||||
unset($v['chatroomId']);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,51 @@ class WechatChatroomController extends BaseController
|
||||
return ResponseHelper::success(['list'=>$list,'total'=>$total]);
|
||||
}
|
||||
|
||||
public function getDetail(){
|
||||
$id = input('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
return ResponseHelper::error('聊天室ID不能为空');
|
||||
}
|
||||
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)){
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
$detail = Db::table('s2_wechat_chatroom')
|
||||
->where(['accountId' => $accountId, 'id' => $id, 'isDeleted' => 0])
|
||||
->find();
|
||||
|
||||
if (!$detail) {
|
||||
return ResponseHelper::error('聊天室不存在或无权限访问');
|
||||
}
|
||||
|
||||
// 处理时间格式
|
||||
$detail['createTime'] = !empty($detail['createTime']) ? date('Y-m-d H:i:s', $detail['createTime']) : '';
|
||||
$detail['updateTime'] = !empty($detail['updateTime']) ? date('Y-m-d H:i:s', $detail['updateTime']) : '';
|
||||
|
||||
// 查询未读消息数量
|
||||
$unreadCount = Db::table('s2_wechat_message')
|
||||
->where('wechatChatroomId', $id)
|
||||
->where('isRead', 0)
|
||||
->count();
|
||||
|
||||
// 查询最新消息
|
||||
$latestMessage = Db::table('s2_wechat_message')
|
||||
->where('wechatChatroomId', $id)
|
||||
->order('id desc')
|
||||
->find();
|
||||
|
||||
$config = [
|
||||
'unreadCount' => $unreadCount,
|
||||
'chat' => !empty($latestMessage),
|
||||
'msgTime' => isset($latestMessage['wechatTime']) ? $latestMessage['wechatTime'] : 0
|
||||
];
|
||||
$detail['config'] = $config;
|
||||
|
||||
return ResponseHelper::success($detail);
|
||||
}
|
||||
|
||||
public function aiAnnouncement()
|
||||
{
|
||||
|
||||
@@ -24,50 +24,50 @@ class WechatFriendController extends BaseController
|
||||
$list = $query->page($page, $limit)->select();
|
||||
|
||||
|
||||
/* // 提取所有好友ID
|
||||
// 提取所有好友ID
|
||||
$friendIds = array_column($list, 'id');
|
||||
|
||||
// 一次性查询所有好友的未读消息数量
|
||||
$unreadCounts = [];
|
||||
if (!empty($friendIds)) {
|
||||
$unreadResults = Db::table('s2_wechat_message')
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->where('wechatFriendId', 'in', $friendIds)
|
||||
->where('isRead', 0)
|
||||
->group('wechatFriendId')
|
||||
->select();
|
||||
if (!empty($unreadResults)) {
|
||||
foreach ($unreadResults as $result) {
|
||||
$unreadCounts[$result['wechatFriendId']] = $result['count'];
|
||||
}
|
||||
}
|
||||
}
|
||||
/* // 一次性查询所有好友的未读消息数量
|
||||
$unreadCounts = [];
|
||||
if (!empty($friendIds)) {
|
||||
$unreadResults = Db::table('s2_wechat_message')
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->where('wechatFriendId', 'in', $friendIds)
|
||||
->where('isRead', 0)
|
||||
->group('wechatFriendId')
|
||||
->select();
|
||||
if (!empty($unreadResults)) {
|
||||
foreach ($unreadResults as $result) {
|
||||
$unreadCounts[$result['wechatFriendId']] = $result['count'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一次性查询所有好友的最新消息
|
||||
$latestMessages = [];
|
||||
if (!empty($friendIds)) {
|
||||
// 使用子查询获取每个好友的最新消息ID
|
||||
$subQuery = Db::table('s2_wechat_message')
|
||||
->field('MAX(id) as max_id, wechatFriendId')
|
||||
->where('wechatFriendId', 'in', $friendIds)
|
||||
->group('wechatFriendId')
|
||||
->buildSql();
|
||||
// 一次性查询所有好友的最新消息
|
||||
$latestMessages = [];
|
||||
if (!empty($friendIds)) {
|
||||
// 使用子查询获取每个好友的最新消息ID
|
||||
$subQuery = Db::table('s2_wechat_message')
|
||||
->field('MAX(id) as max_id, wechatFriendId')
|
||||
->where('wechatFriendId', 'in', $friendIds)
|
||||
->group('wechatFriendId')
|
||||
->buildSql();
|
||||
|
||||
if (!empty($subQuery)) {
|
||||
// 查询最新消息的详细信息
|
||||
$messageResults = Db::table('s2_wechat_message')
|
||||
->alias('m')
|
||||
->join([$subQuery => 'sub'], 'm.id = sub.max_id')
|
||||
->field('m.*, sub.wechatFriendId')
|
||||
->select();
|
||||
if (!empty($subQuery)) {
|
||||
// 查询最新消息的详细信息
|
||||
$messageResults = Db::table('s2_wechat_message')
|
||||
->alias('m')
|
||||
->join([$subQuery => 'sub'], 'm.id = sub.max_id')
|
||||
->field('m.*, sub.wechatFriendId')
|
||||
->select();
|
||||
|
||||
if (!empty($messageResults)) {
|
||||
foreach ($messageResults as $message) {
|
||||
$latestMessages[$message['wechatFriendId']] = $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
if (!empty($messageResults)) {
|
||||
foreach ($messageResults as $message) {
|
||||
$latestMessages[$message['wechatFriendId']] = $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
$aiTypeData = [];
|
||||
@@ -101,4 +101,44 @@ class WechatFriendController extends BaseController
|
||||
|
||||
return ResponseHelper::success(['list' => $list, 'total' => $total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个好友详情
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getDetail()
|
||||
{
|
||||
$friendId = $this->request->param('id');
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
|
||||
if (empty($accountId)) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if (empty($friendId)) {
|
||||
return ResponseHelper::error('好友ID不能为空');
|
||||
}
|
||||
|
||||
// 查询好友详情
|
||||
$friend = Db::table('s2_wechat_friend')
|
||||
->where(['id' => $friendId, 'isDeleted' => 0])
|
||||
->find();
|
||||
|
||||
if (empty($friend)) {
|
||||
return ResponseHelper::error('好友不存在');
|
||||
}
|
||||
|
||||
// 处理好友数据
|
||||
$friend['labels'] = json_decode($friend['labels'], true);
|
||||
$friend['siteLabels'] = json_decode($friend['siteLabels'], true);
|
||||
$friend['createTime'] = !empty($friend['createTime']) ? date('Y-m-d H:i:s', $friend['createTime']) : '';
|
||||
$friend['updateTime'] = !empty($friend['updateTime']) ? date('Y-m-d H:i:s', $friend['updateTime']) : '';
|
||||
$friend['passTime'] = !empty($friend['passTime']) ? date('Y-m-d H:i:s', $friend['passTime']) : '';
|
||||
|
||||
// 获取AI类型设置
|
||||
$aiTypeSetting = FriendSettings::where('friendId', $friendId)->find();
|
||||
$friend['aiType'] = $aiTypeSetting ? $aiTypeSetting['type'] : 0;
|
||||
|
||||
return ResponseHelper::success(['detail' => $friend]);
|
||||
}
|
||||
}
|
||||
17
Server/application/chukebao/model/AiPush.php
Normal file
17
Server/application/chukebao/model/AiPush.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class AiPush extends Model
|
||||
{
|
||||
protected $pk = 'id';
|
||||
protected $name = 'kf_ai_push';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'createTime';
|
||||
protected $updateTime = 'updateTime';
|
||||
}
|
||||
|
||||
16
Server/application/chukebao/model/AiPushRecord.php
Normal file
16
Server/application/chukebao/model/AiPushRecord.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class AiPushRecord extends Model
|
||||
{
|
||||
protected $pk = 'id';
|
||||
protected $name = 'kf_ai_push_record';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'createTime';
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class WorkbenchGroupPushCommand extends Command
|
||||
|
||||
// 检查队列是否已经在运行
|
||||
$queueLockKey = "queue_lock:{$this->queueName}";
|
||||
Cache::rm($queueLockKey);
|
||||
if (Cache::get($queueLockKey)) {
|
||||
$output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行");
|
||||
Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行");
|
||||
|
||||
@@ -78,6 +78,14 @@ class TaskServer extends Server
|
||||
});
|
||||
}
|
||||
|
||||
// 在一个进程里处理自动问候任务
|
||||
if ($current_worker_id == 1) {
|
||||
// 每60秒检查一次自动问候规则
|
||||
Timer::add(60, function () use ($adapter) {
|
||||
$adapter->handleAutoGreetings();
|
||||
});
|
||||
}
|
||||
|
||||
// 更多其他后台任务
|
||||
// ......
|
||||
|
||||
|
||||
@@ -30,4 +30,4 @@ Route::group('v1/pay', function () {
|
||||
|
||||
|
||||
|
||||
Route::get('app/update', 'app\common\controller\PaymentService@createOrder');
|
||||
Route::get('v1/app/update', 'app\common\controller\Api@uploadApp'); //检测app是否需要更新
|
||||
@@ -136,12 +136,12 @@ class Api extends Controller
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
if (!in_array($type,['ckb','ai_store'])){
|
||||
if (!in_array($type,['ckb','aiStore'])){
|
||||
return ResponseHelper::error('参数错误');
|
||||
}
|
||||
|
||||
$data = Db::name('app_version')
|
||||
->field('version,downloadUrl,updateContent')
|
||||
->field('version,downloadUrl,updateContent,forceUpdate')
|
||||
->where(['type'=>$type])
|
||||
->order('id DESC')
|
||||
->find();
|
||||
|
||||
@@ -28,7 +28,7 @@ class Attachment extends Controller
|
||||
$validate = \think\facade\Validate::rule([
|
||||
'file' => [
|
||||
'fileSize' => 10485760, // 10MB
|
||||
'fileExt' => 'jpg,jpeg,png,gif,doc,docx,pdf,zip,rar,mp4,mp3,csv,xlsx,xls,ppt,pptx',
|
||||
'fileExt' => 'jpg,jpeg,png,gif,doc,docx,pdf,zip,rar,mp4,mp3,csv,xlsx,xls,ppt,pptx,txt',
|
||||
]
|
||||
]);
|
||||
|
||||
|
||||
52
Server/application/common/controller/GetOpenid.php
Normal file
52
Server/application/common/controller/GetOpenid.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use EasyWeChat\Factory;
|
||||
use think\Controller;
|
||||
use think\facade\Env;
|
||||
class GetOpenid extends Controller
|
||||
{
|
||||
|
||||
protected $app;
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// 从环境变量获取配置
|
||||
$config = [
|
||||
'app_id' => Env::get('weChat.appid'),
|
||||
'secret' => Env::get('weChat.secret'),
|
||||
'response_type' => 'array'
|
||||
];
|
||||
$this->app = Factory::officialAccount($config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
$app = $this->app;
|
||||
$oauth = $app->oauth;
|
||||
|
||||
// 未登录
|
||||
if (empty($_SESSION['wechat_user'])) {
|
||||
|
||||
$_SESSION['target_url'] = 'user/profile';
|
||||
|
||||
$redirectUrl = $oauth->redirect();
|
||||
|
||||
exit_data($redirectUrl);
|
||||
header("Location: {$redirectUrl}");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 已经登录过
|
||||
$user = $_SESSION['wechat_user'];
|
||||
|
||||
exit_data($user);
|
||||
|
||||
return 'Hello, World!';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -102,15 +102,27 @@ class PasswordLoginController extends BaseController
|
||||
* @param string $account 账号(手机号)
|
||||
* @param string $password 密码(可能是加密后的)
|
||||
* @param string $typeId 登录IP
|
||||
* @param string $deviceId 本地设备imei
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function doLogin(string $account, string $password, int $typeId): array
|
||||
protected function doLogin(string $account, string $password, int $typeId, string $deviceId): array
|
||||
{
|
||||
// 获取用户信息
|
||||
$member = $this->getUser($account, $password, $typeId);
|
||||
$deviceTotal = Db::name('device')->where(['companyId' => $member['companyId'],'deleteTime' => 0])->count();
|
||||
|
||||
//更新设备imei
|
||||
if ($typeId == 2 && !empty($deviceId)){
|
||||
$deviceUser = Db::name('device_user')->where(['companyId' => $member['companyId'],'userId' => $member['id'],'deleteTime' => 0])->find();
|
||||
if (!empty($deviceUser)){
|
||||
$device = Db::name('device')->where(['companyId' => $member['companyId'],'deleteTime' => 0,'id' => $deviceUser['deviceId']])->find();
|
||||
if (!empty($device) && empty($device['deviceImei'])){
|
||||
Db::table('s2_device')->where(['id' => $device['id']])->update(['deviceImei' => $deviceId,'updateTime' => time()]);
|
||||
Db::name('device')->where(['id' => $device['id']])->update(['deviceImei' => $deviceId,'updateTime' => time()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生成JWT令牌
|
||||
@@ -126,34 +138,17 @@ class PasswordLoginController extends BaseController
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$params = $this->request->only(['account', 'password', 'typeId']);
|
||||
$params = $this->request->only(['account', 'password', 'typeId','deviceId']);
|
||||
try {
|
||||
$deviceId = isset($params['deviceId']) ? $params['deviceId'] : '';
|
||||
$userData = $this->dataValidate($params)->doLogin(
|
||||
$params['account'],
|
||||
$params['password'],
|
||||
$params['typeId']
|
||||
$params['typeId'],
|
||||
$deviceId
|
||||
);
|
||||
//同时登录客服系统
|
||||
/* if (!empty($userData['member']['passwordLocal'])){
|
||||
$params = [
|
||||
'grant_type' => 'password',
|
||||
'username' => $userData['member']['account'],
|
||||
'password' => localDecrypt($userData['member']['passwordLocal'])
|
||||
];
|
||||
// 调用登录接口获取token
|
||||
$headerData = ['client:kefu-client'];
|
||||
$header = setHeader($headerData, '', 'plain');
|
||||
$result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST', $header);
|
||||
$token = handleApiResponse($result);
|
||||
$userData['kefuData']['token'] = $token;
|
||||
if (isset($token['access_token']) && !empty($token['access_token'])) {
|
||||
$headerData = ['client:kefu-client'];
|
||||
$header = setHeader($headerData, $token['access_token']);
|
||||
$result = requestCurl( 'https://s2.siyuguanli.com:9991/api/account/self', [], 'GET', $header,'json');
|
||||
$self = handleApiResponse($result);
|
||||
$userData['kefuData']['self'] = $self;
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
return ResponseHelper::success($userData, '登录成功');
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode());
|
||||
|
||||
@@ -16,38 +16,70 @@ use app\common\model\Order;
|
||||
class PaymentService
|
||||
{
|
||||
/**
|
||||
* 下单
|
||||
* 统一支付下单接口
|
||||
* 支持扫码付款、微信支付、支付宝支付
|
||||
*
|
||||
* @param array $order
|
||||
* - out_trade_no: string 商户订单号(必填)
|
||||
* - total_fee: int 金额(分,必填)
|
||||
* - body: string 商品描述(必填)
|
||||
* - notify_url: string 异步通知地址(可覆盖配置)
|
||||
* - attach: string 附加数据(可选)
|
||||
* - time_expire: string 订单失效时间(可选)
|
||||
* - client_ip: string 终端IP(可选)
|
||||
* - sign_type: string MD5/RSA_1_256/RSA_1_1(可选,默认MD5)
|
||||
* - pay_type: string 支付场景,如 JSAPI/APP/H5(可选)
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
* - orderNo: string 商户订单号(必填)
|
||||
* - money: int 金额(分,必填)
|
||||
* - goodsName: string 商品描述(必填)
|
||||
* - service: string 支付服务类型(可选)
|
||||
* - 'wechat' 或 'pay.weixin.jspay': 微信JSAPI支付
|
||||
* - 'alipay' 或 'pay.alipay.jspay': 支付宝JSAPI支付
|
||||
* - 不传或空: 默认扫码付款
|
||||
* - openid: string 微信用户openid(微信JSAPI支付必填)
|
||||
* - buyer_id: string 支付宝用户ID(支付宝JSAPI支付可选)
|
||||
* - notify_url: string 异步通知地址(可选)
|
||||
* @return string JSON格式响应
|
||||
*/
|
||||
public function createOrder(array $order)
|
||||
{
|
||||
// 确定service类型:支持简写形式 wechat/alipay,或完整的 service 值
|
||||
$serviceType = $order['service'] ?? '';
|
||||
|
||||
// 映射简写形式到完整的 service 值
|
||||
if ($serviceType === 'wechat' || $serviceType === 'pay.weixin.jspay') {
|
||||
$service = 'pay.weixin.jspay';
|
||||
} elseif ($serviceType === 'alipay' || $serviceType === 'pay.alipay.jspay') {
|
||||
$service = 'pay.alipay.jspay';
|
||||
} elseif ($serviceType === 'qrCode' || $serviceType === 'unified.trade.native') {
|
||||
$service = 'unified.trade.native';
|
||||
} else {
|
||||
// 默认扫码支付
|
||||
$service = 'unified.trade.native';
|
||||
}
|
||||
|
||||
// 构建基础参数
|
||||
$params = [
|
||||
'service' => 'unified.trade.native',
|
||||
'service' => $service,
|
||||
'sign_type' => PaymentUtil::SIGN_TYPE_MD5,
|
||||
'mch_id' => Env::get('payment.mchId'),
|
||||
'out_trade_no' => $order['orderNo'],
|
||||
'body' => $order['goodsName'] ?? '',
|
||||
'total_fee' => $order['money'] ?? 0,
|
||||
'mch_create_ip' => Request::ip(),
|
||||
'notify_url' => Env::get('payment.notify_url', '127.0.0.1'),
|
||||
'notify_url' => $order['notify_url'] ?? Env::get('payment.notify_url', '127.0.0.1'),
|
||||
'nonce_str' => PaymentUtil::generateNonceStr(),
|
||||
];
|
||||
|
||||
// 微信JSAPI支付需要openid
|
||||
if ($service == 'pay.weixin.jspay') {
|
||||
// $params['sub_openid'] = 'oB44Yw1T6bfVAZwjj729P-6CUSPE';
|
||||
$params['is_raw'] = 0;
|
||||
$params['mch_app_name'] = '存客宝';
|
||||
$params['mch_app_id'] = 'https://kr-op.quwanzhi.com';
|
||||
}
|
||||
|
||||
// 支付宝JSAPI支付需要buyer_id(可选)
|
||||
if ($service == 'pay.alipay.jspay') {
|
||||
$params['is_raw'] = 0;
|
||||
$params['quit_url'] = $params['notify_url'];
|
||||
$params['buyer_id'] = '';
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 过滤空值签名
|
||||
// 签名
|
||||
$secret = Env::get('payment.key');
|
||||
$params['sign_type'] = 'MD5';
|
||||
$params['sign'] = PaymentUtil::generateSign($params, $secret, 'MD5');
|
||||
@@ -57,7 +89,7 @@ class PaymentService
|
||||
throw new \Exception('支付网关地址未配置');
|
||||
}
|
||||
|
||||
//创建订单
|
||||
// 创建订单
|
||||
Order::create([
|
||||
'mchId' => $params['mch_id'],
|
||||
'companyId' => isset($order['companyId']) ? $order['companyId'] : 0,
|
||||
@@ -73,17 +105,41 @@ class PaymentService
|
||||
'nonceStr' => isset($order['nonceStr']) ? $order['nonceStr'] : '',
|
||||
'createTime' => time(),
|
||||
]);
|
||||
|
||||
// XML POST 请求
|
||||
$xmlBody = $this->arrayToXml($params);
|
||||
$response = $this->postXml($url, $xmlBody);
|
||||
$parsed = $this->parseXmlOrRaw($response);
|
||||
|
||||
|
||||
if ($parsed['status'] == 0 && $parsed['result_code'] == 0) {
|
||||
Db::commit();
|
||||
return json_encode(['code' => 200, 'msg' => '订单创建成功', 'data' => $parsed['code_img_url']]);
|
||||
|
||||
// 根据service类型返回不同的数据格式(仅返回接口文档中的字段)
|
||||
$responseData = null;
|
||||
if ($service == 'unified.trade.native') {
|
||||
// 扫码支付返回二维码URL
|
||||
$responseData = $parsed['code_img_url'] ?? '';
|
||||
} elseif ($service == 'pay.weixin.jspay') {
|
||||
// 微信JSAPI支付返回支付参数(仅返回接口文档中存在的字段)
|
||||
$responseData = [];
|
||||
if (isset($parsed['appid'])) $responseData['appid'] = $parsed['appid'];
|
||||
if (isset($parsed['time_stamp'])) $responseData['time_stamp'] = $parsed['time_stamp'];
|
||||
if (isset($parsed['nonce_str'])) $responseData['nonce_str'] = $parsed['nonce_str'];
|
||||
if (isset($parsed['package'])) $responseData['package'] = $parsed['package'];
|
||||
if (isset($parsed['sign_type'])) $responseData['sign_type'] = $parsed['sign_type'];
|
||||
if (isset($parsed['pay_sign'])) $responseData['pay_sign'] = $parsed['pay_sign'];
|
||||
} elseif ($service == 'pay.alipay.jspay') {
|
||||
// 支付宝JSAPI支付返回订单信息(仅返回接口文档中存在的字段)
|
||||
$responseData = [];
|
||||
if (isset($parsed['order_info'])) $responseData['order_info'] = $parsed['order_info'];
|
||||
if (isset($parsed['order_string'])) $responseData['order_string'] = $parsed['order_string'];
|
||||
}
|
||||
|
||||
return json_encode(['code' => 200, 'msg' => '订单创建成功', 'data' => $responseData]);
|
||||
} else {
|
||||
Db::rollback();
|
||||
return json_encode(['code' => 500, 'msg' => '订单创建失败:' . $parsed['err_msg']]);
|
||||
return json_encode(['code' => 500, 'msg' => '订单创建失败:' . ($parsed['err_msg'] ?? '未知错误')]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -194,54 +250,104 @@ class PaymentService
|
||||
/**
|
||||
* 支付结果异步通知
|
||||
* - 威富通回调为 XML;需校验签名与业务字段并更新订单
|
||||
* - 回应:成功回"success",失败回"fail"
|
||||
* @return void
|
||||
* - 支持扫码付款、微信支付、支付宝支付的通知
|
||||
* - 回应:成功返回XML格式SUCCESS,失败返回XML格式FAIL
|
||||
* @return string XML响应
|
||||
*/
|
||||
public function notify()
|
||||
{
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$payload = $this->parseXmlOrRaw($rawBody);
|
||||
if (!is_array($payload) || empty($payload)) {
|
||||
return json_encode(['code' => 500, 'msg' => 'XML解析错误']);
|
||||
\think\facade\Log::error('支付通知:XML解析错误', ['rawBody' => $rawBody]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[XML解析错误]]></return_msg></xml>';
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
$secret = Env::get('payment.key');
|
||||
if (!empty($secret) && isset($payload['sign'])) {
|
||||
$signType = $payload['sign_type'] ?? 'MD5';
|
||||
if (!PaymentUtil::verifySign($payload, $secret, $signType)) {
|
||||
\think\facade\Log::error('支付通知:签名验证失败', ['payload' => $payload]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名验证失败]]></return_msg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($payload['status'] != 0 || $payload['result_code'] != 0) {
|
||||
$errMsg = (isset($payload['err_msg']) ? $payload['err_msg'] : isset($payload['err_msg'])) ? $payload['err_msg'] : '未知错误';
|
||||
return json_encode(['code' => 500, 'msg' => $errMsg]);
|
||||
// 检查通信状态
|
||||
if (isset($payload['status']) && $payload['status'] != 0) {
|
||||
$errMsg = $payload['err_msg'] ?? '通信失败';
|
||||
\think\facade\Log::error('支付通知:通信失败', ['payload' => $payload]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[' . $errMsg . ']]></return_msg></xml>';
|
||||
}
|
||||
|
||||
// 检查业务结果
|
||||
if (isset($payload['result_code']) && $payload['result_code'] != 0) {
|
||||
$errMsg = $payload['err_msg'] ?? '业务处理失败';
|
||||
\think\facade\Log::error('支付通知:业务处理失败', ['payload' => $payload]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[' . $errMsg . ']]></return_msg></xml>';
|
||||
}
|
||||
|
||||
|
||||
// 业务处理:更新订单
|
||||
Db::startTrans();
|
||||
try {
|
||||
$outTradeNo = $payload['out_trade_no'];
|
||||
$pay_result = $payload['pay_result'];
|
||||
$time_end = $payload['time_end'];
|
||||
$outTradeNo = $payload['out_trade_no'] ?? '';
|
||||
$pay_result = $payload['pay_result'] ?? 0;
|
||||
$time_end = $payload['time_end'] ?? '';
|
||||
|
||||
if (empty($outTradeNo)) {
|
||||
Db::rollback();
|
||||
\think\facade\Log::error('支付通知:订单号为空', ['payload' => $payload]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[订单号为空]]></return_msg></xml>';
|
||||
}
|
||||
|
||||
$order = Order::where('orderNo', $outTradeNo)->find();
|
||||
if (!$order) {
|
||||
Db::rollback();
|
||||
return json_encode(['code' => 500, 'msg' => '该订单不存在']);
|
||||
\think\facade\Log::error('支付通知:订单不存在', ['out_trade_no' => $outTradeNo]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[订单不存在]]></return_msg></xml>';
|
||||
}
|
||||
|
||||
// 如果订单已支付,直接返回成功(防止重复处理)
|
||||
if ($order->status == 1) {
|
||||
Db::rollback();
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
|
||||
}
|
||||
|
||||
if ($pay_result != 0) {
|
||||
$order->payInfo = $payload['pay_info'];
|
||||
$order->payInfo = $payload['pay_info'] ?? '支付失败';
|
||||
$order->status = 3;
|
||||
$order->save();
|
||||
Db::commit();
|
||||
return json_encode(['code' => 500, 'msg' => $payload['pay_info']]);
|
||||
\think\facade\Log::error('支付通知:支付失败', ['orderNo' => $outTradeNo, 'pay_info' => $payload['pay_info'] ?? '']);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[' . ($payload['pay_info'] ?? '支付失败') . ']]></return_msg></xml>';
|
||||
}
|
||||
$order->payType = $payload['trade_type'] == 'pay.wechat.jspay' ? 1 : 2;
|
||||
|
||||
// 根据trade_type判断支付方式
|
||||
$tradeType = $payload['trade_type'] ?? '';
|
||||
if (strpos($tradeType, 'wechat') !== false || strpos($tradeType, 'weixin') !== false) {
|
||||
$order->payType = 1; // 微信支付
|
||||
} elseif (strpos($tradeType, 'alipay') !== false) {
|
||||
$order->payType = 2; // 支付宝支付
|
||||
} else {
|
||||
// 默认根据原有逻辑判断
|
||||
$order->payType = $tradeType == 'pay.wechat.jspay' ? 1 : 2;
|
||||
}
|
||||
|
||||
$order->status = 1;
|
||||
$order->payTime = $this->parsePayTime($time_end);
|
||||
$order->transactionId = $payload['transaction_id'] ?? '';
|
||||
$order->save();
|
||||
//订单处理
|
||||
$this->processOrder($order);
|
||||
Db::commit();
|
||||
return json_encode(['code' => 200, 'msg' => '付款成功']);
|
||||
|
||||
// 返回成功响应(XML格式)
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return json_encode(['code' => 500, 'msg' => '付款失败' . $e->getMessage()]);
|
||||
\think\facade\Log::error('支付通知:处理异常', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[处理异常]]></return_msg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,12 +465,12 @@ class PaymentService
|
||||
//订单处理
|
||||
$this->processOrder($order);
|
||||
Db::commit();
|
||||
return json_encode(['code' => 200, 'msg' => '支付成功'] );
|
||||
return json_encode(['code' => 200, 'msg' => '支付成功']);
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return json_encode(['code' => 500, 'msg' => '付款失败' . $e->getMessage()]);
|
||||
}
|
||||
}else{
|
||||
} else {
|
||||
$order = Order::where('orderNo', $resp['out_trade_no'])->lock(true)->find();
|
||||
if ($order) {
|
||||
$order->status = 3;
|
||||
@@ -373,8 +479,8 @@ class PaymentService
|
||||
}
|
||||
return json_encode(['code' => 500, 'msg' => '支付失败', 'data' => $resp]);
|
||||
}
|
||||
|
||||
}else{
|
||||
|
||||
} else {
|
||||
return json_encode(['code' => 500, 'msg' => '通信失败']);
|
||||
}
|
||||
}
|
||||
@@ -413,7 +519,7 @@ class PaymentService
|
||||
$record->form = 5;
|
||||
$record->wechatAccountId = 0;
|
||||
$record->friendIdOrGroupId = 0;
|
||||
$record->remarks = '购买算力【'.$goodsSpecs['name'].'】';
|
||||
$record->remarks = '购买算力【' . $goodsSpecs['name'] . '】';
|
||||
$record->tokens = $goodsSpecs['tokens'];
|
||||
$record->balanceTokens = $newTokens;
|
||||
$record->createTime = time();
|
||||
@@ -423,4 +529,5 @@ class PaymentService
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -154,8 +154,10 @@ Route::group('v1/', function () {
|
||||
// 算力相关
|
||||
Route::group('tokens', function () {
|
||||
Route::get('list', 'app\cunkebao\controller\TokensController@getList');
|
||||
Route::post('pay', 'app\cunkebao\controller\TokensController@pay');
|
||||
Route::get('queryOrder', 'app\cunkebao\controller\TokensController@queryOrder');
|
||||
Route::post('pay', 'app\cunkebao\controller\TokensController@pay'); // 扫码付款
|
||||
Route::get('queryOrder', 'app\cunkebao\controller\TokensController@queryOrder'); // 查询订单(扫码付款)
|
||||
Route::get('orderList', 'app\cunkebao\controller\TokensController@getOrderList'); // 获取订单列表
|
||||
Route::get('statistics', 'app\cunkebao\controller\TokensController@getTokensStatistics'); // 获取算力统计
|
||||
});
|
||||
|
||||
|
||||
@@ -164,6 +166,7 @@ Route::group('v1/', function () {
|
||||
Route::group('knowledge', function () {
|
||||
Route::get('init', 'app\cunkebao\controller\AiSettingsController@init');
|
||||
Route::get('release', 'app\cunkebao\controller\AiSettingsController@release');
|
||||
Route::post('savePrompt', 'app\cunkebao\controller\AiSettingsController@savePrompt'); // 保存统一提示词
|
||||
Route::get('typeList', 'app\cunkebao\controller\AiKnowledgeBaseController@typeList');
|
||||
Route::get('getList', 'app\cunkebao\controller\AiKnowledgeBaseController@getList');
|
||||
Route::post('add', 'app\cunkebao\controller\AiKnowledgeBaseController@add');
|
||||
@@ -174,10 +177,20 @@ Route::group('v1/', function () {
|
||||
Route::post('delete', 'app\cunkebao\controller\AiKnowledgeBaseController@delete');
|
||||
Route::post('addType', 'app\cunkebao\controller\AiKnowledgeBaseController@addType');
|
||||
Route::post('editType', 'app\cunkebao\controller\AiKnowledgeBaseController@editType');
|
||||
Route::put('updateTypeStatus', 'app\cunkebao\controller\AiKnowledgeBaseController@updateTypeStatus'); // 修改类型状态
|
||||
Route::delete('deleteType', 'app\cunkebao\controller\AiKnowledgeBaseController@deleteType');
|
||||
Route::get('detailType', 'app\cunkebao\controller\AiKnowledgeBaseController@detailType');
|
||||
});
|
||||
|
||||
// 门店端账号管理
|
||||
Route::group('store-accounts', function () {
|
||||
Route::get('', 'app\cunkebao\controller\StoreAccountController@index'); // 获取账号列表
|
||||
Route::post('', 'app\cunkebao\controller\StoreAccountController@create'); // 创建账号
|
||||
Route::put('', 'app\cunkebao\controller\StoreAccountController@update'); // 编辑账号
|
||||
Route::delete('', 'app\cunkebao\controller\StoreAccountController@delete'); // 删除账号
|
||||
Route::post('disable', 'app\cunkebao\controller\StoreAccountController@disable'); // 禁用/启用账号
|
||||
});
|
||||
|
||||
|
||||
|
||||
})->middleware(['jwt']);
|
||||
|
||||
@@ -39,21 +39,42 @@ class AiKnowledgeBaseController extends BaseController
|
||||
|
||||
if ($includeSystem == 1) {
|
||||
// 包含系统类型和本公司创建的类型
|
||||
$where[] = ['type', '=', AiKnowledgeBaseType::TYPE_SYSTEM];
|
||||
$where[] = ['companyId|type', 'in', [$companyId, 0]];
|
||||
$where[] = ['companyId', 'in', [$companyId, 0]];
|
||||
} else {
|
||||
// 只显示本公司创建的类型
|
||||
$where[] = ['companyId', '=', $companyId];
|
||||
$where[] = ['type', '=', AiKnowledgeBaseType::TYPE_USER];
|
||||
}
|
||||
|
||||
|
||||
// 统计开启的类型总数
|
||||
$enabledCountWhere = $where;
|
||||
$enabledCountWhere[] = ['status', '=', 1];
|
||||
$enabledCount = AiKnowledgeBaseType::where($enabledCountWhere)->count();
|
||||
|
||||
// 查询数据
|
||||
$list = AiKnowledgeBaseType::where($where)
|
||||
->order('type', 'asc') // 系统类型排在前面
|
||||
->order('createTime', 'desc')
|
||||
->paginate($pageSize, false, ['page' => $page]);
|
||||
|
||||
return ResponseHelper::success($list, '获取成功');
|
||||
// 为每个类型添加素材数量统计
|
||||
$listData = $list->toArray();
|
||||
foreach ($listData['data'] as &$item) {
|
||||
// 统计该类型下的知识库数量(素材数量)
|
||||
$item['materialCount'] = AiKnowledgeBase::where([
|
||||
['typeId', '=', $item['id']],
|
||||
['isDel', '=', 0]
|
||||
])->count();
|
||||
}
|
||||
|
||||
// 重新构造返回数据
|
||||
$result = [
|
||||
'total' => $listData['total'],
|
||||
'data' => $listData['data'],
|
||||
'enabledCount' => $enabledCount, // 开启的类型总数
|
||||
];
|
||||
|
||||
return ResponseHelper::success($result, '获取成功');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
@@ -80,6 +101,7 @@ class AiKnowledgeBaseController extends BaseController
|
||||
$description = $this->request->param('description', '');
|
||||
$label = $this->request->param('label', []);
|
||||
$prompt = $this->request->param('prompt', '');
|
||||
$status = $this->request->param('status', 1); // 默认启用
|
||||
|
||||
// 参数验证
|
||||
if (empty($name)) {
|
||||
@@ -103,8 +125,9 @@ class AiKnowledgeBaseController extends BaseController
|
||||
'type' => AiKnowledgeBaseType::TYPE_USER,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'label' => json_decode($label,256),
|
||||
'label' => json_encode($label,256),
|
||||
'prompt' => $prompt,
|
||||
'status' => $status,
|
||||
'companyId' => $companyId,
|
||||
'userId' => $userId,
|
||||
'createTime' => time(),
|
||||
@@ -142,6 +165,7 @@ class AiKnowledgeBaseController extends BaseController
|
||||
$description = $this->request->param('description', '');
|
||||
$label = $this->request->param('label', []);
|
||||
$prompt = $this->request->param('prompt', '');
|
||||
$status = $this->request->param('status', '');
|
||||
|
||||
// 参数验证
|
||||
if (empty($id)) {
|
||||
@@ -187,8 +211,11 @@ class AiKnowledgeBaseController extends BaseController
|
||||
// 更新数据
|
||||
$typeModel->name = $name;
|
||||
$typeModel->description = $description;
|
||||
$typeModel->label = $label;
|
||||
$typeModel->label = json_encode($label,256);
|
||||
$typeModel->prompt = $prompt;
|
||||
if ($status !== '') {
|
||||
$typeModel->status = $status;
|
||||
}
|
||||
$typeModel->updateTime = time();
|
||||
|
||||
if ($typeModel->save()) {
|
||||
@@ -202,6 +229,111 @@ class AiKnowledgeBaseController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改知识库类型状态
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function updateTypeStatus()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('公司信息获取失败');
|
||||
}
|
||||
|
||||
// 获取参数
|
||||
$id = $this->request->param('id', 0);
|
||||
$status = $this->request->param('status', -1);
|
||||
|
||||
// 参数验证
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('类型ID不能为空');
|
||||
}
|
||||
|
||||
if ($status != 0 && $status != 1) {
|
||||
return ResponseHelper::error('状态参数错误');
|
||||
}
|
||||
|
||||
// 查找类型
|
||||
$typeModel = AiKnowledgeBaseType::where([
|
||||
['id', '=', $id],
|
||||
['isDel', '=', 0]
|
||||
])->find();
|
||||
|
||||
if (!$typeModel) {
|
||||
return ResponseHelper::error('类型不存在');
|
||||
}
|
||||
|
||||
// 检查是否为系统类型
|
||||
if ($typeModel->isSystemType()) {
|
||||
return ResponseHelper::error('系统类型不允许修改状态');
|
||||
}
|
||||
|
||||
// 检查权限(只能修改本公司的类型)
|
||||
if ($typeModel->companyId != $companyId) {
|
||||
return ResponseHelper::error('无权限修改该类型');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
$typeModel->status = $status;
|
||||
$typeModel->updateTime = time();
|
||||
|
||||
if ($typeModel->save()) {
|
||||
$message = $status == 0 ? '禁用成功' : '启用成功';
|
||||
return ResponseHelper::success([], $message);
|
||||
} else {
|
||||
return ResponseHelper::error('操作失败');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库类型详情
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detailType()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('公司信息获取失败');
|
||||
}
|
||||
|
||||
// 获取参数
|
||||
$id = $this->request->param('id', 0);
|
||||
|
||||
// 参数验证
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('类型ID不能为空');
|
||||
}
|
||||
|
||||
// 查找类型
|
||||
$typeModel = AiKnowledgeBaseType::where([
|
||||
['id', '=', $id],
|
||||
['isDel', '=', 0]
|
||||
])->find();
|
||||
|
||||
if (!$typeModel) {
|
||||
return ResponseHelper::error('类型不存在');
|
||||
}
|
||||
|
||||
// 检查权限(系统类型或本公司的类型都可以查看)
|
||||
if ($typeModel->companyId != 0 && $typeModel->companyId != $companyId) {
|
||||
return ResponseHelper::error('无权限查看该类型');
|
||||
}
|
||||
|
||||
return ResponseHelper::success($typeModel, '获取成功');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库类型
|
||||
*
|
||||
@@ -309,6 +441,11 @@ class AiKnowledgeBaseController extends BaseController
|
||||
->order('createTime', 'desc')
|
||||
->paginate($pageSize, false, ['page' => $page]);
|
||||
|
||||
foreach ($list as &$v){
|
||||
$v['size'] = 0;
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return ResponseHelper::success($list, '获取成功');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -40,6 +40,7 @@ class AiSettingsController extends BaseController
|
||||
|
||||
// 确保智能体已创建
|
||||
if (empty($settings->botId)) {
|
||||
$settings->releaseTime = 0;
|
||||
$botCreated = $this->createBot($settings);
|
||||
if (!$botCreated) {
|
||||
return ResponseHelper::error('智能体创建失败');
|
||||
@@ -48,12 +49,13 @@ class AiSettingsController extends BaseController
|
||||
|
||||
// 确保知识库已创建
|
||||
if (empty($settings->datasetId)) {
|
||||
$settings->releaseTime = 0;
|
||||
$knowledgeCreated = $this->createKnowledge($settings);
|
||||
if (!$knowledgeCreated) {
|
||||
return ResponseHelper::error('知识库创建失败');
|
||||
}
|
||||
}
|
||||
if (!empty($settings->botId) && !empty($settings->datasetId)) {
|
||||
if (!empty($settings->botId) && !empty($settings->datasetId) && $settings->releaseTime <= 0) {
|
||||
$cozeAI = new CozeAI();
|
||||
$config = json_decode($settings->config,true);
|
||||
$config['bot_id'] = $settings->botId;
|
||||
@@ -133,7 +135,8 @@ class AiSettingsController extends BaseController
|
||||
## 限制
|
||||
- 仅依据知识库内容回答问题,对于知识库中没有的信息,如实告知用户无法回答。
|
||||
- 回答必须严格遵循中国法律法规,不得出现任何违法违规内容。
|
||||
- 回答需简洁明了,避免冗长复杂的表述。';
|
||||
- 回答需简洁明了,避免冗长复杂的表述(尽量在100字内)。
|
||||
- 适当加些表情点缀。';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,4 +344,90 @@ class AiSettingsController extends BaseController
|
||||
$settings->save();
|
||||
return ResponseHelper::success('', '发布成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存统一提示词
|
||||
* 先更新数据库,再调用CozeAI接口更新智能体
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function savePrompt()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('公司信息获取失败');
|
||||
}
|
||||
|
||||
// 获取提示词参数
|
||||
$promptInfo = $this->request->param('promptInfo', '');
|
||||
if (empty($promptInfo)) {
|
||||
return ResponseHelper::error('提示词内容不能为空');
|
||||
}
|
||||
|
||||
// 查找AI设置
|
||||
$settings = AiSettingsModel::where(['companyId' => $companyId])->find();
|
||||
if (empty($settings)) {
|
||||
return ResponseHelper::error('AI设置不存在,请先初始化');
|
||||
}
|
||||
|
||||
// 检查智能体是否已创建
|
||||
if (empty($settings->botId)) {
|
||||
return ResponseHelper::error('智能体未创建,请先初始化AI设置');
|
||||
}
|
||||
|
||||
// 解析现有配置
|
||||
$config = json_decode($settings->config, true);
|
||||
if (!is_array($config)) {
|
||||
$config = [];
|
||||
}
|
||||
|
||||
// 更新提示词
|
||||
$config['prompt_info'] = $promptInfo;
|
||||
|
||||
// 第一步:更新数据库
|
||||
$settings->config = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||||
$settings->isRelease = 0; // 标记为未发布状态
|
||||
$settings->updateTime = time();
|
||||
|
||||
if (!$settings->save()) {
|
||||
return ResponseHelper::error('数据库更新失败');
|
||||
}
|
||||
|
||||
// 第二步:调用CozeAI接口更新智能体
|
||||
try {
|
||||
$cozeAI = new CozeAI();
|
||||
|
||||
// 参考 init 方法的参数格式,传递完整的 config
|
||||
$updateData = $config;
|
||||
$updateData['bot_id'] = $settings->botId;
|
||||
|
||||
// 如果有知识库,也一并传入
|
||||
if (!empty($settings->datasetId)) {
|
||||
$updateData['dataset_ids'] = [$settings->datasetId];
|
||||
}
|
||||
|
||||
$result = $cozeAI->updateBot($updateData);
|
||||
$result = json_decode($result, true);
|
||||
|
||||
if ($result['code'] != 200) {
|
||||
\think\facade\Log::error('更新智能体提示词失败:' . json_encode($result));
|
||||
return ResponseHelper::error('更新智能体失败:' . ($result['msg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'prompt_info' => $promptInfo,
|
||||
'isRelease' => 0
|
||||
], '提示词保存成功,请重新发布智能体');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('调用CozeAI更新接口异常:' . $e->getMessage());
|
||||
return ResponseHelper::error('更新智能体接口调用失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\think\facade\Log::error('保存提示词异常:' . $e->getMessage());
|
||||
return ResponseHelper::error('系统异常:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,207 @@
|
||||
|
||||
namespace app\cunkebao\controller;
|
||||
|
||||
use think\Db;
|
||||
use app\store\model\TrafficOrderModel;
|
||||
use app\common\model\TrafficSource;
|
||||
use app\store\model\WechatFriendModel;
|
||||
|
||||
/**
|
||||
* RFM 客户价值评分控制器
|
||||
* 基于 RFM 客户价值评分体系技术实施文档实现
|
||||
*/
|
||||
class RFMController extends BaseController
|
||||
{
|
||||
// 默认配置参数
|
||||
const DEFAULT_CYCLE_DAYS = 180; // 默认统计周期(天)
|
||||
const DEFAULT_WEIGHT_R = 0.4; // R维度权重
|
||||
const DEFAULT_WEIGHT_F = 0.3; // F维度权重
|
||||
const DEFAULT_WEIGHT_M = 0.3; // M维度权重
|
||||
const DEFAULT_ABNORMAL_MONEY_RATIO = 3.0; // 异常金额阈值倍数
|
||||
const DEFAULT_SCORE_SCALE = 5; // 默认5分制
|
||||
|
||||
/**
|
||||
* 计算 RFM 评分(默认规则)
|
||||
* 从 traffic_order 表计算客户 RFM 评分
|
||||
*
|
||||
* @param string|null $identifier 流量池用户标识
|
||||
* @param string|null $ownerWechatId 微信ID,为空则统计所有数据
|
||||
* @param array $config 配置参数
|
||||
* - cycle_days: 统计周期(天),默认180
|
||||
* - weight_R: R维度权重,默认0.4
|
||||
* - weight_F: F维度权重,默认0.3
|
||||
* - weight_M: M维度权重,默认0.3
|
||||
* - abnormal_money_ratio: 异常金额阈值倍数,默认3.0
|
||||
* - score_scale: 评分分制(5或100),默认5
|
||||
* - missing_strategy: 缺失值处理策略('score_1'或'exclude'),默认'score_1'
|
||||
* @return array
|
||||
*/
|
||||
public function calculateRfmFromTrafficOrder($identifier = null, $ownerWechatId = null, $config = [])
|
||||
{
|
||||
try {
|
||||
// 合并配置参数
|
||||
$cycleDays = isset($config['cycle_days']) ? (int)$config['cycle_days'] : self::DEFAULT_CYCLE_DAYS;
|
||||
$weightR = isset($config['weight_R']) ? (float)$config['weight_R'] : self::DEFAULT_WEIGHT_R;
|
||||
$weightF = isset($config['weight_F']) ? (float)$config['weight_F'] : self::DEFAULT_WEIGHT_F;
|
||||
$weightM = isset($config['weight_M']) ? (float)$config['weight_M'] : self::DEFAULT_WEIGHT_M;
|
||||
$abnormalMoneyRatio = isset($config['abnormal_money_ratio']) ? (float)$config['abnormal_money_ratio'] : self::DEFAULT_ABNORMAL_MONEY_RATIO;
|
||||
$scoreScale = isset($config['score_scale']) ? (int)$config['score_scale'] : self::DEFAULT_SCORE_SCALE;
|
||||
$missingStrategy = isset($config['missing_strategy']) ? $config['missing_strategy'] : 'score_1';
|
||||
|
||||
// 权重归一化处理
|
||||
$weightSum = $weightR + $weightF + $weightM;
|
||||
if ($weightSum != 1.0) {
|
||||
$weightR = $weightR / $weightSum;
|
||||
$weightF = $weightF / $weightSum;
|
||||
$weightM = $weightM / $weightSum;
|
||||
}
|
||||
|
||||
// 计算时间范围
|
||||
$endTime = time(); // 统计截止时间(当前时间)
|
||||
$startTime = $endTime - ($cycleDays * 24 * 3600); // 统计起始时间
|
||||
|
||||
// 构建查询条件
|
||||
$where = [
|
||||
['isDel', '=', 0],
|
||||
['createTime', '>=', $startTime],
|
||||
['createTime', '<', $endTime],
|
||||
];
|
||||
|
||||
// identifier 条件
|
||||
if (!empty($identifier)) {
|
||||
$where[] = ['identifier', '=', $identifier];
|
||||
}
|
||||
|
||||
// ownerWechatId 条件
|
||||
if (!empty($ownerWechatId)) {
|
||||
$where[] = ['ownerWechatId', '=', $ownerWechatId];
|
||||
}
|
||||
|
||||
// 1. 数据过滤和聚合 - 获取每个客户的R、F、M原始值
|
||||
$orderModel = new TrafficOrderModel();
|
||||
$customers = $orderModel
|
||||
->where($where)
|
||||
->where(function ($query) {
|
||||
// 只统计有效订单(actualPay大于0)
|
||||
$query->where('actualPay', '>', 0);
|
||||
})
|
||||
->field('identifier, MAX(createTime) as lastOrderTime, COUNT(DISTINCT id) as orderCount, SUM(CAST(actualPay AS DECIMAL(18,2))) as totalAmount')
|
||||
->group('identifier')
|
||||
->select();
|
||||
|
||||
if (empty($customers)) {
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '暂无数据',
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
|
||||
// 2. 计算每个客户的R值(最近消费天数)
|
||||
$customerData = [];
|
||||
foreach ($customers as $customer) {
|
||||
$recencyDays = floor(($endTime - $customer['lastOrderTime']) / (24 * 3600));
|
||||
$customerData[] = [
|
||||
'identifier' => $customer['identifier'],
|
||||
'R' => $recencyDays,
|
||||
'F' => (int)$customer['orderCount'],
|
||||
'M' => (float)$customer['totalAmount'],
|
||||
];
|
||||
}
|
||||
|
||||
// 3. 异常值处理 - 剔除大额异常订单
|
||||
$mValues = array_column($customerData, 'M');
|
||||
if (!empty($mValues)) {
|
||||
sort($mValues);
|
||||
$m99Percentile = $this->percentile($mValues, 0.99);
|
||||
$abnormalThreshold = $m99Percentile * $abnormalMoneyRatio;
|
||||
|
||||
// 标记异常客户(但不删除,仅在计算M维度区间时考虑)
|
||||
foreach ($customerData as &$customer) {
|
||||
$customer['isAbnormal'] = $customer['M'] > $abnormalThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 使用五分位法计算各维度的区间阈值
|
||||
$rThresholds = $this->calculatePercentiles(array_column($customerData, 'R'), true); // R是反向的
|
||||
$fThresholds = $this->calculatePercentiles(array_column($customerData, 'F'), false);
|
||||
// M维度排除异常值计算区间
|
||||
$mValuesForPercentile = array_filter(array_column($customerData, 'M'), function($m) use ($abnormalThreshold) {
|
||||
return isset($abnormalThreshold) ? $m <= $abnormalThreshold : true;
|
||||
});
|
||||
$mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false);
|
||||
|
||||
// 5. 计算每个客户的RFM分项得分
|
||||
$results = [];
|
||||
foreach ($customerData as $customer) {
|
||||
$rScore = $this->scoreByPercentile($customer['R'], $rThresholds, true); // R是反向的
|
||||
$fScore = $this->scoreByPercentile($customer['F'], $fThresholds, false);
|
||||
$mScore = $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
|
||||
|
||||
// 计算RFM总分(加权求和)
|
||||
$rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM;
|
||||
|
||||
// 可选:标准化为1-100分
|
||||
$standardScore = null;
|
||||
if ($scoreScale == 100) {
|
||||
$rfmMin = $weightR * 1 + $weightF * 1 + $weightM * 1;
|
||||
$rfmMax = $weightR * 5 + $weightF * 5 + $weightM * 5;
|
||||
$standardScore = (int)round(($rfmScore - $rfmMin) / ($rfmMax - $rfmMin) * 99 + 1);
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'identifier' => $customer['identifier'],
|
||||
'R_raw' => $customer['R'],
|
||||
'R_score' => $rScore,
|
||||
'F_raw' => $customer['F'],
|
||||
'F_score' => $fScore,
|
||||
'M_raw' => round($customer['M'], 2),
|
||||
'M_score' => $mScore,
|
||||
'RFM_score' => round($rfmScore, 2),
|
||||
'RFM_standard_score' => $standardScore,
|
||||
'cycle_start' => date('Y-m-d H:i:s', $startTime),
|
||||
'cycle_end' => date('Y-m-d H:i:s', $endTime),
|
||||
'calculate_time' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
// 按RFM总分降序排序
|
||||
usort($results, function($a, $b) {
|
||||
return $b['RFM_score'] <=> $a['RFM_score'];
|
||||
});
|
||||
|
||||
// 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值
|
||||
$this->updateRfmToTables($results, $ownerWechatId);
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'msg' => '计算成功',
|
||||
'data' => [
|
||||
'results' => $results,
|
||||
'config' => [
|
||||
'cycle_days' => $cycleDays,
|
||||
'weight_R' => $weightR,
|
||||
'weight_F' => $weightF,
|
||||
'weight_M' => $weightM,
|
||||
'score_scale' => $scoreScale,
|
||||
],
|
||||
'statistics' => [
|
||||
'total_customers' => count($results),
|
||||
'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2),
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => 500,
|
||||
'msg' => '计算失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 RFM 评分(兼容旧方法,使用固定阈值)
|
||||
* @param int|null $recencyDays 最近购买天数
|
||||
* @param int $frequency 购买次数
|
||||
* @param float $monetary 购买金额
|
||||
@@ -23,7 +220,9 @@ class RFMController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
// 默认规则
|
||||
/**
|
||||
* 使用固定阈值计算R得分(保留兼容性)
|
||||
*/
|
||||
protected static function scoreR_Default(int $days): int
|
||||
{
|
||||
if ($days <= 30) return 5;
|
||||
@@ -32,6 +231,10 @@ class RFMController extends BaseController
|
||||
if ($days <= 120) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用固定阈值计算F得分(保留兼容性)
|
||||
*/
|
||||
protected static function scoreF_Default(int $times): int
|
||||
{
|
||||
if ($times >= 10) return 5;
|
||||
@@ -41,6 +244,10 @@ class RFMController extends BaseController
|
||||
if ($times >= 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用固定阈值计算M得分(保留兼容性)
|
||||
*/
|
||||
protected static function scoreM_Default(float $amount): int
|
||||
{
|
||||
if ($amount >= 2000) return 5;
|
||||
@@ -50,6 +257,145 @@ class RFMController extends BaseController
|
||||
if ($amount > 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算百分位数(五分位法)
|
||||
* @param array $values 数值数组
|
||||
* @param bool $reverse 是否反向(R维度需要反向,值越小得分越高)
|
||||
* @return array 返回[0.2, 0.4, 0.6, 0.8]分位数的阈值数组
|
||||
*/
|
||||
private function calculatePercentiles($values, $reverse = false)
|
||||
{
|
||||
if (empty($values)) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
$uniqueValues = array_unique($values);
|
||||
sort($uniqueValues);
|
||||
|
||||
// 如果所有值相同,强制均分5个区间
|
||||
if (count($uniqueValues) == 1) {
|
||||
$singleValue = $uniqueValues[0];
|
||||
if ($reverse) {
|
||||
return [$singleValue, $singleValue, $singleValue, $singleValue];
|
||||
} else {
|
||||
return [$singleValue, $singleValue, $singleValue, $singleValue];
|
||||
}
|
||||
}
|
||||
|
||||
$percentiles = [0.2, 0.4, 0.6, 0.8];
|
||||
$thresholds = [];
|
||||
|
||||
foreach ($percentiles as $p) {
|
||||
$thresholds[] = $this->percentile($uniqueValues, $p);
|
||||
}
|
||||
|
||||
return $thresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算百分位数
|
||||
* @param array $sortedArray 已排序的数组
|
||||
* @param float $percentile 百分位数(0-1之间)
|
||||
* @return float
|
||||
*/
|
||||
private function percentile($sortedArray, $percentile)
|
||||
{
|
||||
if (empty($sortedArray)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = count($sortedArray);
|
||||
$index = ($count - 1) * $percentile;
|
||||
$floor = floor($index);
|
||||
$ceil = ceil($index);
|
||||
|
||||
if ($floor == $ceil) {
|
||||
return $sortedArray[(int)$index];
|
||||
}
|
||||
|
||||
$weight = $index - $floor;
|
||||
return $sortedArray[(int)$floor] * (1 - $weight) + $sortedArray[(int)$ceil] * $weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据五分位法阈值计算得分
|
||||
* @param float $value 当前值
|
||||
* @param array $thresholds 阈值数组[T1, T2, T3, T4]
|
||||
* @param bool $reverse 是否反向(R维度反向:值越小得分越高)
|
||||
* @return int 得分1-5
|
||||
*/
|
||||
private function scoreByPercentile($value, $thresholds, $reverse = false)
|
||||
{
|
||||
if (empty($thresholds) || count($thresholds) < 4) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
list($t1, $t2, $t3, $t4) = $thresholds;
|
||||
|
||||
if ($reverse) {
|
||||
// R维度:值越小得分越高
|
||||
if ($value <= $t1) return 5;
|
||||
if ($value <= $t2) return 4;
|
||||
if ($value <= $t3) return 3;
|
||||
if ($value <= $t4) return 2;
|
||||
return 1;
|
||||
} else {
|
||||
// F和M维度:值越大得分越高
|
||||
if ($value >= $t4) return 5;
|
||||
if ($value >= $t3) return 4;
|
||||
if ($value >= $t2) return 3;
|
||||
if ($value >= $t1) return 2;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新RFM值到 ck_traffic_source 和 s2_wechat_friend 表
|
||||
*
|
||||
* @param array $results RFM计算结果数组
|
||||
* @param string|null $ownerWechatId 微信ID,用于过滤更新范围
|
||||
*/
|
||||
private function updateRfmToTables($results, $ownerWechatId = null)
|
||||
{
|
||||
try {
|
||||
foreach ($results as $result) {
|
||||
$identifier = $result['identifier'];
|
||||
$rScore = (string)$result['R_score'];
|
||||
$fScore = (string)$result['F_score'];
|
||||
$mScore = (string)$result['M_score'];
|
||||
|
||||
// 更新 ck_traffic_source 表
|
||||
// 根据 identifier 更新所有匹配的记录
|
||||
$trafficSourceUpdate = [
|
||||
'R' => $rScore,
|
||||
'F' => $fScore,
|
||||
'M' => $mScore,
|
||||
'updateTime' => time()
|
||||
];
|
||||
TrafficSource::where('identifier', $identifier)->update($trafficSourceUpdate);
|
||||
|
||||
// 更新 s2_wechat_friend 表
|
||||
// wechatId 对应 identifier
|
||||
$wechatFriendUpdate = [
|
||||
'R' => $rScore,
|
||||
'F' => $fScore,
|
||||
'M' => $mScore,
|
||||
'updateTime' => time()
|
||||
];
|
||||
$wechatFriendWhere = ['wechatId' => $identifier];
|
||||
if (!empty($ownerWechatId)) {
|
||||
$wechatFriendWhere['ownerWechatId'] = $ownerWechatId;
|
||||
}
|
||||
WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但不影响主流程
|
||||
\think\Log::error('更新RFM值失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
|
||||
namespace app\cunkebao\controller;
|
||||
|
||||
use app\common\model\Device;
|
||||
use app\common\model\DeviceUser;
|
||||
use app\common\model\User;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 门店端账号管理控制器
|
||||
*/
|
||||
class StoreAccountController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 创建账号
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
// 获取参数
|
||||
$account = $this->request->param('account', '');
|
||||
$username = $this->request->param('username', '');
|
||||
$phone = $this->request->param('phone', '');
|
||||
$password = $this->request->param('password', '');
|
||||
$deviceId = $this->request->param('deviceId', 0);
|
||||
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
// 参数验证
|
||||
if (empty($account)) {
|
||||
return ResponseHelper::error('账号不能为空');
|
||||
}
|
||||
if (empty($username)) {
|
||||
return ResponseHelper::error('昵称不能为空');
|
||||
}
|
||||
if (empty($phone)) {
|
||||
return ResponseHelper::error('手机号不能为空');
|
||||
}
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
return ResponseHelper::error('手机号格式不正确');
|
||||
}
|
||||
if (empty($password)) {
|
||||
return ResponseHelper::error('密码不能为空');
|
||||
}
|
||||
if (strlen($password) < 6 || strlen($password) > 20) {
|
||||
return ResponseHelper::error('密码长度必须在6-20个字符之间');
|
||||
}
|
||||
if (empty($deviceId)) {
|
||||
return ResponseHelper::error('请选择设备');
|
||||
}
|
||||
|
||||
// 检查账号是否已存在(同一 typeId 和 companyId 下不能重复)
|
||||
$existUser = Db::name('users')->where(['account' => $account, 'companyId' => $companyId, 'typeId' => 2, 'deleteTime' => 0])
|
||||
->find();
|
||||
if ($existUser) {
|
||||
return ResponseHelper::error('账号已存在');
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在(同一 typeId 和 companyId 下不能重复)
|
||||
$existPhone = Db::name('users')->where(['phone' => $phone, 'companyId' => $companyId, 'typeId' => 2, 'deleteTime' => 0])
|
||||
->find();
|
||||
if ($existPhone) {
|
||||
return ResponseHelper::error('手机号已被使用');
|
||||
}
|
||||
|
||||
// 检查设备是否存在且属于当前公司
|
||||
$device = Device::where('id', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->find();
|
||||
if (!$device) {
|
||||
return ResponseHelper::error('设备不存在或没有权限');
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 创建用户
|
||||
$userData = [
|
||||
'account' => $account,
|
||||
'username' => $username,
|
||||
'phone' => $phone,
|
||||
'passwordMd5' => md5($password),
|
||||
'passwordLocal' => localEncrypt($password),
|
||||
'avatar' => 'https://img.icons8.com/color/512/circled-user-male-skin-type-7.png',
|
||||
'isAdmin' => 0,
|
||||
'companyId' => $companyId,
|
||||
'typeId' => 2, // 门店端固定为2
|
||||
'status' => 1, // 默认可用
|
||||
'balance' => 0,
|
||||
'tokens' => 0,
|
||||
'createTime' => time(),
|
||||
];
|
||||
|
||||
$userId = Db::name('users')->insertGetId($userData);
|
||||
|
||||
// 绑定设备
|
||||
Db::name('device_user')->insert([
|
||||
'companyId' => $companyId,
|
||||
'userId' => $userId,
|
||||
'deviceId' => $deviceId,
|
||||
'deleteTime' => 0,
|
||||
]);
|
||||
|
||||
// 提交事务
|
||||
Db::commit();
|
||||
|
||||
return ResponseHelper::success('创建账号成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑账号
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
try {
|
||||
$userId = $this->request->param('userId', 0);
|
||||
$account = $this->request->param('account', '');
|
||||
$username = $this->request->param('username', '');
|
||||
$phone = $this->request->param('phone', '');
|
||||
$password = $this->request->param('password', '');
|
||||
$deviceId = $this->request->param('deviceId', 0);
|
||||
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
// 参数验证
|
||||
if (empty($userId)) {
|
||||
return ResponseHelper::error('用户ID不能为空');
|
||||
}
|
||||
|
||||
// 检查用户是否存在且属于当前公司
|
||||
$user = Db::name('users')->where(['id' => $userId, 'companyId' => $companyId, 'typeId' => 2])->find();
|
||||
if (!$user) {
|
||||
return ResponseHelper::error('用户不存在或没有权限');
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
|
||||
// 更新账号
|
||||
if (!empty($account)) {
|
||||
// 检查账号是否已被其他用户使用(同一 typeId 下)
|
||||
$existUser = Db::name('users')->where(['account' => $account, 'companyId' => $companyId, 'typeId' => 2, 'deleteTime' => 0])
|
||||
->where('id', '<>', $userId)
|
||||
->find();
|
||||
if ($existUser) {
|
||||
return ResponseHelper::error('账号已被使用');
|
||||
}
|
||||
$updateData['account'] = $account;
|
||||
}
|
||||
|
||||
// 更新昵称
|
||||
if (!empty($username)) {
|
||||
$updateData['username'] = $username;
|
||||
}
|
||||
|
||||
// 更新手机号
|
||||
if (!empty($phone)) {
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
return ResponseHelper::error('手机号格式不正确');
|
||||
}
|
||||
// 检查手机号是否已被其他用户使用(同一 typeId 下)
|
||||
$existPhone = Db::name('users')->where(['phone' => $phone, 'companyId' => $companyId, 'typeId' => 2, 'deleteTime' => 0])
|
||||
->where('id', '<>', $userId)
|
||||
->find();
|
||||
if ($existPhone) {
|
||||
return ResponseHelper::error('手机号已被使用');
|
||||
}
|
||||
$updateData['phone'] = $phone;
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
if (!empty($password)) {
|
||||
if (strlen($password) < 6 || strlen($password) > 20) {
|
||||
return ResponseHelper::error('密码长度必须在6-20个字符之间');
|
||||
}
|
||||
$updateData['passwordMd5'] = md5($password);
|
||||
$updateData['passwordLocal'] = localEncrypt($password);
|
||||
}
|
||||
|
||||
// 更新设备绑定
|
||||
if (!empty($deviceId)) {
|
||||
// 检查设备是否存在且属于当前公司
|
||||
$device = Device::where('id', $deviceId)
|
||||
->where('companyId', $companyId)
|
||||
->find();
|
||||
if (!$device) {
|
||||
return ResponseHelper::error('设备不存在或没有权限');
|
||||
}
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 更新用户信息
|
||||
if (!empty($updateData)) {
|
||||
$updateData['updateTime'] = time();
|
||||
Db::name('users')->where(['id' => $userId])->update($updateData);
|
||||
}
|
||||
|
||||
// 更新设备绑定
|
||||
if (!empty($deviceId)) {
|
||||
// 删除旧的设备绑定
|
||||
Db::name('device_user')->where(['userId' => $userId, 'companyId' => $companyId])->delete();
|
||||
|
||||
// 添加新的设备绑定
|
||||
Db::name('device_user')->insert([
|
||||
'companyId' => $companyId,
|
||||
'userId' => $userId,
|
||||
'deviceId' => $deviceId,
|
||||
'deleteTime' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
Db::commit();
|
||||
|
||||
return ResponseHelper::success('更新账号成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账号
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
$userId = $this->request->param('userId', 0);
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($userId)) {
|
||||
return ResponseHelper::error('用户ID不能为空');
|
||||
}
|
||||
|
||||
// 检查用户是否存在且属于当前公司
|
||||
$user = Db::name('users')->where(['id' => $userId, 'companyId' => $companyId, 'typeId' => 2])->find();
|
||||
if (!$user) {
|
||||
return ResponseHelper::error('用户不存在或没有权限');
|
||||
}
|
||||
|
||||
// 检查是否是管理账号
|
||||
if ($user['isAdmin'] == 1) {
|
||||
return ResponseHelper::error('管理账号无法删除');
|
||||
}
|
||||
|
||||
// 软删除用户
|
||||
Db::name('users')->where(['id' => $userId])->update([
|
||||
'deleteTime' => time(),
|
||||
'updateTime' => time()
|
||||
]);
|
||||
|
||||
// 软删除设备绑定关系
|
||||
Db::name('device_user')->where(['userId' => $userId, 'companyId' => $companyId])->update([
|
||||
'deleteTime' => time()
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('删除账号成功');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用/启用账号
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function disable()
|
||||
{
|
||||
try {
|
||||
$userId = $this->request->param('userId', 0);
|
||||
$status = $this->request->param('status', -1); // 0-禁用 1-启用
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($userId)) {
|
||||
return ResponseHelper::error('用户ID不能为空');
|
||||
}
|
||||
|
||||
if ($status != 0 && $status != 1) {
|
||||
return ResponseHelper::error('状态参数错误');
|
||||
}
|
||||
|
||||
// 检查用户是否存在且属于当前公司
|
||||
$user = Db::name('users')->where(['id' => $userId, 'companyId' => $companyId, 'typeId' => 2])->find();
|
||||
if (!$user) {
|
||||
return ResponseHelper::error('用户不存在或没有权限');
|
||||
}
|
||||
|
||||
// 检查是否是管理账号
|
||||
if ($user['isAdmin'] == 1 && $status == 0) {
|
||||
return ResponseHelper::error('管理账号无法禁用');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
Db::name('users')->where(['id' => $userId])->update([
|
||||
'status' => $status,
|
||||
'updateTime' => time()
|
||||
]);
|
||||
|
||||
$message = $status == 0 ? '禁用账号成功' : '启用账号成功';
|
||||
return ResponseHelper::success($message);
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$keyword = $this->request->param('keyword', '');
|
||||
$status = $this->request->param('status', '');
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 10);
|
||||
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
// 构建查询条件
|
||||
$where = [
|
||||
['companyId', '=', $companyId],
|
||||
['typeId', '=', 2], // 只查询门店端账号
|
||||
['deleteTime', '=', 0]
|
||||
];
|
||||
|
||||
// 关键词搜索(账号、昵称、手机号)
|
||||
if (!empty($keyword)) {
|
||||
$where[] = ['account|username|phone', "LIKE", '%'.$keyword.'%'];
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== '') {
|
||||
$where[] = ['status', '=', $status];
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
$query = Db::name('users')->where($where);
|
||||
$total = $query->count();
|
||||
|
||||
$list = $query->field('id,account,username,phone,avatar,isAdmin,status,balance,tokens,createTime')
|
||||
->order('id desc')
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
|
||||
|
||||
// 获取每个账号绑定的设备(单个设备)
|
||||
if (!empty($list)) {
|
||||
$userIds = array_column($list, 'id');
|
||||
$deviceBindings = Db::name('device_user')
|
||||
->alias('du')
|
||||
->join('device d', 'd.id = du.deviceId', 'left')
|
||||
->where([
|
||||
['du.userId', 'in', $userIds],
|
||||
['du.companyId', '=', $companyId],
|
||||
['du.deleteTime', '=', 0]
|
||||
])
|
||||
->field('du.userId,du.deviceId,d.imei,d.memo')
|
||||
->order('du.id desc')
|
||||
->select();
|
||||
|
||||
// 组织设备数据(单个设备对象)
|
||||
$deviceMap = [];
|
||||
foreach ($deviceBindings as $binding) {
|
||||
$deviceMap[$binding['userId']] = [
|
||||
'deviceId' => $binding['deviceId'],
|
||||
'imei' => $binding['imei'],
|
||||
'memo' => $binding['memo']
|
||||
];
|
||||
}
|
||||
|
||||
// 将设备信息添加到用户数据中
|
||||
foreach ($list as &$item) {
|
||||
$item['device'] = $deviceMap[$item['id']] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHelper::success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace app\cunkebao\controller;
|
||||
use app\common\controller\PaymentService;
|
||||
use app\common\model\Order;
|
||||
use app\cunkebao\model\TokensPackage;
|
||||
use app\chukebao\model\TokensCompany;
|
||||
use app\chukebao\model\TokensRecord;
|
||||
use library\ResponseHelper;
|
||||
use think\facade\Env;
|
||||
|
||||
@@ -23,9 +25,9 @@ class TokensController extends BaseController
|
||||
$list = $query->where($where)->page($page, $limit)->order('sort ASC,id desc')->select();
|
||||
foreach ($list as &$item) {
|
||||
$item['description'] = json_decode($item['description'], true);
|
||||
$item['discount'] = round(((($item['originalPrice'] - $item['price']) / $item['originalPrice']) * 100),2);
|
||||
$item['price'] = round( $item['price'], 2);
|
||||
$item['unitPrice'] = round( $item['price'] / $item['tokens'],6);
|
||||
$item['discount'] = round(((($item['originalPrice'] - $item['price']) / $item['originalPrice']) * 100), 2);
|
||||
$item['price'] = round($item['price'], 2);
|
||||
$item['unitPrice'] = round($item['price'] / $item['tokens'], 6);
|
||||
$item['originalPrice'] = round($item['originalPrice'] / 100, 2);
|
||||
$item['tokens'] = number_format($item['tokens']);
|
||||
}
|
||||
@@ -40,6 +42,12 @@ class TokensController extends BaseController
|
||||
$price = $this->request->param('price', '');
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
$payType = $this->request->param('payType', 'qrCode');
|
||||
|
||||
if (!in_array($payType, ['wechat', 'alipay', 'qrCode'])) {
|
||||
return ResponseHelper::error('付款类型不正确');
|
||||
}
|
||||
|
||||
|
||||
if (empty($id) && empty($price)) {
|
||||
return ResponseHelper::error('套餐和自定义购买金额必须选一个');
|
||||
@@ -73,6 +81,7 @@ class TokensController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
$orderNo = date('YmdHis') . rand(100000, 999999);
|
||||
$order = [
|
||||
'companyId' => $companyId,
|
||||
@@ -82,7 +91,8 @@ class TokensController extends BaseController
|
||||
'goodsName' => $specs['name'],
|
||||
'goodsSpecs' => $specs,
|
||||
'orderType' => 1,
|
||||
'money' => $specs['price']
|
||||
'money' => $specs['price'],
|
||||
'service' => $payType
|
||||
];
|
||||
$paymentService = new PaymentService();
|
||||
$res = $paymentService->createOrder($order);
|
||||
@@ -106,13 +116,200 @@ class TokensController extends BaseController
|
||||
$res = $paymentService->queryOrder($orderNo);
|
||||
$res = json_decode($res, true);
|
||||
if ($res['code'] == 200) {
|
||||
return ResponseHelper::success('','订单已支付');
|
||||
return ResponseHelper::success('', '订单已支付');
|
||||
} else {
|
||||
$errorMsg = !empty($order['payInfo']) ? $order['payInfo'] : '订单未支付';
|
||||
return ResponseHelper::error($errorMsg);
|
||||
}
|
||||
} else {
|
||||
return ResponseHelper::success('','订单已支付');
|
||||
return ResponseHelper::success('', '订单已支付');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取订单列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getOrderList()
|
||||
{
|
||||
try {
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$status = $this->request->param('status', ''); // 订单状态筛选
|
||||
$keyword = $this->request->param('keyword', ''); // 关键词搜索(订单号)
|
||||
$orderType = $this->request->param('orderType', ''); // 订单类型筛选
|
||||
$startTime = $this->request->param('startTime', ''); // 开始时间
|
||||
$endTime = $this->request->param('endTime', ''); // 结束时间
|
||||
|
||||
$userId = $this->getUserInfo('id');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
// 构建查询条件
|
||||
$where = [
|
||||
['companyId', '=', $companyId]
|
||||
];
|
||||
|
||||
// 关键词搜索(订单号、商品名称)
|
||||
if (!empty($keyword)) {
|
||||
$where[] = ['orderNo|goodsName', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
// 状态筛选 (0-待支付 1-已付款 2-已退款 3-付款失败)
|
||||
if ($status !== '') {
|
||||
$where[] = ['status', '=', $status];
|
||||
}
|
||||
|
||||
// 订单类型筛选
|
||||
if ($orderType !== '') {
|
||||
$where[] = ['orderType', '=', $orderType];
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
if (!empty($startTime)) {
|
||||
$where[] = ['createTime', '>=', strtotime($startTime)];
|
||||
}
|
||||
if (!empty($endTime)) {
|
||||
$where[] = ['createTime', '<=', strtotime($endTime . ' 23:59:59')];
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
$query = Order::where($where)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('deleteTime')->whereOr('deleteTime', 0);
|
||||
});
|
||||
$total = $query->count();
|
||||
|
||||
$list = $query->field('id,orderNo,goodsId,goodsName,goodsSpecs,orderType,money,status,payType,payTime,createTime')
|
||||
->order('id desc')
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
|
||||
// 格式化数据
|
||||
foreach ($list as &$item) {
|
||||
// 金额转换(分转元)
|
||||
$item['money'] = round($item['money'] / 100, 2);
|
||||
|
||||
// 解析商品规格
|
||||
if (!empty($item['goodsSpecs'])) {
|
||||
$specs = is_string($item['goodsSpecs']) ? json_decode($item['goodsSpecs'], true) : $item['goodsSpecs'];
|
||||
$item['goodsSpecs'] = $specs;
|
||||
|
||||
// 添加算力数量
|
||||
if (isset($specs['tokens'])) {
|
||||
$item['tokens'] = number_format($specs['tokens']);
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
$statusText = [
|
||||
0 => '待支付',
|
||||
1 => '已付款',
|
||||
2 => '已退款',
|
||||
3 => '付款失败'
|
||||
];
|
||||
$item['statusText'] = $statusText[$item['status']] ?? '未知';
|
||||
|
||||
// 订单类型文本
|
||||
$orderTypeText = [
|
||||
1 => '购买算力'
|
||||
];
|
||||
$item['orderTypeText'] = $orderTypeText[$item['orderType']] ?? '其他';
|
||||
|
||||
// 支付类型文本
|
||||
$payTypeText = [
|
||||
1 => '微信支付',
|
||||
2 => '支付宝'
|
||||
];
|
||||
$item['payTypeText'] = !empty($item['payType']) ? ($payTypeText[$item['payType']] ?? '未知') : '';
|
||||
|
||||
// 格式化时间
|
||||
$item['createTime'] = $item['createTime'] ? date('Y-m-d H:i:s', $item['createTime']) : '';
|
||||
$item['payTime'] = $item['payTime'] ? date('Y-m-d H:i:s', $item['payTime']) : '';
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return ResponseHelper::success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('获取订单列表失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公司算力统计信息
|
||||
* 包括:总算力、今日使用、本月使用、剩余算力
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getTokensStatistics()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('公司信息获取失败');
|
||||
}
|
||||
|
||||
// 获取公司算力余额
|
||||
$tokensCompany = TokensCompany::where('companyId', $companyId)->find();
|
||||
$remainingTokens = $tokensCompany ? intval($tokensCompany->tokens) : 0;
|
||||
|
||||
// 获取今日开始和结束时间戳
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
|
||||
// 获取本月开始和结束时间戳
|
||||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||
$monthEnd = strtotime(date('Y-m-t 23:59:59'));
|
||||
|
||||
// 统计今日消费(type=0表示消费)
|
||||
$todayUsed = TokensRecord::where([
|
||||
['companyId', '=', $companyId],
|
||||
['type', '=', 0], // 0为减少(消费)
|
||||
['createTime', '>=', $todayStart],
|
||||
['createTime', '<=', $todayEnd]
|
||||
])->sum('tokens');
|
||||
$todayUsed = intval($todayUsed);
|
||||
|
||||
// 统计本月消费
|
||||
$monthUsed = TokensRecord::where([
|
||||
['companyId', '=', $companyId],
|
||||
['type', '=', 0], // 0为减少(消费)
|
||||
['createTime', '>=', $monthStart],
|
||||
['createTime', '<=', $monthEnd]
|
||||
])->sum('tokens');
|
||||
$monthUsed = intval($monthUsed);
|
||||
|
||||
// 计算总算力(当前剩余 + 历史总消费)
|
||||
$totalConsumed = TokensRecord::where([
|
||||
['companyId', '=', $companyId],
|
||||
['type', '=', 0]
|
||||
])->sum('tokens');
|
||||
$totalConsumed = intval($totalConsumed);
|
||||
|
||||
// 总充值算力
|
||||
$totalRecharged = TokensRecord::where([
|
||||
['companyId', '=', $companyId],
|
||||
['type', '=', 1] // 1为增加(充值)
|
||||
])->sum('tokens');
|
||||
$totalRecharged = intval($totalRecharged);
|
||||
|
||||
return ResponseHelper::success([
|
||||
'totalTokens' => $totalRecharged, // 总算力(累计充值)
|
||||
'todayUsed' => $todayUsed, // 今日使用
|
||||
'monthUsed' => $monthUsed, // 本月使用
|
||||
'remainingTokens' => $remainingTokens, // 剩余算力
|
||||
'totalConsumed' => $totalConsumed, // 累计消费
|
||||
], '获取成功');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('获取算力统计失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,13 +109,26 @@ class WorkbenchController extends Controller
|
||||
$config = new WorkbenchGroupPush;
|
||||
$config->workbenchId = $workbench->id;
|
||||
$config->pushType = !empty($param['pushType']) ? 1 : 0; // 推送方式:定时/立即
|
||||
$config->targetType = !empty($param['targetType']) ? intval($param['targetType']) : 1; // 推送目标类型:1=群推送,2=好友推送
|
||||
$config->startTime = $param['startTime'];
|
||||
$config->endTime = $param['endTime'];
|
||||
$config->maxPerDay = intval($param['maxPerDay']); // 每日推送数
|
||||
$config->pushOrder = $param['pushOrder']; // 推送顺序
|
||||
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
|
||||
// 根据targetType存储不同的数据
|
||||
if ($config->targetType == 1) {
|
||||
// 群推送
|
||||
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
|
||||
$config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 群组信息
|
||||
$config->friends = json_encode([], JSON_UNESCAPED_UNICODE); // 好友信息为空数组
|
||||
$config->devices = json_encode([], JSON_UNESCAPED_UNICODE); // 群推送不需要设备
|
||||
} else {
|
||||
// 好友推送:isLoop必须为0,设备必填
|
||||
$config->isLoop = 0; // 好友推送时强制为0
|
||||
$config->friends = json_encode($param['wechatFriends'] ?? [], JSON_UNESCAPED_UNICODE); // 好友信息(可以为空数组)
|
||||
$config->groups = json_encode([], JSON_UNESCAPED_UNICODE); // 群组信息为空数组
|
||||
$config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 设备信息(必填)
|
||||
}
|
||||
$config->status = !empty($param['status']) ? 1 : 0; // 是否启用
|
||||
$config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息
|
||||
$config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息
|
||||
$config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : '';
|
||||
$config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : '';
|
||||
@@ -216,7 +229,7 @@ class WorkbenchController extends Controller
|
||||
$query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account');
|
||||
},
|
||||
'groupPush' => function ($query) {
|
||||
$query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries');
|
||||
$query->field('workbenchId,pushType,targetType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,devices,contentLibraries');
|
||||
},
|
||||
'groupCreate' => function($query) {
|
||||
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups');
|
||||
@@ -289,13 +302,25 @@ class WorkbenchController extends Controller
|
||||
if (!empty($item->groupPush)) {
|
||||
$item->config = $item->groupPush;
|
||||
$item->config->pushType = $item->config->pushType;
|
||||
$item->config->targetType = isset($item->config->targetType) ? intval($item->config->targetType) : 1; // 默认1=群推送
|
||||
$item->config->startTime = $item->config->startTime;
|
||||
$item->config->endTime = $item->config->endTime;
|
||||
$item->config->maxPerDay = $item->config->maxPerDay;
|
||||
$item->config->pushOrder = $item->config->pushOrder;
|
||||
$item->config->isLoop = $item->config->isLoop;
|
||||
$item->config->status = $item->config->status;
|
||||
$item->config->groups = json_decode($item->config->groups, true);
|
||||
// 根据targetType解析不同的数据
|
||||
if ($item->config->targetType == 1) {
|
||||
// 群推送
|
||||
$item->config->wechatGroups = json_decode($item->config->groups, true) ?: [];
|
||||
$item->config->wechatFriends = [];
|
||||
$item->config->deviceGroups = [];
|
||||
} else {
|
||||
// 好友推送
|
||||
$item->config->wechatFriends = json_decode($item->config->friends, true) ?: [];
|
||||
$item->config->wechatGroups = [];
|
||||
$item->config->deviceGroups = json_decode($item->config->devices ?? '[]', true) ?: [];
|
||||
}
|
||||
$item->config->contentLibraries = json_decode($item->config->contentLibraries, true);
|
||||
$item->config->lastPushTime = '';
|
||||
}
|
||||
@@ -413,7 +438,7 @@ class WorkbenchController extends Controller
|
||||
$query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account');
|
||||
},
|
||||
'groupPush' => function ($query) {
|
||||
$query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries');
|
||||
$query->field('workbenchId,pushType,targetType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,devices,contentLibraries');
|
||||
},
|
||||
'groupCreate' => function($query) {
|
||||
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups');
|
||||
@@ -484,7 +509,19 @@ class WorkbenchController extends Controller
|
||||
case self::TYPE_GROUP_PUSH:
|
||||
if (!empty($workbench->groupPush)) {
|
||||
$workbench->config = $workbench->groupPush;
|
||||
$workbench->config->wechatGroups = json_decode($workbench->config->groups, true);
|
||||
$workbench->config->targetType = isset($workbench->config->targetType) ? intval($workbench->config->targetType) : 1; // 默认1=群推送
|
||||
// 根据targetType解析不同的数据
|
||||
if ($workbench->config->targetType == 1) {
|
||||
// 群推送
|
||||
$workbench->config->wechatGroups = json_decode($workbench->config->groups, true) ?: [];
|
||||
$workbench->config->wechatFriends = [];
|
||||
$workbench->config->deviceGroups = [];
|
||||
} else {
|
||||
// 好友推送
|
||||
$workbench->config->wechatFriends = json_decode($workbench->config->friends, true) ?: [];
|
||||
$workbench->config->wechatGroups = [];
|
||||
$workbench->config->deviceGroups = json_decode($workbench->config->devices ?? '[]', true) ?: [];
|
||||
}
|
||||
$workbench->config->contentLibraries = json_decode($workbench->config->contentLibraries, true);
|
||||
unset($workbench->groupPush, $workbench->group_push);
|
||||
}
|
||||
@@ -603,11 +640,11 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
|
||||
|
||||
// 获取群
|
||||
if (!empty($workbench->config->wechatGroups)){
|
||||
// 获取群(当targetType=1时)
|
||||
if (!empty($workbench->config->wechatGroups) && isset($workbench->config->targetType) && $workbench->config->targetType == 1){
|
||||
$groupList = Db::name('wechat_group')->alias('wg')
|
||||
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
|
||||
->where('wg.id', 'in', $workbench->config->groups)
|
||||
->where('wg.id', 'in', $workbench->config->wechatGroups)
|
||||
->order('wg.id', 'desc')
|
||||
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar')
|
||||
->select();
|
||||
@@ -616,6 +653,19 @@ class WorkbenchController extends Controller
|
||||
$workbench->config->wechatGroupsOptions = [];
|
||||
}
|
||||
|
||||
// 获取好友(当targetType=2时)
|
||||
if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2){
|
||||
$friendList = Db::table('s2_wechat_friend')->alias('wf')
|
||||
->join('s2_wechat_account wa', 'wa.id = wf.wechatAccountId', 'left')
|
||||
->where('wf.id', 'in', $workbench->config->wechatFriends)
|
||||
->order('wf.id', 'desc')
|
||||
->field('wf.id,wf.wechatId,wf.nickname as friendName,wf.avatar as friendAvatar,wf.conRemark,wf.ownerWechatId,wa.nickName as accountName,wa.avatar as accountAvatar')
|
||||
->select();
|
||||
$workbench->config->wechatFriendsOptions = $friendList;
|
||||
}else{
|
||||
$workbench->config->wechatFriendsOptions = [];
|
||||
}
|
||||
|
||||
// 获取内容库名称
|
||||
if (!empty($workbench->config->contentGroups)) {
|
||||
$libraryNames = ContentLibrary::where('id', 'in', $workbench->config->contentGroups)->select();
|
||||
@@ -748,13 +798,26 @@ class WorkbenchController extends Controller
|
||||
$config = WorkbenchGroupPush::where('workbenchId', $param['id'])->find();
|
||||
if ($config) {
|
||||
$config->pushType = !empty($param['pushType']) ? 1 : 0; // 推送方式:定时/立即
|
||||
$config->targetType = !empty($param['targetType']) ? intval($param['targetType']) : 1; // 推送目标类型:1=群推送,2=好友推送
|
||||
$config->startTime = $param['startTime'];
|
||||
$config->endTime = $param['endTime'];
|
||||
$config->maxPerDay = intval($param['maxPerDay']); // 每日推送数
|
||||
$config->pushOrder = $param['pushOrder']; // 推送顺序
|
||||
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
|
||||
// 根据targetType存储不同的数据
|
||||
if ($config->targetType == 1) {
|
||||
// 群推送
|
||||
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
|
||||
$config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 群组信息
|
||||
$config->friends = json_encode([], JSON_UNESCAPED_UNICODE); // 好友信息为空数组
|
||||
$config->devices = json_encode([], JSON_UNESCAPED_UNICODE); // 群推送不需要设备
|
||||
} else {
|
||||
// 好友推送:isLoop必须为0,设备必填
|
||||
$config->isLoop = 0; // 好友推送时强制为0
|
||||
$config->friends = json_encode($param['wechatFriends'] ?? [], JSON_UNESCAPED_UNICODE); // 好友信息(可以为空数组)
|
||||
$config->groups = json_encode([], JSON_UNESCAPED_UNICODE); // 群组信息为空数组
|
||||
$config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 设备信息(必填)
|
||||
}
|
||||
$config->status = !empty($param['status']) ? 1 : 0; // 是否启用
|
||||
$config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息
|
||||
$config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息
|
||||
$config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : '';
|
||||
$config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : '';
|
||||
@@ -957,6 +1020,7 @@ class WorkbenchController extends Controller
|
||||
$newConfig = new WorkbenchGroupPush;
|
||||
$newConfig->workbenchId = $newWorkbench->id;
|
||||
$newConfig->pushType = $config->pushType;
|
||||
$newConfig->targetType = isset($config->targetType) ? $config->targetType : 1; // 默认1=群推送
|
||||
$newConfig->startTime = $config->startTime;
|
||||
$newConfig->endTime = $config->endTime;
|
||||
$newConfig->maxPerDay = $config->maxPerDay;
|
||||
@@ -964,7 +1028,11 @@ class WorkbenchController extends Controller
|
||||
$newConfig->isLoop = $config->isLoop;
|
||||
$newConfig->status = $config->status;
|
||||
$newConfig->groups = $config->groups;
|
||||
$newConfig->friends = $config->friends;
|
||||
$newConfig->devices = $config->devices;
|
||||
$newConfig->contentLibraries = $config->contentLibraries;
|
||||
$newConfig->socialMediaId = $config->socialMediaId;
|
||||
$newConfig->promotionSiteId = $config->promotionSiteId;
|
||||
$newConfig->createTime = time();
|
||||
$newConfig->updateTime = time();
|
||||
$newConfig->save();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace app\cunkebao\controller\plan;
|
||||
use think\Controller;
|
||||
use think\Request;
|
||||
use EasyWeChat\Factory;
|
||||
use think\facade\Env;
|
||||
|
||||
// use EasyWeChat\Kernel\Exceptions\DecryptException;
|
||||
use EasyWeChat\Kernel\Http\StreamResponse;
|
||||
@@ -12,17 +13,28 @@ use think\Db;
|
||||
|
||||
class PosterWeChatMiniProgram extends Controller
|
||||
{
|
||||
|
||||
protected $config;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// 从环境变量获取配置
|
||||
$this->config = [
|
||||
'app_id' => Env::get('weChat.appidMiniApp','wx789850448e26c91d'),
|
||||
'secret' => Env::get('weChat.secretMiniApp','d18f75b3a3623cb40da05648b08365a1'),
|
||||
'response_type' => 'array'
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
return 'Hello, World!';
|
||||
}
|
||||
|
||||
const MINI_PROGRAM_CONFIG = [
|
||||
'app_id' => 'wx789850448e26c91d',
|
||||
'secret' => 'd18f75b3a3623cb40da05648b08365a1',
|
||||
'response_type' => 'array'
|
||||
];
|
||||
|
||||
|
||||
// 生成小程序码,存客宝-操盘手调用
|
||||
public function generateMiniProgramCodeWithScene($taskId = '')
|
||||
@@ -34,7 +46,7 @@ class PosterWeChatMiniProgram extends Controller
|
||||
|
||||
|
||||
try {
|
||||
$app = Factory::miniProgram(self::MINI_PROGRAM_CONFIG);
|
||||
$app = Factory::miniProgram($this->config);
|
||||
// scene参数长度限制为32位
|
||||
//$scene = 'taskId=' . $taskId;
|
||||
$scene = sprintf("%s", $taskId);
|
||||
@@ -83,7 +95,7 @@ class PosterWeChatMiniProgram extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$app = Factory::miniProgram(self::MINI_PROGRAM_CONFIG);
|
||||
$app = Factory::miniProgram($this->config);
|
||||
|
||||
$result = $app->phone_number->getUserPhoneNumber($code);
|
||||
|
||||
|
||||
@@ -38,13 +38,16 @@ class Workbench extends Validate
|
||||
'contentGroups' => 'requireIf:type,2|array',
|
||||
// 群消息推送特有参数
|
||||
'pushType' => 'requireIf:type,3|in:0,1', // 推送方式 0定时 1立即
|
||||
'targetType' => 'requireIf:type,3|in:1,2', // 推送目标类型:1=群推送,2=好友推送
|
||||
'startTime' => 'requireIf:type,3|dateFormat:H:i',
|
||||
'endTime' => 'requireIf:type,3|dateFormat:H:i',
|
||||
'maxPerDay' => 'requireIf:type,3|number|min:1',
|
||||
'pushOrder' => 'requireIf:type,3|in:1,2', // 1最早 2最新
|
||||
'isLoop' => 'requireIf:type,3|in:0,1',
|
||||
'status' => 'requireIf:type,3|in:0,1',
|
||||
'wechatGroups' => 'requireIf:type,3|array|min:1',
|
||||
'wechatGroups' => 'checkGroupPushTarget|array|min:1', // 当targetType=1时必填
|
||||
'wechatFriends' => 'checkFriendPushTarget|array', // 当targetType=2时可选(可以为空)
|
||||
'deviceGroups' => 'checkFriendPushDevice|array|min:1', // 当targetType=2时必填
|
||||
'contentGroups' => 'requireIf:type,3|array|min:1',
|
||||
// 自动建群特有参数
|
||||
'groupNameTemplate' => 'requireIf:type,4|max:50',
|
||||
@@ -114,9 +117,19 @@ class Workbench extends Validate
|
||||
'pushOrder.in' => '推送顺序错误',
|
||||
'isLoop.requireIf' => '请选择是否循环推送',
|
||||
'isLoop.in' => '循环推送参数错误',
|
||||
'targetType.requireIf' => '请选择推送目标类型',
|
||||
'targetType.in' => '推送目标类型错误,只能选择群推送或好友推送',
|
||||
'wechatGroups.requireIf' => '请选择推送群组',
|
||||
'wechatGroups.checkGroupPushTarget' => '群推送时必须选择推送群组',
|
||||
'wechatGroups.array' => '推送群组格式错误',
|
||||
'wechatGroups.min' => '至少选择一个推送群组',
|
||||
'wechatFriends.requireIf' => '请选择推送好友',
|
||||
'wechatFriends.checkFriendPushTarget' => '好友推送时必须选择推送好友',
|
||||
'wechatFriends.array' => '推送好友格式错误',
|
||||
'deviceGroups.requireIf' => '请选择设备',
|
||||
'deviceGroups.checkFriendPushDevice' => '好友推送时必须选择设备',
|
||||
'deviceGroups.array' => '设备格式错误',
|
||||
'deviceGroups.min' => '至少选择一个设备',
|
||||
// 自动建群相关提示
|
||||
'groupNameTemplate.requireIf' => '请设置群名称前缀',
|
||||
'groupNameTemplate.max' => '群名称前缀最多50个字符',
|
||||
@@ -155,18 +168,18 @@ class Workbench extends Validate
|
||||
protected $scene = [
|
||||
'create' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups',
|
||||
'interval', 'maxLikes', 'startTime', 'endTime', 'contentTypes',
|
||||
'syncInterval', 'syncCount', 'syncType',
|
||||
'pushType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'contentGroups',
|
||||
'groupNamePrefix', 'maxGroups', 'membersPerGroup',
|
||||
'syncCount', 'syncType', 'accountGroups',
|
||||
'pushType', 'targetType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'wechatFriends', 'contentGroups',
|
||||
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
|
||||
'distributeType', 'timeType', 'accountGroups',
|
||||
],
|
||||
'update_status' => ['id', 'status'],
|
||||
'edit' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups',
|
||||
'update' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups',
|
||||
'interval', 'maxLikes', 'startTime', 'endTime', 'contentTypes',
|
||||
'syncInterval', 'syncCount', 'syncType',
|
||||
'pushType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'contentGroups',
|
||||
'groupNamePrefix', 'maxGroups', 'membersPerGroup',
|
||||
'syncCount', 'syncType', 'accountGroups',
|
||||
'pushType', 'targetType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'wechatFriends', 'deviceGroups', 'contentGroups',
|
||||
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
|
||||
'distributeType', 'timeType', 'accountGroups',
|
||||
]
|
||||
];
|
||||
|
||||
@@ -183,4 +196,69 @@ class Workbench extends Validate
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证群推送目标(当targetType=1时,wechatGroups必填)
|
||||
*/
|
||||
protected function checkGroupPushTarget($value, $rule, $data)
|
||||
{
|
||||
// 如果是群消息推送类型
|
||||
if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) {
|
||||
// 如果targetType=1(群推送),则wechatGroups必填
|
||||
$targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1
|
||||
if ($targetType == 1) {
|
||||
// 检查值是否存在且有效
|
||||
if (!isset($value) || $value === null || $value === '') {
|
||||
return false;
|
||||
}
|
||||
if (!is_array($value) || count($value) < 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证好友推送目标(当targetType=2时,wechatFriends可选,可以为空)
|
||||
*/
|
||||
protected function checkFriendPushTarget($value, $rule, $data)
|
||||
{
|
||||
// 如果是群消息推送类型
|
||||
if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) {
|
||||
// 如果targetType=2(好友推送),wechatFriends可以为空数组
|
||||
$targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1
|
||||
if ($targetType == 2) {
|
||||
// 如果提供了值,则必须是数组
|
||||
if (isset($value) && $value !== null && $value !== '') {
|
||||
if (!is_array($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证好友推送时设备必填(当targetType=2时,deviceGroups必填)
|
||||
*/
|
||||
protected function checkFriendPushDevice($value, $rule, $data)
|
||||
{
|
||||
// 如果是群消息推送类型
|
||||
if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) {
|
||||
// 如果targetType=2(好友推送),则deviceGroups必填
|
||||
$targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1
|
||||
if ($targetType == 2) {
|
||||
// 检查值是否存在且有效
|
||||
if (!isset($value) || $value === null || $value === '') {
|
||||
return false;
|
||||
}
|
||||
if (!is_array($value) || count($value) < 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class WorkbenchGroupPushJob
|
||||
{
|
||||
try {
|
||||
// 获取所有工作台
|
||||
$workbenches = Workbench::where(['status' => 1, 'type' => 3, 'isDel' => 0])->order('id desc')->select();
|
||||
$workbenches = Workbench::where(['status' => 1, 'type' => 3, 'isDel' => 0,'id' => 256])->order('id desc')->select();
|
||||
foreach ($workbenches as $workbench) {
|
||||
// 获取工作台配置
|
||||
$config = WorkbenchGroupPush::where('workbenchId', $workbench->id)->find();
|
||||
@@ -87,27 +87,13 @@ class WorkbenchGroupPushJob
|
||||
}
|
||||
|
||||
|
||||
// 发微信个人消息
|
||||
// 发送消息(支持群推送和好友推送)
|
||||
public function sendMsgToGroup($workbench, $config, $msgConf)
|
||||
{
|
||||
// 消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
// 当前,type 为文本、图片、动图表情包的时候,content为string, 其他情况为对象 {type: 'file/link/...', url: '', title: '', thunmbPath: '', desc: ''}
|
||||
// $result = [
|
||||
// "content" => $dataArray['content'],
|
||||
// "msgSubType" => 0,
|
||||
// "msgType" => $dataArray['msgType'],
|
||||
// "seq" => time(),
|
||||
// "wechatAccountId" => $dataArray['wechatAccountId'],
|
||||
// "wechatChatroomId" => 0,
|
||||
// "wechatFriendId" => $dataArray['wechatFriendId'],
|
||||
// ];
|
||||
|
||||
|
||||
$groups = json_decode($config['groups'], true);
|
||||
$groupsData = Db::name('wechat_group')->whereIn('id', $groups)->field('id,wechatAccountId,chatroomId,companyId,ownerWechatId')->select();
|
||||
if (empty($groupsData)) {
|
||||
return false;
|
||||
}
|
||||
$targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送
|
||||
|
||||
$toAccountId = '';
|
||||
$username = Env::get('api.username', '');
|
||||
@@ -117,89 +103,49 @@ class WorkbenchGroupPushJob
|
||||
}
|
||||
// 建立WebSocket
|
||||
$wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
|
||||
|
||||
if ($targetType == 1) {
|
||||
// 群推送
|
||||
$this->sendToGroups($workbench, $config, $msgConf, $wsController);
|
||||
} else {
|
||||
// 好友推送
|
||||
$this->sendToFriends($workbench, $config, $msgConf, $wsController);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送群消息
|
||||
*/
|
||||
protected function sendToGroups($workbench, $config, $msgConf, $wsController)
|
||||
{
|
||||
$groups = json_decode($config['groups'], true);
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groupsData = Db::name('wechat_group')->whereIn('id', $groups)->field('id,wechatAccountId,chatroomId,companyId,ownerWechatId')->select();
|
||||
if (empty($groupsData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($msgConf as $content) {
|
||||
$sendData = [];
|
||||
$sqlData = [];
|
||||
|
||||
foreach ($groupsData as $groups) {
|
||||
foreach ($groupsData as $group) {
|
||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
$sqlData[] = [
|
||||
'workbenchId' => $workbench['id'],
|
||||
'contentId' => $content['id'],
|
||||
'groupId' => $groups['id'],
|
||||
'wechatAccountId' => $groups['wechatAccountId'],
|
||||
'groupId' => $group['id'],
|
||||
'friendId' => null,
|
||||
'targetType' => 1,
|
||||
'wechatAccountId' => $group['wechatAccountId'],
|
||||
'createTime' => time()
|
||||
];
|
||||
|
||||
//内容
|
||||
if (!empty($content['content'])) {
|
||||
//京东转链
|
||||
if (!empty($config['promotionSiteId'])){
|
||||
$WorkbenchController = new WorkbenchController();
|
||||
$jdLink = $WorkbenchController->changeLink($content['content'],$config['promotionSiteId']);
|
||||
$jdLink = json_decode($jdLink, true);
|
||||
if($jdLink['code'] == 200){
|
||||
$content['content'] = $jdLink['data'];
|
||||
}
|
||||
}
|
||||
$sendData[] = [
|
||||
'content' => $content['content'],
|
||||
'msgType' => 1,
|
||||
'wechatAccountId' => $groups['wechatAccountId'],
|
||||
'wechatChatroomId' => $groups['id'],
|
||||
];
|
||||
}
|
||||
|
||||
switch ($content['contentType']) {
|
||||
case 1:
|
||||
//图片解析
|
||||
$imgs = json_decode($content['resUrls'], true);
|
||||
if (!empty($imgs)) {
|
||||
foreach ($imgs as $img) {
|
||||
$sendData[] = [
|
||||
'content' => $img,
|
||||
'msgType' => 3,
|
||||
'wechatAccountId' => $groups['wechatAccountId'],
|
||||
'wechatChatroomId' => $groups['id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
//链接解析
|
||||
$url = json_decode($content['urls'], true);
|
||||
if (!empty($url[0])) {
|
||||
$url = $url[0];
|
||||
$sendData[] = [
|
||||
'content' => [
|
||||
'desc' => '',
|
||||
'thumbPath' => $url['image'],
|
||||
'title' => $url['desc'],
|
||||
'type' => 'link',
|
||||
'url' => $url['url'],
|
||||
],
|
||||
'msgType' => 49,
|
||||
'wechatAccountId' => $groups['wechatAccountId'],
|
||||
'wechatChatroomId' => $groups['id'],
|
||||
];
|
||||
}
|
||||
|
||||
break;
|
||||
case 3:
|
||||
//视频解析
|
||||
$video = json_decode($content['urls'], true);
|
||||
if (!empty($video)) {
|
||||
$video = $video[0];
|
||||
}
|
||||
$sendData[] = [
|
||||
'content' => $video,
|
||||
'msgType' => 43,
|
||||
'wechatAccountId' => $groups['wechatAccountId'],
|
||||
'wechatChatroomId' => $groups['id'],
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
// 构建发送数据
|
||||
$sendData = $this->buildSendData($content, $config, $group['wechatAccountId'], $group['id'], 'group');
|
||||
if (empty($sendData)) {
|
||||
continue;
|
||||
}
|
||||
@@ -214,6 +160,262 @@ class WorkbenchGroupPushJob
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送好友消息
|
||||
*/
|
||||
protected function sendToFriends($workbench, $config, $msgConf, $wsController)
|
||||
{
|
||||
$friends = json_decode($config['friends'], true);
|
||||
$devices = json_decode($config['devices'] ?? '[]', true);
|
||||
|
||||
// 如果好友列表为空,则根据设备查询所有好友
|
||||
if (empty($friends)) {
|
||||
if (empty($devices)) {
|
||||
// 如果没有选择设备,则无法推送
|
||||
Log::warning('好友推送:未选择设备,无法推送全部好友');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 根据设备查询所有好友
|
||||
$friendsData = Db::table('s2_company_account')
|
||||
->alias('ca')
|
||||
->join(['s2_wechat_account' => 'wa'], 'ca.id = wa.deviceAccountId')
|
||||
->join(['s2_wechat_friend' => 'wf'], 'wf.wechatAccountId = wa.id')
|
||||
->where([
|
||||
'ca.status' => 0,
|
||||
'wf.isDeleted' => 0,
|
||||
'wa.deviceAlive' => 1,
|
||||
'wa.wechatAlive' => 1
|
||||
])
|
||||
->whereIn('wa.currentDeviceId', $devices)
|
||||
->field('wf.id,wf.wechatAccountId,wf.wechatId,wf.ownerWechatId')
|
||||
->group('wf.id')
|
||||
->select();
|
||||
} else {
|
||||
// 查询指定的好友信息
|
||||
$friendsData = Db::table('s2_wechat_friend')
|
||||
->whereIn('id', $friends)
|
||||
->where('isDeleted', 0)
|
||||
->field('id,wechatAccountId,wechatId,ownerWechatId')
|
||||
->select();
|
||||
}
|
||||
|
||||
if (empty($friendsData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取所有已推送的好友ID列表(去重,不限制时间范围,用于过滤避免重复推送)
|
||||
$sentFriendIds = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', 2)
|
||||
->column('friendId');
|
||||
$sentFriendIds = array_filter($sentFriendIds); // 过滤null值
|
||||
$sentFriendIds = array_unique($sentFriendIds); // 去重
|
||||
|
||||
// 获取今日已推送的好友ID列表(用于计算今日推送人数)
|
||||
$today = date('Y-m-d');
|
||||
$todayStartTimestamp = strtotime($today . ' 00:00:00');
|
||||
$todayEndTimestamp = strtotime($today . ' 23:59:59');
|
||||
$todaySentFriendIds = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', 2)
|
||||
->whereTime('createTime', 'between', [$todayStartTimestamp, $todayEndTimestamp])
|
||||
->column('friendId');
|
||||
$todaySentFriendIds = array_filter($todaySentFriendIds); // 过滤null值
|
||||
$todaySentFriendIds = array_unique($todaySentFriendIds); // 去重
|
||||
|
||||
// 过滤掉所有已推送的好友(不限制时间范围,避免重复推送)
|
||||
$friendsData = array_filter($friendsData, function($friend) use ($sentFriendIds) {
|
||||
return !in_array($friend['id'], $sentFriendIds);
|
||||
});
|
||||
|
||||
if (empty($friendsData)) {
|
||||
Log::info('好友推送:所有好友都已推送过');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新索引数组
|
||||
$friendsData = array_values($friendsData);
|
||||
|
||||
// 计算剩余可推送人数(基于今日推送人数)
|
||||
$todaySentCount = count($todaySentFriendIds);
|
||||
$maxPerDay = intval($config['maxPerDay']);
|
||||
$remainingCount = $maxPerDay - $todaySentCount;
|
||||
|
||||
if ($remainingCount <= 0) {
|
||||
Log::info('好友推送:今日推送人数已达上限');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 限制本次推送人数(不超过剩余可推送人数)
|
||||
$friendsData = array_slice($friendsData, 0, $remainingCount);
|
||||
|
||||
// 批量处理:每批最多500人
|
||||
$batchSize = 500;
|
||||
$batches = array_chunk($friendsData, $batchSize);
|
||||
|
||||
foreach ($msgConf as $content) {
|
||||
foreach ($batches as $batchIndex => $batch) {
|
||||
$sqlData = [];
|
||||
|
||||
foreach ($batch as $friend) {
|
||||
// 构建发送数据
|
||||
$sendData = $this->buildSendData($content, $config, $friend['wechatAccountId'], $friend['id'], 'friend');
|
||||
|
||||
if (empty($sendData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 发送个人消息
|
||||
foreach ($sendData as $send) {
|
||||
if ($send['msgType'] == 49){
|
||||
$sendContent = json_encode($send['content'], 256);
|
||||
} else {
|
||||
$sendContent = $send['content'];
|
||||
}
|
||||
$wsController->sendPersonal([
|
||||
'wechatFriendId' => $friend['id'],
|
||||
'wechatAccountId' => $friend['wechatAccountId'],
|
||||
'msgType' => $send['msgType'],
|
||||
'content' => $sendContent,
|
||||
]);
|
||||
}
|
||||
|
||||
// 准备插入发送记录
|
||||
$sqlData[] = [
|
||||
'workbenchId' => $workbench['id'],
|
||||
'contentId' => $content['id'],
|
||||
'groupId' => null,
|
||||
'friendId' => $friend['id'],
|
||||
'targetType' => 2,
|
||||
'wechatAccountId' => $friend['wechatAccountId'],
|
||||
'createTime' => time()
|
||||
];
|
||||
}
|
||||
|
||||
// 批量插入发送记录
|
||||
if (!empty($sqlData)) {
|
||||
Db::name('workbench_group_push_item')->insertAll($sqlData);
|
||||
Log::info("好友推送:第" . ($batchIndex + 1) . "批,推送了" . count($sqlData) . "个好友");
|
||||
}
|
||||
|
||||
// 如果不是最后一批,等待一下再处理下一批(避免一次性推送太多)
|
||||
if ($batchIndex < count($batches) - 1) {
|
||||
sleep(1); // 等待1秒
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发送数据
|
||||
*/
|
||||
protected function buildSendData($content, $config, $wechatAccountId, $targetId, $type = 'group')
|
||||
{
|
||||
$sendData = [];
|
||||
|
||||
// 内容处理
|
||||
if (!empty($content['content'])) {
|
||||
// 京东转链
|
||||
if (!empty($config['promotionSiteId'])) {
|
||||
$WorkbenchController = new WorkbenchController();
|
||||
$jdLink = $WorkbenchController->changeLink($content['content'], $config['promotionSiteId']);
|
||||
$jdLink = json_decode($jdLink, true);
|
||||
if ($jdLink['code'] == 200) {
|
||||
$content['content'] = $jdLink['data'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($type == 'group') {
|
||||
$sendData[] = [
|
||||
'content' => $content['content'],
|
||||
'msgType' => 1,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatChatroomId' => $targetId,
|
||||
];
|
||||
} else {
|
||||
$sendData[] = [
|
||||
'content' => $content['content'],
|
||||
'msgType' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 根据内容类型处理
|
||||
switch ($content['contentType']) {
|
||||
case 1:
|
||||
// 图片解析
|
||||
$imgs = json_decode($content['resUrls'], true);
|
||||
if (!empty($imgs)) {
|
||||
foreach ($imgs as $img) {
|
||||
if ($type == 'group') {
|
||||
$sendData[] = [
|
||||
'content' => $img,
|
||||
'msgType' => 3,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatChatroomId' => $targetId,
|
||||
];
|
||||
} else {
|
||||
$sendData[] = [
|
||||
'content' => $img,
|
||||
'msgType' => 3,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
// 链接解析
|
||||
$url = json_decode($content['urls'], true);
|
||||
if (!empty($url[0])) {
|
||||
$url = $url[0];
|
||||
$linkContent = [
|
||||
'desc' => $url['desc'],
|
||||
'thumbPath' => $url['image'],
|
||||
'title' => $url['desc'],
|
||||
'type' => 'link',
|
||||
'url' => $url['url'],
|
||||
];
|
||||
if ($type == 'group') {
|
||||
$sendData[] = [
|
||||
'content' => $linkContent,
|
||||
'msgType' => 49,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatChatroomId' => $targetId,
|
||||
];
|
||||
} else {
|
||||
$sendData[] = [
|
||||
'content' => $linkContent,
|
||||
'msgType' => 49,
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
// 视频解析
|
||||
$video = json_decode($content['resUrls'], true);
|
||||
if (!empty($video)) {
|
||||
$video = $video[0];
|
||||
}
|
||||
if ($type == 'group') {
|
||||
$sendData[] = [
|
||||
'content' => $video,
|
||||
'msgType' => 43,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatChatroomId' => $targetId,
|
||||
];
|
||||
} else {
|
||||
$sendData[] = [
|
||||
'content' => $video,
|
||||
'msgType' => 43,
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $sendData;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 记录发送历史
|
||||
@@ -260,23 +462,51 @@ class WorkbenchGroupPushJob
|
||||
if ($totalSeconds <= 0 || empty($config['maxPerDay'])) {
|
||||
return false;
|
||||
}
|
||||
$interval = floor($totalSeconds / $config['maxPerDay']);
|
||||
|
||||
$targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送
|
||||
|
||||
// 查询今日已同步次数
|
||||
$count = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp])
|
||||
->count();
|
||||
if ($count >= $config['maxPerDay']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算本次同步的最早允许时间
|
||||
$nextSyncTime = $startTimestamp + $count * $interval;
|
||||
if (time() < $nextSyncTime) {
|
||||
return false;
|
||||
if ($targetType == 2) {
|
||||
// 好友推送:maxPerDay表示每日推送人数
|
||||
// 查询今日已推送的好友ID列表(去重,仅统计今日)
|
||||
$sentFriendIds = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', 2)
|
||||
->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp])
|
||||
->column('friendId');
|
||||
$sentFriendIds = array_filter($sentFriendIds); // 过滤null值
|
||||
$count = count(array_unique($sentFriendIds)); // 去重后统计今日推送人数
|
||||
|
||||
if ($count >= $config['maxPerDay']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算本次同步的最早允许时间(按人数计算间隔)
|
||||
$interval = floor($totalSeconds / $config['maxPerDay']);
|
||||
$nextSyncTime = $startTimestamp + $count * $interval;
|
||||
if (time() < $nextSyncTime) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// 群推送:maxPerDay表示每日推送次数
|
||||
$interval = floor($totalSeconds / $config['maxPerDay']);
|
||||
|
||||
// 查询今日已同步次数
|
||||
$count = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', 1)
|
||||
->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp])
|
||||
->count();
|
||||
if ($count >= $config['maxPerDay']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算本次同步的最早允许时间
|
||||
$nextSyncTime = $startTimestamp + $count * $interval;
|
||||
if (time() < $nextSyncTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -293,13 +523,14 @@ class WorkbenchGroupPushJob
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送
|
||||
|
||||
if ($config['pushType'] == 1) {
|
||||
$limit = 10;
|
||||
} else {
|
||||
$limit = 1;
|
||||
}
|
||||
|
||||
|
||||
//推送顺序
|
||||
if ($config['pushOrder'] == 1) {
|
||||
$order = 'ci.sendTime desc, ci.id asc';
|
||||
@@ -307,11 +538,10 @@ class WorkbenchGroupPushJob
|
||||
$order = 'ci.sendTime desc, ci.id desc';
|
||||
}
|
||||
|
||||
|
||||
// 基础查询
|
||||
// 基础查询,根据targetType过滤记录
|
||||
$query = Db::name('content_library')->alias('cl')
|
||||
->join('content_item ci', 'ci.libraryId = cl.id')
|
||||
->join('workbench_group_push_item wgpi', 'wgpi.contentId = ci.id and wgpi.workbenchId = ' . $workbench->id, 'left')
|
||||
->join('workbench_group_push_item wgpi', 'wgpi.contentId = ci.id and wgpi.workbenchId = ' . $workbench->id . ' and wgpi.targetType = ' . $targetType, 'left')
|
||||
->where(['cl.isDel' => 0, 'ci.isDel' => 0])
|
||||
->where('ci.sendTime <= ' . (time() + 60))
|
||||
->whereIn('cl.id', $contentids)
|
||||
@@ -329,9 +559,9 @@ class WorkbenchGroupPushJob
|
||||
// 复制 query
|
||||
$query2 = clone $query;
|
||||
$query3 = clone $query;
|
||||
// 根据accountType处理不同的发送逻辑
|
||||
// 根据isLoop处理不同的发送逻辑
|
||||
if ($config['isLoop'] == 1) {
|
||||
// 可以循环发送
|
||||
// 可以循环发送(只有群推送时才能为1)
|
||||
// 1. 优先获取未发送的内容
|
||||
$unsentContent = $query->where('wgpi.id', 'null')
|
||||
->order($order)
|
||||
@@ -340,8 +570,20 @@ class WorkbenchGroupPushJob
|
||||
if (!empty($unsentContent)) {
|
||||
return $unsentContent;
|
||||
}
|
||||
$lastSendData = Db::name('workbench_group_push_item')->where('workbenchId', $workbench->id)->order('id desc')->find();
|
||||
$fastSendData = Db::name('workbench_group_push_item')->where('workbenchId', $workbench->id)->order('id asc')->find();
|
||||
$lastSendData = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', $targetType)
|
||||
->order('id desc')
|
||||
->find();
|
||||
$fastSendData = Db::name('workbench_group_push_item')
|
||||
->where('workbenchId', $workbench->id)
|
||||
->where('targetType', $targetType)
|
||||
->order('id asc')
|
||||
->find();
|
||||
|
||||
if (empty($lastSendData) || empty($fastSendData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sentContent = $query2->where('wgpi.contentId', '<', $lastSendData['contentId'])->order('wgpi.id ASC')->group('wgpi.contentId')->limit(0, $limit)->select();
|
||||
|
||||
@@ -350,7 +592,7 @@ class WorkbenchGroupPushJob
|
||||
}
|
||||
return $sentContent;
|
||||
} else {
|
||||
// 不能循环发送,只获取未发送的内容
|
||||
// 不能循环发送,只获取未发送的内容(好友推送时isLoop=0)
|
||||
$list = $query->where('wgpi.id', 'null')
|
||||
->order($order)
|
||||
->limit(0, $limit)
|
||||
|
||||
@@ -35,9 +35,8 @@ Route::group('v1/store', function () {
|
||||
// 数据统计相关路由
|
||||
Route::group('statistics', function () {
|
||||
Route::get('overview', 'app\store\controller\StatisticsController@getOverview'); // 获取数据概览
|
||||
Route::get('customer-analysis', 'app\store\controller\StatisticsController@getCustomerAnalysis'); // 获取客户分析数据
|
||||
Route::get('interaction-analysis', 'app\store\controller\StatisticsController@getInteractionAnalysis'); // 获取互动分析数据
|
||||
});
|
||||
Route::get('comprehensive-analysis', 'app\store\controller\StatisticsController@getComprehensiveAnalysis'); // 获取综合分析数据
|
||||
});
|
||||
|
||||
// 供应商相关路由
|
||||
Route::group('vendor', function () {
|
||||
@@ -45,4 +44,6 @@ Route::group('v1/store', function () {
|
||||
Route::get('detail', 'app\store\controller\VendorController@detail'); // 获取供应商详情
|
||||
Route::post('order', 'app\store\controller\VendorController@createOrder'); // 创建订单
|
||||
});
|
||||
})->middleware(['jwt']);
|
||||
})->middleware(['jwt']);
|
||||
|
||||
Route::get('v1/store/login', 'app\store\controller\LoginController@index');
|
||||
43
Server/application/store/controller/LoginController.php
Normal file
43
Server/application/store/controller/LoginController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace app\store\controller;
|
||||
|
||||
use app\common\util\JwtUtil;
|
||||
use think\Db;
|
||||
use think\Controller;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$deviceId = $this->request->param('deviceId', '');
|
||||
if (empty($deviceId)) {
|
||||
return errorJson('缺少必要参数');
|
||||
}
|
||||
|
||||
$user = Db::name('users')->alias('u')
|
||||
->field('u.*')
|
||||
->join('device_user du', 'u.id = du.userId and u.companyId = du.companyId')
|
||||
->join('device d', 'du.deviceId = d.id and u.companyId = du.companyId')
|
||||
->where(['d.deviceImei' => $deviceId, 'u.deleteTime' => 0, 'du.deleteTime' => 0, 'd.deleteTime' => 0])
|
||||
->find();
|
||||
if (empty($user)) {
|
||||
return errorJson('用户不存在');
|
||||
}
|
||||
$member = array_merge($user, [
|
||||
'lastLoginIp' => $this->request->ip(),
|
||||
'lastLoginTime' => time()
|
||||
]);
|
||||
|
||||
// 生成JWT令牌
|
||||
$token = JwtUtil::createToken($user, 86400 * 30);
|
||||
$token_expired = time() + 86400 * 30;
|
||||
|
||||
$data = [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
'token_expired' => $token_expired
|
||||
];
|
||||
return successJson($data, '登录成功');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace app\store\controller;
|
||||
|
||||
use app\store\model\WechatFriendModel;
|
||||
use app\store\model\WechatMessageModel;
|
||||
use app\store\model\TrafficOrderModel;
|
||||
use think\Db;
|
||||
|
||||
|
||||
@@ -18,8 +19,8 @@ class StatisticsController extends BaseController
|
||||
public function getOverview()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->userInfo['companyId'];
|
||||
$userId = $this->userInfo['id'];
|
||||
$companyId = $this->userInfo['companyId'];
|
||||
$userId = $this->userInfo['id'];
|
||||
|
||||
// 构建查询条件
|
||||
$deviceIds = Db::name('device_user')->where(['userId' => $userId, 'companyId' => $companyId])->order('id DESC')->column('deviceId');
|
||||
@@ -34,7 +35,7 @@ class StatisticsController extends BaseController
|
||||
->value('wechatId');
|
||||
}
|
||||
|
||||
$wechatAccountIds = Db::table('s2_wechat_account')->whereIn('wechatId',$ownerWechatIds)->column('id');
|
||||
$wechatAccountIds = Db::table('s2_wechat_account')->whereIn('wechatId', $ownerWechatIds)->column('id');
|
||||
|
||||
|
||||
// 获取时间范围
|
||||
@@ -45,43 +46,57 @@ class StatisticsController extends BaseController
|
||||
$lastEndTime = $timeRange['last_end_time'];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 1. 总客户数
|
||||
$totalCustomers = WechatFriendModel::whereIn('ownerWechatId',$ownerWechatIds)
|
||||
->where('isDeleted',0)
|
||||
->count();
|
||||
$totalCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->whereTime('createTime', '>=', $startTime)
|
||||
->whereTime('createTime', '<', $endTime)
|
||||
->count();
|
||||
|
||||
// 上期总客户数
|
||||
$lastTotalCustomers = WechatFriendModel::whereIn('ownerWechatId',$ownerWechatIds)->count();
|
||||
$lastTotalCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereTime('createTime', '>=', $lastStartTime)
|
||||
->whereTime('createTime', '<', $lastEndTime)
|
||||
->count();
|
||||
|
||||
// 2. 新增客户数
|
||||
$newCustomers = WechatFriendModel::whereIn('ownerWechatId',$ownerWechatIds)
|
||||
->whereTime('createTime', '>=', $startTime)
|
||||
->whereTime('createTime', '<', $endTime)
|
||||
->count();
|
||||
$newCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereTime('createTime', '>=', $startTime)
|
||||
->whereTime('createTime', '<', $endTime)
|
||||
->count();
|
||||
|
||||
// 上期新增客户数
|
||||
$lastNewCustomers = WechatFriendModel::whereIn('ownerWechatId',$ownerWechatIds)
|
||||
->whereTime('createTime', '>=', $lastStartTime)
|
||||
->whereTime('createTime', '<', $lastEndTime)
|
||||
->count();
|
||||
$lastNewCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereTime('createTime', '>=', $lastStartTime)
|
||||
->whereTime('createTime', '<', $lastEndTime)
|
||||
->count();
|
||||
|
||||
//3. 互动次数
|
||||
$interactionCount = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->count();
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->count();
|
||||
|
||||
// 上期互动次数
|
||||
$lastInteractionCount = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $lastStartTime)
|
||||
->where('createTime', '<', $lastEndTime)
|
||||
->count();
|
||||
|
||||
->where('createTime', '>=', $lastStartTime)
|
||||
->where('createTime', '<', $lastEndTime)
|
||||
->count();
|
||||
|
||||
|
||||
// 4. RFM 平均值计算(不查询上期数据)
|
||||
$rfmStats = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->field('AVG(`R`) as avgR, AVG(`F`) as avgF, AVG(`M`) as avgM')
|
||||
->find();
|
||||
|
||||
// 处理查询结果,如果字段为null则默认为0
|
||||
$avgR = isset($rfmStats['avgR']) && $rfmStats['avgR'] !== null ? round((float)$rfmStats['avgR'], 2) : 0;
|
||||
$avgF = isset($rfmStats['avgF']) && $rfmStats['avgF'] !== null ? round((float)$rfmStats['avgF'], 2) : 0;
|
||||
$avgM = isset($rfmStats['avgM']) && $rfmStats['avgM'] !== null ? round((float)$rfmStats['avgM'], 2) : 0;
|
||||
|
||||
// 计算三者的平均值
|
||||
$avgRFM = ($avgR + $avgF + $avgM) / 3;
|
||||
$avgRFM = round($avgRFM, 2);
|
||||
|
||||
// 计算环比增长率
|
||||
$customerGrowth = $this->calculateGrowth($totalCustomers, $lastTotalCustomers);
|
||||
@@ -99,6 +114,16 @@ class StatisticsController extends BaseController
|
||||
'interaction_count' => [
|
||||
'value' => $interactionCount,
|
||||
'growth' => $interactionGrowth
|
||||
],
|
||||
'conversion_rate' => [
|
||||
'value' => 10,
|
||||
'growth' => 15
|
||||
],
|
||||
'account_value' => [
|
||||
'avg_r' => $avgR,
|
||||
'avg_f' => $avgF,
|
||||
'avg_m' => $avgM,
|
||||
'avg_rfm' => $avgRFM
|
||||
]
|
||||
];
|
||||
|
||||
@@ -108,14 +133,15 @@ class StatisticsController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取客户分析数据
|
||||
* 获取综合分析数据
|
||||
*/
|
||||
public function getCustomerAnalysis()
|
||||
public function getComprehensiveAnalysis()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->userInfo['companyId'];
|
||||
$userId = $this->userInfo['id'];
|
||||
$companyId = $this->userInfo['companyId'];
|
||||
$userId = $this->userInfo['id'];
|
||||
|
||||
// 构建查询条件
|
||||
$deviceIds = Db::name('device_user')->where(['userId' => $userId, 'companyId' => $companyId])->order('id DESC')->column('deviceId');
|
||||
@@ -129,58 +155,189 @@ class StatisticsController extends BaseController
|
||||
->order('id DESC')
|
||||
->value('wechatId');
|
||||
}
|
||||
$wechatAccountIds = Db::table('s2_wechat_account')->whereIn('wechatId', $ownerWechatIds)->column('id');
|
||||
|
||||
|
||||
|
||||
// 获取时间范围
|
||||
$timeRange = $this->getTimeRange();
|
||||
$startTime = $timeRange['start_time'];
|
||||
$endTime = $timeRange['end_time'];
|
||||
$lastStartTime = $timeRange['last_start_time'];
|
||||
$lastEndTime = $timeRange['last_end_time'];
|
||||
|
||||
// 1. 客户增长趋势数据
|
||||
$totalCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted',0)
|
||||
->whereTime('createTime', '<', $endTime)
|
||||
// ========== 1. 客户平均转化金额 ==========
|
||||
// 获取有订单的客户数(去重)
|
||||
$convertedCustomers = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->group('identifier')
|
||||
->column('identifier');
|
||||
$convertedCustomerCount = count($convertedCustomers);
|
||||
|
||||
// 总销售额
|
||||
$totalSales = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->sum('actualPay');
|
||||
$totalSales = $totalSales ?: 0;
|
||||
|
||||
// 客户平均转化金额
|
||||
$avgConversionAmount = $convertedCustomerCount > 0 ? round($totalSales / $convertedCustomerCount, 2) : 0;
|
||||
|
||||
// ========== 2. 价值指标 ==========
|
||||
// 销售总额(已计算)
|
||||
|
||||
// 平均订单金额(总订单数)
|
||||
$totalOrderCount = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->count();
|
||||
$avgOrderAmount = $totalOrderCount > 0 ? round($totalSales / $totalOrderCount, 2) : 0;
|
||||
|
||||
// 高价值客户(消费超过平均订单金额的客户)
|
||||
// 先获取每个客户的消费总额
|
||||
$customerTotalSpend = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('identifier, SUM(actualPay) as totalSpend')
|
||||
->group('identifier')
|
||||
->select();
|
||||
|
||||
$highValueCustomerCount = 0;
|
||||
$avgCustomerSpend = $convertedCustomerCount > 0 ? ($totalSales / $convertedCustomerCount) : 0;
|
||||
foreach ($customerTotalSpend as $customer) {
|
||||
if ($customer['totalSpend'] > $avgCustomerSpend) {
|
||||
$highValueCustomerCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 高价值客户百分比
|
||||
$totalCustomersForCalc = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
$highValueCustomerPercent = $totalCustomersForCalc > 0 ? round(($highValueCustomerCount / $totalCustomersForCalc) * 100, 1) : 0;
|
||||
|
||||
// ========== 3. 增长趋势 ==========
|
||||
// 上期销售额
|
||||
$lastTotalSales = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $lastStartTime)
|
||||
->where('createTime', '<', $lastEndTime)
|
||||
->sum('actualPay');
|
||||
$lastTotalSales = $lastTotalSales ?: 0;
|
||||
|
||||
// 周收益增长(金额差值)
|
||||
$weeklyRevenueGrowth = round($totalSales - $lastTotalSales, 2);
|
||||
|
||||
// 新客转化(新客户中有订单的人数)
|
||||
$newCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted',0)
|
||||
->whereTime('createTime', '>=', $startTime)
|
||||
->whereTime('createTime', '<', $endTime)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->column('wechatId');
|
||||
|
||||
// 获取新客户中有订单的(identifier 对应 wechatId)
|
||||
$newConvertedCustomers = 0;
|
||||
if (!empty($newCustomers)) {
|
||||
$newConvertedCustomers = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->whereIn('identifier', $newCustomers)
|
||||
->group('identifier')
|
||||
->count();
|
||||
}
|
||||
|
||||
// 活跃客户增长(有互动的客户)
|
||||
$activeCustomers = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->group('wechatFriendId')
|
||||
->count();
|
||||
|
||||
$lastActiveCustomers = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $lastStartTime)
|
||||
->where('createTime', '<', $lastEndTime)
|
||||
->group('wechatFriendId')
|
||||
->count();
|
||||
|
||||
// 活跃客户增长(人数差值)
|
||||
$activeCustomerGrowth = $activeCustomers - $lastActiveCustomers;
|
||||
|
||||
// ========== 4. 客户活跃度 ==========
|
||||
// 按天统计每个客户的互动次数,然后分类
|
||||
// 高频互动用户数(平均每天3次以上)
|
||||
$days = max(1, ($endTime - $startTime) / 86400); // 计算天数
|
||||
$highFrequencyThreshold = $days * 3; // 高频阈值
|
||||
|
||||
$highFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count > ' . $highFrequencyThreshold)
|
||||
->count();
|
||||
|
||||
// 计算流失客户数
|
||||
$lostCustomers = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('isDeleted',1)
|
||||
->where('createTime', '>', 0)
|
||||
->whereTime('deleteTime', '>=', $startTime)
|
||||
->whereTime('deleteTime', '<', $endTime)
|
||||
// 中频互动用户数(平均每天1-3次)
|
||||
$midFrequencyThreshold = $days * 1;
|
||||
$midFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count >= ' . $midFrequencyThreshold . ' AND count <= ' . $highFrequencyThreshold)
|
||||
->count();
|
||||
|
||||
// 2. 客户来源分布数据
|
||||
// 朋友推荐
|
||||
$friendRecommend = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('addFrom', [17, 1000017])
|
||||
// 低频互动用户数(少于平均每天1次)
|
||||
$lowFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId', $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count < ' . $midFrequencyThreshold)
|
||||
->count();
|
||||
|
||||
// 微信搜索
|
||||
$wechatSearch = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('addFrom', [3, 15, 1000003, 1000015])
|
||||
->count();
|
||||
$frequency_analysis = [
|
||||
['name' => '高频', 'value' => $highFrequencyUsers],
|
||||
['name' => '中频', 'value' => $midFrequencyUsers],
|
||||
['name' => '低频', 'value' => $lowFrequencyUsers]
|
||||
];
|
||||
|
||||
// 微信群
|
||||
$wechatGroup = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('addFrom', [14, 1000014])
|
||||
->count();
|
||||
// ========== 5. 转化客户来源 ==========
|
||||
// 只统计有订单的客户来源(identifier 对应 wechatId)
|
||||
$convertedFriendIds = TrafficOrderModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->group('identifier')
|
||||
->column('identifier');
|
||||
|
||||
$friendRecommend = 0;
|
||||
$wechatSearch = 0;
|
||||
$wechatGroup = 0;
|
||||
|
||||
if (!empty($convertedFriendIds)) {
|
||||
// 朋友推荐(有订单的)
|
||||
$friendRecommend = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('wechatId', $convertedFriendIds)
|
||||
->whereIn('addFrom', [17, 1000017])
|
||||
->count();
|
||||
|
||||
// 其他渠道(总数减去已知渠道)
|
||||
$otherSource = $totalCustomers - $friendRecommend - $wechatSearch - $wechatGroup;
|
||||
$otherSource = max(0, $otherSource); // 确保不会出现负数
|
||||
// 微信搜索(有订单的)
|
||||
$wechatSearch = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('wechatId', $convertedFriendIds)
|
||||
->whereIn('addFrom', [3, 15, 1000003, 1000015])
|
||||
->count();
|
||||
|
||||
// 微信群(有订单的)
|
||||
$wechatGroup = WechatFriendModel::whereIn('ownerWechatId', $ownerWechatIds)
|
||||
->whereIn('wechatId', $convertedFriendIds)
|
||||
->whereIn('addFrom', [14, 1000014])
|
||||
->count();
|
||||
}
|
||||
|
||||
$totalConvertedCustomers = $convertedCustomerCount;
|
||||
$otherSource = max(0, $totalConvertedCustomers - $friendRecommend - $wechatSearch - $wechatGroup);
|
||||
|
||||
// 计算百分比
|
||||
$calculatePercentage = function($value) use ($totalCustomers) {
|
||||
if ($totalCustomers <= 0) return 0;
|
||||
return round(($value / $totalCustomers) * 100, 2);
|
||||
$calculatePercentage = function ($value) use ($totalConvertedCustomers) {
|
||||
if ($totalConvertedCustomers <= 0) return 0;
|
||||
return round(($value / $totalConvertedCustomers) * 100, 2);
|
||||
};
|
||||
|
||||
$sourceDistribution = [
|
||||
@@ -198,151 +355,24 @@ class StatisticsController extends BaseController
|
||||
'name' => '微信群',
|
||||
'value' => $calculatePercentage($wechatGroup) . '%',
|
||||
'count' => $wechatGroup
|
||||
],
|
||||
[
|
||||
'name' => '其他渠道',
|
||||
'value' => $calculatePercentage($otherSource) . '%',
|
||||
'count' => $otherSource
|
||||
]
|
||||
];
|
||||
|
||||
$data = [
|
||||
'trend' => [
|
||||
'total' => $totalCustomers,
|
||||
'new' => $newCustomers,
|
||||
'lost' => $lostCustomers
|
||||
],
|
||||
'source_distribution' => $sourceDistribution
|
||||
];
|
||||
|
||||
return successJson($data);
|
||||
} catch (\Exception $e) {
|
||||
return errorJson('获取客户分析数据失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取互动分析数据
|
||||
*/
|
||||
public function getInteractionAnalysis()
|
||||
{
|
||||
try {
|
||||
$companyId = $this->userInfo['companyId'];
|
||||
$userId = $this->userInfo['id'];
|
||||
|
||||
// 构建查询条件
|
||||
$deviceIds = Db::name('device_user')->where(['userId' => $userId, 'companyId' => $companyId])->order('id DESC')->column('deviceId');
|
||||
if (empty($deviceIds)) {
|
||||
return errorJson('设备不存在');
|
||||
}
|
||||
$ownerWechatIds = [];
|
||||
foreach ($deviceIds as $deviceId) {
|
||||
$ownerWechatIds[] = Db::name('device_wechat_login')
|
||||
->where(['deviceId' => $deviceId])
|
||||
->order('id DESC')
|
||||
->value('wechatId');
|
||||
}
|
||||
$wechatAccountIds = Db::table('s2_wechat_account')->whereIn('wechatId',$ownerWechatIds)->column('id');
|
||||
|
||||
// 获取时间范围
|
||||
$timeRange = $this->getTimeRange();
|
||||
$startTime = $timeRange['start_time'];
|
||||
$endTime = $timeRange['end_time'];
|
||||
|
||||
// 不再需要转换为时间戳,因为getTimeRange已经转换
|
||||
// $startTimestamp = strtotime($startTime);
|
||||
// $endTimestamp = strtotime($endTime);
|
||||
|
||||
// 1. 互动频率分析
|
||||
// 高频互动用户数(每天3次以上)
|
||||
$highFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count > 3')
|
||||
->count();
|
||||
|
||||
// 中频互动用户数(每天1-3次)
|
||||
$midFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count >= 1 AND count <= 3')
|
||||
->count();
|
||||
|
||||
// 低频互动用户数(仅有1次)
|
||||
$lowFrequencyUsers = WechatMessageModel::whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->field('wechatFriendId, COUNT(*) as count')
|
||||
->group('wechatFriendId')
|
||||
->having('count = 1')
|
||||
->count();
|
||||
|
||||
// 2. 互动内容分析
|
||||
// 文字消息数量
|
||||
$textMessages = WechatMessageModel::where([
|
||||
'msgType' => 1
|
||||
])
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->count();
|
||||
|
||||
// 图片互动数量
|
||||
$imgInteractions = WechatMessageModel::where([
|
||||
'msgType' => 3
|
||||
])
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->count();
|
||||
|
||||
// 群聊互动数量
|
||||
$groupInteractions = WechatMessageModel::where([
|
||||
'type' => 2
|
||||
])
|
||||
->where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->count();
|
||||
|
||||
// 产品咨询数量 (通过消息内容模糊查询)
|
||||
$productInquiries = WechatMessageModel::where('createTime', '>=', $startTime)
|
||||
->where('createTime', '<', $endTime)
|
||||
->where('content', 'like', '%产品%')
|
||||
->whereOr('content', 'like', '%价格%')
|
||||
->whereOr('content', 'like', '%购买%')
|
||||
->whereOr('content', 'like', '%优惠%')
|
||||
->whereIn('wechatAccountId' , $wechatAccountIds)
|
||||
->count();
|
||||
|
||||
// 构建返回数据
|
||||
$data = [
|
||||
'frequency_analysis' => [
|
||||
'high_frequency' => $highFrequencyUsers,
|
||||
'mid_frequency' => $midFrequencyUsers,
|
||||
'low_frequency' => $lowFrequencyUsers,
|
||||
'chart_data' => [
|
||||
['name' => '高频互动', 'value' => $highFrequencyUsers],
|
||||
['name' => '中频互动', 'value' => $midFrequencyUsers],
|
||||
['name' => '低频互动', 'value' => $lowFrequencyUsers]
|
||||
]
|
||||
'avg_conversion_amount' => $avgConversionAmount, // 客户平均转化金额
|
||||
'value_indicators' => [
|
||||
'total_sales' => round($totalSales, 2), // 销售总额
|
||||
'avg_order_amount' => $avgOrderAmount, // 平均订单金额
|
||||
'high_value_customers' => $highValueCustomerPercent . '%' // 高价值客户
|
||||
],
|
||||
'content_analysis' => [
|
||||
'text_messages' => $textMessages,
|
||||
'img_interactions' => $imgInteractions,
|
||||
'group_interactions' => $groupInteractions,
|
||||
'product_inquiries' => $productInquiries,
|
||||
'chart_data' => [
|
||||
['name' => '文字互动', 'value' => $textMessages],
|
||||
['name' => '图片互动', 'value' => $imgInteractions],
|
||||
['name' => '群聊互动', 'value' => $groupInteractions],
|
||||
['name' => '产品咨询', 'value' => $productInquiries]
|
||||
]
|
||||
]
|
||||
'growth_trend' => [
|
||||
'weekly_revenue_growth' => $weeklyRevenueGrowth, // 周收益增长(金额)
|
||||
'new_customer_conversion' => $newConvertedCustomers, // 新客转化(人数)
|
||||
'active_customer_growth' => $activeCustomerGrowth // 活跃客户增长(人数差值)
|
||||
],
|
||||
'frequency_analysis' => $frequency_analysis, // 客户活跃度
|
||||
'source_distribution' => $sourceDistribution // 转化客户来源
|
||||
];
|
||||
|
||||
return successJson($data);
|
||||
@@ -353,7 +383,7 @@ class StatisticsController extends BaseController
|
||||
|
||||
/**
|
||||
* 获取时间范围
|
||||
*
|
||||
*
|
||||
* @param bool $toTimestamp 是否将日期转为时间戳,默认为true
|
||||
* @return array 时间范围数组
|
||||
*/
|
||||
@@ -361,7 +391,7 @@ class StatisticsController extends BaseController
|
||||
{
|
||||
// 可选:today, yesterday, this_week, last_week, this_month, this_quarter, this_year
|
||||
$timeType = input('time_type', 'this_week');
|
||||
|
||||
|
||||
switch ($timeType) {
|
||||
case 'today': // 今日
|
||||
$startTime = date('Y-m-d');
|
||||
@@ -369,35 +399,35 @@ class StatisticsController extends BaseController
|
||||
$lastStartTime = date('Y-m-d', strtotime('-1 day')); // 昨日
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'yesterday': // 昨日
|
||||
$startTime = date('Y-m-d', strtotime('-1 day'));
|
||||
$endTime = date('Y-m-d');
|
||||
$lastStartTime = date('Y-m-d', strtotime('-2 day')); // 前日
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'this_week': // 本周
|
||||
$startTime = date('Y-m-d', strtotime('monday this week'));
|
||||
$endTime = date('Y-m-d', strtotime('monday next week'));
|
||||
$lastStartTime = date('Y-m-d', strtotime('monday last week')); // 上周一
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'last_week': // 上周
|
||||
$startTime = date('Y-m-d', strtotime('monday last week'));
|
||||
$endTime = date('Y-m-d', strtotime('monday this week'));
|
||||
$lastStartTime = date('Y-m-d', strtotime('monday last week', strtotime('last week'))); // 上上周一
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'this_month': // 本月
|
||||
$startTime = date('Y-m-01');
|
||||
$endTime = date('Y-m-d', strtotime(date('Y-m-01') . ' +1 month'));
|
||||
$lastStartTime = date('Y-m-01', strtotime('-1 month')); // 上月初
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'this_quarter': // 本季度
|
||||
$month = date('n');
|
||||
$quarter = ceil($month / 3);
|
||||
@@ -408,14 +438,14 @@ class StatisticsController extends BaseController
|
||||
$lastStartTime = date('Y-m-d', strtotime($startTime . ' -3 month'));
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
case 'this_year': // 本年度
|
||||
$startTime = date('Y-01-01');
|
||||
$endTime = (date('Y') + 1) . '-01-01';
|
||||
$lastStartTime = (date('Y') - 1) . '-01-01'; // 去年初
|
||||
$lastEndTime = $startTime;
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
$startTime = date('Y-m-d', strtotime('monday this week'));
|
||||
$endTime = date('Y-m-d', strtotime('monday next week'));
|
||||
|
||||
11
Server/application/store/model/TrafficOrderModel.php
Normal file
11
Server/application/store/model/TrafficOrderModel.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace app\store\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class TrafficOrderModel extends Model
|
||||
{
|
||||
protected $name = 'traffic_order';
|
||||
|
||||
}
|
||||
@@ -1537,4 +1537,500 @@ class Adapter implements WeChatServiceInterface
|
||||
} while ($affected > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动问候功能
|
||||
* 根据不同的触发类型检查并发送问候消息
|
||||
*/
|
||||
public function handleAutoGreetings()
|
||||
{
|
||||
try {
|
||||
// 获取所有启用的问候规则
|
||||
$rules = Db::name('kf_auto_greetings')
|
||||
->where(['status' => 1, 'isDel' => 0])
|
||||
->order('level asc, id asc')
|
||||
->select();
|
||||
|
||||
if (empty($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$trigger = $rule['trigger'];
|
||||
$condition = json_decode($rule['condition'], true);
|
||||
|
||||
switch ($trigger) {
|
||||
case 1: // 新好友
|
||||
$this->handleNewFriendGreeting($rule);
|
||||
break;
|
||||
case 2: // 首次发消息
|
||||
$this->handleFirstMessageGreeting($rule);
|
||||
break;
|
||||
case 3: // 时间触发
|
||||
$this->handleTimeTriggerGreeting($rule, $condition);
|
||||
break;
|
||||
case 4: // 关键词触发
|
||||
$this->handleKeywordTriggerGreeting($rule, $condition);
|
||||
break;
|
||||
case 5: // 生日触发
|
||||
$this->handleBirthdayTriggerGreeting($rule, $condition);
|
||||
break;
|
||||
case 6: // 自定义
|
||||
$this->handleCustomTriggerGreeting($rule, $condition);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('自动问候处理失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新好友触发
|
||||
*/
|
||||
private function handleNewFriendGreeting($rule)
|
||||
{
|
||||
// 获取最近24小时内添加的好友(避免重复处理)
|
||||
$last24h = time() - 24 * 3600;
|
||||
|
||||
// 查询该用户/公司最近24小时内新添加的好友
|
||||
// 通过 s2_wechat_account -> s2_company_account 关联获取 companyId
|
||||
$friends = Db::table('s2_wechat_friend')
|
||||
->alias('wf')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id')
|
||||
->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id')
|
||||
->where([
|
||||
['wf.isPassed', '=', 1],
|
||||
['wf.isDeleted', '=', 0],
|
||||
['wf.passTime', '>=', $last24h],
|
||||
['ca.departmentId', '=', $rule['companyId']],
|
||||
])
|
||||
->field('wf.id, wf.wechatAccountId')
|
||||
->select();
|
||||
|
||||
foreach ($friends as $friend) {
|
||||
// 检查是否已经发送过问候
|
||||
$exists = Db::name('kf_auto_greetings_record')
|
||||
->where([
|
||||
'autoId' => $rule['id'],
|
||||
'friendIdOrGroupId' => $friend['id'],
|
||||
'wechatAccountId' => $friend['wechatAccountId'],
|
||||
])
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
$this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理首次发消息触发
|
||||
*/
|
||||
private function handleFirstMessageGreeting($rule)
|
||||
{
|
||||
// 获取最近1小时内收到的消息
|
||||
$last1h = time() - 3600;
|
||||
|
||||
// 查询消息表,找出首次发消息的好友
|
||||
// 通过 s2_wechat_account -> s2_company_account 关联获取 companyId
|
||||
$messages = Db::table('s2_wechat_message')
|
||||
->alias('wm')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id')
|
||||
->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id')
|
||||
->where([
|
||||
['wm.isSend', '=', 0], // 接收的消息
|
||||
['wm.wechatChatroomId', '=', 0], // 个人消息
|
||||
['wm.createTime', '>=', $last1h],
|
||||
['ca.departmentId', '=', $rule['companyId']],
|
||||
])
|
||||
->group('wm.wechatFriendId, wm.wechatAccountId')
|
||||
->field('wm.wechatFriendId, wm.wechatAccountId, MIN(wm.createTime) as firstMsgTime')
|
||||
->select();
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
// 检查该好友是否之前发送过消息
|
||||
$previousMsg = Db::table('s2_wechat_message')
|
||||
->where([
|
||||
'wechatFriendId' => $msg['wechatFriendId'],
|
||||
'wechatAccountId' => $msg['wechatAccountId'],
|
||||
'isSend' => 0,
|
||||
])
|
||||
->where('createTime', '<', $msg['firstMsgTime'])
|
||||
->find();
|
||||
|
||||
// 如果是首次发消息,且没有发送过问候
|
||||
if (!$previousMsg) {
|
||||
$exists = Db::name('kf_auto_greetings_record')
|
||||
->where([
|
||||
'autoId' => $rule['id'],
|
||||
'friendIdOrGroupId' => $msg['wechatFriendId'],
|
||||
'wechatAccountId' => $msg['wechatAccountId'],
|
||||
])
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
$this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理时间触发
|
||||
*/
|
||||
private function handleTimeTriggerGreeting($rule, $condition)
|
||||
{
|
||||
if (empty($condition) || !isset($condition['type'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$currentTime = date('H:i', $now);
|
||||
$currentDate = date('m-d', $now);
|
||||
$currentDateTime = date('m-d H:i', $now);
|
||||
$currentWeekday = date('w', $now); // 0=周日, 1=周一, ..., 6=周六
|
||||
|
||||
$shouldTrigger = false;
|
||||
|
||||
switch ($condition['type']) {
|
||||
case 'daily_time': // 每天固定时间
|
||||
if ($currentTime === $condition['value']) {
|
||||
$shouldTrigger = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'yearly_datetime': // 每年固定日期时间
|
||||
if ($currentDateTime === $condition['value']) {
|
||||
$shouldTrigger = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fixed_range': // 固定时间段
|
||||
if (is_array($condition['value']) && count($condition['value']) === 2) {
|
||||
$startTime = strtotime('2000-01-01 ' . $condition['value'][0]);
|
||||
$endTime = strtotime('2000-01-01 ' . $condition['value'][1]);
|
||||
$currentTimeStamp = strtotime('2000-01-01 ' . $currentTime);
|
||||
|
||||
if ($currentTimeStamp >= $startTime && $currentTimeStamp <= $endTime) {
|
||||
$shouldTrigger = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workday': // 工作日
|
||||
// 周一到周五(1-5)
|
||||
if ($currentWeekday >= 1 && $currentWeekday <= 5 && $currentTime === $condition['value']) {
|
||||
$shouldTrigger = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($shouldTrigger) {
|
||||
// 获取该用户/公司的所有好友
|
||||
// 通过 s2_wechat_account -> s2_company_account 关联获取 companyId
|
||||
$friends = Db::table('s2_wechat_friend')
|
||||
->alias('wf')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id')
|
||||
->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id')
|
||||
->where([
|
||||
['wf.isPassed', '=', 1],
|
||||
['wf.isDeleted', '=', 0],
|
||||
['ca.departmentId', '=', $rule['companyId']],
|
||||
])
|
||||
->field('wf.id, wf.wechatAccountId')
|
||||
->select();
|
||||
|
||||
foreach ($friends as $friend) {
|
||||
// 检查今天是否已经发送过
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$exists = Db::name('kf_auto_greetings_record')
|
||||
->where([
|
||||
'autoId' => $rule['id'],
|
||||
'friendIdOrGroupId' => $friend['id'],
|
||||
'wechatAccountId' => $friend['wechatAccountId'],
|
||||
])
|
||||
->where('createTime', '>=', $todayStart)
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
$this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理关键词触发
|
||||
*/
|
||||
private function handleKeywordTriggerGreeting($rule, $condition)
|
||||
{
|
||||
if (empty($condition) || empty($condition['keywords'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keywords = $condition['keywords'];
|
||||
$matchType = $condition['match_type'] ?? 'fuzzy';
|
||||
|
||||
// 获取最近1小时内收到的消息
|
||||
$last1h = time() - 3600;
|
||||
|
||||
// 通过 s2_wechat_account -> s2_company_account 关联获取 companyId
|
||||
$messages = Db::table('s2_wechat_message')
|
||||
->alias('wm')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id')
|
||||
->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id')
|
||||
->where([
|
||||
['wm.isSend', '=', 0], // 接收的消息
|
||||
['wm.wechatChatroomId', '=', 0], // 个人消息
|
||||
['wm.msgType', '=', 1], // 文本消息
|
||||
['wm.createTime', '>=', $last1h],
|
||||
['ca.departmentId', '=', $rule['companyId']],
|
||||
])
|
||||
->field('wm.*')
|
||||
->select();
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$content = $msg['content'] ?? '';
|
||||
|
||||
// 检查关键词匹配
|
||||
$matched = false;
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($matchType === 'exact') {
|
||||
// 精准匹配
|
||||
if ($content === $keyword) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 模糊匹配
|
||||
if (strpos($content, $keyword) !== false) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched) {
|
||||
// 检查是否已经发送过问候(同一好友同一规则,1小时内只发送一次)
|
||||
$last1h = time() - 3600;
|
||||
$exists = Db::name('kf_auto_greetings_record')
|
||||
->where([
|
||||
'autoId' => $rule['id'],
|
||||
'friendIdOrGroupId' => $msg['wechatFriendId'],
|
||||
'wechatAccountId' => $msg['wechatAccountId'],
|
||||
])
|
||||
->where('createTime', '>=', $last1h)
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
$this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理生日触发
|
||||
*/
|
||||
private function handleBirthdayTriggerGreeting($rule, $condition)
|
||||
{
|
||||
if (empty($condition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析condition格式
|
||||
// 支持格式:
|
||||
// 1. {'month': 10, 'day': 10} - 当天任何时间都可以触发
|
||||
// 2. {'month': 10, 'day': 10, 'time': '09:00'} - 当天指定时间触发
|
||||
// 3. {'month': 10, 'day': 10, 'time_range': ['09:00', '10:00']} - 当天时间范围内触发
|
||||
// 兼容旧格式:['10-10'] 或 '10-10'(仅支持 MM-DD 格式,不包含年份)
|
||||
|
||||
$birthdayMonth = null;
|
||||
$birthdayDay = null;
|
||||
$birthdayTime = null;
|
||||
$timeRange = null;
|
||||
|
||||
if (is_array($condition)) {
|
||||
// 新格式:对象格式 {'month': 10, 'day': 10}
|
||||
if (isset($condition['month']) && isset($condition['day'])) {
|
||||
$birthdayMonth = (int)$condition['month'];
|
||||
$birthdayDay = (int)$condition['day'];
|
||||
$birthdayTime = $condition['time'] ?? null;
|
||||
$timeRange = $condition['time_range'] ?? null;
|
||||
}
|
||||
// 兼容旧格式:['10-10'] 或 ['10-10 09:00'](仅支持 MM-DD 格式)
|
||||
elseif (isset($condition[0])) {
|
||||
$dateStr = $condition[0];
|
||||
// 只接受月日格式:'10-10' 或 '10-10 09:00'
|
||||
if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $dateStr, $matches)) {
|
||||
$birthdayMonth = (int)$matches[1];
|
||||
$birthdayDay = (int)$matches[2];
|
||||
if (isset($matches[3])) {
|
||||
$birthdayTime = $matches[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif (is_string($condition)) {
|
||||
// 字符串格式:只接受 '10-10' 或 '10-10 09:00'(MM-DD 格式,不包含年份)
|
||||
if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $condition, $matches)) {
|
||||
$birthdayMonth = (int)$matches[1];
|
||||
$birthdayDay = (int)$matches[2];
|
||||
if (isset($matches[3])) {
|
||||
$birthdayTime = $matches[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($birthdayMonth === null || $birthdayDay === null || $birthdayMonth < 1 || $birthdayMonth > 12 || $birthdayDay < 1 || $birthdayDay > 31) {
|
||||
return;
|
||||
}
|
||||
|
||||
$todayMonth = (int)date('m');
|
||||
$todayDay = (int)date('d');
|
||||
|
||||
// 检查今天是否是生日(只匹配月日,不匹配年份)
|
||||
if ($todayMonth !== $birthdayMonth || $todayDay !== $birthdayDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果配置了时间,检查当前时间是否匹配
|
||||
$now = time();
|
||||
$currentTime = date('H:i', $now);
|
||||
|
||||
if ($birthdayTime !== null) {
|
||||
// 指定了具体时间,检查是否在指定时间(允许1分钟误差,避免定时任务执行时间不精确)
|
||||
$birthdayTimestamp = strtotime('2000-01-01 ' . $birthdayTime);
|
||||
$currentTimestamp = strtotime('2000-01-01 ' . $currentTime);
|
||||
$diff = abs($currentTimestamp - $birthdayTimestamp);
|
||||
|
||||
// 如果时间差超过2分钟,不触发(允许1分钟误差)
|
||||
if ($diff > 120) {
|
||||
return;
|
||||
}
|
||||
} elseif ($timeRange !== null && is_array($timeRange) && count($timeRange) === 2) {
|
||||
// 指定了时间范围,检查当前时间是否在范围内
|
||||
$startTime = strtotime('2000-01-01 ' . $timeRange[0]);
|
||||
$endTime = strtotime('2000-01-01 ' . $timeRange[1]);
|
||||
$currentTimestamp = strtotime('2000-01-01 ' . $currentTime);
|
||||
|
||||
if ($currentTimestamp < $startTime || $currentTimestamp > $endTime) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 如果没有配置时间或时间范围,则当天任何时间都可以触发
|
||||
|
||||
// 获取该用户/公司的所有好友
|
||||
// 通过 s2_wechat_account -> s2_company_account 关联获取 companyId
|
||||
$friends = Db::table('s2_wechat_friend')
|
||||
->alias('wf')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id')
|
||||
->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id')
|
||||
->where([
|
||||
['wf.isPassed', '=', 1],
|
||||
['wf.isDeleted', '=', 0],
|
||||
['ca.departmentId', '=', $rule['companyId']],
|
||||
])
|
||||
->field('wf.id, wf.wechatAccountId')
|
||||
->select();
|
||||
|
||||
foreach ($friends as $friend) {
|
||||
// 检查今天是否已经发送过
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$exists = Db::name('kf_auto_greetings_record')
|
||||
->where([
|
||||
'autoId' => $rule['id'],
|
||||
'friendIdOrGroupId' => $friend['id'],
|
||||
'wechatAccountId' => $friend['wechatAccountId'],
|
||||
])
|
||||
->where('createTime', '>=', $todayStart)
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
$this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自定义触发
|
||||
*/
|
||||
private function handleCustomTriggerGreeting($rule, $condition)
|
||||
{
|
||||
// 自定义类型需要根据具体业务需求实现
|
||||
// 这里提供一个基础框架,可根据实际需求扩展
|
||||
// 暂时不实现,留待后续扩展
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送问候消息
|
||||
* @param array $rule 问候规则
|
||||
* @param int $wechatAccountId 微信账号ID
|
||||
* @param int $friendId 好友ID
|
||||
* @param int $groupId 群ID(0表示个人消息)
|
||||
*/
|
||||
private function sendGreetingMessage($rule, $wechatAccountId, $friendId, $groupId = 0)
|
||||
{
|
||||
try {
|
||||
$content = $rule['content'];
|
||||
|
||||
// 创建记录
|
||||
$recordId = Db::name('kf_auto_greetings_record')->insertGetId([
|
||||
'autoId' => $rule['id'],
|
||||
'userId' => $rule['userId'],
|
||||
'companyId' => $rule['companyId'],
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'friendIdOrGroupId' => $friendId,
|
||||
'isSend' => 0,
|
||||
'sendTime' => 0,
|
||||
'receiveTime' => 0,
|
||||
'createTime' => time(),
|
||||
]);
|
||||
|
||||
// 发送消息(文本消息)
|
||||
$username = Env::get('api.username', '');
|
||||
$password = Env::get('api.password', '');
|
||||
$toAccountId = '';
|
||||
if (!empty($username) || !empty($password)) {
|
||||
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
|
||||
}
|
||||
|
||||
$wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
|
||||
|
||||
$sendTime = time();
|
||||
$result = $wsController->sendPersonal([
|
||||
'wechatFriendId' => $friendId,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'msgType' => 1, // 文本消息
|
||||
'content' => $content,
|
||||
]);
|
||||
|
||||
$isSend = 0;
|
||||
$receiveTime = 0;
|
||||
|
||||
// 解析返回结果
|
||||
$resultData = json_decode($result, true);
|
||||
if (!empty($resultData) && $resultData['code'] == 200) {
|
||||
$isSend = 1;
|
||||
$receiveTime = time(); // 简化处理,实际应该从返回结果中获取
|
||||
}
|
||||
|
||||
// 更新记录
|
||||
Db::name('kf_auto_greetings_record')
|
||||
->where('id', $recordId)
|
||||
->update([
|
||||
'isSend' => $isSend,
|
||||
'sendTime' => $sendTime,
|
||||
'receiveTime' => $receiveTime,
|
||||
]);
|
||||
|
||||
// 更新规则使用次数
|
||||
Db::name('kf_auto_greetings')
|
||||
->where('id', $rule['id'])
|
||||
->setInc('usageCount');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('发送问候消息失败:' . $e->getMessage() . ',规则ID:' . $rule['id']);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// API配置文件
|
||||
|
||||
// 基础配置
|
||||
export const BASE_URL = 'http://yishi.com'
|
||||
//export const BASE_URL = 'https://ckbapi.quwanzhi.com'
|
||||
//export const BASE_URL = 'http://yishi.com'
|
||||
export const BASE_URL = 'https://ckbapi.quwanzhi.com'
|
||||
|
||||
// 获取请求头
|
||||
const getHeaders = (options = {}) => {
|
||||
|
||||
@@ -3,14 +3,30 @@ import { request, requestWithRetry } from '../config'
|
||||
// 认证相关API
|
||||
export const authApi = {
|
||||
// 用户登录
|
||||
login: (account, password) => {
|
||||
// @param {string} account - 账号
|
||||
// @param {string} password - 密码
|
||||
// @param {string} deviceId - 设备ID(仅APP端传递,H5端为空字符串)
|
||||
login: (account, password, deviceId) => {
|
||||
return request({
|
||||
url: '/v1/auth/login',
|
||||
method: 'POST',
|
||||
data: {
|
||||
account: account,
|
||||
password: password,
|
||||
typeId: 2 // 固定为2
|
||||
typeId: 2, // 固定为2
|
||||
deviceId: deviceId || '' // 设备ID(仅APP端有值,H5端为空字符串)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 免密登录
|
||||
// @param {string} deviceId - 设备ID
|
||||
noPasswordLogin: (deviceId) => {
|
||||
return request({
|
||||
url: '/v1/store/login',
|
||||
method: 'GET',
|
||||
data: {
|
||||
deviceId: deviceId || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上方区域:数据概览 -->
|
||||
<view class="top-section" style="position: relative;">
|
||||
<!-- 概览标题和时间选择 -->
|
||||
<view class="overview-header">
|
||||
<view class="overview-title">数据概览</view>
|
||||
@@ -34,15 +36,13 @@
|
||||
<view class="overview-item">
|
||||
<view class="overview-item-content">
|
||||
<view class="item-header">
|
||||
<text class="item-label">总客户数</text>
|
||||
<text class="item-label">账号价值估值</text>
|
||||
<view class="item-icon blue">
|
||||
<text class="iconfont icon-yonghuqun" style="color: #0080ff; font-size: 20px;"></text>
|
||||
<text class="iconfont icon-shuju1" style="color: #0080ff; font-size: 20px;"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-value">{{ overviewData.totalCustomers.toLocaleString() }}</view>
|
||||
<view class="item-change" :class="overviewData.totalCustomersChange >= 0 ? 'up' : 'down'">
|
||||
{{ (overviewData.totalCustomersChange >= 0 ? '+' : '') + overviewData.totalCustomersChange.toFixed(1) }}% 较上期
|
||||
</view>
|
||||
<view class="item-value">{{ overviewData.accountValue.toFixed(1) }}</view>
|
||||
<view class="item-desc">RFM平均评分(满分10分)</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
@@ -89,367 +89,110 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分段器 -->
|
||||
<view class="subsection-container">
|
||||
<u-subsection
|
||||
:list="subsectionList"
|
||||
:current="currentSubsection"
|
||||
@change="changeSubsection"
|
||||
mode="button"
|
||||
:activeColor="'#2979ff'"
|
||||
bgColor="#f5f7fa"
|
||||
fontSize="14"
|
||||
itemStyle="padding-left: 15px; padding-right: 15px;"
|
||||
></u-subsection>
|
||||
</view>
|
||||
|
||||
<!-- 客户分析区域 -->
|
||||
<view v-if="currentSubsection === 0" class="analysis-content">
|
||||
<view class="analysis-grid">
|
||||
<!-- 客户增长趋势卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">客户增长趋势</text>
|
||||
<text class="card-subtitle">近期客户增长数据</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="trend-icon">
|
||||
<text class="iconfont icon-shuju1" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">客户增长趋势图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 客户统计数据 -->
|
||||
<view class="customer-stats">
|
||||
<view class="customer-item">
|
||||
<view class="customer-dot" style="background-color: #0080ff;"></view>
|
||||
<text class="customer-label">总客户</text>
|
||||
<text class="customer-value">{{ customerAnalysis.trend.total.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="customer-item">
|
||||
<view class="customer-dot" style="background-color: #19be6b;"></view>
|
||||
<text class="customer-label">新增客户</text>
|
||||
<text class="customer-value">{{ customerAnalysis.trend.new.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="customer-item">
|
||||
<view class="customer-dot" style="background-color: #fa3534;"></view>
|
||||
<text class="customer-label">流失客户</text>
|
||||
<text class="customer-value">{{ customerAnalysis.trend.lost.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户来源分布卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">客户来源分布</text>
|
||||
<text class="card-subtitle">不同渠道客户占比</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="pie-icon">
|
||||
<text class="iconfont icon-bingtu" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">客户来源分布图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 来源分布数据 -->
|
||||
<view class="source-distribution">
|
||||
<view v-for="(source, index) in customerAnalysis.sourceDistribution"
|
||||
:key="index"
|
||||
class="source-item"
|
||||
>
|
||||
<view class="source-dot" :style="{ backgroundColor: getSourceColor(index) }"></view>
|
||||
<text class="source-name">{{ source.name }}</text>
|
||||
<text class="source-value">{{ source.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 数据概览区域遮罩层 -->
|
||||
<view v-if="isLoadingOverview" class="section-loading-mask">
|
||||
<view class="section-loading-content">
|
||||
<view class="section-spinner"></view>
|
||||
<text class="section-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他分析区域 -->
|
||||
<view v-if="currentSubsection > 0" class="analysis-content">
|
||||
<!-- 互动分析区域 -->
|
||||
<view v-if="currentSubsection === 1" class="analysis-grid">
|
||||
<!-- 互动频率分析卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">互动频率分析</text>
|
||||
<text class="card-subtitle">客户互动频次统计</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-shujucanmou" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">互动频率分析图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 互动频率统计 -->
|
||||
<view class="interaction-stats">
|
||||
<view class="interaction-row">
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label">高频互动</text>
|
||||
<text class="interaction-value">{{ interactionAnalysis.frequencyAnalysis.highFrequency.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label">中频互动</text>
|
||||
<text class="interaction-value">{{ interactionAnalysis.frequencyAnalysis.midFrequency.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label">低频互动</text>
|
||||
<text class="interaction-value">{{ interactionAnalysis.frequencyAnalysis.lowFrequency.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="interaction-row">
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label-small">每周多次互动</text>
|
||||
</view>
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label-small">每月多次互动</text>
|
||||
</view>
|
||||
<view class="interaction-item">
|
||||
<text class="interaction-label-small">偶尔互动</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 互动内容分析卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">互动内容分析</text>
|
||||
<text class="card-subtitle">客户互动内容类型占比</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-bingtu" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">互动内容分析图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 互动内容类型分布 -->
|
||||
<view class="content-distribution">
|
||||
<view class="content-item">
|
||||
<view class="content-icon blue">
|
||||
<text class="iconfont icon-xiaoxi" style="color: #2979ff; font-size: 15px;"></text>
|
||||
</view>
|
||||
<text class="content-name">文字互动</text>
|
||||
<text class="content-value">{{ interactionAnalysis.contentAnalysis.textMessages.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="content-item">
|
||||
<view class="content-icon green">
|
||||
<text class="iconfont icon-tupian" style="color: #19be6b; font-size: 15px;"></text>
|
||||
</view>
|
||||
<text class="content-name">图片互动</text>
|
||||
<text class="content-value">{{ interactionAnalysis.contentAnalysis.imgInteractions.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="content-item">
|
||||
<view class="content-icon purple">
|
||||
<text class="iconfont icon-yonghuqun" style="color: #9c26b0; font-size: 15px;"></text>
|
||||
</view>
|
||||
<text class="content-name">群聊互动</text>
|
||||
<text class="content-value">{{ interactionAnalysis.contentAnalysis.groupInteractions.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="content-item">
|
||||
<view class="content-icon orange">
|
||||
<text class="iconfont icon-shujucanmou" style="color: #ff9900; font-size: 15px;"></text>
|
||||
</view>
|
||||
<text class="content-name">产品咨询</text>
|
||||
<text class="content-value">{{ interactionAnalysis.contentAnalysis.productInquiries.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 转化分析区域 -->
|
||||
<view v-if="currentSubsection === 2" class="analysis-grid">
|
||||
<!-- 转化漏斗卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">转化漏斗</text>
|
||||
<text class="card-subtitle">客户转化路径分析</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-shujucanmou" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">转化漏斗图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 转化漏斗数据 -->
|
||||
<view class="funnel-stats">
|
||||
<view class="funnel-item">
|
||||
<view class="funnel-label">互动</view>
|
||||
<view class="funnel-value">3,256</view>
|
||||
<view class="funnel-percent">100%</view>
|
||||
</view>
|
||||
<view class="funnel-item">
|
||||
<view class="funnel-label">咨询</view>
|
||||
<view class="funnel-value">1,856</view>
|
||||
<view class="funnel-percent">57%</view>
|
||||
</view>
|
||||
<view class="funnel-item">
|
||||
<view class="funnel-label">意向</view>
|
||||
<view class="funnel-value">845</view>
|
||||
<view class="funnel-percent">26%</view>
|
||||
</view>
|
||||
<view class="funnel-item">
|
||||
<view class="funnel-label">成交</view>
|
||||
<view class="funnel-value">386</view>
|
||||
<view class="funnel-percent">12%</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 转化效率卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">转化效率</text>
|
||||
<text class="card-subtitle">各阶段转化率分析</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-bingtu" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">转化效率图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 转化效率数据 -->
|
||||
<view class="efficiency-stats">
|
||||
<view class="efficiency-item">
|
||||
<view class="efficiency-row">
|
||||
<view class="efficiency-label">互动→咨询</view>
|
||||
<view class="efficiency-value">57%</view>
|
||||
</view>
|
||||
<view class="efficiency-percent">
|
||||
<text class="percent-change up">+5.2% 较上期</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="efficiency-item">
|
||||
<view class="efficiency-row">
|
||||
<view class="efficiency-label">咨询→意向</view>
|
||||
<view class="efficiency-value">45.5%</view>
|
||||
</view>
|
||||
<view class="efficiency-percent">
|
||||
<text class="percent-change up">+3.8% 较上期</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="efficiency-item">
|
||||
<view class="efficiency-row">
|
||||
<view class="efficiency-label">意向→成交</view>
|
||||
<view class="efficiency-value">45.7%</view>
|
||||
</view>
|
||||
<view class="efficiency-percent">
|
||||
<text class="percent-change up">+4.2% 较上期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入分析区域 -->
|
||||
<view v-if="currentSubsection === 3" class="analysis-grid">
|
||||
<!-- 收入趋势卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">收入趋势</text>
|
||||
<text class="card-subtitle">近期销售额和趋势</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-shuju1" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">收入趋势图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 收入统计数据 -->
|
||||
<view class="income-stats">
|
||||
<view class="income-stat-item">
|
||||
<view class="income-label">总收入</view>
|
||||
<view class="income-main-value">¥258,386</view>
|
||||
<view class="income-change up">+22.5% 较上期</view>
|
||||
</view>
|
||||
|
||||
<view class="income-stat-item" style="margin-top: 15px;">
|
||||
<view class="income-label">客单价</view>
|
||||
<view class="income-main-value">¥843</view>
|
||||
<view class="income-change up">+5.8% 较上期</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 产品销售分布卡片 -->
|
||||
<view class="analysis-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">产品销售分布</text>
|
||||
<text class="card-subtitle">各产品系列销售占比</text>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<view class="chart-placeholder">
|
||||
<view class="chart-icon">
|
||||
<text class="iconfont icon-bingtu" style="color: #999; font-size: 50px;"></text>
|
||||
</view>
|
||||
<text class="chart-text">产品销售分布图表</text>
|
||||
</view>
|
||||
|
||||
<!-- 产品销售分布数据 -->
|
||||
<view class="product-distribution">
|
||||
<view class="product-item">
|
||||
<view class="product-dot" style="background-color: #2979ff;"></view>
|
||||
<text class="product-name">法儿曼胶原修复系列</text>
|
||||
<text class="product-percent">42%</text>
|
||||
</view>
|
||||
<view class="product-value">¥108,551</view>
|
||||
|
||||
<view class="product-item" style="margin-top: 12px;">
|
||||
<view class="product-dot" style="background-color: #19be6b;"></view>
|
||||
<text class="product-name">安格安睛眼部系列</text>
|
||||
<text class="product-percent">23%</text>
|
||||
</view>
|
||||
<view class="product-value">¥59,444</view>
|
||||
|
||||
<view class="product-item" style="margin-top: 12px;">
|
||||
<view class="product-dot" style="background-color: #9c26b0;"></view>
|
||||
<text class="product-name">色仕莱诺胸部系列</text>
|
||||
<text class="product-percent">18%</text>
|
||||
</view>
|
||||
<view class="product-value">¥46,522</view>
|
||||
|
||||
<view class="product-item" style="margin-top: 12px;">
|
||||
<view class="product-dot" style="background-color: #ff9900;"></view>
|
||||
<text class="product-name">头部疗愈SPA系列</text>
|
||||
<text class="product-percent">17%</text>
|
||||
</view>
|
||||
<view class="product-value">¥43,939</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他暂无数据区域 -->
|
||||
<view v-else-if="currentSubsection > 3" class="empty-data">
|
||||
<text class="iconfont icon-kong" style="color: #c0c4cc; font-size: 50px;"></text>
|
||||
<text class="empty-text">{{ subsectionList[currentSubsection] }}暂无数据</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 下方区域:综合分析 -->
|
||||
<view class="bottom-section" style="position: relative;">
|
||||
<view class="comprehensive-analysis-card">
|
||||
<!-- 标题 -->
|
||||
<view class="analysis-title">综合分析</view>
|
||||
|
||||
<!-- 客户平均转化金额 -->
|
||||
<view class="avg-conversion-card">
|
||||
<text class="avg-conversion-label">客户平均转化金额</text>
|
||||
<text class="avg-conversion-value">¥{{ comprehensiveData.avgConversionAmount.toFixed(2) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 价值指标和增长趋势 -->
|
||||
<view class="metrics-grid">
|
||||
<!-- 价值指标 -->
|
||||
<view class="metrics-column">
|
||||
<view class="metrics-header">
|
||||
<text class="iconfont icon-shuju1" style="color: #999; font-size: 14px; margin-right: 4px;"></text>
|
||||
<text class="metrics-title">价值指标</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">销售总额</text>
|
||||
<text class="metrics-value">¥{{ comprehensiveData.totalSales.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">平均订单金额</text>
|
||||
<text class="metrics-value">¥{{ comprehensiveData.avgOrderAmount.toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">高价值客户</text>
|
||||
<text class="metrics-value">{{ comprehensiveData.highValueCustomers.toFixed(1) }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 增长趋势 -->
|
||||
<view class="metrics-column">
|
||||
<view class="metrics-header">
|
||||
<text class="iconfont icon-shuju1" style="color: #999; font-size: 14px; margin-right: 4px;"></text>
|
||||
<text class="metrics-title">增长趋势</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">周收益增长</text>
|
||||
<text class="metrics-value up">{{ comprehensiveData.weeklyRevenueGrowth > 0 ? '+' : '' }}¥{{ comprehensiveData.weeklyRevenueGrowth.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">新客转化</text>
|
||||
<text class="metrics-value up">{{ comprehensiveData.newCustomerConversion > 0 ? '+' : '' }}{{ comprehensiveData.newCustomerConversion }}人</text>
|
||||
</view>
|
||||
<view class="metrics-item">
|
||||
<text class="metrics-label">活跃客户增长</text>
|
||||
<text class="metrics-value up">{{ comprehensiveData.activeCustomerGrowth > 0 ? '+' : '' }}{{ comprehensiveData.activeCustomerGrowth }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户活跃度和转化客户来源 -->
|
||||
<view class="metrics-grid bottom-section">
|
||||
<!-- 客户活跃度 -->
|
||||
<view class="metrics-column">
|
||||
<view class="metrics-header">
|
||||
<text class="iconfont icon-shujucanmou" style="color: #666; font-size: 16px; margin-right: 4px;"></text>
|
||||
<text class="metrics-title">客户活跃度</text>
|
||||
</view>
|
||||
<view class="activity-item" v-for="(item, index) in comprehensiveData.customerActivity" :key="index">
|
||||
<view class="activity-dot" :class="getActivityDotClass(item.name)"></view>
|
||||
<text class="activity-label">{{ item.name }}</text>
|
||||
<text class="activity-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 转化客户来源 -->
|
||||
<view class="metrics-column">
|
||||
<view class="metrics-header">
|
||||
<text class="iconfont icon-shuju1" style="color: #666; font-size: 16px; margin-right: 4px;"></text>
|
||||
<text class="metrics-title">转化客户来源</text>
|
||||
</view>
|
||||
<view class="source-item-new" v-for="(item, index) in comprehensiveData.conversionSource" :key="index">
|
||||
<text class="iconfont" :class="getSourceIconClass(item.name)" style="color: #666; font-size: 14px; margin-right: 6px;"></text>
|
||||
<text class="source-label">{{ item.name }}</text>
|
||||
<text class="source-value">{{ item.count.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<!-- 综合分析区域遮罩层 -->
|
||||
<view v-if="isLoadingComprehensive" class="section-loading-mask">
|
||||
<view class="section-loading-content">
|
||||
<view class="section-spinner"></view>
|
||||
<text class="section-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<u-popup :show="showDatePopup" mode="bottom" @close="showDatePopup = false">
|
||||
<view class="date-selector-popup">
|
||||
<view class="date-selector-header">
|
||||
@@ -527,6 +270,7 @@
|
||||
@confirm="confirmEndDate"
|
||||
@cancel="showEndDatePicker = false"
|
||||
></u-datetime-picker>
|
||||
|
||||
</view>
|
||||
|
||||
</template>
|
||||
@@ -545,6 +289,8 @@
|
||||
data() {
|
||||
const today = new Date();
|
||||
return {
|
||||
isLoadingOverview: false, // 数据概览区域加载状态
|
||||
isLoadingComprehensive: false, // 综合分析区域加载状态
|
||||
dateRange: '本周',
|
||||
timeType: 'this_week',
|
||||
showDatePopup: false,
|
||||
@@ -560,14 +306,24 @@
|
||||
subsectionList: ['客户分析', '互动分析'/* , '转化分析', '收入分析' */],
|
||||
currentSubsection: 0,
|
||||
overviewData: {
|
||||
totalCustomers: 0,
|
||||
accountValue: 0,
|
||||
newCustomers: 0,
|
||||
totalCustomersChange: 0,
|
||||
newCustomersChange: 0,
|
||||
interactions: 0,
|
||||
interactionsChange: 0,
|
||||
conversionRate: 28.6,
|
||||
conversionRateChange: 3.2
|
||||
conversionRate: 0,
|
||||
conversionRateChange: 0
|
||||
},
|
||||
comprehensiveData: {
|
||||
avgConversionAmount: 0,
|
||||
totalSales: 0,
|
||||
avgOrderAmount: 0,
|
||||
highValueCustomers: 0,
|
||||
weeklyRevenueGrowth: 0,
|
||||
newCustomerConversion: 0,
|
||||
activeCustomerGrowth: 0,
|
||||
customerActivity: [], // 改为数组,存储API返回的原始数据
|
||||
conversionSource: [] // 改为数组,存储API返回的原始数据
|
||||
},
|
||||
dateOptions: [
|
||||
{ label: '今日', value: 'today' },
|
||||
@@ -604,8 +360,12 @@
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchOverviewData();
|
||||
this.fetchCustomerAnalysis();
|
||||
this.isLoadingOverview = true;
|
||||
this.isLoadingComprehensive = true;
|
||||
Promise.all([
|
||||
this.fetchOverviewData(),
|
||||
this.fetchCustomerAnalysis()
|
||||
]);
|
||||
},
|
||||
methods: {
|
||||
async fetchOverviewData() {
|
||||
@@ -620,13 +380,13 @@
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
this.overviewData = {
|
||||
...this.overviewData,
|
||||
totalCustomers: res.data.total_customers.value || 0,
|
||||
totalCustomersChange: res.data.total_customers.growth || 0,
|
||||
newCustomers: res.data.new_customers.value || 0,
|
||||
newCustomersChange: res.data.new_customers.growth || 0,
|
||||
interactions: res.data.interaction_count.value || 0,
|
||||
interactionsChange: res.data.interaction_count.growth || 0
|
||||
accountValue: res.data.account_value?.avg_rfm || this.overviewData.accountValue,
|
||||
newCustomers: res.data.new_customers?.value || this.overviewData.newCustomers,
|
||||
newCustomersChange: res.data.new_customers?.growth || this.overviewData.newCustomersChange,
|
||||
interactions: res.data.interaction_count?.value || this.overviewData.interactions,
|
||||
interactionsChange: res.data.interaction_count?.growth || this.overviewData.interactionsChange,
|
||||
conversionRate: res.data.conversion_rate?.value || this.overviewData.conversionRate,
|
||||
conversionRateChange: res.data.conversion_rate?.growth || this.overviewData.conversionRateChange
|
||||
};
|
||||
} else {
|
||||
uni.showToast({
|
||||
@@ -640,28 +400,42 @@
|
||||
title: '网络异常,请稍后重试',
|
||||
icon: 'none'
|
||||
});
|
||||
} finally {
|
||||
this.isLoadingOverview = false;
|
||||
}
|
||||
},
|
||||
async fetchCustomerAnalysis() {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/v1/store/statistics/customer-analysis',
|
||||
url: '/v1/store/statistics/comprehensive-analysis',
|
||||
method: 'GET',
|
||||
data: {
|
||||
time_type: this.timeType
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
// 更新趋势数据
|
||||
this.customerAnalysis.trend = {
|
||||
total: res.data.trend.total || 0,
|
||||
new: res.data.trend.new || 0,
|
||||
lost: res.data.trend.lost || 0
|
||||
};
|
||||
// 处理高价值客户百分比字符串(如"0.0%"转为0.0)
|
||||
let highValueCustomers = 0;
|
||||
if (res.data.value_indicators?.high_value_customers) {
|
||||
const highValueStr = res.data.value_indicators.high_value_customers;
|
||||
highValueCustomers = parseFloat(highValueStr.replace('%', '')) || 0;
|
||||
}
|
||||
|
||||
// 更新来源分布数据
|
||||
this.customerAnalysis.sourceDistribution = res.data.source_distribution || [];
|
||||
// 更新综合分析数据,直接存储数组数据
|
||||
this.comprehensiveData = {
|
||||
...this.comprehensiveData,
|
||||
avgConversionAmount: res.data.avg_conversion_amount ?? this.comprehensiveData.avgConversionAmount,
|
||||
totalSales: res.data.value_indicators?.total_sales ?? this.comprehensiveData.totalSales,
|
||||
avgOrderAmount: res.data.value_indicators?.avg_order_amount ?? this.comprehensiveData.avgOrderAmount,
|
||||
highValueCustomers: highValueCustomers ?? this.comprehensiveData.highValueCustomers,
|
||||
weeklyRevenueGrowth: res.data.growth_trend?.weekly_revenue_growth ?? this.comprehensiveData.weeklyRevenueGrowth,
|
||||
newCustomerConversion: res.data.growth_trend?.new_customer_conversion ?? this.comprehensiveData.newCustomerConversion,
|
||||
activeCustomerGrowth: res.data.growth_trend?.active_customer_growth ?? this.comprehensiveData.activeCustomerGrowth,
|
||||
customerActivity: res.data.frequency_analysis || [],
|
||||
conversionSource: res.data.source_distribution || []
|
||||
};
|
||||
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.msg || '获取客户分析数据失败',
|
||||
@@ -674,6 +448,8 @@
|
||||
title: '网络异常,请稍后重试',
|
||||
icon: 'none'
|
||||
});
|
||||
} finally {
|
||||
this.isLoadingComprehensive = false;
|
||||
}
|
||||
},
|
||||
async fetchInteractionAnalysis() {
|
||||
@@ -715,21 +491,54 @@
|
||||
title: '网络异常,请稍后重试',
|
||||
icon: 'none'
|
||||
});
|
||||
} finally {
|
||||
this.isLoadingComprehensive = false;
|
||||
}
|
||||
},
|
||||
async changeSubsection(index) {
|
||||
this.currentSubsection = index;
|
||||
|
||||
// 根据不同的分段加载不同的数据
|
||||
if (index === 0) {
|
||||
await this.fetchCustomerAnalysis();
|
||||
} else if (index === 1) {
|
||||
await this.fetchInteractionAnalysis();
|
||||
this.isLoadingComprehensive = true;
|
||||
try {
|
||||
if (index === 0) {
|
||||
await this.fetchCustomerAnalysis();
|
||||
} else if (index === 1) {
|
||||
await this.fetchInteractionAnalysis();
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingComprehensive = false;
|
||||
}
|
||||
},
|
||||
closePage() {
|
||||
this.$emit('close');
|
||||
},
|
||||
// 根据客户活跃度名称返回对应的dot颜色class
|
||||
getActivityDotClass(name) {
|
||||
if (!name) return 'gray';
|
||||
// 优先精确匹配
|
||||
if (name === '高频') return 'red';
|
||||
if (name === '中频') return 'orange';
|
||||
if (name === '低频') return 'gray';
|
||||
// 模糊匹配
|
||||
if (name.includes('高频')) return 'red';
|
||||
if (name.includes('中频')) return 'orange';
|
||||
if (name.includes('低频')) return 'gray';
|
||||
return 'gray'; // 默认灰色
|
||||
},
|
||||
// 根据转化客户来源名称返回对应的图标class
|
||||
getSourceIconClass(name) {
|
||||
if (!name) return 'icon-yonghu';
|
||||
// 优先精确匹配
|
||||
if (name === '朋友推荐') return 'icon-yonghu';
|
||||
if (name === '微信搜索') return 'icon-sousuo';
|
||||
if (name === '微信群') return 'icon-yonghuqun';
|
||||
// 模糊匹配
|
||||
if (name.includes('推荐')) return 'icon-yonghu';
|
||||
if (name.includes('搜索')) return 'icon-sousuo';
|
||||
if (name.includes('群')) return 'icon-yonghuqun';
|
||||
return 'icon-yonghu'; // 默认图标
|
||||
},
|
||||
showDateSelector() {
|
||||
this.showDatePopup = true;
|
||||
},
|
||||
@@ -741,13 +550,19 @@
|
||||
this.showDatePopup = false;
|
||||
|
||||
// 重新获取数据
|
||||
await this.fetchOverviewData();
|
||||
|
||||
// 根据当前选中的分段重新加载对应数据
|
||||
if (this.currentSubsection === 0) {
|
||||
await this.fetchCustomerAnalysis();
|
||||
} else if (this.currentSubsection === 1) {
|
||||
await this.fetchInteractionAnalysis();
|
||||
this.isLoadingOverview = true;
|
||||
this.isLoadingComprehensive = true;
|
||||
try {
|
||||
await this.fetchOverviewData();
|
||||
|
||||
// 根据当前选中的分段重新加载对应数据
|
||||
if (this.currentSubsection === 0) {
|
||||
await this.fetchCustomerAnalysis();
|
||||
} else if (this.currentSubsection === 1) {
|
||||
await this.fetchInteractionAnalysis();
|
||||
}
|
||||
} finally {
|
||||
// 加载状态在各自的 fetch 方法中控制
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -962,6 +777,21 @@
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* 上方区域:数据概览 */
|
||||
.top-section {
|
||||
background-color: #fff;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 8px solid #f5f7fa;
|
||||
}
|
||||
|
||||
/* 下方区域:综合分析 */
|
||||
.bottom-section {
|
||||
background-color: #f5f7fa;
|
||||
padding-top: 0;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1064,6 +894,12 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.up {
|
||||
color: #18b566;
|
||||
}
|
||||
@@ -1280,7 +1116,8 @@
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
@@ -1737,4 +1574,226 @@
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 综合分析区域样式 - 整体卡片 */
|
||||
.comprehensive-analysis-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin: 15px;
|
||||
margin-top: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.analysis-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* 客户平均转化金额卡片 */
|
||||
.avg-conversion-card {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.avg-conversion-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avg-conversion-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
/* 指标网格 */
|
||||
.metrics-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 底部部分增加上边距 */
|
||||
.metrics-grid.bottom-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.metrics-column {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.metrics-column:first-child {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.metrics-column:last-child {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metrics-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metrics-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metrics-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.metrics-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metrics-value {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.metrics-value.up {
|
||||
color: #18b566;
|
||||
}
|
||||
|
||||
/* 客户活跃度样式 */
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.activity-dot.red {
|
||||
background-color: #fa3534;
|
||||
}
|
||||
|
||||
.activity-dot.orange {
|
||||
background-color: #ff9900;
|
||||
}
|
||||
|
||||
.activity-dot.gray {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.activity-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 转化客户来源样式 */
|
||||
.source-item-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.source-item-new:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 区域遮罩层样式 */
|
||||
.section-loading-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(0, 128, 255, 0.2);
|
||||
border-top-color: #0080ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.section-loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,61 @@
|
||||
<template>
|
||||
<view v-if="show" class="side-menu-container">
|
||||
<view class="side-menu-mask" @tap="closeSideMenu"></view>
|
||||
<view class="side-menu">
|
||||
<view>
|
||||
<!-- 更新弹窗 - 放在组件外层,确保即使侧边栏关闭也能显示 -->
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view v-if="showUpdateDialog" class="update-dialog-mask" @tap="closeUpdateDialog">
|
||||
<view class="update-dialog" @tap.stop>
|
||||
<!-- 火箭图标 -->
|
||||
<view class="update-rocket">
|
||||
<text class="iconfont" style="font-size: 80px; color: #5096ff;">🚀</text>
|
||||
</view>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<view class="update-version-info">
|
||||
<text class="update-version-text">发现新版本 {{ updateInfo.version }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 更新内容列表 -->
|
||||
<view class="update-content-list">
|
||||
<view
|
||||
class="update-content-item"
|
||||
v-for="(item, index) in updateInfo.updateContent"
|
||||
:key="index"
|
||||
>
|
||||
<text class="update-item-number">{{ index + 1 }}.</text>
|
||||
<text class="update-item-text">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 下载进度条 -->
|
||||
<view v-if="downloading" class="download-progress-wrapper">
|
||||
<view class="download-progress-bar">
|
||||
<view class="download-progress-fill" :style="{ width: downloadProgress + '%' }"></view>
|
||||
</view>
|
||||
<text class="download-progress-text">{{ downloadProgress }}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 升级按钮 -->
|
||||
<view class="update-button-wrapper">
|
||||
<view
|
||||
class="update-button"
|
||||
:class="{ 'update-button-disabled': downloading }"
|
||||
@tap="startDownload"
|
||||
>
|
||||
<text class="update-button-text">{{ downloading ? '下载中...' : '即刻升级' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view v-if="!updateInfo.forceUpdate && !downloading" class="update-close-btn" @tap="closeUpdateDialog">
|
||||
<text class="update-close-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view v-if="show" class="side-menu-container">
|
||||
<view class="side-menu-mask" @tap="closeSideMenu"></view>
|
||||
<view class="side-menu">
|
||||
<view class="side-menu-header">
|
||||
<text class="side-menu-title">AI数智员工</text>
|
||||
<!-- <text class="close-icon" @tap="closeSideMenu">
|
||||
@@ -116,6 +170,22 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view class="module-item" @tap="() => handleCheckUpdate(false)">
|
||||
<view class="module-left">
|
||||
<view class="module-icon green">
|
||||
<text class="iconfont icon-shezhi" style="color: #33cc99; font-size: 24px;"></text>
|
||||
</view>
|
||||
<view class="module-info">
|
||||
<text class="module-name">检查更新</text>
|
||||
<text class="module-desc" v-if="!checkingUpdate && !hasNewVersion">当前版本 {{ currentVersion }}</text>
|
||||
<text class="module-desc" v-if="checkingUpdate" style="color: #33cc99;">检查中...</text>
|
||||
<text class="module-desc" v-if="!checkingUpdate && hasNewVersion" style="color: #ff6699;">发现新版本 {{ latestVersion }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="module-item" @tap="showSettings" v-if='hide'>
|
||||
<view class="module-left">
|
||||
<view class="module-icon gray">
|
||||
@@ -158,6 +228,8 @@
|
||||
@close="closeLoginPage"
|
||||
@login-success="handleLoginSuccess"
|
||||
></login-register>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -169,7 +241,7 @@
|
||||
import DataStatistics from './DataStatistics.vue';
|
||||
import CustomerManagement from './CustomerManagement.vue';
|
||||
import { hasValidToken, clearToken, redirectToLogin } from '../api/utils/auth';
|
||||
import { request } from '../api/config';
|
||||
import { request, APP_CONFIG } from '../api/config';
|
||||
|
||||
export default {
|
||||
name: "SideMenu",
|
||||
@@ -204,7 +276,23 @@
|
||||
showCustomerManagementPage: false,
|
||||
showLoginPageFlag: false,
|
||||
isLoggedIn: false, // 用户登录状态
|
||||
userInfo: null // 用户信息
|
||||
userInfo: null, // 用户信息
|
||||
// 版本更新相关
|
||||
currentVersion: '', // 当前版本
|
||||
latestVersion: '', // 最新版本
|
||||
hasNewVersion: false, // 是否有新版本
|
||||
checkingUpdate: false, // 是否正在检查更新
|
||||
showUpdateDialog: false, // 是否显示更新弹窗
|
||||
updateInfo: {
|
||||
version: '', // 新版本号
|
||||
updateContent: [], // 更新内容列表
|
||||
downloadUrl: '', // 下载地址
|
||||
forceUpdate: false // 是否强制更新
|
||||
},
|
||||
// 下载相关
|
||||
downloading: false, // 是否正在下载
|
||||
downloadProgress: 0, // 下载进度 0-100
|
||||
downloadTask: null // 下载任务对象
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -226,6 +314,8 @@
|
||||
this.checkLoginStatus();
|
||||
// 获取功能开关状态
|
||||
this.getFunctionStatus();
|
||||
// 获取当前版本号并自动检查更新
|
||||
this.getCurrentVersionAndCheckUpdate();
|
||||
},
|
||||
methods: {
|
||||
// 检查登录状态
|
||||
@@ -454,7 +544,367 @@
|
||||
showSettings() {
|
||||
// 显示系统设置页面
|
||||
this.showSystemSettingsPage = true;
|
||||
},
|
||||
|
||||
// 获取当前版本号
|
||||
getCurrentVersion() {
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.getProperty(plus.runtime.appid, (info) => {
|
||||
this.currentVersion = info.version || '1.0.0';
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
this.currentVersion = '1.0.0';
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 获取当前版本号并自动检查更新
|
||||
getCurrentVersionAndCheckUpdate() {
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.getProperty(plus.runtime.appid, (info) => {
|
||||
this.currentVersion = info.version || '1.0.0';
|
||||
console.log('获取到当前版本号:', this.currentVersion);
|
||||
// 版本号获取完成后,自动检查更新(延迟500ms,确保应用已完全启动)
|
||||
setTimeout(() => {
|
||||
this.handleCheckUpdate(true); // 传入 true 表示自动检查,不显示"已是最新版本"提示
|
||||
}, 500);
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
this.currentVersion = '1.0.0';
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 检查更新
|
||||
// autoCheck: true 表示自动检查(应用启动时),不显示"已是最新版本"提示
|
||||
// autoCheck: false 表示手动检查(用户点击按钮),显示所有提示
|
||||
async handleCheckUpdate(autoCheck = false) {
|
||||
// #ifdef APP-PLUS
|
||||
if (this.checkingUpdate) {
|
||||
return; // 正在检查中,避免重复请求
|
||||
}
|
||||
|
||||
// 如果版本号还没获取到,先获取版本号
|
||||
if (!this.currentVersion) {
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.getProperty(plus.runtime.appid, (info) => {
|
||||
this.currentVersion = info.version || '1.0.0';
|
||||
// 版本号获取完成后,继续检查更新
|
||||
setTimeout(() => {
|
||||
this.handleCheckUpdate(autoCheck);
|
||||
}, 100);
|
||||
});
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkingUpdate = true;
|
||||
|
||||
try {
|
||||
console.log('开始检查更新,当前版本:', this.currentVersion);
|
||||
|
||||
// 调用检查更新接口
|
||||
const res = await request({
|
||||
url: '/v1/app/update',
|
||||
method: 'GET',
|
||||
data: {
|
||||
version: this.currentVersion,
|
||||
type: 'aiStore'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('更新检测结果:', res);
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
this.latestVersion = data.version || '';
|
||||
|
||||
// 比较版本号
|
||||
const compareResult = this.compareVersion(this.latestVersion, this.currentVersion);
|
||||
|
||||
if (compareResult > 0) {
|
||||
// 线上版本大于本地版本,有新版本
|
||||
this.hasNewVersion = true;
|
||||
|
||||
// 设置更新信息
|
||||
this.updateInfo = {
|
||||
version: data.version || '',
|
||||
updateContent: this.parseUpdateContent(data.updateContent || data.content || ''),
|
||||
downloadUrl: data.downloadUrl || data.url || '',
|
||||
forceUpdate: data.forceUpdate || false
|
||||
};
|
||||
|
||||
// 根据检查类型决定是否显示弹窗
|
||||
// autoCheck === false 表示手动检查,autoCheck === true 表示自动检查
|
||||
if (autoCheck === false) {
|
||||
// 手动检查:有新版本时总是显示弹窗(不受每日限制)
|
||||
console.log('手动检查更新,直接显示弹窗');
|
||||
this.showUpdateDialog = true;
|
||||
} else {
|
||||
// 自动检查:每天只能弹出一次
|
||||
console.log('自动检查更新,检查今日是否已显示过弹窗');
|
||||
if (this.shouldShowUpdateDialog()) {
|
||||
this.showUpdateDialog = true;
|
||||
this.recordUpdateDialogShown();
|
||||
} else {
|
||||
console.log('今天已显示过更新弹窗,不再自动弹出');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 已是最新版本
|
||||
this.hasNewVersion = false;
|
||||
// 只有手动检查时才显示"已是最新版本"提示
|
||||
if (!autoCheck) {
|
||||
uni.showToast({
|
||||
title: '已是最新版本',
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 只有手动检查时才显示错误提示
|
||||
if (!autoCheck) {
|
||||
uni.showToast({
|
||||
title: res.msg || '检查更新失败',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
// 只有手动检查时才显示错误提示
|
||||
if (!autoCheck) {
|
||||
uni.showToast({
|
||||
title: '检查更新失败,请稍后重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.checkingUpdate = false;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
if (!autoCheck) {
|
||||
uni.showToast({
|
||||
title: '此功能仅在APP中可用',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 比较版本号,返回 1 表示 version1 > version2,返回 -1 表示 version1 < version2,返回 0 表示相等
|
||||
compareVersion(version1, version2) {
|
||||
if (!version1 || !version2) return 0;
|
||||
|
||||
const v1Parts = version1.split('.').map(Number);
|
||||
const v2Parts = version2.split('.').map(Number);
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1Part = v1Parts[i] || 0;
|
||||
const v2Part = v2Parts[i] || 0;
|
||||
|
||||
if (v1Part > v2Part) return 1;
|
||||
if (v1Part < v2Part) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
|
||||
// 解析更新内容,将字符串转换为数组
|
||||
parseUpdateContent(content) {
|
||||
if (!content) return [];
|
||||
|
||||
// 如果已经是数组,直接返回
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// 如果是字符串,尝试按换行符或分号分割
|
||||
if (typeof content === 'string') {
|
||||
// 先尝试按换行符分割
|
||||
let items = content.split(/\n+/).filter(item => item.trim());
|
||||
|
||||
// 如果没有换行,尝试按分号分割
|
||||
if (items.length === 1) {
|
||||
items = content.split(/[;;]/).filter(item => item.trim());
|
||||
}
|
||||
|
||||
// 清理每个项目,移除可能的编号前缀(如 "1. ", "1、", "- " 等)
|
||||
return items.map(item => {
|
||||
return item.replace(/^[\d一二三四五六七八九十]+[\.、\s\-]*/, '').trim();
|
||||
}).filter(item => item);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
// 检查今天是否应该显示更新弹窗(用于自动检查)
|
||||
shouldShowUpdateDialog() {
|
||||
try {
|
||||
const lastShownDate = uni.getStorageSync('updateDialogLastShownDate');
|
||||
if (!lastShownDate) {
|
||||
return true; // 从未显示过,可以显示
|
||||
}
|
||||
|
||||
// 获取今天的日期字符串(格式:YYYY-MM-DD)
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 如果今天已经显示过,则不显示
|
||||
return lastShownDate !== todayStr;
|
||||
} catch (e) {
|
||||
console.error('检查更新弹窗显示状态失败:', e);
|
||||
return true; // 出错时默认允许显示
|
||||
}
|
||||
},
|
||||
|
||||
// 记录今天已显示更新弹窗
|
||||
recordUpdateDialogShown() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
uni.setStorageSync('updateDialogLastShownDate', todayStr);
|
||||
console.log('已记录更新弹窗显示日期:', todayStr);
|
||||
} catch (e) {
|
||||
console.error('记录更新弹窗显示日期失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭更新弹窗
|
||||
closeUpdateDialog() {
|
||||
if (!this.updateInfo.forceUpdate) {
|
||||
this.showUpdateDialog = false;
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '此版本为重要更新,请升级后使用',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 开始下载更新
|
||||
startDownload() {
|
||||
// #ifdef APP-PLUS
|
||||
if (!this.updateInfo.downloadUrl) {
|
||||
uni.showToast({
|
||||
title: '下载地址无效',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.downloading) {
|
||||
return; // 已经在下载中
|
||||
}
|
||||
|
||||
this.downloading = true;
|
||||
this.downloadProgress = 0;
|
||||
|
||||
// 创建下载任务
|
||||
const downloadPath = '_downloads/update_' + Date.now() + '.apk';
|
||||
this.downloadTask = plus.downloader.createDownload(this.updateInfo.downloadUrl, {
|
||||
filename: downloadPath // 下载文件名
|
||||
}, (download, status) => {
|
||||
if (status === 200) {
|
||||
// 下载成功
|
||||
console.log('下载成功:', download.filename);
|
||||
this.downloading = false;
|
||||
this.downloadProgress = 100;
|
||||
|
||||
// 安装APK
|
||||
setTimeout(() => {
|
||||
this.installAPK(download.filename);
|
||||
}, 500);
|
||||
} else {
|
||||
// 下载失败
|
||||
console.error('下载失败:', status);
|
||||
this.downloading = false;
|
||||
this.downloadProgress = 0;
|
||||
uni.showToast({
|
||||
title: '下载失败,请稍后重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听下载进度
|
||||
this.downloadTask.addEventListener('statechanged', (download, status) => {
|
||||
switch (download.state) {
|
||||
case 1: // 开始下载
|
||||
console.log('开始下载...');
|
||||
break;
|
||||
case 2: // 连接到服务器
|
||||
console.log('连接到服务器...');
|
||||
break;
|
||||
case 3: // 下载中
|
||||
if (download.totalSize > 0) {
|
||||
const progress = Math.floor((download.downloadedSize / download.totalSize) * 100);
|
||||
this.downloadProgress = Math.min(progress, 99); // 最大99,完成时再设为100
|
||||
console.log('下载进度:', this.downloadProgress + '%', '已下载:', download.downloadedSize, '总大小:', download.totalSize);
|
||||
}
|
||||
break;
|
||||
case 4: // 下载完成
|
||||
console.log('下载完成');
|
||||
this.downloadProgress = 100;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 开始下载
|
||||
this.downloadTask.start();
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 安装APK
|
||||
installAPK(filePath) {
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
// 获取文件的完整路径
|
||||
const fullPath = plus.io.convertLocalFileSystemURL(filePath);
|
||||
console.log('准备安装APK:', fullPath);
|
||||
|
||||
plus.runtime.install(fullPath, {}, () => {
|
||||
console.log('安装成功');
|
||||
uni.showToast({
|
||||
title: '安装成功',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
// 关闭弹窗
|
||||
setTimeout(() => {
|
||||
this.showUpdateDialog = false;
|
||||
}, 1500);
|
||||
}, (error) => {
|
||||
console.error('安装失败:', error);
|
||||
uni.showToast({
|
||||
title: '安装失败,请到下载文件夹手动安装',
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('安装异常:', error);
|
||||
uni.showToast({
|
||||
title: '安装异常,请到下载文件夹手动安装',
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -740,4 +1190,178 @@
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 更新弹窗样式 */
|
||||
.update-dialog-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-dialog {
|
||||
position: relative;
|
||||
width: 85%;
|
||||
max-width: 600px;
|
||||
background-color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 40px 30px 30px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 手机上最大宽度80% */
|
||||
@media screen and (max-width: 768px) {
|
||||
.update-dialog {
|
||||
width: 80%;
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.update-rocket {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #5096ff 0%, #6b7fff 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 15px rgba(80, 150, 255, 0.3);
|
||||
}
|
||||
|
||||
.update-rocket text {
|
||||
font-size: 50px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.update-version-info {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.update-version-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.update-content-list {
|
||||
margin-bottom: 30px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.update-content-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.update-item-number {
|
||||
font-size: 15px;
|
||||
color: #5096ff;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.update-item-text {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.download-progress-wrapper {
|
||||
margin-bottom: 20px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.download-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.download-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #5096ff 0%, #6b7fff 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.download-progress-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #5096ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-button-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, #5096ff 0%, #6b7fff 100%);
|
||||
border-radius: 25px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 15px rgba(80, 150, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.update-button:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(80, 150, 255, 0.2);
|
||||
}
|
||||
|
||||
.update-button-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.update-button-text {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.update-close-btn {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.update-close-icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
"name" : "AI数智员工",
|
||||
"appid" : "__UNI__9421F6C",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.1",
|
||||
"versionName" : "1.1.1",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "store",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
|
||||
@@ -1,82 +1,56 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- 页面顶部导航 -->
|
||||
<view class="page-header">
|
||||
<view class="back-btn"></view>
|
||||
<view class="page-title">登录/注册</view>
|
||||
<!-- 返回按钮 - 只在APP端账号登录模式下显示 -->
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view class="back-btn" v-if="loginType === 'password'" @tap="goBack">
|
||||
<u-icon name="arrow-left" size="20" color="#333"></u-icon>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="content-area">
|
||||
<!-- 登录方式选项卡 -->
|
||||
<view class="tab-container" v-if="false">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: loginType === 'code' }"
|
||||
@tap="loginType = 'code'"
|
||||
>
|
||||
验证码登录
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: loginType === 'password' }"
|
||||
@tap="loginType = 'password'"
|
||||
>
|
||||
密码登录
|
||||
<!-- App图标和名称 -->
|
||||
<view class="app-header">
|
||||
<view class="app-icon">
|
||||
<image src="/static/logo.png" mode="aspectFit" class="logo-image"></image>
|
||||
</view>
|
||||
<view class="app-name">AI数智员工</view>
|
||||
</view>
|
||||
|
||||
<!-- 地区提示 -->
|
||||
<view class="tip-text u-line-1">
|
||||
您所在地区仅支持 手机号 <!-- / 微信 / Apple 登录 -->
|
||||
<!-- 登录方式选择按钮 - 只在APP端免密登录模式下显示 -->
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view class="login-type-buttons" v-if="loginType === 'code'">
|
||||
<!-- 免密登录按钮 -->
|
||||
<view
|
||||
class="login-type-btn primary-btn"
|
||||
:class="{ active: loginType === 'code' }"
|
||||
@tap="handleCodeLogin"
|
||||
>
|
||||
免密登录
|
||||
</view>
|
||||
|
||||
<!-- 账号登录按钮 -->
|
||||
<view
|
||||
class="login-type-btn secondary-btn"
|
||||
:class="{ active: loginType === 'password' }"
|
||||
@tap="switchLoginType('password')"
|
||||
>
|
||||
账号登录
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 验证码登录 -->
|
||||
<block v-if="loginType === 'code'">
|
||||
<!-- 账号登录表单 -->
|
||||
<view class="form-container" v-if="loginType === 'password'">
|
||||
<view class="form-title">账号登录</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="input-item">
|
||||
<view class="input-prefix">+86</view>
|
||||
<u-input
|
||||
type="number"
|
||||
v-model="phone"
|
||||
maxlength="11"
|
||||
placeholder="手机号"
|
||||
class="input-field"
|
||||
border="0"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<view class="input-item code-input-box">
|
||||
<u-input
|
||||
type="number"
|
||||
v-model="code"
|
||||
maxlength="6"
|
||||
placeholder="验证码"
|
||||
class="input-field"
|
||||
border="0"
|
||||
/>
|
||||
<view
|
||||
class="send-code-btn"
|
||||
:class="{ disabled: codeSending || !isPhoneValid }"
|
||||
@tap="sendCode"
|
||||
>
|
||||
{{ codeText }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<block v-else>
|
||||
<view class="form-item">
|
||||
<view class="input-item">
|
||||
<view class="input-prefix">+86</view>
|
||||
<u-input
|
||||
type="number"
|
||||
type="text"
|
||||
v-model="phone"
|
||||
maxlength="11"
|
||||
placeholder="手机号"
|
||||
maxlength="50"
|
||||
placeholder="请输入账号"
|
||||
class="input-field"
|
||||
border="0"
|
||||
/>
|
||||
@@ -87,7 +61,7 @@
|
||||
<u-input
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
v-model="password"
|
||||
placeholder="密码"
|
||||
placeholder="请输入密码"
|
||||
class="input-field"
|
||||
border="0"
|
||||
/>
|
||||
@@ -96,21 +70,22 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 用户协议 -->
|
||||
<view class="agreement-container">
|
||||
<checkbox-group @change="checkboxChange">
|
||||
<checkbox :value="agreement" :checked="agreement" class="agreement-checkbox" color="#4080ff" />
|
||||
</checkbox-group>
|
||||
<text class="agreement-text">已阅读并同意</text>
|
||||
<text class="agreement-link" @tap="openAgreement('user')">用户协议</text>
|
||||
<text class="agreement-text">阅读并同意</text>
|
||||
<text class="agreement-link" @tap="openAgreement('user')">《用户协议》</text>
|
||||
<text class="agreement-text">与</text>
|
||||
<text class="agreement-link" @tap="openAgreement('privacy')">隐私政策</text>
|
||||
<text class="agreement-link" @tap="openAgreement('privacy')">《隐私权限》</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<!-- 登录按钮 - 只在账号登录模式下显示 -->
|
||||
<view
|
||||
v-if="loginType === 'password'"
|
||||
class="login-btn"
|
||||
:class="{ active: canLogin }"
|
||||
@tap="handleLogin"
|
||||
@@ -168,40 +143,51 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loginType: 'password', // 默认密码登录
|
||||
phone: '', // 手机号
|
||||
code: '', // 验证码
|
||||
loginType: '', // 登录类型,根据平台动态设置
|
||||
phone: '', // 账号
|
||||
password: '', // 密码
|
||||
passwordVisible: false, // 密码是否可见
|
||||
agreement: true, // 是否同意协议
|
||||
codeSending: false, // 是否正在发送验证码
|
||||
countdown: 60, // 倒计时
|
||||
codeText: '发送验证码' // 验证码按钮文本
|
||||
deviceId: '' // 设备ID
|
||||
}
|
||||
},
|
||||
// 页面加载时检查token
|
||||
// 页面加载时检查token并获取设备ID
|
||||
onLoad() {
|
||||
// 根据平台设置默认登录类型
|
||||
// #ifdef APP-PLUS
|
||||
// APP端默认免密登录
|
||||
this.loginType = 'code';
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
// H5端默认账号密码登录
|
||||
this.loginType = 'password';
|
||||
// #endif
|
||||
|
||||
this.checkTokenStatus();
|
||||
this.getDeviceId();
|
||||
},
|
||||
// 页面显示时检查token
|
||||
onShow() {
|
||||
this.checkTokenStatus();
|
||||
},
|
||||
computed: {
|
||||
// 验证手机号是否有效
|
||||
isPhoneValid() {
|
||||
return this.phone && this.phone.length === 11;
|
||||
// 验证账号是否有效(支持手机号、邮箱、用户名等格式)
|
||||
isAccountValid() {
|
||||
return this.phone && this.phone.trim().length >= 2;
|
||||
},
|
||||
// 验证是否可以登录
|
||||
canLogin() {
|
||||
if (!this.phone || !this.agreement) {
|
||||
if (!this.agreement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.loginType === 'code') {
|
||||
return this.isPhoneValid && this.code && this.code.length === 6;
|
||||
// 免密登录只需要同意协议和设备ID
|
||||
return !!this.deviceId;
|
||||
} else {
|
||||
return this.password && this.password.length >= 6;
|
||||
// 账号登录需要账号和密码
|
||||
return this.isAccountValid && this.password && this.password.length >= 6;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -214,8 +200,15 @@
|
||||
}
|
||||
},
|
||||
|
||||
// 返回上一页
|
||||
// 返回上一页或切换回免密登录
|
||||
goBack() {
|
||||
// #ifdef APP-PLUS
|
||||
// APP端在账号登录模式下,点击返回切换回免密登录
|
||||
if (this.loginType === 'password') {
|
||||
this.loginType = 'code';
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
uni.navigateBack();
|
||||
},
|
||||
|
||||
@@ -224,30 +217,115 @@
|
||||
this.loginType = type;
|
||||
},
|
||||
|
||||
// 发送验证码
|
||||
sendCode() {
|
||||
if (this.codeSending || !this.isPhoneValid) return;
|
||||
// 处理免密登录(点击免密登录按钮直接登录)
|
||||
handleCodeLogin() {
|
||||
// 如果当前不是免密登录类型,先切换
|
||||
if (this.loginType !== 'code') {
|
||||
this.loginType = 'code';
|
||||
// 等待一个事件循环,确保状态更新后再登录
|
||||
this.$nextTick(() => {
|
||||
this.handleLogin();
|
||||
});
|
||||
} else {
|
||||
// 如果已经是免密登录类型,直接登录
|
||||
this.handleLogin();
|
||||
}
|
||||
},
|
||||
|
||||
// 使用Android原生MD5生成32位十六进制字符串
|
||||
generateMD5(input) {
|
||||
if (!input) return '';
|
||||
|
||||
this.codeSending = true;
|
||||
this.countdown = 60;
|
||||
this.codeText = `${this.countdown}秒后重发`;
|
||||
|
||||
// 模拟发送验证码
|
||||
uni.showToast({
|
||||
title: '验证码已发送',
|
||||
icon: 'success'
|
||||
});
|
||||
|
||||
const timer = setInterval(() => {
|
||||
this.countdown--;
|
||||
this.codeText = `${this.countdown}秒后重发`;
|
||||
|
||||
if (this.countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
this.codeSending = false;
|
||||
this.codeText = '发送验证码';
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
if (typeof plus !== 'undefined' && plus.os && plus.os.name === 'Android') {
|
||||
// 使用Android原生MessageDigest生成MD5
|
||||
const MessageDigest = plus.android.importClass('java.security.MessageDigest');
|
||||
const md = MessageDigest.getInstance('MD5');
|
||||
|
||||
// 将字符串转换为字节数组
|
||||
const String = plus.android.importClass('java.lang.String');
|
||||
const strObj = new String(input);
|
||||
const bytes = strObj.getBytes('UTF-8');
|
||||
|
||||
// 更新消息摘要
|
||||
md.update(bytes);
|
||||
const digest = md.digest();
|
||||
|
||||
// 将字节数组转换为十六进制字符串
|
||||
let hexString = '';
|
||||
for (let i = 0; i < digest.length; i++) {
|
||||
const byteValue = digest[i] & 0xFF;
|
||||
const hex = byteValue.toString(16).toUpperCase();
|
||||
hexString += hex.length === 1 ? '0' + hex : hex;
|
||||
}
|
||||
|
||||
return hexString;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.warn('使用Android原生MD5失败,使用备用方案:', e);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 备用方案:使用简单的哈希函数生成32位十六进制字符串
|
||||
let hash = 0;
|
||||
const str = String(input); // JavaScript的String构造函数
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
|
||||
// 将哈希值转换为正数,然后转为16进制
|
||||
let hexString = Math.abs(hash).toString(16).toUpperCase();
|
||||
|
||||
// 如果不够32位,使用输入字符串补充
|
||||
if (hexString.length < 32) {
|
||||
// 使用输入字符串的字符码填充
|
||||
let padding = '';
|
||||
for (let i = 0; padding.length < 32 - hexString.length; i++) {
|
||||
const charCode = str.charCodeAt(i % str.length);
|
||||
padding += charCode.toString(16).toUpperCase().padStart(2, '0');
|
||||
}
|
||||
hexString = (hexString + padding).substring(0, 32);
|
||||
} else if (hexString.length > 32) {
|
||||
hexString = hexString.substring(0, 32);
|
||||
}
|
||||
|
||||
// 确保是32位,不足的话用0填充
|
||||
return hexString.padStart(32, '0').toUpperCase();
|
||||
},
|
||||
|
||||
// 获取设备ID(Android平台 - 唯一且不变的设备标识,32位十六进制格式)
|
||||
getDeviceId() {
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
// 获取设备信息
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
console.log('设备信息:', res);
|
||||
// 优先使用deviceId,如果没有则使用uuid或其他唯一标识
|
||||
this.deviceId = res.deviceId || res.uuid || res.system + '_' + res.model;
|
||||
console.log('APP设备ID:', this.deviceId);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('获取设备信息失败:', err);
|
||||
// 如果获取失败,使用一个临时ID
|
||||
this.deviceId = 'unknown_device';
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('获取设备ID异常:', err);
|
||||
this.deviceId = 'unknown_device';
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
// 非APP端不传设备ID
|
||||
this.deviceId = '';
|
||||
console.log('非APP端:不传设备ID');
|
||||
// #endif
|
||||
},
|
||||
|
||||
checkboxChange(){
|
||||
@@ -258,7 +336,6 @@
|
||||
// 处理登录
|
||||
async handleLogin() {
|
||||
// 检查是否同意协议
|
||||
console.log(this.agreement)
|
||||
if (!this.agreement) {
|
||||
uni.showToast({
|
||||
title: '请阅读并同意用户协议和隐私政策',
|
||||
@@ -270,21 +347,25 @@
|
||||
|
||||
if (!this.canLogin) {
|
||||
// 显示错误原因
|
||||
if (!this.isPhoneValid) {
|
||||
if (this.loginType === 'code' && !this.deviceId) {
|
||||
uni.showToast({
|
||||
title: '请输入正确的手机号',
|
||||
icon: 'none'
|
||||
});
|
||||
} else if (this.loginType === 'code' && (!this.code || this.code.length !== 6)) {
|
||||
uni.showToast({
|
||||
title: '请输入6位验证码',
|
||||
icon: 'none'
|
||||
});
|
||||
} else if (this.loginType === 'password' && (!this.password || this.password.length < 6)) {
|
||||
uni.showToast({
|
||||
title: '密码不能少于6位',
|
||||
title: '无法获取设备ID,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
// 重新获取设备ID
|
||||
this.getDeviceId();
|
||||
} else if (this.loginType === 'password') {
|
||||
if (!this.isAccountValid) {
|
||||
uni.showToast({
|
||||
title: '请输入账号',
|
||||
icon: 'none'
|
||||
});
|
||||
} else if (!this.password || this.password.length < 6) {
|
||||
uni.showToast({
|
||||
title: '密码不能少于6位',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -295,11 +376,20 @@
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用登录API
|
||||
const loginPassword = this.loginType === 'password' ? this.password : this.code;
|
||||
const response = await authApi.login(this.phone, loginPassword);
|
||||
let response;
|
||||
|
||||
if (this.loginType === 'code') {
|
||||
// 免密登录:使用设备ID调用接口
|
||||
console.log('免密登录,设备ID:', this.deviceId);
|
||||
response = await authApi.noPasswordLogin(this.deviceId);
|
||||
} else {
|
||||
// 账号登录:使用账号和密码,APP端传递设备ID,H5端不传
|
||||
console.log('账号登录,账号:', this.phone);
|
||||
console.log('账号登录,设备ID:', this.deviceId);
|
||||
response = await authApi.login(this.phone, this.password, this.deviceId);
|
||||
}
|
||||
|
||||
console.log(response);
|
||||
console.log('登录响应:', response);
|
||||
|
||||
if (response.code === 200) { // 成功code是200
|
||||
// 登录成功,缓存token信息
|
||||
@@ -371,37 +461,117 @@
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 40px; /* 为状态栏预留空间 */
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
left: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 0 30px;
|
||||
padding: 60px 30px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-type-buttons {
|
||||
width: 100%;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-type-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background-color: #4080ff;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.primary-btn.active {
|
||||
background-color: #4080ff;
|
||||
box-shadow: 0 4px 12px rgba(64, 128, 255, 0.3);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.secondary-btn.active {
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-password-tip {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@@ -462,6 +632,7 @@
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code-input-box {
|
||||
@@ -473,13 +644,15 @@
|
||||
right: 0;
|
||||
background-color: #4080ff;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-code-btn.disabled {
|
||||
background-color: #ccc;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.password-icon {
|
||||
@@ -511,14 +684,16 @@
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
background-color: #dddddd;
|
||||
color: #ffffff;
|
||||
border-radius: 22px;
|
||||
margin: 20px 0;
|
||||
border-radius: 25px;
|
||||
margin: 30px 0 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 526 KiB |
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
.container main {
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
import { uploadFile } from "@/api/common";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
|
||||
onAudioUploaded: (audioData: {
|
||||
url: string;
|
||||
name: string;
|
||||
durationMs?: number;
|
||||
}) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
maxDuration?: number; // 最大录音时长(秒)
|
||||
@@ -206,6 +210,7 @@ const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||
// 调用回调函数,传递音频URL和时长(毫秒)
|
||||
onAudioUploaded({
|
||||
url: filePath,
|
||||
name: audioFile.name,
|
||||
durationMs: recordingTime * 1000, // 将秒转换为毫秒
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useRef } from "react";
|
||||
import { message } from "antd";
|
||||
|
||||
interface SimpleFileUploadProps {
|
||||
onFileUploaded?: (filePath: string) => void;
|
||||
onFileUploaded?: (filePath: { name: string; url: string }) => void;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
type?: number; // 1: 图片, 2: 视频, 3: 音频, 4: 文件
|
||||
slot?: React.ReactNode;
|
||||
@@ -51,7 +51,10 @@ const SimpleFileUpload: React.FC<SimpleFileUploadProps> = ({
|
||||
|
||||
try {
|
||||
const fileUrl = await uploadFile(file);
|
||||
onFileUploaded?.(fileUrl);
|
||||
onFileUploaded?.({
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
});
|
||||
message.success("文件上传成功");
|
||||
} catch (error: any) {
|
||||
console.error("文件上传失败:", error);
|
||||
|
||||
75
Touchkebao/src/pages/404/index.module.scss
Normal file
75
Touchkebao/src/pages/404/index.module.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 20px 0 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
59
Touchkebao/src/pages/404/index.tsx
Normal file
59
Touchkebao/src/pages/404/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "antd-mobile";
|
||||
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const NotFound: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className={styles["not-found-container"]}>
|
||||
<div className={styles["not-found-content"]}>
|
||||
{/* 404 图标 */}
|
||||
<div className={styles["error-code"]}>404</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
<h1 className={styles["error-title"]}>页面未找到</h1>
|
||||
<p className={styles["error-description"]}>
|
||||
抱歉,您访问的页面不存在或已被删除
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={styles["action-buttons"]}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGoHome}
|
||||
className={styles["action-btn"]}
|
||||
>
|
||||
<HomeOutlined />
|
||||
<span>返回首页</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="default"
|
||||
size="large"
|
||||
onClick={handleGoBack}
|
||||
className={styles["action-btn"]}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
<span>返回上页</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -89,6 +89,7 @@ const Login: React.FC = () => {
|
||||
const loginParams = {
|
||||
...values,
|
||||
verifySessionId: verify.verifySessionId,
|
||||
typeId: 1,
|
||||
};
|
||||
|
||||
const response =
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// KPI数据类型定义
|
||||
export interface KPIData {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
trend?: {
|
||||
direction: "up" | "down";
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 话术组数据类型定义
|
||||
export interface DialogueGroupData {
|
||||
status: string;
|
||||
reachRate: number;
|
||||
replyRate: number;
|
||||
clickRate: number;
|
||||
conversionRate: number;
|
||||
avgReplyTime: string;
|
||||
pushCount: number;
|
||||
}
|
||||
|
||||
// KPI统计数据
|
||||
export const kpiData: KPIData[] = [
|
||||
{
|
||||
id: "reach-rate",
|
||||
value: "96.5%",
|
||||
label: "触达率",
|
||||
subtitle: "成功发送/计划发送",
|
||||
trend: {
|
||||
direction: "up",
|
||||
text: "+2.3% 本月",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reply-rate",
|
||||
value: "42.8%",
|
||||
label: "回复率",
|
||||
subtitle: "收到回复/成功发送",
|
||||
trend: {
|
||||
direction: "up",
|
||||
text: "+5.1% 本月",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "avg-reply-time",
|
||||
value: "18分钟",
|
||||
label: "平均回复时间",
|
||||
subtitle: "从发送到回复的平均时长",
|
||||
trend: {
|
||||
direction: "down",
|
||||
text: "-3分钟",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "link-click-rate",
|
||||
value: "28.3%",
|
||||
label: "链接点击率",
|
||||
subtitle: "点击链接/成功发送",
|
||||
trend: {
|
||||
direction: "up",
|
||||
text: "+1.8% 本月",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 话术组对比数据
|
||||
export const dialogueGroupData: DialogueGroupData[] = [
|
||||
{
|
||||
status: "优秀",
|
||||
reachRate: 98.1,
|
||||
replyRate: 48.7,
|
||||
clickRate: 32.5,
|
||||
conversionRate: 12.8,
|
||||
avgReplyTime: "15分钟",
|
||||
pushCount: 156,
|
||||
},
|
||||
{
|
||||
status: "良好",
|
||||
reachRate: 95.8,
|
||||
replyRate: 38.2,
|
||||
clickRate: 25.4,
|
||||
conversionRate: 9.2,
|
||||
avgReplyTime: "22分钟",
|
||||
pushCount: 142,
|
||||
},
|
||||
{
|
||||
status: "一般",
|
||||
reachRate: 92.3,
|
||||
replyRate: 28.5,
|
||||
clickRate: 18.7,
|
||||
conversionRate: 6.5,
|
||||
avgReplyTime: "28分钟",
|
||||
pushCount: 98,
|
||||
},
|
||||
];
|
||||
|
||||
// 时间范围选项
|
||||
export const timeRangeOptions = [
|
||||
{ label: "最近7天", value: "7days" },
|
||||
{ label: "最近30天", value: "30days" },
|
||||
{ label: "最近90天", value: "90days" },
|
||||
{ label: "自定义", value: "custom" },
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user