Merge branch 'yongpxu-dev' into yongpxu-dev2

This commit is contained in:
超级老白兔
2025-11-12 11:40:39 +08:00
142 changed files with 18879 additions and 1709 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ Store_vue/unpackage/
Store_vue/.vscode/
SuperAdmin/.specstory/
Cunkebao/dist
Touchkebao/.specstory/

View File

@@ -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
View File

@@ -0,0 +1,8 @@
/runtime
/.idea
/.vscode
/vendor
*.log
.env
/tests/tmp
/tests/.phpunit.result.cache

21
Moncter/LICENSE Normal file
View 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
View 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>

View 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']);
}
}

View 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);
}
}

View File

@@ -0,0 +1,4 @@
<?php
/**
* Here is your custom functions.
*/

View 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;
}
}

View 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;
}

View 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';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace app\process;
use Webman\App;
class Http extends App
{
}

View 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;
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

26
Moncter/config/app.php Normal file
View 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,
];

View 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',
]
];

View 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,
];

View 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;

View 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', // 认证机制(默认推荐)
],
],
],
];

View 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 [];

View 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
View 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],
],
]
],
],
];

View 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 [];

View 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
View 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
View 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
];

View 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
View 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,
],
];

View 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
View 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
View 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. 安装 PHPsudo apt install php-cliUbuntu/Debian或 sudo dnf install php-cliCentOS/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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -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;
}
}

View File

@@ -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%;
}
}
}

View File

@@ -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
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();

View 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
{
}

View 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
{
}

View 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
View File

@@ -0,0 +1,3 @@
CHCP 65001
php windows.php
pause

136
Moncter/windows.php Normal file
View 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
View 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字典/事实/审计)+ Rediscohort 人群集与位图)。
### 二点五、运行逻辑图(分层架构,数据流视角)
```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
- 集合/位图存 cohortKey 规范:`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/外呼对接;质量监控与看板。

View 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_游戏

View File

@@ -0,0 +1,249 @@
# RFM 客户价值评分体系技术实施文档
## 1. 文档目的
本文档旨在明确 RFMRecency-Frequency-Monetary客户价值评分体系的技术实现标准包括维度定义、评分规则、数据处理流程、参数配置及异常处理方案为系统开发、数据分析及业务应用提供统一依据。
## 2. 核心术语定义
| 术语 | 英文缩写 | 定义 | 数据来源 | 统计周期说明 |
| ------ | ------------ | ------------------------------- | --------- | ---------------------------------- |
| 最近消费时间 | RecencyR | 客户最后一次有效消费行为距统计截止日的时间间隔(单位:天) | 订单系统、交易日志 | 支持自定义配置(默认 3-12 个月,按业务场景调整) |
| 消费频率 | FrequencyF | 统计周期内客户发生有效消费行为的总次数 | 订单系统、交易日志 | 与 R 维度统计周期一致,剔除重复下单、取消订单等无效记录 |
| 消费金额 | MonetaryM | 统计周期内客户有效消费行为的总金额(单位:元,支持多币种换算) | 订单系统、支付日志 | 仅统计已支付完成的订单金额,剔除退款、优惠抵扣部分 |
| RFM 总分 | RFM Score | 基于 R、F、M 三个维度的分项得分,按预设权重计算的综合得分 | 系统计算生成 | 得分范围 1-15 分5 分制单项)或 1-100 分(标准化后) |
## 3. 评分规则技术规范
### 3.1 分项评分规则(默认 5 分制)
#### 3.1.1 RecencyR评分规则
* 核心逻辑:时间间隔越短,得分越高(反向映射)
* 分段标准:采用**五分位法**(按数据分布自动划分区间,避免均分失真)
| 得分 | 时间间隔区间(天) | 划分逻辑 |
| --- | ---------- | ----------------- |
| 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 FrequencyF评分规则
* 核心逻辑:消费次数越多,得分越高(正向映射)
* 分段标准:采用**五分位法**(支持最小消费次数阈值配置)
| 得分 | 消费次数区间 | 划分逻辑 |
| --- | ----------- | ------------------- |
| 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 MonetaryM评分规则
* 核心逻辑:消费金额越高,得分越高(正向映射)
* 分段标准:采用**五分位法**(支持剔除大额异常值后划分)
| 得分 | 消费金额区间(元) | 划分逻辑 |
| --- | ----------- | ------------------- |
| 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 原始指标:
* RMAX (消费时间) 到统计截止日的时间间隔(天)
* FCOUNT (DISTINCT 订单唯一标识)
* MSUM (消费金额)
1. **缺失值处理**
* 统计周期内无消费记录的客户R = 统计周期总天数F=0M=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 生成)

View File

@@ -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');
});

View File

@@ -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' => []]);
}
}

View File

@@ -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' => '提示词缺失']);
}

View File

@@ -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 = [

View File

@@ -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));
// 接收响应

View File

@@ -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,

View File

@@ -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');

View File

@@ -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()
{

View 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) {
// 静默失败,不影响主流程
}
}
}

View File

@@ -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());

View File

@@ -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']);
}

View File

@@ -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()
{

View File

@@ -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]);
}
}

View 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';
}

View 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';
}

View File

@@ -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} 已经在运行中,跳过执行");

View File

@@ -78,6 +78,14 @@ class TaskServer extends Server
});
}
// 在一个进程里处理自动问候任务
if ($current_worker_id == 1) {
// 每60秒检查一次自动问候规则
Timer::add(60, function () use ($adapter) {
$adapter->handleAutoGreetings();
});
}
// 更多其他后台任务
// ......

View File

@@ -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是否需要更新

View File

@@ -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();

View File

@@ -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',
]
]);

View 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!';
}
}

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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']);

View File

@@ -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) {

View File

@@ -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());
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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');

View 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, '登录成功');
}
}

View File

@@ -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'));

View File

@@ -0,0 +1,11 @@
<?php
namespace app\store\model;
use think\Model;
class TrafficOrderModel extends Model
{
protected $name = 'traffic_order';
}

View File

@@ -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 群ID0表示个人消息
*/
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']);
}
}
}

View File

@@ -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 = {}) => {

View File

@@ -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 || ''
}
})
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,7 +2,7 @@
"name" : "AI数智员工",
"appid" : "__UNI__9421F6C",
"description" : "",
"versionName" : "1.0.1",
"versionName" : "1.1.1",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */

View File

@@ -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"

View File

@@ -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();
},
// 获取设备IDAndroid平台 - 唯一且不变的设备标识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端传递设备IDH5端不传
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

View File

@@ -2,7 +2,6 @@
display: flex;
height: 100vh;
flex-direction: column;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.container main {

View File

@@ -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, // 将秒转换为毫秒
});

View File

@@ -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);

View 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;
}
}

View 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;

View File

@@ -89,6 +89,7 @@ const Login: React.FC = () => {
const loginParams = {
...values,
verifySessionId: verify.verifySessionId,
typeId: 1,
};
const response =

View File

@@ -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