diff --git a/.gitignore b/.gitignore index 8b8fee88..df8964e0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Store_vue/unpackage/ Store_vue/.vscode/ SuperAdmin/.specstory/ Cunkebao/dist +Touchkebao/.specstory/ diff --git a/Cunkebao/src/pages/login/Login.tsx b/Cunkebao/src/pages/login/Login.tsx index d1db021e..5fa9b1bc 100644 --- a/Cunkebao/src/pages/login/Login.tsx +++ b/Cunkebao/src/pages/login/Login.tsx @@ -63,7 +63,7 @@ const Login: React.FC = () => { // 添加typeId参数 const loginParams = { ...values, - typeId: activeTab as number, + typeId: 1, }; const response = diff --git a/Moncter/.gitignore b/Moncter/.gitignore new file mode 100644 index 00000000..516299c3 --- /dev/null +++ b/Moncter/.gitignore @@ -0,0 +1,8 @@ +/runtime +/.idea +/.vscode +/vendor +*.log +.env +/tests/tmp +/tests/.phpunit.result.cache diff --git a/Moncter/LICENSE b/Moncter/LICENSE new file mode 100644 index 00000000..2c662929 --- /dev/null +++ b/Moncter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 walkor 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. diff --git a/Moncter/README.md b/Moncter/README.md new file mode 100644 index 00000000..4031784b --- /dev/null +++ b/Moncter/README.md @@ -0,0 +1,70 @@ +
+

webman

+ +基于workerman开发的超高性能PHP框架 + + +

学习

+ + + +
+ +

赞助商

+ +

特别赞助

+ + + + +

铂金赞助

+ + + + +
+ + +
+ +

请作者喝咖啡

+ + + +
+如果您觉得webman对您有所帮助,欢迎捐赠。 + + +
+ + +
+

LICENSE

+The webman is open-sourced software licensed under the MIT. +
+ +
+ + diff --git a/Moncter/app/controller/IndexController.php b/Moncter/app/controller/IndexController.php new file mode 100644 index 00000000..7d904faa --- /dev/null +++ b/Moncter/app/controller/IndexController.php @@ -0,0 +1,24 @@ + 'webman']); + } + + public function json(Request $request) + { + return json(['code' => 0, 'msg' => 'ok']); + } + +} diff --git a/Moncter/app/controller/UserController.php b/Moncter/app/controller/UserController.php new file mode 100644 index 00000000..b23f213c --- /dev/null +++ b/Moncter/app/controller/UserController.php @@ -0,0 +1,16 @@ +get('name', $default_name); + // 向浏览器返回字符串 + return response('hello ' . $name); + } +} \ No newline at end of file diff --git a/Moncter/app/functions.php b/Moncter/app/functions.php new file mode 100644 index 00000000..5c9c58da --- /dev/null +++ b/Moncter/app/functions.php @@ -0,0 +1,4 @@ + + * @copyright walkor + * @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('

403 forbidden

', 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; + } +} diff --git a/Moncter/app/model/Test.php b/Moncter/app/model/Test.php new file mode 100644 index 00000000..92d70e38 --- /dev/null +++ b/Moncter/app/model/Test.php @@ -0,0 +1,29 @@ + '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'; +} \ No newline at end of file diff --git a/Moncter/app/process/Http.php b/Moncter/app/process/Http.php new file mode 100644 index 00000000..f462c3a4 --- /dev/null +++ b/Moncter/app/process/Http.php @@ -0,0 +1,10 @@ + + * @copyright walkor + * @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; + } + +} diff --git a/Moncter/app/view/index/view.html b/Moncter/app/view/index/view.html new file mode 100644 index 00000000..67ebb26d --- /dev/null +++ b/Moncter/app/view/index/view.html @@ -0,0 +1,14 @@ + + + + + + + + webman + + + +hello + + diff --git a/Moncter/composer.json b/Moncter/composer.json new file mode 100644 index 00000000..82eaf4f0 --- /dev/null +++ b/Moncter/composer.json @@ -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 +} diff --git a/Moncter/composer.lock b/Moncter/composer.lock new file mode 100644 index 00000000..3ece18e4 --- /dev/null +++ b/Moncter/composer.lock @@ -0,0 +1,2455 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b36fd3581fc1bf43e25a6294dd7efc58", + "packages": [ + { + "name": "brick/math", + "version": "0.14.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-08-29T12:40:03+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "illuminate/bus", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/bus.git", + "reference": "5f7cd1f99b2ff7dd0ef20aead81da1390c4bc8e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/bus/zipball/5f7cd1f99b2ff7dd0ef20aead81da1390c4bc8e3", + "reference": "5f7cd1f99b2ff7dd0ef20aead81da1390c4bc8e3", + "shasum": "" + }, + "require": { + "illuminate/collections": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/pipeline": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2" + }, + "suggest": { + "illuminate/queue": "Required to use closures when chaining jobs (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Bus\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Bus package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/cache", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/cache.git", + "reference": "f9196623f6b75f7e69b9ac92f367491909753987" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/cache/zipball/f9196623f6b75f7e69b9ac92f367491909753987", + "reference": "f9196623f6b75f7e69b9ac92f367491909753987", + "shasum": "" + }, + "require": { + "illuminate/collections": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2" + }, + "provide": { + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "suggest": { + "ext-apcu": "Required to use the APC cache driver.", + "ext-filter": "Required to use the DynamoDb cache driver.", + "ext-memcached": "Required to use the memcache cache driver.", + "illuminate/database": "Required to use the database cache driver (^11.0).", + "illuminate/filesystem": "Required to use the file cache driver (^11.0).", + "illuminate/redis": "Required to use the redis cache driver (^11.0).", + "symfony/cache": "Required to use PSR-6 cache bridge (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Cache package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-01-27T22:47:27+00:00" + }, + { + "name": "illuminate/collections", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "856b1da953e46281ba61d7c82d337072d3ee1825" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/856b1da953e46281ba61d7c82d337072d3ee1825", + "reference": "856b1da953e46281ba61d7c82d337072d3ee1825", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "php": "^8.2" + }, + "suggest": { + "symfony/var-dumper": "Required to use the dump method (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "319b717e0587bd7c8a3b44464f0e27867b4bcda9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/319b717e0587bd7c8a3b44464f0e27867b4bcda9", + "reference": "319b717e0587bd7c8a3b44464f0e27867b4bcda9", + "shasum": "" + }, + "require": { + "php": "^8.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/container", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/container.git", + "reference": "79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/container/zipball/79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a", + "reference": "79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1" + }, + "provide": { + "psr/container-implementation": "1.1|2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "4b2a67d1663f50085bc91e6371492697a5d2d4e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/4b2a67d1663f50085bc91e6371492697a5d2d4e8", + "reference": "4b2a67d1663f50085bc91e6371492697a5d2d4e8", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/database", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/database.git", + "reference": "96abcce13f405701363d916dd312835e04848d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/database/zipball/96abcce13f405701363d916dd312835e04848d04", + "reference": "96abcce13f405701363d916dd312835e04848d04", + "shasum": "" + }, + "require": { + "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12|^0.13|^0.14", + "ext-pdo": "*", + "illuminate/collections": "^11.0", + "illuminate/container": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "illuminate/support": "^11.0", + "laravel/serializable-closure": "^1.3|^2.0", + "php": "^8.2" + }, + "suggest": { + "ext-filter": "Required to use the Postgres database driver.", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.24).", + "illuminate/console": "Required to use the database commands (^11.0).", + "illuminate/events": "Required to use the observers with Eloquent (^11.0).", + "illuminate/filesystem": "Required to use the migrations (^11.0).", + "illuminate/pagination": "Required to paginate the result set (^11.0).", + "symfony/finder": "Required to use Eloquent model factories (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Database\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Database package.", + "homepage": "https://laravel.com", + "keywords": [ + "database", + "laravel", + "orm", + "sql" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-09-29T09:23:31+00:00" + }, + { + "name": "illuminate/events", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/events.git", + "reference": "b72dab66d8e05d22dc5aa949efec150bbc73e827" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/events/zipball/b72dab66d8e05d22dc5aa949efec150bbc73e827", + "reference": "b72dab66d8e05d22dc5aa949efec150bbc73e827", + "shasum": "" + }, + "require": { + "illuminate/bus": "^11.0", + "illuminate/collections": "^11.0", + "illuminate/container": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Illuminate\\Events\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Events package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", + "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2024-06-28T20:10:30+00:00" + }, + { + "name": "illuminate/pipeline", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pipeline.git", + "reference": "f73bb7cab13ac8ef91094dc46976f5e992eea127" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pipeline/zipball/f73bb7cab13ac8ef91094dc46976f5e992eea127", + "reference": "f73bb7cab13ac8ef91094dc46976f5e992eea127", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pipeline\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pipeline package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/support", + "version": "v11.46.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "716b5e258ee670cf143da883495b22595db12b90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/716b5e258ee670cf143da883495b22595db12b90", + "reference": "716b5e258ee670cf143da883495b22595db12b90", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^11.0", + "illuminate/conditionable": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "nesbot/carbon": "^2.72.6|^3.8.4", + "php": "^8.2", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^11.0).", + "laravel/serializable-closure": "Required to use the once function (^1.3|^2.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.0).", + "symfony/uid": "Required to use Str::ulid() (^7.0).", + "symfony/var-dumper": "Required to use the dd function (^7.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-08-11T14:50:36+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "038ce42edee619599a1debb7e81d7b3759492819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819", + "reference": "038ce42edee619599a1debb7e81d7b3759492819", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-10-09T13:42:30+00:00" + }, + { + "name": "mongodb/laravel-mongodb", + "version": "4.8.1", + "source": { + "type": "git", + "url": "https://github.com/mongodb/laravel-mongodb.git", + "reference": "da3a46a1b4ca25117c1d388dd6348206d04e4a9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/laravel-mongodb/zipball/da3a46a1b4ca25117c1d388dd6348206d04e4a9f", + "reference": "da3a46a1b4ca25117c1d388dd6348206d04e4a9f", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "ext-mongodb": "^1.15", + "illuminate/cache": "^10.36|^11", + "illuminate/container": "^10.0|^11", + "illuminate/database": "^10.30|^11", + "illuminate/events": "^10.0|^11", + "illuminate/support": "^10.0|^11", + "mongodb/mongodb": "^1.15", + "php": "^8.1" + }, + "conflict": { + "illuminate/bus": "< 10.37.2" + }, + "replace": { + "jenssegers/mongodb": "self.version" + }, + "require-dev": { + "doctrine/coding-standard": "12.0.x-dev", + "league/flysystem-gridfs": "^3.28", + "league/flysystem-read-only": "^3.0", + "mockery/mockery": "^1.4.4", + "mongodb/builder": "^0.2", + "orchestra/testbench": "^8.0|^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.3", + "spatie/laravel-query-builder": "^5.6" + }, + "suggest": { + "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS", + "mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "MongoDB\\Laravel\\MongoDBServiceProvider", + "MongoDB\\Laravel\\MongoDBQueueServiceProvider", + "MongoDB\\Laravel\\MongoDBBusServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "MongoDB\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com", + "role": "Leader" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com", + "role": "Maintainer" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com", + "role": "Maintainer" + }, + { + "name": "Jens Segers", + "homepage": "https://jenssegers.com", + "role": "Creator" + } + ], + "description": "A MongoDB based Eloquent model and Query builder for Laravel", + "homepage": "https://github.com/mongodb/laravel-mongodb", + "keywords": [ + "database", + "eloquent", + "laravel", + "model", + "mongo", + "mongodb" + ], + "support": { + "issues": "https://www.mongodb.com/support", + "security": "https://www.mongodb.com/security", + "source": "https://github.com/mongodb/laravel-mongodb/tree/4.8.1" + }, + "time": "2024-11-20T15:01:02+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "75da9ea3b63d97b05e0e8648d8c09a17bc54c0b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/75da9ea3b63d97b05e0e8648d8c09a17bc54c0b6", + "reference": "75da9ea3b63d97b05e0e8648d8c09a17bc54c0b6", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-hash": "*", + "ext-json": "*", + "ext-mongodb": "^1.20.0", + "php": "^7.4 || ^8.0", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php80": "^1.27", + "symfony/polyfill-php81": "^1.27" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "rector/rector": "^1.1", + "squizlabs/php_codesniffer": "^3.7", + "symfony/phpunit-bridge": "^5.2", + "vimeo/psalm": "^5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/1.20.0" + }, + "time": "2024-09-25T12:54:08+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "5cf826f2991858b54d5c3809bee745560a1042a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5cf826f2991858b54d5c3809bee745560a1042a7", + "reference": "5cf826f2991858b54d5c3809bee745560a1042a7", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2@dev", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpspec/prophecy": "^1.15", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", + "predis/predis": "^1.1 || ^2.0", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2024-11-12T12:43:37+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.3", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-09-06T13:39:36+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-07T11:39:36+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:32:26+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "workerman/coroutine", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/workerman-php/coroutine.git", + "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", + "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.4" + }, + "time": "2025-10-11T15:09:08+00:00" + }, + { + "name": "workerman/webman-framework", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/walkor/webman-framework.git", + "reference": "f803bd867f07bb0929faef060b59a19a44186bfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/walkor/webman-framework/zipball/f803bd867f07bb0929faef060b59a19a44186bfc", + "reference": "f803bd867f07bb0929faef060b59a19a44186bfc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": ">=8.1", + "psr/container": ">=1.0", + "psr/log": "^3.0", + "workerman/workerman": "^5.1 || dev-master" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "type": "library", + "autoload": { + "files": [ + "./src/support/helpers.php" + ], + "psr-4": { + "Webman\\": "./src", + "Support\\": "./src/support", + "support\\": "./src/support", + "Support\\View\\": "./src/support/view", + "Support\\Bootstrap\\": "./src/support/bootstrap", + "Support\\Exception\\": "./src/support/exception" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "description": "High performance HTTP Service Framework.", + "homepage": "https://www.workerman.net", + "keywords": [ + "High Performance", + "http service" + ], + "support": { + "email": "walkor@workerman.net", + "forum": "https://wenda.workerman.net/", + "issues": "https://github.com/walkor/webman/issues", + "source": "https://github.com/walkor/webman-framework", + "wiki": "https://doc.workerman.net/" + }, + "time": "2025-03-10T11:52:22+00:00" + }, + { + "name": "workerman/workerman", + "version": "v5.1.4", + "source": { + "type": "git", + "url": "https://github.com/walkor/workerman.git", + "reference": "ff4e17babdc92b16b3252060233c88f6c2e9a61a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/walkor/workerman/zipball/ff4e17babdc92b16b3252060233c88f6c2e9a61a", + "reference": "ff4e17babdc92b16b3252060233c88f6c2e9a61a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": "=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/Moncter/config/app.php b/Moncter/config/app.php new file mode 100644 index 00000000..f26e3584 --- /dev/null +++ b/Moncter/config/app.php @@ -0,0 +1,26 @@ + + * @copyright walkor + * @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, +]; diff --git a/Moncter/config/autoload.php b/Moncter/config/autoload.php new file mode 100644 index 00000000..69a8135e --- /dev/null +++ b/Moncter/config/autoload.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @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', + ] +]; diff --git a/Moncter/config/bootstrap.php b/Moncter/config/bootstrap.php new file mode 100644 index 00000000..95d2e87e --- /dev/null +++ b/Moncter/config/bootstrap.php @@ -0,0 +1,17 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + support\bootstrap\Session::class, +]; diff --git a/Moncter/config/container.php b/Moncter/config/container.php new file mode 100644 index 00000000..106b7b4a --- /dev/null +++ b/Moncter/config/container.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return new Webman\Container; \ No newline at end of file diff --git a/Moncter/config/database.php b/Moncter/config/database.php new file mode 100644 index 00000000..fd7cd4b5 --- /dev/null +++ b/Moncter/config/database.php @@ -0,0 +1,27 @@ + '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', // 认证机制(默认推荐) + ], + ], + ], +]; \ No newline at end of file diff --git a/Moncter/config/dependence.php b/Moncter/config/dependence.php new file mode 100644 index 00000000..8e964eda --- /dev/null +++ b/Moncter/config/dependence.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return []; \ No newline at end of file diff --git a/Moncter/config/exception.php b/Moncter/config/exception.php new file mode 100644 index 00000000..f2aede33 --- /dev/null +++ b/Moncter/config/exception.php @@ -0,0 +1,17 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + '' => support\exception\Handler::class, +]; \ No newline at end of file diff --git a/Moncter/config/log.php b/Moncter/config/log.php new file mode 100644 index 00000000..7f05de57 --- /dev/null +++ b/Moncter/config/log.php @@ -0,0 +1,32 @@ + + * @copyright walkor + * @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], + ], + ] + ], + ], +]; diff --git a/Moncter/config/middleware.php b/Moncter/config/middleware.php new file mode 100644 index 00000000..8e964eda --- /dev/null +++ b/Moncter/config/middleware.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return []; \ No newline at end of file diff --git a/Moncter/config/process.php b/Moncter/config/process.php new file mode 100644 index 00000000..892dc826 --- /dev/null +++ b/Moncter/config/process.php @@ -0,0 +1,62 @@ + + * @copyright walkor + * @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 === '/', + ] + ] + ] +]; diff --git a/Moncter/config/route.php b/Moncter/config/route.php new file mode 100644 index 00000000..a5064fca --- /dev/null +++ b/Moncter/config/route.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Route; + + + + + + diff --git a/Moncter/config/server.php b/Moncter/config/server.php new file mode 100644 index 00000000..054d01fb --- /dev/null +++ b/Moncter/config/server.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @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 +]; diff --git a/Moncter/config/session.php b/Moncter/config/session.php new file mode 100644 index 00000000..043f8c45 --- /dev/null +++ b/Moncter/config/session.php @@ -0,0 +1,65 @@ + + * @copyright walkor + * @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], + +]; diff --git a/Moncter/config/static.php b/Moncter/config/static.php new file mode 100644 index 00000000..63136796 --- /dev/null +++ b/Moncter/config/static.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @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, + ], +]; \ No newline at end of file diff --git a/Moncter/config/translation.php b/Moncter/config/translation.php new file mode 100644 index 00000000..96589b2b --- /dev/null +++ b/Moncter/config/translation.php @@ -0,0 +1,25 @@ + + * @copyright walkor + * @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', +]; \ No newline at end of file diff --git a/Moncter/config/view.php b/Moncter/config/view.php new file mode 100644 index 00000000..e3a7b856 --- /dev/null +++ b/Moncter/config/view.php @@ -0,0 +1,22 @@ + + * @copyright walkor + * @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 +]; diff --git a/Moncter/go.sh b/Moncter/go.sh new file mode 100755 index 00000000..6b14ac80 --- /dev/null +++ b/Moncter/go.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail # 严格模式:报错立即退出、禁止未定义变量、管道错误触发退出 + +# ================= 配置项(可根据实际情况修改)================= +# PHP 脚本路径(相对路径/绝对路径均可,推荐绝对路径更稳定) +PHP_SCRIPT="start.php" +# PHP 解释器路径(默认自动查找,若提示 php 未找到,手动指定如 /usr/bin/php) +PHP_BIN=$(which php || echo "/usr/bin/php") +# ============================================================== + +# 1. 检查 PHP 解释器是否存在且可执行 +if [ ! -x "$PHP_BIN" ]; then + echo -e "\033[31m错误:未找到可执行的 PHP 解释器!\033[0m" + echo " 解决方案:" + echo " 1. 安装 PHP:sudo apt install php-cli(Ubuntu/Debian)或 sudo dnf install php-cli(CentOS/RHEL)" + echo " 2. 若已安装,手动修改脚本中的 PHP_BIN 为实际路径(通过 which php 查询)" + exit 1 +fi + +# 2. 检查 PHP 脚本是否存在 +if [ ! -f "$PHP_SCRIPT" ]; then + echo -e "\033[31m错误:未找到脚本文件 $PHP_SCRIPT!\033[0m" + echo " 请确保脚本与 $PHP_SCRIPT 在同一目录,或修改脚本中的 PHP_SCRIPT 为绝对路径" + exit 1 +fi + +# 3. 给 PHP 脚本添加执行权限(自动修复权限问题) +if [ ! -x "$PHP_SCRIPT" ]; then + echo -e "\033[33m警告:$PHP_SCRIPT 缺少执行权限,正在自动添加...\033[0m" + chmod u+x "$PHP_SCRIPT" || { + echo -e "\033[31m错误:添加执行权限失败,请用 sudo 运行脚本!\033[0m" + exit 1 + } +fi + +# 4. 执行核心命令(带日志输出优化) +echo -e "\033[32m=== 开始执行:$PHP_BIN $PHP_SCRIPT start ===\033[0m" +$PHP_BIN "$PHP_SCRIPT" start + +# 5. 执行结果判断 +if [ $? -eq 0 ]; then + echo -e "\033[32m=== 执行成功!===\033[0m" +else + echo -e "\033[31m=== 执行失败!请查看上方错误信息 ===\033[0m" + exit 1 +fi \ No newline at end of file diff --git a/Moncter/public/favicon.ico b/Moncter/public/favicon.ico new file mode 100644 index 00000000..b9f722e0 Binary files /dev/null and b/Moncter/public/favicon.ico differ diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss new file mode 100644 index 00000000..b8085ce9 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss @@ -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; + } +} diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss new file mode 100644 index 00000000..fee50c0a --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -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%; + } + } +} + diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx new file mode 100644 index 00000000..082831d5 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -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 { diff --git a/Moncter/start.php b/Moncter/start.php new file mode 100755 index 00000000..41ad7ef2 --- /dev/null +++ b/Moncter/start.php @@ -0,0 +1,5 @@ +#!/usr/bin/env php + + * @copyright walkor + * @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 +{ + +} \ No newline at end of file diff --git a/Moncter/support/Response.php b/Moncter/support/Response.php new file mode 100644 index 00000000..9bc4e1eb --- /dev/null +++ b/Moncter/support/Response.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @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 +{ + +} \ No newline at end of file diff --git a/Moncter/support/bootstrap.php b/Moncter/support/bootstrap.php new file mode 100644 index 00000000..d913defd --- /dev/null +++ b/Moncter/support/bootstrap.php @@ -0,0 +1,139 @@ + + * @copyright walkor + * @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); + diff --git a/Moncter/windows.bat b/Moncter/windows.bat new file mode 100644 index 00000000..f07ce532 --- /dev/null +++ b/Moncter/windows.bat @@ -0,0 +1,3 @@ +CHCP 65001 +php windows.php +pause \ No newline at end of file diff --git a/Moncter/windows.php b/Moncter/windows.php new file mode 100644 index 00000000..f37a72c9 --- /dev/null +++ b/Moncter/windows.php @@ -0,0 +1,136 @@ +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 = << 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); + } +} diff --git a/Moncter/技术方案.md b/Moncter/技术方案.md new file mode 100644 index 00000000..25ea2751 --- /dev/null +++ b/Moncter/技术方案.md @@ -0,0 +1,348 @@ +## 用户标签引擎技术方案(以身份证为主键) + +### 一、目标与范围 +- 构建可扩展的用户标签引擎,统一以身份证为主键的人(person)进行画像与筛选。 +- 支持多数据源接入(交易、行为、社群、外呼等),并聚合多个手机号、多个微信号到同一人。 +- 提供规则驱动的人群筛选、客群快照和线索分发能力,服务销售精细化运营。 + +### 0. 需求整合清单(共识) +- 数据接入:多数据源/多数据库连接,按“数据源-表/接口”粒度定义 Job;增量水位、批量与重试。 +- 标识治理:支持弱标识(手机号等)建“临时人”,获取强标识(身份证/unionid/客户号)后合并;全链路幂等与审计。 +- 标签体系:分通道层与人层;`tag_dict` 定义口径/类型/窗口/聚合/版本;标签写入包含 window/source/version。 +- 聚合计算:通道→人层遵循 `aggregation`(sum/max/avg/any/best_of 等),支持实时触发与离线批处理。 +- 规则与人群:DSL 配置、试算/执行、审计;Redis 维护 cohort/位图,支持快照与导出。 +- 回灌与重算:规则或口径变更可对存量回评估;任务状态、错误与执行明细可观测、可重试。 +- 安全合规:身份证只存哈希(加盐),最小化数据使用,接口鉴权与导出留痕。 + +### 二、总体架构 +- 核心理念:人层(person)是唯一真相;通道层(channel)承载具体手机号/微信号等标识。 +- 组件分层: + - 数据接入层:标准化事件/明细,写入通道层标签。 + - 聚合计算层:将通道层指标按口径聚合到人层标签。 + - 规则/人群层:基于人层标签做筛选、快照、导出与分发。 + - 存储与缓存:MySQL(字典/事实/审计)+ Redis(cohort 人群集与位图)。 + +### 二点五、运行逻辑图(分层架构,数据流视角) +```mermaid +╔═════════════════════════════════════════════════════════════════════════╗ +║ 数据源层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + │交易系统│ │APP行为│ │客服系统│ │CRM │ + └─────┘ └─────┘ └─────┘ └─────┘ + │ │ │ │ + └───────┴─────────┘ + ▼ +╔═════════════════════════════════════════════════════════════════════════╗ +║ 接入层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌───────────────────────────────────────┐ + │ Job调度器 增量水位 │ + └───────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────┐ + │ 标准化 校验 │ + └───────────────────────────────────────┘ + ▼ +╔═════════════════════════════════════════════════════════════════════════╗ +║ 身份层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌───────────────────────────────────────┐ + │ IdentifierService │ + │ 手机号→person_id │ + └───────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────┐ + │ 临时人建表 强标识合并 │ + └───────────────────────────────────────┘ + ▼ +╔═════════════════════════════════════════════════════════════════════════╗ +║ 标签层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌───────────────────────────────────────┐ + │ channel_tags │ + │ 通道标签存储 │ + └───────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────┐ + │ Aggregator │ + │ 聚合计算 │ + └───────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────┐ + │ person_tags │ + │ 人层标签存储 │ + └───────────────────────────────────────┘ + ▼ +╔═════════════════════════════════════════════════════════════════════════╗ +║ 规则层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌───────────────────────────────────────┐ + │ RuleEngine │ + │ DSL执行 │ + └───────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────┐ + │ Redis Cohort │ + │ 人群集合 │ + └───────────────────────────────────────┘ + ▼ +╔═════════════════════════════════════════════════════════════════════════╗ +║ 应用层 ║ +╚═════════════════════════════════════════════════════════════════════════╝ + ┌─────┐ ┌─────┐ ┌─────┐ + │人群查询│ │快照导出│ │分发推送│ + └─────┘ └─────┘ └─────┘ + +``` + +### 二点六、运行逻辑图(时序视角) +```mermaid +sequenceDiagram + participant 业务系统 as 业务系统
(交易/APP/客服) + participant 接入服务 as 数据接入服务 + participant 身份服务 as 身份解析服务 + participant 通道标签 as channel_tags
(存储) + participant 聚合服务 as 聚合计算服务 + participant 人层标签 as person_tags
(存储) + participant 规则引擎 as 规则引擎 + participant 人群缓存 as Redis Cohort + + 业务系统->>接入服务: 1. 推送事件/批量数据 + 接入服务->>接入服务: 2. 标准化、校验、去重 + 接入服务->>身份服务: 3. 解析标识
(手机号/微信→person_id) + 身份服务-->>接入服务: 返回person_id
(不存在则建临时人) + 接入服务->>通道标签: 4. 写入通道标签
(幂等、window/source/version) + + 通道标签->>聚合服务: 5. 触发聚合事件
(实时/批量) + 聚合服务->>人层标签: 6. 按口径聚合
(sum/max/avg/any) + + 人层标签->>规则引擎: 7. 标签变更触发
(受影响person_id) + 规则引擎->>规则引擎: 8. 执行DSL规则 + 规则引擎->>人群缓存: 9. 更新cohort
(SADD/SINTER) + + 人群缓存-->>业务系统: 10. 人群查询/导出 +``` + +### 二点七、运行逻辑图(简化版,核心路径) +```mermaid +graph TB + Start([数据源
交易/行为/客服/CRM]) --> Ingest[数据接入
Job调度 + 标准化] + Ingest --> Identity[身份解析
手机号→person_id] + Identity --> Channel[通道标签
channel_tags] + Channel --> Aggregate[聚合计算
通道→人层] + Aggregate --> Person[人层标签
person_tags] + Person --> Rule[规则引擎
DSL筛选] + Rule --> Cohort[人群集合
Redis Cohort] + Cohort --> Export([应用输出
查询/快照/分发]) + + Ingest -.->|状态| Audit1[(ingest_state)] + Ingest -.->|错误| Audit2[(ingest_errors)] + Rule -.->|执行记录| Audit3[(rule_executions)] + + style Start fill:#e1f5ff + style Export fill:#e1f5ff + style Ingest fill:#fff4e1 + style Identity fill:#e8f5e9 + style Channel fill:#f3e5f5 + style Aggregate fill:#f3e5f5 + style Person fill:#f3e5f5 + style Rule fill:#fff9c4 + style Cohort fill:#fff9c4 +``` + +### 三、数据模型 +```sql +-- 人:身份证(脱敏哈希)为主键 +CREATE TABLE person ( + person_id CHAR(32) PRIMARY KEY, -- md5(uppercase(id_card_no_without_spaces)) + id_card_hash CHAR(32) UNIQUE, + name VARCHAR(64) NULL, + gender TINYINT NULL, + birthday DATE NULL, + created_at DATETIME, + updated_at DATETIME +); + +-- 标识绑定:一个人可有多个手机号/微信/外部ID +CREATE TABLE person_identifier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + person_id CHAR(32), + id_type ENUM('phone','wechat','external','email') NOT NULL, + id_value VARCHAR(128) NOT NULL, + is_primary TINYINT DEFAULT 0, + verified TINYINT DEFAULT 0, + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY uk_type_value (id_type, id_value), + KEY idx_person (person_id) +); + +-- 标签字典 +CREATE TABLE tag_dict ( + tag_code VARCHAR(128) PRIMARY KEY, -- 例:person.trade.arpu_90d + name VARCHAR(128), + category VARCHAR(64), + level ENUM('person','channel') NOT NULL, + type ENUM('int','bool','enum','set','string','float') NOT NULL, + enum_values JSON NULL, + unit VARCHAR(16) NULL, + aggregation ENUM('sum','max','min','avg','any','best_of') NULL, -- 通道→人层口径 + description TEXT, + version INT DEFAULT 1, + status ENUM('draft','active','deprecated') DEFAULT 'active', + owner VARCHAR(64), + created_at DATETIME, + updated_at DATETIME +); + +-- 人层标签(销售筛选用) +CREATE TABLE person_tags ( + person_id CHAR(32), + tag_code VARCHAR(128), + tag_value VARCHAR(256), + confidence TINYINT DEFAULT 100, + source VARCHAR(64), + window VARCHAR(32), + version INT, + updated_at DATETIME, + PRIMARY KEY (person_id, tag_code), + KEY idx_tag (tag_code, tag_value) +); + +-- 通道层标签(手机号/微信号维度) +CREATE TABLE channel_tags ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + id_type ENUM('phone','wechat','external') NOT NULL, + id_value VARCHAR(128) NOT NULL, + tag_code VARCHAR(128), + tag_value VARCHAR(256), + source VARCHAR(64), + window VARCHAR(32), + updated_at DATETIME, + UNIQUE KEY uk_dim (id_type, id_value, tag_code), + KEY idx_tag (tag_code, tag_value) +); + +-- 规则配置与执行审计 +CREATE TABLE tag_rules ( + rule_id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128), + dsl JSON, + status ENUM('draft','active','paused') DEFAULT 'active', + schedule VARCHAR(64) NULL, + output_tag VARCHAR(128) NULL, + owner VARCHAR(64), + created_at DATETIME, + updated_at DATETIME +); + +CREATE TABLE rule_executions ( + exec_id BIGINT PRIMARY KEY AUTO_INCREMENT, + rule_id BIGINT, + started_at DATETIME, + finished_at DATETIME, + affected_users INT, + status ENUM('success','failed','partial'), + message TEXT +); +``` + +### 四、标签规范 +- 分类: + - 基础画像、行为活跃、交易能力、风险合规、社交关系、生命周期、设备画像、地域属性。 +- 命名:`{level}.{category}.{name}_{window}` + - 人层:`person.trade.arpu_90d`,`person.behavior.active_days_30d` + - 通道层:`channel.wechat.group_join_30d`,`channel.phone.outbound_connect_7d` +- 口径:在 `tag_dict` 中声明 `type/enum/range/aggregation/window/update_freq/version`。 +- 聚合: + - 风险类:any(任一通道命中→人层命中)。 + - 活跃/价值:max/sum/avg 视业务定义。 + - 可达性:best_of(如选近30日响应率最高的手机号)。 + +### 五、规则与人群(DSL) +```json +{ + "name": "高潜付费人群", + "logic": "AND", + "conditions": [ + {"tag": "person.trade.arpu_90d", "op": "gte", "value": 500}, + {"tag": "person.behavior.active_days_30d", "op": "gte", "value": 10}, + {"tag": "person.risk.blacklist", "op": "eq", "value": false} + ], + "window": "rolling_90d", + "ttl": "24h" +} +``` + +### 六、计算与刷新策略 +- 批处理(T+1/T+0小时):生成稳定画像(交易统计、生命周期等)。 +- 准实时(秒级/分级):事件驱动更新通道层标签,触发对应人层增量聚合。 +- 回评估:字典/规则变更后对存量人群重算,并写入审计。 + +### 七、系统设计(ThinkPHP 5.1) +- 目录结构(`application/tag`): + - controller:`TagDictController`、`RuleController`、`SegmentController`、`IdentifierController` + - model:`TagDict`、`Person`、`PersonIdentifier`、`PersonTags`、`ChannelTags`、`TagRules`、`RuleExecutions` + - service: + - `IdentifierService`(标识绑定/查找/合并) + - `ChannelTagService`(通道标签写入) + - `PersonTagService`(人层聚合/重算) + - `TagQueryService`(条件解析→Redis/DB组合查询) + - `RuleEngineService`(DSL 校验/执行/审计) + - `CohortCacheService`(cohort 维护、集合/位图运算) + - command:`ExecuteRule`、`RecomputeTags`、`CohortSnapshot` + +- 路由(建议,受 `jwt` 保护): +```php +Route::group('v1/tag', function () { + Route::get('dict', 'app\\tag\\controller\\TagDictController@index'); + Route::post('dict', 'app\\tag\\controller\\TagDictController@create'); + Route::post('identifier/bind', 'app\\tag\\controller\\IdentifierController@bind'); + Route::post('rule/execute/:id', 'app\\tag\\controller\\RuleController@execute'); + Route::post('segment/query', 'app\\tag\\controller\\SegmentController@query'); + Route::post('segment/snapshot', 'app\\tag\\controller\\SegmentController@snapshot'); +})->middleware(['jwt']); +``` + +### 八、查询与筛选 +- 人层为主:`person_tags` 组合条件查询,Redis 保存常用 cohort: + - Redis 示例:`SINTER cohort:person.trade.arpu_90d:gt500 cohort:person.active_days_30d:gte10 SDIFF cohort:person.risk.blacklist:eq1` +- MySQL 组合查询示例: +```sql +SELECT DISTINCT t1.person_id +FROM person_tags t1 +JOIN person_tags t2 ON t2.person_id=t1.person_id +LEFT JOIN person_tags t3 ON t3.person_id=t1.person_id AND t3.tag_code='person.risk.blacklist' +WHERE t1.tag_code='person.trade.arpu_90d' AND CAST(t1.tag_value AS DECIMAL)>=500 + AND t2.tag_code='person.behavior.active_days_30d' AND CAST(t2.tag_value AS SIGNED)>=10 + AND (t3.tag_value IS NULL OR t3.tag_value='0') +LIMIT 50 OFFSET 0; +``` + +### 九、关键流程 +1) 事件接入:收到 `id_type + id_value`(如手机号)→ 查 `person_identifier` → 得到 `person_id`。 +2) 通道标签更新:写 `channel_tags`,并发布“聚合任务”。 +3) 人层聚合:按 `tag_dict.aggregation` 规则,更新 `person_tags`。 +4) 规则评估:对受影响的 `person_id` 运行启用中的规则,更新 cohort/输出标签。 +5) 人群产出:支持分页查询、生成快照、导出或推送 CRM/外呼系统。 + +### 十、缓存与索引 +- Redis: + - 集合/位图存 cohort,Key 规范:`cohort:{tag_code}:{op}{value}` 或区间桶。 + - TTL:默认 24h,可按规则 `ttl` 覆盖。 +- MySQL: + - `person_tags(tag_code, tag_value)`、`channel_tags(tag_code, tag_value)` 倒排索引。 + - 审计表按时间分区或冷热分离。 + +### 十一、合规与安全 +- 身份证只存哈希(不可逆),不落明文;导出脱敏。 +- 最小权限访问,接口留痕审计(规则执行、导出、查看)。 +- 口径透明:标签保留来源、窗口、置信度、版本。 + +### 十二、里程碑(落地计划) +- M1:建表与服务骨架;接入交易/行为两类数据;产出10个核心标签。 +- M2:规则试算与快照;Redis cohort;首批销售客群模板(高价值流失预警)。 +- M3:通道“最佳触达”策略;CRM/外呼对接;质量监控与看板。 + + diff --git a/Moncter/数据库列表.md b/Moncter/数据库列表.md new file mode 100644 index 00000000..2f1bc309 --- /dev/null +++ b/Moncter/数据库列表.md @@ -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_游戏 \ No newline at end of file diff --git a/Server/RFM客户价值评分体系技术实施文档.md b/Server/RFM客户价值评分体系技术实施文档.md new file mode 100644 index 00000000..4b5fb84d --- /dev/null +++ b/Server/RFM客户价值评分体系技术实施文档.md @@ -0,0 +1,249 @@ +# RFM 客户价值评分体系技术实施文档 + +## 1. 文档目的 + +本文档旨在明确 RFM(Recency-Frequency-Monetary)客户价值评分体系的技术实现标准,包括维度定义、评分规则、数据处理流程、参数配置及异常处理方案,为系统开发、数据分析及业务应用提供统一依据。 + +## 2. 核心术语定义 + + + +| 术语 | 英文缩写 | 定义 | 数据来源 | 统计周期说明 | +| ------ | ------------ | ------------------------------- | --------- | ---------------------------------- | +| 最近消费时间 | Recency(R) | 客户最后一次有效消费行为距统计截止日的时间间隔(单位:天) | 订单系统、交易日志 | 支持自定义配置(默认 3-12 个月,按业务场景调整) | +| 消费频率 | Frequency(F) | 统计周期内客户发生有效消费行为的总次数 | 订单系统、交易日志 | 与 R 维度统计周期一致,剔除重复下单、取消订单等无效记录 | +| 消费金额 | Monetary(M) | 统计周期内客户有效消费行为的总金额(单位:元,支持多币种换算) | 订单系统、支付日志 | 仅统计已支付完成的订单金额,剔除退款、优惠抵扣部分 | +| RFM 总分 | RFM Score | 基于 R、F、M 三个维度的分项得分,按预设权重计算的综合得分 | 系统计算生成 | 得分范围 1-15 分(5 分制单项)或 1-100 分(标准化后) | + +## 3. 评分规则技术规范 + +### 3.1 分项评分规则(默认 5 分制) + +#### 3.1.1 Recency(R)评分规则 + + + +* 核心逻辑:时间间隔越短,得分越高(反向映射) + +* 分段标准:采用**五分位法**(按数据分布自动划分区间,避免均分失真) + + + +| 得分 | 时间间隔区间(天) | 划分逻辑 | +| --- | ---------- | ----------------- | +| 5 分 | \[0, T1] | 统计周期内最近消费的 20% 客户 | +| 4 分 | (T1, T2] | 统计周期内次近消费的 20% 客户 | +| 3 分 | (T2, T3] | 统计周期内中间消费的 20% 客户 | +| 2 分 | (T3, T4] | 统计周期内较久消费的 20% 客户 | +| 1 分 | (T4, Tmax] | 统计周期内最久消费的 20% 客户 | + + + +* 区间计算方式:T1=PERCENTILE\_CONT (0.2)、T2=PERCENTILE\_CONT (0.4)、T3=PERCENTILE\_CONT (0.6)、T4=PERCENTILE\_CONT (0.8),其中 Tmax 为统计周期总天数 + +#### 3.1.2 Frequency(F)评分规则 + + + +* 核心逻辑:消费次数越多,得分越高(正向映射) + +* 分段标准:采用**五分位法**(支持最小消费次数阈值配置) + + + +| 得分 | 消费次数区间 | 划分逻辑 | +| --- | ----------- | ------------------- | +| 5 分 | \[F4, +∞) | 统计周期内消费次数最多的 20% 客户 | +| 4 分 | \[F3, F4) | 统计周期内消费次数次多的 20% 客户 | +| 3 分 | \[F2, F3) | 统计周期内消费次数中间的 20% 客户 | +| 2 分 | \[F1, F2) | 统计周期内消费次数较少的 20% 客户 | +| 1 分 | \[Fmin, F1) | 统计周期内消费次数最少的 20% 客户 | + + + +* 区间计算方式:F1=PERCENTILE\_CONT (0.2)、F2=PERCENTILE\_CONT (0.4)、F3=PERCENTILE\_CONT (0.6)、F4=PERCENTILE\_CONT (0.8),其中 Fmin 为 1(仅统计有效消费次数≥1 的客户) + +#### 3.1.3 Monetary(M)评分规则 + + + +* 核心逻辑:消费金额越高,得分越高(正向映射) + +* 分段标准:采用**五分位法**(支持剔除大额异常值后划分) + + + +| 得分 | 消费金额区间(元) | 划分逻辑 | +| --- | ----------- | ------------------- | +| 5 分 | \[M4, +∞) | 统计周期内消费金额最高的 20% 客户 | +| 4 分 | \[M3, M4) | 统计周期内消费金额次高的 20% 客户 | +| 3 分 | \[M2, M3) | 统计周期内消费金额中间的 20% 客户 | +| 2 分 | \[M1, M2) | 统计周期内消费金额较低的 20% 客户 | +| 1 分 | \[Mmin, M1) | 统计周期内消费金额最低的 20% 客户 | + + + +* 区间计算方式:M1=PERCENTILE\_CONT (0.2)、M2=PERCENTILE\_CONT (0.4)、M3=PERCENTILE\_CONT (0.6)、M4=PERCENTILE\_CONT (0.8),其中 Mmin 为统计周期内最小有效订单金额 + +### 3.2 总分计算规则 + +#### 3.2.1 加权求和公式 + +$RFM_{Score} = R_{Score} \times W_R + F_{Score} \times W_F + M_{Score} \times W_M$ + + + +* 权重配置:支持自定义(默认配置:$W_R=0.4$,$W_F=0.3$,$W_M=0.3$) + +* 权重约束:$W_R + W_F + W_M = 1.0$,且单个权重取值范围为 \[0.1, 0.8] + +#### 3.2.2 得分标准化(可选) + + + +* 若需将总分映射为 1-100 分,采用线性标准化公式: + + $RFM_{StandardScore} = \frac{RFM_{Score} - RFM_{Min}}{RFM_{Max} - RFM_{Min}} \times 99 + 1$ + +* 其中:$RFM_{Min}=W_R \times 1 + W_F \times 1 + W_M \times 1$,$RFM_{Max}=W_R \times 5 + W_F \times 5 + W_M \times 5$ + +## 4. 数据处理流程 + +### 4.1 数据输入要求 + + + +| 数据项 | 数据类型 | 格式要求 | 校验规则 | +| ------ | ------------- | ------------------- | ------------ | +| 客户唯一标识 | String/Int | 全局唯一(如用户 ID、会员 ID) | 非空、去重 | +| 订单唯一标识 | String/Int | 全局唯一(如订单号) | 非空、去重 | +| 消费时间 | DateTime | yyyy-MM-dd HH:mm:ss | 需在统计周期内 | +| 消费金额 | Decimal(18,2) | 大于 0 | 剔除负数、0 值 | +| 订单状态 | String | 枚举值(已支付、已取消、已退款等) | 仅保留 “已支付” 状态 | + +### 4.2 数据预处理步骤 + + + +1. **数据过滤**: + +* 剔除统计周期外的订单数据 + +* 剔除订单状态为 “已取消”“已退款”“无效” 的记录 + +* 剔除员工内部订单、测试订单(按订单标签或用户标签过滤) + +* 剔除单笔金额超过$M_{99分位值} \times 3$的异常大额订单(可配置开关) + +1. **数据聚合**: + +* 按客户唯一标识分组,计算 R、F、M 原始指标: + + + * R:MAX (消费时间) 到统计截止日的时间间隔(天) + + * F:COUNT (DISTINCT 订单唯一标识) + + * M:SUM (消费金额) + +1. **缺失值处理**: + +* 统计周期内无消费记录的客户:R = 统计周期总天数,F=0,M=0,分项得分均为 1 分 + +* 单个指标缺失(如仅缺失 M):按 1 分计分项得分 + +### 4.3 评分计算流程 + + + +```mermaid +graph TD + A[数据输入] --> B[数据过滤] + B --> C[数据聚合计算R/F/M原始值] + C --> D[缺失值处理] + D --> E[按五分位法划分各维度区间] + E --> F[计算R/F/M分项得分] + F --> G[按权重计算RFM总分] + G --> H[可选:标准化为1-100分] + H --> I[输出客户RFM评分结果] +``` + +## 5. 参数配置说明 + + + +| 参数名称 | 配置项 | 取值范围 | 默认值 | 配置方式 | +| ------- | ---------------------- | ------------- | ------ | --------------- | +| 统计周期 | cycle\_days | 30-365 | 180 | 系统配置页手动输入 | +| R 维度权重 | weight\_R | 0.1-0.8 | 0.4 | 系统配置页滑动条调整 | +| F 维度权重 | weight\_F | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 | +| M 维度权重 | weight\_M | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 | +| 异常金额阈值 | abnormal\_money\_ratio | 1.5-5.0 | 3.0 | 系统配置页手动输入(倍数关系) | +| 评分分制 | score\_scale | 5 分制 / 100 分制 | 5 分制 | 系统配置页单选 | +| 缺失值处理策略 | missing\_strategy | 按 1 分计 / 剔除客户 | 按 1 分计 | 系统配置页单选 | + +## 6. 异常处理方案 + +### 6.1 数据异常 + + + +| 异常类型 | 表现形式 | 处理逻辑 | 影响范围 | +| ------ | ------------------------------ | ---------------------- | --------------- | +| 重复订单 | 同一客户同一时间相同订单号 | 去重保留 1 条有效记录 | 不影响 F、M 计算 | +| 大额异常订单 | 单笔金额 > $M_{99分位值} \times 异常阈值$ | 自动标记,可选择剔除或保留 | 仅影响 M 维度区间划分 | +| 消费时间异常 | 消费时间晚于统计截止日 | 视为无效数据,剔除 | 不影响最终结果 | +| 客户标识重复 | 同一客户多个唯一标识 | 按客户合并规则(如手机号、身份证号关联)聚合 | 需提前完成客户统一 ID 映射 | + +### 6.2 计算异常 + + + +| 异常类型 | 触发条件 | 处理逻辑 | 输出结果 | +| -------- | --------------------- | --------------------------------------- | --------------- | +| 维度区间为空 | 某维度所有客户数据相同(如 F 均为 1) | 强制均分 5 个区间 | 分项得分按 1-5 分依次分配 | +| 权重总和不为 1 | 配置权重时计算错误 | 系统自动归一化处理($W'_X = W_X / (W_R+W_F+W_M)$) | 不影响总分有效性 | +| 统计周期过短 | 小于 30 天导致数据量不足 | 系统给出警告,允许强制执行 | 区间划分可能失真,建议延长周期 | + +## 7. 输出结果格式 + +### 7.1 单客户评分结果 + + + +| 字段名 | 数据类型 | 示例 | +| --------- | ------------- | ----------------------- | +| 客户 ID | String | CUST2023001 | +| R 原始值(天) | Int | 15 | +| R 得分 | Int | 5 | +| F 原始值(次) | Int | 8 | +| F 得分 | Int | 4 | +| M 原始值(元) | Decimal(18,2) | 2560.00 | +| M 得分 | Int | 5 | +| RFM 总分 | Decimal(5,2) | 4.70 | +| 标准化得分(可选) | Int | 94 | +| 统计周期 | String | 2023-01-01 至 2023-06-30 | +| 计算时间 | DateTime | 2023-07-01 00:30:25 | + +### 7.2 批量输出文件格式 + + + +* 支持 CSV、Parquet、JSON 格式导出 + +* 编码格式:UTF-8 + +* 压缩方式:默认 GZIP(可配置关闭) + +## 8. 业务适配建议 + + + +| 业务场景 | 统计周期建议 | 权重调整建议 | 特殊配置 | +| --------------- | ------- | --------------------------- | ----------------- | +| 快消零售 | 3-6 个月 | $W_R=0.5, W_F=0.3, W_M=0.2$ | 提高 R 维度权重,关注复购及时性 | +| 高客单价行业(如奢侈品、家居) | 12 个月 | $W_R=0.3, W_F=0.2, W_M=0.5$ | 提高 M 维度权重,关注消费能力 | +| 新品推广期 | 1-3 个月 | $W_R=0.6, W_F=0.2, W_M=0.2$ | 重点关注近期新客户 | +| 会员体系运营 | 6-12 个月 | $W_R=0.4, W_F=0.4, W_M=0.2$ | 提高 F 维度权重,鼓励高频消费 | + +> (注:文档部分内容可能由 AI 生成) \ No newline at end of file diff --git a/Server/application/ai/config/route.php b/Server/application/ai/config/route.php index 4fd9f4f6..704b7d09 100644 --- a/Server/application/ai/config/route.php +++ b/Server/application/ai/config/route.php @@ -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'); }); diff --git a/Server/application/ai/controller/CozeAI.php b/Server/application/ai/controller/CozeAI.php index 9d9bf150..5208326f 100644 --- a/Server/application/ai/controller/CozeAI.php +++ b/Server/application/ai/controller/CozeAI.php @@ -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' => []]); } } \ No newline at end of file diff --git a/Server/application/ai/controller/DouBaoAI.php b/Server/application/ai/controller/DouBaoAI.php index e7a50646..5cb71a3b 100644 --- a/Server/application/ai/controller/DouBaoAI.php +++ b/Server/application/ai/controller/DouBaoAI.php @@ -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' => '提示词缺失']); } diff --git a/Server/application/ai/controller/OpenAi.php b/Server/application/ai/controller/OpenAi.php index b71491f6..ac0ff245 100644 --- a/Server/application/ai/controller/OpenAi.php +++ b/Server/application/ai/controller/OpenAi.php @@ -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 = [ diff --git a/Server/application/api/controller/WebSocketController.php b/Server/application/api/controller/WebSocketController.php index 63a93ca1..773cbb3d 100644 --- a/Server/application/api/controller/WebSocketController.php +++ b/Server/application/api/controller/WebSocketController.php @@ -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)); // 接收响应 diff --git a/Server/application/api/controller/WebSocketControllerCopy.php b/Server/application/api/controller/WebSocketControllerCopy.php index 33bf77f3..12c516bd 100644 --- a/Server/application/api/controller/WebSocketControllerCopy.php +++ b/Server/application/api/controller/WebSocketControllerCopy.php @@ -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, diff --git a/Server/application/chukebao/config/route.php b/Server/application/chukebao/config/route.php index 9dd32c2c..353f4877 100644 --- a/Server/application/chukebao/config/route.php +++ b/Server/application/chukebao/config/route.php @@ -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'); diff --git a/Server/application/chukebao/controller/AiChatController.php b/Server/application/chukebao/controller/AiChatController.php index 919204f0..4090d578 100644 --- a/Server/application/chukebao/controller/AiChatController.php +++ b/Server/application/chukebao/controller/AiChatController.php @@ -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() { diff --git a/Server/application/chukebao/controller/AiPushController.php b/Server/application/chukebao/controller/AiPushController.php new file mode 100644 index 00000000..b1cecbd6 --- /dev/null +++ b/Server/application/chukebao/controller/AiPushController.php @@ -0,0 +1,505 @@ +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) { + // 静默失败,不影响主流程 + } + } +} + diff --git a/Server/application/chukebao/controller/AutoGreetingsController.php b/Server/application/chukebao/controller/AutoGreetingsController.php index 88b66754..ef186b3c 100644 --- a/Server/application/chukebao/controller/AutoGreetingsController.php +++ b/Server/application/chukebao/controller/AutoGreetingsController.php @@ -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()); diff --git a/Server/application/chukebao/controller/MessageController.php b/Server/application/chukebao/controller/MessageController.php index 5593eafb..5a6402c2 100644 --- a/Server/application/chukebao/controller/MessageController.php +++ b/Server/application/chukebao/controller/MessageController.php @@ -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']); } diff --git a/Server/application/chukebao/controller/WechatChatroomController.php b/Server/application/chukebao/controller/WechatChatroomController.php index 76edb7e0..bd07bb31 100644 --- a/Server/application/chukebao/controller/WechatChatroomController.php +++ b/Server/application/chukebao/controller/WechatChatroomController.php @@ -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() { diff --git a/Server/application/chukebao/controller/WechatFriendController.php b/Server/application/chukebao/controller/WechatFriendController.php index d94a0ebb..5851e0f8 100644 --- a/Server/application/chukebao/controller/WechatFriendController.php +++ b/Server/application/chukebao/controller/WechatFriendController.php @@ -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]); + } } \ No newline at end of file diff --git a/Server/application/chukebao/model/AiPush.php b/Server/application/chukebao/model/AiPush.php new file mode 100644 index 00000000..567c132f --- /dev/null +++ b/Server/application/chukebao/model/AiPush.php @@ -0,0 +1,17 @@ +queueName}"; + Cache::rm($queueLockKey); if (Cache::get($queueLockKey)) { $output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行"); Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行"); diff --git a/Server/application/common/TaskServer.php b/Server/application/common/TaskServer.php index 8684c42c..2e205fdf 100644 --- a/Server/application/common/TaskServer.php +++ b/Server/application/common/TaskServer.php @@ -78,6 +78,14 @@ class TaskServer extends Server }); } + // 在一个进程里处理自动问候任务 + if ($current_worker_id == 1) { + // 每60秒检查一次自动问候规则 + Timer::add(60, function () use ($adapter) { + $adapter->handleAutoGreetings(); + }); + } + // 更多其他后台任务 // ...... diff --git a/Server/application/common/config/route.php b/Server/application/common/config/route.php index fc94c79b..54001bde 100644 --- a/Server/application/common/config/route.php +++ b/Server/application/common/config/route.php @@ -30,4 +30,4 @@ Route::group('v1/pay', function () { -Route::get('app/update', 'app\common\controller\PaymentService@createOrder'); \ No newline at end of file +Route::get('v1/app/update', 'app\common\controller\Api@uploadApp'); //检测app是否需要更新 \ No newline at end of file diff --git a/Server/application/common/controller/Api.php b/Server/application/common/controller/Api.php index e95ca699..459e8ee3 100644 --- a/Server/application/common/controller/Api.php +++ b/Server/application/common/controller/Api.php @@ -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(); diff --git a/Server/application/common/controller/Attachment.php b/Server/application/common/controller/Attachment.php index 68659283..7a2973bf 100644 --- a/Server/application/common/controller/Attachment.php +++ b/Server/application/common/controller/Attachment.php @@ -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', ] ]); diff --git a/Server/application/common/controller/GetOpenid.php b/Server/application/common/controller/GetOpenid.php new file mode 100644 index 00000000..b33c81de --- /dev/null +++ b/Server/application/common/controller/GetOpenid.php @@ -0,0 +1,52 @@ + 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!'; + } + +} \ No newline at end of file diff --git a/Server/application/common/controller/PasswordLoginController.php b/Server/application/common/controller/PasswordLoginController.php index a3748a06..232e4e68 100644 --- a/Server/application/common/controller/PasswordLoginController.php +++ b/Server/application/common/controller/PasswordLoginController.php @@ -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()); diff --git a/Server/application/common/controller/PaymentService.php b/Server/application/common/controller/PaymentService.php index 3d4f31eb..9c951c8c 100644 --- a/Server/application/common/controller/PaymentService.php +++ b/Server/application/common/controller/PaymentService.php @@ -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 ''; } + // 验证签名 + $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 ''; + } + } - 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 ''; + } + + // 检查业务结果 + if (isset($payload['result_code']) && $payload['result_code'] != 0) { + $errMsg = $payload['err_msg'] ?? '业务处理失败'; + \think\facade\Log::error('支付通知:业务处理失败', ['payload' => $payload]); + return ''; } // 业务处理:更新订单 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 ''; + } + $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 ''; + } + + // 如果订单已支付,直接返回成功(防止重复处理) + if ($order->status == 1) { + Db::rollback(); + return ''; } 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 ''; } - $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 ''; } catch (\Exception $e) { Db::rollback(); - return json_encode(['code' => 500, 'msg' => '付款失败' . $e->getMessage()]); + \think\facade\Log::error('支付通知:处理异常', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); + return ''; } } @@ -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; } + } \ No newline at end of file diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index d7f4012e..cf64cef7 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -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']); diff --git a/Server/application/cunkebao/controller/AiKnowledgeBaseController.php b/Server/application/cunkebao/controller/AiKnowledgeBaseController.php index 7ab048a9..06a95c88 100644 --- a/Server/application/cunkebao/controller/AiKnowledgeBaseController.php +++ b/Server/application/cunkebao/controller/AiKnowledgeBaseController.php @@ -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) { diff --git a/Server/application/cunkebao/controller/AiSettingsController.php b/Server/application/cunkebao/controller/AiSettingsController.php index 8eb41423..429410be 100644 --- a/Server/application/cunkebao/controller/AiSettingsController.php +++ b/Server/application/cunkebao/controller/AiSettingsController.php @@ -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()); + } + } } \ No newline at end of file diff --git a/Server/application/cunkebao/controller/RFMController.php b/Server/application/cunkebao/controller/RFMController.php index 693e808e..f9163db1 100644 --- a/Server/application/cunkebao/controller/RFMController.php +++ b/Server/application/cunkebao/controller/RFMController.php @@ -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()); + } + } } diff --git a/Server/application/cunkebao/controller/StoreAccountController.php b/Server/application/cunkebao/controller/StoreAccountController.php new file mode 100644 index 00000000..e58d6865 --- /dev/null +++ b/Server/application/cunkebao/controller/StoreAccountController.php @@ -0,0 +1,404 @@ +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); + } + } +} diff --git a/Server/application/cunkebao/controller/TokensController.php b/Server/application/cunkebao/controller/TokensController.php index df70d924..1db47b44 100644 --- a/Server/application/cunkebao/controller/TokensController.php +++ b/Server/application/cunkebao/controller/TokensController.php @@ -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()); } } } \ No newline at end of file diff --git a/Server/application/cunkebao/controller/WorkbenchController.php b/Server/application/cunkebao/controller/WorkbenchController.php index 9499c40f..fde42fa7 100644 --- a/Server/application/cunkebao/controller/WorkbenchController.php +++ b/Server/application/cunkebao/controller/WorkbenchController.php @@ -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(); diff --git a/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php b/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php index f51e5d76..dd1539a4 100644 --- a/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php +++ b/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php @@ -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); diff --git a/Server/application/cunkebao/validate/Workbench.php b/Server/application/cunkebao/validate/Workbench.php index ba9cb462..89b8c3bb 100644 --- a/Server/application/cunkebao/validate/Workbench.php +++ b/Server/application/cunkebao/validate/Workbench.php @@ -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; + } } \ No newline at end of file diff --git a/Server/application/job/WorkbenchGroupPushJob.php b/Server/application/job/WorkbenchGroupPushJob.php index 8ecbc1a9..e4a1f648 100644 --- a/Server/application/job/WorkbenchGroupPushJob.php +++ b/Server/application/job/WorkbenchGroupPushJob.php @@ -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) diff --git a/Server/application/store/config/route.php b/Server/application/store/config/route.php index dbae6f08..ae09c5df 100644 --- a/Server/application/store/config/route.php +++ b/Server/application/store/config/route.php @@ -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']); \ No newline at end of file +})->middleware(['jwt']); + +Route::get('v1/store/login', 'app\store\controller\LoginController@index'); \ No newline at end of file diff --git a/Server/application/store/controller/LoginController.php b/Server/application/store/controller/LoginController.php new file mode 100644 index 00000000..8dc571d2 --- /dev/null +++ b/Server/application/store/controller/LoginController.php @@ -0,0 +1,43 @@ +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, '登录成功'); + } +} \ No newline at end of file diff --git a/Server/application/store/controller/StatisticsController.php b/Server/application/store/controller/StatisticsController.php index 2096b2bc..17c9226c 100644 --- a/Server/application/store/controller/StatisticsController.php +++ b/Server/application/store/controller/StatisticsController.php @@ -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')); diff --git a/Server/application/store/model/TrafficOrderModel.php b/Server/application/store/model/TrafficOrderModel.php new file mode 100644 index 00000000..4d51d66f --- /dev/null +++ b/Server/application/store/model/TrafficOrderModel.php @@ -0,0 +1,11 @@ + 0); } + /** + * 处理自动问候功能 + * 根据不同的触发类型检查并发送问候消息 + */ + public function handleAutoGreetings() + { + try { + // 获取所有启用的问候规则 + $rules = Db::name('kf_auto_greetings') + ->where(['status' => 1, 'isDel' => 0]) + ->order('level asc, id asc') + ->select(); + + if (empty($rules)) { + return; + } + + foreach ($rules as $rule) { + $trigger = $rule['trigger']; + $condition = json_decode($rule['condition'], true); + + switch ($trigger) { + case 1: // 新好友 + $this->handleNewFriendGreeting($rule); + break; + case 2: // 首次发消息 + $this->handleFirstMessageGreeting($rule); + break; + case 3: // 时间触发 + $this->handleTimeTriggerGreeting($rule, $condition); + break; + case 4: // 关键词触发 + $this->handleKeywordTriggerGreeting($rule, $condition); + break; + case 5: // 生日触发 + $this->handleBirthdayTriggerGreeting($rule, $condition); + break; + case 6: // 自定义 + $this->handleCustomTriggerGreeting($rule, $condition); + break; + } + } + } catch (\Exception $e) { + Log::error('自动问候处理失败:' . $e->getMessage()); + } + } + + /** + * 处理新好友触发 + */ + private function handleNewFriendGreeting($rule) + { + // 获取最近24小时内添加的好友(避免重复处理) + $last24h = time() - 24 * 3600; + + // 查询该用户/公司最近24小时内新添加的好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['wf.passTime', '>=', $last24h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查是否已经发送过问候 + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + + /** + * 处理首次发消息触发 + */ + private function handleFirstMessageGreeting($rule) + { + // 获取最近1小时内收到的消息 + $last1h = time() - 3600; + + // 查询消息表,找出首次发消息的好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $messages = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wm.isSend', '=', 0], // 接收的消息 + ['wm.wechatChatroomId', '=', 0], // 个人消息 + ['wm.createTime', '>=', $last1h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->group('wm.wechatFriendId, wm.wechatAccountId') + ->field('wm.wechatFriendId, wm.wechatAccountId, MIN(wm.createTime) as firstMsgTime') + ->select(); + + foreach ($messages as $msg) { + // 检查该好友是否之前发送过消息 + $previousMsg = Db::table('s2_wechat_message') + ->where([ + 'wechatFriendId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + 'isSend' => 0, + ]) + ->where('createTime', '<', $msg['firstMsgTime']) + ->find(); + + // 如果是首次发消息,且没有发送过问候 + if (!$previousMsg) { + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + ]) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); + } + } + } + } + + /** + * 处理时间触发 + */ + private function handleTimeTriggerGreeting($rule, $condition) + { + if (empty($condition) || !isset($condition['type'])) { + return; + } + + $now = time(); + $currentTime = date('H:i', $now); + $currentDate = date('m-d', $now); + $currentDateTime = date('m-d H:i', $now); + $currentWeekday = date('w', $now); // 0=周日, 1=周一, ..., 6=周六 + + $shouldTrigger = false; + + switch ($condition['type']) { + case 'daily_time': // 每天固定时间 + if ($currentTime === $condition['value']) { + $shouldTrigger = true; + } + break; + + case 'yearly_datetime': // 每年固定日期时间 + if ($currentDateTime === $condition['value']) { + $shouldTrigger = true; + } + break; + + case 'fixed_range': // 固定时间段 + if (is_array($condition['value']) && count($condition['value']) === 2) { + $startTime = strtotime('2000-01-01 ' . $condition['value'][0]); + $endTime = strtotime('2000-01-01 ' . $condition['value'][1]); + $currentTimeStamp = strtotime('2000-01-01 ' . $currentTime); + + if ($currentTimeStamp >= $startTime && $currentTimeStamp <= $endTime) { + $shouldTrigger = true; + } + } + break; + + case 'workday': // 工作日 + // 周一到周五(1-5) + if ($currentWeekday >= 1 && $currentWeekday <= 5 && $currentTime === $condition['value']) { + $shouldTrigger = true; + } + break; + } + + if ($shouldTrigger) { + // 获取该用户/公司的所有好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查今天是否已经发送过 + $todayStart = strtotime(date('Y-m-d 00:00:00')); + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->where('createTime', '>=', $todayStart) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + } + + /** + * 处理关键词触发 + */ + private function handleKeywordTriggerGreeting($rule, $condition) + { + if (empty($condition) || empty($condition['keywords'])) { + return; + } + + $keywords = $condition['keywords']; + $matchType = $condition['match_type'] ?? 'fuzzy'; + + // 获取最近1小时内收到的消息 + $last1h = time() - 3600; + + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $messages = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wm.isSend', '=', 0], // 接收的消息 + ['wm.wechatChatroomId', '=', 0], // 个人消息 + ['wm.msgType', '=', 1], // 文本消息 + ['wm.createTime', '>=', $last1h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wm.*') + ->select(); + + foreach ($messages as $msg) { + $content = $msg['content'] ?? ''; + + // 检查关键词匹配 + $matched = false; + foreach ($keywords as $keyword) { + if ($matchType === 'exact') { + // 精准匹配 + if ($content === $keyword) { + $matched = true; + break; + } + } else { + // 模糊匹配 + if (strpos($content, $keyword) !== false) { + $matched = true; + break; + } + } + } + + if ($matched) { + // 检查是否已经发送过问候(同一好友同一规则,1小时内只发送一次) + $last1h = time() - 3600; + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + ]) + ->where('createTime', '>=', $last1h) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); + } + } + } + } + + /** + * 处理生日触发 + */ + private function handleBirthdayTriggerGreeting($rule, $condition) + { + if (empty($condition)) { + return; + } + + // 解析condition格式 + // 支持格式: + // 1. {'month': 10, 'day': 10} - 当天任何时间都可以触发 + // 2. {'month': 10, 'day': 10, 'time': '09:00'} - 当天指定时间触发 + // 3. {'month': 10, 'day': 10, 'time_range': ['09:00', '10:00']} - 当天时间范围内触发 + // 兼容旧格式:['10-10'] 或 '10-10'(仅支持 MM-DD 格式,不包含年份) + + $birthdayMonth = null; + $birthdayDay = null; + $birthdayTime = null; + $timeRange = null; + + if (is_array($condition)) { + // 新格式:对象格式 {'month': 10, 'day': 10} + if (isset($condition['month']) && isset($condition['day'])) { + $birthdayMonth = (int)$condition['month']; + $birthdayDay = (int)$condition['day']; + $birthdayTime = $condition['time'] ?? null; + $timeRange = $condition['time_range'] ?? null; + } + // 兼容旧格式:['10-10'] 或 ['10-10 09:00'](仅支持 MM-DD 格式) + elseif (isset($condition[0])) { + $dateStr = $condition[0]; + // 只接受月日格式:'10-10' 或 '10-10 09:00' + if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $dateStr, $matches)) { + $birthdayMonth = (int)$matches[1]; + $birthdayDay = (int)$matches[2]; + if (isset($matches[3])) { + $birthdayTime = $matches[3]; + } + } + } + } elseif (is_string($condition)) { + // 字符串格式:只接受 '10-10' 或 '10-10 09:00'(MM-DD 格式,不包含年份) + if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $condition, $matches)) { + $birthdayMonth = (int)$matches[1]; + $birthdayDay = (int)$matches[2]; + if (isset($matches[3])) { + $birthdayTime = $matches[3]; + } + } + } + + if ($birthdayMonth === null || $birthdayDay === null || $birthdayMonth < 1 || $birthdayMonth > 12 || $birthdayDay < 1 || $birthdayDay > 31) { + return; + } + + $todayMonth = (int)date('m'); + $todayDay = (int)date('d'); + + // 检查今天是否是生日(只匹配月日,不匹配年份) + if ($todayMonth !== $birthdayMonth || $todayDay !== $birthdayDay) { + return; + } + + // 如果配置了时间,检查当前时间是否匹配 + $now = time(); + $currentTime = date('H:i', $now); + + if ($birthdayTime !== null) { + // 指定了具体时间,检查是否在指定时间(允许1分钟误差,避免定时任务执行时间不精确) + $birthdayTimestamp = strtotime('2000-01-01 ' . $birthdayTime); + $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); + $diff = abs($currentTimestamp - $birthdayTimestamp); + + // 如果时间差超过2分钟,不触发(允许1分钟误差) + if ($diff > 120) { + return; + } + } elseif ($timeRange !== null && is_array($timeRange) && count($timeRange) === 2) { + // 指定了时间范围,检查当前时间是否在范围内 + $startTime = strtotime('2000-01-01 ' . $timeRange[0]); + $endTime = strtotime('2000-01-01 ' . $timeRange[1]); + $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); + + if ($currentTimestamp < $startTime || $currentTimestamp > $endTime) { + return; + } + } + // 如果没有配置时间或时间范围,则当天任何时间都可以触发 + + // 获取该用户/公司的所有好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查今天是否已经发送过 + $todayStart = strtotime(date('Y-m-d 00:00:00')); + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->where('createTime', '>=', $todayStart) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + + /** + * 处理自定义触发 + */ + private function handleCustomTriggerGreeting($rule, $condition) + { + // 自定义类型需要根据具体业务需求实现 + // 这里提供一个基础框架,可根据实际需求扩展 + // 暂时不实现,留待后续扩展 + } + + /** + * 发送问候消息 + * @param array $rule 问候规则 + * @param int $wechatAccountId 微信账号ID + * @param int $friendId 好友ID + * @param int $groupId 群ID(0表示个人消息) + */ + private function sendGreetingMessage($rule, $wechatAccountId, $friendId, $groupId = 0) + { + try { + $content = $rule['content']; + + // 创建记录 + $recordId = Db::name('kf_auto_greetings_record')->insertGetId([ + 'autoId' => $rule['id'], + 'userId' => $rule['userId'], + 'companyId' => $rule['companyId'], + 'wechatAccountId' => $wechatAccountId, + 'friendIdOrGroupId' => $friendId, + 'isSend' => 0, + 'sendTime' => 0, + 'receiveTime' => 0, + 'createTime' => time(), + ]); + + // 发送消息(文本消息) + $username = Env::get('api.username', ''); + $password = Env::get('api.password', ''); + $toAccountId = ''; + if (!empty($username) || !empty($password)) { + $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); + } + + $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); + + $sendTime = time(); + $result = $wsController->sendPersonal([ + 'wechatFriendId' => $friendId, + 'wechatAccountId' => $wechatAccountId, + 'msgType' => 1, // 文本消息 + 'content' => $content, + ]); + + $isSend = 0; + $receiveTime = 0; + + // 解析返回结果 + $resultData = json_decode($result, true); + if (!empty($resultData) && $resultData['code'] == 200) { + $isSend = 1; + $receiveTime = time(); // 简化处理,实际应该从返回结果中获取 + } + + // 更新记录 + Db::name('kf_auto_greetings_record') + ->where('id', $recordId) + ->update([ + 'isSend' => $isSend, + 'sendTime' => $sendTime, + 'receiveTime' => $receiveTime, + ]); + + // 更新规则使用次数 + Db::name('kf_auto_greetings') + ->where('id', $rule['id']) + ->setInc('usageCount'); + + } catch (\Exception $e) { + Log::error('发送问候消息失败:' . $e->getMessage() . ',规则ID:' . $rule['id']); + } + } + } diff --git a/Store_vue/api/config/index.js b/Store_vue/api/config/index.js index 961630bf..29c6eba6 100644 --- a/Store_vue/api/config/index.js +++ b/Store_vue/api/config/index.js @@ -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 = {}) => { diff --git a/Store_vue/api/modules/auth.js b/Store_vue/api/modules/auth.js index b6855228..98924fd8 100644 --- a/Store_vue/api/modules/auth.js +++ b/Store_vue/api/modules/auth.js @@ -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 || '' } }) } diff --git a/Store_vue/components/DataStatistics.vue b/Store_vue/components/DataStatistics.vue index 6329b225..46e646a3 100644 --- a/Store_vue/components/DataStatistics.vue +++ b/Store_vue/components/DataStatistics.vue @@ -12,6 +12,8 @@ + + 数据概览 @@ -34,15 +36,13 @@ - 总客户数 + 账号价值估值 - + - {{ overviewData.totalCustomers.toLocaleString() }} - - {{ (overviewData.totalCustomersChange >= 0 ? '+' : '') + overviewData.totalCustomersChange.toFixed(1) }}% 较上期 - + {{ overviewData.accountValue.toFixed(1) }} + RFM平均评分(满分10分) @@ -89,367 +89,110 @@ - - - - - - - - - - - - 客户增长趋势 - 近期客户增长数据 - - - - - - - 客户增长趋势图表 - - - - - - - 总客户 - {{ customerAnalysis.trend.total.toLocaleString() }} - - - - 新增客户 - {{ customerAnalysis.trend.new.toLocaleString() }} - - - - 流失客户 - {{ customerAnalysis.trend.lost.toLocaleString() }} - - - - - - - - - 客户来源分布 - 不同渠道客户占比 - - - - - - - 客户来源分布图表 - - - - - - - {{ source.name }} - {{ source.value }} - - - - + + + + + 加载中... - - - - - - - - - 互动频率分析 - 客户互动频次统计 - - - - - - - 互动频率分析图表 - - - - - - - 高频互动 - {{ interactionAnalysis.frequencyAnalysis.highFrequency.toLocaleString() }} - - - 中频互动 - {{ interactionAnalysis.frequencyAnalysis.midFrequency.toLocaleString() }} - - - 低频互动 - {{ interactionAnalysis.frequencyAnalysis.lowFrequency.toLocaleString() }} - - - - - 每周多次互动 - - - 每月多次互动 - - - 偶尔互动 - - - - - - - - - - 互动内容分析 - 客户互动内容类型占比 - - - - - - - 互动内容分析图表 - - - - - - - - - 文字互动 - {{ interactionAnalysis.contentAnalysis.textMessages.toLocaleString() }} - - - - - - 图片互动 - {{ interactionAnalysis.contentAnalysis.imgInteractions.toLocaleString() }} - - - - - - 群聊互动 - {{ interactionAnalysis.contentAnalysis.groupInteractions.toLocaleString() }} - - - - - - 产品咨询 - {{ interactionAnalysis.contentAnalysis.productInquiries.toLocaleString() }} - - - - - - - - - - - - 转化漏斗 - 客户转化路径分析 - - - - - - - 转化漏斗图表 - - - - - - 互动 - 3,256 - 100% - - - 咨询 - 1,856 - 57% - - - 意向 - 845 - 26% - - - 成交 - 386 - 12% - - - - - - - - - 转化效率 - 各阶段转化率分析 - - - - - - - 转化效率图表 - - - - - - - 互动→咨询 - 57% - - - +5.2% 较上期 - - - - - 咨询→意向 - 45.5% - - - +3.8% 较上期 - - - - - 意向→成交 - 45.7% - - - +4.2% 较上期 - - - - - - - - - - - - - 收入趋势 - 近期销售额和趋势 - - - - - - - 收入趋势图表 - - - - - - 总收入 - ¥258,386 - +22.5% 较上期 - - - - 客单价 - ¥843 - +5.8% 较上期 - - - - - - - - - 产品销售分布 - 各产品系列销售占比 - - - - - - - 产品销售分布图表 - - - - - - - 法儿曼胶原修复系列 - 42% - - ¥108,551 - - - - 安格安睛眼部系列 - 23% - - ¥59,444 - - - - 色仕莱诺胸部系列 - 18% - - ¥46,522 - - - - 头部疗愈SPA系列 - 17% - - ¥43,939 - - - - - - - - - {{ subsectionList[currentSubsection] }}暂无数据 - - - + + + + + + 综合分析 + + + + 客户平均转化金额 + ¥{{ comprehensiveData.avgConversionAmount.toFixed(2) }} + + + + + + + + + 价值指标 + + + 销售总额 + ¥{{ comprehensiveData.totalSales.toLocaleString() }} + + + 平均订单金额 + ¥{{ comprehensiveData.avgOrderAmount.toFixed(2) }} + + + 高价值客户 + {{ comprehensiveData.highValueCustomers.toFixed(1) }}% + + + + + + + + 增长趋势 + + + 周收益增长 + {{ comprehensiveData.weeklyRevenueGrowth > 0 ? '+' : '' }}¥{{ comprehensiveData.weeklyRevenueGrowth.toLocaleString() }} + + + 新客转化 + {{ comprehensiveData.newCustomerConversion > 0 ? '+' : '' }}{{ comprehensiveData.newCustomerConversion }}人 + + + 活跃客户增长 + {{ comprehensiveData.activeCustomerGrowth > 0 ? '+' : '' }}{{ comprehensiveData.activeCustomerGrowth }}人 + + + + + + + + + + + 客户活跃度 + + + + {{ item.name }} + {{ item.value }} + + + + + + + + 转化客户来源 + + + + {{ item.name }} + {{ item.count.toLocaleString() }} + + + + - + + + + + 加载中... + + + + + @@ -527,6 +270,7 @@ @confirm="confirmEndDate" @cancel="showEndDatePicker = false" > + @@ -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; + } \ No newline at end of file diff --git a/Store_vue/components/SideMenu.vue b/Store_vue/components/SideMenu.vue index c351e8ef..33eb8d36 100644 --- a/Store_vue/components/SideMenu.vue +++ b/Store_vue/components/SideMenu.vue @@ -1,7 +1,61 @@ @@ -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 } + } } @@ -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; + } \ No newline at end of file diff --git a/Store_vue/manifest.json b/Store_vue/manifest.json index 4c22f42d..c7fa191b 100644 --- a/Store_vue/manifest.json +++ b/Store_vue/manifest.json @@ -2,7 +2,7 @@ "name" : "AI数智员工", "appid" : "__UNI__9421F6C", "description" : "", - "versionName" : "1.0.1", + "versionName" : "1.1.1", "versionCode" : "100", "transformPx" : false, /* 5+App特有相关 */ diff --git a/Store_vue/package.json b/Store_vue/package.json index cf60374f..de7626df 100644 --- a/Store_vue/package.json +++ b/Store_vue/package.json @@ -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" diff --git a/Store_vue/pages/login/index.vue b/Store_vue/pages/login/index.vue index 87bcaec9..b3ce9ca2 100644 --- a/Store_vue/pages/login/index.vue +++ b/Store_vue/pages/login/index.vue @@ -1,82 +1,56 @@