first
This commit is contained in:
17
server-api/.env.example
Normal file
17
server-api/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
PUBLIC_FILE_BASE_URL=
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=anxinyan
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=change_me
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_PREFIX=
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_PREFIX=anxinyan:
|
||||
8
server-api/.gitignore
vendored
Normal file
8
server-api/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/runtime
|
||||
/.idea
|
||||
/.vscode
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
/tests/tmp
|
||||
/tests/.phpunit.result.cache
|
||||
18
server-api/Dockerfile
Normal file
18
server-api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM php:8.3.22-cli-alpine
|
||||
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk update --no-cache \
|
||||
&& docker-php-source extract
|
||||
|
||||
# install extensions
|
||||
RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl
|
||||
|
||||
# enable opcache and pcntl
|
||||
RUN docker-php-ext-enable opcache pcntl
|
||||
RUN docker-php-source delete \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
21
server-api/LICENSE
Normal file
21
server-api/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
70
server-api/README.md
Normal file
70
server-api/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
|
||||
<h1>webman</h1>
|
||||
|
||||
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
|
||||
|
||||
|
||||
<h1>学习</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style="float:left;padding-bottom:30px;">
|
||||
|
||||
<h1>赞助商</h1>
|
||||
|
||||
<h4>特别赞助</h4>
|
||||
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
|
||||
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
|
||||
</a>
|
||||
|
||||
<h4>铂金赞助</h4>
|
||||
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
|
||||
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="float:left;padding-bottom:30px;clear:both">
|
||||
|
||||
<h1>请作者喝咖啡</h1>
|
||||
|
||||
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
|
||||
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
|
||||
<br>
|
||||
<b>如果您觉得webman对您有所帮助,欢迎捐赠。</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div style="clear: both">
|
||||
<h1>LICENSE</h1>
|
||||
The webman is open-sourced software licensed under the MIT.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
22
server-api/app/bootstrap/Dotenv.php
Normal file
22
server-api/app/bootstrap/Dotenv.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace app\bootstrap;
|
||||
|
||||
use Dotenv\Dotenv as DotenvLoader;
|
||||
use Webman\Bootstrap;
|
||||
use Workerman\Worker;
|
||||
|
||||
class Dotenv implements Bootstrap
|
||||
{
|
||||
private static bool $loaded = false;
|
||||
|
||||
public static function start(?Worker $worker): void
|
||||
{
|
||||
if (self::$loaded || !is_file(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
DotenvLoader::createImmutable(base_path())->safeLoad();
|
||||
self::$loaded = true;
|
||||
}
|
||||
}
|
||||
42
server-api/app/controller/IndexController.php
Normal file
42
server-api/app/controller/IndexController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return <<<EOF
|
||||
<style>
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
iframe {
|
||||
border: none;
|
||||
overflow: scroll;
|
||||
}
|
||||
</style>
|
||||
<iframe
|
||||
src="https://www.workerman.net/wellcome"
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
|
||||
></iframe>
|
||||
EOF;
|
||||
}
|
||||
|
||||
public function view(Request $request)
|
||||
{
|
||||
return view('index/view', ['name' => 'webman']);
|
||||
}
|
||||
|
||||
public function json(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
}
|
||||
296
server-api/app/controller/admin/AccessController.php
Normal file
296
server-api/app/controller/admin/AccessController.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AdminAccessService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class AccessController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '管理员数量',
|
||||
'value' => (int)Db::name('admin_users')->count(),
|
||||
'desc' => '当前后台管理员账号总数',
|
||||
],
|
||||
[
|
||||
'title' => '启用角色',
|
||||
'value' => (int)Db::name('admin_roles')->where('status', 'enabled')->count(),
|
||||
'desc' => '当前启用中的角色数量',
|
||||
],
|
||||
[
|
||||
'title' => '权限点',
|
||||
'value' => (int)Db::name('admin_permissions')->count(),
|
||||
'desc' => '后台模块当前可分配的权限点数量',
|
||||
],
|
||||
[
|
||||
'title' => '角色授权',
|
||||
'value' => (int)Db::name('admin_role_permissions')->count(),
|
||||
'desc' => '角色与权限的关联配置总数',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function admins(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
$rows = Db::name('admin_users')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
$roleIds = Db::name('admin_role_relations')->where('admin_user_id', $item['id'])->column('role_id');
|
||||
$roles = $roleIds
|
||||
? Db::name('admin_roles')->whereIn('id', $roleIds)->column('name')
|
||||
: [];
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
'mobile' => $item['mobile'],
|
||||
'email' => $item['email'],
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->accessService()->statusText($item['status']),
|
||||
'role_ids' => array_map('intval', $roleIds),
|
||||
'role_names' => array_values($roles),
|
||||
'last_login_at' => $item['last_login_at'],
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function roles(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
$rows = Db::name('admin_roles')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
$permissionIds = Db::name('admin_role_permissions')->where('role_id', $item['id'])->column('permission_id');
|
||||
$permissions = $permissionIds
|
||||
? Db::name('admin_permissions')->whereIn('id', $permissionIds)->column('name')
|
||||
: [];
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
'code' => $item['code'],
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->accessService()->statusText($item['status']),
|
||||
'permission_ids' => array_map('intval', $permissionIds),
|
||||
'permission_names' => array_values($permissions),
|
||||
'admin_count' => (int)Db::name('admin_role_relations')->where('role_id', $item['id'])->count(),
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function permissions(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
$rows = Db::name('admin_permissions')
|
||||
->order('module', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
'code' => $item['code'],
|
||||
'module' => $item['module'],
|
||||
'action' => $item['action'],
|
||||
'module_text' => $this->accessService()->moduleText($item['module']),
|
||||
], $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAdmin(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
$id = (int)$request->input('id', 0);
|
||||
$name = trim((string)$request->input('name', ''));
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$email = trim((string)$request->input('email', ''));
|
||||
$password = trim((string)$request->input('password', ''));
|
||||
$status = trim((string)$request->input('status', 'enabled'));
|
||||
$roleIds = $this->normalizeIds((array)$request->input('role_ids', []));
|
||||
|
||||
if ($name === '' || $mobile === '') {
|
||||
return api_error('管理员姓名和手机号不能为空', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$admin = Db::name('admin_users')->where('id', $id)->find();
|
||||
if (!$admin) {
|
||||
Db::rollback();
|
||||
return api_error('管理员不存在', 404);
|
||||
}
|
||||
|
||||
$exists = Db::name('admin_users')
|
||||
->where('mobile', $mobile)
|
||||
->where('id', '<>', $id)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::rollback();
|
||||
return api_error('管理员手机号已存在', 422);
|
||||
}
|
||||
|
||||
Db::name('admin_users')->where('id', $id)->update([
|
||||
'name' => $name,
|
||||
'mobile' => $mobile,
|
||||
'email' => $email,
|
||||
'password' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : $admin['password'],
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$adminId = $id;
|
||||
} else {
|
||||
$exists = Db::name('admin_users')->where('mobile', $mobile)->find();
|
||||
if ($exists) {
|
||||
Db::rollback();
|
||||
return api_error('管理员手机号已存在', 422);
|
||||
}
|
||||
|
||||
$adminId = (int)Db::name('admin_users')->insertGetId([
|
||||
'name' => $name,
|
||||
'mobile' => $mobile,
|
||||
'email' => $email,
|
||||
'password' => password_hash($password !== '' ? $password : 'Admin@123456', PASSWORD_BCRYPT),
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'last_login_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::name('admin_role_relations')->where('admin_user_id', $adminId)->delete();
|
||||
foreach ($roleIds as $roleId) {
|
||||
Db::name('admin_role_relations')->insert([
|
||||
'admin_user_id' => $adminId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('管理员保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success(['id' => $adminId], '管理员已保存');
|
||||
}
|
||||
|
||||
public function saveRole(Request $request)
|
||||
{
|
||||
$this->accessService()->bootstrapDefaults();
|
||||
|
||||
$id = (int)$request->input('id', 0);
|
||||
$name = trim((string)$request->input('name', ''));
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
$status = trim((string)$request->input('status', 'enabled'));
|
||||
$permissionIds = $this->normalizeIds((array)$request->input('permission_ids', []));
|
||||
|
||||
if ($name === '' || $code === '') {
|
||||
return api_error('角色名称和编码不能为空', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$role = Db::name('admin_roles')->where('id', $id)->find();
|
||||
if (!$role) {
|
||||
Db::rollback();
|
||||
return api_error('角色不存在', 404);
|
||||
}
|
||||
|
||||
$exists = Db::name('admin_roles')
|
||||
->where('code', $code)
|
||||
->where('id', '<>', $id)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::rollback();
|
||||
return api_error('角色编码已存在', 422);
|
||||
}
|
||||
|
||||
Db::name('admin_roles')->where('id', $id)->update([
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$roleId = $id;
|
||||
} else {
|
||||
$exists = Db::name('admin_roles')->where('code', $code)->find();
|
||||
if ($exists) {
|
||||
Db::rollback();
|
||||
return api_error('角色编码已存在', 422);
|
||||
}
|
||||
|
||||
$roleId = (int)Db::name('admin_roles')->insertGetId([
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::name('admin_role_permissions')->where('role_id', $roleId)->delete();
|
||||
foreach ($permissionIds as $permissionId) {
|
||||
Db::name('admin_role_permissions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permissionId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('角色保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success(['id' => $roleId], '角色已保存');
|
||||
}
|
||||
|
||||
private function normalizeIds(array $values): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $values), fn (int $item) => $item > 0)));
|
||||
}
|
||||
|
||||
private function accessService(): AdminAccessService
|
||||
{
|
||||
return new AdminAccessService();
|
||||
}
|
||||
}
|
||||
1614
server-api/app/controller/admin/AppraisalTasksController.php
Normal file
1614
server-api/app/controller/admin/AppraisalTasksController.php
Normal file
File diff suppressed because it is too large
Load Diff
44
server-api/app/controller/admin/AuthController.php
Normal file
44
server-api/app/controller/admin/AuthController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AdminAuthService;
|
||||
use support\Request;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$password = trim((string)$request->input('password', ''));
|
||||
|
||||
if ($mobile === '' || $password === '') {
|
||||
return api_error('手机号和密码不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AdminAuthService())->login($mobile, $password, $request);
|
||||
return api_success($payload, '登录成功');
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 401);
|
||||
}
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
$admin = (new AdminAuthService())->current($request);
|
||||
if (!$admin) {
|
||||
return api_error('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'admin_info' => $admin,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
(new AdminAuthService())->logout($request);
|
||||
return api_success([], '已退出登录');
|
||||
}
|
||||
}
|
||||
889
server-api/app/controller/admin/CatalogController.php
Normal file
889
server-api/app/controller/admin/CatalogController.php
Normal file
@@ -0,0 +1,889 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\CatalogTemplateSampleImageService;
|
||||
use app\support\ContentService;
|
||||
use app\support\FileStorageService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class CatalogController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '启用品类',
|
||||
'value' => (int)Db::name('catalog_categories')->where('is_enabled', 1)->count(),
|
||||
'desc' => '当前前台可用的鉴定品类数量',
|
||||
],
|
||||
[
|
||||
'title' => '启用品牌',
|
||||
'value' => (int)Db::name('catalog_brands')->where('is_enabled', 1)->count(),
|
||||
'desc' => '已配置并启用的品牌数量',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function categories(Request $request)
|
||||
{
|
||||
$rows = Db::name('catalog_categories')
|
||||
->field([
|
||||
'id',
|
||||
'name',
|
||||
'code',
|
||||
'sort_order',
|
||||
'is_enabled',
|
||||
'need_shipping',
|
||||
'supported_service_types',
|
||||
])
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$categoryVisuals = $this->categoryVisualMap($request);
|
||||
|
||||
$templateSummaryMap = [];
|
||||
$appraisalTemplateSummaryMap = [];
|
||||
if ($rows) {
|
||||
$categoryIds = array_map(fn (array $item) => (int)$item['id'], $rows);
|
||||
$templateRows = Db::name('upload_templates')
|
||||
->field(['id', 'scope_id'])
|
||||
->where('scope_type', 'category')
|
||||
->whereIn('scope_id', $categoryIds)
|
||||
->where('is_enabled', 1)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$templateIds = array_map(fn (array $item) => (int)$item['id'], $templateRows);
|
||||
$itemCountMap = [];
|
||||
if ($templateIds) {
|
||||
$itemRows = Db::name('upload_template_items')
|
||||
->fieldRaw('template_id, COUNT(*) AS item_count')
|
||||
->whereIn('template_id', $templateIds)
|
||||
->where('is_enabled', 1)
|
||||
->group('template_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($itemRows as $item) {
|
||||
$itemCountMap[(int)$item['template_id']] = (int)$item['item_count'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($templateRows as $item) {
|
||||
$categoryId = (int)($item['scope_id'] ?? 0);
|
||||
if ($categoryId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($templateSummaryMap[$categoryId])) {
|
||||
$templateSummaryMap[$categoryId] = [
|
||||
'template_count' => 0,
|
||||
'item_count' => 0,
|
||||
];
|
||||
}
|
||||
$templateSummaryMap[$categoryId]['template_count'] += 1;
|
||||
$templateSummaryMap[$categoryId]['item_count'] += $itemCountMap[(int)$item['id']] ?? 0;
|
||||
}
|
||||
|
||||
$appraisalTemplateRows = Db::name('appraisal_templates')
|
||||
->field(['id', 'scope_id', 'is_default'])
|
||||
->where('scope_type', 'category')
|
||||
->whereIn('scope_id', $categoryIds)
|
||||
->where('is_enabled', 1)
|
||||
->order('is_default', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$appraisalTemplateIds = array_map(fn (array $item) => (int)$item['id'], $appraisalTemplateRows);
|
||||
$pointCountMap = [];
|
||||
if ($appraisalTemplateIds) {
|
||||
$pointRows = Db::name('appraisal_template_key_points')
|
||||
->fieldRaw('template_id, COUNT(*) AS point_count')
|
||||
->whereIn('template_id', $appraisalTemplateIds)
|
||||
->group('template_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($pointRows as $item) {
|
||||
$pointCountMap[(int)$item['template_id']] = (int)$item['point_count'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($appraisalTemplateRows as $item) {
|
||||
$categoryId = (int)($item['scope_id'] ?? 0);
|
||||
if ($categoryId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (isset($appraisalTemplateSummaryMap[$categoryId])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($appraisalTemplateSummaryMap[$categoryId])) {
|
||||
$appraisalTemplateSummaryMap[$categoryId] = [
|
||||
'template_count' => 0,
|
||||
'point_count' => 0,
|
||||
];
|
||||
}
|
||||
$appraisalTemplateSummaryMap[$categoryId]['template_count'] = 1;
|
||||
$appraisalTemplateSummaryMap[$categoryId]['point_count'] += $pointCountMap[(int)$item['id']] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
$list = array_map(function (array $item) use ($templateSummaryMap, $appraisalTemplateSummaryMap, $categoryVisuals) {
|
||||
$summary = $templateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'item_count' => 0];
|
||||
$appraisalSummary = $appraisalTemplateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'point_count' => 0];
|
||||
$codeKey = $this->categoryMatchKey((string)$item['code']);
|
||||
$nameKey = $this->categoryMatchKey((string)$item['name']);
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
'code' => $item['code'],
|
||||
'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '',
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_enabled' => (bool)$item['is_enabled'],
|
||||
'need_shipping' => (bool)$item['need_shipping'],
|
||||
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||
'upload_template_count' => (int)$summary['template_count'],
|
||||
'upload_template_item_count' => (int)$summary['item_count'],
|
||||
'upload_template_summary' => (int)$summary['template_count'] > 0
|
||||
? sprintf('%d 套模板 / %d 项采集项', (int)$summary['template_count'], (int)$summary['item_count'])
|
||||
: '未配置模板',
|
||||
'appraisal_template_count' => 1,
|
||||
'appraisal_template_point_count' => (int)$appraisalSummary['point_count'],
|
||||
'appraisal_template_summary' => sprintf('%d 个自定义鉴定项', (int)$appraisalSummary['point_count']),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function uploadTemplates(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 0);
|
||||
if ($categoryId <= 0) {
|
||||
return api_error('品类 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||
if (!$category) {
|
||||
return api_error('品类不存在', 404);
|
||||
}
|
||||
|
||||
$serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null);
|
||||
if (!$serviceProviders) {
|
||||
$serviceProviders = ['anxinyan'];
|
||||
}
|
||||
|
||||
$existingRows = Db::name('upload_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->whereIn('service_provider', $serviceProviders)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$existingMap = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$provider = (string)($row['service_provider'] ?? '');
|
||||
if ($provider === '' || isset($existingMap[$provider])) {
|
||||
continue;
|
||||
}
|
||||
$existingMap[$provider] = $row;
|
||||
}
|
||||
|
||||
$list = array_map(function (string $serviceProvider) use ($categoryId, $category, $existingMap, $request) {
|
||||
$existing = $existingMap[$serviceProvider] ?? null;
|
||||
$items = [];
|
||||
if ($existing) {
|
||||
$itemRows = Db::name('upload_template_items')
|
||||
->where('template_id', (int)$existing['id'])
|
||||
->order('sort_order', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$items = array_map(fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'item_code' => (string)$item['item_code'],
|
||||
'item_name' => (string)$item['item_name'],
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
'guide_text' => (string)$item['guide_text'],
|
||||
'sample_image_url' => $this->templateSampleImageService()->normalizeUrl((string)$item['sample_image_url'], $request),
|
||||
'max_upload_count' => (int)$item['max_upload_count'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_enabled' => (bool)$item['is_enabled'],
|
||||
], $itemRows);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $existing ? (int)$existing['id'] : null,
|
||||
'category_id' => $categoryId,
|
||||
'category_name' => (string)$category['name'],
|
||||
'service_provider' => $serviceProvider,
|
||||
'service_provider_text' => $this->serviceProviderText($serviceProvider),
|
||||
'name' => $existing['name'] ?? sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)),
|
||||
'code' => $existing['code'] ?? sprintf('upload_category_%d_%s', $categoryId, $serviceProvider),
|
||||
'is_enabled' => $existing ? (bool)$existing['is_enabled'] : true,
|
||||
'is_default' => $existing ? (bool)$existing['is_default'] : ($serviceProvider === 'anxinyan'),
|
||||
'items' => $items,
|
||||
];
|
||||
}, $serviceProviders);
|
||||
|
||||
return api_success([
|
||||
'category' => [
|
||||
'id' => $categoryId,
|
||||
'name' => (string)$category['name'],
|
||||
'code' => (string)$category['code'],
|
||||
],
|
||||
'list' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveUploadTemplates(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 0);
|
||||
$templates = $request->input('templates', []);
|
||||
if ($categoryId <= 0) {
|
||||
return api_error('品类 ID 不能为空', 422);
|
||||
}
|
||||
if (!is_array($templates)) {
|
||||
return api_error('模板数据格式不正确', 422);
|
||||
}
|
||||
|
||||
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||
if (!$category) {
|
||||
return api_error('品类不存在', 404);
|
||||
}
|
||||
|
||||
$serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null);
|
||||
if (!$serviceProviders) {
|
||||
$serviceProviders = ['anxinyan'];
|
||||
}
|
||||
$allowedProviders = array_fill_keys($serviceProviders, true);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$defaultTemplateId = 0;
|
||||
$orphanSampleImageUrls = [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($templates as $template) {
|
||||
if (!is_array($template)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$serviceProvider = trim((string)($template['service_provider'] ?? ''));
|
||||
if ($serviceProvider === '' || !isset($allowedProviders[$serviceProvider])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateId = (int)($template['id'] ?? 0);
|
||||
$exists = null;
|
||||
if ($templateId > 0) {
|
||||
$exists = Db::name('upload_templates')->where('id', $templateId)->find();
|
||||
}
|
||||
if (!$exists) {
|
||||
$exists = Db::name('upload_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->where('service_provider', $serviceProvider)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => trim((string)($template['name'] ?? '')) ?: sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)),
|
||||
'code' => trim((string)($template['code'] ?? '')) ?: sprintf('upload_category_%d_%s', $categoryId, $serviceProvider),
|
||||
'scope_type' => 'category',
|
||||
'scope_id' => $categoryId,
|
||||
'service_provider' => $serviceProvider,
|
||||
'is_default' => !empty($template['is_default']) ? 1 : 0,
|
||||
'is_enabled' => array_key_exists('is_enabled', $template) ? (!empty($template['is_enabled']) ? 1 : 0) : 1,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('upload_templates')->where('id', (int)$exists['id'])->update($payload);
|
||||
$savedTemplateId = (int)$exists['id'];
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$savedTemplateId = (int)Db::name('upload_templates')->insertGetId($payload);
|
||||
}
|
||||
|
||||
if ($serviceProvider === 'anxinyan') {
|
||||
$defaultTemplateId = $savedTemplateId;
|
||||
}
|
||||
|
||||
$existingItemRows = Db::name('upload_template_items')
|
||||
->where('template_id', $savedTemplateId)
|
||||
->select()
|
||||
->toArray();
|
||||
$existingSampleUrls = array_values(array_filter(array_map(
|
||||
fn (array $item) => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')),
|
||||
$existingItemRows
|
||||
)));
|
||||
|
||||
Db::name('upload_template_items')->where('template_id', $savedTemplateId)->delete();
|
||||
$items = is_array($template['items'] ?? null) ? $template['items'] : [];
|
||||
$insertRows = [];
|
||||
$nextSampleUrls = [];
|
||||
foreach ($items as $index => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemCode = trim((string)($item['item_code'] ?? ''));
|
||||
$itemName = trim((string)($item['item_name'] ?? ''));
|
||||
if ($itemCode === '' || $itemName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertRows[] = [
|
||||
'template_id' => $savedTemplateId,
|
||||
'item_code' => $itemCode,
|
||||
'item_name' => $itemName,
|
||||
'is_required' => !empty($item['is_required']) ? 1 : 0,
|
||||
'guide_text' => trim((string)($item['guide_text'] ?? '')),
|
||||
'sample_image_url' => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')),
|
||||
'max_upload_count' => max(1, (int)($item['max_upload_count'] ?? 1)),
|
||||
'sort_order' => (int)($item['sort_order'] ?? (($index + 1) * 10)),
|
||||
'is_enabled' => array_key_exists('is_enabled', $item) ? (!empty($item['is_enabled']) ? 1 : 0) : 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$sampleUrl = $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? ''));
|
||||
if ($sampleUrl !== '') {
|
||||
$nextSampleUrls[] = $sampleUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if ($insertRows) {
|
||||
Db::name('upload_template_items')->insertAll($insertRows);
|
||||
}
|
||||
|
||||
$removedSampleUrls = array_values(array_diff($existingSampleUrls, $nextSampleUrls));
|
||||
if ($removedSampleUrls) {
|
||||
$orphanSampleImageUrls = array_values(array_unique(array_merge($orphanSampleImageUrls, $removedSampleUrls)));
|
||||
}
|
||||
}
|
||||
|
||||
Db::name('catalog_categories')->where('id', $categoryId)->update([
|
||||
'default_upload_template_id' => $defaultTemplateId > 0 ? $defaultTemplateId : null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('上传模板保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($orphanSampleImageUrls as $fileUrl) {
|
||||
$this->templateSampleImageService()->delete($fileUrl);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'category_id' => $categoryId,
|
||||
], '上传模板已保存');
|
||||
}
|
||||
|
||||
public function appraisalTemplates(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 0);
|
||||
if ($categoryId <= 0) {
|
||||
return api_error('品类 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||
if (!$category) {
|
||||
return api_error('品类不存在', 404);
|
||||
}
|
||||
|
||||
$template = Db::name('appraisal_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->where('is_enabled', 1)
|
||||
->order('is_default', 'desc')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$points = [];
|
||||
if ($template) {
|
||||
$pointRows = Db::name('appraisal_template_key_points')
|
||||
->where('template_id', (int)$template['id'])
|
||||
->order('sort_order', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$points = array_map(fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'point_code' => (string)$item['point_code'],
|
||||
'point_name' => (string)$item['point_name'],
|
||||
'point_type' => (string)$item['point_type'],
|
||||
'options' => $this->decodeJsonArray($item['options_json'] ?? null),
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
], $pointRows);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'id' => $template ? (int)$template['id'] : null,
|
||||
'category_id' => $categoryId,
|
||||
'category_name' => (string)$category['name'],
|
||||
'service_provider' => 'category',
|
||||
'service_provider_text' => '通用品类模板',
|
||||
'name' => $template['name'] ?? sprintf('%s鉴定模板', (string)$category['name']),
|
||||
'code' => $template['code'] ?? sprintf('appraisal_category_%d', $categoryId),
|
||||
'is_enabled' => true,
|
||||
'is_default' => true,
|
||||
'result_options' => [],
|
||||
'condition_options' => [],
|
||||
'valuation_hint' => '',
|
||||
'key_points' => $points,
|
||||
];
|
||||
|
||||
return api_success([
|
||||
'category' => [
|
||||
'id' => $categoryId,
|
||||
'name' => (string)$category['name'],
|
||||
'code' => (string)$category['code'],
|
||||
],
|
||||
'template' => $payload,
|
||||
'list' => [$payload],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAppraisalTemplates(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 0);
|
||||
$template = $request->input('template', null);
|
||||
$templates = $request->input('templates', []);
|
||||
if ($categoryId <= 0) {
|
||||
return api_error('品类 ID 不能为空', 422);
|
||||
}
|
||||
if (!is_array($template)) {
|
||||
$template = is_array($templates) ? ($templates[0] ?? []) : [];
|
||||
}
|
||||
if (!is_array($template)) {
|
||||
return api_error('模板数据格式不正确', 422);
|
||||
}
|
||||
|
||||
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||
if (!$category) {
|
||||
return api_error('品类不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$exists = Db::name('appraisal_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->order('is_default', 'desc')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'name' => sprintf('%s鉴定模板', (string)$category['name']),
|
||||
'code' => sprintf('appraisal_category_%d', $categoryId),
|
||||
'scope_type' => 'category',
|
||||
'scope_id' => $categoryId,
|
||||
'service_provider' => 'category',
|
||||
'is_default' => 1,
|
||||
'is_enabled' => 1,
|
||||
'result_options_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'condition_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'valuation_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('appraisal_templates')->where('id', (int)$exists['id'])->update($payload);
|
||||
$savedTemplateId = (int)$exists['id'];
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$savedTemplateId = (int)Db::name('appraisal_templates')->insertGetId($payload);
|
||||
}
|
||||
|
||||
$otherTemplateIds = Db::name('appraisal_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->where('id', '<>', $savedTemplateId)
|
||||
->column('id');
|
||||
if ($otherTemplateIds) {
|
||||
Db::name('appraisal_templates')->whereIn('id', $otherTemplateIds)->update([
|
||||
'is_enabled' => 0,
|
||||
'is_default' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::name('appraisal_template_key_points')->where('template_id', $savedTemplateId)->delete();
|
||||
$points = is_array($template['key_points'] ?? null) ? $template['key_points'] : [];
|
||||
$insertRows = [];
|
||||
foreach ($points as $index => $point) {
|
||||
if (!is_array($point)) {
|
||||
continue;
|
||||
}
|
||||
$pointName = trim((string)($point['point_name'] ?? ''));
|
||||
if ($pointName === '') {
|
||||
continue;
|
||||
}
|
||||
$pointCode = $this->normalizeCode((string)($point['point_code'] ?? '')) ?: sprintf('point_%d', $index + 1);
|
||||
|
||||
$pointType = trim((string)($point['point_type'] ?? 'text'));
|
||||
if (!in_array($pointType, ['text', 'textarea', 'select', 'boolean'], true)) {
|
||||
$pointType = 'text';
|
||||
}
|
||||
|
||||
$insertRows[] = [
|
||||
'template_id' => $savedTemplateId,
|
||||
'point_code' => $pointCode,
|
||||
'point_name' => $pointName,
|
||||
'point_type' => $pointType,
|
||||
'options_json' => json_encode($this->normalizeArray($point['options'] ?? []), JSON_UNESCAPED_UNICODE),
|
||||
'sort_order' => (int)($point['sort_order'] ?? (($index + 1) * 10)),
|
||||
'is_required' => !empty($point['is_required']) ? 1 : 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if ($insertRows) {
|
||||
Db::name('appraisal_template_key_points')->insertAll($insertRows);
|
||||
}
|
||||
|
||||
Db::name('catalog_categories')->where('id', $categoryId)->update([
|
||||
'default_appraisal_template_id' => $savedTemplateId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('鉴定模板保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'category_id' => $categoryId,
|
||||
], '鉴定模板已保存');
|
||||
}
|
||||
|
||||
public function uploadTemplateSampleImage(Request $request)
|
||||
{
|
||||
try {
|
||||
$asset = $this->templateSampleImageService()->upload($request);
|
||||
return api_success($asset, '示例图上传成功');
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteUploadTemplateSampleImage(Request $request)
|
||||
{
|
||||
$fileUrl = trim((string)$request->input('file_url', ''));
|
||||
if ($fileUrl === '') {
|
||||
return api_error('文件地址不能为空', 422);
|
||||
}
|
||||
|
||||
$this->templateSampleImageService()->delete($fileUrl);
|
||||
|
||||
return api_success([
|
||||
'file_url' => $fileUrl,
|
||||
], '示例图已删除');
|
||||
}
|
||||
|
||||
public function saveCategory(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$name = trim((string)$request->input('name', ''));
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
$imageUrl = trim((string)$request->input('image_url', ''));
|
||||
|
||||
if ($name === '' || $code === '') {
|
||||
return api_error('品类名称和编码不能为空', 422);
|
||||
}
|
||||
|
||||
$previous = $id > 0
|
||||
? Db::name('catalog_categories')->field(['name', 'code'])->where('id', $id)->find()
|
||||
: null;
|
||||
|
||||
$payload = [
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'sort_order' => (int)$request->input('sort_order', 0),
|
||||
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
|
||||
'need_shipping' => $request->input('need_shipping', true) ? 1 : 0,
|
||||
'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('catalog_categories')->where('id', $id)->update($payload);
|
||||
$this->saveCategoryVisual($name, $code, $imageUrl, is_array($previous) ? $previous : null);
|
||||
return api_success(['id' => $id], '更新成功');
|
||||
}
|
||||
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
$newId = Db::name('catalog_categories')->insertGetId($payload);
|
||||
$this->saveCategoryVisual($name, $code, $imageUrl);
|
||||
return api_success(['id' => (int)$newId], '创建成功');
|
||||
}
|
||||
|
||||
public function brands(Request $request)
|
||||
{
|
||||
$rows = Db::name('catalog_brands')
|
||||
->alias('b')
|
||||
->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
|
||||
->leftJoin('catalog_categories c', 'c.id = cbc.category_id')
|
||||
->field([
|
||||
'b.id',
|
||||
'b.name',
|
||||
'b.en_name',
|
||||
'b.code',
|
||||
'b.sort_order',
|
||||
'b.is_enabled',
|
||||
'b.supported_service_types',
|
||||
'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids',
|
||||
'GROUP_CONCAT(DISTINCT c.name) AS category_names',
|
||||
])
|
||||
->group('b.id')
|
||||
->order('b.sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
'en_name' => $item['en_name'],
|
||||
'code' => $item['code'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'is_enabled' => (bool)$item['is_enabled'],
|
||||
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
|
||||
'category_names' => $item['category_names'] ?: '',
|
||||
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function saveBrand(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$name = trim((string)$request->input('name', ''));
|
||||
$enName = trim((string)$request->input('en_name', ''));
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
$categoryIds = $this->normalizeIntArray($request->input('category_ids', []));
|
||||
|
||||
if ($name === '' || $code === '') {
|
||||
return api_error('品牌名称和编码不能为空', 422);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => $name,
|
||||
'en_name' => $enName,
|
||||
'code' => $code,
|
||||
'sort_order' => (int)$request->input('sort_order', 0),
|
||||
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
|
||||
'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($id > 0) {
|
||||
Db::name('catalog_brands')->where('id', $id)->update($payload);
|
||||
Db::name('catalog_brand_categories')->where('brand_id', $id)->delete();
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
Db::name('catalog_brand_categories')->insert([
|
||||
'brand_id' => $id,
|
||||
'category_id' => $categoryId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
return api_success(['id' => $id], '更新成功');
|
||||
}
|
||||
|
||||
$payload['logo'] = '';
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
$newId = Db::name('catalog_brands')->insertGetId($payload);
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
Db::name('catalog_brand_categories')->insert([
|
||||
'brand_id' => $newId,
|
||||
'category_id' => $categoryId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
return api_success(['id' => (int)$newId], '创建成功');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('品牌保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function decodeJsonArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values($value);
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? array_values($decoded) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function decodeJsonObject(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values(array_filter(array_map(static fn ($item) => trim((string)$item), $value), static fn ($item) => $item !== ''));
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return array_values(array_filter(array_map('trim', explode(',', $value)), static fn ($item) => $item !== ''));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeCode(string $value): string
|
||||
{
|
||||
$normalized = strtolower(trim($value));
|
||||
$normalized = (string)preg_replace('/[^a-z0-9_]+/', '_', $normalized);
|
||||
$normalized = trim($normalized, '_');
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeIntArray(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(array_map(static fn ($item) => (int)$item, $value), static fn ($item) => $item > 0));
|
||||
}
|
||||
|
||||
private function decodeIntList(string $value): array
|
||||
{
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(array_map(static fn ($item) => (int)$item, explode(',', $value)), static fn ($item) => $item > 0));
|
||||
}
|
||||
|
||||
private function serviceProviderText(string $serviceProvider): string
|
||||
{
|
||||
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
|
||||
}
|
||||
|
||||
private function categoryVisualMap(Request $request): array
|
||||
{
|
||||
$items = (new ContentService())->getHomeConfig()['category_visuals'] ?? [];
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
$storage = new FileStorageService();
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageUrl = trim((string)($item['image_url'] ?? ''));
|
||||
if ($imageUrl === '') {
|
||||
continue;
|
||||
}
|
||||
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
|
||||
|
||||
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
|
||||
if ($categoryCode !== '') {
|
||||
$map['code:' . $categoryCode] = $imageUrl;
|
||||
}
|
||||
|
||||
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
|
||||
if ($categoryName !== '') {
|
||||
$map['name:' . $categoryName] = $imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function saveCategoryVisual(string $categoryName, string $categoryCode, string $imageUrl, ?array $previous = null): void
|
||||
{
|
||||
$contentService = new ContentService();
|
||||
$homeConfig = $contentService->getHomeConfig();
|
||||
$items = is_array($homeConfig['category_visuals'] ?? null) ? $homeConfig['category_visuals'] : [];
|
||||
|
||||
$removeKeys = [
|
||||
'code:' . $this->categoryMatchKey($categoryCode),
|
||||
'name:' . $this->categoryMatchKey($categoryName),
|
||||
];
|
||||
if ($previous) {
|
||||
$removeKeys[] = 'code:' . $this->categoryMatchKey((string)($previous['code'] ?? ''));
|
||||
$removeKeys[] = 'name:' . $this->categoryMatchKey((string)($previous['name'] ?? ''));
|
||||
}
|
||||
$removeKeys = array_values(array_filter(array_unique($removeKeys), static fn ($key) => !str_ends_with($key, ':')));
|
||||
|
||||
$nextItems = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemKeys = [
|
||||
'code:' . $this->categoryMatchKey((string)($item['category_code'] ?? '')),
|
||||
'name:' . $this->categoryMatchKey((string)($item['category_name'] ?? '')),
|
||||
];
|
||||
if (array_intersect($removeKeys, $itemKeys)) {
|
||||
continue;
|
||||
}
|
||||
$nextItems[] = $item;
|
||||
}
|
||||
|
||||
$nextItems[] = [
|
||||
'category_name' => $categoryName,
|
||||
'category_code' => $categoryCode,
|
||||
'image_url' => $imageUrl,
|
||||
];
|
||||
|
||||
$homeConfig['category_visuals'] = $nextItems;
|
||||
$contentService->saveHomeConfig($homeConfig);
|
||||
}
|
||||
|
||||
private function categoryMatchKey(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
|
||||
|
||||
return strtolower($normalized ?? $value);
|
||||
}
|
||||
|
||||
private function templateSampleImageService(): CatalogTemplateSampleImageService
|
||||
{
|
||||
return new CatalogTemplateSampleImageService();
|
||||
}
|
||||
}
|
||||
155
server-api/app/controller/admin/ContentsController.php
Normal file
155
server-api/app/controller/admin/ContentsController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\ContentImageService;
|
||||
use app\support\ContentService;
|
||||
use support\Request;
|
||||
|
||||
class ContentsController
|
||||
{
|
||||
public function bootstrap(Request $request)
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
return api_success([
|
||||
'home_config' => $service->getHomeConfig(),
|
||||
'policy_config' => $service->getPolicyConfig(),
|
||||
'meta_config' => $service->getMetaConfig(),
|
||||
'help_articles' => $service->getHelpArticles(false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function home(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'home_config' => $this->service()->getHomeConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveHome(Request $request)
|
||||
{
|
||||
try {
|
||||
$this->service()->saveHomeConfig((array)$request->input('home_config', []));
|
||||
return api_success([
|
||||
'home_config' => $this->service()->getHomeConfig(),
|
||||
], '首页内容已保存');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('首页内容保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadImage(Request $request)
|
||||
{
|
||||
try {
|
||||
return api_success((new ContentImageService())->upload($request), '图片已上传');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('图片上传失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function helpArticles(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'list' => $this->service()->getHelpArticles(false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function policy(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'policy_config' => $this->service()->getPolicyConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePolicy(Request $request)
|
||||
{
|
||||
try {
|
||||
$this->service()->savePolicyConfig((array)$request->input('policy_config', []));
|
||||
return api_success([
|
||||
'policy_config' => $this->service()->getPolicyConfig(),
|
||||
], '协议与说明已保存');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('协议与说明保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function meta(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'meta_config' => $this->service()->getMetaConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveMeta(Request $request)
|
||||
{
|
||||
try {
|
||||
$this->service()->saveMetaConfig((array)$request->input('meta_config', []));
|
||||
return api_success([
|
||||
'meta_config' => $this->service()->getMetaConfig(),
|
||||
], '帮助分类与报告提示已保存');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('帮助分类与报告提示保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveHelpArticle(Request $request)
|
||||
{
|
||||
try {
|
||||
$id = $this->service()->saveHelpArticle([
|
||||
'id' => (int)$request->input('id', 0),
|
||||
'category' => trim((string)$request->input('category', 'service')),
|
||||
'title' => trim((string)$request->input('title', '')),
|
||||
'summary' => trim((string)$request->input('summary', '')),
|
||||
'keywords' => (array)$request->input('keywords', []),
|
||||
'content_blocks' => (array)$request->input('content_blocks', []),
|
||||
'is_recommended' => (bool)$request->input('is_recommended', false),
|
||||
'is_enabled' => (bool)$request->input('is_enabled', true),
|
||||
'sort_order' => (int)$request->input('sort_order', 0),
|
||||
]);
|
||||
|
||||
return api_success(['id' => $id], '帮助文章已保存');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('帮助文章保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteHelpArticle(Request $request)
|
||||
{
|
||||
try {
|
||||
$this->service()->deleteHelpArticle((int)$request->input('id', 0));
|
||||
return api_success([], '帮助文章已删除');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('帮助文章删除失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function service(): ContentService
|
||||
{
|
||||
return new ContentService();
|
||||
}
|
||||
}
|
||||
393
server-api/app/controller/admin/CustomersController.php
Normal file
393
server-api/app/controller/admin/CustomersController.php
Normal file
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\EnterpriseCustomerService;
|
||||
use app\support\EnterpriseOrderService;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class CustomersController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
|
||||
$query = Db::name('enterprise_customers')->order('id', 'desc');
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->whereRaw(
|
||||
'(customer_code LIKE :keyword_code OR customer_name LIKE :keyword_name OR contact_name LIKE :keyword_contact OR contact_mobile LIKE :keyword_mobile)',
|
||||
[
|
||||
'keyword_code' => "%{$keyword}%",
|
||||
'keyword_name' => "%{$keyword}%",
|
||||
'keyword_contact' => "%{$keyword}%",
|
||||
'keyword_mobile' => "%{$keyword}%",
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
if (in_array($status, ['enabled', 'disabled'], true)) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
$customerIds = array_map(static fn(array $item) => (int)$item['id'], $rows);
|
||||
$appCountMap = $this->countMap('enterprise_customer_apps', $customerIds);
|
||||
$orderCountMap = $this->countMap('enterprise_customer_order_refs', $customerIds);
|
||||
$eventCountMap = $this->countMap('enterprise_order_events', $customerIds);
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(function (array $item) use ($appCountMap, $orderCountMap, $eventCountMap) {
|
||||
$customer = $this->customerService()->formatCustomer($item);
|
||||
$id = (int)$customer['id'];
|
||||
$customer['app_count'] = (int)($appCountMap[$id] ?? 0);
|
||||
$customer['order_count'] = (int)($orderCountMap[$id] ?? 0);
|
||||
$customer['event_count'] = (int)($eventCountMap[$id] ?? 0);
|
||||
return $customer;
|
||||
}, $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('客户 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
|
||||
if (!$customer) {
|
||||
return api_error('客户不存在', 404);
|
||||
}
|
||||
|
||||
$apps = Db::name('enterprise_customer_apps')
|
||||
->where('customer_id', $id)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'customer' => $this->customerService()->formatCustomer($customer),
|
||||
'apps' => array_map(fn(array $item) => $this->customerService()->formatApp($item), $apps),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$customerName = trim((string)$request->input('customer_name', ''));
|
||||
if ($customerName === '') {
|
||||
return api_error('客户名称不能为空', 422);
|
||||
}
|
||||
|
||||
$status = trim((string)$request->input('status', 'enabled'));
|
||||
if (!in_array($status, ['enabled', 'disabled'], true)) {
|
||||
return api_error('客户状态不正确', 422);
|
||||
}
|
||||
|
||||
$webhookUrl = trim((string)$request->input('webhook_url', ''));
|
||||
if ($webhookUrl !== '' && !preg_match('/^https?:\/\//i', $webhookUrl)) {
|
||||
return api_error('Webhook URL 必须以 http 或 https 开头', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$payload = [
|
||||
'customer_name' => $customerName,
|
||||
'contact_name' => trim((string)$request->input('contact_name', '')),
|
||||
'contact_mobile' => trim((string)$request->input('contact_mobile', '')),
|
||||
'contact_email' => trim((string)$request->input('contact_email', '')),
|
||||
'settlement_type' => 'monthly',
|
||||
'webhook_url' => $webhookUrl,
|
||||
'webhook_enabled' => $request->input('webhook_enabled', false) ? 1 : 0,
|
||||
'status' => $status,
|
||||
'remark' => trim((string)$request->input('remark', '')),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
|
||||
if (!$customer) {
|
||||
Db::rollback();
|
||||
return api_error('客户不存在', 404);
|
||||
}
|
||||
Db::name('enterprise_customers')->where('id', $id)->update($payload);
|
||||
} else {
|
||||
$payload['customer_code'] = $this->customerService()->generateCustomerCode();
|
||||
$payload['created_at'] = $now;
|
||||
$id = (int)Db::name('enterprise_customers')->insertGetId($payload);
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
|
||||
if ($customer) {
|
||||
$this->customerService()->ensureVirtualUser($customer);
|
||||
}
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('客户保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
], $request->input('id', 0) ? '客户已更新' : '客户已创建');
|
||||
}
|
||||
|
||||
public function createApp(Request $request)
|
||||
{
|
||||
$customerId = (int)$request->input('customer_id', 0);
|
||||
$appName = trim((string)$request->input('app_name', '默认应用'));
|
||||
if ($customerId <= 0) {
|
||||
return api_error('客户 ID 不能为空', 422);
|
||||
}
|
||||
if ($appName === '') {
|
||||
$appName = '默认应用';
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', $customerId)->find();
|
||||
if (!$customer) {
|
||||
return api_error('客户不存在', 404);
|
||||
}
|
||||
|
||||
$secret = $this->customerService()->generateAppSecret();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$appId = (int)Db::name('enterprise_customer_apps')->insertGetId([
|
||||
'customer_id' => $customerId,
|
||||
'app_name' => $appName,
|
||||
'app_key' => $this->customerService()->generateAppKey(),
|
||||
'app_secret_cipher' => $this->customerService()->encryptSecret($secret),
|
||||
'secret_last4' => substr($secret, -4),
|
||||
'status' => 'enabled',
|
||||
'last_used_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$app = Db::name('enterprise_customer_apps')->where('id', $appId)->find();
|
||||
return api_success([
|
||||
'app' => $this->customerService()->formatApp($app),
|
||||
'app_secret' => $secret,
|
||||
], '应用 Key 已创建,请立即复制保存 Secret');
|
||||
}
|
||||
|
||||
public function updateAppStatus(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
if ($id <= 0 || !in_array($status, ['enabled', 'disabled'], true)) {
|
||||
return api_error('应用 ID 或状态不正确', 422);
|
||||
}
|
||||
|
||||
$app = Db::name('enterprise_customer_apps')->where('id', $id)->find();
|
||||
if (!$app) {
|
||||
return api_error('应用不存在', 404);
|
||||
}
|
||||
|
||||
Db::name('enterprise_customer_apps')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'status' => $status,
|
||||
], $status === 'enabled' ? '应用已启用' : '应用已停用');
|
||||
}
|
||||
|
||||
public function resetAppSecret(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('应用 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$app = Db::name('enterprise_customer_apps')->where('id', $id)->find();
|
||||
if (!$app) {
|
||||
return api_error('应用不存在', 404);
|
||||
}
|
||||
|
||||
$secret = $this->customerService()->generateAppSecret();
|
||||
Db::name('enterprise_customer_apps')->where('id', $id)->update([
|
||||
'app_secret_cipher' => $this->customerService()->encryptSecret($secret),
|
||||
'secret_last4' => substr($secret, -4),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$fresh = Db::name('enterprise_customer_apps')->where('id', $id)->find();
|
||||
return api_success([
|
||||
'app' => $this->customerService()->formatApp($fresh),
|
||||
'app_secret' => $secret,
|
||||
], '应用 Secret 已重置,请立即复制保存');
|
||||
}
|
||||
|
||||
public function orders(Request $request)
|
||||
{
|
||||
$customerId = (int)$request->input('customer_id', 0);
|
||||
if ($customerId <= 0) {
|
||||
return api_error('客户 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$rows = Db::name('enterprise_customer_order_refs')
|
||||
->alias('r')
|
||||
->leftJoin('orders o', 'o.id = r.order_id')
|
||||
->leftJoin('order_products p', 'p.order_id = r.order_id')
|
||||
->field([
|
||||
'r.id',
|
||||
'r.customer_id',
|
||||
'r.external_order_no',
|
||||
'r.order_id',
|
||||
'r.order_no',
|
||||
'r.appraisal_no',
|
||||
'r.created_at',
|
||||
'o.order_status',
|
||||
'o.display_status',
|
||||
'o.pay_amount',
|
||||
'p.product_name',
|
||||
])
|
||||
->where('r.customer_id', $customerId)
|
||||
->order('r.id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(static fn(array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'customer_id' => (int)$item['customer_id'],
|
||||
'external_order_no' => (string)$item['external_order_no'],
|
||||
'order_id' => (int)$item['order_id'],
|
||||
'order_no' => (string)$item['order_no'],
|
||||
'appraisal_no' => (string)$item['appraisal_no'],
|
||||
'product_name' => (string)($item['product_name'] ?: '待完善物品信息'),
|
||||
'order_status' => (string)($item['order_status'] ?? ''),
|
||||
'display_status' => (string)($item['display_status'] ?? ''),
|
||||
'pay_amount' => (float)($item['pay_amount'] ?? 0),
|
||||
'created_at' => (string)$item['created_at'],
|
||||
], $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function events(Request $request)
|
||||
{
|
||||
$customerId = (int)$request->input('customer_id', 0);
|
||||
if ($customerId <= 0) {
|
||||
return api_error('客户 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$rows = Db::name('enterprise_order_events')
|
||||
->where('customer_id', $customerId)
|
||||
->order('id', 'desc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(fn(array $item) => $this->webhookService()->formatEvent($item), $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deliveries(Request $request)
|
||||
{
|
||||
$customerId = (int)$request->input('customer_id', 0);
|
||||
$eventId = (int)$request->input('event_id', 0);
|
||||
if ($customerId <= 0 && $eventId <= 0) {
|
||||
return api_error('客户 ID 或事件 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$query = Db::name('enterprise_webhook_deliveries')->order('id', 'desc')->limit(200);
|
||||
if ($customerId > 0) {
|
||||
$query->where('customer_id', $customerId);
|
||||
}
|
||||
if ($eventId > 0) {
|
||||
$query->where('event_id', $eventId);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(fn(array $item) => $this->webhookService()->formatDelivery($item), $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resendEvent(Request $request)
|
||||
{
|
||||
$eventId = (int)$request->input('event_id', 0);
|
||||
if ($eventId <= 0) {
|
||||
return api_error('事件 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->webhookService()->deliverEvent($eventId, true);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('事件补发失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'delivery' => $this->webhookService()->formatDelivery($result['delivery']),
|
||||
'sent' => (bool)$result['sent'],
|
||||
], $result['sent'] ? '事件已补发成功' : '事件补发未成功,请查看推送记录');
|
||||
}
|
||||
|
||||
public function orderProgress(Request $request)
|
||||
{
|
||||
$customerId = (int)$request->input('customer_id', 0);
|
||||
$externalOrderNo = trim((string)$request->input('external_order_no', ''));
|
||||
if ($customerId <= 0 || $externalOrderNo === '') {
|
||||
return api_error('客户 ID 和外部订单号不能为空', 422);
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', $customerId)->find();
|
||||
if (!$customer) {
|
||||
return api_error('客户不存在', 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$order = (new EnterpriseOrderService())->findOrder($customer, $externalOrderNo, '');
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 404);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
private function countMap(string $table, array $customerIds): array
|
||||
{
|
||||
if (!$customerIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name($table)
|
||||
->field('customer_id, COUNT(*) AS total')
|
||||
->whereIn('customer_id', $customerIds)
|
||||
->group('customer_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['customer_id']] = (int)$row['total'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function customerService(): EnterpriseCustomerService
|
||||
{
|
||||
return new EnterpriseCustomerService();
|
||||
}
|
||||
|
||||
private function webhookService(): EnterpriseWebhookService
|
||||
{
|
||||
return new EnterpriseWebhookService();
|
||||
}
|
||||
}
|
||||
66
server-api/app/controller/admin/DashboardController.php
Normal file
66
server-api/app/controller/admin/DashboardController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class DashboardController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$totalOrders = (int)Db::name('orders')->count();
|
||||
$pendingCount = (int)Db::name('orders')->whereIn('order_status', [
|
||||
'pending_payment',
|
||||
'pending_submission',
|
||||
'pending_shipping',
|
||||
'pending_supplement',
|
||||
])->count();
|
||||
$processingCount = (int)Db::name('orders')->whereIn('order_status', [
|
||||
'received',
|
||||
'pending_assignment',
|
||||
'in_first_review',
|
||||
'in_final_review',
|
||||
'generating_report',
|
||||
])->count();
|
||||
$pendingReturnCount = (int)Db::name('orders')->where('order_status', 'report_published')->count();
|
||||
$returningCount = (int)Db::name('orders')
|
||||
->alias('o')
|
||||
->join('order_logistics l', 'l.order_id = o.id')
|
||||
->where('o.order_status', 'completed')
|
||||
->where('l.logistics_type', 'return_to_user')
|
||||
->where('l.tracking_no', '<>', '')
|
||||
->where('l.tracking_status', '<>', 'received')
|
||||
->count();
|
||||
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '订单总量',
|
||||
'value' => $totalOrders,
|
||||
'desc' => '当前数据库内订单总数',
|
||||
],
|
||||
[
|
||||
'title' => '待处理订单',
|
||||
'value' => $pendingCount,
|
||||
'desc' => '待支付、待补资料、待寄送等订单',
|
||||
],
|
||||
[
|
||||
'title' => '处理中订单',
|
||||
'value' => $processingCount,
|
||||
'desc' => '已进入鉴定流程的订单',
|
||||
],
|
||||
[
|
||||
'title' => '待寄回订单',
|
||||
'value' => $pendingReturnCount,
|
||||
'desc' => '报告已出具,等待平台安排回寄',
|
||||
],
|
||||
[
|
||||
'title' => '回寄途中',
|
||||
'value' => $returningCount,
|
||||
'desc' => '已登记回寄运单,等待用户签收',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
server-api/app/controller/admin/MaterialsController.php
Normal file
80
server-api/app/controller/admin/MaterialsController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\MaterialTagService;
|
||||
use support\Request;
|
||||
|
||||
class MaterialsController
|
||||
{
|
||||
public function batches(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'list' => $this->service()->listBatches([
|
||||
'keyword' => $request->input('keyword', ''),
|
||||
'qr_url' => $request->input('qr_url', ''),
|
||||
'verify_code' => $request->input('verify_code', ''),
|
||||
'date_start' => $request->input('date_start', ''),
|
||||
'date_end' => $request->input('date_end', ''),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('物料批次 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return api_success($this->service()->detail($id, trim((string)$request->input('keyword', ''))));
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||
}
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$count = (int)$request->input('count', 0);
|
||||
$remark = trim((string)$request->input('remark', ''));
|
||||
$adminId = (int)$request->header('x-admin-id', 0);
|
||||
$adminName = trim((string)$request->header('x-admin-name', ''));
|
||||
|
||||
try {
|
||||
return api_success($this->service()->createBatch($count, $remark, $adminId, $adminName), '物料批次已生成');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('物料批次生成失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function download(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('物料批次 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$file = $this->service()->downloadBatch($id, $request);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 500);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$filename = rawurlencode($file['filename']);
|
||||
return response($file['content'], 200, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition' => "attachment; filename=\"{$file['filename']}\"; filename*=UTF-8''{$filename}",
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
private function service(): MaterialTagService
|
||||
{
|
||||
return new MaterialTagService();
|
||||
}
|
||||
}
|
||||
165
server-api/app/controller/admin/MessagesController.php
Normal file
165
server-api/app/controller/admin/MessagesController.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class MessagesController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '启用模板',
|
||||
'value' => (int)Db::name('message_templates')->where('is_enabled', 1)->count(),
|
||||
'desc' => '当前已启用的消息模板数量',
|
||||
],
|
||||
[
|
||||
'title' => '触发规则',
|
||||
'value' => (int)Db::name('message_rules')->where('is_enabled', 1)->count(),
|
||||
'desc' => '当前启用的消息触发规则数量',
|
||||
],
|
||||
[
|
||||
'title' => '发送日志',
|
||||
'value' => (int)Db::name('message_logs')->count(),
|
||||
'desc' => '消息发送记录总数',
|
||||
],
|
||||
[
|
||||
'title' => '站内消息',
|
||||
'value' => (int)Db::name('user_messages')->count(),
|
||||
'desc' => '当前已生成的站内消息数量',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function templates(Request $request)
|
||||
{
|
||||
$rows = Db::name('message_templates')
|
||||
->field([
|
||||
'id',
|
||||
'template_name',
|
||||
'template_code',
|
||||
'channel',
|
||||
'event_code',
|
||||
'title',
|
||||
'content',
|
||||
'is_enabled',
|
||||
])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'template_name' => $item['template_name'],
|
||||
'template_code' => $item['template_code'],
|
||||
'channel' => $item['channel'],
|
||||
'channel_text' => $this->channelText($item['channel']),
|
||||
'event_code' => $item['event_code'],
|
||||
'title' => $item['title'],
|
||||
'content' => $item['content'],
|
||||
'is_enabled' => (bool)$item['is_enabled'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function saveTemplate(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$templateName = trim((string)$request->input('template_name', ''));
|
||||
$templateCode = trim((string)$request->input('template_code', ''));
|
||||
$channel = trim((string)$request->input('channel', ''));
|
||||
$eventCode = trim((string)$request->input('event_code', ''));
|
||||
|
||||
if ($templateName === '' || $templateCode === '' || $channel === '' || $eventCode === '') {
|
||||
return api_error('模板名称、模板编码、发送渠道和触发事件不能为空', 422);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'template_name' => $templateName,
|
||||
'template_code' => $templateCode,
|
||||
'channel' => $channel,
|
||||
'event_code' => $eventCode,
|
||||
'title' => trim((string)$request->input('title', '')),
|
||||
'content' => trim((string)$request->input('content', '')),
|
||||
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('message_templates')->where('id', $id)->update($payload);
|
||||
return api_success(['id' => $id], '更新成功');
|
||||
}
|
||||
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
$newId = Db::name('message_templates')->insertGetId($payload);
|
||||
return api_success(['id' => (int)$newId], '创建成功');
|
||||
}
|
||||
|
||||
public function logs(Request $request)
|
||||
{
|
||||
$rows = Db::name('message_logs')
|
||||
->field([
|
||||
'id',
|
||||
'user_id',
|
||||
'template_id',
|
||||
'biz_type',
|
||||
'biz_id',
|
||||
'channel',
|
||||
'status',
|
||||
'fail_reason',
|
||||
'sent_at',
|
||||
'created_at',
|
||||
])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$templates = Db::name('message_templates')->column('template_name', 'id');
|
||||
|
||||
$list = array_map(function (array $item) use ($templates) {
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'user_id' => (int)($item['user_id'] ?? 0),
|
||||
'template_name' => $templates[$item['template_id']] ?? '未知模板',
|
||||
'biz_type' => $item['biz_type'],
|
||||
'biz_id' => (int)($item['biz_id'] ?? 0),
|
||||
'channel' => $item['channel'],
|
||||
'channel_text' => $this->channelText($item['channel']),
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->logStatusText($item['status']),
|
||||
'fail_reason' => $item['fail_reason'] ?: '',
|
||||
'sent_at' => $item['sent_at'],
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
private function channelText(string $channel): string
|
||||
{
|
||||
return match ($channel) {
|
||||
'inbox' => '站内消息',
|
||||
'sms' => '短信',
|
||||
'wechat_subscribe' => '微信订阅消息',
|
||||
default => $channel,
|
||||
};
|
||||
}
|
||||
|
||||
private function logStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'pending' => '待发送',
|
||||
'sent' => '已发送',
|
||||
'failed' => '发送失败',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
}
|
||||
952
server-api/app/controller/admin/OrdersController.php
Normal file
952
server-api/app/controller/admin/OrdersController.php
Normal file
@@ -0,0 +1,952 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class OrdersController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
|
||||
|
||||
$query = Db::name('orders')
|
||||
->alias('o')
|
||||
->leftJoin('order_products p', 'p.order_id = o.id')
|
||||
->field([
|
||||
'o.id',
|
||||
'o.order_no',
|
||||
'o.appraisal_no',
|
||||
'o.service_provider',
|
||||
'o.order_status',
|
||||
'o.display_status',
|
||||
'o.estimated_finish_time',
|
||||
'o.source_channel',
|
||||
'o.source_customer_id',
|
||||
'o.pay_amount',
|
||||
'o.created_at',
|
||||
'p.product_name',
|
||||
'p.category_name',
|
||||
'p.brand_name',
|
||||
])
|
||||
->order('o.id', 'desc');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->whereRaw(
|
||||
'(o.order_no LIKE :keyword_order OR o.appraisal_no LIKE :keyword_appraisal OR p.product_name LIKE :keyword_product)',
|
||||
[
|
||||
'keyword_order' => "%{$keyword}%",
|
||||
'keyword_appraisal' => "%{$keyword}%",
|
||||
'keyword_product' => "%{$keyword}%",
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
$specialStatusFilters = ['returning', 'completed_signed'];
|
||||
if ($status !== '' && !in_array($status, $specialStatusFilters, true)) {
|
||||
$query->where('o.order_status', $status);
|
||||
}
|
||||
|
||||
if ($serviceProvider !== '') {
|
||||
$query->where('o.service_provider', $serviceProvider);
|
||||
}
|
||||
|
||||
if ($sourceChannel !== '') {
|
||||
$query->where('o.source_channel', $sourceChannel);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
$returnTrackingMap = [];
|
||||
if ($rows) {
|
||||
$returnRows = Db::name('order_logistics')
|
||||
->whereIn('order_id', array_column($rows, 'id'))
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($returnRows as $row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) {
|
||||
$returnTrackingMap[$orderId] = [
|
||||
'tracking_no' => (string)($row['tracking_no'] ?? ''),
|
||||
'tracking_status' => (string)($row['tracking_status'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$list = array_map(function (array $item) use ($returnTrackingMap) {
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'order_no' => $item['order_no'],
|
||||
'appraisal_no' => $item['appraisal_no'],
|
||||
'product_name' => $item['product_name'] ?: '待完善物品信息',
|
||||
'category_name' => $item['category_name'] ?: '',
|
||||
'brand_name' => $item['brand_name'] ?: '',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($item['source_channel'] ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($item['source_channel'] ?? '')),
|
||||
'source_customer_id' => (string)($item['source_customer_id'] ?? ''),
|
||||
'order_status' => $item['order_status'],
|
||||
'display_status' => $this->displayStatus(
|
||||
(string)$item['order_status'],
|
||||
(string)$item['display_status'],
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
|
||||
),
|
||||
'estimated_finish_time' => $item['estimated_finish_time'],
|
||||
'pay_amount' => (float)$item['pay_amount'],
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
if ($status === 'returning') {
|
||||
$list = array_values(array_filter($list, function (array $item) {
|
||||
return $item['order_status'] === 'completed' && $item['display_status'] === '物品已寄回';
|
||||
}));
|
||||
}
|
||||
|
||||
if ($status === 'completed_signed') {
|
||||
$list = array_values(array_filter($list, function (array $item) {
|
||||
return $item['order_status'] === 'completed' && $item['display_status'] === '已完成';
|
||||
}));
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'list' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if (!$id) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$product = Db::name('order_products')->where('order_id', $id)->find();
|
||||
$extra = Db::name('order_extras')->where('order_id', $id)->find();
|
||||
$sendLogistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$returnLogistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$timeline = Db::name('order_timelines')
|
||||
->where('order_id', $id)
|
||||
->order('occurred_at', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$timeline = array_map(fn (array $item) => [
|
||||
'node_text' => $item['node_text'],
|
||||
'node_desc' => $item['node_desc'],
|
||||
'occurred_at' => $item['occurred_at'],
|
||||
], $timeline);
|
||||
|
||||
$supplement = Db::name('order_supplement_tasks')->where('order_id', $id)->order('id', 'desc')->find();
|
||||
$supplementItems = [];
|
||||
if ($supplement) {
|
||||
$supplementItems = Db::name('order_supplement_task_items')
|
||||
->where('task_id', $supplement['id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$supplementItems = array_map(fn (array $item) => [
|
||||
'item_name' => $item['item_name'],
|
||||
'guide_text' => $item['guide_text'],
|
||||
], $supplementItems);
|
||||
}
|
||||
|
||||
$report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find();
|
||||
$hasPublishedOrderReport = $report && ($report['report_status'] ?? '') === 'published';
|
||||
$canAttemptReturnLogistics = in_array($order['order_status'], ['report_published', 'completed'], true)
|
||||
&& (($returnLogistics['tracking_status'] ?? '') !== 'received');
|
||||
$shippingTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find();
|
||||
$returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
|
||||
if (!$returnAddress) {
|
||||
$returnAddress = Db::name('user_addresses')
|
||||
->where('user_id', (int)$order['user_id'])
|
||||
->where('is_default', 1)
|
||||
->order('id', 'desc')
|
||||
->find()
|
||||
?: Db::name('user_addresses')
|
||||
->where('user_id', (int)$order['user_id'])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($returnAddress) {
|
||||
$returnAddress = [
|
||||
'user_address_id' => (int)$returnAddress['id'],
|
||||
'consignee' => $returnAddress['consignee'],
|
||||
'mobile' => $returnAddress['mobile'],
|
||||
'province' => $returnAddress['province'],
|
||||
'city' => $returnAddress['city'],
|
||||
'district' => $returnAddress['district'],
|
||||
'detail_address' => $returnAddress['detail_address'],
|
||||
];
|
||||
}
|
||||
}
|
||||
$logisticsNodes = [];
|
||||
if ($sendLogistics) {
|
||||
$logisticsNodes = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $sendLogistics['id'])
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$returnLogisticsNodes = [];
|
||||
if ($returnLogistics) {
|
||||
$returnLogisticsNodes = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $returnLogistics['id'])
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_info' => [
|
||||
'id' => (int)$order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'appraisal_no' => $order['appraisal_no'],
|
||||
'service_provider' => $order['service_provider'],
|
||||
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($order['source_channel'] ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
|
||||
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
|
||||
'order_status' => $order['order_status'],
|
||||
'display_status' => $this->displayStatus(
|
||||
(string)$order['order_status'],
|
||||
(string)$order['display_status'],
|
||||
$returnLogistics['tracking_no'] ?? '',
|
||||
$returnLogistics['tracking_status'] ?? '',
|
||||
),
|
||||
'pay_amount' => (float)$order['pay_amount'],
|
||||
'estimated_finish_time' => $order['estimated_finish_time'],
|
||||
'created_at' => $order['created_at'],
|
||||
'can_reassign_warehouse' => $order['order_status'] === 'pending_shipping' && empty($sendLogistics['tracking_no']),
|
||||
'can_mark_received' => $order['order_status'] === 'pending_shipping'
|
||||
&& (!empty($sendLogistics['tracking_no']) || ($order['source_channel'] ?? '') === 'enterprise_push'),
|
||||
'can_submit_return_logistics' => $hasPublishedOrderReport && $canAttemptReturnLogistics,
|
||||
'return_logistics_block_reason' => (!$hasPublishedOrderReport && $canAttemptReturnLogistics)
|
||||
? '订单报告未发布前,物品不允许寄回'
|
||||
: '',
|
||||
'can_mark_return_received' => $order['order_status'] === 'completed' && !empty($returnLogistics['tracking_no']) && ($returnLogistics['tracking_status'] ?? '') !== 'received',
|
||||
],
|
||||
'product_info' => [
|
||||
'product_name' => $product['product_name'] ?? '',
|
||||
'category_id' => (int)($product['category_id'] ?? 0),
|
||||
'category_name' => $product['category_name'] ?? '',
|
||||
'brand_id' => (int)($product['brand_id'] ?? 0),
|
||||
'brand_name' => $product['brand_name'] ?? '',
|
||||
'color' => $product['color'] ?? '',
|
||||
'size_spec' => $product['size_spec'] ?? '',
|
||||
'serial_no' => $product['serial_no'] ?? '',
|
||||
],
|
||||
'extra_info' => [
|
||||
'purchase_channel' => $extra['purchase_channel'] ?? '',
|
||||
'purchase_price' => (float)($extra['purchase_price'] ?? 0),
|
||||
'usage_status' => $extra['usage_status'] ?? '',
|
||||
'condition_desc' => $extra['condition_desc'] ?? '',
|
||||
'remark' => $extra['remark'] ?? '',
|
||||
],
|
||||
'shipping_target' => $shippingTarget ? [
|
||||
'warehouse_id' => (int)($shippingTarget['warehouse_id'] ?? 0),
|
||||
'warehouse_name' => $shippingTarget['warehouse_name'],
|
||||
'warehouse_code' => $shippingTarget['warehouse_code'],
|
||||
'receiver_name' => $shippingTarget['receiver_name'],
|
||||
'receiver_mobile' => $shippingTarget['receiver_mobile'],
|
||||
'full_address' => trim(sprintf(
|
||||
'%s%s%s%s',
|
||||
$shippingTarget['province'] ?? '',
|
||||
$shippingTarget['city'] ?? '',
|
||||
$shippingTarget['district'] ?? '',
|
||||
$shippingTarget['detail_address'] ?? ''
|
||||
)),
|
||||
'service_time' => $shippingTarget['service_time'],
|
||||
'notice' => $shippingTarget['notice'],
|
||||
] : null,
|
||||
'return_address' => $returnAddress ? [
|
||||
'user_address_id' => (int)($returnAddress['user_address_id'] ?? 0),
|
||||
'consignee' => $returnAddress['consignee'],
|
||||
'mobile' => $returnAddress['mobile'],
|
||||
'full_address' => trim(sprintf(
|
||||
'%s%s%s%s',
|
||||
$returnAddress['province'] ?? '',
|
||||
$returnAddress['city'] ?? '',
|
||||
$returnAddress['district'] ?? '',
|
||||
$returnAddress['detail_address'] ?? ''
|
||||
)),
|
||||
] : null,
|
||||
'timeline' => $timeline,
|
||||
'logistics_info' => $sendLogistics ? [
|
||||
'express_company' => $sendLogistics['express_company'],
|
||||
'tracking_no' => $sendLogistics['tracking_no'],
|
||||
'tracking_status' => $sendLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'),
|
||||
'latest_desc' => $this->formatAdminLogisticsDesc(
|
||||
'send_to_center',
|
||||
$sendLogistics['tracking_status'],
|
||||
$sendLogistics['express_company'],
|
||||
$sendLogistics['tracking_no'],
|
||||
$sendLogistics['latest_desc']
|
||||
),
|
||||
'latest_time' => $sendLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $this->formatAdminLogisticsDesc(
|
||||
'send_to_center',
|
||||
$sendLogistics['tracking_status'],
|
||||
$sendLogistics['express_company'],
|
||||
$sendLogistics['tracking_no'],
|
||||
$item['node_desc']
|
||||
),
|
||||
'node_location' => $item['node_location'],
|
||||
], $logisticsNodes),
|
||||
] : null,
|
||||
'return_logistics' => $returnLogistics ? [
|
||||
'express_company' => $returnLogistics['express_company'],
|
||||
'tracking_no' => $returnLogistics['tracking_no'],
|
||||
'tracking_status' => $returnLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'),
|
||||
'latest_desc' => $this->formatAdminLogisticsDesc(
|
||||
'return_to_user',
|
||||
$returnLogistics['tracking_status'],
|
||||
$returnLogistics['express_company'],
|
||||
$returnLogistics['tracking_no'],
|
||||
$returnLogistics['latest_desc']
|
||||
),
|
||||
'latest_time' => $returnLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $this->formatAdminLogisticsDesc(
|
||||
'return_to_user',
|
||||
$returnLogistics['tracking_status'],
|
||||
$returnLogistics['express_company'],
|
||||
$returnLogistics['tracking_no'],
|
||||
$item['node_desc']
|
||||
),
|
||||
'node_location' => $item['node_location'],
|
||||
], $returnLogisticsNodes),
|
||||
] : null,
|
||||
'supplement_task' => $supplement ? [
|
||||
'reason' => $supplement['reason'],
|
||||
'deadline' => $supplement['deadline'],
|
||||
'status' => $supplement['status'],
|
||||
'items' => $supplementItems,
|
||||
] : null,
|
||||
'report_summary' => $report ? [
|
||||
'report_no' => $report['report_no'],
|
||||
'report_title' => $report['report_title'],
|
||||
'report_status' => $report['report_status'],
|
||||
'publish_time' => $report['publish_time'],
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function warehouseOptions(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$product = Db::name('order_products')->where('order_id', $id)->find();
|
||||
$options = (new WarehouseService())->optionsForOrder(
|
||||
(string)($order['service_provider'] ?? 'anxinyan'),
|
||||
!empty($product['category_id']) ? (int)$product['category_id'] : null
|
||||
);
|
||||
|
||||
return api_success([
|
||||
'list' => $options,
|
||||
]);
|
||||
}
|
||||
|
||||
public function reassignWarehouse(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$warehouseId = (int)$request->input('warehouse_id', 0);
|
||||
if ($id <= 0 || $warehouseId <= 0) {
|
||||
return api_error('订单 ID 和仓库 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$logistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($order['order_status'] !== 'pending_shipping' || !empty($logistics['tracking_no'])) {
|
||||
return api_error('当前订单已进入寄送流程,暂不支持改派仓库', 422);
|
||||
}
|
||||
|
||||
$warehouse = Db::name('shipping_warehouses')
|
||||
->where('id', $warehouseId)
|
||||
->where('status', 'enabled')
|
||||
->find();
|
||||
if (!$warehouse) {
|
||||
return api_error('目标仓库不存在或已停用', 404);
|
||||
}
|
||||
|
||||
$product = Db::name('order_products')->where('order_id', $id)->find();
|
||||
$categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null;
|
||||
$allowedWarehouses = (new WarehouseService())->optionsForOrder((string)$order['service_provider'], $categoryId);
|
||||
$allowedIds = array_column($allowedWarehouses, 'id');
|
||||
if (!in_array($warehouseId, $allowedIds, true)) {
|
||||
return api_error('目标仓库不适用于当前订单服务类型或品类', 422);
|
||||
}
|
||||
|
||||
$currentTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find();
|
||||
if ($currentTarget && (int)($currentTarget['warehouse_id'] ?? 0) === $warehouseId) {
|
||||
return api_error('当前订单已绑定该仓库,无需重复改派', 422);
|
||||
}
|
||||
|
||||
$snapshot = [
|
||||
'warehouse_id' => (int)$warehouse['id'],
|
||||
'warehouse_name' => $warehouse['warehouse_name'],
|
||||
'warehouse_code' => $warehouse['warehouse_code'],
|
||||
'receiver_name' => $warehouse['receiver_name'],
|
||||
'receiver_mobile' => $warehouse['receiver_mobile'],
|
||||
'province' => $warehouse['province'],
|
||||
'city' => $warehouse['city'],
|
||||
'district' => $warehouse['district'],
|
||||
'detail_address' => $warehouse['detail_address'],
|
||||
'service_time' => $warehouse['service_time'],
|
||||
'notice' => $warehouse['notice'],
|
||||
];
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::startTrans();
|
||||
try {
|
||||
(new WarehouseService())->bindOrderTarget($id, (string)$order['service_provider'], $categoryId);
|
||||
Db::name('order_shipping_targets')->where('order_id', $id)->update([
|
||||
'warehouse_id' => $snapshot['warehouse_id'],
|
||||
'warehouse_name' => $snapshot['warehouse_name'],
|
||||
'warehouse_code' => $snapshot['warehouse_code'],
|
||||
'service_provider' => $order['service_provider'],
|
||||
'receiver_name' => $snapshot['receiver_name'],
|
||||
'receiver_mobile' => $snapshot['receiver_mobile'],
|
||||
'province' => $snapshot['province'],
|
||||
'city' => $snapshot['city'],
|
||||
'district' => $snapshot['district'],
|
||||
'detail_address' => $snapshot['detail_address'],
|
||||
'service_time' => $snapshot['service_time'],
|
||||
'notice' => $snapshot['notice'],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $id,
|
||||
'node_code' => 'warehouse_reassigned',
|
||||
'node_text' => '仓库已改派',
|
||||
'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']),
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => 1,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('仓库改派失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'warehouse_id' => $snapshot['warehouse_id'],
|
||||
'warehouse_name' => $snapshot['warehouse_name'],
|
||||
], '仓库已改派');
|
||||
}
|
||||
|
||||
public function receiveLogistics(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$logistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$allowEnterpriseManualReceive = ($order['source_channel'] ?? '') === 'enterprise_push';
|
||||
if ((!$logistics || $logistics['tracking_no'] === '') && !$allowEnterpriseManualReceive) {
|
||||
return api_error('当前订单还没有有效运单信息', 422);
|
||||
}
|
||||
|
||||
if ($order['order_status'] !== 'pending_shipping') {
|
||||
return api_error('当前订单状态不支持标记签收', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$latestDesc = '鉴定中心已签收包裹,等待鉴定师开始处理。';
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($logistics) {
|
||||
Db::name('order_logistics')->where('id', $logistics['id'])->update([
|
||||
'tracking_status' => 'received',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$logisticsId = (int)$logistics['id'];
|
||||
} else {
|
||||
$latestDesc = '大客户推送订单已确认到仓,等待鉴定师开始处理。';
|
||||
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
|
||||
'order_id' => $id,
|
||||
'logistics_type' => 'send_to_center',
|
||||
'express_company' => '',
|
||||
'tracking_no' => '',
|
||||
'tracking_status' => 'received',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $now,
|
||||
'node_desc' => $latestDesc,
|
||||
'node_location' => '鉴定中心',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('orders')->where('id', $id)->update([
|
||||
'order_status' => 'in_first_review',
|
||||
'display_status' => '鉴定中',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$taskUpdate = [
|
||||
'status' => 'processing',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$task = Db::name('appraisal_tasks')
|
||||
->where('order_id', $id)
|
||||
->where('task_stage', 'first_review')
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
if ($task && empty($task['started_at'])) {
|
||||
$taskUpdate['started_at'] = $now;
|
||||
}
|
||||
if ($task) {
|
||||
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate);
|
||||
}
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $id,
|
||||
'node_code' => 'first_review',
|
||||
'node_text' => '鉴定中',
|
||||
'node_desc' => $logistics
|
||||
? '包裹已由鉴定中心签收,订单已进入鉴定流程'
|
||||
: '大客户推送订单已确认到仓,订单已进入鉴定流程',
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => 1,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('标记签收失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
(new EnterpriseWebhookService())->recordOrderEvent($id, 'inbound_received', [
|
||||
'express_company' => (string)($logistics['express_company'] ?? ''),
|
||||
'tracking_no' => (string)($logistics['tracking_no'] ?? ''),
|
||||
'received_at' => $now,
|
||||
]);
|
||||
|
||||
return api_success(['id' => $id], '已标记鉴定中心签收');
|
||||
}
|
||||
|
||||
public function saveReturnLogistics(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$expressCompany = trim((string)$request->input('express_company', ''));
|
||||
$trackingNo = trim((string)$request->input('tracking_no', ''));
|
||||
|
||||
if ($id <= 0 || $expressCompany === '' || $trackingNo === '') {
|
||||
return api_error('订单、快递公司和运单号不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
if (!in_array($order['order_status'], ['report_published', 'completed'], true)) {
|
||||
return api_error('当前订单状态不支持登记回寄运单', 422);
|
||||
}
|
||||
|
||||
$report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find();
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
return api_error('订单报告未发布前,物品不允许寄回', 422);
|
||||
}
|
||||
|
||||
$returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
|
||||
if (!$returnAddress) {
|
||||
$fallbackAddress = Db::name('user_addresses')
|
||||
->where('user_id', (int)$order['user_id'])
|
||||
->where('is_default', 1)
|
||||
->order('id', 'desc')
|
||||
->find()
|
||||
?: Db::name('user_addresses')
|
||||
->where('user_id', (int)$order['user_id'])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$fallbackAddress) {
|
||||
return api_error('当前订单尚未确认寄回地址,且用户账户下没有可用地址', 422);
|
||||
}
|
||||
|
||||
$returnAddress = [
|
||||
'user_address_id' => (int)$fallbackAddress['id'],
|
||||
'consignee' => $fallbackAddress['consignee'],
|
||||
'mobile' => $fallbackAddress['mobile'],
|
||||
'province' => $fallbackAddress['province'],
|
||||
'city' => $fallbackAddress['city'],
|
||||
'district' => $fallbackAddress['district'],
|
||||
'detail_address' => $fallbackAddress['detail_address'],
|
||||
];
|
||||
}
|
||||
|
||||
$existing = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$existingReturnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
|
||||
if (!$existingReturnAddress) {
|
||||
Db::name('order_return_addresses')->insert([
|
||||
'order_id' => $id,
|
||||
'user_address_id' => $returnAddress['user_address_id'] ?? null,
|
||||
'consignee' => $returnAddress['consignee'] ?? '',
|
||||
'mobile' => $returnAddress['mobile'] ?? '',
|
||||
'province' => $returnAddress['province'] ?? '',
|
||||
'city' => $returnAddress['city'] ?? '',
|
||||
'district' => $returnAddress['district'] ?? '',
|
||||
'detail_address' => $returnAddress['detail_address'] ?? '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
Db::name('order_logistics')->where('id', $existing['id'])->update([
|
||||
'logistics_type' => 'return_to_user',
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'tracking_status' => 'in_transit',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$logisticsId = (int)$existing['id'];
|
||||
$nodeText = '已更新回寄运单';
|
||||
$nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo);
|
||||
} else {
|
||||
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
|
||||
'order_id' => $id,
|
||||
'logistics_type' => 'return_to_user',
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'tracking_status' => 'in_transit',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$nodeText = '已寄回用户';
|
||||
$nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo);
|
||||
}
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $now,
|
||||
'node_desc' => $latestDesc,
|
||||
'node_location' => $returnAddress['city'] ?? '用户地址',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('orders')->where('id', $id)->update([
|
||||
'order_status' => 'completed',
|
||||
'display_status' => '物品已寄回',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $id,
|
||||
'node_code' => 'return_shipped',
|
||||
'node_text' => $nodeText,
|
||||
'node_desc' => $nodeDesc,
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => 1,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
|
||||
'user_id' => (int)($order['user_id'] ?? 0),
|
||||
'biz_type' => 'return_shipped',
|
||||
'biz_id' => $id,
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'fallback_title' => '鉴定物品已寄回',
|
||||
'fallback_content' => sprintf('平台已通过%s回寄鉴定物品,运单号 %s,可前往订单详情查看物流进度。', $expressCompany, $trackingNo),
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('回寄运单登记失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
(new EnterpriseWebhookService())->recordOrderEvent($id, 'return_shipped', [
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'shipped_at' => $now,
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
], '回寄运单已登记');
|
||||
}
|
||||
|
||||
public function receiveReturnLogistics(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$logistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$logistics || $logistics['tracking_no'] === '') {
|
||||
return api_error('当前订单还没有有效回寄运单', 422);
|
||||
}
|
||||
if (($logistics['tracking_status'] ?? '') === 'received') {
|
||||
return api_error('当前订单已标记用户签收,无需重复操作', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$latestDesc = '用户已签收回寄商品,本次订单已完成。';
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('order_logistics')->where('id', $logistics['id'])->update([
|
||||
'tracking_status' => 'received',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logistics['id'],
|
||||
'node_time' => $now,
|
||||
'node_desc' => $latestDesc,
|
||||
'node_location' => '用户地址',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('orders')->where('id', $id)->update([
|
||||
'order_status' => 'completed',
|
||||
'display_status' => '已完成',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $id,
|
||||
'node_code' => 'return_received',
|
||||
'node_text' => '用户已签收',
|
||||
'node_desc' => '回寄商品已由用户签收,本次订单已完成。',
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => 1,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('return_received', [
|
||||
'user_id' => (int)($order['user_id'] ?? 0),
|
||||
'biz_type' => 'return_received',
|
||||
'biz_id' => $id,
|
||||
'fallback_title' => '回寄商品已签收',
|
||||
'fallback_content' => '系统已确认您签收回寄商品,本次鉴定订单已完成。',
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('标记用户签收失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
(new EnterpriseWebhookService())->recordOrderEvent($id, 'completed', [
|
||||
'express_company' => (string)($logistics['express_company'] ?? ''),
|
||||
'tracking_no' => (string)($logistics['tracking_no'] ?? ''),
|
||||
'completed_at' => $now,
|
||||
]);
|
||||
|
||||
return api_success(['id' => $id], '已标记用户签收');
|
||||
}
|
||||
|
||||
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
||||
{
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
return match ($status) {
|
||||
'submitted' => '已登记回寄运单',
|
||||
'in_transit' => '回寄途中',
|
||||
'received' => '用户已签收',
|
||||
default => $status === '' ? '待回寄' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
'submitted' => '用户已提交运单',
|
||||
'in_transit' => '用户已寄出,运输中',
|
||||
'received' => '鉴定中心已签收',
|
||||
default => $status === '' ? '待提交' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||
{
|
||||
if ($orderStatus === 'report_published') {
|
||||
return '待寄回';
|
||||
}
|
||||
|
||||
if ($orderStatus === 'completed') {
|
||||
if ($returnTrackingStatus === 'received') {
|
||||
return '已完成';
|
||||
}
|
||||
if ($returnTrackingNo !== '') {
|
||||
return '物品已寄回';
|
||||
}
|
||||
}
|
||||
|
||||
return $displayStatus;
|
||||
}
|
||||
|
||||
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
||||
{
|
||||
$sourceChannel = trim($sourceChannel);
|
||||
$aliases = [
|
||||
'wechat_mini_program' => 'mini_program',
|
||||
'weixin_mini_program' => 'mini_program',
|
||||
'mp_weixin' => 'mini_program',
|
||||
'miniapp' => 'mini_program',
|
||||
'user_app' => 'mini_program',
|
||||
'web_h5' => 'h5',
|
||||
'enterprise' => 'enterprise_push',
|
||||
'enterprise_order' => 'enterprise_push',
|
||||
'customer_push' => 'enterprise_push',
|
||||
'large_customer_push' => 'enterprise_push',
|
||||
];
|
||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
||||
}
|
||||
|
||||
private function sourceChannelText(string $sourceChannel): string
|
||||
{
|
||||
return match ($this->normalizeOrderSourceChannel($sourceChannel)) {
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
default => '未知渠道',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatAdminLogisticsDesc(string $logisticsType, string $status, string $expressCompany, string $trackingNo, string $fallback): string
|
||||
{
|
||||
$expressCompany = trim($expressCompany);
|
||||
$trackingNo = trim($trackingNo);
|
||||
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
if (in_array($status, ['submitted', 'in_transit'], true) && $expressCompany !== '' && $trackingNo !== '') {
|
||||
return sprintf('平台已登记回寄运单:%s %s,商品正在回寄途中。', $expressCompany, $trackingNo);
|
||||
}
|
||||
|
||||
if ($status === 'received') {
|
||||
return '用户已签收回寄商品,订单已完成。';
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if ($status === 'submitted' && $expressCompany !== '' && $trackingNo !== '') {
|
||||
return sprintf('用户已提交寄送运单:%s %s,等待鉴定中心签收。', $expressCompany, $trackingNo);
|
||||
}
|
||||
|
||||
if ($status === 'in_transit' && $expressCompany !== '' && $trackingNo !== '') {
|
||||
return sprintf('用户已寄出商品:%s %s,当前运输中。', $expressCompany, $trackingNo);
|
||||
}
|
||||
|
||||
if ($status === 'received') {
|
||||
return '鉴定中心已签收包裹,等待鉴定师开始处理。';
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
705
server-api/app/controller/admin/ReportsController.php
Normal file
705
server-api/app/controller/admin/ReportsController.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\ContentService;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\MessageDispatcher;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class ReportsController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||
|
||||
$query = Db::name('reports')
|
||||
->alias('r')
|
||||
->leftJoin('orders o', 'o.id = r.order_id')
|
||||
->leftJoin('order_products p', 'p.order_id = r.order_id')
|
||||
->field([
|
||||
'r.id',
|
||||
'r.report_no',
|
||||
'r.order_id',
|
||||
'r.appraisal_no',
|
||||
'r.report_type',
|
||||
'r.report_title',
|
||||
'r.report_status',
|
||||
'r.service_provider',
|
||||
'r.institution_name',
|
||||
'r.publish_time',
|
||||
'o.order_no',
|
||||
'p.product_name',
|
||||
'p.category_name',
|
||||
'p.brand_name',
|
||||
])
|
||||
->order('r.id', 'desc');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('r.report_status', $status);
|
||||
}
|
||||
|
||||
if ($serviceProvider !== '') {
|
||||
$query->where('r.service_provider', $serviceProvider);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
$contentMap = $this->loadReportContentMap(array_map(fn(array $item) => (int)$item['id'], $rows));
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $item) {
|
||||
$productSnapshot = $contentMap[(int)$item['id']]['product_snapshot'] ?? [];
|
||||
$mapped = [
|
||||
'id' => (int)$item['id'],
|
||||
'order_id' => (int)($item['order_id'] ?? 0),
|
||||
'order_no' => $item['order_no'] ?? '',
|
||||
'appraisal_no' => $item['appraisal_no'] ?? '',
|
||||
'report_no' => $item['report_no'],
|
||||
'report_type' => $item['report_type'] ?: 'appraisal',
|
||||
'report_type_text' => $this->reportTypeText($item['report_type'] ?: 'appraisal'),
|
||||
'report_title' => $item['report_title'],
|
||||
'report_status' => $item['report_status'],
|
||||
'report_status_text' => $this->reportStatusText($item['report_status']),
|
||||
'service_provider' => $item['service_provider'],
|
||||
'service_provider_text' => $this->serviceProviderText($item['service_provider']),
|
||||
'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']),
|
||||
'publish_time' => $item['publish_time'],
|
||||
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
|
||||
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
||||
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
||||
];
|
||||
|
||||
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = $mapped;
|
||||
}
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if (!$id) {
|
||||
return api_error('报告 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$report = Db::name('reports')->where('id', $id)->find();
|
||||
if (!$report) {
|
||||
return api_error('报告不存在', 404);
|
||||
}
|
||||
|
||||
$content = Db::name('report_contents')->where('report_id', $id)->find();
|
||||
$productSnapshot = $this->decodeJsonField($content['product_snapshot_json'] ?? null);
|
||||
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
|
||||
$appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
|
||||
$valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
|
||||
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
|
||||
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
|
||||
if (($report['report_status'] ?? '') === 'published') {
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
|
||||
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
|
||||
if (!$verify) {
|
||||
$verify = [];
|
||||
}
|
||||
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
|
||||
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
|
||||
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
|
||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
||||
|
||||
return api_success([
|
||||
'report_header' => [
|
||||
'id' => (int)$report['id'],
|
||||
'order_id' => (int)($report['order_id'] ?? 0),
|
||||
'report_no' => $report['report_no'],
|
||||
'report_type' => $report['report_type'] ?: 'appraisal',
|
||||
'report_type_text' => $this->reportTypeText($report['report_type'] ?: 'appraisal'),
|
||||
'report_title' => $report['report_title'],
|
||||
'report_status' => $report['report_status'],
|
||||
'report_status_text' => $this->reportStatusText($report['report_status']),
|
||||
'service_provider' => $report['service_provider'],
|
||||
'service_provider_text' => $this->serviceProviderText($report['service_provider']),
|
||||
'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']),
|
||||
'publish_time' => $report['publish_time'],
|
||||
],
|
||||
'product_info' => $productSnapshot,
|
||||
'result_info' => $resultSnapshot,
|
||||
'appraisal_info' => $appraisalSnapshot,
|
||||
'valuation_info' => $valuationSnapshot,
|
||||
'evidence_attachments' => $evidenceAttachments,
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||
'verify_info' => [
|
||||
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
||||
'verify_url' => $verify['verify_url'] ?? $verifyUrl,
|
||||
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? $reportPageUrl,
|
||||
'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl,
|
||||
'verify_count' => (int)($verify['verify_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveInspection(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$header = $request->input('report_header', []);
|
||||
$productInfo = $request->input('product_info', []);
|
||||
$resultInfo = $request->input('result_info', []);
|
||||
$appraisalInfo = $request->input('appraisal_info', []);
|
||||
$valuationInfo = $request->input('valuation_info', []);
|
||||
$riskNoticeText = trim((string)$request->input('risk_notice_text', ''));
|
||||
|
||||
if (!is_array($header) || !is_array($productInfo) || !is_array($resultInfo) || !is_array($appraisalInfo) || !is_array($valuationInfo)) {
|
||||
return api_error('检查单参数格式错误', 422);
|
||||
}
|
||||
|
||||
$serviceProvider = trim((string)($header['service_provider'] ?? 'anxinyan'));
|
||||
if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) {
|
||||
return api_error('服务类型不正确', 422);
|
||||
}
|
||||
|
||||
$reportStatus = trim((string)($header['report_status'] ?? 'pending_publish'));
|
||||
if (!in_array($reportStatus, ['draft', 'pending_publish', 'published'], true)) {
|
||||
return api_error('报告状态不正确', 422);
|
||||
}
|
||||
|
||||
$productName = trim((string)($productInfo['product_name'] ?? ''));
|
||||
$resultText = trim((string)($resultInfo['result_text'] ?? ''));
|
||||
if ($productName === '') {
|
||||
return api_error('商品名称不能为空', 422);
|
||||
}
|
||||
if ($resultText === '') {
|
||||
return api_error('鉴定结论不能为空', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$existing = null;
|
||||
if ($id > 0) {
|
||||
$existing = Db::name('reports')->where('id', $id)->find();
|
||||
if (!$existing || (($existing['report_type'] ?? 'appraisal') !== 'inspection')) {
|
||||
Db::rollback();
|
||||
return api_error('检查单不存在', 404);
|
||||
}
|
||||
if (($existing['report_status'] ?? '') === 'published') {
|
||||
Db::rollback();
|
||||
return api_error('已发布的检查单不支持直接编辑,请复制后重新补录', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$reportNo = trim((string)($header['report_no'] ?? ($existing['report_no'] ?? '')));
|
||||
if ($reportNo === '') {
|
||||
$reportNo = $this->generateUniqueReportNo('inspection');
|
||||
}
|
||||
|
||||
$conflict = Db::name('reports')
|
||||
->where('report_no', $reportNo)
|
||||
->when($id > 0, fn($query) => $query->where('id', '<>', $id))
|
||||
->find();
|
||||
if ($conflict) {
|
||||
Db::rollback();
|
||||
return api_error('检查单编号已存在,请更换后重试', 422);
|
||||
}
|
||||
|
||||
$reportTitle = trim((string)($header['report_title'] ?? ''));
|
||||
if ($reportTitle === '') {
|
||||
$reportTitle = $this->defaultReportTitle($serviceProvider, 'inspection');
|
||||
}
|
||||
|
||||
$institutionName = trim((string)($header['institution_name'] ?? ''));
|
||||
if ($institutionName === '') {
|
||||
$institutionName = $this->defaultInstitutionName($serviceProvider);
|
||||
}
|
||||
|
||||
$publishTime = $reportStatus === 'published'
|
||||
? trim((string)($header['publish_time'] ?? ($existing['publish_time'] ?? $now)))
|
||||
: null;
|
||||
|
||||
$reportPayload = [
|
||||
'report_no' => $reportNo,
|
||||
'order_id' => 0,
|
||||
'appraisal_no' => $existing['appraisal_no'] ?? $this->generateUniqueAppraisalNo('inspection'),
|
||||
'report_type' => 'inspection',
|
||||
'service_provider' => $serviceProvider,
|
||||
'institution_name' => $institutionName,
|
||||
'report_title' => $reportTitle,
|
||||
'report_status' => $reportStatus,
|
||||
'report_version' => $existing ? ((int)$existing['report_version'] + 1) : 1,
|
||||
'publish_time' => $publishTime ?: null,
|
||||
'invalid_reason' => '',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
Db::name('reports')->where('id', $id)->update($reportPayload);
|
||||
$reportId = $id;
|
||||
} else {
|
||||
$reportPayload['created_at'] = $now;
|
||||
$reportId = (int)Db::name('reports')->insertGetId($reportPayload);
|
||||
}
|
||||
|
||||
$normalizedProductInfo = [
|
||||
'product_name' => $productName,
|
||||
'category_name' => trim((string)($productInfo['category_name'] ?? '')),
|
||||
'brand_name' => trim((string)($productInfo['brand_name'] ?? '')),
|
||||
'color' => trim((string)($productInfo['color'] ?? '')),
|
||||
'size_spec' => trim((string)($productInfo['size_spec'] ?? '')),
|
||||
'serial_no' => trim((string)($productInfo['serial_no'] ?? '')),
|
||||
];
|
||||
|
||||
$normalizedResultInfo = [
|
||||
'result_status' => trim((string)($resultInfo['result_status'] ?? 'authentic')),
|
||||
'result_text' => $resultText,
|
||||
'result_desc' => trim((string)($resultInfo['result_desc'] ?? '')),
|
||||
];
|
||||
|
||||
$normalizedAppraisalInfo = [
|
||||
'service_provider' => $serviceProvider,
|
||||
'institution_name' => $institutionName,
|
||||
'appraiser_name' => trim((string)($appraisalInfo['appraiser_name'] ?? '')),
|
||||
'reviewer_name' => trim((string)($appraisalInfo['reviewer_name'] ?? '')),
|
||||
'appraisal_time' => trim((string)($appraisalInfo['appraisal_time'] ?? ($publishTime ?: $now))),
|
||||
];
|
||||
|
||||
$normalizedValuationInfo = [
|
||||
'condition_grade' => trim((string)($valuationInfo['condition_grade'] ?? '')),
|
||||
'condition_desc' => trim((string)($valuationInfo['condition_desc'] ?? '')),
|
||||
'valuation_min' => (float)($valuationInfo['valuation_min'] ?? 0),
|
||||
'valuation_max' => (float)($valuationInfo['valuation_max'] ?? 0),
|
||||
'valuation_desc' => trim((string)($valuationInfo['valuation_desc'] ?? '')),
|
||||
];
|
||||
|
||||
$contentPayload = [
|
||||
'report_id' => $reportId,
|
||||
'product_snapshot_json' => json_encode($normalizedProductInfo, JSON_UNESCAPED_UNICODE),
|
||||
'result_snapshot_json' => json_encode($normalizedResultInfo, JSON_UNESCAPED_UNICODE),
|
||||
'appraisal_snapshot_json' => json_encode($normalizedAppraisalInfo, JSON_UNESCAPED_UNICODE),
|
||||
'valuation_snapshot_json' => json_encode($normalizedValuationInfo, JSON_UNESCAPED_UNICODE),
|
||||
'risk_notice_text' => $riskNoticeText !== '' ? $riskNoticeText : (new ContentService())->getReportRiskNotice('inspection'),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$content = Db::name('report_contents')->where('report_id', $reportId)->find();
|
||||
if ($content) {
|
||||
Db::name('report_contents')->where('report_id', $reportId)->update($contentPayload);
|
||||
} else {
|
||||
$contentPayload['created_at'] = $now;
|
||||
Db::name('report_contents')->insert($contentPayload);
|
||||
}
|
||||
|
||||
$reportRecord = Db::name('reports')->where('id', $reportId)->find();
|
||||
$verifyInfo = [
|
||||
'verify_url' => '',
|
||||
'report_page_url' => '',
|
||||
];
|
||||
|
||||
if ($reportStatus === 'published' && $reportRecord) {
|
||||
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
||||
} else {
|
||||
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
|
||||
return api_success([
|
||||
'id' => $reportId,
|
||||
'report_status' => $reportStatus,
|
||||
'publish_time' => $publishTime ?: '',
|
||||
'verify_url' => $verifyInfo['verify_url'] ?? '',
|
||||
'report_page_url' => $verifyInfo['report_page_url'] ?? '',
|
||||
], $existing ? '检查单已更新' : '检查单已补录');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('检查单保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function publish(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if (!$id) {
|
||||
return api_error('报告 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$report = Db::name('reports')->where('id', $id)->find();
|
||||
if (!$report) {
|
||||
Db::rollback();
|
||||
return api_error('报告不存在', 404);
|
||||
}
|
||||
|
||||
if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
|
||||
Db::rollback();
|
||||
return api_error('当前报告状态不支持发布', 422);
|
||||
}
|
||||
|
||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||
if ($report['report_status'] !== 'published') {
|
||||
Db::name('reports')->where('id', $id)->update([
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$report['report_status'] = 'published';
|
||||
$report['publish_time'] = $effectivePublishTime;
|
||||
}
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
||||
}
|
||||
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
Db::name('orders')->where('id', $report['order_id'])->update([
|
||||
'order_status' => 'report_published',
|
||||
'display_status' => '报告已出具',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$order = Db::name('orders')->where('id', $report['order_id'])->find();
|
||||
$product = Db::name('order_products')->where('order_id', $report['order_id'])->find();
|
||||
|
||||
$timelineExists = Db::name('order_timelines')
|
||||
->where('order_id', $report['order_id'])
|
||||
->where('node_code', 'report_published')
|
||||
->where('node_text', '报告已出具')
|
||||
->find();
|
||||
|
||||
if (!$timelineExists) {
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $report['order_id'],
|
||||
'node_code' => 'report_published',
|
||||
'node_text' => '报告已出具',
|
||||
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => 1,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('report_published', [
|
||||
'user_id' => (int)($order['user_id'] ?? 0),
|
||||
'biz_type' => 'report',
|
||||
'biz_id' => (int)$report['id'],
|
||||
'report_no' => $report['report_no'],
|
||||
'report_title' => $report['report_title'],
|
||||
'product_name' => $product['product_name'] ?? '',
|
||||
'publish_time' => $report['publish_time'] ?: $now,
|
||||
'verify_url' => $verify['verify_url'],
|
||||
'fallback_title' => '报告已出具',
|
||||
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
|
||||
]);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
|
||||
'report_id' => $id,
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $verify['verify_url'],
|
||||
'report_page_url' => $verify['report_page_url'],
|
||||
], '报告已发布');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('报告发布失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function reportStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'draft' => '草稿中',
|
||||
'pending_publish' => '待发布',
|
||||
'published' => '已发布',
|
||||
'updated' => '已更新',
|
||||
'invalid' => '已作废',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function reportTypeText(string $reportType): string
|
||||
{
|
||||
return match ($reportType) {
|
||||
'inspection' => '补录检查单',
|
||||
default => '订单报告',
|
||||
};
|
||||
}
|
||||
|
||||
private function serviceProviderText(string $serviceProvider): string
|
||||
{
|
||||
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
|
||||
}
|
||||
|
||||
private function decodeJsonField(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function loadReportContentMap(array $reportIds): array
|
||||
{
|
||||
if (!$reportIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name('report_contents')->whereIn('report_id', $reportIds)->select()->toArray();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['report_id']] = [
|
||||
'product_snapshot' => $this->decodeJsonField($row['product_snapshot_json'] ?? null),
|
||||
'result_snapshot' => $this->decodeJsonField($row['result_snapshot_json'] ?? null),
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function matchKeyword(array $item, string $keyword): bool
|
||||
{
|
||||
$needle = mb_strtolower($keyword);
|
||||
foreach (['report_no', 'report_title', 'product_name', 'brand_name', 'institution_name', 'order_no', 'appraisal_no'] as $field) {
|
||||
if (str_contains(mb_strtolower((string)($item[$field] ?? '')), $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function createOrUpdateVerifyRecord(array $report, string $now): array
|
||||
{
|
||||
$reportNo = (string)$report['report_no'];
|
||||
$verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo));
|
||||
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
|
||||
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]);
|
||||
|
||||
$payload = [
|
||||
'report_id' => (int)$report['id'],
|
||||
'report_no' => $reportNo,
|
||||
'verify_token' => $verifyToken,
|
||||
'verify_qrcode_url' => $reportPageUrl,
|
||||
'verify_url' => $verifyUrl,
|
||||
'verify_status' => 'valid',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$verify = Db::name('report_verifies')->where('report_id', $report['id'])->find();
|
||||
if ($verify) {
|
||||
Db::name('report_verifies')->where('id', $verify['id'])->update($payload);
|
||||
} else {
|
||||
$payload['last_verified_at'] = null;
|
||||
$payload['verify_count'] = 0;
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('report_verifies')->insert($payload);
|
||||
}
|
||||
|
||||
$fresh = Db::name('report_verifies')->where('report_id', $report['id'])->find() ?: $payload;
|
||||
$fresh['report_page_url'] = $reportPageUrl;
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
private function buildPublicPageUrl(string $pagePath, array $query = []): string
|
||||
{
|
||||
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
|
||||
$page = ltrim($pagePath, '/');
|
||||
$queryString = http_build_query($query);
|
||||
$hashPath = '/#/' . $page;
|
||||
if ($queryString !== '') {
|
||||
$hashPath .= '?' . $queryString;
|
||||
}
|
||||
|
||||
if ($baseUrl === '') {
|
||||
return $hashPath;
|
||||
}
|
||||
|
||||
return $baseUrl . $hashPath;
|
||||
}
|
||||
|
||||
private function normalizeH5BaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function enrichAppraisalSnapshot(array $report, array $snapshot): array
|
||||
{
|
||||
if (($report['report_type'] ?? 'appraisal') !== 'appraisal' || (int)($report['order_id'] ?? 0) <= 0) {
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
$tasks = Db::name('appraisal_tasks')
|
||||
->where('order_id', (int)$report['order_id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$firstReviewTask = null;
|
||||
$finalReviewTask = null;
|
||||
foreach ($tasks as $task) {
|
||||
if (($task['task_stage'] ?? '') === 'first_review') {
|
||||
$firstReviewTask = $task;
|
||||
}
|
||||
if (($task['task_stage'] ?? '') === 'final_review') {
|
||||
$finalReviewTask = $task;
|
||||
}
|
||||
}
|
||||
|
||||
$institutionName = $snapshot['institution_name'] ?? ($report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']));
|
||||
$appraiserName = $this->normalizeAssigneeName($snapshot['appraiser_name'] ?? '')
|
||||
?: $this->normalizeAssigneeName($firstReviewTask['assignee_name'] ?? '')
|
||||
?: $this->normalizeAssigneeName($finalReviewTask['assignee_name'] ?? '');
|
||||
$reviewerName = $appraiserName;
|
||||
$appraisalTime = $snapshot['appraisal_time']
|
||||
?? ($firstReviewTask['submitted_at']
|
||||
?? $firstReviewTask['started_at']
|
||||
?? $finalReviewTask['submitted_at']
|
||||
?? $finalReviewTask['started_at']
|
||||
?? '');
|
||||
|
||||
$snapshot['service_provider'] = $snapshot['service_provider'] ?? $report['service_provider'];
|
||||
$snapshot['institution_name'] = $institutionName;
|
||||
$snapshot['appraiser_name'] = $appraiserName;
|
||||
$snapshot['reviewer_name'] = $reviewerName;
|
||||
$snapshot['appraisal_time'] = $appraisalTime;
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function refreshAppraisalSnapshot(int $reportId, int $orderId, string $serviceProvider, string $now): void
|
||||
{
|
||||
$content = Db::name('report_contents')->where('report_id', $reportId)->find();
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->enrichAppraisalSnapshot(
|
||||
[
|
||||
'report_type' => 'appraisal',
|
||||
'order_id' => $orderId,
|
||||
'service_provider' => $serviceProvider,
|
||||
'institution_name' => '',
|
||||
],
|
||||
$this->decodeJsonField($content['appraisal_snapshot_json'] ?? null),
|
||||
);
|
||||
|
||||
Db::name('report_contents')->where('report_id', $reportId)->update([
|
||||
'appraisal_snapshot_json' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeAssigneeName(?string $value): string
|
||||
{
|
||||
$name = trim((string)$value);
|
||||
if ($name === '' || $name === '未分配') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function evidenceService(): AppraisalEvidenceService
|
||||
{
|
||||
return new AppraisalEvidenceService();
|
||||
}
|
||||
|
||||
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function generateUniqueReportNo(string $reportType): string
|
||||
{
|
||||
$prefix = $reportType === 'inspection' ? 'AXY-CHK' : 'AXY-R';
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999));
|
||||
if (!Db::name('reports')->where('report_no', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999));
|
||||
}
|
||||
|
||||
private function generateUniqueAppraisalNo(string $reportType): string
|
||||
{
|
||||
$prefix = $reportType === 'inspection' ? 'AXY-CHECK' : 'AXY-APP';
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999));
|
||||
if (!Db::name('reports')->where('appraisal_no', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999));
|
||||
}
|
||||
|
||||
private function defaultReportTitle(string $serviceProvider, string $reportType): string
|
||||
{
|
||||
if ($reportType === 'inspection') {
|
||||
return $serviceProvider === 'zhongjian' ? '中检检查单' : '安心验检查单';
|
||||
}
|
||||
|
||||
return $serviceProvider === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告';
|
||||
}
|
||||
|
||||
private function defaultInstitutionName(string $serviceProvider): string
|
||||
{
|
||||
return $serviceProvider === 'zhongjian' ? '中检合作机构' : '安心验';
|
||||
}
|
||||
}
|
||||
480
server-api/app/controller/admin/SystemConfigsController.php
Normal file
480
server-api/app/controller/admin/SystemConfigsController.php
Normal file
@@ -0,0 +1,480 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\FileStorageConfigService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class SystemConfigsController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->bootstrapDefaults();
|
||||
|
||||
$configs = Db::name('system_configs')
|
||||
->whereIn('config_group', array_keys($this->definitions()))
|
||||
->order('config_group', 'asc')
|
||||
->order('config_key', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$configMap = [];
|
||||
foreach ($configs as $item) {
|
||||
$configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? '';
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($this->definitions() as $groupCode => $group) {
|
||||
$groups[] = [
|
||||
'group_code' => $groupCode,
|
||||
'group_name' => $group['group_name'],
|
||||
'group_desc' => $group['group_desc'],
|
||||
'items' => array_map(function (array $item) use ($groupCode, $configMap) {
|
||||
return [
|
||||
'config_key' => $item['config_key'],
|
||||
'title' => $item['title'],
|
||||
'field_type' => $item['field_type'],
|
||||
'placeholder' => $item['placeholder'],
|
||||
'remark' => $item['remark'],
|
||||
'is_secret' => (bool)$item['is_secret'],
|
||||
'options' => $item['options'] ?? [],
|
||||
'visible_when' => $item['visible_when'] ?? null,
|
||||
'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '',
|
||||
];
|
||||
}, $group['items']),
|
||||
];
|
||||
}
|
||||
|
||||
return api_success(['groups' => $groups]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$items = $request->input('items', []);
|
||||
if (!is_array($items) || !$items) {
|
||||
return api_error('配置项不能为空', 422);
|
||||
}
|
||||
|
||||
$definitions = $this->definitions();
|
||||
$allowedMap = [];
|
||||
foreach ($definitions as $groupCode => $group) {
|
||||
foreach ($group['items'] as $item) {
|
||||
$allowedMap[$groupCode . '.' . $item['config_key']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$configValueMap = [];
|
||||
foreach ($definitions as $groupCode => $group) {
|
||||
foreach ($group['items'] as $item) {
|
||||
$configValueMap[$groupCode . '.' . $item['config_key']] = (string)Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $item['config_key'])
|
||||
->value('config_value');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupCode = trim((string)($item['config_group'] ?? ''));
|
||||
$configKey = trim((string)($item['config_key'] ?? ''));
|
||||
$mapKey = $groupCode . '.' . $configKey;
|
||||
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configValueMap[$mapKey] = (string)($item['config_value'] ?? '');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateConfigValues($configValueMap);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupCode = trim((string)($item['config_group'] ?? ''));
|
||||
$configKey = trim((string)($item['config_key'] ?? ''));
|
||||
$configValue = (string)($item['config_value'] ?? '');
|
||||
$mapKey = $groupCode . '.' . $configKey;
|
||||
|
||||
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'config_group' => $groupCode,
|
||||
'config_key' => $configKey,
|
||||
'config_value' => $configValue,
|
||||
'remark' => '后台系统配置',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('system_configs')->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
(new FileStorageConfigService())->clearCache();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('系统配置保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([], '系统配置已保存');
|
||||
}
|
||||
|
||||
public function uploadFile(Request $request)
|
||||
{
|
||||
$groupCode = trim((string)$request->input('config_group', ''));
|
||||
$configKey = trim((string)$request->input('config_key', ''));
|
||||
if ($groupCode === '' || $configKey === '') {
|
||||
return api_error('配置分组和配置项不能为空', 422);
|
||||
}
|
||||
|
||||
$allowed = $this->uploadableConfigMap();
|
||||
$mapKey = $groupCode . '.' . $configKey;
|
||||
if (!isset($allowed[$mapKey])) {
|
||||
return api_error('当前配置项不支持文件上传', 422);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return api_error('上传文件无效', 422);
|
||||
}
|
||||
|
||||
$originalName = (string)$file->getUploadName();
|
||||
$extension = strtolower((string)$file->getUploadExtension());
|
||||
if ($extension !== 'pem') {
|
||||
return api_error('仅支持上传 .pem 文件', 422);
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
if (!is_string($content) || !str_contains($content, '-----BEGIN')) {
|
||||
return api_error('PEM 文件内容格式不正确', 422);
|
||||
}
|
||||
|
||||
$storageDir = base_path() . '/storage/payment-certs';
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0775, true);
|
||||
}
|
||||
|
||||
$targetFilename = $allowed[$mapKey]['filename'];
|
||||
$targetPath = $storageDir . '/' . $targetFilename;
|
||||
file_put_contents($targetPath, $content);
|
||||
@chmod($targetPath, 0600);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'config_group' => $groupCode,
|
||||
'config_key' => $configKey,
|
||||
'config_value' => $targetPath,
|
||||
'remark' => '后台系统配置',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('system_configs')->insert($payload);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'config_group' => $groupCode,
|
||||
'config_key' => $configKey,
|
||||
'config_value' => $targetPath,
|
||||
'file_name' => $targetFilename,
|
||||
'original_name' => $originalName,
|
||||
], '文件已上传');
|
||||
}
|
||||
|
||||
private function bootstrapDefaults(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($this->definitions() as $groupCode => $group) {
|
||||
foreach ($group['items'] as $item) {
|
||||
$exists = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $item['config_key'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('system_configs')->insert([
|
||||
'config_group' => $groupCode,
|
||||
'config_key' => $item['config_key'],
|
||||
'config_value' => (string)($item['default_value'] ?? ''),
|
||||
'remark' => '后台系统配置',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function definitions(): array
|
||||
{
|
||||
return [
|
||||
'file_storage' => [
|
||||
'group_name' => '文件存储',
|
||||
'group_desc' => '配置业务文件存储方式。支持本地磁盘或阿里云 OSS,切换为 OSS 后需填写对应 Bucket 与密钥资料。',
|
||||
'items' => [
|
||||
[
|
||||
'config_key' => 'driver',
|
||||
'title' => '存储驱动',
|
||||
'field_type' => 'select',
|
||||
'placeholder' => '请选择文件存储方式',
|
||||
'remark' => '本地模式写入服务器 public/uploads;OSS 模式写入阿里云对象存储。',
|
||||
'is_secret' => false,
|
||||
'default_value' => 'local',
|
||||
'options' => [
|
||||
['label' => '本地存储', 'value' => 'local'],
|
||||
['label' => '阿里云 OSS', 'value' => 'oss'],
|
||||
['label' => '七牛云 Kodo', 'value' => 'qiniu'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'config_key' => 'public_base_url',
|
||||
'title' => '公开访问域名',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 https://api.anxinjianyan.com 或 https://static.example.com',
|
||||
'remark' => '用于生成文件公网访问地址;本地可填 API 域名,OSS 可填自定义 CDN/回源域名,不填则按驱动自动推导。',
|
||||
'is_secret' => false,
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_endpoint',
|
||||
'title' => 'OSS Endpoint',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com',
|
||||
'remark' => '填写 Bucket 所在地域的公网 Endpoint。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_bucket',
|
||||
'title' => 'OSS Bucket',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '请输入 Bucket 名称',
|
||||
'remark' => '将作为所有业务文件的目标 Bucket。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_access_key_id',
|
||||
'title' => 'OSS AccessKey ID',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '请输入 OSS AccessKey ID',
|
||||
'remark' => '用于 OSS 文件上传、删除和存在性校验。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_access_key_secret',
|
||||
'title' => 'OSS AccessKey Secret',
|
||||
'field_type' => 'password',
|
||||
'placeholder' => '请输入 OSS AccessKey Secret',
|
||||
'remark' => '请妥善保管,仅后台可见。',
|
||||
'is_secret' => true,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_bucket_domain',
|
||||
'title' => 'OSS 绑定域名',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 https://static.anxinjianyan.com',
|
||||
'remark' => '如 Bucket 已绑定自定义域名,可填写;不填则默认使用 https://bucket.endpoint。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'oss_path_prefix',
|
||||
'title' => 'OSS 路径前缀',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 anxinyan-prod',
|
||||
'remark' => '可选。填写后 OSS 对象会统一写入此前缀目录下。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'qiniu_bucket',
|
||||
'title' => '七牛 Bucket',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '请输入七牛 Kodo Bucket 名称',
|
||||
'remark' => '将作为七牛云对象存储的目标 Bucket。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'qiniu_access_key',
|
||||
'title' => '七牛 AccessKey',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '请输入七牛 AccessKey',
|
||||
'remark' => '用于七牛文件上传、删除和存在性校验。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'qiniu_secret_key',
|
||||
'title' => '七牛 SecretKey',
|
||||
'field_type' => 'password',
|
||||
'placeholder' => '请输入七牛 SecretKey',
|
||||
'remark' => '请妥善保管,仅后台可见。',
|
||||
'is_secret' => true,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'qiniu_bucket_domain',
|
||||
'title' => '七牛公网访问域名',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 https://static.example.com 或 https://xxx.clouddn.com',
|
||||
'remark' => '用于生成七牛文件公网访问地址。建议填写已绑定并可公开访问的域名。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
|
||||
],
|
||||
[
|
||||
'config_key' => 'qiniu_path_prefix',
|
||||
'title' => '七牛路径前缀',
|
||||
'field_type' => 'text',
|
||||
'placeholder' => '例如 anxinyan-prod',
|
||||
'remark' => '可选。填写后七牛对象会统一写入此前缀目录下。',
|
||||
'is_secret' => false,
|
||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'mini_program' => [
|
||||
'group_name' => '小程序配置',
|
||||
'group_desc' => '配置微信小程序 AppID、密钥及消息通知相关参数。',
|
||||
'items' => [
|
||||
['config_key' => 'app_id', 'title' => '小程序 AppID', 'field_type' => 'text', 'placeholder' => '请输入小程序 AppID', 'remark' => '用于小程序登录、消息与支付能力接入', 'is_secret' => false],
|
||||
['config_key' => 'app_secret', 'title' => '小程序 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入小程序 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
|
||||
['config_key' => 'original_id', 'title' => '原始 ID', 'field_type' => 'text', 'placeholder' => '请输入原始 ID', 'remark' => '用于公众号/小程序主体识别', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
'h5' => [
|
||||
'group_name' => 'H5 配置',
|
||||
'group_desc' => '配置 H5 接入、开放平台、回调地址以及公开页面域名。',
|
||||
'items' => [
|
||||
['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false],
|
||||
['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
|
||||
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '请输入 H5 授权回调地址', 'remark' => '用于 H5 登录或支付回调', 'is_secret' => false],
|
||||
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
'payment' => [
|
||||
'group_name' => '支付与商户平台',
|
||||
'group_desc' => '配置微信支付商户号、API 密钥、证书序列号等上线必要参数。',
|
||||
'items' => [
|
||||
['config_key' => 'mch_id', 'title' => '商户号 MchID', 'field_type' => 'text', 'placeholder' => '请输入商户号', 'remark' => '微信支付商户平台分配的商户号', 'is_secret' => false],
|
||||
['config_key' => 'api_v3_key', 'title' => 'APIv3 Key', 'field_type' => 'password', 'placeholder' => '请输入 APIv3 Key', 'remark' => '用于微信支付接口验签与解密', 'is_secret' => true],
|
||||
['config_key' => 'merchant_serial_no', 'title' => '商户证书序列号', 'field_type' => 'text', 'placeholder' => '请输入商户证书序列号', 'remark' => '与商户 API 证书匹配', 'is_secret' => false],
|
||||
['config_key' => 'apiclient_key_path', 'title' => 'apiclient_key.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_key.pem', 'remark' => '上传微信支付商户私钥文件,系统将保存到后端非公开目录', 'is_secret' => true],
|
||||
['config_key' => 'apiclient_cert_path', 'title' => 'apiclient_cert.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_cert.pem', 'remark' => '上传微信支付商户证书文件,系统将保存到后端非公开目录', 'is_secret' => false],
|
||||
['config_key' => 'merchant_private_key', 'title' => '商户私钥', 'field_type' => 'textarea', 'placeholder' => '请输入商户私钥内容', 'remark' => '用于支付签名,请妥善保管', 'is_secret' => true],
|
||||
['config_key' => 'platform_certificate_serial', 'title' => '平台证书序列号', 'field_type' => 'text', 'placeholder' => '请输入微信支付平台证书序列号', 'remark' => '用于平台证书校验', 'is_secret' => false],
|
||||
['config_key' => 'notify_url', 'title' => '支付回调地址', 'field_type' => 'text', 'placeholder' => '请输入支付回调通知地址', 'remark' => '支付成功后用于回调业务系统', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
'sms' => [
|
||||
'group_name' => '短信配置',
|
||||
'group_desc' => '配置阿里云短信服务 AccessKey、签名和登录验证码模板,用于手机号验证码登录。',
|
||||
'items' => [
|
||||
['config_key' => 'access_key_id', 'title' => 'AccessKey ID', 'field_type' => 'text', 'placeholder' => '请输入阿里云 AccessKey ID', 'remark' => '用于调用阿里云短信 SendSms 接口', 'is_secret' => false],
|
||||
['config_key' => 'access_key_secret', 'title' => 'AccessKey Secret', 'field_type' => 'password', 'placeholder' => '请输入阿里云 AccessKey Secret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
|
||||
['config_key' => 'sign_name', 'title' => '短信签名', 'field_type' => 'text', 'placeholder' => '请输入短信签名', 'remark' => '需与阿里云短信服务已审核通过的签名一致', 'is_secret' => false],
|
||||
['config_key' => 'login_template_code', 'title' => '登录模板 Code', 'field_type' => 'text', 'placeholder' => '例如 SMS_123456789', 'remark' => '模板中需包含 code 变量', 'is_secret' => false],
|
||||
['config_key' => 'region_id', 'title' => 'Region ID', 'field_type' => 'text', 'placeholder' => '默认 cn-hangzhou', 'remark' => '通常填写 cn-hangzhou', 'is_secret' => false],
|
||||
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function uploadableConfigMap(): array
|
||||
{
|
||||
return [
|
||||
'payment.apiclient_key_path' => [
|
||||
'filename' => 'apiclient_key.pem',
|
||||
],
|
||||
'payment.apiclient_cert_path' => [
|
||||
'filename' => 'apiclient_cert.pem',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function validateConfigValues(array $configValueMap): void
|
||||
{
|
||||
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
|
||||
if ($driver === 'local') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'oss') {
|
||||
$required = [
|
||||
'file_storage.oss_endpoint' => 'OSS Endpoint',
|
||||
'file_storage.oss_bucket' => 'OSS Bucket',
|
||||
'file_storage.oss_access_key_id' => 'OSS AccessKey ID',
|
||||
'file_storage.oss_access_key_secret' => 'OSS AccessKey Secret',
|
||||
];
|
||||
|
||||
foreach ($required as $key => $label) {
|
||||
if (trim((string)($configValueMap[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('当前已切换为 OSS 存储,请先填写 %s', $label));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver !== 'qiniu') {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'file_storage.qiniu_bucket' => '七牛 Bucket',
|
||||
'file_storage.qiniu_access_key' => '七牛 AccessKey',
|
||||
'file_storage.qiniu_secret_key' => '七牛 SecretKey',
|
||||
];
|
||||
|
||||
foreach ($required as $key => $label) {
|
||||
if (trim((string)($configValueMap[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('当前已切换为七牛云存储,请先填写 %s', $label));
|
||||
}
|
||||
}
|
||||
|
||||
$publicBaseUrl = trim((string)($configValueMap['file_storage.public_base_url'] ?? ''));
|
||||
$bucketDomain = trim((string)($configValueMap['file_storage.qiniu_bucket_domain'] ?? ''));
|
||||
if ($publicBaseUrl === '' && $bucketDomain === '') {
|
||||
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
|
||||
}
|
||||
}
|
||||
}
|
||||
344
server-api/app/controller/admin/TicketsController.php
Normal file
344
server-api/app/controller/admin/TicketsController.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\ContentService;
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\TicketAttachmentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class TicketsController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '工单总量',
|
||||
'value' => (int)Db::name('tickets')->count(),
|
||||
'desc' => '当前数据库内工单总数',
|
||||
],
|
||||
[
|
||||
'title' => '待处理工单',
|
||||
'value' => (int)Db::name('tickets')->whereIn('status', ['pending', 'processing'])->count(),
|
||||
'desc' => '待处理与处理中工单数量',
|
||||
],
|
||||
[
|
||||
'title' => '已解决工单',
|
||||
'value' => (int)Db::name('tickets')->where('status', 'resolved')->count(),
|
||||
'desc' => '当前已解决的工单数量',
|
||||
],
|
||||
[
|
||||
'title' => '工单留言',
|
||||
'value' => (int)Db::name('ticket_messages')->count(),
|
||||
'desc' => '当前工单消息记录总数',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
$type = trim((string)$request->input('ticket_type', ''));
|
||||
|
||||
$query = Db::name('tickets')
|
||||
->field([
|
||||
'id',
|
||||
'ticket_no',
|
||||
'ticket_type',
|
||||
'biz_type',
|
||||
'biz_id',
|
||||
'order_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'priority',
|
||||
'assignee_id',
|
||||
'title',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->order('id', 'desc');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->whereLike('ticket_no', "%{$keyword}%")
|
||||
->whereOrLike('title', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query->where('ticket_type', $type);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'ticket_no' => $item['ticket_no'],
|
||||
'ticket_type' => $item['ticket_type'],
|
||||
'ticket_type_text' => $this->ticketTypeText($item['ticket_type']),
|
||||
'biz_type' => $item['biz_type'],
|
||||
'biz_id' => (int)($item['biz_id'] ?? 0),
|
||||
'order_id' => (int)($item['order_id'] ?? 0),
|
||||
'user_id' => (int)($item['user_id'] ?? 0),
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->statusText($item['status']),
|
||||
'priority' => $item['priority'],
|
||||
'priority_text' => $this->priorityText($item['priority']),
|
||||
'title' => $item['title'] ?: '未命名工单',
|
||||
'created_at' => $item['created_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if (!$id) {
|
||||
return api_error('工单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$ticket = Db::name('tickets')->where('id', $id)->find();
|
||||
if (!$ticket) {
|
||||
return api_error('工单不存在', 404);
|
||||
}
|
||||
|
||||
$messages = Db::name('ticket_messages')
|
||||
->where('ticket_id', $id)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'ticket_info' => [
|
||||
'id' => (int)$ticket['id'],
|
||||
'ticket_no' => $ticket['ticket_no'],
|
||||
'ticket_type' => $ticket['ticket_type'],
|
||||
'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']),
|
||||
'biz_type' => $ticket['biz_type'],
|
||||
'biz_id' => (int)($ticket['biz_id'] ?? 0),
|
||||
'order_id' => (int)($ticket['order_id'] ?? 0),
|
||||
'user_id' => (int)($ticket['user_id'] ?? 0),
|
||||
'status' => $ticket['status'],
|
||||
'status_text' => $this->statusText($ticket['status']),
|
||||
'priority' => $ticket['priority'],
|
||||
'priority_text' => $this->priorityText($ticket['priority']),
|
||||
'title' => $ticket['title'],
|
||||
'content' => $ticket['content'],
|
||||
'created_at' => $ticket['created_at'],
|
||||
'updated_at' => $ticket['updated_at'],
|
||||
],
|
||||
'messages' => array_map(function (array $item) {
|
||||
return [
|
||||
'sender_type' => $item['sender_type'],
|
||||
'sender_type_text' => $item['sender_type'] === 'customer_service' ? '客服' : ($item['sender_type'] === 'system' ? '系统' : '用户'),
|
||||
'content' => $item['content'] ?: '',
|
||||
'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request),
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $messages),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if (!$id) {
|
||||
return api_error('工单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$ticket = Db::name('tickets')->where('id', $id)->find();
|
||||
if (!$ticket) {
|
||||
return api_error('工单不存在', 404);
|
||||
}
|
||||
|
||||
$status = trim((string)$request->input('status', $ticket['status']));
|
||||
$priority = trim((string)$request->input('priority', $ticket['priority']));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('tickets')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'priority' => $priority,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($status !== $ticket['status']) {
|
||||
$this->notifyStatusChanged($ticket, $status, $now);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('工单更新失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success(['id' => $id], '工单已更新');
|
||||
}
|
||||
|
||||
public function reply(Request $request)
|
||||
{
|
||||
$ticketId = (int)$request->input('ticket_id', 0);
|
||||
$content = trim((string)$request->input('content', ''));
|
||||
$attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true);
|
||||
|
||||
if (!$ticketId) {
|
||||
return api_error('工单 ID 不能为空', 422);
|
||||
}
|
||||
if ($content === '' && !$attachments) {
|
||||
return api_error('回复内容和附件至少填写一项', 422);
|
||||
}
|
||||
|
||||
$ticket = Db::name('tickets')->where('id', $ticketId)->find();
|
||||
if (!$ticket) {
|
||||
return api_error('工单不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::startTrans();
|
||||
try {
|
||||
$messageId = (int)Db::name('ticket_messages')->insertGetId([
|
||||
'ticket_id' => $ticketId,
|
||||
'sender_type' => 'customer_service',
|
||||
'sender_id' => 1,
|
||||
'content' => $content,
|
||||
'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
Db::name('tickets')->where('id', $ticketId)->update([
|
||||
'status' => 'processing',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('ticket_reply', [
|
||||
'user_id' => (int)($ticket['user_id'] ?? 0),
|
||||
'biz_type' => 'ticket_message',
|
||||
'biz_id' => $messageId,
|
||||
'ticket_id' => $ticketId,
|
||||
'ticket_no' => $ticket['ticket_no'],
|
||||
'ticket_title' => $ticket['title'] ?: '客服工单',
|
||||
'reply_content' => $content,
|
||||
'fallback_title' => '工单有新回复',
|
||||
'fallback_content' => sprintf('客服已回复您的工单「%s」,点击查看详情。', $ticket['title'] ?: '客服工单'),
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
return api_success(['ticket_id' => $ticketId], '回复成功');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('回复失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadFile(Request $request)
|
||||
{
|
||||
try {
|
||||
$asset = $this->attachmentService()->upload($request);
|
||||
return api_success($asset);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteFile(Request $request)
|
||||
{
|
||||
$fileUrl = trim((string)$request->input('file_url', ''));
|
||||
if ($fileUrl === '') {
|
||||
return api_error('文件地址不能为空', 422);
|
||||
}
|
||||
|
||||
$this->attachmentService()->delete($fileUrl);
|
||||
|
||||
return api_success([
|
||||
'file_url' => $fileUrl,
|
||||
], '删除成功');
|
||||
}
|
||||
|
||||
private function statusText(string $status): string
|
||||
{
|
||||
return (new ContentService())->ticketStatusText($status);
|
||||
}
|
||||
|
||||
private function priorityText(string $priority): string
|
||||
{
|
||||
return match ($priority) {
|
||||
'high' => '高优先级',
|
||||
'normal' => '普通',
|
||||
'low' => '低优先级',
|
||||
default => $priority,
|
||||
};
|
||||
}
|
||||
|
||||
private function ticketTypeText(string $type): string
|
||||
{
|
||||
return (new ContentService())->ticketTypeText($type);
|
||||
}
|
||||
|
||||
private function notifyStatusChanged(array $ticket, string $status, string $now): void
|
||||
{
|
||||
$eventConfig = match ($status) {
|
||||
'waiting_user' => [
|
||||
'event_code' => 'ticket_waiting_user',
|
||||
'title' => '工单等待您补充反馈',
|
||||
'content' => sprintf('客服正在跟进工单「%s」,当前需要您补充反馈信息。', $ticket['title'] ?: '客服工单'),
|
||||
'system_message' => '工单状态已更新为待用户反馈,请等待用户补充信息。',
|
||||
],
|
||||
'resolved' => [
|
||||
'event_code' => 'ticket_resolved',
|
||||
'title' => '工单已解决',
|
||||
'content' => sprintf('您的工单「%s」已处理完成,如仍有疑问可继续留言。', $ticket['title'] ?: '客服工单'),
|
||||
'system_message' => '工单状态已更新为已解决。',
|
||||
],
|
||||
'closed' => [
|
||||
'event_code' => 'ticket_closed',
|
||||
'title' => '工单已关闭',
|
||||
'content' => sprintf('您的工单「%s」已关闭,如需继续处理可重新发起工单。', $ticket['title'] ?: '客服工单'),
|
||||
'system_message' => '工单状态已更新为已关闭。',
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$eventConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messageId = (int)Db::name('ticket_messages')->insertGetId([
|
||||
'ticket_id' => (int)$ticket['id'],
|
||||
'sender_type' => 'system',
|
||||
'sender_id' => null,
|
||||
'content' => $eventConfig['system_message'],
|
||||
'attachments_json' => null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent($eventConfig['event_code'], [
|
||||
'user_id' => (int)($ticket['user_id'] ?? 0),
|
||||
'biz_type' => 'ticket_message',
|
||||
'biz_id' => $messageId,
|
||||
'ticket_id' => (int)$ticket['id'],
|
||||
'ticket_no' => $ticket['ticket_no'] ?? '',
|
||||
'ticket_title' => $ticket['title'] ?: '客服工单',
|
||||
'fallback_title' => $eventConfig['title'],
|
||||
'fallback_content' => $eventConfig['content'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function attachmentService(): TicketAttachmentService
|
||||
{
|
||||
return new TicketAttachmentService();
|
||||
}
|
||||
}
|
||||
244
server-api/app/controller/admin/UsersController.php
Normal file
244
server-api/app/controller/admin/UsersController.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class UsersController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '用户总量',
|
||||
'value' => (int)Db::name('users')->count(),
|
||||
'desc' => '当前数据库中的用户数量',
|
||||
],
|
||||
[
|
||||
'title' => '正常用户',
|
||||
'value' => (int)Db::name('users')->where('status', 'enabled')->count(),
|
||||
'desc' => '当前可正常使用系统的用户数量',
|
||||
],
|
||||
[
|
||||
'title' => '地址数量',
|
||||
'value' => (int)Db::name('user_addresses')->count(),
|
||||
'desc' => '用户维护的寄送与收货地址总数',
|
||||
],
|
||||
[
|
||||
'title' => '消息总量',
|
||||
'value' => (int)Db::name('user_messages')->count(),
|
||||
'desc' => '已发送给用户的站内消息数量',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
|
||||
$query = Db::name('users')
|
||||
->alias('u')
|
||||
->leftJoin('user_addresses a', 'a.user_id = u.id AND a.is_default = 1')
|
||||
->field([
|
||||
'u.id',
|
||||
'u.nickname',
|
||||
'u.mobile',
|
||||
'u.password',
|
||||
'u.status',
|
||||
'u.created_at',
|
||||
'u.updated_at',
|
||||
'a.province',
|
||||
'a.city',
|
||||
'a.district',
|
||||
'a.detail_address',
|
||||
])
|
||||
->order('u.id', 'desc');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->whereLike('u.nickname', "%{$keyword}%")
|
||||
->whereOrLike('u.mobile', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('u.status', $status);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
$userId = (int)$item['id'];
|
||||
|
||||
return [
|
||||
'id' => $userId,
|
||||
'nickname' => $item['nickname'] ?: '未命名用户',
|
||||
'mobile' => $item['mobile'] ?: '',
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->userStatusText($item['status']),
|
||||
'password_set' => ((string)($item['password'] ?? '')) !== '',
|
||||
'default_address' => trim(sprintf(
|
||||
'%s%s%s%s',
|
||||
$item['province'] ?? '',
|
||||
$item['city'] ?? '',
|
||||
$item['district'] ?? '',
|
||||
$item['detail_address'] ?? ''
|
||||
)),
|
||||
'order_count' => (int)Db::name('orders')->where('user_id', $userId)->count(),
|
||||
'message_count' => (int)Db::name('user_messages')->where('user_id', $userId)->count(),
|
||||
'ticket_count' => (int)Db::name('tickets')->where('user_id', $userId)->count(),
|
||||
'created_at' => $item['created_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('用户 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$user = Db::name('users')->where('id', $id)->find();
|
||||
if (!$user) {
|
||||
return api_error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$addresses = Db::name('user_addresses')
|
||||
->where('user_id', $id)
|
||||
->order('is_default', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$recentOrders = Db::name('orders')
|
||||
->where('user_id', $id)
|
||||
->order('id', 'desc')
|
||||
->limit(5)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$recentMessages = Db::name('user_messages')
|
||||
->where('user_id', $id)
|
||||
->order('id', 'desc')
|
||||
->limit(5)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'user_info' => [
|
||||
'id' => (int)$user['id'],
|
||||
'nickname' => $user['nickname'] ?: '未命名用户',
|
||||
'mobile' => $user['mobile'] ?: '',
|
||||
'status' => $user['status'],
|
||||
'status_text' => $this->userStatusText($user['status']),
|
||||
'password_set' => ((string)($user['password'] ?? '')) !== '',
|
||||
'created_at' => $user['created_at'],
|
||||
'updated_at' => $user['updated_at'],
|
||||
],
|
||||
'addresses' => array_map(fn (array $item) => [
|
||||
'consignee' => $item['consignee'],
|
||||
'mobile' => $item['mobile'],
|
||||
'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])),
|
||||
'is_default' => (bool)$item['is_default'],
|
||||
], $addresses),
|
||||
'recent_orders' => array_map(fn (array $item) => [
|
||||
'order_no' => $item['order_no'],
|
||||
'display_status' => $item['display_status'],
|
||||
'pay_amount' => (float)$item['pay_amount'],
|
||||
'created_at' => $item['created_at'],
|
||||
], $recentOrders),
|
||||
'recent_messages' => array_map(fn (array $item) => [
|
||||
'title' => $item['title'],
|
||||
'content' => $item['content'],
|
||||
'is_read' => (bool)$item['is_read'],
|
||||
'created_at' => $item['created_at'],
|
||||
], $recentMessages),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$id = (int)$request->input('id', 0);
|
||||
$nickname = trim((string)$request->input('nickname', ''));
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$status = trim((string)$request->input('status', 'enabled'));
|
||||
$password = trim((string)$request->input('password', ''));
|
||||
|
||||
if ($nickname === '' || $mobile === '') {
|
||||
return api_error('昵称和手机号不能为空', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$payload = [
|
||||
'nickname' => $nickname,
|
||||
'mobile' => $mobile,
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($password !== '') {
|
||||
$payload['password'] = password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$user = Db::name('users')->where('id', $id)->find();
|
||||
if (!$user) {
|
||||
return api_error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$exists = Db::name('users')
|
||||
->where('mobile', $mobile)
|
||||
->where('id', '<>', $id)
|
||||
->find();
|
||||
if ($exists) {
|
||||
return api_error('手机号已存在', 422);
|
||||
}
|
||||
|
||||
Db::name('users')->where('id', $id)->update($payload);
|
||||
return api_success(['id' => $id], '用户已更新');
|
||||
}
|
||||
|
||||
$exists = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($exists) {
|
||||
return api_error('手机号已存在', 422);
|
||||
}
|
||||
|
||||
$payload['avatar'] = '';
|
||||
$payload['password'] = $payload['password'] ?? '';
|
||||
$payload['last_login_at'] = null;
|
||||
$payload['created_at'] = $now;
|
||||
$newId = (int)Db::name('users')->insertGetId($payload);
|
||||
|
||||
return api_success(['id' => $newId], '用户已创建');
|
||||
}
|
||||
|
||||
private function userStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'enabled' => '正常',
|
||||
'disabled' => '已停用',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function ensurePasswordColumn(): void
|
||||
{
|
||||
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
|
||||
if ($column) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
|
||||
}
|
||||
}
|
||||
69
server-api/app/controller/admin/WarehousesController.php
Normal file
69
server-api/app/controller/admin/WarehousesController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
|
||||
class WarehousesController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'cards' => $this->service()->overviewCards(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'list' => $this->service()->list(),
|
||||
'category_options' => array_map(static fn(array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => $item['name'],
|
||||
], \support\think\Db::name('catalog_categories')->where('is_enabled', 1)->order('sort_order', 'asc')->select()->toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
try {
|
||||
$warehouseId = $this->service()->save([
|
||||
'warehouse_name' => $request->input('warehouse_name', ''),
|
||||
'warehouse_code' => $request->input('warehouse_code', ''),
|
||||
'service_provider' => $request->input('service_provider', 'anxinyan'),
|
||||
'receiver_name' => $request->input('receiver_name', ''),
|
||||
'receiver_mobile' => $request->input('receiver_mobile', ''),
|
||||
'province' => $request->input('province', ''),
|
||||
'city' => $request->input('city', ''),
|
||||
'district' => $request->input('district', ''),
|
||||
'detail_address' => $request->input('detail_address', ''),
|
||||
'service_time' => $request->input('service_time', ''),
|
||||
'notice' => $request->input('notice', ''),
|
||||
'supported_category_ids' => $request->input('supported_category_ids', []),
|
||||
'service_area_provinces' => $request->input('service_area_provinces', []),
|
||||
'service_area_cities' => $request->input('service_area_cities', []),
|
||||
'status' => $request->input('status', 'enabled'),
|
||||
'is_default' => $request->input('is_default', false),
|
||||
'sort_order' => $request->input('sort_order', 0),
|
||||
'remark' => $request->input('remark', ''),
|
||||
], $id);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('仓库保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $warehouseId,
|
||||
], $id > 0 ? '仓库已更新' : '仓库已创建');
|
||||
}
|
||||
|
||||
private function service(): WarehouseService
|
||||
{
|
||||
return new WarehouseService();
|
||||
}
|
||||
}
|
||||
219
server-api/app/controller/app/AddressesController.php
Normal file
219
server-api/app/controller/app/AddressesController.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class AddressesController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$rows = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->order('is_default', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'list' => array_map(fn (array $item) => $this->formatAddress($item), $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('地址 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
|
||||
if (!$address) {
|
||||
return api_error('地址不存在', 404);
|
||||
}
|
||||
|
||||
return api_success($this->formatAddress($address));
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$id = (int)$request->input('id', 0);
|
||||
$consignee = trim((string)$request->input('consignee', ''));
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$province = trim((string)$request->input('province', ''));
|
||||
$city = trim((string)$request->input('city', ''));
|
||||
$district = trim((string)$request->input('district', ''));
|
||||
$detailAddress = trim((string)$request->input('detail_address', ''));
|
||||
$isDefault = (bool)$request->input('is_default', false);
|
||||
|
||||
if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') {
|
||||
return api_error('请完整填写地址信息', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$existing = null;
|
||||
if ($id > 0) {
|
||||
$existing = Db::name('user_addresses')->where('id', $id)->where('user_id', $userId)->find();
|
||||
if (!$existing) {
|
||||
Db::rollback();
|
||||
return api_error('地址不存在', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$addressCount = (int)Db::name('user_addresses')->where('user_id', $userId)->count();
|
||||
$shouldSetDefault = $isDefault || $addressCount === 0 || ($existing && (bool)$existing['is_default']);
|
||||
|
||||
if ($shouldSetDefault) {
|
||||
Db::name('user_addresses')->where('user_id', $userId)->update([
|
||||
'is_default' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'consignee' => $consignee,
|
||||
'mobile' => $mobile,
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'detail_address' => $detailAddress,
|
||||
'is_default' => $shouldSetDefault ? 1 : 0,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
Db::name('user_addresses')->where('id', $id)->update($payload);
|
||||
$addressId = $id;
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$addressId = (int)Db::name('user_addresses')->insertGetId($payload);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('地址保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$address = Db::name('user_addresses')->where('id', $addressId)->where('user_id', $userId)->find();
|
||||
|
||||
return api_success([
|
||||
'id' => $addressId,
|
||||
'address' => $this->formatAddress($address ?: []),
|
||||
], '地址已保存');
|
||||
}
|
||||
|
||||
public function setDefault(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('地址 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
|
||||
if (!$address) {
|
||||
return api_error('地址不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('user_addresses')->where('user_id', app_user_id($request))->update([
|
||||
'is_default' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->update([
|
||||
'is_default' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('默认地址设置失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
], '已设为默认地址');
|
||||
}
|
||||
|
||||
public function delete(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('地址 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
|
||||
if (!$address) {
|
||||
return api_error('地址不存在', 404);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->delete();
|
||||
|
||||
if ((bool)$address['is_default']) {
|
||||
$next = Db::name('user_addresses')
|
||||
->where('user_id', app_user_id($request))
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if ($next) {
|
||||
Db::name('user_addresses')->where('id', $next['id'])->update([
|
||||
'is_default' => 1,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('地址删除失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
], '地址已删除');
|
||||
}
|
||||
|
||||
private function formatAddress(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)($item['id'] ?? 0),
|
||||
'consignee' => $item['consignee'] ?? '',
|
||||
'mobile' => $item['mobile'] ?? '',
|
||||
'province' => $item['province'] ?? '',
|
||||
'city' => $item['city'] ?? '',
|
||||
'district' => $item['district'] ?? '',
|
||||
'detail_address' => $item['detail_address'] ?? '',
|
||||
'full_address' => trim(sprintf(
|
||||
'%s%s%s%s',
|
||||
$item['province'] ?? '',
|
||||
$item['city'] ?? '',
|
||||
$item['district'] ?? '',
|
||||
$item['detail_address'] ?? ''
|
||||
)),
|
||||
'is_default' => (bool)($item['is_default'] ?? false),
|
||||
'created_at' => $item['created_at'] ?? '',
|
||||
'updated_at' => $item['updated_at'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
663
server-api/app/controller/app/AppraisalController.php
Normal file
663
server-api/app/controller/app/AppraisalController.php
Normal file
@@ -0,0 +1,663 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\ContentService;
|
||||
use app\support\FileStorageService;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
use function str_starts_with;
|
||||
|
||||
class AppraisalController
|
||||
{
|
||||
public function uploadFile(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$draftId = (int)$request->post('draft_id', 0);
|
||||
$itemCode = trim((string)$request->post('item_code', ''));
|
||||
$itemName = trim((string)$request->post('item_name', ''));
|
||||
|
||||
if (!$draftId || $itemCode === '') {
|
||||
return api_error('草稿 ID 和资料项编码不能为空', 422);
|
||||
}
|
||||
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find();
|
||||
if (!$draft) {
|
||||
return api_error('草稿不存在', 404);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return api_error('上传文件无效', 422);
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
|
||||
$filename = sprintf('%s_%s.%s', $itemCode, uniqid(), $extension);
|
||||
$relativeDir = 'uploads/appraisal/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
|
||||
return api_success([
|
||||
'file_id' => md5($relativePath),
|
||||
'item_code' => $itemCode,
|
||||
'item_name' => $itemName,
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileUrl,
|
||||
'name' => $file->getUploadName(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function createDraft(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$serviceProvider = (string)$request->input('service_provider', 'anxinyan');
|
||||
$serviceMode = (string)$request->input('service_mode', 'physical');
|
||||
|
||||
$draftId = Db::name('appraisal_drafts')->insertGetId([
|
||||
'user_id' => $userId,
|
||||
'service_mode' => $serviceMode,
|
||||
'service_provider' => $serviceProvider,
|
||||
'current_step' => 1,
|
||||
'status' => 'draft',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'draft_id' => (int)$draftId,
|
||||
'service_provider' => $serviceProvider,
|
||||
'service_mode' => $serviceMode,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteFile(Request $request)
|
||||
{
|
||||
$fileUrl = trim((string)$request->post('file_url', ''));
|
||||
if ($fileUrl === '') {
|
||||
return api_error('文件地址不能为空', 422);
|
||||
}
|
||||
|
||||
$relativePath = $this->storage()->storagePath($fileUrl);
|
||||
if (!str_starts_with($relativePath, 'uploads/appraisal/')) {
|
||||
return api_error('不允许删除该文件', 403);
|
||||
}
|
||||
|
||||
$this->storage()->delete($relativePath);
|
||||
|
||||
return api_success([
|
||||
'file_url' => $fileUrl,
|
||||
], '删除成功');
|
||||
}
|
||||
|
||||
public function draftDetail(Request $request)
|
||||
{
|
||||
$draftId = (int)$request->input('draft_id', 0);
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
|
||||
if (!$draft) {
|
||||
return api_error('草稿不存在', 404);
|
||||
}
|
||||
|
||||
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
|
||||
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
|
||||
|
||||
return api_success([
|
||||
'draft_id' => (int)$draft['id'],
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'service_mode' => $draft['service_mode'],
|
||||
'current_step' => (int)$draft['current_step'],
|
||||
'product_info' => $product ?: new \stdClass(),
|
||||
'extra_info' => $extra ?: new \stdClass(),
|
||||
'upload_info' => [
|
||||
'items' => $this->draftUploadItems($draftId, $request),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveDraft(Request $request)
|
||||
{
|
||||
$draftId = (int)$request->input('draft_id', 0);
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
|
||||
if (!$draft) {
|
||||
return api_error('草稿不存在', 404);
|
||||
}
|
||||
|
||||
$currentStep = (int)$request->input('current_step', $draft['current_step']);
|
||||
$productInfo = (array)$request->input('product_info', []);
|
||||
$extraInfo = (array)$request->input('extra_info', []);
|
||||
$uploadInfo = (array)$request->input('upload_info', []);
|
||||
|
||||
Db::name('appraisal_drafts')
|
||||
->where('id', $draftId)
|
||||
->update([
|
||||
'service_provider' => $request->input('service_provider', $draft['service_provider']),
|
||||
'current_step' => $currentStep,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($productInfo) {
|
||||
$payload = [
|
||||
'draft_id' => $draftId,
|
||||
'category_id' => $productInfo['category_id'] ?? null,
|
||||
'brand_id' => $productInfo['brand_id'] ?? null,
|
||||
'color' => $productInfo['color'] ?? '',
|
||||
'size_spec' => $productInfo['size_spec'] ?? '',
|
||||
'serial_no' => $productInfo['serial_no'] ?? '',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$exists = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
|
||||
if ($exists) {
|
||||
Db::name('appraisal_draft_products')->where('draft_id', $draftId)->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
Db::name('appraisal_draft_products')->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
if ($extraInfo) {
|
||||
$purchaseDate = $extraInfo['purchase_date'] ?? null;
|
||||
if ($purchaseDate === '') {
|
||||
$purchaseDate = null;
|
||||
}
|
||||
$payload = [
|
||||
'draft_id' => $draftId,
|
||||
'purchase_channel' => $extraInfo['purchase_channel'] ?? '',
|
||||
'purchase_price' => $extraInfo['purchase_price'] ?? 0,
|
||||
'purchase_date' => $purchaseDate,
|
||||
'usage_status' => $extraInfo['usage_status'] ?? '',
|
||||
'condition_desc' => $extraInfo['condition_desc'] ?? '',
|
||||
'has_accessories' => !empty($extraInfo['accessories']) ? 1 : 0,
|
||||
'accessories_json' => json_encode($extraInfo['accessories'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'remark' => $extraInfo['remark'] ?? '',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$exists = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
|
||||
if ($exists) {
|
||||
Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = date('Y-m-d H:i:s');
|
||||
Db::name('appraisal_draft_extras')->insert($payload);
|
||||
}
|
||||
}
|
||||
|
||||
if ($uploadInfo) {
|
||||
$draftUploadIds = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->column('id');
|
||||
if ($draftUploadIds) {
|
||||
Db::name('appraisal_draft_upload_files')->whereIn('draft_upload_id', $draftUploadIds)->delete();
|
||||
}
|
||||
Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->delete();
|
||||
foreach (($uploadInfo['items'] ?? []) as $item) {
|
||||
$draftUploadId = Db::name('appraisal_draft_uploads')->insertGetId([
|
||||
'draft_id' => $draftId,
|
||||
'template_id' => $uploadInfo['template_id'] ?? null,
|
||||
'item_code' => $item['item_code'] ?? '',
|
||||
'item_name' => $item['item_name'] ?? '',
|
||||
'is_required' => !empty($item['is_required']) ? 1 : 0,
|
||||
'quality_status' => $item['quality_status'] ?? 'pending',
|
||||
'quality_message' => $item['quality_message'] ?? '',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
foreach (($item['files'] ?? []) as $index => $file) {
|
||||
Db::name('appraisal_draft_upload_files')->insert([
|
||||
'draft_upload_id' => $draftUploadId,
|
||||
'file_id' => $file['file_id'] ?? '',
|
||||
'file_url' => $this->assetUrlService()->storagePath((string)($file['file_url'] ?? '')),
|
||||
'thumbnail_url' => $this->assetUrlService()->storagePath((string)($file['thumbnail_url'] ?? ($file['file_url'] ?? ''))),
|
||||
'sort_order' => $index,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return api_success(['draft_id' => $draftId, 'current_step' => $currentStep]);
|
||||
}
|
||||
|
||||
public function uploadTemplate(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 1);
|
||||
$serviceProvider = (string)$request->input('service_provider', 'anxinyan');
|
||||
|
||||
$template = Db::name('upload_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->where('service_provider', $serviceProvider)
|
||||
->where('is_enabled', 1)
|
||||
->find();
|
||||
|
||||
if (!$template) {
|
||||
$template = Db::name('upload_templates')
|
||||
->where('scope_type', 'category')
|
||||
->where('scope_id', $categoryId)
|
||||
->where('service_provider', 'anxinyan')
|
||||
->where('is_enabled', 1)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$template) {
|
||||
return api_success([
|
||||
'template_id' => 0,
|
||||
'required_items' => [],
|
||||
'optional_items' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$items = Db::name('upload_template_items')
|
||||
->where('template_id', $template['id'])
|
||||
->where('is_enabled', 1)
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$requiredItems = [];
|
||||
$optionalItems = [];
|
||||
foreach ($items as $item) {
|
||||
$payload = [
|
||||
'item_code' => $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'guide_text' => $item['guide_text'],
|
||||
'sample_image_url' => $this->assetUrlService()->normalizeUrl((string)$item['sample_image_url'], $request),
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
'quality_status' => $item['is_required'] ? 'pending' : 'optional',
|
||||
'quality_message' => '',
|
||||
];
|
||||
if ($item['is_required']) {
|
||||
$requiredItems[] = $payload;
|
||||
} else {
|
||||
$optionalItems[] = $payload;
|
||||
}
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'template_id' => (int)$template['id'],
|
||||
'required_items' => $requiredItems,
|
||||
'optional_items' => $optionalItems,
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request)
|
||||
{
|
||||
$draftId = (int)$request->input('draft_id', 0);
|
||||
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
|
||||
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
|
||||
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
|
||||
|
||||
if (!$draft) {
|
||||
return api_error('预览数据不存在', 404);
|
||||
}
|
||||
|
||||
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
|
||||
$policyConfig = (new ContentService())->getPolicyConfig();
|
||||
|
||||
return api_success([
|
||||
'service_summary' => [
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'service_provider_text' => $draft['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
],
|
||||
'product_summary' => [
|
||||
'product_name' => $this->resolveProductName($product),
|
||||
'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null),
|
||||
'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null),
|
||||
'price' => $extra['purchase_price'] ?? 0,
|
||||
],
|
||||
'upload_summary' => [
|
||||
'uploaded_count' => $this->countUploadedDraftItems($draftId),
|
||||
],
|
||||
'fee_detail' => [
|
||||
'service_fee' => (float)$serviceConfig['price'],
|
||||
'discount_fee' => 0,
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
],
|
||||
'agreements' => $policyConfig['appraisal_agreements'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$draftId = (int)$request->input('draft_id', 0);
|
||||
$returnAddressId = (int)$request->input('return_address_id', 0);
|
||||
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', 'mini_program'));
|
||||
$sourceCustomerId = trim((string)$request->input('source_customer_id', ''));
|
||||
|
||||
if ($sourceChannel === 'enterprise_push' && $sourceCustomerId === '') {
|
||||
return api_error('大客户推送订单必须提供客户 ID', 422);
|
||||
}
|
||||
if ($sourceChannel !== 'enterprise_push') {
|
||||
$sourceCustomerId = '';
|
||||
}
|
||||
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find();
|
||||
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
|
||||
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
|
||||
|
||||
if (!$draft || !$product) {
|
||||
return api_error('提交数据不完整', 422);
|
||||
}
|
||||
|
||||
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
|
||||
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
|
||||
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
|
||||
$productName = $this->resolveProductName($product);
|
||||
$warehouseService = new WarehouseService();
|
||||
$defaultAddress = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
$returnAddress = null;
|
||||
if ($returnAddressId > 0) {
|
||||
$returnAddress = Db::name('user_addresses')
|
||||
->where('id', $returnAddressId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if (!$returnAddress) {
|
||||
return api_error('寄回地址不存在,请重新选择', 422);
|
||||
}
|
||||
}
|
||||
if (!$returnAddress) {
|
||||
$returnAddress = $defaultAddress ?: Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
if (!$returnAddress) {
|
||||
return api_error('请先添加并确认寄回地址', 422);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$orderId = Db::name('orders')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'user_id' => $userId,
|
||||
'service_mode' => $draft['service_mode'],
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'payment_status' => 'paid',
|
||||
'order_status' => 'pending_shipping',
|
||||
'display_status' => '待寄送商品',
|
||||
'estimated_finish_time' => $estimated,
|
||||
'source_channel' => $sourceChannel,
|
||||
'source_customer_id' => $sourceCustomerId,
|
||||
'pay_amount' => $serviceConfig['price'],
|
||||
'paid_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_products')->insert([
|
||||
'order_id' => $orderId,
|
||||
'category_id' => $product['category_id'] ?? null,
|
||||
'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null),
|
||||
'brand_id' => $product['brand_id'] ?? null,
|
||||
'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null),
|
||||
'color' => $product['color'] ?? '',
|
||||
'size_spec' => $product['size_spec'] ?? '',
|
||||
'serial_no' => $product['serial_no'] ?? '',
|
||||
'product_name' => $productName,
|
||||
'product_cover' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_extras')->insert([
|
||||
'order_id' => $orderId,
|
||||
'purchase_channel' => $extra['purchase_channel'] ?? '',
|
||||
'purchase_price' => $extra['purchase_price'] ?? 0,
|
||||
'purchase_date' => $extra['purchase_date'] ?? null,
|
||||
'usage_status' => $extra['usage_status'] ?? '',
|
||||
'condition_desc' => $extra['condition_desc'] ?? '',
|
||||
'has_accessories' => $extra['has_accessories'] ?? 0,
|
||||
'accessories_json' => $extra['accessories_json'] ?? json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'remark' => $extra['remark'] ?? '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($returnAddress) {
|
||||
Db::name('order_return_addresses')->insert([
|
||||
'order_id' => $orderId,
|
||||
'user_address_id' => (int)$returnAddress['id'],
|
||||
'consignee' => $returnAddress['consignee'] ?? '',
|
||||
'mobile' => $returnAddress['mobile'] ?? '',
|
||||
'province' => $returnAddress['province'] ?? '',
|
||||
'city' => $returnAddress['city'] ?? '',
|
||||
'district' => $returnAddress['district'] ?? '',
|
||||
'detail_address' => $returnAddress['detail_address'] ?? '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$shippingTarget = $warehouseService->bindOrderTarget(
|
||||
$orderId,
|
||||
(string)$draft['service_provider'],
|
||||
!empty($product['category_id']) ? (int)$product['category_id'] : null,
|
||||
$defaultAddress ?: null
|
||||
);
|
||||
|
||||
Db::name('order_timelines')->insertAll([
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'created',
|
||||
'node_text' => '下单成功',
|
||||
'node_desc' => '订单已生成并完成支付',
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'pending_shipping',
|
||||
'node_text' => '待寄送商品',
|
||||
'node_desc' => sprintf(
|
||||
'请尽快将商品寄送至%s,以免影响处理时效',
|
||||
$shippingTarget['warehouse_name'] ?: '鉴定中心'
|
||||
),
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$draftUploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray();
|
||||
foreach ($draftUploads as $draftUpload) {
|
||||
$draftFiles = Db::name('appraisal_draft_upload_files')->where('draft_upload_id', $draftUpload['id'])->select()->toArray();
|
||||
if (!$draftFiles) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderUploadId = Db::name('order_upload_items')->insertGetId([
|
||||
'order_id' => $orderId,
|
||||
'template_id' => $draftUpload['template_id'],
|
||||
'item_code' => $draftUpload['item_code'],
|
||||
'item_name' => $draftUpload['item_name'],
|
||||
'is_required' => $draftUpload['is_required'],
|
||||
'source_type' => 'initial',
|
||||
'status' => $draftUpload['quality_status'],
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach ($draftFiles as $draftFile) {
|
||||
Db::name('order_upload_files')->insert([
|
||||
'order_upload_item_id' => $orderUploadId,
|
||||
'file_id' => $draftFile['file_id'],
|
||||
'file_url' => $draftFile['file_url'],
|
||||
'thumbnail_url' => $draftFile['thumbnail_url'],
|
||||
'quality_status' => $draftUpload['quality_status'],
|
||||
'quality_message' => $draftUpload['quality_message'],
|
||||
'uploaded_by_user_id' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Db::name('appraisal_tasks')->insert([
|
||||
'order_id' => $orderId,
|
||||
'task_stage' => 'first_review',
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'status' => 'pending',
|
||||
'assignee_id' => null,
|
||||
'assignee_name' => '未分配',
|
||||
'started_at' => null,
|
||||
'submitted_at' => null,
|
||||
'sla_deadline' => $estimated,
|
||||
'is_overtime' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('appraisal_drafts')->where('id', $draftId)->update([
|
||||
'status' => 'submitted',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('order_created', [
|
||||
'user_id' => $userId,
|
||||
'biz_type' => 'order',
|
||||
'biz_id' => (int)$orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'product_name' => $productName,
|
||||
'pay_amount' => (string)$serviceConfig['price'],
|
||||
'fallback_title' => '订单提交成功',
|
||||
'fallback_content' => '您的鉴定订单已提交成功,可前往订单中心查看进度。',
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('提交失败,请稍后重试', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => (int)$orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
'next_status' => 'pending_shipping',
|
||||
]);
|
||||
}
|
||||
|
||||
private function lookupName(string $table, string $field, mixed $id): string
|
||||
{
|
||||
if (empty($id)) {
|
||||
return '';
|
||||
}
|
||||
return (string)Db::name($table)->where('id', $id)->value($field);
|
||||
}
|
||||
|
||||
private function resolveProductName(?array $product): string
|
||||
{
|
||||
if (!$product) {
|
||||
return '';
|
||||
}
|
||||
$categoryName = $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null);
|
||||
$brandName = $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null);
|
||||
$fallbackName = trim($categoryName . ' ' . $brandName);
|
||||
if ($fallbackName !== '') {
|
||||
return $fallbackName;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function serviceConfig(string $serviceProvider): array
|
||||
{
|
||||
$configs = [
|
||||
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
|
||||
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
|
||||
];
|
||||
if (isset($configs[$serviceProvider])) {
|
||||
return $configs[$serviceProvider];
|
||||
}
|
||||
return $configs['anxinyan'];
|
||||
}
|
||||
|
||||
private function draftUploadItems(int $draftId, Request $request): array
|
||||
{
|
||||
$uploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray();
|
||||
if (!$uploads) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function (array $item) use ($request) {
|
||||
$files = Db::name('appraisal_draft_upload_files')
|
||||
->where('draft_upload_id', $item['id'])
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'item_code' => $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
'quality_status' => $item['quality_status'],
|
||||
'quality_message' => $item['quality_message'],
|
||||
'files' => array_map(fn (array $file) => [
|
||||
'file_id' => $file['file_id'],
|
||||
'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request),
|
||||
'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request),
|
||||
], $files),
|
||||
];
|
||||
}, $uploads);
|
||||
}
|
||||
|
||||
private function countUploadedDraftItems(int $draftId): int
|
||||
{
|
||||
$uploadIds = Db::name('appraisal_draft_upload_files')
|
||||
->alias('f')
|
||||
->join('appraisal_draft_uploads u', 'u.id = f.draft_upload_id')
|
||||
->where('u.draft_id', $draftId)
|
||||
->group('u.id')
|
||||
->column('u.id');
|
||||
|
||||
return count($uploadIds);
|
||||
}
|
||||
|
||||
private function assetUrlService(): PublicAssetUrlService
|
||||
{
|
||||
return new PublicAssetUrlService();
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
|
||||
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
||||
{
|
||||
$sourceChannel = trim($sourceChannel);
|
||||
$aliases = [
|
||||
'wechat_mini_program' => 'mini_program',
|
||||
'weixin_mini_program' => 'mini_program',
|
||||
'mp_weixin' => 'mini_program',
|
||||
'miniapp' => 'mini_program',
|
||||
'user_app' => 'mini_program',
|
||||
'web_h5' => 'h5',
|
||||
'enterprise' => 'enterprise_push',
|
||||
'enterprise_order' => 'enterprise_push',
|
||||
'customer_push' => 'enterprise_push',
|
||||
'large_customer_push' => 'enterprise_push',
|
||||
];
|
||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true)
|
||||
? $sourceChannel
|
||||
: 'mini_program';
|
||||
}
|
||||
}
|
||||
112
server-api/app/controller/app/AuthController.php
Normal file
112
server-api/app/controller/app/AuthController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\AppAuthService;
|
||||
use support\Request;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
public function sendCode(Request $request)
|
||||
{
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
if ($mobile === '') {
|
||||
return api_error('手机号不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->sendLoginCode($mobile, $request);
|
||||
return api_success($payload, '验证码已发送');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('验证码发送失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function loginByCode(Request $request)
|
||||
{
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
if ($mobile === '' || $code === '') {
|
||||
return api_error('手机号和验证码不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->loginByCode($mobile, $code, $request);
|
||||
return api_success($payload, '登录成功');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 401);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('登录失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function loginByPassword(Request $request)
|
||||
{
|
||||
$mobile = trim((string)$request->input('mobile', ''));
|
||||
$password = trim((string)$request->input('password', ''));
|
||||
if ($mobile === '' || $password === '') {
|
||||
return api_error('手机号和密码不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->loginByPassword($mobile, $password, $request);
|
||||
return api_success($payload, '登录成功');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 401);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('登录失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
$userInfo = (new AppAuthService())->current($request);
|
||||
if (!$userInfo) {
|
||||
return api_error('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'user_info' => $userInfo,
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePassword(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$currentPassword = trim((string)$request->input('current_password', ''));
|
||||
$newPassword = trim((string)$request->input('new_password', ''));
|
||||
$confirmPassword = trim((string)$request->input('confirm_password', ''));
|
||||
|
||||
if ($newPassword === '' || $confirmPassword === '') {
|
||||
return api_error('新密码和确认密码不能为空', 422);
|
||||
}
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
return api_error('两次输入的新密码不一致', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new AppAuthService())->savePassword($userId, $currentPassword, $newPassword);
|
||||
return api_success($payload, !empty($payload['had_password']) ? '登录密码已更新' : '登录密码已设置');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('密码保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
(new AppAuthService())->logout($request);
|
||||
return api_success([], '已退出登录');
|
||||
}
|
||||
}
|
||||
42
server-api/app/controller/app/CatalogController.php
Normal file
42
server-api/app/controller/app/CatalogController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class CatalogController
|
||||
{
|
||||
public function categories(Request $request)
|
||||
{
|
||||
$list = Db::name('catalog_categories')
|
||||
->field(['id AS category_id', 'name AS category_name', 'code AS category_code'])
|
||||
->where('is_enabled', 1)
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function brands(Request $request)
|
||||
{
|
||||
$categoryId = (int)$request->input('category_id', 0);
|
||||
|
||||
$query = Db::name('catalog_brands')
|
||||
->alias('b')
|
||||
->field(['b.id AS brand_id', 'b.name AS brand_name', 'b.en_name AS brand_en_name'])
|
||||
->where('b.is_enabled', 1)
|
||||
->order('b.sort_order', 'asc');
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$query->join('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
|
||||
->where('cbc.category_id', $categoryId);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'list' => $query->select()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
101
server-api/app/controller/app/HelpCenterController.php
Normal file
101
server-api/app/controller/app/HelpCenterController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ContentService;
|
||||
use support\Request;
|
||||
|
||||
class HelpCenterController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('q', ''));
|
||||
$category = trim((string)$request->input('category', ''));
|
||||
|
||||
$articles = $this->articles();
|
||||
$allArticles = $this->articles();
|
||||
$categoryConfig = (new ContentService())->getHelpCategories();
|
||||
|
||||
if ($keyword !== '') {
|
||||
$articles = array_values(array_filter($articles, function (array $item) use ($keyword) {
|
||||
$haystack = implode(' ', array_merge(
|
||||
[$item['title'], $item['summary'], $item['category_text']],
|
||||
$item['keywords'],
|
||||
$item['content_blocks']
|
||||
));
|
||||
|
||||
return str_contains($haystack, $keyword);
|
||||
}));
|
||||
}
|
||||
|
||||
if ($category !== '' && $category !== 'all') {
|
||||
$articles = array_values(array_filter($articles, fn (array $item) => $item['category'] === $category));
|
||||
}
|
||||
|
||||
$categoryCounts = [];
|
||||
foreach ($allArticles as $item) {
|
||||
$categoryCounts[$item['category']] = ($categoryCounts[$item['category']] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$categories = array_map(function (array $item) use ($allArticles, $categoryCounts) {
|
||||
$code = (string)($item['code'] ?? '');
|
||||
return [
|
||||
'code' => $code,
|
||||
'title' => (string)($item['title'] ?? $code),
|
||||
'desc' => (string)($item['desc'] ?? ''),
|
||||
'count' => $code === 'all' ? count($allArticles) : (int)($categoryCounts[$code] ?? 0),
|
||||
];
|
||||
}, $categoryConfig);
|
||||
|
||||
return api_success([
|
||||
'categories' => $categories,
|
||||
'articles' => array_map(fn (array $item) => $this->articleSummary($item), $articles),
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('文章 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$article = null;
|
||||
foreach ($this->articles() as $item) {
|
||||
if ((int)$item['id'] === $id) {
|
||||
$article = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$article) {
|
||||
return api_error('帮助文章不存在', 404);
|
||||
}
|
||||
|
||||
$related = array_values(array_filter($this->articles(), fn (array $item) => $item['category'] === $article['category'] && $item['id'] !== $article['id']));
|
||||
|
||||
return api_success([
|
||||
'article' => $article,
|
||||
'related_articles' => array_map(fn (array $item) => $this->articleSummary($item), array_slice($related, 0, 3)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function articleSummary(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'title' => $item['title'],
|
||||
'category' => $item['category'],
|
||||
'category_text' => $item['category_text'],
|
||||
'summary' => $item['summary'],
|
||||
'keywords' => $item['keywords'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
'is_recommended' => (bool)$item['is_recommended'],
|
||||
];
|
||||
}
|
||||
|
||||
private function articles(): array
|
||||
{
|
||||
return (new ContentService())->getHelpArticles(true);
|
||||
}
|
||||
}
|
||||
94
server-api/app/controller/app/HomeController.php
Normal file
94
server-api/app/controller/app/HomeController.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\model\CatalogCategory;
|
||||
use app\support\ContentService;
|
||||
use app\support\FileStorageService;
|
||||
use support\Request;
|
||||
|
||||
class HomeController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$content = (new ContentService())->getHomeConfig();
|
||||
$categoryVisuals = $this->categoryVisualMap($content['category_visuals'] ?? [], $request);
|
||||
|
||||
$categories = CatalogCategory::where('is_enabled', 1)
|
||||
->order('sort_order', 'asc')
|
||||
->field(['id', 'name', 'code'])
|
||||
->select()
|
||||
->map(function ($item) use ($categoryVisuals) {
|
||||
$code = (string)$item->code;
|
||||
$name = (string)$item->name;
|
||||
$codeKey = $this->categoryMatchKey($code);
|
||||
$nameKey = $this->categoryMatchKey($name);
|
||||
|
||||
return [
|
||||
'category_id' => (int)$item->id,
|
||||
'category_name' => $name,
|
||||
'category_code' => $code,
|
||||
'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '',
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'banners' => $content['banners'],
|
||||
'page_visuals' => $content['page_visuals'],
|
||||
'service_entries' => $content['service_entries'],
|
||||
'category_entries' => $categories,
|
||||
'quick_entries' => $content['quick_entries'],
|
||||
'trust_metrics' => $content['trust_metrics'],
|
||||
'trust_points' => $content['trust_points'],
|
||||
'faqs' => $content['faqs'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function pageVisuals(Request $request)
|
||||
{
|
||||
$content = (new ContentService())->getHomeConfig();
|
||||
|
||||
return api_success($content['page_visuals'] ?? [
|
||||
'order_background_image_url' => '',
|
||||
'report_background_image_url' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function categoryVisualMap(array $items, Request $request): array
|
||||
{
|
||||
$map = [];
|
||||
$storage = new FileStorageService();
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageUrl = trim((string)($item['image_url'] ?? ''));
|
||||
if ($imageUrl === '') {
|
||||
continue;
|
||||
}
|
||||
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
|
||||
|
||||
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
|
||||
if ($categoryCode !== '') {
|
||||
$map['code:' . $categoryCode] = $imageUrl;
|
||||
}
|
||||
|
||||
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
|
||||
if ($categoryName !== '') {
|
||||
$map['name:' . $categoryName] = $imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function categoryMatchKey(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
|
||||
|
||||
return strtolower($normalized ?? $value);
|
||||
}
|
||||
}
|
||||
49
server-api/app/controller/app/MaterialTagsController.php
Normal file
49
server-api/app/controller/app/MaterialTagsController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\MaterialTagService;
|
||||
use support\Request;
|
||||
|
||||
class MaterialTagsController
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$token = trim((string)$request->input('token', ''));
|
||||
if ($token === '') {
|
||||
return api_error('吊牌标识不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return api_success($this->service()->showPublicTag($token, $request));
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404, [
|
||||
'tag_status' => 'not_found',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$token = trim((string)$request->input('token', ''));
|
||||
$reportNo = trim((string)$request->input('report_no', ''));
|
||||
$verifyCode = trim((string)$request->input('verify_code', ''));
|
||||
|
||||
if ($token === '' || $reportNo === '' || $verifyCode === '') {
|
||||
return api_error('吊牌标识、报告编号和验真编码不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return api_success($this->service()->verifyPublicTag($token, $reportNo, $verifyCode, $request));
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404, [
|
||||
'verify_passed' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function service(): MaterialTagService
|
||||
{
|
||||
return new MaterialTagService();
|
||||
}
|
||||
}
|
||||
277
server-api/app/controller/app/MessagesController.php
Normal file
277
server-api/app/controller/app/MessagesController.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ContentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class MessagesController
|
||||
{
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$rows = Db::name('user_messages')
|
||||
->where('user_id', $userId)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$latest = $rows[0] ?? null;
|
||||
$summary = $this->buildSummary($rows);
|
||||
|
||||
return api_success([
|
||||
'total_count' => $summary['total_count'],
|
||||
'unread_count' => $summary['unread_count'],
|
||||
'category_counts' => $summary['category_counts'],
|
||||
'latest_title' => $latest['title'] ?? '',
|
||||
'latest_time' => $latest['created_at'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function meta(Request $request)
|
||||
{
|
||||
$content = new ContentService();
|
||||
|
||||
return api_success([
|
||||
'message_page_copy' => $content->getMessagePageCopy(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$category = $this->normalizeCategory((string)$request->input('category', 'all'));
|
||||
$unreadOnly = (bool)$request->input('unread_only', false);
|
||||
|
||||
$rows = Db::name('user_messages')
|
||||
->where('user_id', $userId)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$filteredRows = array_values(array_filter($rows, function (array $item) use ($category, $unreadOnly) {
|
||||
if ($unreadOnly && (bool)$item['is_read']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($category !== 'all' && $this->messageCategory($item['biz_type'] ?? '') !== $category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
[$targetUrl, $targetLabel] = $this->resolveMessageTarget($item);
|
||||
$messageCategory = $this->messageCategory($item['biz_type']);
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'title' => $item['title'],
|
||||
'content' => $item['content'] ?: '',
|
||||
'biz_type' => $item['biz_type'],
|
||||
'biz_type_text' => $this->bizTypeText($item['biz_type']),
|
||||
'category' => $messageCategory,
|
||||
'category_text' => $this->categoryText($messageCategory),
|
||||
'biz_id' => (int)($item['biz_id'] ?? 0),
|
||||
'is_read' => (bool)$item['is_read'],
|
||||
'created_at' => $item['created_at'],
|
||||
'target_url' => $targetUrl,
|
||||
'target_label' => $targetLabel,
|
||||
];
|
||||
}, $filteredRows);
|
||||
|
||||
$summary = $this->buildSummary($rows);
|
||||
|
||||
return api_success([
|
||||
'list' => $list,
|
||||
'summary' => array_merge($summary, [
|
||||
'current_count' => count($list),
|
||||
'current_category' => $category,
|
||||
'unread_only' => $unreadOnly,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function read(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('消息 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$message = Db::name('user_messages')->where('id', $id)->where('user_id', app_user_id($request))->find();
|
||||
if (!$message) {
|
||||
return api_error('消息不存在', 404);
|
||||
}
|
||||
|
||||
if (!(bool)$message['is_read']) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('user_messages')->where('id', $id)->update([
|
||||
'is_read' => 1,
|
||||
'read_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'is_read' => true,
|
||||
], '已标记为已读');
|
||||
}
|
||||
|
||||
public function readAll(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$affected = Db::name('user_messages')
|
||||
->where('user_id', $userId)
|
||||
->where('is_read', 0)
|
||||
->update([
|
||||
'is_read' => 1,
|
||||
'read_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'affected' => (int)$affected,
|
||||
], '已全部标记为已读');
|
||||
}
|
||||
|
||||
private function resolveMessageTarget(array $message): array
|
||||
{
|
||||
$bizType = $message['biz_type'] ?? '';
|
||||
$bizId = (int)($message['biz_id'] ?? 0);
|
||||
|
||||
if ($bizType === 'report' && $bizId > 0) {
|
||||
$report = Db::name('reports')->where('id', $bizId)->find();
|
||||
if ($report) {
|
||||
return ["/pages/report/detail?report_no=" . rawurlencode((string)$report['report_no']), '查看报告'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bizType === 'order' && $bizId > 0) {
|
||||
$order = Db::name('orders')->where('id', $bizId)->find();
|
||||
if ($order) {
|
||||
return ["/pages/order/detail?id={$bizId}", '查看订单'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bizType === 'return_shipped' && $bizId > 0) {
|
||||
$order = Db::name('orders')->where('id', $bizId)->find();
|
||||
if ($order) {
|
||||
return ["/pages/order/detail?id={$bizId}", '查看物流'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bizType === 'return_received' && $bizId > 0) {
|
||||
$order = Db::name('orders')->where('id', $bizId)->find();
|
||||
if ($order) {
|
||||
return ["/pages/order/detail?id={$bizId}", '查看订单'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bizType === 'supplement' && $bizId > 0) {
|
||||
$supplementTask = Db::name('order_supplement_tasks')->where('id', $bizId)->find();
|
||||
if ($supplementTask) {
|
||||
if (($supplementTask['status'] ?? '') === 'pending') {
|
||||
return ["/pages/order/supplement?order_id={$supplementTask['order_id']}", '去补资料'];
|
||||
}
|
||||
return ["/pages/order/detail?id={$supplementTask['order_id']}", '查看进度'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bizType === 'ticket_message' && $bizId > 0) {
|
||||
$ticketMessage = Db::name('ticket_messages')->where('id', $bizId)->find();
|
||||
if ($ticketMessage) {
|
||||
return ["/pages/support/detail?id={$ticketMessage['ticket_id']}", '查看工单'];
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($bizType, ['ticket_waiting_user', 'ticket_resolved', 'ticket_closed'], true) && $bizId > 0) {
|
||||
$ticket = Db::name('tickets')->where('id', $bizId)->find();
|
||||
if ($ticket) {
|
||||
return ["/pages/support/detail?id={$ticket['id']}", '查看工单'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['', '查看详情'];
|
||||
}
|
||||
|
||||
private function bizTypeText(string $bizType): string
|
||||
{
|
||||
return match ($bizType) {
|
||||
'report' => '报告通知',
|
||||
'order' => '订单通知',
|
||||
'return_shipped' => '回寄通知',
|
||||
'return_received' => '签收通知',
|
||||
'supplement' => '补资料通知',
|
||||
'ticket_message' => '工单通知',
|
||||
'ticket_waiting_user' => '工单通知',
|
||||
'ticket_resolved' => '工单通知',
|
||||
'ticket_closed' => '工单通知',
|
||||
default => '系统通知',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildSummary(array $rows): array
|
||||
{
|
||||
$categoryCounts = [
|
||||
'all' => count($rows),
|
||||
'order' => 0,
|
||||
'report' => 0,
|
||||
'supplement' => 0,
|
||||
'ticket' => 0,
|
||||
];
|
||||
|
||||
$unreadCount = 0;
|
||||
foreach ($rows as $item) {
|
||||
$category = $this->messageCategory($item['biz_type'] ?? '');
|
||||
if ($category !== 'all' && isset($categoryCounts[$category])) {
|
||||
$categoryCounts[$category]++;
|
||||
}
|
||||
if (!(bool)($item['is_read'] ?? false)) {
|
||||
$unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => count($rows),
|
||||
'unread_count' => $unreadCount,
|
||||
'category_counts' => $categoryCounts,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCategory(string $category): string
|
||||
{
|
||||
return in_array($category, ['all', 'order', 'report', 'supplement', 'ticket'], true)
|
||||
? $category
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function messageCategory(string $bizType): string
|
||||
{
|
||||
return match ($bizType) {
|
||||
'order' => 'order',
|
||||
'return_shipped' => 'order',
|
||||
'return_received' => 'order',
|
||||
'report' => 'report',
|
||||
'supplement' => 'supplement',
|
||||
'ticket_message', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'ticket',
|
||||
default => 'all',
|
||||
};
|
||||
}
|
||||
|
||||
private function categoryText(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
'order' => '订单',
|
||||
'report' => '报告',
|
||||
'supplement' => '补资料',
|
||||
'ticket' => '工单',
|
||||
default => '全部',
|
||||
};
|
||||
}
|
||||
}
|
||||
97
server-api/app/controller/app/MineController.php
Normal file
97
server-api/app/controller/app/MineController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class MineController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user) {
|
||||
return api_error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$ordersCount = (int)Db::name('orders')
|
||||
->where('user_id', $userId)
|
||||
->count();
|
||||
|
||||
$reportRows = Db::name('reports')
|
||||
->alias('r')
|
||||
->join('orders o', 'o.id = r.order_id')
|
||||
->leftJoin('report_contents c', 'c.report_id = r.id')
|
||||
->field([
|
||||
'r.id',
|
||||
'r.report_no',
|
||||
'c.result_snapshot_json',
|
||||
'c.valuation_snapshot_json',
|
||||
])
|
||||
->where('o.user_id', $userId)
|
||||
->where('r.report_status', 'published')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$reportCount = count($reportRows);
|
||||
$authenticCount = 0;
|
||||
$totalValuation = 0.0;
|
||||
|
||||
foreach ($reportRows as $row) {
|
||||
$result = $this->decodeJsonField($row['result_snapshot_json'] ?? null);
|
||||
$valuation = $this->decodeJsonField($row['valuation_snapshot_json'] ?? null);
|
||||
|
||||
$resultStatus = (string)($result['result_status'] ?? '');
|
||||
$resultText = (string)($result['result_text'] ?? '');
|
||||
if ($resultStatus === 'authentic' || str_contains($resultText, '正')) {
|
||||
$authenticCount++;
|
||||
}
|
||||
|
||||
$min = (float)($valuation['valuation_min'] ?? 0);
|
||||
$max = (float)($valuation['valuation_max'] ?? 0);
|
||||
if ($min > 0 && $max > 0) {
|
||||
$totalValuation += ($min + $max) / 2;
|
||||
} else {
|
||||
$totalValuation += max($min, $max);
|
||||
}
|
||||
}
|
||||
|
||||
$unreadCount = (int)Db::name('user_messages')
|
||||
->where('user_id', $userId)
|
||||
->where('is_read', 0)
|
||||
->count();
|
||||
|
||||
return api_success([
|
||||
'profile_info' => [
|
||||
'user_id' => (int)$user['id'],
|
||||
'nickname' => $user['nickname'] ?: '安心验用户',
|
||||
'mobile' => $user['mobile'] ?: '',
|
||||
'avatar' => $user['avatar'] ?: '',
|
||||
'status' => $user['status'] ?: 'enabled',
|
||||
'status_text' => ($user['status'] ?? 'enabled') === 'enabled' ? '账号正常' : '账号异常',
|
||||
'password_set' => ((string)($user['password'] ?? '')) !== '',
|
||||
],
|
||||
'asset_summary' => [
|
||||
'total_valuation' => round($totalValuation, 2),
|
||||
'item_count' => $ordersCount,
|
||||
'report_count' => $reportCount,
|
||||
'authentic_rate' => $reportCount > 0 ? (int)round($authenticCount / $reportCount * 100) : 0,
|
||||
'unread_count' => $unreadCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function decodeJsonField(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return json_decode($value, true) ?: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
546
server-api/app/controller/app/OrdersController.php
Normal file
546
server-api/app/controller/app/OrdersController.php
Normal file
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\model\Order;
|
||||
use app\model\OrderProduct;
|
||||
use app\model\OrderSupplementTask;
|
||||
use app\model\OrderSupplementTaskItem;
|
||||
use app\model\OrderTimeline;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class OrdersController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$orders = Db::name('orders')
|
||||
->alias('o')
|
||||
->leftJoin('order_products p', 'p.order_id = o.id')
|
||||
->leftJoin('order_logistics l', 'l.order_id = o.id AND l.logistics_type = "send_to_center"')
|
||||
->field([
|
||||
'o.id',
|
||||
'o.order_no',
|
||||
'o.appraisal_no',
|
||||
'o.service_provider',
|
||||
'o.order_status',
|
||||
'o.display_status',
|
||||
'o.estimated_finish_time',
|
||||
'p.product_name',
|
||||
'p.product_cover',
|
||||
'l.tracking_no',
|
||||
])
|
||||
->where('o.user_id', $userId)
|
||||
->order('o.id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$returnTrackingMap = [];
|
||||
if ($orders) {
|
||||
$returnRows = Db::name('order_logistics')
|
||||
->whereIn('order_id', array_column($orders, 'id'))
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($returnRows as $row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) {
|
||||
$returnTrackingMap[$orderId] = [
|
||||
'tracking_no' => (string)($row['tracking_no'] ?? ''),
|
||||
'tracking_status' => (string)($row['tracking_status'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$list = array_map(function (array $item) use ($returnTrackingMap) {
|
||||
return [
|
||||
'order_id' => (int)$item['id'],
|
||||
'order_no' => $item['order_no'],
|
||||
'appraisal_no' => $item['appraisal_no'],
|
||||
'order_status' => $item['order_status'],
|
||||
'product_name' => $item['product_name'] ?: '待补充商品名称',
|
||||
'product_cover' => $item['product_cover'] ?: '',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'display_status' => $this->displayStatus(
|
||||
$item['order_status'],
|
||||
$item['display_status'],
|
||||
$item['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
|
||||
),
|
||||
'status_desc' => $this->statusDescription(
|
||||
$item['order_status'],
|
||||
$item['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
|
||||
),
|
||||
'estimated_finish_time' => $item['estimated_finish_time'],
|
||||
'primary_action' => $this->primaryAction(
|
||||
$item['order_status'],
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
|
||||
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
|
||||
),
|
||||
];
|
||||
}, $orders);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 1);
|
||||
$userId = app_user_id($request);
|
||||
|
||||
$order = Order::where('id', $id)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$product = OrderProduct::where('order_id', $id)->find();
|
||||
$extra = Db::name('order_extras')->where('order_id', $id)->find();
|
||||
$sendLogistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$returnLogistics = Db::name('order_logistics')
|
||||
->where('order_id', $id)
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$timeline = OrderTimeline::where('order_id', $id)
|
||||
->order('occurred_at', 'asc')
|
||||
->select()
|
||||
->map(fn ($item) => [
|
||||
'node_code' => $item->node_code,
|
||||
'node_text' => $item->node_text,
|
||||
'node_desc' => $item->node_desc,
|
||||
'occurred_at' => $item->occurred_at,
|
||||
])
|
||||
->toArray();
|
||||
|
||||
$supplement = OrderSupplementTask::where('order_id', $id)
|
||||
->where('status', 'pending')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$supplementItems = [];
|
||||
if ($supplement) {
|
||||
$supplementItems = OrderSupplementTaskItem::where('task_id', $supplement->id)
|
||||
->select()
|
||||
->map(fn ($item) => [
|
||||
'item_code' => $item->item_code,
|
||||
'item_name' => $item->item_name,
|
||||
'guide_text' => $item->guide_text,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$materials = Db::name('order_upload_items')
|
||||
->where('order_id', $id)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$materials = array_values(array_filter(array_map(function (array $item) use ($request) {
|
||||
$files = Db::name('order_upload_files')
|
||||
->where('order_upload_item_id', $item['id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (!$files) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'upload_item_id' => (int)$item['id'],
|
||||
'item_code' => $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
'source_type' => $item['source_type'],
|
||||
'source_type_text' => $this->materialSourceTypeText($item['source_type']),
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->materialStatusText($item['status']),
|
||||
'file_count' => count($files),
|
||||
'files' => array_map(fn (array $file) => [
|
||||
'file_id' => $file['file_id'],
|
||||
'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request),
|
||||
'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request),
|
||||
'quality_status' => $file['quality_status'],
|
||||
'quality_message' => $file['quality_message'],
|
||||
], $files),
|
||||
];
|
||||
}, $materials)));
|
||||
|
||||
$returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
|
||||
if (!$returnAddress) {
|
||||
$returnAddress = Db::name('user_addresses')
|
||||
->where('user_id', (int)$order->user_id)
|
||||
->where('is_default', 1)
|
||||
->order('id', 'desc')
|
||||
->find()
|
||||
?: Db::name('user_addresses')
|
||||
->where('user_id', (int)$order->user_id)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($returnAddress) {
|
||||
$returnAddress = [
|
||||
'user_address_id' => (int)$returnAddress['id'],
|
||||
'consignee' => $returnAddress['consignee'],
|
||||
'mobile' => $returnAddress['mobile'],
|
||||
'province' => $returnAddress['province'],
|
||||
'city' => $returnAddress['city'],
|
||||
'district' => $returnAddress['district'],
|
||||
'detail_address' => $returnAddress['detail_address'],
|
||||
];
|
||||
}
|
||||
}
|
||||
$returnNodes = [];
|
||||
if ($returnLogistics) {
|
||||
$returnNodes = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $returnLogistics['id'])
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_info' => [
|
||||
'order_id' => (int)$order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'appraisal_no' => $order->appraisal_no,
|
||||
'service_provider' => $order->service_provider,
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($order->source_channel ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($order->source_channel ?? '')),
|
||||
'source_customer_id' => (string)($order->source_customer_id ?? ''),
|
||||
'order_status' => $order->order_status,
|
||||
'display_status' => $this->displayStatus(
|
||||
$order->order_status,
|
||||
$order->display_status,
|
||||
$sendLogistics['tracking_no'] ?? '',
|
||||
$returnLogistics['tracking_no'] ?? '',
|
||||
$returnLogistics['tracking_status'] ?? '',
|
||||
),
|
||||
'status_desc' => $this->statusDescription(
|
||||
$order->order_status,
|
||||
$sendLogistics['tracking_no'] ?? '',
|
||||
$returnLogistics['tracking_no'] ?? '',
|
||||
$returnLogistics['tracking_status'] ?? '',
|
||||
),
|
||||
'estimated_finish_time' => $order->estimated_finish_time,
|
||||
'can_edit_return_address' => empty($returnLogistics['tracking_no']),
|
||||
],
|
||||
'product_info' => [
|
||||
'product_name' => $product?->product_name ?: '',
|
||||
'category_name' => $product?->category_name ?: '',
|
||||
'brand_name' => $product?->brand_name ?: '',
|
||||
'color' => $product?->color ?: '',
|
||||
'size_spec' => $product?->size_spec ?: '',
|
||||
'serial_no' => $product?->serial_no ?: '',
|
||||
],
|
||||
'extra_info' => [
|
||||
'purchase_channel' => $extra['purchase_channel'] ?? '',
|
||||
'purchase_price' => (float)($extra['purchase_price'] ?? 0),
|
||||
'purchase_date' => $extra['purchase_date'] ?? '',
|
||||
'usage_status' => $extra['usage_status'] ?? '',
|
||||
'usage_status_text' => $this->usageStatusText($extra['usage_status'] ?? ''),
|
||||
'condition_desc' => $extra['condition_desc'] ?? '',
|
||||
'has_accessories' => (bool)($extra['has_accessories'] ?? false),
|
||||
'accessories' => $this->decodeJsonArray($extra['accessories_json'] ?? null),
|
||||
'remark' => $extra['remark'] ?? '',
|
||||
],
|
||||
'materials' => $materials,
|
||||
'return_address' => $returnAddress ? $this->formatReturnAddress($returnAddress) : null,
|
||||
'return_logistics' => $returnLogistics ? [
|
||||
'express_company' => $returnLogistics['express_company'],
|
||||
'tracking_no' => $returnLogistics['tracking_no'],
|
||||
'tracking_status' => $returnLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'),
|
||||
'latest_desc' => $returnLogistics['latest_desc'],
|
||||
'latest_time' => $returnLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $item['node_desc'],
|
||||
'node_location' => $item['node_location'],
|
||||
], $returnNodes),
|
||||
] : null,
|
||||
'timeline' => $timeline,
|
||||
'supplement_task' => $supplement ? [
|
||||
'task_id' => (int)$supplement->id,
|
||||
'reason' => $supplement->reason,
|
||||
'deadline' => $supplement->deadline,
|
||||
'items' => $supplementItems,
|
||||
] : null,
|
||||
'available_actions' => [
|
||||
'primary_action' => $this->primaryAction($order->order_status),
|
||||
'secondary_action' => '联系客服',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveReturnAddress(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$addressId = (int)$request->input('address_id', 0);
|
||||
$userId = app_user_id($request);
|
||||
|
||||
if ($orderId <= 0 || $addressId <= 0) {
|
||||
return api_error('订单和地址参数不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$returnLogistics = Db::name('order_logistics')
|
||||
->where('order_id', $orderId)
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!empty($returnLogistics['tracking_no'])) {
|
||||
return api_error('回寄运单已生成,当前不可再修改寄回地址', 422);
|
||||
}
|
||||
|
||||
$address = Db::name('user_addresses')->where('id', $addressId)->where('user_id', $userId)->find();
|
||||
if (!$address) {
|
||||
return api_error('地址不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$snapshot = [
|
||||
'user_address_id' => (int)$address['id'],
|
||||
'consignee' => $address['consignee'],
|
||||
'mobile' => $address['mobile'],
|
||||
'province' => $address['province'],
|
||||
'city' => $address['city'],
|
||||
'district' => $address['district'],
|
||||
'detail_address' => $address['detail_address'],
|
||||
];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$existing = Db::name('order_return_addresses')->where('order_id', $orderId)->find();
|
||||
if ($existing) {
|
||||
Db::name('order_return_addresses')->where('order_id', $orderId)->update(array_merge($snapshot, [
|
||||
'updated_at' => $now,
|
||||
]));
|
||||
$nodeText = '已更新寄回地址';
|
||||
} else {
|
||||
Db::name('order_return_addresses')->insert(array_merge($snapshot, [
|
||||
'order_id' => $orderId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]));
|
||||
$nodeText = '已确认寄回地址';
|
||||
}
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'return_address_selected',
|
||||
'node_text' => $nodeText,
|
||||
'node_desc' => sprintf('用户已确认寄回地址:%s%s%s%s', $address['province'], $address['city'], $address['district'], $address['detail_address']),
|
||||
'operator_type' => 'user',
|
||||
'operator_id' => $userId,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('寄回地址保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'return_address' => $this->formatReturnAddress($snapshot),
|
||||
], '寄回地址已更新');
|
||||
}
|
||||
|
||||
private function primaryAction(string $status, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||
{
|
||||
return match ($status) {
|
||||
'pending_payment' => '去支付',
|
||||
'pending_submission' => '去上传',
|
||||
'pending_shipping' => '查看寄送',
|
||||
'pending_supplement' => '去补资料',
|
||||
'report_published' => '查看报告',
|
||||
'completed' => ($returnTrackingNo !== '' && $returnTrackingStatus !== 'received') ? '查看物流' : '查看报告',
|
||||
default => '查看进度',
|
||||
};
|
||||
}
|
||||
|
||||
private function statusDescription(string $status, string $trackingNo = '', string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||
{
|
||||
return match ($status) {
|
||||
'pending_payment' => '请完成支付后继续本次鉴定服务',
|
||||
'pending_submission' => '请补充必要资料后继续进入鉴定流程',
|
||||
'pending_shipping' => $trackingNo !== '' ? '运单已提交,等待鉴定中心签收' : '请尽快将商品寄送至鉴定中心',
|
||||
'received' => '商品已由鉴定中心签收,等待鉴定师开始处理',
|
||||
'in_first_review' => '鉴定师正在处理,后续节点会持续同步',
|
||||
'in_final_review' => '鉴定师正在处理,预计 24 小时内出具报告',
|
||||
'pending_supplement' => '鉴定师需要您补充资料后继续处理',
|
||||
'report_published' => '正式报告已生成,待平台安排回寄商品',
|
||||
'completed' => $returnTrackingStatus === 'received'
|
||||
? '回寄商品已签收,本次订单已完成'
|
||||
: ($returnTrackingNo !== '' ? '鉴定物品已寄回,请留意签收与物流信息' : '正式报告已生成,可立即查看并验真'),
|
||||
default => '当前无需操作,请耐心等待',
|
||||
};
|
||||
}
|
||||
|
||||
private function displayStatus(
|
||||
string $status,
|
||||
string $displayStatus,
|
||||
string $trackingNo = '',
|
||||
string $returnTrackingNo = '',
|
||||
string $returnTrackingStatus = '',
|
||||
): string
|
||||
{
|
||||
if ($status === 'pending_shipping' && $trackingNo !== '') {
|
||||
return '已提交运单';
|
||||
}
|
||||
|
||||
if ($status === 'report_published') {
|
||||
return '待寄回';
|
||||
}
|
||||
|
||||
if ($status === 'completed') {
|
||||
if ($returnTrackingStatus === 'received') {
|
||||
return '已完成';
|
||||
}
|
||||
if ($returnTrackingNo !== '') {
|
||||
return '物品已寄回';
|
||||
}
|
||||
}
|
||||
|
||||
return $displayStatus;
|
||||
}
|
||||
|
||||
private function usageStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'new' => '全新未使用',
|
||||
'light_use' => '轻微使用痕迹',
|
||||
'used' => '长期使用',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function materialStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'uploaded' => '已上传',
|
||||
'optional' => '选填未上传',
|
||||
'pending' => '待上传',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function materialSourceTypeText(string $sourceType): string
|
||||
{
|
||||
return match ($sourceType) {
|
||||
'supplement' => '补充资料',
|
||||
default => '下单资料',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
||||
{
|
||||
$sourceChannel = trim($sourceChannel);
|
||||
$aliases = [
|
||||
'wechat_mini_program' => 'mini_program',
|
||||
'weixin_mini_program' => 'mini_program',
|
||||
'mp_weixin' => 'mini_program',
|
||||
'miniapp' => 'mini_program',
|
||||
'user_app' => 'mini_program',
|
||||
'web_h5' => 'h5',
|
||||
'enterprise' => 'enterprise_push',
|
||||
'enterprise_order' => 'enterprise_push',
|
||||
'customer_push' => 'enterprise_push',
|
||||
'large_customer_push' => 'enterprise_push',
|
||||
];
|
||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
||||
}
|
||||
|
||||
private function sourceChannelText(string $sourceChannel): string
|
||||
{
|
||||
return match ($this->normalizeOrderSourceChannel($sourceChannel)) {
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
default => '未知渠道',
|
||||
};
|
||||
}
|
||||
|
||||
private function decodeJsonArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values(array_filter($value, fn ($item) => is_string($item) && $item !== ''));
|
||||
}
|
||||
|
||||
if (!is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($decoded, fn ($item) => is_string($item) && $item !== ''));
|
||||
}
|
||||
|
||||
private function formatReturnAddress(array $item): array
|
||||
{
|
||||
return [
|
||||
'user_address_id' => (int)($item['user_address_id'] ?? 0),
|
||||
'consignee' => $item['consignee'] ?? '',
|
||||
'mobile' => $item['mobile'] ?? '',
|
||||
'province' => $item['province'] ?? '',
|
||||
'city' => $item['city'] ?? '',
|
||||
'district' => $item['district'] ?? '',
|
||||
'detail_address' => $item['detail_address'] ?? '',
|
||||
'full_address' => trim(sprintf(
|
||||
'%s%s%s%s',
|
||||
$item['province'] ?? '',
|
||||
$item['city'] ?? '',
|
||||
$item['district'] ?? '',
|
||||
$item['detail_address'] ?? ''
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
||||
{
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
return match ($status) {
|
||||
'submitted' => '已登记回寄运单',
|
||||
'in_transit' => '回寄途中',
|
||||
'received' => '用户已签收',
|
||||
default => $status === '' ? '待回寄' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
'submitted' => '已提交运单',
|
||||
'in_transit' => '运输中',
|
||||
'received' => '已签收',
|
||||
default => $status === '' ? '待提交' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function assetUrlService(): PublicAssetUrlService
|
||||
{
|
||||
return new PublicAssetUrlService();
|
||||
}
|
||||
}
|
||||
296
server-api/app/controller/app/ReportsController.php
Normal file
296
server-api/app/controller/app/ReportsController.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\model\Report;
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\AppAuthService;
|
||||
use app\support\ContentService;
|
||||
use app\support\FileStorageService;
|
||||
use app\support\ReportPdfGenerator;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class ReportsController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$rows = Db::name('orders')
|
||||
->alias('o')
|
||||
->leftJoin('reports r', 'r.order_id = o.id AND r.report_status = "published"')
|
||||
->leftJoin('order_products p', 'p.order_id = o.id')
|
||||
->field([
|
||||
'o.id AS order_id',
|
||||
'o.order_status',
|
||||
'o.display_status',
|
||||
'o.service_provider',
|
||||
'p.product_name',
|
||||
'p.product_cover',
|
||||
'r.id AS report_id',
|
||||
'r.report_no',
|
||||
'r.institution_name',
|
||||
'r.publish_time',
|
||||
])
|
||||
->where('o.user_id', $userId)
|
||||
->whereIn('o.order_status', ['in_first_review', 'in_final_review', 'generating_report', 'report_published', 'completed'])
|
||||
->order('o.id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
$published = !empty($item['report_id']);
|
||||
return [
|
||||
'report_id' => $published ? (int)$item['report_id'] : null,
|
||||
'order_id' => (int)$item['order_id'],
|
||||
'report_no' => $item['report_no'] ?: '',
|
||||
'product_name' => $item['product_name'] ?: '',
|
||||
'product_cover' => $item['product_cover'] ?: '',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'status' => $published ? '已出报告' : '待出报告',
|
||||
'result_text' => $published ? '正品' : '待出报告',
|
||||
'institution_name' => $item['institution_name'] ?: '安心验',
|
||||
'publish_time' => $item['publish_time'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$reportNo = trim((string)$request->input('report_no', ''));
|
||||
if (!$id && $reportNo === '') {
|
||||
return api_error('报告标识不能为空', 422);
|
||||
}
|
||||
|
||||
$report = null;
|
||||
if ($reportNo !== '') {
|
||||
$report = Report::where('report_status', 'published')
|
||||
->where('report_no', $reportNo)
|
||||
->find();
|
||||
} elseif ($id > 0) {
|
||||
$userInfo = app_user($request) ?: (new AppAuthService())->current($request);
|
||||
if (!$userInfo) {
|
||||
return api_error('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
$report = Db::name('reports')
|
||||
->alias('r')
|
||||
->join('orders o', 'o.id = r.order_id')
|
||||
->where('r.id', $id)
|
||||
->where('r.report_status', 'published')
|
||||
->where('o.user_id', (int)$userInfo['id'])
|
||||
->field('r.*')
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$report) {
|
||||
return api_error('报告不存在', 404);
|
||||
}
|
||||
|
||||
$reportData = is_array($report) ? $report : $report->toArray();
|
||||
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
||||
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find();
|
||||
$verify = $this->normalizeVerifyInfo($reportData, $verify ?: []);
|
||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($reportData['report_type'] ?? 'appraisal'));
|
||||
$payload = [
|
||||
'product_snapshot' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
|
||||
'result_snapshot' => $this->decodeJsonField($content['result_snapshot_json'] ?? null),
|
||||
'appraisal_snapshot' => $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null),
|
||||
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||
];
|
||||
|
||||
return api_success([
|
||||
'report_header' => [
|
||||
'report_id' => (int)$reportData['id'],
|
||||
'report_no' => $reportData['report_no'],
|
||||
'report_type' => $reportData['report_type'] ?? 'appraisal',
|
||||
'report_title' => $reportData['report_title'],
|
||||
'report_status' => $reportData['report_status'],
|
||||
'service_provider' => $reportData['service_provider'],
|
||||
'institution_name' => $reportData['institution_name'],
|
||||
'publish_time' => $reportData['publish_time'],
|
||||
],
|
||||
'result_info' => $payload['result_snapshot'],
|
||||
'product_info' => $payload['product_snapshot'],
|
||||
'appraisal_info' => $payload['appraisal_snapshot'],
|
||||
'valuation_info' => $payload['valuation_snapshot'],
|
||||
'evidence_attachments' => $evidenceAttachments,
|
||||
'risk_notice_text' => $payload['risk_notice_text'],
|
||||
'verify_info' => [
|
||||
'report_no' => $reportData['report_no'],
|
||||
'verify_status' => $verify['verify_status'] ?? 'valid',
|
||||
'verify_url' => $verify['verify_url'] ?? '',
|
||||
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
|
||||
],
|
||||
'file_info' => [
|
||||
'pdf_url' => $pdfUrl,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function decodeJsonField(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return json_decode($value, true) ?: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeVerifyInfo(array $report, array $verify): array
|
||||
{
|
||||
$reportNo = (string)($report['report_no'] ?? '');
|
||||
$verifyPageUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
|
||||
|
||||
$verify['report_no'] = $verify['report_no'] ?? $reportNo;
|
||||
$verify['verify_status'] = $verify['verify_status'] ?? 'valid';
|
||||
|
||||
$rawVerifyUrl = trim((string)($verify['verify_url'] ?? ''));
|
||||
if ($rawVerifyUrl === '' || str_starts_with($rawVerifyUrl, '/api/app/verify')) {
|
||||
$verify['verify_url'] = $verifyPageUrl;
|
||||
}
|
||||
|
||||
$rawQrValue = trim((string)($verify['verify_qrcode_url'] ?? ''));
|
||||
if ($rawQrValue === '' || str_starts_with($rawQrValue, '/api/app/verify') || str_contains($rawQrValue, '/pages/report/detail')) {
|
||||
$verify['verify_qrcode_url'] = $verify['verify_url'];
|
||||
}
|
||||
|
||||
return $verify;
|
||||
}
|
||||
|
||||
private function ensurePdfFile(Request $request, array $report, array $content, array $verify): string
|
||||
{
|
||||
$existingFile = Db::name('report_files')
|
||||
->where('report_id', (int)$report['id'])
|
||||
->where('file_type', 'pdf')
|
||||
->find();
|
||||
|
||||
if ($existingFile && !empty($existingFile['file_url'])) {
|
||||
$relativeUrl = ltrim((string)$existingFile['file_url'], '/');
|
||||
if ($this->storage()->exists($relativeUrl)) {
|
||||
return $this->storage()->publicUrl($request, $relativeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$productInfo = $this->decodeJsonField($content['product_snapshot_json'] ?? null);
|
||||
$resultInfo = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
|
||||
$appraisalInfo = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
|
||||
$valuationInfo = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
|
||||
|
||||
$publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s'));
|
||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
||||
$relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime));
|
||||
$filename = $report['report_no'] . '.pdf';
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$generator = new ReportPdfGenerator();
|
||||
$pdfBinary = $generator->generate([
|
||||
'report_title' => $report['report_title'] ?? '鉴定报告',
|
||||
'service_provider_text' => ($report['service_provider'] ?? 'anxinyan') === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'institution_name' => $report['institution_name'] ?? '安心验',
|
||||
'report_no' => $report['report_no'] ?? '',
|
||||
'publish_time' => $publishTime,
|
||||
'result_text' => $resultInfo['result_text'] ?? '-',
|
||||
'result_desc' => $resultInfo['result_desc'] ?? '-',
|
||||
'product_name' => $productInfo['product_name'] ?? '-',
|
||||
'category_brand' => trim(($productInfo['category_name'] ?? '-') . ' / ' . ($productInfo['brand_name'] ?? '-')),
|
||||
'spec_info' => trim(($productInfo['color'] ?? '-') . ' / ' . ($productInfo['size_spec'] ?? '-')),
|
||||
'appraisers' => trim((string)($appraisalInfo['appraiser_name'] ?? '-')),
|
||||
'condition_grade' => $valuationInfo['condition_grade'] ?? '-',
|
||||
'valuation_range' => sprintf(
|
||||
'¥%s - ¥%s',
|
||||
$valuationInfo['valuation_min'] ?? 0,
|
||||
$valuationInfo['valuation_max'] ?? 0
|
||||
),
|
||||
'verify_info' => sprintf(
|
||||
'%s / %s',
|
||||
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
||||
($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-')
|
||||
),
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
||||
]);
|
||||
|
||||
$this->storage()->putContents($relativePath, $pdfBinary);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$filePayload = [
|
||||
'report_id' => (int)$report['id'],
|
||||
'file_type' => 'pdf',
|
||||
'file_url' => '/' . $relativePath,
|
||||
'file_status' => 'ready',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existingFile) {
|
||||
Db::name('report_files')->where('id', $existingFile['id'])->update($filePayload);
|
||||
} else {
|
||||
$filePayload['created_at'] = $now;
|
||||
Db::name('report_files')->insert($filePayload);
|
||||
}
|
||||
|
||||
return $this->storage()->publicUrl($request, $relativePath);
|
||||
}
|
||||
|
||||
private function buildPublicPageUrl(string $pagePath, array $query = []): string
|
||||
{
|
||||
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
|
||||
$page = ltrim($pagePath, '/');
|
||||
$queryString = http_build_query($query);
|
||||
$hashPath = '/#/' . $page;
|
||||
if ($queryString !== '') {
|
||||
$hashPath .= '?' . $queryString;
|
||||
}
|
||||
|
||||
if ($baseUrl === '') {
|
||||
return $hashPath;
|
||||
}
|
||||
|
||||
return $baseUrl . $hashPath;
|
||||
}
|
||||
|
||||
private function normalizeH5BaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function evidenceService(): AppraisalEvidenceService
|
||||
{
|
||||
return new AppraisalEvidenceService();
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
130
server-api/app/controller/app/SettingsController.php
Normal file
130
server-api/app/controller/app/SettingsController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ContentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class SettingsController
|
||||
{
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user) {
|
||||
return api_error('用户不存在', 404);
|
||||
}
|
||||
|
||||
return api_success($this->buildPayload($user, $userId));
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user) {
|
||||
return api_error('用户不存在', 404);
|
||||
}
|
||||
|
||||
$nickname = trim((string)$request->input('nickname', $user['nickname']));
|
||||
if ($nickname === '') {
|
||||
return api_error('昵称不能为空', 422);
|
||||
}
|
||||
|
||||
$preferenceInput = (array)$request->input('preferences', []);
|
||||
$preferences = $this->normalizePreferences($preferenceInput);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('users')->where('id', $userId)->update([
|
||||
'nickname' => $nickname,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$config = Db::name('system_configs')
|
||||
->where('config_group', 'user_settings')
|
||||
->where('config_key', $this->preferenceConfigKey($userId))
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'config_group' => 'user_settings',
|
||||
'config_key' => $this->preferenceConfigKey($userId),
|
||||
'config_value' => json_encode($preferences, JSON_UNESCAPED_UNICODE),
|
||||
'remark' => '用户端设置偏好',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($config) {
|
||||
Db::name('system_configs')->where('id', $config['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('system_configs')->insert($payload);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('设置保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$latestUser = Db::name('users')->where('id', $userId)->find();
|
||||
|
||||
return api_success($this->buildPayload($latestUser ?: $user, $userId), '设置已保存');
|
||||
}
|
||||
|
||||
private function buildPayload(array $user, int $userId): array
|
||||
{
|
||||
$content = (new ContentService())->getPolicyConfig();
|
||||
|
||||
return [
|
||||
'profile_info' => [
|
||||
'user_id' => (int)$user['id'],
|
||||
'nickname' => $user['nickname'] ?: '安心验用户',
|
||||
'mobile' => $user['mobile'] ?: '',
|
||||
'avatar' => $user['avatar'] ?: '',
|
||||
'status' => $user['status'] ?: 'enabled',
|
||||
'status_text' => ($user['status'] ?? 'enabled') === 'enabled' ? '账号正常' : '账号异常',
|
||||
'password_set' => ((string)($user['password'] ?? '')) !== '',
|
||||
],
|
||||
'preferences' => $this->loadPreferences($userId),
|
||||
'legal_entries' => $content['legal_entries'],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadPreferences(int $userId): array
|
||||
{
|
||||
$configValue = Db::name('system_configs')
|
||||
->where('config_group', 'user_settings')
|
||||
->where('config_key', $this->preferenceConfigKey($userId))
|
||||
->value('config_value');
|
||||
|
||||
$decoded = [];
|
||||
if (is_string($configValue) && $configValue !== '') {
|
||||
$decoded = json_decode($configValue, true);
|
||||
$decoded = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return $this->normalizePreferences($decoded);
|
||||
}
|
||||
|
||||
private function normalizePreferences(array $input): array
|
||||
{
|
||||
return [
|
||||
'notify_order' => array_key_exists('notify_order', $input) ? (bool)$input['notify_order'] : true,
|
||||
'notify_report' => array_key_exists('notify_report', $input) ? (bool)$input['notify_report'] : true,
|
||||
'notify_supplement' => array_key_exists('notify_supplement', $input) ? (bool)$input['notify_supplement'] : true,
|
||||
'notify_ticket' => array_key_exists('notify_ticket', $input) ? (bool)$input['notify_ticket'] : true,
|
||||
'marketing_notify' => array_key_exists('marketing_notify', $input) ? (bool)$input['marketing_notify'] : false,
|
||||
'privacy_mode' => array_key_exists('privacy_mode', $input) ? (bool)$input['privacy_mode'] : false,
|
||||
];
|
||||
}
|
||||
|
||||
private function preferenceConfigKey(int $userId): string
|
||||
{
|
||||
return 'user_' . $userId;
|
||||
}
|
||||
}
|
||||
277
server-api/app/controller/app/ShippingController.php
Normal file
277
server-api/app/controller/app/ShippingController.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class ShippingController
|
||||
{
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$product = Db::name('order_products')->where('order_id', $orderId)->find();
|
||||
$logistics = Db::name('order_logistics')
|
||||
->where('order_id', $orderId)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$nodes = [];
|
||||
if ($logistics) {
|
||||
$nodes = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $logistics['id'])
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$warehouseService = new WarehouseService();
|
||||
$categoryId = (int)($product['category_id'] ?? 0);
|
||||
$defaultAddress = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
$center = $warehouseService->getOrderTarget($orderId)
|
||||
?: $warehouseService->resolveForShipping((string)($order['service_provider'] ?? 'anxinyan'), $categoryId, $defaultAddress ?: null);
|
||||
$trackingSubmitted = !empty($logistics['tracking_no']);
|
||||
$warehouseOptions = $warehouseService->optionsForOrder((string)($order['service_provider'] ?? 'anxinyan'), $categoryId, $defaultAddress ?: null);
|
||||
|
||||
return api_success([
|
||||
'order_info' => [
|
||||
'order_id' => (int)$order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'appraisal_no' => $order['appraisal_no'],
|
||||
'service_provider' => $order['service_provider'],
|
||||
'display_status' => $trackingSubmitted ? '已提交运单' : ($order['display_status'] ?: '待寄送商品'),
|
||||
'estimated_finish_time' => $order['estimated_finish_time'],
|
||||
'product_name' => $product['product_name'] ?? '',
|
||||
],
|
||||
'shipping_address' => $center,
|
||||
'shipping_options' => [
|
||||
'current_warehouse_id' => (int)($center['warehouse_id'] ?? 0),
|
||||
'can_select_warehouse' => in_array($order['order_status'], ['pending_shipping'], true) && !$trackingSubmitted,
|
||||
'list' => $warehouseOptions,
|
||||
],
|
||||
'shipping_notice' => [
|
||||
'tips' => [
|
||||
'请在包裹内附上订单号或鉴定单号,便于鉴定中心快速匹配。',
|
||||
'贵重商品建议使用顺丰、京东等可追踪快递,并保留寄件凭证。',
|
||||
'寄出后请尽快填写快递公司和运单号,我们会同步更新处理进度。',
|
||||
],
|
||||
'express_recommendations' => ['顺丰速运', '京东快递', 'EMS', '中通快递'],
|
||||
],
|
||||
'logistics_info' => [
|
||||
'express_company' => $logistics['express_company'] ?? '',
|
||||
'tracking_no' => $logistics['tracking_no'] ?? '',
|
||||
'tracking_status' => $logistics['tracking_status'] ?? '',
|
||||
'tracking_status_text' => $this->trackingStatusText((string)($logistics['tracking_status'] ?? '')),
|
||||
'latest_desc' => $logistics['latest_desc'] ?? '',
|
||||
'latest_time' => $logistics['latest_time'] ?? '',
|
||||
'is_submitted' => $trackingSubmitted,
|
||||
],
|
||||
'logistics_nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $item['node_desc'],
|
||||
'node_location' => $item['node_location'],
|
||||
], $nodes),
|
||||
'can_submit_tracking' => in_array($order['order_status'], ['pending_shipping'], true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$userId = app_user_id($request);
|
||||
$expressCompany = trim((string)$request->input('express_company', ''));
|
||||
$trackingNo = trim((string)$request->input('tracking_no', ''));
|
||||
$warehouseId = (int)$request->input('warehouse_id', 0);
|
||||
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
if ($expressCompany === '' || $trackingNo === '') {
|
||||
return api_error('快递公司和运单号不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
if (!in_array($order['order_status'], ['pending_shipping'], true)) {
|
||||
return api_error('当前订单状态不支持提交运单', 422);
|
||||
}
|
||||
|
||||
$product = Db::name('order_products')->where('order_id', $orderId)->find();
|
||||
$warehouseService = new WarehouseService();
|
||||
$categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null;
|
||||
$currentTarget = $warehouseService->getOrderTarget($orderId)
|
||||
?: $warehouseService->resolveForShipping((string)($order['service_provider'] ?? 'anxinyan'), $categoryId);
|
||||
$selectedWarehouse = $currentTarget;
|
||||
|
||||
if ($warehouseId > 0) {
|
||||
$defaultAddress = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
$candidateWarehouses = $warehouseService->optionsForOrder((string)$order['service_provider'], $categoryId, $defaultAddress ?: null);
|
||||
$matched = null;
|
||||
foreach ($candidateWarehouses as $item) {
|
||||
if ((int)$item['id'] === $warehouseId) {
|
||||
$matched = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$matched) {
|
||||
return api_error('所选仓库不可用,请重新选择', 422);
|
||||
}
|
||||
|
||||
$selectedWarehouse = [
|
||||
'warehouse_id' => (int)$matched['id'],
|
||||
'warehouse_name' => $matched['warehouse_name'],
|
||||
'warehouse_code' => $matched['warehouse_code'],
|
||||
'receiver_name' => $matched['receiver_name'],
|
||||
'receiver_mobile' => $matched['receiver_mobile'],
|
||||
'province' => $matched['province'],
|
||||
'city' => $matched['city'],
|
||||
'district' => $matched['district'],
|
||||
'detail_address' => $matched['detail_address'],
|
||||
'service_time' => $matched['service_time'],
|
||||
'notice' => $matched['notice'],
|
||||
];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$latestDesc = sprintf('您已提交 %s 运单号 %s,等待鉴定中心签收。', $expressCompany, $trackingNo);
|
||||
$existing = Db::name('order_logistics')
|
||||
->where('order_id', $orderId)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$warehouseChanged = (int)($selectedWarehouse['warehouse_id'] ?? 0) > 0
|
||||
&& (int)($selectedWarehouse['warehouse_id'] ?? 0) !== (int)($currentTarget['warehouse_id'] ?? 0);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ((int)($selectedWarehouse['warehouse_id'] ?? 0) > 0) {
|
||||
$warehouseService->bindOrderTarget($orderId, (string)$order['service_provider'], $categoryId);
|
||||
Db::name('order_shipping_targets')->where('order_id', $orderId)->update([
|
||||
'warehouse_id' => $selectedWarehouse['warehouse_id'],
|
||||
'warehouse_name' => $selectedWarehouse['warehouse_name'],
|
||||
'warehouse_code' => $selectedWarehouse['warehouse_code'],
|
||||
'service_provider' => $order['service_provider'],
|
||||
'receiver_name' => $selectedWarehouse['receiver_name'],
|
||||
'receiver_mobile' => $selectedWarehouse['receiver_mobile'],
|
||||
'province' => $selectedWarehouse['province'],
|
||||
'city' => $selectedWarehouse['city'],
|
||||
'district' => $selectedWarehouse['district'],
|
||||
'detail_address' => $selectedWarehouse['detail_address'],
|
||||
'service_time' => $selectedWarehouse['service_time'],
|
||||
'notice' => $selectedWarehouse['notice'],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
Db::name('order_logistics')->where('id', $existing['id'])->update([
|
||||
'logistics_type' => 'send_to_center',
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'tracking_status' => 'submitted',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$logisticsId = (int)$existing['id'];
|
||||
$nodeText = '已更新运单';
|
||||
$nodeDesc = sprintf('用户更新了寄送运单:%s %s', $expressCompany, $trackingNo);
|
||||
} else {
|
||||
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
|
||||
'order_id' => $orderId,
|
||||
'logistics_type' => 'send_to_center',
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'tracking_status' => 'submitted',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$nodeText = '已提交运单';
|
||||
$nodeDesc = sprintf('用户已提交寄送运单:%s %s', $expressCompany, $trackingNo);
|
||||
}
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $now,
|
||||
'node_desc' => $latestDesc,
|
||||
'node_location' => $selectedWarehouse['warehouse_name'] ?? '用户端',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('orders')->where('id', $orderId)->update([
|
||||
'display_status' => '已提交运单',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'tracking_submitted',
|
||||
'node_text' => $nodeText,
|
||||
'node_desc' => $nodeDesc,
|
||||
'operator_type' => 'user',
|
||||
'operator_id' => $userId,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
if ($warehouseChanged) {
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'warehouse_selected',
|
||||
'node_text' => '已选择寄送仓库',
|
||||
'node_desc' => sprintf('用户已选择寄送仓库:%s', $selectedWarehouse['warehouse_name']),
|
||||
'operator_type' => 'user',
|
||||
'operator_id' => $userId,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('运单提交失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'warehouse_id' => (int)($selectedWarehouse['warehouse_id'] ?? 0),
|
||||
], '运单已提交');
|
||||
}
|
||||
|
||||
private function trackingStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'submitted' => '已提交运单',
|
||||
'in_transit' => '运输中',
|
||||
'received' => '已签收',
|
||||
default => $status === '' ? '待提交' : $status,
|
||||
};
|
||||
}
|
||||
}
|
||||
359
server-api/app/controller/app/SupplementController.php
Normal file
359
server-api/app/controller/app/SupplementController.php
Normal file
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\FileStorageService;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
use function ltrim;
|
||||
use function str_starts_with;
|
||||
|
||||
class SupplementController
|
||||
{
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$supplementTask = Db::name('order_supplement_tasks')
|
||||
->where('order_id', $orderId)
|
||||
->where('status', 'pending')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$supplementTask) {
|
||||
return api_error('当前订单暂无待补资料任务', 404);
|
||||
}
|
||||
|
||||
$taskItems = Db::name('order_supplement_task_items')
|
||||
->where('task_id', $supplementTask['id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$uploadItems = Db::name('order_upload_items')
|
||||
->where('order_id', $orderId)
|
||||
->where('source_type', 'supplement')
|
||||
->where('created_at', '>=', $supplementTask['created_at'])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$uploadItemsByName = [];
|
||||
foreach ($uploadItems as $item) {
|
||||
if (!isset($uploadItemsByName[$item['item_name']])) {
|
||||
$uploadItemsByName[$item['item_name']] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$list = array_map(function (array $item) use ($uploadItemsByName, $request) {
|
||||
$uploadItem = $uploadItemsByName[$item['item_name']] ?? null;
|
||||
$files = [];
|
||||
|
||||
if ($uploadItem) {
|
||||
$files = Db::name('order_upload_files')
|
||||
->where('order_upload_item_id', $uploadItem['id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'upload_item_id' => (int)($uploadItem['id'] ?? 0),
|
||||
'item_code' => $uploadItem['item_code'] ?? $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'guide_text' => $item['guide_text'],
|
||||
'is_required' => (bool)$item['is_required'],
|
||||
'status' => $uploadItem['status'] ?? 'pending',
|
||||
'files' => array_map(fn (array $file) => [
|
||||
'id' => (int)$file['id'],
|
||||
'file_id' => $file['file_id'],
|
||||
'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request),
|
||||
'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request),
|
||||
], $files),
|
||||
];
|
||||
}, $taskItems);
|
||||
|
||||
return api_success([
|
||||
'order_id' => (int)$order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'appraisal_no' => $order['appraisal_no'],
|
||||
'reason' => $supplementTask['reason'],
|
||||
'deadline' => $supplementTask['deadline'],
|
||||
'items' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadFile(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$uploadItemId = (int)$request->post('upload_item_id', 0);
|
||||
if ($uploadItemId <= 0) {
|
||||
return api_error('资料项 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$uploadItem = $this->findUserUploadItem($uploadItemId, $userId);
|
||||
if (!$uploadItem) {
|
||||
return api_error('补资料项不存在', 404);
|
||||
}
|
||||
|
||||
$supplementTask = $this->findPendingSupplementTask((int)$uploadItem['order_id']);
|
||||
if (!$supplementTask) {
|
||||
return api_error('当前订单暂无待补资料任务', 422);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return api_error('上传文件无效', 422);
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
|
||||
$filename = sprintf('%s_%s.%s', $uploadItem['item_code'], uniqid(), $extension);
|
||||
$relativeDir = 'uploads/supplement/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::name('order_upload_files')->insert([
|
||||
'order_upload_item_id' => $uploadItemId,
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => '/' . ltrim($relativePath, '/'),
|
||||
'thumbnail_url' => '/' . ltrim($relativePath, '/'),
|
||||
'quality_status' => 'uploaded',
|
||||
'quality_message' => '',
|
||||
'uploaded_by_user_id' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_upload_items')->where('id', $uploadItemId)->update([
|
||||
'status' => 'uploaded',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'id' => $uploadItemId,
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileUrl,
|
||||
'name' => $file->getUploadName(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteFile(Request $request)
|
||||
{
|
||||
$fileId = trim((string)$request->input('file_id', ''));
|
||||
if ($fileId === '') {
|
||||
return api_error('文件 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$file = Db::name('order_upload_files')->where('file_id', $fileId)->find();
|
||||
if (!$file) {
|
||||
return api_error('文件不存在', 404);
|
||||
}
|
||||
|
||||
$uploadItem = $this->findUserUploadItem((int)$file['order_upload_item_id'], app_user_id($request));
|
||||
if (!$uploadItem) {
|
||||
return api_error('无权删除该文件', 403);
|
||||
}
|
||||
|
||||
$relativePath = $this->storage()->storagePath((string)$file['file_url']);
|
||||
if (str_starts_with($relativePath, 'uploads/supplement/')) {
|
||||
$this->storage()->delete($relativePath);
|
||||
}
|
||||
|
||||
Db::name('order_upload_files')->where('id', $file['id'])->delete();
|
||||
|
||||
$remainingCount = (int)Db::name('order_upload_files')
|
||||
->where('order_upload_item_id', $uploadItem['id'])
|
||||
->count();
|
||||
|
||||
Db::name('order_upload_items')->where('id', $uploadItem['id'])->update([
|
||||
'status' => $remainingCount > 0 ? 'uploaded' : 'pending',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return api_success([
|
||||
'file_id' => $fileId,
|
||||
], '删除成功');
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$supplementTask = $this->findPendingSupplementTask($orderId);
|
||||
if (!$supplementTask) {
|
||||
return api_error('当前订单暂无待补资料任务', 422);
|
||||
}
|
||||
|
||||
$taskItems = Db::name('order_supplement_task_items')
|
||||
->where('task_id', $supplementTask['id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$uploadItems = Db::name('order_upload_items')
|
||||
->where('order_id', $orderId)
|
||||
->where('source_type', 'supplement')
|
||||
->where('created_at', '>=', $supplementTask['created_at'])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$uploadItemsByName = [];
|
||||
foreach ($uploadItems as $item) {
|
||||
if (!isset($uploadItemsByName[$item['item_name']])) {
|
||||
$uploadItemsByName[$item['item_name']] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($taskItems as $item) {
|
||||
if (!(bool)$item['is_required']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadItem = $uploadItemsByName[$item['item_name']] ?? null;
|
||||
if (!$uploadItem) {
|
||||
return api_error("请先上传「{$item['item_name']}」", 422);
|
||||
}
|
||||
|
||||
$fileCount = (int)Db::name('order_upload_files')
|
||||
->where('order_upload_item_id', $uploadItem['id'])
|
||||
->count();
|
||||
|
||||
if ($fileCount <= 0) {
|
||||
return api_error("请先上传「{$item['item_name']}」", 422);
|
||||
}
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$resumeTask = Db::name('appraisal_tasks')
|
||||
->where('order_id', $orderId)
|
||||
->where('status', 'returned')
|
||||
->orderRaw("FIELD(task_stage, 'final_review', 'first_review')")
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$resumeTask) {
|
||||
$resumeTask = Db::name('appraisal_tasks')
|
||||
->where('order_id', $orderId)
|
||||
->where('task_stage', 'first_review')
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
}
|
||||
|
||||
$nextOrderStatus = 'in_first_review';
|
||||
$nextDisplayStatus = '鉴定中';
|
||||
$timelineDesc = '您已完成补充资料上传,订单重新进入鉴定流程';
|
||||
|
||||
Db::name('order_supplement_tasks')->where('id', $supplementTask['id'])->update([
|
||||
'status' => 'submitted',
|
||||
'submitted_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('orders')->where('id', $orderId)->update([
|
||||
'order_status' => $nextOrderStatus,
|
||||
'display_status' => $nextDisplayStatus,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($resumeTask) {
|
||||
$resumeTaskUpdate = [
|
||||
'status' => 'processing',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (empty($resumeTask['started_at'])) {
|
||||
$resumeTaskUpdate['started_at'] = $now;
|
||||
}
|
||||
|
||||
Db::name('appraisal_tasks')
|
||||
->where('id', $resumeTask['id'])
|
||||
->update($resumeTaskUpdate);
|
||||
}
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'supplement_submitted',
|
||||
'node_text' => '补资料已提交',
|
||||
'node_desc' => $timelineDesc,
|
||||
'operator_type' => 'user',
|
||||
'operator_id' => $userId,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('提交补资料失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'next_status' => $nextOrderStatus,
|
||||
], '补资料已提交');
|
||||
}
|
||||
|
||||
private function findUserUploadItem(int $uploadItemId, int $userId): ?array
|
||||
{
|
||||
$uploadItem = Db::name('order_upload_items')->where('id', $uploadItemId)->find();
|
||||
if (!$uploadItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $uploadItem['order_id'])->where('user_id', $userId)->find();
|
||||
if (!$order || $uploadItem['source_type'] !== 'supplement') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $uploadItem;
|
||||
}
|
||||
|
||||
private function findPendingSupplementTask(int $orderId): ?array
|
||||
{
|
||||
$task = Db::name('order_supplement_tasks')
|
||||
->where('order_id', $orderId)
|
||||
->where('status', 'pending')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
return $task ?: null;
|
||||
}
|
||||
|
||||
private function assetUrlService(): PublicAssetUrlService
|
||||
{
|
||||
return new PublicAssetUrlService();
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
357
server-api/app/controller/app/TicketsController.php
Normal file
357
server-api/app/controller/app/TicketsController.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ContentService;
|
||||
use app\support\TicketAttachmentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class TicketsController
|
||||
{
|
||||
public function overview(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$ticketIds = Db::name('tickets')->where('user_id', $userId)->column('id');
|
||||
$ticketTypes = (new ContentService())->getTicketTypes();
|
||||
|
||||
return api_success([
|
||||
'cards' => [
|
||||
[
|
||||
'title' => '全部工单',
|
||||
'value' => (int)Db::name('tickets')->where('user_id', $userId)->count(),
|
||||
'desc' => '您当前已提交的全部客服工单',
|
||||
],
|
||||
[
|
||||
'title' => '待处理',
|
||||
'value' => (int)Db::name('tickets')->where('user_id', $userId)->whereIn('status', ['pending', 'processing', 'waiting_user'])->count(),
|
||||
'desc' => '客服待处理或正在跟进中的工单',
|
||||
],
|
||||
[
|
||||
'title' => '已解决',
|
||||
'value' => (int)Db::name('tickets')->where('user_id', $userId)->where('status', 'resolved')->count(),
|
||||
'desc' => '已处理完成的工单数量',
|
||||
],
|
||||
[
|
||||
'title' => '工单留言',
|
||||
'value' => $ticketIds ? (int)Db::name('ticket_messages')->whereIn('ticket_id', $ticketIds)->count() : 0,
|
||||
'desc' => '您与客服之间的全部沟通记录',
|
||||
],
|
||||
],
|
||||
'ticket_types' => $ticketTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function meta(Request $request)
|
||||
{
|
||||
$content = new ContentService();
|
||||
return api_success([
|
||||
'ticket_types' => $content->getTicketTypes(),
|
||||
'ticket_statuses' => $content->getTicketStatuses(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$status = trim((string)$request->input('status', ''));
|
||||
$type = trim((string)$request->input('ticket_type', ''));
|
||||
|
||||
$query = Db::name('tickets')
|
||||
->where('user_id', $userId)
|
||||
->order('id', 'desc');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query->where('ticket_type', $type);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
|
||||
$list = array_map(function (array $item) {
|
||||
$lastMessage = Db::name('ticket_messages')
|
||||
->where('ticket_id', $item['id'])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$lastAttachments = $this->attachmentService()->normalize($lastMessage['attachments_json'] ?? null, $request);
|
||||
$latestMessage = $lastMessage['content'] ?? ($item['content'] ?? '');
|
||||
if ($latestMessage === '' && $lastAttachments) {
|
||||
$latestMessage = sprintf('[附件 %d 张]', count($lastAttachments));
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'ticket_no' => $item['ticket_no'],
|
||||
'ticket_type' => $item['ticket_type'],
|
||||
'ticket_type_text' => $this->ticketTypeText($item['ticket_type']),
|
||||
'status' => $item['status'],
|
||||
'status_text' => $this->statusText($item['status']),
|
||||
'priority' => $item['priority'],
|
||||
'priority_text' => $this->priorityText($item['priority']),
|
||||
'title' => $item['title'] ?: '未命名工单',
|
||||
'order_id' => (int)($item['order_id'] ?? 0),
|
||||
'latest_message' => $latestMessage,
|
||||
'updated_at' => $item['updated_at'],
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return api_success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('工单 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
$ticket = Db::name('tickets')->where('id', $id)->where('user_id', app_user_id($request))->find();
|
||||
if (!$ticket) {
|
||||
return api_error('工单不存在', 404);
|
||||
}
|
||||
|
||||
$messages = Db::name('ticket_messages')
|
||||
->where('ticket_id', $id)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$order = null;
|
||||
if (!empty($ticket['order_id'])) {
|
||||
$order = Db::name('orders')
|
||||
->field(['id', 'order_no', 'display_status'])
|
||||
->where('id', $ticket['order_id'])
|
||||
->where('user_id', app_user_id($request))
|
||||
->find();
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'ticket_info' => [
|
||||
'id' => (int)$ticket['id'],
|
||||
'ticket_no' => $ticket['ticket_no'],
|
||||
'ticket_type' => $ticket['ticket_type'],
|
||||
'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']),
|
||||
'status' => $ticket['status'],
|
||||
'status_text' => $this->statusText($ticket['status']),
|
||||
'priority' => $ticket['priority'],
|
||||
'priority_text' => $this->priorityText($ticket['priority']),
|
||||
'title' => $ticket['title'],
|
||||
'content' => $ticket['content'] ?: '',
|
||||
'order_id' => (int)($ticket['order_id'] ?? 0),
|
||||
'created_at' => $ticket['created_at'],
|
||||
'updated_at' => $ticket['updated_at'],
|
||||
],
|
||||
'order_info' => $order ? [
|
||||
'order_id' => (int)$order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'display_status' => $order['display_status'],
|
||||
] : null,
|
||||
'messages' => array_map(function (array $item) {
|
||||
return [
|
||||
'sender_type' => $item['sender_type'],
|
||||
'sender_type_text' => match ($item['sender_type']) {
|
||||
'customer_service' => '客服',
|
||||
'system' => '系统',
|
||||
default => '您',
|
||||
},
|
||||
'content' => $item['content'] ?: '',
|
||||
'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request),
|
||||
'created_at' => $item['created_at'],
|
||||
];
|
||||
}, $messages),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$ticketType = trim((string)$request->input('ticket_type', 'order_issue'));
|
||||
$title = trim((string)$request->input('title', ''));
|
||||
$content = trim((string)$request->input('content', ''));
|
||||
$attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true);
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
$reportId = (int)$request->input('report_id', 0);
|
||||
|
||||
if ($title === '') {
|
||||
return api_error('工单标题不能为空', 422);
|
||||
}
|
||||
if ($content === '' && !$attachments) {
|
||||
return api_error('问题描述和附件至少填写一项', 422);
|
||||
}
|
||||
|
||||
$bizType = 'support';
|
||||
$bizId = null;
|
||||
|
||||
if ($orderId > 0) {
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('关联订单不存在', 404);
|
||||
}
|
||||
$bizType = 'order';
|
||||
$bizId = $orderId;
|
||||
} elseif ($reportId > 0) {
|
||||
$report = Db::name('reports')->where('id', $reportId)->find();
|
||||
if (!$report) {
|
||||
return api_error('关联报告不存在', 404);
|
||||
}
|
||||
$bizType = 'report';
|
||||
$bizId = $reportId;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$ticketNo = 'TK' . date('YmdHis') . mt_rand(100, 999);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$ticketId = (int)Db::name('tickets')->insertGetId([
|
||||
'ticket_no' => $ticketNo,
|
||||
'ticket_type' => $ticketType,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'order_id' => $orderId > 0 ? $orderId : null,
|
||||
'user_id' => $userId,
|
||||
'status' => 'pending',
|
||||
'priority' => 'normal',
|
||||
'assignee_id' => null,
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'closed_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('ticket_messages')->insertAll([
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'sender_type' => 'user',
|
||||
'sender_id' => $userId,
|
||||
'content' => $content,
|
||||
'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null,
|
||||
'created_at' => $now,
|
||||
],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'sender_type' => 'system',
|
||||
'sender_id' => null,
|
||||
'content' => '工单已创建,客服会尽快与您联系。',
|
||||
'attachments_json' => null,
|
||||
'created_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('工单创建失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'ticket_id' => $ticketId,
|
||||
'ticket_no' => $ticketNo,
|
||||
], '工单已提交');
|
||||
}
|
||||
|
||||
public function reply(Request $request)
|
||||
{
|
||||
$ticketId = (int)$request->input('ticket_id', 0);
|
||||
$content = trim((string)$request->input('content', ''));
|
||||
$attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true);
|
||||
|
||||
if ($ticketId <= 0) {
|
||||
return api_error('工单 ID 不能为空', 422);
|
||||
}
|
||||
if ($content === '' && !$attachments) {
|
||||
return api_error('回复内容和附件至少填写一项', 422);
|
||||
}
|
||||
|
||||
$ticket = Db::name('tickets')->where('id', $ticketId)->where('user_id', app_user_id($request))->find();
|
||||
if (!$ticket) {
|
||||
return api_error('工单不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('ticket_messages')->insert([
|
||||
'ticket_id' => $ticketId,
|
||||
'sender_type' => 'user',
|
||||
'sender_id' => app_user_id($request),
|
||||
'content' => $content,
|
||||
'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('tickets')->where('id', $ticketId)->update([
|
||||
'status' => 'processing',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('发送失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'ticket_id' => $ticketId,
|
||||
], '已发送');
|
||||
}
|
||||
|
||||
public function uploadFile(Request $request)
|
||||
{
|
||||
try {
|
||||
$asset = $this->attachmentService()->upload($request);
|
||||
return api_success($asset);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteFile(Request $request)
|
||||
{
|
||||
$fileUrl = trim((string)$request->input('file_url', ''));
|
||||
if ($fileUrl === '') {
|
||||
return api_error('文件地址不能为空', 422);
|
||||
}
|
||||
|
||||
$this->attachmentService()->delete($fileUrl);
|
||||
|
||||
return api_success([
|
||||
'file_url' => $fileUrl,
|
||||
], '删除成功');
|
||||
}
|
||||
|
||||
private function statusText(string $status): string
|
||||
{
|
||||
return (new ContentService())->ticketStatusText($status);
|
||||
}
|
||||
|
||||
private function priorityText(string $priority): string
|
||||
{
|
||||
return match ($priority) {
|
||||
'high' => '高优先级',
|
||||
'normal' => '普通',
|
||||
'low' => '低优先级',
|
||||
default => $priority,
|
||||
};
|
||||
}
|
||||
|
||||
private function ticketTypeText(string $type): string
|
||||
{
|
||||
return (new ContentService())->ticketTypeText($type);
|
||||
}
|
||||
|
||||
private function attachmentService(): TicketAttachmentService
|
||||
{
|
||||
return new TicketAttachmentService();
|
||||
}
|
||||
}
|
||||
84
server-api/app/controller/app/VerifyController.php
Normal file
84
server-api/app/controller/app/VerifyController.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class VerifyController
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$reportNo = trim((string)$request->input('report_no', ''));
|
||||
if ($reportNo === '') {
|
||||
return api_error('报告编号不能为空', 422);
|
||||
}
|
||||
|
||||
$verify = Db::name('report_verifies')->where('report_no', $reportNo)->find();
|
||||
$report = Db::name('reports')
|
||||
->where('report_no', $reportNo)
|
||||
->where('report_status', 'published')
|
||||
->find();
|
||||
if (!$verify || !$report) {
|
||||
return api_error('报告不存在', 404, [
|
||||
'verify_status' => 'not_found',
|
||||
]);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('report_verifies')->where('id', $verify['id'])->update([
|
||||
'verify_count' => (int)$verify['verify_count'] + 1,
|
||||
'last_verified_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
Db::name('report_verify_logs')->insert([
|
||||
'report_verify_id' => (int)$verify['id'],
|
||||
'verify_type' => 'h5',
|
||||
'ip' => (string)$request->getRealIp(),
|
||||
'user_agent' => (string)$request->header('user-agent', ''),
|
||||
'verified_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$content = Db::name('report_contents')->where('report_id', $report['id'])->find();
|
||||
$productSnapshot = $this->decodeJsonField($content['product_snapshot_json'] ?? null);
|
||||
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
|
||||
return api_success([
|
||||
'verify_status' => $verify['verify_status'],
|
||||
'verify_message' => match ($verify['verify_status']) {
|
||||
'valid' => '该报告真实有效,可作为对应鉴定结果参考。',
|
||||
'updated' => '该报告已更新,请以最新版本为准。',
|
||||
'invalid' => '该报告已失效,请勿继续作为有效凭证使用。',
|
||||
default => '当前验真状态异常,请稍后重试。',
|
||||
},
|
||||
'report_summary' => [
|
||||
'report_no' => $report['report_no'],
|
||||
'report_title' => $report['report_title'],
|
||||
'institution_name' => $report['institution_name'],
|
||||
'publish_time' => $report['publish_time'],
|
||||
],
|
||||
'product_summary' => $productSnapshot,
|
||||
'result_summary' => $resultSnapshot,
|
||||
'evidence_attachments' => $evidenceAttachments,
|
||||
]);
|
||||
}
|
||||
|
||||
private function decodeJsonField(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return json_decode($value, true) ?: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function evidenceService(): AppraisalEvidenceService
|
||||
{
|
||||
return new AppraisalEvidenceService();
|
||||
}
|
||||
}
|
||||
69
server-api/app/controller/open/OrdersController.php
Normal file
69
server-api/app/controller/open/OrdersController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\open;
|
||||
|
||||
use app\support\EnterpriseOpenApiAuthService;
|
||||
use app\support\EnterpriseOrderService;
|
||||
use support\Request;
|
||||
|
||||
class OrdersController
|
||||
{
|
||||
public function create(Request $request)
|
||||
{
|
||||
try {
|
||||
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->rawBody(), true);
|
||||
if (!is_array($payload)) {
|
||||
return api_error('请求体必须是合法 JSON 对象', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new EnterpriseOrderService())->createOrder($auth['customer'], $payload, $request);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 409);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('订单创建失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success($result, !empty($result['idempotent']) ? '订单已存在' : '订单已创建');
|
||||
}
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
try {
|
||||
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 401);
|
||||
}
|
||||
|
||||
$externalOrderNo = trim((string)($request->route?->param('external_order_no', '') ?? ''));
|
||||
if ($externalOrderNo === '') {
|
||||
$externalOrderNo = trim((string)$request->input('external_order_no', ''));
|
||||
}
|
||||
$orderNo = trim((string)$request->input('order_no', ''));
|
||||
|
||||
try {
|
||||
$order = (new EnterpriseOrderService())->findOrder($auth['customer'], $externalOrderNo, $orderNo);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 404);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('订单查询失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
44
server-api/app/functions.php
Normal file
44
server-api/app/functions.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
if (!function_exists('api_success')) {
|
||||
function api_success(array $data = [], string $message = 'ok', int $code = 0): Response
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('api_error')) {
|
||||
function api_error(string $message = 'error', int $code = 1, array $data = []): Response
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('app_user')) {
|
||||
function app_user(Request $request): ?array
|
||||
{
|
||||
$user = $request->appUser ?? null;
|
||||
return is_array($user) ? $user : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('app_user_id')) {
|
||||
function app_user_id(Request $request): int
|
||||
{
|
||||
return (int)(app_user($request)['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
69
server-api/app/middleware/AdminAuthMiddleware.php
Normal file
69
server-api/app/middleware/AdminAuthMiddleware.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use app\support\AdminAuthService;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
class AdminAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$path = $request->path();
|
||||
if (!str_starts_with($path, '/api/admin')) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/api/admin/ping', '/api/admin/auth/login'], true)) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
$authService = new AdminAuthService();
|
||||
$adminInfo = $authService->current($request);
|
||||
if (!$adminInfo) {
|
||||
return api_error('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
$permissionCode = $this->permissionCode($path);
|
||||
if ($permissionCode !== '' && !$authService->hasPermission($adminInfo, $permissionCode)) {
|
||||
return api_error('无权访问该后台功能', 403);
|
||||
}
|
||||
|
||||
$request->setHeader('x-admin-id', (string)$adminInfo['id']);
|
||||
$request->setHeader('x-admin-name', (string)$adminInfo['name']);
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
private function permissionCode(string $path): string
|
||||
{
|
||||
return match (true) {
|
||||
str_starts_with($path, '/api/admin/dashboard') => 'dashboard.view',
|
||||
str_starts_with($path, '/api/admin/orders'),
|
||||
str_starts_with($path, '/api/admin/order/') => 'orders.manage',
|
||||
str_starts_with($path, '/api/admin/appraisal-tasks'),
|
||||
str_starts_with($path, '/api/admin/appraisal-task/') => 'appraisal_tasks.manage',
|
||||
str_starts_with($path, '/api/admin/catalog/') => 'catalog.manage',
|
||||
str_starts_with($path, '/api/admin/reports'),
|
||||
str_starts_with($path, '/api/admin/report/') => 'reports.manage',
|
||||
str_starts_with($path, '/api/admin/messages') => 'messages.manage',
|
||||
str_starts_with($path, '/api/admin/tickets'),
|
||||
str_starts_with($path, '/api/admin/ticket/') => 'tickets.manage',
|
||||
str_starts_with($path, '/api/admin/users'),
|
||||
str_starts_with($path, '/api/admin/user/') => 'users.manage',
|
||||
str_starts_with($path, '/api/admin/customers'),
|
||||
str_starts_with($path, '/api/admin/customer/') => 'customers.manage',
|
||||
str_starts_with($path, '/api/admin/warehouses'),
|
||||
str_starts_with($path, '/api/admin/warehouse/') => 'warehouses.manage',
|
||||
str_starts_with($path, '/api/admin/material/') => 'materials.manage',
|
||||
str_starts_with($path, '/api/admin/access/') => 'access.manage',
|
||||
str_starts_with($path, '/api/admin/content/') => 'system.manage',
|
||||
str_starts_with($path, '/api/admin/system-configs') => 'system.manage',
|
||||
str_starts_with($path, '/api/admin/auth/me'),
|
||||
str_starts_with($path, '/api/admin/auth/logout') => '',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
57
server-api/app/middleware/AppAuthMiddleware.php
Normal file
57
server-api/app/middleware/AppAuthMiddleware.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use app\support\AppAuthService;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
class AppAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$path = $request->path();
|
||||
if (!str_starts_with($path, '/api/app')) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
if ($request->method() === 'OPTIONS') {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
$authService = new AppAuthService();
|
||||
$userInfo = $authService->current($request);
|
||||
if ($userInfo) {
|
||||
$request->appUser = $userInfo;
|
||||
}
|
||||
|
||||
if ($this->isPublicPath($path)) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
if (!$userInfo) {
|
||||
return api_error('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
private function isPublicPath(string $path): bool
|
||||
{
|
||||
return in_array($path, [
|
||||
'/api/app/home/index',
|
||||
'/api/app/content/page-visuals',
|
||||
'/api/app/catalog/brands',
|
||||
'/api/app/help-center',
|
||||
'/api/app/help-article/detail',
|
||||
'/api/app/report/detail',
|
||||
'/api/app/verify',
|
||||
'/api/app/material-tag',
|
||||
'/api/app/material-tag/verify',
|
||||
'/api/app/auth/send-code',
|
||||
'/api/app/auth/login/code',
|
||||
'/api/app/auth/login/password',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
28
server-api/app/middleware/CorsMiddleware.php
Normal file
28
server-api/app/middleware/CorsMiddleware.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
class CorsMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => $request->header('origin', '*'),
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With, X-AXY-App-Key, X-AXY-Timestamp, X-AXY-Nonce, X-AXY-Signature',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
];
|
||||
|
||||
if ($request->method() === 'OPTIONS') {
|
||||
return response('', 204, $headers);
|
||||
}
|
||||
|
||||
/** @var Response $response */
|
||||
$response = $handler($request);
|
||||
return $response->withHeaders($headers);
|
||||
}
|
||||
}
|
||||
42
server-api/app/middleware/StaticFile.php
Normal file
42
server-api/app/middleware/StaticFile.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* Class StaticFile
|
||||
* @package app\middleware
|
||||
*/
|
||||
class StaticFile implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
// Access to files beginning with. Is prohibited
|
||||
if (strpos($request->path(), '/.') !== false) {
|
||||
return response('<h1>403 forbidden</h1>', 403);
|
||||
}
|
||||
/** @var Response $response */
|
||||
$response = $handler($request);
|
||||
// Add cross domain HTTP header
|
||||
/*$response->withHeaders([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);*/
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
12
server-api/app/model/BaseModel.php
Normal file
12
server-api/app/model/BaseModel.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
use support\think\Model;
|
||||
|
||||
abstract class BaseModel extends Model
|
||||
{
|
||||
protected $autoWriteTimestamp = 'datetime';
|
||||
protected $createTime = 'created_at';
|
||||
protected $updateTime = 'updated_at';
|
||||
}
|
||||
8
server-api/app/model/CatalogCategory.php
Normal file
8
server-api/app/model/CatalogCategory.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class CatalogCategory extends BaseModel
|
||||
{
|
||||
protected $table = 'catalog_categories';
|
||||
}
|
||||
8
server-api/app/model/Order.php
Normal file
8
server-api/app/model/Order.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class Order extends BaseModel
|
||||
{
|
||||
protected $table = 'orders';
|
||||
}
|
||||
8
server-api/app/model/OrderProduct.php
Normal file
8
server-api/app/model/OrderProduct.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class OrderProduct extends BaseModel
|
||||
{
|
||||
protected $table = 'order_products';
|
||||
}
|
||||
8
server-api/app/model/OrderSupplementTask.php
Normal file
8
server-api/app/model/OrderSupplementTask.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class OrderSupplementTask extends BaseModel
|
||||
{
|
||||
protected $table = 'order_supplement_tasks';
|
||||
}
|
||||
8
server-api/app/model/OrderSupplementTaskItem.php
Normal file
8
server-api/app/model/OrderSupplementTaskItem.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class OrderSupplementTaskItem extends BaseModel
|
||||
{
|
||||
protected $table = 'order_supplement_task_items';
|
||||
}
|
||||
9
server-api/app/model/OrderTimeline.php
Normal file
9
server-api/app/model/OrderTimeline.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class OrderTimeline extends BaseModel
|
||||
{
|
||||
protected $table = 'order_timelines';
|
||||
protected $updateTime = null;
|
||||
}
|
||||
8
server-api/app/model/Report.php
Normal file
8
server-api/app/model/Report.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class Report extends BaseModel
|
||||
{
|
||||
protected $table = 'reports';
|
||||
}
|
||||
8
server-api/app/model/ReportContent.php
Normal file
8
server-api/app/model/ReportContent.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class ReportContent extends BaseModel
|
||||
{
|
||||
protected $table = 'report_contents';
|
||||
}
|
||||
8
server-api/app/model/ReportVerify.php
Normal file
8
server-api/app/model/ReportVerify.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
class ReportVerify extends BaseModel
|
||||
{
|
||||
protected $table = 'report_verifies';
|
||||
}
|
||||
29
server-api/app/model/Test.php
Normal file
29
server-api/app/model/Test.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class Test extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'test';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
}
|
||||
10
server-api/app/process/Http.php
Normal file
10
server-api/app/process/Http.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use Webman\App;
|
||||
|
||||
class Http extends App
|
||||
{
|
||||
|
||||
}
|
||||
305
server-api/app/process/Monitor.php
Normal file
305
server-api/app/process/Monitor.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Class FileMonitor
|
||||
* @package process
|
||||
*/
|
||||
class Monitor
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $paths = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $extensions = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $loadedFiles = [];
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected int $ppid = 0;
|
||||
|
||||
/**
|
||||
* Pause monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function pause(): void
|
||||
{
|
||||
file_put_contents(static::lockFile(), time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume monitor
|
||||
* @return void
|
||||
*/
|
||||
public static function resume(): void
|
||||
{
|
||||
clearstatcache();
|
||||
if (is_file(static::lockFile())) {
|
||||
unlink(static::lockFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether monitor is paused
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPaused(): bool
|
||||
{
|
||||
clearstatcache();
|
||||
return file_exists(static::lockFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock file
|
||||
* @return string
|
||||
*/
|
||||
protected static function lockFile(): string
|
||||
{
|
||||
return runtime_path('monitor.lock');
|
||||
}
|
||||
|
||||
/**
|
||||
* FileMonitor constructor.
|
||||
* @param $monitorDir
|
||||
* @param $monitorExtensions
|
||||
* @param array $options
|
||||
*/
|
||||
public function __construct($monitorDir, $monitorExtensions, array $options = [])
|
||||
{
|
||||
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
|
||||
static::resume();
|
||||
$this->paths = (array)$monitorDir;
|
||||
$this->extensions = $monitorExtensions;
|
||||
foreach (get_included_files() as $index => $file) {
|
||||
$this->loadedFiles[$file] = $index;
|
||||
if (strpos($file, 'webman-framework/src/support/App.php')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
$disableFunctions = explode(',', ini_get('disable_functions'));
|
||||
if (in_array('exec', $disableFunctions, true)) {
|
||||
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
|
||||
} else {
|
||||
if ($options['enable_file_monitor'] ?? true) {
|
||||
Timer::add(1, function () {
|
||||
$this->checkAllFilesChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
|
||||
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
|
||||
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $monitorDir
|
||||
* @return bool
|
||||
*/
|
||||
public function checkFilesChange($monitorDir): bool
|
||||
{
|
||||
static $lastMtime, $tooManyFilesCheck;
|
||||
if (!$lastMtime) {
|
||||
$lastMtime = time();
|
||||
}
|
||||
clearstatcache();
|
||||
if (!is_dir($monitorDir)) {
|
||||
if (!is_file($monitorDir)) {
|
||||
return false;
|
||||
}
|
||||
$iterator = [new SplFileInfo($monitorDir)];
|
||||
} else {
|
||||
// recursive traversal directory
|
||||
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
|
||||
$iterator = new RecursiveIteratorIterator($dirIterator);
|
||||
}
|
||||
$count = 0;
|
||||
foreach ($iterator as $file) {
|
||||
$count ++;
|
||||
/** @var SplFileInfo $file */
|
||||
if (is_dir($file->getRealPath())) {
|
||||
continue;
|
||||
}
|
||||
// check mtime
|
||||
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
|
||||
$lastMtime = $file->getMTime();
|
||||
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
|
||||
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
|
||||
continue;
|
||||
}
|
||||
$var = 0;
|
||||
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
|
||||
if ($var) {
|
||||
continue;
|
||||
}
|
||||
// send SIGUSR1 signal to master process for reload
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
if ($masterPid = $this->getMasterPid()) {
|
||||
echo $file . " updated and reload\n";
|
||||
posix_kill($masterPid, SIGUSR1);
|
||||
} else {
|
||||
echo "Master process has gone away and can not reload\n";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
echo $file . " updated and reload\n";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!$tooManyFilesCheck && $count > 1000) {
|
||||
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
|
||||
$tooManyFilesCheck = 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getMasterPid(): int
|
||||
{
|
||||
if ($this->ppid === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
|
||||
echo "Master process has gone away\n";
|
||||
return $this->ppid = 0;
|
||||
}
|
||||
if (PHP_OS_FAMILY !== 'Linux') {
|
||||
return $this->ppid;
|
||||
}
|
||||
$cmdline = "/proc/$this->ppid/cmdline";
|
||||
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
|
||||
// Process not exist
|
||||
$this->ppid = 0;
|
||||
}
|
||||
return $this->ppid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function checkAllFilesChange(): bool
|
||||
{
|
||||
if (static::isPaused()) {
|
||||
return false;
|
||||
}
|
||||
foreach ($this->paths as $path) {
|
||||
if ($this->checkFilesChange($path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $memoryLimit
|
||||
* @return void
|
||||
*/
|
||||
public function checkMemory($memoryLimit): void
|
||||
{
|
||||
if (static::isPaused() || $memoryLimit <= 0) {
|
||||
return;
|
||||
}
|
||||
$masterPid = $this->getMasterPid();
|
||||
if ($masterPid <= 0) {
|
||||
echo "Master process has gone away\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
|
||||
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
|
||||
return;
|
||||
}
|
||||
foreach (explode(' ', $children) as $pid) {
|
||||
$pid = (int)$pid;
|
||||
$statusFile = "/proc/$pid/status";
|
||||
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
|
||||
continue;
|
||||
}
|
||||
$mem = 0;
|
||||
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
|
||||
$mem = $match[1];
|
||||
}
|
||||
$mem = (int)($mem / 1024);
|
||||
if ($mem >= $memoryLimit) {
|
||||
posix_kill($pid, SIGINT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory limit
|
||||
* @param $memoryLimit
|
||||
* @return int
|
||||
*/
|
||||
protected function getMemoryLimit($memoryLimit): int
|
||||
{
|
||||
if ($memoryLimit === 0) {
|
||||
return 0;
|
||||
}
|
||||
$usePhpIni = false;
|
||||
if (!$memoryLimit) {
|
||||
$memoryLimit = ini_get('memory_limit');
|
||||
$usePhpIni = true;
|
||||
}
|
||||
|
||||
if ($memoryLimit == -1) {
|
||||
return 0;
|
||||
}
|
||||
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
|
||||
$memoryLimit = (int)$memoryLimit;
|
||||
if ($unit === 'g') {
|
||||
$memoryLimit = 1024 * $memoryLimit;
|
||||
} else if ($unit === 'k') {
|
||||
$memoryLimit = ($memoryLimit / 1024);
|
||||
} else if ($unit === 'm') {
|
||||
$memoryLimit = (int)($memoryLimit);
|
||||
} else if ($unit === 't') {
|
||||
$memoryLimit = (1024 * 1024 * $memoryLimit);
|
||||
} else {
|
||||
$memoryLimit = ($memoryLimit / (1024 * 1024));
|
||||
}
|
||||
if ($memoryLimit < 50) {
|
||||
$memoryLimit = 50;
|
||||
}
|
||||
if ($usePhpIni) {
|
||||
$memoryLimit = (0.8 * $memoryLimit);
|
||||
}
|
||||
return (int)$memoryLimit;
|
||||
}
|
||||
|
||||
}
|
||||
253
server-api/app/support/AdminAccessService.php
Normal file
253
server-api/app/support/AdminAccessService.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class AdminAccessService
|
||||
{
|
||||
public function bootstrapDefaults(): void
|
||||
{
|
||||
$this->syncPermissions();
|
||||
$superAdminRoleId = $this->ensureSuperAdminRole();
|
||||
$this->ensureDefaultOperationRoles();
|
||||
$this->ensureDefaultAdmin($superAdminRoleId);
|
||||
}
|
||||
|
||||
public function permissionDefinitions(): array
|
||||
{
|
||||
return [
|
||||
['name' => '查看工作台', 'code' => 'dashboard.view', 'module' => 'dashboard', 'action' => 'view'],
|
||||
['name' => '管理订单', 'code' => 'orders.manage', 'module' => 'orders', 'action' => 'manage'],
|
||||
['name' => '管理鉴定任务', 'code' => 'appraisal_tasks.manage', 'module' => 'appraisal_tasks', 'action' => 'manage'],
|
||||
['name' => '管理商品资料', 'code' => 'catalog.manage', 'module' => 'catalog', 'action' => 'manage'],
|
||||
['name' => '管理报告', 'code' => 'reports.manage', 'module' => 'reports', 'action' => 'manage'],
|
||||
['name' => '管理消息', 'code' => 'messages.manage', 'module' => 'messages', 'action' => 'manage'],
|
||||
['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'],
|
||||
['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'],
|
||||
['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'],
|
||||
['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'],
|
||||
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
|
||||
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
|
||||
['name' => '管理系统配置', 'code' => 'system.manage', 'module' => 'system_config', 'action' => 'manage'],
|
||||
];
|
||||
}
|
||||
|
||||
public function moduleText(string $module): string
|
||||
{
|
||||
return match ($module) {
|
||||
'dashboard' => '工作台',
|
||||
'orders' => '订单中心',
|
||||
'appraisal_tasks' => '鉴定作业台',
|
||||
'catalog' => '商品资料中心',
|
||||
'reports' => '报告中心',
|
||||
'messages' => '消息中心',
|
||||
'tickets' => '客服与售后',
|
||||
'users' => '用户管理',
|
||||
'customers' => '客户管理',
|
||||
'warehouses' => '仓库中心',
|
||||
'materials' => '物料管理',
|
||||
'access' => '权限中心',
|
||||
'system_config' => '系统配置',
|
||||
default => $module,
|
||||
};
|
||||
}
|
||||
|
||||
public function statusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'enabled' => '启用中',
|
||||
'disabled' => '已停用',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function syncPermissions(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($this->permissionDefinitions() as $item) {
|
||||
$exists = Db::name('admin_permissions')->where('code', $item['code'])->find();
|
||||
$payload = [
|
||||
'name' => $item['name'],
|
||||
'code' => $item['code'],
|
||||
'module' => $item['module'],
|
||||
'action' => $item['action'],
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($exists) {
|
||||
Db::name('admin_permissions')->where('id', $exists['id'])->update($payload);
|
||||
} else {
|
||||
try {
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('admin_permissions')->insert($payload);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore duplicate insert caused by concurrent bootstrap.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureSuperAdminRole(): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$role = Db::name('admin_roles')->where('code', 'super_admin')->find();
|
||||
|
||||
if ($role) {
|
||||
Db::name('admin_roles')->where('id', $role['id'])->update([
|
||||
'name' => '超级管理员',
|
||||
'status' => 'enabled',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$roleId = (int)$role['id'];
|
||||
} else {
|
||||
$roleId = (int)Db::name('admin_roles')->insertGetId([
|
||||
'name' => '超级管理员',
|
||||
'code' => 'super_admin',
|
||||
'status' => 'enabled',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$permissionIds = Db::name('admin_permissions')->column('id');
|
||||
foreach ($permissionIds as $permissionId) {
|
||||
$exists = Db::name('admin_role_permissions')
|
||||
->where('role_id', $roleId)
|
||||
->where('permission_id', $permissionId)
|
||||
->find();
|
||||
if (!$exists) {
|
||||
try {
|
||||
Db::name('admin_role_permissions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permissionId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore duplicate insert caused by concurrent bootstrap.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
private function ensureDefaultOperationRoles(): void
|
||||
{
|
||||
$this->ensureRoleWithPermissions('appraiser', '鉴定师', [
|
||||
'dashboard.view',
|
||||
'appraisal_tasks.manage',
|
||||
'reports.manage',
|
||||
]);
|
||||
|
||||
$this->ensureRoleWithPermissions('reviewer', '报告管理员', [
|
||||
'dashboard.view',
|
||||
'appraisal_tasks.manage',
|
||||
'reports.manage',
|
||||
]);
|
||||
|
||||
$this->ensureRoleWithPermissions('material_manager', '物料管理员', [
|
||||
'dashboard.view',
|
||||
'materials.manage',
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$role = Db::name('admin_roles')->where('code', $code)->find();
|
||||
|
||||
if ($role) {
|
||||
Db::name('admin_roles')->where('id', $role['id'])->update([
|
||||
'name' => $name,
|
||||
'status' => 'enabled',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$roleId = (int)$role['id'];
|
||||
} else {
|
||||
$roleId = (int)Db::name('admin_roles')->insertGetId([
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'status' => 'enabled',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$permissionIds = Db::name('admin_permissions')
|
||||
->whereIn('code', $permissionCodes)
|
||||
->column('id');
|
||||
|
||||
$permissionIds = array_map('intval', $permissionIds);
|
||||
$existingPermissionIds = array_map(
|
||||
'intval',
|
||||
Db::name('admin_role_permissions')->where('role_id', $roleId)->column('permission_id')
|
||||
);
|
||||
|
||||
$obsoletePermissionIds = array_values(array_diff($existingPermissionIds, $permissionIds));
|
||||
if ($obsoletePermissionIds) {
|
||||
Db::name('admin_role_permissions')
|
||||
->where('role_id', $roleId)
|
||||
->whereIn('permission_id', $obsoletePermissionIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$missingPermissionIds = array_values(array_diff($permissionIds, $existingPermissionIds));
|
||||
foreach ($missingPermissionIds as $permissionId) {
|
||||
try {
|
||||
Db::name('admin_role_permissions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => (int)$permissionId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore duplicate insert caused by concurrent bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
private function ensureDefaultAdmin(int $superAdminRoleId): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$admin = Db::name('admin_users')->order('id', 'asc')->find();
|
||||
$defaultPasswordHash = password_hash('Admin@123456', PASSWORD_BCRYPT);
|
||||
|
||||
if ($admin) {
|
||||
if (($admin['password'] ?? '') === '' || ($admin['password'] ?? '') === 'not-used') {
|
||||
Db::name('admin_users')->where('id', $admin['id'])->update([
|
||||
'password' => $defaultPasswordHash,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$adminId = (int)$admin['id'];
|
||||
} else {
|
||||
$adminId = (int)Db::name('admin_users')->insertGetId([
|
||||
'name' => '系统管理员',
|
||||
'mobile' => '13800138000',
|
||||
'email' => 'admin@anxinyan.local',
|
||||
'password' => $defaultPasswordHash,
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$relation = Db::name('admin_role_relations')
|
||||
->where('admin_user_id', $adminId)
|
||||
->where('role_id', $superAdminRoleId)
|
||||
->find();
|
||||
if (!$relation) {
|
||||
try {
|
||||
Db::name('admin_role_relations')->insert([
|
||||
'admin_user_id' => $adminId,
|
||||
'role_id' => $superAdminRoleId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore duplicate insert caused by concurrent bootstrap.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
server-api/app/support/AdminAuthService.php
Normal file
160
server-api/app/support/AdminAuthService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use Webman\Http\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class AdminAuthService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensureTokenTable();
|
||||
(new AdminAccessService())->bootstrapDefaults();
|
||||
}
|
||||
|
||||
public function login(string $mobile, string $password, Request $request): array
|
||||
{
|
||||
$admin = Db::name('admin_users')->where('mobile', $mobile)->find();
|
||||
if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
if (!password_verify($password, (string)$admin['password'])) {
|
||||
throw new \RuntimeException('手机号或密码错误');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expireTime = date('Y-m-d H:i:s', time() + 7 * 24 * 3600);
|
||||
|
||||
// Allow concurrent logins across devices/browsers. Only clean up this user's expired tokens.
|
||||
Db::name('admin_api_tokens')
|
||||
->where('admin_user_id', $admin['id'])
|
||||
->where('expire_time', '<', $now)
|
||||
->delete();
|
||||
Db::name('admin_api_tokens')->insert([
|
||||
'admin_user_id' => (int)$admin['id'],
|
||||
'token_hash' => $tokenHash,
|
||||
'expire_time' => $expireTime,
|
||||
'last_active_at' => $now,
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('admin_users')->where('id', $admin['id'])->update([
|
||||
'last_login_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'admin_info' => $this->adminInfo((int)$admin['id']),
|
||||
];
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::name('admin_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
|
||||
}
|
||||
|
||||
public function current(Request $request): ?array
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = Db::name('admin_api_tokens')
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->find();
|
||||
if (!$record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
|
||||
Db::name('admin_api_tokens')->where('id', $record['id'])->delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
$admin = Db::name('admin_users')->where('id', $record['admin_user_id'])->find();
|
||||
if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
Db::name('admin_api_tokens')->where('id', $record['id'])->update([
|
||||
'last_active_at' => date('Y-m-d H:i:s'),
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->adminInfo((int)$admin['id']);
|
||||
}
|
||||
|
||||
public function hasPermission(array $adminInfo, string $permissionCode): bool
|
||||
{
|
||||
if ($permissionCode === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($permissionCode, $adminInfo['permission_codes'], true);
|
||||
}
|
||||
|
||||
private function adminInfo(int $adminUserId): array
|
||||
{
|
||||
$admin = Db::name('admin_users')->where('id', $adminUserId)->find();
|
||||
$roleIds = Db::name('admin_role_relations')->where('admin_user_id', $adminUserId)->column('role_id');
|
||||
$roles = $roleIds ? Db::name('admin_roles')->whereIn('id', $roleIds)->select()->toArray() : [];
|
||||
$permissionIds = $roleIds ? Db::name('admin_role_permissions')->whereIn('role_id', $roleIds)->column('permission_id') : [];
|
||||
$permissions = $permissionIds ? Db::name('admin_permissions')->whereIn('id', $permissionIds)->select()->toArray() : [];
|
||||
|
||||
return [
|
||||
'id' => (int)($admin['id'] ?? 0),
|
||||
'name' => $admin['name'] ?? '',
|
||||
'mobile' => $admin['mobile'] ?? '',
|
||||
'email' => $admin['email'] ?? '',
|
||||
'status' => $admin['status'] ?? 'enabled',
|
||||
'role_names' => array_values(array_map(fn (array $item) => $item['name'], $roles)),
|
||||
'permission_codes' => array_values(array_unique(array_map(fn (array $item) => $item['code'], $permissions))),
|
||||
];
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): string
|
||||
{
|
||||
$authorization = trim((string)$request->header('authorization', ''));
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function ensureTokenTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS admin_api_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
admin_user_id BIGINT UNSIGNED NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
expire_time DATETIME NOT NULL,
|
||||
last_active_at DATETIME NULL DEFAULT NULL,
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_agent VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_api_tokens_token_hash (token_hash),
|
||||
KEY idx_admin_api_tokens_admin_user_id (admin_user_id),
|
||||
KEY idx_admin_api_tokens_expire_time (expire_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台登录Token';
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
441
server-api/app/support/AppAuthService.php
Normal file
441
server-api/app/support/AppAuthService.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use Webman\Http\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class AppAuthService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$this->ensureTokenTable();
|
||||
$this->ensureSmsCodeTable();
|
||||
}
|
||||
|
||||
public function sendLoginCode(string $mobile, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号已停用,无法发送验证码');
|
||||
}
|
||||
|
||||
$scene = 'login';
|
||||
$now = time();
|
||||
$latest = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', $scene)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if ($latest) {
|
||||
$retryAt = strtotime((string)$latest['created_at']) + 60;
|
||||
if ($retryAt > $now) {
|
||||
throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now)));
|
||||
}
|
||||
}
|
||||
|
||||
$todayStart = date('Y-m-d 00:00:00');
|
||||
$todayCount = (int)Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', $scene)
|
||||
->where('created_at', '>=', $todayStart)
|
||||
->count();
|
||||
if ($todayCount >= 20) {
|
||||
throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试');
|
||||
}
|
||||
|
||||
$code = (string)random_int(100000, 999999);
|
||||
$nowText = date('Y-m-d H:i:s', $now);
|
||||
$expireTime = date('Y-m-d H:i:s', $now + 300);
|
||||
|
||||
$sendResult = null;
|
||||
$sendStatus = 'failed';
|
||||
$failedReason = '';
|
||||
try {
|
||||
$sendResult = (new AppSmsService())->sendLoginCode($mobile, $code);
|
||||
$sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success';
|
||||
} catch (\Throwable $e) {
|
||||
$failedReason = $this->truncateText($e->getMessage(), 250);
|
||||
}
|
||||
|
||||
Db::name('sms_code_logs')->insert([
|
||||
'mobile' => $mobile,
|
||||
'scene' => $scene,
|
||||
'code_hash' => $this->codeHash($mobile, $scene, $code),
|
||||
'send_status' => $sendStatus,
|
||||
'provider' => $sendResult['provider'] ?? 'aliyun_sms',
|
||||
'template_code' => $this->systemConfig('sms', 'login_template_code'),
|
||||
'request_id' => $sendResult['request_id'] ?? '',
|
||||
'biz_id' => $sendResult['biz_id'] ?? '',
|
||||
'failed_reason' => $failedReason,
|
||||
'expire_time' => $expireTime,
|
||||
'used_at' => null,
|
||||
'send_ip' => $request->getRealIp(),
|
||||
'created_at' => $nowText,
|
||||
'updated_at' => $nowText,
|
||||
]);
|
||||
|
||||
if ($sendStatus === 'failed') {
|
||||
throw new \RuntimeException($failedReason ?: '验证码发送失败');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'mobile' => $mobile,
|
||||
'scene' => $scene,
|
||||
'expire_seconds' => 300,
|
||||
'retry_after_seconds' => 60,
|
||||
];
|
||||
if (($sendResult['debug_code'] ?? null) !== null) {
|
||||
$payload['debug_code'] = $sendResult['debug_code'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function loginByCode(string $mobile, string $code, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
throw new \RuntimeException('验证码格式不正确');
|
||||
}
|
||||
|
||||
$record = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', 'login')
|
||||
->whereIn('send_status', ['success', 'mock'])
|
||||
->whereNull('used_at')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$record) {
|
||||
throw new \RuntimeException('验证码不存在或已失效');
|
||||
}
|
||||
|
||||
if (strtotime((string)$record['expire_time']) < time()) {
|
||||
throw new \RuntimeException('验证码已过期,请重新获取');
|
||||
}
|
||||
|
||||
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
|
||||
throw new \RuntimeException('验证码错误');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('sms_code_logs')->where('id', $record['id'])->update([
|
||||
'used_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号已停用');
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => '安心验用户' . substr($mobile, -4),
|
||||
'avatar' => '',
|
||||
'mobile' => $mobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
}
|
||||
|
||||
$this->syncMobileAuth($userId, $mobile, $now);
|
||||
return $this->issueToken($userId, $request, 'sms_code');
|
||||
}
|
||||
|
||||
public function loginByPassword(string $mobile, string $password, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$password = trim($password);
|
||||
if ($password === '') {
|
||||
throw new \RuntimeException('密码不能为空');
|
||||
}
|
||||
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$passwordHash = (string)($user['password'] ?? '');
|
||||
if ($passwordHash === '') {
|
||||
throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $passwordHash)) {
|
||||
throw new \RuntimeException('手机号或密码错误');
|
||||
}
|
||||
|
||||
$this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s'));
|
||||
return $this->issueToken((int)$user['id'], $request, 'password');
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
|
||||
}
|
||||
|
||||
public function current(Request $request): ?array
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = Db::name('user_api_tokens')
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->find();
|
||||
if (!$record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
|
||||
Db::name('user_api_tokens')->where('id', $record['id'])->delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Db::name('users')->where('id', $record['user_id'])->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
Db::name('user_api_tokens')->where('id', $record['id'])->update([
|
||||
'last_active_at' => date('Y-m-d H:i:s'),
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->userInfo((int)$user['id']);
|
||||
}
|
||||
|
||||
public function savePassword(int $userId, string $currentPassword, string $newPassword): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$currentHash = (string)($user['password'] ?? '');
|
||||
$hadPassword = $currentHash !== '';
|
||||
if ($currentHash !== '') {
|
||||
if ($currentPassword === '') {
|
||||
throw new \RuntimeException('请输入当前密码');
|
||||
}
|
||||
if (!password_verify($currentPassword, $currentHash)) {
|
||||
throw new \RuntimeException('当前密码错误');
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateNewPassword($newPassword);
|
||||
|
||||
Db::name('users')->where('id', $userId)->update([
|
||||
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'password_set' => true,
|
||||
'had_password' => $hadPassword,
|
||||
];
|
||||
}
|
||||
|
||||
private function issueToken(int $userId, Request $request, string $authType): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
|
||||
|
||||
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
|
||||
Db::name('user_api_tokens')->insert([
|
||||
'user_id' => $userId,
|
||||
'token_hash' => $tokenHash,
|
||||
'auth_type' => $authType,
|
||||
'expire_time' => $expireTime,
|
||||
'last_active_at' => $now,
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('users')->where('id', $userId)->update([
|
||||
'last_login_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'user_info' => $this->userInfo($userId),
|
||||
];
|
||||
}
|
||||
|
||||
private function userInfo(int $userId): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
return [
|
||||
'id' => (int)($user['id'] ?? 0),
|
||||
'nickname' => $user['nickname'] ?: '安心验用户',
|
||||
'mobile' => $user['mobile'] ?? '',
|
||||
'avatar' => $user['avatar'] ?? '',
|
||||
'status' => $user['status'] ?? 'enabled',
|
||||
'password_set' => ((string)($user['password'] ?? '')) !== '',
|
||||
];
|
||||
}
|
||||
|
||||
private function syncMobileAuth(int $userId, string $mobile, string $now): void
|
||||
{
|
||||
$existing = Db::name('user_auths')
|
||||
->where('auth_type', 'mobile')
|
||||
->where('auth_key', $mobile)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'auth_type' => 'mobile',
|
||||
'auth_key' => $mobile,
|
||||
'auth_open_id' => '',
|
||||
'auth_union_id' => '',
|
||||
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('user_auths')->insert($payload);
|
||||
}
|
||||
|
||||
private function normalizeMobile(string $mobile): string
|
||||
{
|
||||
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
|
||||
if (!preg_match('/^1\d{10}$/', $mobile)) {
|
||||
throw new \RuntimeException('请输入正确的手机号');
|
||||
}
|
||||
return $mobile;
|
||||
}
|
||||
|
||||
private function validateNewPassword(string $password): void
|
||||
{
|
||||
if (mb_strlen($password) < 8) {
|
||||
throw new \RuntimeException('密码长度不能少于 8 位');
|
||||
}
|
||||
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) {
|
||||
throw new \RuntimeException('密码需同时包含字母和数字');
|
||||
}
|
||||
}
|
||||
|
||||
private function codeHash(string $mobile, string $scene, string $code): string
|
||||
{
|
||||
return hash('sha256', implode('|', [$mobile, $scene, $code]));
|
||||
}
|
||||
|
||||
private function systemConfig(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): string
|
||||
{
|
||||
$authorization = trim((string)$request->header('authorization', ''));
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function ensurePasswordColumn(): void
|
||||
{
|
||||
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
|
||||
if ($column) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
|
||||
}
|
||||
|
||||
private function ensureTokenTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
auth_type VARCHAR(32) NOT NULL DEFAULT 'password',
|
||||
expire_time DATETIME NOT NULL,
|
||||
last_active_at DATETIME NULL DEFAULT NULL,
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_agent VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_api_tokens_token_hash (token_hash),
|
||||
KEY idx_user_api_tokens_user_id (user_id),
|
||||
KEY idx_user_api_tokens_expire_time (expire_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token';
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function ensureSmsCodeTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sms_code_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
mobile VARCHAR(32) NOT NULL,
|
||||
scene VARCHAR(32) NOT NULL DEFAULT 'login',
|
||||
code_hash VARCHAR(64) NOT NULL,
|
||||
send_status VARCHAR(32) NOT NULL DEFAULT 'success',
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms',
|
||||
template_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
request_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
biz_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
failed_reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
expire_time DATETIME NOT NULL,
|
||||
used_at DATETIME NULL DEFAULT NULL,
|
||||
send_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_sms_code_logs_mobile_scene (mobile, scene),
|
||||
KEY idx_sms_code_logs_expire_time (expire_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录';
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function truncateText(string $value, int $maxLength): string
|
||||
{
|
||||
if (mb_strlen($value) <= $maxLength) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
}
|
||||
125
server-api/app/support/AppSmsService.php
Normal file
125
server-api/app/support/AppSmsService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use AlibabaCloud\SDK\Dysmsapi\V20170525\Dysmsapi;
|
||||
use AlibabaCloud\SDK\Dysmsapi\V20170525\Models\SendSmsRequest;
|
||||
use Darabonba\OpenApi\Models\Config;
|
||||
use support\think\Db;
|
||||
|
||||
class AppSmsService
|
||||
{
|
||||
public function sendLoginCode(string $mobile, string $code): array
|
||||
{
|
||||
$this->bootstrapCaBundle();
|
||||
$config = $this->config();
|
||||
if (!$this->isConfigured($config)) {
|
||||
if ($this->isDebugMode()) {
|
||||
return [
|
||||
'provider' => 'debug',
|
||||
'request_id' => 'debug',
|
||||
'biz_id' => 'debug',
|
||||
'raw_body' => [
|
||||
'Code' => 'OK',
|
||||
'Message' => 'DEBUG_SMS_BYPASS',
|
||||
],
|
||||
'debug_code' => $code,
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException('短信配置未完成,请先在后台系统配置中填写阿里云短信参数');
|
||||
}
|
||||
|
||||
$client = new Dysmsapi(new Config([
|
||||
'accessKeyId' => $config['access_key_id'],
|
||||
'accessKeySecret' => $config['access_key_secret'],
|
||||
'regionId' => $config['region_id'] ?: 'cn-hangzhou',
|
||||
'endpoint' => $config['endpoint'] ?: null,
|
||||
]));
|
||||
|
||||
$response = $client->sendSms(new SendSmsRequest([
|
||||
'phoneNumbers' => $mobile,
|
||||
'signName' => $config['sign_name'],
|
||||
'templateCode' => $config['login_template_code'],
|
||||
'templateParam' => json_encode(['code' => $code], JSON_UNESCAPED_UNICODE),
|
||||
]));
|
||||
|
||||
$body = $response->body ? $response->body->toMap() : [];
|
||||
$responseCode = (string)($body['Code'] ?? '');
|
||||
if ($responseCode !== 'OK') {
|
||||
throw new \RuntimeException((string)($body['Message'] ?? '短信发送失败'));
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'aliyun_sms',
|
||||
'request_id' => (string)($body['RequestId'] ?? ''),
|
||||
'biz_id' => (string)($body['BizId'] ?? ''),
|
||||
'raw_body' => $body,
|
||||
'debug_code' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function config(): array
|
||||
{
|
||||
$rows = Db::name('system_configs')
|
||||
->where('config_group', 'sms')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[$row['config_key']] = trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
return [
|
||||
'access_key_id' => $map['access_key_id'] ?? '',
|
||||
'access_key_secret' => $map['access_key_secret'] ?? '',
|
||||
'sign_name' => $map['sign_name'] ?? '',
|
||||
'login_template_code' => $map['login_template_code'] ?? '',
|
||||
'region_id' => $map['region_id'] ?? 'cn-hangzhou',
|
||||
'endpoint' => $map['endpoint'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function isConfigured(array $config): bool
|
||||
{
|
||||
return $config['access_key_id'] !== ''
|
||||
&& $config['access_key_secret'] !== ''
|
||||
&& $config['sign_name'] !== ''
|
||||
&& $config['login_template_code'] !== '';
|
||||
}
|
||||
|
||||
private function isDebugMode(): bool
|
||||
{
|
||||
return in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true);
|
||||
}
|
||||
|
||||
private function bootstrapCaBundle(): void
|
||||
{
|
||||
if (ini_get('curl.cainfo') || ini_get('openssl.cafile')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->candidateCaFiles() as $path) {
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ini_set('curl.cainfo', $path);
|
||||
ini_set('openssl.cafile', $path);
|
||||
putenv('CURL_CA_BUNDLE=' . $path);
|
||||
putenv('SSL_CERT_FILE=' . $path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function candidateCaFiles(): array
|
||||
{
|
||||
return [
|
||||
'/etc/ssl/cert.pem',
|
||||
'/private/etc/ssl/cert.pem',
|
||||
'/opt/homebrew/etc/openssl@3/cert.pem',
|
||||
'/usr/local/etc/openssl@3/cert.pem',
|
||||
];
|
||||
}
|
||||
}
|
||||
141
server-api/app/support/AppraisalEvidenceService.php
Normal file
141
server-api/app/support/AppraisalEvidenceService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use function parse_url;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
|
||||
class AppraisalEvidenceService
|
||||
{
|
||||
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic'];
|
||||
private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'm4v', 'webm', 'avi', 'mpeg', 'mpg'];
|
||||
private const PDF_EXTENSIONS = ['pdf'];
|
||||
|
||||
public function upload(Request $request, string $inputName = 'file'): array
|
||||
{
|
||||
$file = $request->file($inputName);
|
||||
if (!$file || !$file->isValid()) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: '');
|
||||
$fileType = $this->detectFileType($extension);
|
||||
if ($fileType === 'file') {
|
||||
throw new \RuntimeException('仅支持上传图片、视频或 PDF 文件');
|
||||
}
|
||||
|
||||
$filename = sprintf('evidence_%s.%s', uniqid(), $extension ?: 'dat');
|
||||
$relativeDir = 'uploads/appraisal-evidence/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
|
||||
return [
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileType === 'image' ? $fileUrl : '',
|
||||
'name' => $file->getUploadName(),
|
||||
'file_type' => $fileType,
|
||||
'mime_type' => $this->mimeType($fileType, $extension),
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(string $fileUrl): void
|
||||
{
|
||||
$relativePath = $this->storage()->storagePath($fileUrl);
|
||||
if (!str_starts_with($relativePath, 'uploads/appraisal-evidence/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storage()->delete($relativePath);
|
||||
}
|
||||
|
||||
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
|
||||
{
|
||||
if (is_string($attachments) && $attachments !== '') {
|
||||
$decoded = json_decode($attachments, true);
|
||||
$attachments = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
if (!is_array($attachments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($attachments as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileUrl = trim((string)($item['file_url'] ?? ''));
|
||||
if ($fileUrl === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string)($item['name'] ?? ''));
|
||||
$mimeType = trim((string)($item['mime_type'] ?? ''));
|
||||
$fileType = trim((string)($item['file_type'] ?? ''));
|
||||
|
||||
if ($fileType === '') {
|
||||
$path = parse_url('/' . $this->storage()->storagePath($fileUrl), PHP_URL_PATH) ?: '';
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$fileType = $this->detectFileType($extension);
|
||||
if ($mimeType === '') {
|
||||
$mimeType = $this->mimeType($fileType, $extension);
|
||||
}
|
||||
}
|
||||
|
||||
$storedFileUrl = $this->storage()->storagePath($fileUrl);
|
||||
$storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : ''))));
|
||||
|
||||
$list[] = [
|
||||
'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))),
|
||||
'file_url' => $forStorage
|
||||
? '/' . $storedFileUrl
|
||||
: ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl),
|
||||
'thumbnail_url' => $forStorage
|
||||
? ($storedThumbnailUrl !== '' ? '/' . $storedThumbnailUrl : '')
|
||||
: ($request
|
||||
? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : ''))), $request)
|
||||
: trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : '')))),
|
||||
'name' => $name,
|
||||
'file_type' => $fileType ?: 'file',
|
||||
'mime_type' => $mimeType,
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function detectFileType(string $extension): string
|
||||
{
|
||||
if (in_array($extension, self::IMAGE_EXTENSIONS, true)) {
|
||||
return 'image';
|
||||
}
|
||||
if (in_array($extension, self::VIDEO_EXTENSIONS, true)) {
|
||||
return 'video';
|
||||
}
|
||||
if (in_array($extension, self::PDF_EXTENSIONS, true)) {
|
||||
return 'pdf';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
private function mimeType(string $fileType, string $extension): string
|
||||
{
|
||||
return match ($fileType) {
|
||||
'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')),
|
||||
'video' => 'video/' . ($extension ?: 'mp4'),
|
||||
'pdf' => 'application/pdf',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
64
server-api/app/support/CatalogTemplateSampleImageService.php
Normal file
64
server-api/app/support/CatalogTemplateSampleImageService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use function md5;
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
|
||||
class CatalogTemplateSampleImageService
|
||||
{
|
||||
public function upload(Request $request, string $inputName = 'file'): array
|
||||
{
|
||||
$file = $request->file($inputName);
|
||||
if (!$file || !$file->isValid()) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
|
||||
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片');
|
||||
}
|
||||
|
||||
$filename = sprintf('upload_template_sample_%s.%s', uniqid(), $extension);
|
||||
$relativeDir = 'uploads/upload-template-samples/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
|
||||
return [
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileUrl,
|
||||
'name' => $file->getUploadName(),
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(string $fileUrl): void
|
||||
{
|
||||
$relativePath = $this->storage()->storagePath($fileUrl);
|
||||
if (!str_starts_with($relativePath, 'uploads/upload-template-samples/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storage()->delete($relativePath);
|
||||
}
|
||||
|
||||
public function normalizeUrl(string $fileUrl, Request $request): string
|
||||
{
|
||||
return $this->storage()->normalizeUrl($fileUrl, $request);
|
||||
}
|
||||
|
||||
public function storagePath(string $fileUrl): string
|
||||
{
|
||||
return $this->storage()->storagePath($fileUrl);
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
43
server-api/app/support/ContentImageService.php
Normal file
43
server-api/app/support/ContentImageService.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use function md5;
|
||||
use function sprintf;
|
||||
use function strtolower;
|
||||
|
||||
class ContentImageService
|
||||
{
|
||||
public function upload(Request $request, string $inputName = 'file'): array
|
||||
{
|
||||
$file = $request->file($inputName);
|
||||
if (!$file || !$file->isValid()) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
|
||||
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片');
|
||||
}
|
||||
|
||||
$filename = sprintf('content_image_%s.%s', uniqid(), $extension);
|
||||
$relativeDir = 'uploads/content-images/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
|
||||
return [
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileUrl,
|
||||
'name' => $file->getUploadName(),
|
||||
];
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
1109
server-api/app/support/ContentService.php
Normal file
1109
server-api/app/support/ContentService.php
Normal file
File diff suppressed because it is too large
Load Diff
128
server-api/app/support/EnterpriseCustomerService.php
Normal file
128
server-api/app/support/EnterpriseCustomerService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class EnterpriseCustomerService
|
||||
{
|
||||
public function generateCustomerCode(): string
|
||||
{
|
||||
do {
|
||||
$code = 'ENT' . date('Ymd') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||
} while (Db::name('enterprise_customers')->where('customer_code', $code)->find());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function generateAppKey(): string
|
||||
{
|
||||
do {
|
||||
$key = 'ak_' . bin2hex(random_bytes(12));
|
||||
} while (Db::name('enterprise_customer_apps')->where('app_key', $key)->find());
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function generateAppSecret(): string
|
||||
{
|
||||
return 'sk_' . bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
public function encryptSecret(string $secret): string
|
||||
{
|
||||
$key = $this->secretKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipher === false) {
|
||||
throw new \RuntimeException('应用密钥加密失败');
|
||||
}
|
||||
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
public function decryptSecret(string $cipherText): string
|
||||
{
|
||||
$raw = base64_decode($cipherText, true);
|
||||
if ($raw === false || strlen($raw) <= 16) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
$secret = openssl_decrypt($cipher, 'AES-256-CBC', $this->secretKey(), OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return is_string($secret) ? $secret : '';
|
||||
}
|
||||
|
||||
public function ensureVirtualUser(array $customer): int
|
||||
{
|
||||
$existingUserId = (int)($customer['user_id'] ?? 0);
|
||||
if ($existingUserId > 0 && Db::name('users')->where('id', $existingUserId)->find()) {
|
||||
return $existingUserId;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$mobile = 'ENT' . substr(hash('sha256', (string)($customer['customer_code'] ?? $customer['id'] ?? '')), 0, 20) . '@V';
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => (string)($customer['customer_name'] ?? '大客户'),
|
||||
'avatar' => '',
|
||||
'mobile' => $mobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('enterprise_customers')->where('id', $customer['id'])->update([
|
||||
'user_id' => $userId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
public function formatCustomer(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'customer_code' => (string)$item['customer_code'],
|
||||
'customer_name' => (string)$item['customer_name'],
|
||||
'contact_name' => (string)($item['contact_name'] ?? ''),
|
||||
'contact_mobile' => (string)($item['contact_mobile'] ?? ''),
|
||||
'contact_email' => (string)($item['contact_email'] ?? ''),
|
||||
'settlement_type' => (string)($item['settlement_type'] ?? 'monthly'),
|
||||
'settlement_type_text' => '月结',
|
||||
'user_id' => (int)($item['user_id'] ?? 0),
|
||||
'webhook_url' => (string)($item['webhook_url'] ?? ''),
|
||||
'webhook_enabled' => (bool)($item['webhook_enabled'] ?? false),
|
||||
'status' => (string)($item['status'] ?? 'enabled'),
|
||||
'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用',
|
||||
'remark' => (string)($item['remark'] ?? ''),
|
||||
'created_at' => (string)($item['created_at'] ?? ''),
|
||||
'updated_at' => (string)($item['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function formatApp(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'customer_id' => (int)$item['customer_id'],
|
||||
'app_name' => (string)($item['app_name'] ?? ''),
|
||||
'app_key' => (string)$item['app_key'],
|
||||
'secret_last4' => (string)($item['secret_last4'] ?? ''),
|
||||
'status' => (string)($item['status'] ?? 'enabled'),
|
||||
'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用',
|
||||
'last_used_at' => (string)($item['last_used_at'] ?? ''),
|
||||
'created_at' => (string)($item['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function secretKey(): string
|
||||
{
|
||||
$seed = $_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? 'anxinyan-enterprise-secret-key';
|
||||
return hash('sha256', (string)$seed, true);
|
||||
}
|
||||
}
|
||||
78
server-api/app/support/EnterpriseOpenApiAuthService.php
Normal file
78
server-api/app/support/EnterpriseOpenApiAuthService.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class EnterpriseOpenApiAuthService
|
||||
{
|
||||
public function authenticate(Request $request): array
|
||||
{
|
||||
$appKey = trim((string)$request->header('x-axy-app-key', ''));
|
||||
$timestamp = trim((string)$request->header('x-axy-timestamp', ''));
|
||||
$nonce = trim((string)$request->header('x-axy-nonce', ''));
|
||||
$signature = trim((string)$request->header('x-axy-signature', ''));
|
||||
|
||||
if ($appKey === '' || $timestamp === '' || $nonce === '' || $signature === '') {
|
||||
throw new \RuntimeException('开放接口鉴权头不完整');
|
||||
}
|
||||
|
||||
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {
|
||||
throw new \RuntimeException('请求时间戳已过期');
|
||||
}
|
||||
|
||||
$app = Db::name('enterprise_customer_apps')->where('app_key', $appKey)->find();
|
||||
if (!$app || ($app['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('应用 Key 不存在或已停用');
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', $app['customer_id'])->find();
|
||||
if (!$customer || ($customer['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('客户不存在或已停用');
|
||||
}
|
||||
|
||||
$this->storeNonce($appKey, $nonce, (int)$timestamp);
|
||||
|
||||
$secret = (new EnterpriseCustomerService())->decryptSecret((string)($app['app_secret_cipher'] ?? ''));
|
||||
if ($secret === '') {
|
||||
throw new \RuntimeException('应用密钥不可用');
|
||||
}
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
$pathWithQuery = $request->uri();
|
||||
$base = strtoupper($request->method()) . $pathWithQuery . $timestamp . $nonce . hash('sha256', $rawBody);
|
||||
$expected = hash_hmac('sha256', $base, $secret);
|
||||
|
||||
if (!hash_equals($expected, strtolower($signature))) {
|
||||
throw new \RuntimeException('请求签名无效');
|
||||
}
|
||||
|
||||
Db::name('enterprise_customer_apps')->where('id', $app['id'])->update([
|
||||
'last_used_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'app' => $app,
|
||||
'customer' => $customer,
|
||||
];
|
||||
}
|
||||
|
||||
private function storeNonce(string $appKey, string $nonce, int $timestamp): void
|
||||
{
|
||||
$expireBefore = date('Y-m-d H:i:s', time() - 600);
|
||||
Db::name('enterprise_api_nonces')->where('created_at', '<', $expireBefore)->delete();
|
||||
|
||||
try {
|
||||
Db::name('enterprise_api_nonces')->insert([
|
||||
'app_key' => $appKey,
|
||||
'nonce' => $nonce,
|
||||
'request_timestamp' => $timestamp,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('请求 nonce 已使用');
|
||||
}
|
||||
}
|
||||
}
|
||||
449
server-api/app/support/EnterpriseOrderService.php
Normal file
449
server-api/app/support/EnterpriseOrderService.php
Normal file
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class EnterpriseOrderService
|
||||
{
|
||||
public function createOrder(array $customer, array $payload, Request $request): array
|
||||
{
|
||||
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
|
||||
if ($externalOrderNo === '') {
|
||||
throw new \InvalidArgumentException('external_order_no 不能为空');
|
||||
}
|
||||
|
||||
$payloadHash = hash('sha256', json_encode($this->normalizePayloadForHash($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$existingRef = Db::name('enterprise_customer_order_refs')
|
||||
->where('customer_id', (int)$customer['id'])
|
||||
->where('external_order_no', $externalOrderNo)
|
||||
->find();
|
||||
if ($existingRef) {
|
||||
if (($existingRef['payload_hash'] ?? '') !== $payloadHash) {
|
||||
throw new \RuntimeException('external_order_no 已存在,但请求内容不一致');
|
||||
}
|
||||
return [
|
||||
'idempotent' => true,
|
||||
'order' => $this->buildOrderProgress((int)$customer['id'], $existingRef, (string)$customer['customer_code']),
|
||||
];
|
||||
}
|
||||
|
||||
$serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan'));
|
||||
if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) {
|
||||
throw new \InvalidArgumentException('service_provider 无效');
|
||||
}
|
||||
|
||||
$serviceConfig = $this->serviceConfig($serviceProvider);
|
||||
|
||||
$product = $this->normalizeProduct((array)($payload['product_info'] ?? []));
|
||||
$returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? []));
|
||||
$materials = $this->normalizeMaterials((array)($payload['materials'] ?? []));
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
|
||||
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
|
||||
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
|
||||
$userId = (new EnterpriseCustomerService())->ensureVirtualUser($customer);
|
||||
$productName = $this->resolveProductName($product);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$orderId = (int)Db::name('orders')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'user_id' => $userId,
|
||||
'service_mode' => 'physical',
|
||||
'service_provider' => $serviceProvider,
|
||||
'payment_status' => 'paid',
|
||||
'order_status' => 'pending_shipping',
|
||||
'display_status' => '待寄送商品',
|
||||
'estimated_finish_time' => $estimated,
|
||||
'source_channel' => 'enterprise_push',
|
||||
'source_customer_id' => $customer['customer_code'],
|
||||
'pay_amount' => $serviceConfig['price'],
|
||||
'paid_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_products')->insert(array_merge($product, [
|
||||
'order_id' => $orderId,
|
||||
'product_name' => $productName,
|
||||
'product_cover' => $materials[0]['file_url'] ?? '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]));
|
||||
|
||||
$extra = (array)($payload['extra_info'] ?? []);
|
||||
Db::name('order_extras')->insert([
|
||||
'order_id' => $orderId,
|
||||
'purchase_channel' => trim((string)($extra['purchase_channel'] ?? '')),
|
||||
'purchase_price' => (float)($extra['purchase_price'] ?? 0),
|
||||
'purchase_date' => $extra['purchase_date'] ?? null,
|
||||
'usage_status' => trim((string)($extra['usage_status'] ?? '')),
|
||||
'condition_desc' => trim((string)($extra['condition_desc'] ?? '')),
|
||||
'has_accessories' => !empty($extra['has_accessories']) ? 1 : 0,
|
||||
'accessories_json' => json_encode(array_values((array)($extra['accessories'] ?? [])), JSON_UNESCAPED_UNICODE),
|
||||
'remark' => trim((string)($extra['remark'] ?? '')),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($returnAddress) {
|
||||
Db::name('order_return_addresses')->insert(array_merge($returnAddress, [
|
||||
'order_id' => $orderId,
|
||||
'user_address_id' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]));
|
||||
}
|
||||
|
||||
$shippingTarget = (new WarehouseService())->bindOrderTarget(
|
||||
$orderId,
|
||||
$serviceProvider,
|
||||
!empty($product['category_id']) ? (int)$product['category_id'] : null,
|
||||
null
|
||||
);
|
||||
|
||||
$this->insertMaterials($orderId, $materials, $now);
|
||||
$this->insertTimelines($orderId, $now, $shippingTarget);
|
||||
$this->insertTask($orderId, $serviceProvider, $estimated, $now);
|
||||
$this->insertInboundLogistics($orderId, $this->normalizeInboundLogistics($payload), $now);
|
||||
|
||||
Db::name('enterprise_customer_order_refs')->insert([
|
||||
'customer_id' => (int)$customer['id'],
|
||||
'external_order_no' => $externalOrderNo,
|
||||
'order_id' => $orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'payload_hash' => $payloadHash,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'order_created', [
|
||||
'product_name' => $productName,
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
]);
|
||||
|
||||
$ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find();
|
||||
return [
|
||||
'idempotent' => false,
|
||||
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
|
||||
];
|
||||
}
|
||||
|
||||
public function findOrder(array $customer, string $externalOrderNo = '', string $orderNo = ''): array
|
||||
{
|
||||
$query = Db::name('enterprise_customer_order_refs')->where('customer_id', (int)$customer['id']);
|
||||
if ($externalOrderNo !== '') {
|
||||
$query->where('external_order_no', $externalOrderNo);
|
||||
} elseif ($orderNo !== '') {
|
||||
$query->where('order_no', $orderNo);
|
||||
} else {
|
||||
throw new \InvalidArgumentException('external_order_no 或 order_no 不能为空');
|
||||
}
|
||||
|
||||
$ref = $query->find();
|
||||
if (!$ref) {
|
||||
throw new \RuntimeException('订单不存在');
|
||||
}
|
||||
|
||||
return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']);
|
||||
}
|
||||
|
||||
public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array
|
||||
{
|
||||
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
|
||||
if (!$order) {
|
||||
throw new \RuntimeException('订单不存在');
|
||||
}
|
||||
|
||||
$timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray();
|
||||
$sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
|
||||
$returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
|
||||
$report = Db::name('reports')->where('order_id', (int)$order['id'])->order('id', 'desc')->find();
|
||||
$verify = $report ? (Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: null) : null;
|
||||
|
||||
return [
|
||||
'customer_id' => $customerCode !== '' ? $customerCode : (string)$customerId,
|
||||
'customer_code' => $customerCode !== '' ? $customerCode : (string)$customerId,
|
||||
'external_order_no' => (string)$ref['external_order_no'],
|
||||
'order_id' => (int)$order['id'],
|
||||
'order_no' => (string)$order['order_no'],
|
||||
'appraisal_no' => (string)$order['appraisal_no'],
|
||||
'order_status' => (string)$order['order_status'],
|
||||
'display_status' => (string)$order['display_status'],
|
||||
'payment_status' => (string)$order['payment_status'],
|
||||
'pay_amount' => (float)$order['pay_amount'],
|
||||
'estimated_finish_time' => (string)($order['estimated_finish_time'] ?? ''),
|
||||
'created_at' => (string)$order['created_at'],
|
||||
'timeline' => array_map(fn(array $item) => [
|
||||
'node_code' => (string)$item['node_code'],
|
||||
'node_text' => (string)$item['node_text'],
|
||||
'node_desc' => (string)$item['node_desc'],
|
||||
'occurred_at' => (string)$item['occurred_at'],
|
||||
], $timeline),
|
||||
'inbound_logistics' => $this->formatLogistics($sendLogistics),
|
||||
'return_logistics' => $this->formatLogistics($returnLogistics),
|
||||
'report_summary' => $report ? [
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'report_status' => (string)$report['report_status'],
|
||||
'publish_time' => (string)($report['publish_time'] ?? ''),
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['verify_qrcode_url'] ?? ''),
|
||||
'verify_status' => (string)($verify['verify_status'] ?? ''),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePayloadForHash(array $payload): array
|
||||
{
|
||||
ksort($payload);
|
||||
foreach ($payload as &$value) {
|
||||
if (is_array($value)) {
|
||||
$value = $this->normalizePayloadForHash($value);
|
||||
}
|
||||
}
|
||||
unset($value);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function normalizeProduct(array $product): array
|
||||
{
|
||||
$productName = trim((string)($product['product_name'] ?? ''));
|
||||
|
||||
return [
|
||||
'category_id' => !empty($product['category_id']) ? (int)$product['category_id'] : null,
|
||||
'category_name' => trim((string)($product['category_name'] ?? '')),
|
||||
'brand_id' => !empty($product['brand_id']) ? (int)$product['brand_id'] : null,
|
||||
'brand_name' => trim((string)($product['brand_name'] ?? '')),
|
||||
'color' => trim((string)($product['color'] ?? '')),
|
||||
'size_spec' => trim((string)($product['size_spec'] ?? '')),
|
||||
'serial_no' => trim((string)($product['serial_no'] ?? '')),
|
||||
'product_name' => $productName,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeReturnAddress(array $address): ?array
|
||||
{
|
||||
$requiredKeys = ['consignee', 'mobile', 'province', 'city', 'district', 'detail_address'];
|
||||
$hasAnyValue = false;
|
||||
foreach ($requiredKeys as $key) {
|
||||
if (trim((string)($address[$key] ?? '')) !== '') {
|
||||
$hasAnyValue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasAnyValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($requiredKeys as $key) {
|
||||
if (trim((string)($address[$key] ?? '')) === '') {
|
||||
throw new \InvalidArgumentException("return_address.{$key} 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'consignee' => trim((string)$address['consignee']),
|
||||
'mobile' => trim((string)$address['mobile']),
|
||||
'province' => trim((string)$address['province']),
|
||||
'city' => trim((string)$address['city']),
|
||||
'district' => trim((string)$address['district']),
|
||||
'detail_address' => trim((string)$address['detail_address']),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeMaterials(array $materials): array
|
||||
{
|
||||
$list = [];
|
||||
foreach ($materials as $index => $item) {
|
||||
if (is_string($item)) {
|
||||
$url = trim($item);
|
||||
$itemCode = 'material_' . ($index + 1);
|
||||
$itemName = '鉴定资料';
|
||||
} elseif (is_array($item)) {
|
||||
$url = trim((string)($item['file_url'] ?? $item['url'] ?? ''));
|
||||
$itemCode = trim((string)($item['item_code'] ?? 'material_' . ($index + 1)));
|
||||
$itemName = trim((string)($item['item_name'] ?? '鉴定资料'));
|
||||
} else {
|
||||
$url = '';
|
||||
$itemCode = 'material_' . ($index + 1);
|
||||
$itemName = '鉴定资料';
|
||||
}
|
||||
if ($url === '' || !preg_match('/^https?:\/\//i', $url)) {
|
||||
throw new \InvalidArgumentException('materials 只支持 http/https 图片 URL');
|
||||
}
|
||||
$list[] = [
|
||||
'item_code' => $itemCode,
|
||||
'item_name' => $itemName,
|
||||
'file_url' => $url,
|
||||
'thumbnail_url' => is_array($item) ? trim((string)($item['thumbnail_url'] ?? $url)) : $url,
|
||||
'is_required' => is_array($item) && !empty($item['is_required']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function normalizeInboundLogistics(array $payload): array
|
||||
{
|
||||
$logistics = (array)($payload['inbound_logistics'] ?? []);
|
||||
if (!empty($payload['express_company']) || !empty($payload['tracking_no'])) {
|
||||
$logistics = array_merge($logistics, [
|
||||
'express_company' => $payload['express_company'] ?? ($logistics['express_company'] ?? ''),
|
||||
'tracking_no' => $payload['tracking_no'] ?? ($logistics['tracking_no'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $logistics;
|
||||
}
|
||||
|
||||
private function resolveProductName(array $product): string
|
||||
{
|
||||
$productName = trim((string)($product['product_name'] ?? ''));
|
||||
if ($productName !== '') {
|
||||
return $productName;
|
||||
}
|
||||
return trim(($product['brand_name'] ?? '') . ' ' . ($product['category_name'] ?? ''));
|
||||
}
|
||||
|
||||
private function serviceConfig(string $serviceProvider): array
|
||||
{
|
||||
$configs = [
|
||||
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
|
||||
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
|
||||
];
|
||||
if (isset($configs[$serviceProvider])) {
|
||||
return $configs[$serviceProvider];
|
||||
}
|
||||
return $configs['anxinyan'];
|
||||
}
|
||||
|
||||
private function insertMaterials(int $orderId, array $materials, string $now): void
|
||||
{
|
||||
foreach ($materials as $item) {
|
||||
$uploadItemId = (int)Db::name('order_upload_items')->insertGetId([
|
||||
'order_id' => $orderId,
|
||||
'template_id' => null,
|
||||
'item_code' => $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'is_required' => $item['is_required'],
|
||||
'source_type' => 'initial',
|
||||
'status' => 'uploaded',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_upload_files')->insert([
|
||||
'order_upload_item_id' => $uploadItemId,
|
||||
'file_id' => md5($item['file_url']),
|
||||
'file_url' => $item['file_url'],
|
||||
'thumbnail_url' => $item['thumbnail_url'],
|
||||
'quality_status' => 'uploaded',
|
||||
'quality_message' => '',
|
||||
'uploaded_by_user_id' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function insertTimelines(int $orderId, string $now, array $shippingTarget): void
|
||||
{
|
||||
Db::name('order_timelines')->insertAll([
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'created',
|
||||
'node_text' => '下单成功',
|
||||
'node_desc' => '大客户订单已推送并创建成功',
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'pending_shipping',
|
||||
'node_text' => '待寄送商品',
|
||||
'node_desc' => sprintf('请将商品寄送至%s', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertTask(int $orderId, string $serviceProvider, string $estimated, string $now): void
|
||||
{
|
||||
Db::name('appraisal_tasks')->insert([
|
||||
'order_id' => $orderId,
|
||||
'task_stage' => 'first_review',
|
||||
'service_provider' => $serviceProvider,
|
||||
'status' => 'pending',
|
||||
'assignee_id' => null,
|
||||
'assignee_name' => '未分配',
|
||||
'started_at' => null,
|
||||
'submitted_at' => null,
|
||||
'sla_deadline' => $estimated,
|
||||
'is_overtime' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertInboundLogistics(int $orderId, array $logistics, string $now): void
|
||||
{
|
||||
$expressCompany = trim((string)($logistics['express_company'] ?? ''));
|
||||
$trackingNo = trim((string)($logistics['tracking_no'] ?? ''));
|
||||
if ($expressCompany === '' || $trackingNo === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$latestDesc = sprintf('客户已提交寄送运单:%s %s,等待鉴定中心签收。', $expressCompany, $trackingNo);
|
||||
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
|
||||
'order_id' => $orderId,
|
||||
'logistics_type' => 'send_to_center',
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_no' => $trackingNo,
|
||||
'tracking_status' => 'submitted',
|
||||
'latest_desc' => $latestDesc,
|
||||
'latest_time' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $now,
|
||||
'node_desc' => $latestDesc,
|
||||
'node_location' => '',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatLogistics(?array $logistics): ?array
|
||||
{
|
||||
if (!$logistics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'express_company' => (string)$logistics['express_company'],
|
||||
'tracking_no' => (string)$logistics['tracking_no'],
|
||||
'tracking_status' => (string)$logistics['tracking_status'],
|
||||
'latest_desc' => (string)$logistics['latest_desc'],
|
||||
'latest_time' => (string)($logistics['latest_time'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
228
server-api/app/support/EnterpriseWebhookService.php
Normal file
228
server-api/app/support/EnterpriseWebhookService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class EnterpriseWebhookService
|
||||
{
|
||||
public function recordOrderEvent(int $orderId, string $eventCode, array $data = [], bool $sendNow = true): ?array
|
||||
{
|
||||
$ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find();
|
||||
if (!$ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', (int)$ref['customer_id'])->find();
|
||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||
if (!$customer || !$order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$eventMeta = $this->eventMeta($eventCode);
|
||||
$payload = [
|
||||
'event_code' => $eventCode,
|
||||
'event_text' => $eventMeta['event_text'],
|
||||
'customer_id' => (string)$customer['customer_code'],
|
||||
'customer_code' => (string)$customer['customer_code'],
|
||||
'external_order_no' => (string)$ref['external_order_no'],
|
||||
'order_no' => (string)$order['order_no'],
|
||||
'appraisal_no' => (string)$order['appraisal_no'],
|
||||
'status_code' => $eventMeta['status_code'],
|
||||
'status_text' => $eventMeta['status_text'],
|
||||
'occurred_at' => date('Y-m-d H:i:s'),
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
$eventId = (int)Db::name('enterprise_order_events')->insertGetId([
|
||||
'customer_id' => (int)$customer['id'],
|
||||
'order_id' => $orderId,
|
||||
'external_order_no' => (string)$ref['external_order_no'],
|
||||
'event_code' => $eventCode,
|
||||
'event_text' => $eventMeta['event_text'],
|
||||
'status_code' => $eventMeta['status_code'],
|
||||
'status_text' => $eventMeta['status_text'],
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'occurred_at' => $payload['occurred_at'],
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$payload['event_id'] = $eventId;
|
||||
|
||||
if ($sendNow) {
|
||||
$this->deliverEvent($eventId, false);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function deliverEvent(int $eventId, bool $manual = false): array
|
||||
{
|
||||
$event = Db::name('enterprise_order_events')->where('id', $eventId)->find();
|
||||
if (!$event) {
|
||||
throw new \RuntimeException('事件不存在');
|
||||
}
|
||||
|
||||
$customer = Db::name('enterprise_customers')->where('id', (int)$event['customer_id'])->find();
|
||||
if (!$customer) {
|
||||
throw new \RuntimeException('客户不存在');
|
||||
}
|
||||
|
||||
$url = trim((string)($customer['webhook_url'] ?? ''));
|
||||
$appKey = (string)Db::name('enterprise_customer_apps')
|
||||
->where('customer_id', (int)$customer['id'])
|
||||
->where('status', 'enabled')
|
||||
->order('id', 'asc')
|
||||
->value('app_key');
|
||||
$attemptNo = (int)Db::name('enterprise_webhook_deliveries')->where('event_id', $eventId)->count() + 1;
|
||||
|
||||
if (!(bool)($customer['webhook_enabled'] ?? false) || $url === '' || $appKey === '') {
|
||||
$delivery = $this->saveDelivery($event, $url, $appKey, $attemptNo, 'skipped', 0, '', 'Webhook未启用或配置不完整', $manual);
|
||||
return ['delivery' => $delivery, 'sent' => false];
|
||||
}
|
||||
|
||||
$payload = json_decode((string)$event['payload_json'], true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
$payload['event_id'] = $eventId;
|
||||
|
||||
$result = $this->postJson($url, $payload, $appKey);
|
||||
$status = ($result['http_status'] >= 200 && $result['http_status'] < 300 && $result['error_message'] === '') ? 'success' : 'failed';
|
||||
|
||||
$delivery = $this->saveDelivery(
|
||||
$event,
|
||||
$url,
|
||||
$appKey,
|
||||
$attemptNo,
|
||||
$status,
|
||||
$result['http_status'],
|
||||
$result['response_body'],
|
||||
$result['error_message'],
|
||||
$manual
|
||||
);
|
||||
|
||||
return ['delivery' => $delivery, 'sent' => $status === 'success'];
|
||||
}
|
||||
|
||||
public function formatEvent(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'customer_id' => (int)$item['customer_id'],
|
||||
'order_id' => (int)$item['order_id'],
|
||||
'external_order_no' => (string)$item['external_order_no'],
|
||||
'event_code' => (string)$item['event_code'],
|
||||
'event_text' => (string)$item['event_text'],
|
||||
'status_code' => (string)$item['status_code'],
|
||||
'status_text' => (string)$item['status_text'],
|
||||
'occurred_at' => (string)$item['occurred_at'],
|
||||
'created_at' => (string)$item['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
public function formatDelivery(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'event_id' => (int)$item['event_id'],
|
||||
'customer_id' => (int)$item['customer_id'],
|
||||
'webhook_url' => (string)$item['webhook_url'],
|
||||
'app_key' => (string)$item['app_key'],
|
||||
'attempt_no' => (int)$item['attempt_no'],
|
||||
'delivery_status' => (string)$item['delivery_status'],
|
||||
'delivery_status_text' => $this->deliveryStatusText((string)$item['delivery_status']),
|
||||
'http_status' => (int)$item['http_status'],
|
||||
'response_body' => (string)($item['response_body'] ?? ''),
|
||||
'error_message' => (string)($item['error_message'] ?? ''),
|
||||
'is_manual' => (bool)($item['is_manual'] ?? false),
|
||||
'sent_at' => (string)($item['sent_at'] ?? ''),
|
||||
'created_at' => (string)($item['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function eventMeta(string $eventCode): array
|
||||
{
|
||||
return match ($eventCode) {
|
||||
'order_created' => ['event_text' => '订单创建', 'status_code' => 'pending_shipping', 'status_text' => '待寄送商品'],
|
||||
'inbound_received' => ['event_text' => '快递已到仓', 'status_code' => 'received', 'status_text' => '鉴定中心已收货'],
|
||||
'appraising' => ['event_text' => '物品鉴定中', 'status_code' => 'appraising', 'status_text' => '物品鉴定中'],
|
||||
'appraisal_finished' => ['event_text' => '物品鉴定完成', 'status_code' => 'generating_report', 'status_text' => '物品鉴定完成'],
|
||||
'report_published' => ['event_text' => '报告已发布', 'status_code' => 'report_published', 'status_text' => '报告已发布'],
|
||||
'return_shipped' => ['event_text' => '物品已寄回', 'status_code' => 'return_shipped', 'status_text' => '物品已寄回'],
|
||||
'completed' => ['event_text' => '订单已完成', 'status_code' => 'completed', 'status_text' => '已完成'],
|
||||
'supplement_required' => ['event_text' => '需要补充资料', 'status_code' => 'pending_supplement', 'status_text' => '需要补充资料'],
|
||||
default => ['event_text' => $eventCode, 'status_code' => $eventCode, 'status_text' => $eventCode],
|
||||
};
|
||||
}
|
||||
|
||||
private function postJson(string $url, array $payload, string $appKey): array
|
||||
{
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-AXY-App-Key: ' . $appKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'http_status' => $httpStatus,
|
||||
'response_body' => is_string($response) ? substr($response, 0, 2000) : '',
|
||||
'error_message' => $errno ? $error : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function saveDelivery(
|
||||
array $event,
|
||||
string $url,
|
||||
string $appKey,
|
||||
int $attemptNo,
|
||||
string $status,
|
||||
int $httpStatus,
|
||||
string $responseBody,
|
||||
string $errorMessage,
|
||||
bool $manual
|
||||
): array {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$id = (int)Db::name('enterprise_webhook_deliveries')->insertGetId([
|
||||
'event_id' => (int)$event['id'],
|
||||
'customer_id' => (int)$event['customer_id'],
|
||||
'webhook_url' => $url,
|
||||
'app_key' => $appKey,
|
||||
'attempt_no' => $attemptNo,
|
||||
'delivery_status' => $status,
|
||||
'http_status' => $httpStatus,
|
||||
'response_body' => $responseBody,
|
||||
'error_message' => $errorMessage,
|
||||
'is_manual' => $manual ? 1 : 0,
|
||||
'sent_at' => $status === 'skipped' ? null : $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return Db::name('enterprise_webhook_deliveries')->where('id', $id)->find() ?: [];
|
||||
}
|
||||
|
||||
private function deliveryStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'success' => '推送成功',
|
||||
'failed' => '推送失败',
|
||||
'skipped' => '已跳过',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
}
|
||||
243
server-api/app/support/FileStorageConfigService.php
Normal file
243
server-api/app/support/FileStorageConfigService.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class FileStorageConfigService
|
||||
{
|
||||
private const GROUP = 'file_storage';
|
||||
|
||||
public function getConfig(): array
|
||||
{
|
||||
$rows = Db::name('system_configs')
|
||||
->where('config_group', self::GROUP)
|
||||
->column('config_value', 'config_key');
|
||||
|
||||
return [
|
||||
'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')),
|
||||
'public_base_url' => trim((string)($rows['public_base_url'] ?? '')),
|
||||
'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')),
|
||||
'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')),
|
||||
'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')),
|
||||
'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')),
|
||||
'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')),
|
||||
'oss_path_prefix' => trim((string)($rows['oss_path_prefix'] ?? '')),
|
||||
'qiniu_bucket' => trim((string)($rows['qiniu_bucket'] ?? '')),
|
||||
'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')),
|
||||
'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')),
|
||||
'qiniu_bucket_domain' => trim((string)($rows['qiniu_bucket_domain'] ?? '')),
|
||||
'qiniu_path_prefix' => trim((string)($rows['qiniu_path_prefix'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// noop; kept for call-site compatibility.
|
||||
}
|
||||
|
||||
public function driver(): string
|
||||
{
|
||||
return $this->getConfig()['driver'];
|
||||
}
|
||||
|
||||
public function isOss(): bool
|
||||
{
|
||||
return $this->driver() === 'oss';
|
||||
}
|
||||
|
||||
public function isQiniu(): bool
|
||||
{
|
||||
return $this->driver() === 'qiniu';
|
||||
}
|
||||
|
||||
public function assertReady(): void
|
||||
{
|
||||
if ($this->isOss()) {
|
||||
$config = $this->getConfig();
|
||||
$requiredKeys = [
|
||||
'oss_endpoint' => 'OSS Endpoint',
|
||||
'oss_bucket' => 'OSS Bucket',
|
||||
'oss_access_key_id' => 'OSS AccessKey ID',
|
||||
'oss_access_key_secret' => 'OSS AccessKey Secret',
|
||||
];
|
||||
|
||||
foreach ($requiredKeys as $key => $label) {
|
||||
if (trim((string)($config[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('%s 未配置,当前无法使用 OSS 存储', $label));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isQiniu()) {
|
||||
$config = $this->getConfig();
|
||||
$requiredKeys = [
|
||||
'qiniu_bucket' => '七牛 Bucket',
|
||||
'qiniu_access_key' => '七牛 AccessKey',
|
||||
'qiniu_secret_key' => '七牛 SecretKey',
|
||||
];
|
||||
|
||||
foreach ($requiredKeys as $key => $label) {
|
||||
if (trim((string)($config[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('%s 未配置,当前无法使用七牛云存储', $label));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function publicBaseUrl(): string
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
if ($config['public_base_url'] !== '') {
|
||||
return $this->normalizeBaseUrl($config['public_base_url']);
|
||||
}
|
||||
|
||||
if ($this->isOss() && $config['oss_bucket_domain'] !== '') {
|
||||
return $this->normalizeBaseUrl($config['oss_bucket_domain']);
|
||||
}
|
||||
|
||||
if ($this->isOss() && $config['oss_endpoint'] !== '' && $config['oss_bucket'] !== '') {
|
||||
return $this->normalizeBaseUrl(sprintf(
|
||||
'https://%s.%s',
|
||||
$config['oss_bucket'],
|
||||
$this->normalizeEndpointHost($config['oss_endpoint'])
|
||||
));
|
||||
}
|
||||
|
||||
if ($this->isQiniu() && $config['qiniu_bucket_domain'] !== '') {
|
||||
return $this->normalizeBaseUrl($config['qiniu_bucket_domain']);
|
||||
}
|
||||
|
||||
$notifyUrl = Db::name('system_configs')
|
||||
->where('config_group', 'payment')
|
||||
->where('config_key', 'notify_url')
|
||||
->value('config_value');
|
||||
if (is_string($notifyUrl) && trim($notifyUrl) !== '') {
|
||||
return $this->extractOrigin($notifyUrl);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function bucket(): string
|
||||
{
|
||||
return $this->getConfig()['oss_bucket'];
|
||||
}
|
||||
|
||||
public function qiniuBucket(): string
|
||||
{
|
||||
return $this->getConfig()['qiniu_bucket'];
|
||||
}
|
||||
|
||||
public function endpoint(): string
|
||||
{
|
||||
return $this->normalizeEndpointHost($this->getConfig()['oss_endpoint']);
|
||||
}
|
||||
|
||||
public function accessKeyId(): string
|
||||
{
|
||||
return $this->getConfig()['oss_access_key_id'];
|
||||
}
|
||||
|
||||
public function qiniuAccessKey(): string
|
||||
{
|
||||
return $this->getConfig()['qiniu_access_key'];
|
||||
}
|
||||
|
||||
public function accessKeySecret(): string
|
||||
{
|
||||
return $this->getConfig()['oss_access_key_secret'];
|
||||
}
|
||||
|
||||
public function qiniuSecretKey(): string
|
||||
{
|
||||
return $this->getConfig()['qiniu_secret_key'];
|
||||
}
|
||||
|
||||
public function objectKey(string $relativePath): string
|
||||
{
|
||||
$relativePath = ltrim($relativePath, '/');
|
||||
$config = $this->getConfig();
|
||||
$prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/');
|
||||
|
||||
if ($prefix === '') {
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
return $prefix . '/' . $relativePath;
|
||||
}
|
||||
|
||||
public function removePathPrefix(string $objectKey): string
|
||||
{
|
||||
$objectKey = ltrim($objectKey, '/');
|
||||
$config = $this->getConfig();
|
||||
$prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/');
|
||||
|
||||
if ($prefix === '') {
|
||||
return $objectKey;
|
||||
}
|
||||
|
||||
if (str_starts_with($objectKey, $prefix . '/')) {
|
||||
return substr($objectKey, strlen($prefix) + 1);
|
||||
}
|
||||
|
||||
return $objectKey;
|
||||
}
|
||||
|
||||
public function normalizeDriver(string $value): string
|
||||
{
|
||||
return in_array($value, ['oss', 'qiniu'], true) ? $value : 'local';
|
||||
}
|
||||
|
||||
public function normalizeBaseUrl(string $baseUrl): string
|
||||
{
|
||||
$baseUrl = trim($baseUrl);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function extractOrigin(string $url): string
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
$scheme = (string)($parts['scheme'] ?? '');
|
||||
$host = (string)($parts['host'] ?? '');
|
||||
$port = (string)($parts['port'] ?? '');
|
||||
|
||||
if ($host === '') {
|
||||
return $this->normalizeBaseUrl($url);
|
||||
}
|
||||
|
||||
$origin = ($scheme !== '' ? $scheme : 'https') . '://' . $host;
|
||||
if ($port !== '' && !(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
|
||||
$origin .= ':' . $port;
|
||||
}
|
||||
|
||||
return rtrim($origin, '/');
|
||||
}
|
||||
|
||||
public function normalizeEndpointHost(string $endpoint): string
|
||||
{
|
||||
$endpoint = trim($endpoint);
|
||||
if ($endpoint === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('/^https?:\/\//i', $endpoint)) {
|
||||
$host = parse_url($endpoint, PHP_URL_HOST);
|
||||
if (is_string($host) && $host !== '') {
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
|
||||
return preg_replace('#^https?://#i', '', rtrim($endpoint, '/')) ?: '';
|
||||
}
|
||||
}
|
||||
363
server-api/app/support/FileStorageService.php
Normal file
363
server-api/app/support/FileStorageService.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use OSS\OssClient;
|
||||
use Qiniu\Auth as QiniuAuth;
|
||||
use Qiniu\Config as QiniuConfig;
|
||||
use Qiniu\Storage\BucketManager as QiniuBucketManager;
|
||||
use Qiniu\Storage\UploadManager as QiniuUploadManager;
|
||||
use support\Request;
|
||||
use function dirname;
|
||||
use function file_exists;
|
||||
use function file_put_contents;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function ltrim;
|
||||
use function mkdir;
|
||||
use function sys_get_temp_dir;
|
||||
use function tempnam;
|
||||
use function unlink;
|
||||
|
||||
class FileStorageService
|
||||
{
|
||||
private static bool $caBundleInitialized = false;
|
||||
|
||||
private ?OssClient $ossClient = null;
|
||||
private ?QiniuAuth $qiniuAuth = null;
|
||||
|
||||
public function publicUrl(Request $request, string $value): string
|
||||
{
|
||||
return $this->assetUrlService()->buildUrl($request, $this->storagePath($value));
|
||||
}
|
||||
|
||||
public function normalizeUrl(string $value, Request $request): string
|
||||
{
|
||||
return $this->assetUrlService()->normalizeUrl($value, $request);
|
||||
}
|
||||
|
||||
public function storagePath(string $value): string
|
||||
{
|
||||
return ltrim($this->assetUrlService()->storagePath($value), '/');
|
||||
}
|
||||
|
||||
public function putUploadedFile(mixed $file, string $relativePath): void
|
||||
{
|
||||
$relativePath = $this->storagePath($relativePath);
|
||||
|
||||
if ($this->configService()->isOss()) {
|
||||
$this->configService()->assertReady();
|
||||
$realPath = $file->getRealPath();
|
||||
if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$this->ossClient()->uploadFile(
|
||||
$this->configService()->bucket(),
|
||||
$this->configService()->objectKey($relativePath),
|
||||
$realPath
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configService()->isQiniu()) {
|
||||
$this->configService()->assertReady();
|
||||
$realPath = $file->getRealPath();
|
||||
if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$key = $this->configService()->objectKey($relativePath);
|
||||
$this->qiniuUploadFile($realPath, $key);
|
||||
return;
|
||||
}
|
||||
|
||||
$target = public_path() . '/' . $relativePath;
|
||||
if (!is_dir(dirname($target))) {
|
||||
mkdir(dirname($target), 0775, true);
|
||||
}
|
||||
|
||||
$file->move($target);
|
||||
}
|
||||
|
||||
public function putContents(string $relativePath, string $content): void
|
||||
{
|
||||
$relativePath = $this->storagePath($relativePath);
|
||||
|
||||
if ($this->configService()->isOss()) {
|
||||
$this->configService()->assertReady();
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_oss_');
|
||||
if ($tmpFile === false) {
|
||||
throw new \RuntimeException('无法创建临时文件');
|
||||
}
|
||||
|
||||
file_put_contents($tmpFile, $content);
|
||||
|
||||
try {
|
||||
$this->ossClient()->uploadFile(
|
||||
$this->configService()->bucket(),
|
||||
$this->configService()->objectKey($relativePath),
|
||||
$tmpFile
|
||||
);
|
||||
} finally {
|
||||
if (file_exists($tmpFile)) {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configService()->isQiniu()) {
|
||||
$this->configService()->assertReady();
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_qiniu_');
|
||||
if ($tmpFile === false) {
|
||||
throw new \RuntimeException('无法创建临时文件');
|
||||
}
|
||||
|
||||
file_put_contents($tmpFile, $content);
|
||||
|
||||
try {
|
||||
$key = $this->configService()->objectKey($relativePath);
|
||||
$this->qiniuUploadFile($tmpFile, $key);
|
||||
} finally {
|
||||
if (file_exists($tmpFile)) {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$target = public_path() . '/' . $relativePath;
|
||||
if (!is_dir(dirname($target))) {
|
||||
mkdir(dirname($target), 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents($target, $content);
|
||||
}
|
||||
|
||||
public function exists(string $value): bool
|
||||
{
|
||||
$relativePath = $this->storagePath($value);
|
||||
if ($relativePath === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->configService()->isOss()) {
|
||||
$this->configService()->assertReady();
|
||||
return $this->ossClient()->doesObjectExist(
|
||||
$this->configService()->bucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->configService()->isQiniu()) {
|
||||
$this->configService()->assertReady();
|
||||
[$ret, $err] = $this->qiniuBucketManager()->stat(
|
||||
$this->configService()->qiniuBucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
|
||||
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
|
||||
[$ret, $err] = $this->qiniuBucketManager(false)->stat(
|
||||
$this->configService()->qiniuBucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
if ($err === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int)$err->code() === 612) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('七牛云文件检查失败:' . $err->message());
|
||||
}
|
||||
|
||||
return is_file(public_path() . '/' . $relativePath);
|
||||
}
|
||||
|
||||
public function delete(string $value): void
|
||||
{
|
||||
$relativePath = $this->storagePath($value);
|
||||
if ($relativePath === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configService()->isOss()) {
|
||||
$this->configService()->assertReady();
|
||||
$this->ossClient()->deleteObject(
|
||||
$this->configService()->bucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configService()->isQiniu()) {
|
||||
$this->configService()->assertReady();
|
||||
[$ret, $err] = $this->qiniuBucketManager()->delete(
|
||||
$this->configService()->qiniuBucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
|
||||
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
|
||||
[$ret, $err] = $this->qiniuBucketManager(false)->delete(
|
||||
$this->configService()->qiniuBucket(),
|
||||
$this->configService()->objectKey($relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
if ($err !== null && (int)$err->code() !== 612) {
|
||||
throw new \RuntimeException('七牛云删除失败:' . $err->message());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fullPath = public_path() . '/' . $relativePath;
|
||||
if (file_exists($fullPath) && is_file($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function ossClient(): OssClient
|
||||
{
|
||||
if ($this->ossClient instanceof OssClient) {
|
||||
return $this->ossClient;
|
||||
}
|
||||
|
||||
$this->configService()->assertReady();
|
||||
$this->ensureCaBundleConfigured();
|
||||
|
||||
return $this->ossClient = new OssClient(
|
||||
$this->configService()->accessKeyId(),
|
||||
$this->configService()->accessKeySecret(),
|
||||
$this->configService()->endpoint()
|
||||
);
|
||||
}
|
||||
|
||||
private function qiniuAuth(): QiniuAuth
|
||||
{
|
||||
if ($this->qiniuAuth instanceof QiniuAuth) {
|
||||
return $this->qiniuAuth;
|
||||
}
|
||||
|
||||
$this->configService()->assertReady();
|
||||
$this->ensureCaBundleConfigured();
|
||||
|
||||
return $this->qiniuAuth = new QiniuAuth(
|
||||
$this->configService()->qiniuAccessKey(),
|
||||
$this->configService()->qiniuSecretKey()
|
||||
);
|
||||
}
|
||||
|
||||
private function qiniuConfig(bool $useHttps = true): QiniuConfig
|
||||
{
|
||||
$config = new QiniuConfig();
|
||||
$config->useHTTPS = $useHttps;
|
||||
$config->useCdnDomains = false;
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function qiniuUploadManager(bool $useHttps = true): QiniuUploadManager
|
||||
{
|
||||
return new QiniuUploadManager($this->qiniuConfig($useHttps));
|
||||
}
|
||||
|
||||
private function qiniuBucketManager(bool $useHttps = true): QiniuBucketManager
|
||||
{
|
||||
return new QiniuBucketManager(
|
||||
$this->qiniuAuth(),
|
||||
$this->qiniuConfig($useHttps)
|
||||
);
|
||||
}
|
||||
|
||||
private function qiniuUploadFile(string $filePath, string $key): void
|
||||
{
|
||||
$token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key);
|
||||
try {
|
||||
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath);
|
||||
} catch (\Throwable $e) {
|
||||
$err = $e;
|
||||
}
|
||||
|
||||
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
|
||||
try {
|
||||
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath);
|
||||
} catch (\Throwable $e) {
|
||||
$err = $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($err !== null) {
|
||||
throw new \RuntimeException('七牛云上传失败:' . $this->qiniuErrorMessage($err));
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldRetryQiniuWithoutHttps(mixed $err): bool
|
||||
{
|
||||
return stripos($this->qiniuErrorMessage($err), 'SSL certificate problem') !== false;
|
||||
}
|
||||
|
||||
private function qiniuErrorMessage(mixed $err): string
|
||||
{
|
||||
if ($err instanceof \Throwable) {
|
||||
return $err->getMessage();
|
||||
}
|
||||
|
||||
if (is_object($err) && method_exists($err, 'message')) {
|
||||
return (string)$err->message();
|
||||
}
|
||||
|
||||
if (is_string($err) && $err !== '') {
|
||||
return $err;
|
||||
}
|
||||
|
||||
return '未知错误';
|
||||
}
|
||||
|
||||
private function assetUrlService(): PublicAssetUrlService
|
||||
{
|
||||
return new PublicAssetUrlService();
|
||||
}
|
||||
|
||||
private function configService(): FileStorageConfigService
|
||||
{
|
||||
return new FileStorageConfigService();
|
||||
}
|
||||
|
||||
private function ensureCaBundleConfigured(): void
|
||||
{
|
||||
if (self::$caBundleInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentCurlCa = (string)ini_get('curl.cainfo');
|
||||
$currentOpensslCa = (string)ini_get('openssl.cafile');
|
||||
if (($currentCurlCa !== '' && is_file($currentCurlCa)) || ($currentOpensslCa !== '' && is_file($currentOpensslCa))) {
|
||||
self::$caBundleInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
'/etc/ssl/cert.pem',
|
||||
'/private/etc/ssl/cert.pem',
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/opt/homebrew/etc/openssl@3/cert.pem',
|
||||
'/usr/local/etc/openssl@3/cert.pem',
|
||||
];
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ini_set('curl.cainfo', $path);
|
||||
@ini_set('openssl.cafile', $path);
|
||||
self::$caBundleInitialized = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
704
server-api/app/support/MaterialTagService.php
Normal file
704
server-api/app/support/MaterialTagService.php
Normal file
@@ -0,0 +1,704 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class MaterialTagService
|
||||
{
|
||||
private const VERIFY_CODE_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
private const MAX_BATCH_COUNT = 10000;
|
||||
|
||||
public function createBatch(int $count, string $remark, int $adminId, string $adminName): array
|
||||
{
|
||||
if ($count < 1 || $count > self::MAX_BATCH_COUNT) {
|
||||
throw new \InvalidArgumentException('单批生成数量需在 1-10000 之间');
|
||||
}
|
||||
|
||||
$h5BaseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
|
||||
if ($h5BaseUrl === '') {
|
||||
throw new \InvalidArgumentException('请先在系统配置中填写 H5 页面根地址');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$batchNo = $this->generateUniqueBatchNo();
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$batchId = (int)Db::name('material_batches')->insertGetId([
|
||||
'batch_no' => $batchNo,
|
||||
'total_count' => $count,
|
||||
'remark' => mb_substr($remark, 0, 500),
|
||||
'download_count' => 0,
|
||||
'created_by' => $adminId,
|
||||
'created_by_name' => $adminName,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
$pendingTokens = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$token = $this->generateUniqueToken($pendingTokens);
|
||||
$pendingTokens[$token] = true;
|
||||
$rows[] = [
|
||||
'batch_id' => $batchId,
|
||||
'qr_token' => $token,
|
||||
'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl),
|
||||
'verify_code' => $this->generateVerifyCode(),
|
||||
'bind_status' => 'unbound',
|
||||
'scan_count' => 0,
|
||||
'verify_count' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
Db::name('material_tag_codes')->insertAll($chunk);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $batchId,
|
||||
'batch_no' => $batchNo,
|
||||
'total_count' => $count,
|
||||
'remark' => $remark,
|
||||
];
|
||||
}
|
||||
|
||||
public function listBatches(array $filters): array
|
||||
{
|
||||
$keyword = trim((string)($filters['keyword'] ?? ''));
|
||||
$qrUrl = trim((string)($filters['qr_url'] ?? ''));
|
||||
$verifyCode = trim((string)($filters['verify_code'] ?? ''));
|
||||
$dateStart = trim((string)($filters['date_start'] ?? ''));
|
||||
$dateEnd = trim((string)($filters['date_end'] ?? ''));
|
||||
|
||||
$query = Db::name('material_batches')->alias('b')->field('b.*')->order('b.id', 'desc');
|
||||
|
||||
$matchedCodeRows = [];
|
||||
$codeSearchValue = $qrUrl !== '' ? $qrUrl : ($verifyCode !== '' ? $verifyCode : $keyword);
|
||||
if ($codeSearchValue !== '') {
|
||||
$searchToken = $this->extractToken($codeSearchValue) ?: $codeSearchValue;
|
||||
$codeQuery = Db::name('material_tag_codes')
|
||||
->where(function ($builder) use ($codeSearchValue, $qrUrl, $verifyCode, $searchToken) {
|
||||
if ($qrUrl !== '') {
|
||||
$builder->whereRaw('(qr_url LIKE :qr_url OR qr_token = :qr_token)', [
|
||||
'qr_url' => "%{$qrUrl}%",
|
||||
'qr_token' => $searchToken,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if ($verifyCode !== '') {
|
||||
$builder->where('verify_code', $verifyCode);
|
||||
return;
|
||||
}
|
||||
$builder->whereRaw('(qr_url LIKE :keyword_qr_url OR qr_token = :keyword_qr_token OR verify_code = :keyword_verify_code)', [
|
||||
'keyword_qr_url' => "%{$codeSearchValue}%",
|
||||
'keyword_qr_token' => $searchToken,
|
||||
'keyword_verify_code' => $codeSearchValue,
|
||||
]);
|
||||
});
|
||||
$matchedCodeRows = $codeQuery->order('id', 'asc')->select()->toArray();
|
||||
if (!$matchedCodeRows) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('b.id', array_values(array_unique(array_map(fn (array $item) => (int)$item['batch_id'], $matchedCodeRows))));
|
||||
}
|
||||
|
||||
if ($dateStart !== '') {
|
||||
$query->where('b.created_at', '>=', $dateStart . (strlen($dateStart) === 10 ? ' 00:00:00' : ''));
|
||||
}
|
||||
if ($dateEnd !== '') {
|
||||
$query->where('b.created_at', '<=', $dateEnd . (strlen($dateEnd) === 10 ? ' 23:59:59' : ''));
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
if (!$rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$batchIds = array_map(fn (array $item) => (int)$item['id'], $rows);
|
||||
$boundCounts = Db::name('material_tag_codes')
|
||||
->whereIn('batch_id', $batchIds)
|
||||
->where('bind_status', 'bound')
|
||||
->group('batch_id')
|
||||
->column('COUNT(*) AS c', 'batch_id');
|
||||
|
||||
$matchedByBatch = [];
|
||||
if ($matchedCodeRows) {
|
||||
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $matchedCodeRows))));
|
||||
foreach ($matchedCodeRows as $codeRow) {
|
||||
$batchId = (int)$codeRow['batch_id'];
|
||||
$matchedByBatch[$batchId][] = $this->formatTagCode($codeRow, $reportMap[(int)($codeRow['report_id'] ?? 0)] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(function (array $row) use ($boundCounts, $matchedByBatch) {
|
||||
$id = (int)$row['id'];
|
||||
return [
|
||||
'id' => $id,
|
||||
'batch_no' => (string)$row['batch_no'],
|
||||
'total_count' => (int)$row['total_count'],
|
||||
'bound_count' => (int)($boundCounts[$id] ?? 0),
|
||||
'download_count' => (int)$row['download_count'],
|
||||
'remark' => (string)($row['remark'] ?? ''),
|
||||
'created_by_name' => (string)($row['created_by_name'] ?? ''),
|
||||
'last_downloaded_at' => (string)($row['last_downloaded_at'] ?? ''),
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'matched_codes' => $matchedByBatch[$id] ?? [],
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function detail(int $batchId, string $keyword = ''): array
|
||||
{
|
||||
$batch = Db::name('material_batches')->where('id', $batchId)->find();
|
||||
if (!$batch) {
|
||||
throw new \RuntimeException('物料批次不存在', 404);
|
||||
}
|
||||
|
||||
$query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc');
|
||||
$keyword = trim($keyword);
|
||||
if ($keyword !== '') {
|
||||
$token = $this->extractToken($keyword);
|
||||
$query->where(function ($builder) use ($keyword, $token) {
|
||||
$builder->whereRaw('(qr_url LIKE :detail_qr_url OR verify_code = :detail_verify_code OR qr_token = :detail_qr_token)', [
|
||||
'detail_qr_url' => "%{$keyword}%",
|
||||
'detail_verify_code' => $keyword,
|
||||
'detail_qr_token' => $token ?: $keyword,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
$codes = $query->select()->toArray();
|
||||
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes))));
|
||||
|
||||
return [
|
||||
'batch' => [
|
||||
'id' => (int)$batch['id'],
|
||||
'batch_no' => (string)$batch['batch_no'],
|
||||
'total_count' => (int)$batch['total_count'],
|
||||
'download_count' => (int)$batch['download_count'],
|
||||
'remark' => (string)($batch['remark'] ?? ''),
|
||||
'created_by_name' => (string)($batch['created_by_name'] ?? ''),
|
||||
'last_downloaded_at' => (string)($batch['last_downloaded_at'] ?? ''),
|
||||
'created_at' => (string)$batch['created_at'],
|
||||
],
|
||||
'codes' => array_map(fn (array $row) => $this->formatTagCode($row, $reportMap[(int)($row['report_id'] ?? 0)] ?? null), $codes),
|
||||
];
|
||||
}
|
||||
|
||||
public function downloadBatch(int $batchId, Request $request): array
|
||||
{
|
||||
$batch = Db::name('material_batches')->where('id', $batchId)->find();
|
||||
if (!$batch) {
|
||||
throw new \RuntimeException('物料批次不存在', 404);
|
||||
}
|
||||
|
||||
$codes = Db::name('material_tag_codes')
|
||||
->where('batch_id', $batchId)
|
||||
->order('id', 'asc')
|
||||
->field(['qr_url', 'verify_code'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$filename = sprintf('material-batch-%s.xlsx', preg_replace('/[^a-zA-Z0-9_-]/', '-', (string)$batch['batch_no']));
|
||||
$binary = $this->buildXlsxBinary($codes);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$adminId = (int)$request->header('x-admin-id', 0);
|
||||
$adminName = trim((string)$request->header('x-admin-name', ''));
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('material_batches')->where('id', $batchId)->update([
|
||||
'download_count' => (int)$batch['download_count'] + 1,
|
||||
'last_downloaded_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
Db::name('material_batch_download_logs')->insert([
|
||||
'batch_id' => $batchId,
|
||||
'operator_id' => $adminId,
|
||||
'operator_name' => $adminName,
|
||||
'ip' => substr((string)$request->getRealIp(), 0, 64),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'downloaded_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'filename' => $filename,
|
||||
'content' => $binary,
|
||||
];
|
||||
}
|
||||
|
||||
public function bindTagToReportByTask(int $taskId, string $input, Request $request): array
|
||||
{
|
||||
$tag = $this->findTagByInput($input);
|
||||
if (!$tag) {
|
||||
throw new \InvalidArgumentException('吊牌二维码不存在');
|
||||
}
|
||||
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
|
||||
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
|
||||
}
|
||||
|
||||
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
|
||||
if (!$task) {
|
||||
throw new \RuntimeException('任务不存在', 404);
|
||||
}
|
||||
$report = Db::name('reports')
|
||||
->where('order_id', (int)$task['order_id'])
|
||||
->where('report_type', 'appraisal')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$report) {
|
||||
throw new \InvalidArgumentException('请先提交鉴定结论生成报告草稿后再绑定吊牌');
|
||||
}
|
||||
if (($report['report_status'] ?? '') === 'published') {
|
||||
throw new \InvalidArgumentException('报告已发布,不能再绑定或更换吊牌');
|
||||
}
|
||||
|
||||
$existing = Db::name('material_tag_codes')->where('report_id', (int)$report['id'])->find();
|
||||
if ($existing) {
|
||||
throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
|
||||
'report_id' => (int)$report['id'],
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'bind_status' => 'bound',
|
||||
'bound_task_id' => $taskId,
|
||||
'bound_order_id' => (int)$task['order_id'],
|
||||
'bound_by' => (int)$request->header('x-admin-id', 0),
|
||||
'bound_by_name' => trim((string)$request->header('x-admin-name', '')),
|
||||
'bound_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$fresh = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find();
|
||||
return $this->formatTagCode($fresh ?: $tag, [
|
||||
'id' => (int)$report['id'],
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_status' => (string)$report['report_status'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function findBoundTagForReport(int $reportId): ?array
|
||||
{
|
||||
if ($reportId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$tag = Db::name('material_tag_codes')->where('report_id', $reportId)->find();
|
||||
if (!$tag) {
|
||||
return null;
|
||||
}
|
||||
$report = Db::name('reports')->where('id', $reportId)->find();
|
||||
return $this->formatTagCode($tag, $report ?: null);
|
||||
}
|
||||
|
||||
public function showPublicTag(string $token, Request $request): array
|
||||
{
|
||||
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
|
||||
if (!$tag) {
|
||||
throw new \RuntimeException('吊牌不存在', 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
|
||||
'scan_count' => (int)$tag['scan_count'] + 1,
|
||||
'last_scanned_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->insertScanLog($tag, 'scan', false, $request, $now);
|
||||
$tag['scan_count'] = (int)$tag['scan_count'] + 1;
|
||||
$tag['last_scanned_at'] = $now;
|
||||
|
||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||
: null;
|
||||
|
||||
if (!$report) {
|
||||
return [
|
||||
'tag_status' => 'unbound',
|
||||
'status_text' => '吊牌尚未关联报告',
|
||||
'message' => '该吊牌已完成建码,但暂未绑定鉴定报告。',
|
||||
'qr_token' => (string)$tag['qr_token'],
|
||||
'qr_url' => (string)$tag['qr_url'],
|
||||
'scan_count' => (int)$tag['scan_count'],
|
||||
'verify_count' => (int)$tag['verify_count'],
|
||||
'report_summary' => null,
|
||||
'product_summary' => [],
|
||||
'result_summary' => [],
|
||||
'verify_passed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (($report['report_status'] ?? '') !== 'published') {
|
||||
return [
|
||||
'tag_status' => 'pending_report',
|
||||
'status_text' => '报告生成中',
|
||||
'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。',
|
||||
'qr_token' => (string)$tag['qr_token'],
|
||||
'qr_url' => (string)$tag['qr_url'],
|
||||
'scan_count' => (int)$tag['scan_count'],
|
||||
'verify_count' => (int)$tag['verify_count'],
|
||||
'report_summary' => [
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'institution_name' => (string)$report['institution_name'],
|
||||
'publish_time' => (string)($report['publish_time'] ?? ''),
|
||||
],
|
||||
'product_summary' => [],
|
||||
'result_summary' => [],
|
||||
'verify_passed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find() ?: [];
|
||||
return [
|
||||
'tag_status' => 'published',
|
||||
'status_text' => '报告已发布',
|
||||
'message' => '该吊牌已关联正式鉴定报告,可输入吊牌验真编码完成组合验真。',
|
||||
'qr_token' => (string)$tag['qr_token'],
|
||||
'qr_url' => (string)$tag['qr_url'],
|
||||
'scan_count' => (int)$tag['scan_count'],
|
||||
'verify_count' => (int)$tag['verify_count'],
|
||||
'report_summary' => [
|
||||
'report_id' => (int)$report['id'],
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'institution_name' => (string)$report['institution_name'],
|
||||
'publish_time' => (string)($report['publish_time'] ?? ''),
|
||||
],
|
||||
'product_summary' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
|
||||
'result_summary' => $this->decodeJsonField($content['result_snapshot_json'] ?? null),
|
||||
'verify_passed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyPublicTag(string $token, string $reportNo, string $verifyCode, Request $request): array
|
||||
{
|
||||
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
|
||||
if (!$tag) {
|
||||
throw new \RuntimeException('吊牌不存在', 404);
|
||||
}
|
||||
|
||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||
: null;
|
||||
|
||||
$passed = $report
|
||||
&& ($report['report_status'] ?? '') === 'published'
|
||||
&& hash_equals((string)$tag['verify_code'], $verifyCode)
|
||||
&& (string)$report['report_no'] === $reportNo;
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
if ($passed) {
|
||||
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
|
||||
'verify_count' => (int)$tag['verify_count'] + 1,
|
||||
'last_verified_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$this->insertScanLog($tag, 'verify_code', $passed, $request, $now, $verifyCode, $reportNo);
|
||||
|
||||
if (!$passed) {
|
||||
return [
|
||||
'verify_passed' => false,
|
||||
'verify_message' => '验真编码与当前吊牌或报告不匹配,请核对后重试。',
|
||||
'verify_count' => (int)$tag['verify_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'verify_passed' => true,
|
||||
'verify_message' => '组合验真通过,该吊牌二维码、报告编号与验真编码匹配。',
|
||||
'verify_count' => (int)$tag['verify_count'] + 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function extractToken(string $input): string
|
||||
{
|
||||
$value = trim($input);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5);
|
||||
$parts = parse_url($decoded);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
if (!empty($query['token'])) {
|
||||
return trim((string)$query['token']);
|
||||
}
|
||||
}
|
||||
|
||||
$fragment = (string)($parts['fragment'] ?? '');
|
||||
if ($fragment !== '') {
|
||||
$questionPos = strpos($fragment, '?');
|
||||
if ($questionPos !== false) {
|
||||
parse_str(substr($fragment, $questionPos + 1), $query);
|
||||
if (!empty($query['token'])) {
|
||||
return trim((string)$query['token']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/(?:^|[?&])token=([^&#]+)/', $decoded, $matches)) {
|
||||
return trim((string)rawurldecode($matches[1]));
|
||||
}
|
||||
|
||||
return preg_match('/^[a-zA-Z0-9_-]{16,80}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private function findTagByInput(string $input): ?array
|
||||
{
|
||||
$value = trim($input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$token = $this->extractToken($value);
|
||||
if ($token !== '') {
|
||||
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
|
||||
if ($tag) {
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
|
||||
return Db::name('material_tag_codes')->where('qr_url', $value)->find() ?: null;
|
||||
}
|
||||
|
||||
private function formatTagCode(array $row, ?array $report): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'batch_id' => (int)$row['batch_id'],
|
||||
'qr_token' => (string)$row['qr_token'],
|
||||
'qr_url' => (string)$row['qr_url'],
|
||||
'verify_code' => (string)$row['verify_code'],
|
||||
'bind_status' => (string)$row['bind_status'],
|
||||
'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定',
|
||||
'report_id' => (int)($row['report_id'] ?? 0),
|
||||
'report_no' => (string)($row['report_no'] ?: ($report['report_no'] ?? '')),
|
||||
'report_status' => (string)($report['report_status'] ?? ''),
|
||||
'scan_count' => (int)($row['scan_count'] ?? 0),
|
||||
'verify_count' => (int)($row['verify_count'] ?? 0),
|
||||
'bound_at' => (string)($row['bound_at'] ?? ''),
|
||||
'bound_by_name' => (string)($row['bound_by_name'] ?? ''),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadReportMap(array $reportIds): array
|
||||
{
|
||||
$reportIds = array_values(array_unique(array_filter(array_map('intval', $reportIds))));
|
||||
if (!$reportIds) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('reports')->whereIn('id', $reportIds)->select()->toArray();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['id']] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function buildMaterialTagUrl(string $token, string $baseUrl): string
|
||||
{
|
||||
return $baseUrl . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
|
||||
}
|
||||
|
||||
private function generateUniqueBatchNo(): string
|
||||
{
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$candidate = sprintf('MAT-%s-%04d', date('YmdHis'), random_int(0, 9999));
|
||||
if (!Db::name('material_batches')->where('batch_no', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return 'MAT-' . date('YmdHis') . '-' . bin2hex(random_bytes(3));
|
||||
}
|
||||
|
||||
private function generateUniqueToken(array $pendingTokens): string
|
||||
{
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$candidate = 'mt_' . bin2hex(random_bytes(16));
|
||||
if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('二维码 token 生成失败,请重试');
|
||||
}
|
||||
|
||||
private function generateVerifyCode(): string
|
||||
{
|
||||
$code = '';
|
||||
$max = strlen(self::VERIFY_CODE_CHARS) - 1;
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$code .= self::VERIFY_CODE_CHARS[random_int(0, $max)];
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function normalizeH5BaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($baseUrl, '#');
|
||||
if ($hashPos !== false) {
|
||||
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||
}
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
return '';
|
||||
}
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function decodeJsonField(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return json_decode($value, true) ?: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function insertScanLog(array $tag, string $verifyType, bool $passed, Request $request, string $now, string $verifyCode = '', string $reportNo = ''): void
|
||||
{
|
||||
Db::name('material_tag_scan_logs')->insert([
|
||||
'tag_code_id' => (int)$tag['id'],
|
||||
'batch_id' => (int)$tag['batch_id'],
|
||||
'report_id' => (int)($tag['report_id'] ?? 0) ?: null,
|
||||
'report_no' => $reportNo !== '' ? $reportNo : (string)($tag['report_no'] ?? ''),
|
||||
'verify_type' => $verifyType,
|
||||
'verify_code_input' => $verifyCode,
|
||||
'verify_passed' => $passed ? 1 : 0,
|
||||
'ip' => substr((string)$request->getRealIp(), 0, 64),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'scanned_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildXlsxBinary(array $rows): string
|
||||
{
|
||||
if (!class_exists(\ZipArchive::class)) {
|
||||
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成 Excel');
|
||||
}
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
|
||||
if ($tmpFile === false) {
|
||||
throw new \RuntimeException('临时文件创建失败');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
|
||||
@unlink($tmpFile);
|
||||
throw new \RuntimeException('Excel 文件创建失败');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
|
||||
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
|
||||
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
|
||||
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
|
||||
$zip->close();
|
||||
|
||||
$content = file_get_contents($tmpFile);
|
||||
@unlink($tmpFile);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Excel 文件读取失败');
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function xlsxContentTypesXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
private function xlsxRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function xlsxWorkbookXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
|
||||
. '</workbook>';
|
||||
}
|
||||
|
||||
private function xlsxWorkbookRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
private function xlsxSheetXml(array $rows): string
|
||||
{
|
||||
$sheetRows = [
|
||||
['二维码链接', '验真编码'],
|
||||
...array_map(fn (array $row) => [(string)$row['qr_url'], (string)$row['verify_code']], $rows),
|
||||
];
|
||||
|
||||
$xmlRows = [];
|
||||
foreach ($sheetRows as $rowIndex => $row) {
|
||||
$excelRow = $rowIndex + 1;
|
||||
$xmlRows[] = sprintf(
|
||||
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
|
||||
$excelRow,
|
||||
$excelRow,
|
||||
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
|
||||
$excelRow,
|
||||
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
|
||||
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
|
||||
. '</worksheet>';
|
||||
}
|
||||
}
|
||||
174
server-api/app/support/MessageDispatcher.php
Normal file
174
server-api/app/support/MessageDispatcher.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class MessageDispatcher
|
||||
{
|
||||
public function sendInboxEvent(string $eventCode, array $context): bool
|
||||
{
|
||||
$userId = (int)($context['user_id'] ?? 0);
|
||||
$bizType = trim((string)($context['biz_type'] ?? ''));
|
||||
$bizId = (int)($context['biz_id'] ?? 0);
|
||||
|
||||
if ($userId <= 0 || $bizType === '' || $bizId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->shouldSendByPreference($userId, $eventCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule = Db::name('message_rules')
|
||||
->where('event_code', $eventCode)
|
||||
->where('channel', 'inbox')
|
||||
->where('is_enabled', 1)
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
|
||||
if (!$rule) {
|
||||
$disabledRule = Db::name('message_rules')
|
||||
->where('event_code', $eventCode)
|
||||
->where('channel', 'inbox')
|
||||
->find();
|
||||
|
||||
if ($disabledRule) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$template = null;
|
||||
if ($rule) {
|
||||
$template = Db::name('message_templates')
|
||||
->where('id', (int)$rule['template_id'])
|
||||
->where('channel', 'inbox')
|
||||
->where('is_enabled', 1)
|
||||
->find();
|
||||
}
|
||||
|
||||
$alreadySent = Db::name('message_logs')
|
||||
->where('user_id', $userId)
|
||||
->where('biz_type', $bizType)
|
||||
->where('biz_id', $bizId)
|
||||
->where('channel', 'inbox')
|
||||
->where('status', 'sent')
|
||||
->find();
|
||||
|
||||
if ($alreadySent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$title = $this->renderTemplate(
|
||||
$template['title'] ?? (string)($context['fallback_title'] ?? ''),
|
||||
$context
|
||||
);
|
||||
$content = $this->renderTemplate(
|
||||
$template['content'] ?? (string)($context['fallback_content'] ?? ''),
|
||||
$context
|
||||
);
|
||||
|
||||
if ($title === '' && $content === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::name('user_messages')->insert([
|
||||
'user_id' => $userId,
|
||||
'title' => $title !== '' ? $title : '系统通知',
|
||||
'content' => $content,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'is_read' => 0,
|
||||
'read_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('message_logs')->insert([
|
||||
'user_id' => $userId,
|
||||
'template_id' => $template['id'] ?? null,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'channel' => 'inbox',
|
||||
'status' => 'sent',
|
||||
'fail_reason' => '',
|
||||
'sent_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function shouldSendByPreference(int $userId, string $eventCode): bool
|
||||
{
|
||||
$preferenceKey = $this->preferenceKeyForEvent($eventCode);
|
||||
if ($preferenceKey === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configValue = Db::name('system_configs')
|
||||
->where('config_group', 'user_settings')
|
||||
->where('config_key', 'user_' . $userId)
|
||||
->value('config_value');
|
||||
|
||||
if (!is_string($configValue) || $configValue === '') {
|
||||
return $this->defaultPreference($preferenceKey);
|
||||
}
|
||||
|
||||
$decoded = json_decode($configValue, true);
|
||||
if (!is_array($decoded)) {
|
||||
return $this->defaultPreference($preferenceKey);
|
||||
}
|
||||
|
||||
if (!array_key_exists($preferenceKey, $decoded)) {
|
||||
return $this->defaultPreference($preferenceKey);
|
||||
}
|
||||
|
||||
return (bool)$decoded[$preferenceKey];
|
||||
}
|
||||
|
||||
private function preferenceKeyForEvent(string $eventCode): string
|
||||
{
|
||||
return match ($eventCode) {
|
||||
'order_created' => 'notify_order',
|
||||
'return_shipped' => 'notify_order',
|
||||
'return_received' => 'notify_order',
|
||||
'report_published' => 'notify_report',
|
||||
'supplement_required' => 'notify_supplement',
|
||||
'ticket_reply', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'notify_ticket',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function defaultPreference(string $key): bool
|
||||
{
|
||||
return match ($key) {
|
||||
'marketing_notify', 'privacy_mode' => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderTemplate(string $text, array $context): string
|
||||
{
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', function (array $matches) use ($context) {
|
||||
$key = $matches[1];
|
||||
if (!array_key_exists($key, $context)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = $context[$key];
|
||||
if (is_scalar($value) || $value === null) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, $text) ?? $text;
|
||||
}
|
||||
}
|
||||
140
server-api/app/support/PublicAssetUrlService.php
Normal file
140
server-api/app/support/PublicAssetUrlService.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class PublicAssetUrlService
|
||||
{
|
||||
public function buildUrl(Request $request, string $relativePath): string
|
||||
{
|
||||
$relativePath = ltrim($relativePath, '/');
|
||||
$publicPath = $this->configService()->isOss()
|
||||
? $this->configService()->objectKey($relativePath)
|
||||
: $relativePath;
|
||||
|
||||
return $this->resolveBaseUrl($request) . '/' . ltrim($publicPath, '/');
|
||||
}
|
||||
|
||||
public function normalizeUrl(string $value, Request $request): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
$path = (string)($parts['path'] ?? '');
|
||||
$query = (string)($parts['query'] ?? '');
|
||||
$fragment = (string)($parts['fragment'] ?? '');
|
||||
$host = strtolower((string)($parts['host'] ?? ''));
|
||||
$scheme = (string)($parts['scheme'] ?? '');
|
||||
|
||||
if ($scheme === '' && $host === '') {
|
||||
return $this->appendQueryAndFragment(
|
||||
$this->buildUrl($request, $path !== '' ? $path : $value),
|
||||
$query,
|
||||
$fragment
|
||||
);
|
||||
}
|
||||
|
||||
if ($path !== '' && $this->shouldRewriteHost($host)) {
|
||||
return $this->appendQueryAndFragment(
|
||||
$this->buildUrl($request, $path),
|
||||
$query,
|
||||
$fragment
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function storagePath(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = parse_url($value, PHP_URL_PATH);
|
||||
if (!is_string($path) || $path === '') {
|
||||
$path = ltrim($value, '/');
|
||||
return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path;
|
||||
}
|
||||
|
||||
$path = ltrim($path, '/');
|
||||
return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path;
|
||||
}
|
||||
|
||||
private function resolveBaseUrl(Request $request): string
|
||||
{
|
||||
$configured = $this->configService()->publicBaseUrl();
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$configured = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ($_ENV['APP_PUBLIC_BASE_URL'] ?? '')));
|
||||
if ($configured !== '') {
|
||||
return $this->normalizeBaseUrl($configured);
|
||||
}
|
||||
|
||||
$scheme = trim((string)($request->header('x-forwarded-proto') ?: 'http'));
|
||||
$host = trim((string)($request->header('x-forwarded-host') ?: $request->header('host') ?: $request->host()));
|
||||
if (($commaPos = strpos($host, ',')) !== false) {
|
||||
$host = trim(substr($host, 0, $commaPos));
|
||||
}
|
||||
|
||||
$port = trim((string)($request->header('x-forwarded-port') ?: ''));
|
||||
if ($port !== '' && strpos($host, ':') === false) {
|
||||
if (!(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
|
||||
$host .= ':' . $port;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeBaseUrl($scheme . '://' . $host);
|
||||
}
|
||||
|
||||
private function normalizeBaseUrl(string $baseUrl): string
|
||||
{
|
||||
$baseUrl = trim($baseUrl);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function configService(): FileStorageConfigService
|
||||
{
|
||||
return new FileStorageConfigService();
|
||||
}
|
||||
|
||||
private function shouldRewriteHost(string $host): bool
|
||||
{
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($host, ['localhost', '127.0.0.1', '0.0.0.0', 'host.docker.internal'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/', $host) === 1;
|
||||
}
|
||||
|
||||
private function appendQueryAndFragment(string $url, string $query, string $fragment): string
|
||||
{
|
||||
if ($query !== '') {
|
||||
$url .= '?' . $query;
|
||||
}
|
||||
if ($fragment !== '') {
|
||||
$url .= '#' . $fragment;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
172
server-api/app/support/ReportPdfGenerator.php
Normal file
172
server-api/app/support/ReportPdfGenerator.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
class ReportPdfGenerator
|
||||
{
|
||||
public function generate(array $payload): string
|
||||
{
|
||||
$content = $this->buildContentStream($payload);
|
||||
|
||||
$objects = [
|
||||
1 => '<< /Type /Catalog /Pages 2 0 R >>',
|
||||
2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
3 => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
||||
4 => sprintf("<< /Length %d >>\nstream\n%s\nendstream", strlen($content), $content),
|
||||
5 => '<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>',
|
||||
6 => '<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>',
|
||||
];
|
||||
|
||||
return $this->renderPdf($objects);
|
||||
}
|
||||
|
||||
private function buildContentStream(array $payload): string
|
||||
{
|
||||
$title = $this->normalizeText((string)($payload['report_title'] ?? '鉴定报告'));
|
||||
$serviceProviderText = $this->normalizeText((string)($payload['service_provider_text'] ?? '-'));
|
||||
$institutionName = $this->normalizeText((string)($payload['institution_name'] ?? '-'));
|
||||
$reportNo = $this->normalizeText((string)($payload['report_no'] ?? '-'));
|
||||
$publishTime = $this->normalizeText((string)($payload['publish_time'] ?? '-'));
|
||||
$resultText = $this->normalizeText((string)($payload['result_text'] ?? '-'));
|
||||
$resultDesc = $this->normalizeText((string)($payload['result_desc'] ?? '-'));
|
||||
$productName = $this->normalizeText((string)($payload['product_name'] ?? '-'));
|
||||
$categoryBrand = $this->normalizeText((string)($payload['category_brand'] ?? '-'));
|
||||
$specInfo = $this->normalizeText((string)($payload['spec_info'] ?? '-'));
|
||||
$appraisers = $this->normalizeText((string)($payload['appraisers'] ?? '-'));
|
||||
$conditionGrade = $this->normalizeText((string)($payload['condition_grade'] ?? '-'));
|
||||
$valuationRange = $this->normalizeText((string)($payload['valuation_range'] ?? '-'));
|
||||
$verifyInfo = $this->normalizeText((string)($payload['verify_info'] ?? '-'));
|
||||
$riskNotice = $this->normalizeText((string)($payload['risk_notice_text'] ?? '-'));
|
||||
|
||||
$blocks = [];
|
||||
$y = 790;
|
||||
|
||||
$blocks[] = $this->textBlock($title, 52, $y, 20);
|
||||
$y -= 32;
|
||||
$blocks[] = $this->textBlock('正式报告凭证,请以编号验真结果为准。', 52, $y, 10);
|
||||
$y -= 30;
|
||||
|
||||
foreach ([
|
||||
sprintf('报告编号:%s', $reportNo),
|
||||
sprintf('出具机构:%s', $institutionName),
|
||||
sprintf('出具时间:%s', $publishTime),
|
||||
sprintf('服务类型:%s', $serviceProviderText),
|
||||
] as $line) {
|
||||
$blocks[] = $this->textBlock($line, 52, $y, 12);
|
||||
$y -= 22;
|
||||
}
|
||||
|
||||
$y -= 8;
|
||||
$blocks[] = $this->textBlock(sprintf('鉴定结论:%s', $resultText), 52, $y, 16);
|
||||
$y -= 26;
|
||||
|
||||
foreach ($this->wrapText('结果说明:' . $resultDesc, 30) as $line) {
|
||||
$blocks[] = $this->textBlock($line, 52, $y, 11);
|
||||
$y -= 18;
|
||||
}
|
||||
|
||||
$y -= 8;
|
||||
foreach ([
|
||||
sprintf('商品名称:%s', $productName),
|
||||
sprintf('品类 / 品牌:%s', $categoryBrand),
|
||||
sprintf('颜色 / 规格:%s', $specInfo),
|
||||
sprintf('鉴定师:%s', $appraisers),
|
||||
sprintf('成色评级:%s', $conditionGrade),
|
||||
sprintf('估值区间:%s', $valuationRange),
|
||||
sprintf('验真信息:%s', $verifyInfo),
|
||||
] as $line) {
|
||||
$blocks[] = $this->textBlock($line, 52, $y, 11);
|
||||
$y -= 20;
|
||||
}
|
||||
|
||||
$y -= 8;
|
||||
foreach ($this->wrapText('风险说明:' . $riskNotice, 30) as $line) {
|
||||
$blocks[] = $this->textBlock($line, 52, $y, 10);
|
||||
$y -= 17;
|
||||
}
|
||||
|
||||
if ($y > 48) {
|
||||
$blocks[] = $this->textBlock('安心验鉴定平台', 52, 42, 9);
|
||||
}
|
||||
|
||||
return implode("\n", array_filter($blocks));
|
||||
}
|
||||
|
||||
private function wrapText(string $text, int $maxUnits): array
|
||||
{
|
||||
$normalized = $this->normalizeText($text);
|
||||
if ($normalized === '') {
|
||||
return ['-'];
|
||||
}
|
||||
|
||||
$chars = preg_split('//u', $normalized, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$lines = [];
|
||||
$current = '';
|
||||
$width = 0.0;
|
||||
|
||||
foreach ($chars as $char) {
|
||||
$charWidth = strlen($char) === 1 && ord($char) < 128 ? 0.5 : 1.0;
|
||||
if ($current !== '' && $width + $charWidth > $maxUnits) {
|
||||
$lines[] = $current;
|
||||
$current = '';
|
||||
$width = 0.0;
|
||||
}
|
||||
$current .= $char;
|
||||
$width += $charWidth;
|
||||
}
|
||||
|
||||
if ($current !== '') {
|
||||
$lines[] = $current;
|
||||
}
|
||||
|
||||
return $lines ?: ['-'];
|
||||
}
|
||||
|
||||
private function textBlock(string $text, int $x, int $y, int $fontSize): string
|
||||
{
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
"BT\n/F1 %d Tf\n1 0 0 1 %d %d Tm\n<%s> Tj\nET",
|
||||
$fontSize,
|
||||
$x,
|
||||
$y,
|
||||
strtoupper(bin2hex(mb_convert_encoding($text, 'UCS-2BE', 'UTF-8')))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeText(string $text): string
|
||||
{
|
||||
$text = trim(str_replace(["\r\n", "\r", "\n", "\t"], [' ', ' ', ' ', ' '], $text));
|
||||
return preg_replace('/\s+/u', ' ', $text) ?: '';
|
||||
}
|
||||
|
||||
private function renderPdf(array $objects): string
|
||||
{
|
||||
$pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
|
||||
$offsets = [];
|
||||
|
||||
foreach ($objects as $id => $body) {
|
||||
$offsets[$id] = strlen($pdf);
|
||||
$pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $id, $body);
|
||||
}
|
||||
|
||||
$xrefPosition = strlen($pdf);
|
||||
$pdf .= sprintf("xref\n0 %d\n", count($objects) + 1);
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
|
||||
foreach ($objects as $id => $_body) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $offsets[$id]);
|
||||
}
|
||||
|
||||
$pdf .= sprintf(
|
||||
"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF",
|
||||
count($objects) + 1,
|
||||
$xrefPosition
|
||||
);
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
}
|
||||
88
server-api/app/support/TicketAttachmentService.php
Normal file
88
server-api/app/support/TicketAttachmentService.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Request;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
|
||||
class TicketAttachmentService
|
||||
{
|
||||
public function upload(Request $request, string $inputName = 'file'): array
|
||||
{
|
||||
$file = $request->file($inputName);
|
||||
if (!$file || !$file->isValid()) {
|
||||
throw new \RuntimeException('上传文件无效');
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
|
||||
$filename = sprintf('ticket_%s.%s', uniqid(), $extension);
|
||||
$relativeDir = 'uploads/tickets/' . date('Ymd');
|
||||
$relativePath = $relativeDir . '/' . $filename;
|
||||
$this->storage()->putUploadedFile($file, $relativePath);
|
||||
|
||||
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
|
||||
|
||||
return [
|
||||
'file_id' => md5($relativePath),
|
||||
'file_url' => $fileUrl,
|
||||
'thumbnail_url' => $fileUrl,
|
||||
'name' => $file->getUploadName(),
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(string $fileUrl): void
|
||||
{
|
||||
$relativePath = $this->storage()->storagePath($fileUrl);
|
||||
if (!str_starts_with($relativePath, 'uploads/tickets/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storage()->delete($relativePath);
|
||||
}
|
||||
|
||||
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
|
||||
{
|
||||
if (is_string($attachments) && $attachments !== '') {
|
||||
$decoded = json_decode($attachments, true);
|
||||
$attachments = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
if (!is_array($attachments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($attachments as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileUrl = trim((string)($item['file_url'] ?? ''));
|
||||
if ($fileUrl === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storedFileUrl = $this->storage()->storagePath($fileUrl);
|
||||
$storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? $fileUrl)));
|
||||
|
||||
$list[] = [
|
||||
'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))),
|
||||
'file_url' => $forStorage
|
||||
? '/' . $storedFileUrl
|
||||
: ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl),
|
||||
'thumbnail_url' => $forStorage
|
||||
? '/' . $storedThumbnailUrl
|
||||
: ($request ? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? $fileUrl)), $request) : trim((string)($item['thumbnail_url'] ?? $fileUrl))),
|
||||
'name' => trim((string)($item['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function storage(): FileStorageService
|
||||
{
|
||||
return new FileStorageService();
|
||||
}
|
||||
}
|
||||
574
server-api/app/support/WarehouseService.php
Normal file
574
server-api/app/support/WarehouseService.php
Normal file
@@ -0,0 +1,574 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class WarehouseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensureWarehouseTable();
|
||||
$this->ensureWarehouseRuleColumns();
|
||||
$this->ensureOrderTargetTable();
|
||||
$this->bootstrapDefaults();
|
||||
}
|
||||
|
||||
public function overviewCards(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'title' => '仓库总数',
|
||||
'value' => (int)Db::name('shipping_warehouses')->count(),
|
||||
'desc' => '当前已维护的检测中心 / 收货仓库数量',
|
||||
],
|
||||
[
|
||||
'title' => '启用仓库',
|
||||
'value' => (int)Db::name('shipping_warehouses')->where('status', 'enabled')->count(),
|
||||
'desc' => '当前对前台寄送页可见的仓库数量',
|
||||
],
|
||||
[
|
||||
'title' => '安心验仓库',
|
||||
'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'anxinyan')->count(),
|
||||
'desc' => '归属安心验服务的默认收货中心数量',
|
||||
],
|
||||
[
|
||||
'title' => '中检仓库',
|
||||
'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'zhongjian')->count(),
|
||||
'desc' => '归属中检服务的默认收货中心数量',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$rows = Db::name('shipping_warehouses')
|
||||
->order('service_provider', 'asc')
|
||||
->order('is_default', 'desc')
|
||||
->order('sort_order', 'asc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return array_map(fn(array $item) => $this->formatWarehouse($item), $rows);
|
||||
}
|
||||
|
||||
public function save(array $payload, int $id = 0): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan'));
|
||||
$status = trim((string)($payload['status'] ?? 'enabled'));
|
||||
$supportedCategoryIds = $this->normalizeIntArray($payload['supported_category_ids'] ?? []);
|
||||
$serviceAreaProvinces = $this->normalizeStringArray($payload['service_area_provinces'] ?? []);
|
||||
$serviceAreaCities = $this->normalizeStringArray($payload['service_area_cities'] ?? []);
|
||||
$warehouseCode = trim((string)($payload['warehouse_code'] ?? ''));
|
||||
|
||||
if ($warehouseCode === '') {
|
||||
$warehouseCode = $this->generateWarehouseCode($serviceProvider);
|
||||
}
|
||||
|
||||
$existsByCode = Db::name('shipping_warehouses')
|
||||
->where('warehouse_code', $warehouseCode)
|
||||
->when($id > 0, fn($query) => $query->where('id', '<>', $id))
|
||||
->find();
|
||||
if ($existsByCode) {
|
||||
throw new \RuntimeException('仓库编码已存在,请更换后重试');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'warehouse_name' => trim((string)($payload['warehouse_name'] ?? '')),
|
||||
'warehouse_code' => $warehouseCode,
|
||||
'warehouse_type' => 'detection_center',
|
||||
'service_provider' => $serviceProvider,
|
||||
'receiver_name' => trim((string)($payload['receiver_name'] ?? '')),
|
||||
'receiver_mobile' => trim((string)($payload['receiver_mobile'] ?? '')),
|
||||
'province' => trim((string)($payload['province'] ?? '')),
|
||||
'city' => trim((string)($payload['city'] ?? '')),
|
||||
'district' => trim((string)($payload['district'] ?? '')),
|
||||
'detail_address' => trim((string)($payload['detail_address'] ?? '')),
|
||||
'service_time' => trim((string)($payload['service_time'] ?? '')),
|
||||
'notice' => trim((string)($payload['notice'] ?? '')),
|
||||
'supported_category_ids_json' => $supportedCategoryIds ? json_encode($supportedCategoryIds, JSON_UNESCAPED_UNICODE) : null,
|
||||
'service_area_provinces_json' => $serviceAreaProvinces ? json_encode($serviceAreaProvinces, JSON_UNESCAPED_UNICODE) : null,
|
||||
'service_area_cities_json' => $serviceAreaCities ? json_encode($serviceAreaCities, JSON_UNESCAPED_UNICODE) : null,
|
||||
'status' => $status !== '' ? $status : 'enabled',
|
||||
'is_default' => !empty($payload['is_default']) ? 1 : 0,
|
||||
'sort_order' => (int)($payload['sort_order'] ?? 0),
|
||||
'remark' => trim((string)($payload['remark'] ?? '')),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$this->validatePayload($data);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ((int)$data['is_default'] === 1) {
|
||||
Db::name('shipping_warehouses')
|
||||
->where('service_provider', $serviceProvider)
|
||||
->update([
|
||||
'is_default' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('shipping_warehouses')->where('id', $id)->update($data);
|
||||
$warehouseId = $id;
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$warehouseId = (int)Db::name('shipping_warehouses')->insertGetId($data);
|
||||
}
|
||||
|
||||
if ((int)$data['is_default'] !== 1) {
|
||||
$currentDefault = Db::name('shipping_warehouses')
|
||||
->where('service_provider', $serviceProvider)
|
||||
->where('status', 'enabled')
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
if (!$currentDefault) {
|
||||
Db::name('shipping_warehouses')->where('id', $warehouseId)->update([
|
||||
'is_default' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $warehouseId;
|
||||
}
|
||||
|
||||
public function resolveForShipping(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
|
||||
{
|
||||
$options = $this->optionsForOrder($serviceProvider, $categoryId, $userAddress);
|
||||
if (!$options) {
|
||||
return [
|
||||
'warehouse_id' => 0,
|
||||
'warehouse_name' => '',
|
||||
'warehouse_code' => '',
|
||||
'receiver_name' => '',
|
||||
'receiver_mobile' => '',
|
||||
'province' => '',
|
||||
'city' => '',
|
||||
'district' => '',
|
||||
'detail_address' => '',
|
||||
'service_time' => '',
|
||||
'notice' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$matched = $options[0];
|
||||
return [
|
||||
'warehouse_id' => (int)$matched['id'],
|
||||
'warehouse_name' => $matched['warehouse_name'],
|
||||
'warehouse_code' => $matched['warehouse_code'],
|
||||
'receiver_name' => $matched['receiver_name'],
|
||||
'receiver_mobile' => $matched['receiver_mobile'],
|
||||
'province' => $matched['province'],
|
||||
'city' => $matched['city'],
|
||||
'district' => $matched['district'],
|
||||
'detail_address' => $matched['detail_address'],
|
||||
'service_time' => $matched['service_time'],
|
||||
'notice' => $matched['notice'],
|
||||
];
|
||||
}
|
||||
|
||||
public function bindOrderTarget(int $orderId, string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
|
||||
{
|
||||
$snapshot = $this->resolveForShipping($serviceProvider, $categoryId, $userAddress);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$payload = [
|
||||
'order_id' => $orderId,
|
||||
'warehouse_id' => (int)($snapshot['warehouse_id'] ?? 0) ?: null,
|
||||
'warehouse_name' => $snapshot['warehouse_name'] ?? '',
|
||||
'warehouse_code' => $snapshot['warehouse_code'] ?? '',
|
||||
'service_provider' => $serviceProvider,
|
||||
'receiver_name' => $snapshot['receiver_name'] ?? '',
|
||||
'receiver_mobile' => $snapshot['receiver_mobile'] ?? '',
|
||||
'province' => $snapshot['province'] ?? '',
|
||||
'city' => $snapshot['city'] ?? '',
|
||||
'district' => $snapshot['district'] ?? '',
|
||||
'detail_address' => $snapshot['detail_address'] ?? '',
|
||||
'service_time' => $snapshot['service_time'] ?? '',
|
||||
'notice' => $snapshot['notice'] ?? '',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$exists = Db::name('order_shipping_targets')->where('order_id', $orderId)->find();
|
||||
if ($exists) {
|
||||
Db::name('order_shipping_targets')->where('order_id', $orderId)->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('order_shipping_targets')->insert($payload);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function getOrderTarget(int $orderId): ?array
|
||||
{
|
||||
$row = Db::name('order_shipping_targets')->where('order_id', $orderId)->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'warehouse_id' => (int)($row['warehouse_id'] ?? 0),
|
||||
'warehouse_name' => $row['warehouse_name'] ?? '',
|
||||
'warehouse_code' => $row['warehouse_code'] ?? '',
|
||||
'receiver_name' => $row['receiver_name'] ?? '',
|
||||
'receiver_mobile' => $row['receiver_mobile'] ?? '',
|
||||
'province' => $row['province'] ?? '',
|
||||
'city' => $row['city'] ?? '',
|
||||
'district' => $row['district'] ?? '',
|
||||
'detail_address' => $row['detail_address'] ?? '',
|
||||
'service_time' => $row['service_time'] ?? '',
|
||||
'notice' => $row['notice'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function optionsForOrder(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
|
||||
{
|
||||
$rows = Db::name('shipping_warehouses')
|
||||
->where('status', 'enabled')
|
||||
->where('service_provider', $serviceProvider)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (!$rows) {
|
||||
$rows = Db::name('shipping_warehouses')
|
||||
->where('status', 'enabled')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$list = array_map(fn(array $item) => $this->formatWarehouse($item), $rows);
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$item['match_score'] = $this->matchScore($item, $categoryId, $userAddress);
|
||||
$item['is_recommended'] = $item['match_score'] >= 300;
|
||||
$item['recommended_reason'] = $this->recommendedReason($item, $categoryId, $userAddress);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
usort($list, static function (array $left, array $right) {
|
||||
if ($left['match_score'] === $right['match_score']) {
|
||||
if ((int)$left['is_default'] === (int)$right['is_default']) {
|
||||
if ((int)$left['sort_order'] === (int)$right['sort_order']) {
|
||||
return (int)$left['id'] <=> (int)$right['id'];
|
||||
}
|
||||
return (int)$left['sort_order'] <=> (int)$right['sort_order'];
|
||||
}
|
||||
return (int)$right['is_default'] <=> (int)$left['is_default'];
|
||||
}
|
||||
|
||||
return (int)$right['match_score'] <=> (int)$left['match_score'];
|
||||
});
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function formatWarehouse(array $item): array
|
||||
{
|
||||
$supportedCategoryIds = $this->decodeIntArray($item['supported_category_ids_json'] ?? null);
|
||||
$serviceAreaProvinces = $this->decodeStringArray($item['service_area_provinces_json'] ?? null);
|
||||
$serviceAreaCities = $this->decodeStringArray($item['service_area_cities_json'] ?? null);
|
||||
|
||||
$categoryNames = [];
|
||||
if ($supportedCategoryIds) {
|
||||
$categoryNames = Db::name('catalog_categories')
|
||||
->whereIn('id', $supportedCategoryIds)
|
||||
->column('name');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'warehouse_name' => $item['warehouse_name'],
|
||||
'warehouse_code' => $item['warehouse_code'],
|
||||
'warehouse_type' => $item['warehouse_type'],
|
||||
'warehouse_type_text' => '检测中心 / 收货仓库',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'receiver_name' => $item['receiver_name'],
|
||||
'receiver_mobile' => $item['receiver_mobile'],
|
||||
'province' => $item['province'],
|
||||
'city' => $item['city'],
|
||||
'district' => $item['district'],
|
||||
'detail_address' => $item['detail_address'],
|
||||
'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])),
|
||||
'service_time' => $item['service_time'],
|
||||
'notice' => $item['notice'],
|
||||
'supported_category_ids' => $supportedCategoryIds,
|
||||
'supported_category_names' => array_values($categoryNames),
|
||||
'service_area_provinces' => $serviceAreaProvinces,
|
||||
'service_area_cities' => $serviceAreaCities,
|
||||
'status' => $item['status'],
|
||||
'status_text' => $item['status'] === 'enabled' ? '启用中' : '已停用',
|
||||
'is_default' => (bool)$item['is_default'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'remark' => $item['remark'] ?? '',
|
||||
'created_at' => $item['created_at'] ?? '',
|
||||
'updated_at' => $item['updated_at'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function validatePayload(array $data): void
|
||||
{
|
||||
foreach (['warehouse_name', 'receiver_name', 'receiver_mobile', 'province', 'city', 'district', 'detail_address', 'service_time'] as $field) {
|
||||
if (trim((string)($data[$field] ?? '')) === '') {
|
||||
throw new \RuntimeException('请完整填写仓库名称、收件信息与地址');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeIntArray(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(static function ($item) {
|
||||
$int = (int)$item;
|
||||
return $int > 0 ? $int : null;
|
||||
}, $value))));
|
||||
}
|
||||
|
||||
private function normalizeStringArray(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($value as $item) {
|
||||
$text = trim((string)$item);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = $text;
|
||||
}
|
||||
|
||||
return array_values(array_unique($items));
|
||||
}
|
||||
|
||||
private function decodeIntArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $this->normalizeIntArray($value);
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $this->normalizeIntArray($decoded) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function decodeStringArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $this->normalizeStringArray($value);
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $this->normalizeStringArray($decoded) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function generateWarehouseCode(string $serviceProvider): string
|
||||
{
|
||||
$prefix = $serviceProvider === 'zhongjian' ? 'ZJ' : 'AXY';
|
||||
return sprintf('%s-WH-%s', $prefix, date('YmdHis'));
|
||||
}
|
||||
|
||||
private function ensureWarehouseTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS shipping_warehouses (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
warehouse_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
warehouse_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'detection_center',
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
receiver_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
receiver_mobile VARCHAR(32) NOT NULL DEFAULT '',
|
||||
province VARCHAR(64) NOT NULL DEFAULT '',
|
||||
city VARCHAR(64) NOT NULL DEFAULT '',
|
||||
district VARCHAR(64) NOT NULL DEFAULT '',
|
||||
detail_address VARCHAR(255) NOT NULL DEFAULT '',
|
||||
service_time VARCHAR(128) NOT NULL DEFAULT '',
|
||||
notice VARCHAR(500) NOT NULL DEFAULT '',
|
||||
supported_category_ids_json JSON NULL,
|
||||
service_area_provinces_json JSON NULL,
|
||||
service_area_cities_json JSON NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shipping_warehouses_code (warehouse_code),
|
||||
KEY idx_shipping_warehouses_service_provider (service_provider),
|
||||
KEY idx_shipping_warehouses_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货仓库 / 检测中心';
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function ensureWarehouseRuleColumns(): void
|
||||
{
|
||||
$columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_provinces_json'");
|
||||
if (!$columns) {
|
||||
Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_provinces_json JSON NULL AFTER supported_category_ids_json");
|
||||
}
|
||||
|
||||
$columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_cities_json'");
|
||||
if (!$columns) {
|
||||
Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_cities_json JSON NULL AFTER service_area_provinces_json");
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureOrderTargetTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS order_shipping_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
warehouse_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||
warehouse_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
warehouse_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
receiver_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
receiver_mobile VARCHAR(32) NOT NULL DEFAULT '',
|
||||
province VARCHAR(64) NOT NULL DEFAULT '',
|
||||
city VARCHAR(64) NOT NULL DEFAULT '',
|
||||
district VARCHAR(64) NOT NULL DEFAULT '',
|
||||
detail_address VARCHAR(255) NOT NULL DEFAULT '',
|
||||
service_time VARCHAR(128) NOT NULL DEFAULT '',
|
||||
notice VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_shipping_targets_order_id (order_id),
|
||||
KEY idx_order_shipping_targets_warehouse_id (warehouse_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单锁定仓库快照'
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function bootstrapDefaults(): void
|
||||
{
|
||||
$count = (int)Db::name('shipping_warehouses')->count();
|
||||
if ($count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('shipping_warehouses')->insertAll([
|
||||
[
|
||||
'warehouse_name' => '安心验鉴定中心',
|
||||
'warehouse_code' => 'AXY-WH-DEFAULT',
|
||||
'warehouse_type' => 'detection_center',
|
||||
'service_provider' => 'anxinyan',
|
||||
'receiver_name' => '安心验鉴定中心',
|
||||
'receiver_mobile' => '400-800-1314',
|
||||
'province' => '广东省',
|
||||
'city' => '深圳市',
|
||||
'district' => '南山区',
|
||||
'detail_address' => '科技园鉴定路 88 号 安心验收件中心',
|
||||
'service_time' => '周一至周日 09:30-18:30',
|
||||
'notice' => '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。',
|
||||
'supported_category_ids_json' => null,
|
||||
'service_area_provinces_json' => null,
|
||||
'service_area_cities_json' => null,
|
||||
'status' => 'enabled',
|
||||
'is_default' => 1,
|
||||
'sort_order' => 1,
|
||||
'remark' => '默认仓库',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'warehouse_name' => '中检合作鉴定中心',
|
||||
'warehouse_code' => 'ZJ-WH-DEFAULT',
|
||||
'warehouse_type' => 'detection_center',
|
||||
'service_provider' => 'zhongjian',
|
||||
'receiver_name' => '中检合作鉴定中心',
|
||||
'receiver_mobile' => '400-800-1314',
|
||||
'province' => '广东省',
|
||||
'city' => '深圳市',
|
||||
'district' => '南山区',
|
||||
'detail_address' => '科技园鉴定路 88 号 安心验中检收件中心',
|
||||
'service_time' => '周一至周日 09:30-18:30',
|
||||
'notice' => '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。',
|
||||
'supported_category_ids_json' => null,
|
||||
'service_area_provinces_json' => null,
|
||||
'service_area_cities_json' => null,
|
||||
'status' => 'enabled',
|
||||
'is_default' => 1,
|
||||
'sort_order' => 1,
|
||||
'remark' => '默认仓库',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function matchScore(array $warehouse, ?int $categoryId, ?array $userAddress): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) {
|
||||
$score += 200;
|
||||
}
|
||||
|
||||
if ($userAddress) {
|
||||
$province = trim((string)($userAddress['province'] ?? ''));
|
||||
$city = trim((string)($userAddress['city'] ?? ''));
|
||||
|
||||
if ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) {
|
||||
$score += 120;
|
||||
}
|
||||
|
||||
if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) {
|
||||
$score += 180;
|
||||
}
|
||||
}
|
||||
|
||||
if ($warehouse['is_default']) {
|
||||
$score += 40;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
private function recommendedReason(array $warehouse, ?int $categoryId, ?array $userAddress): string
|
||||
{
|
||||
$reasons = [];
|
||||
|
||||
if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) {
|
||||
$reasons[] = '匹配当前品类';
|
||||
}
|
||||
|
||||
if ($userAddress) {
|
||||
$province = trim((string)($userAddress['province'] ?? ''));
|
||||
$city = trim((string)($userAddress['city'] ?? ''));
|
||||
|
||||
if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) {
|
||||
$reasons[] = '匹配当前城市';
|
||||
} elseif ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) {
|
||||
$reasons[] = '匹配当前省份';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reasons && $warehouse['is_default']) {
|
||||
$reasons[] = '默认仓库';
|
||||
}
|
||||
|
||||
return implode(' / ', $reasons);
|
||||
}
|
||||
}
|
||||
14
server-api/app/view/index/view.html
Normal file
14
server-api/app/view/index/view.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="/favicon.ico"/>
|
||||
<title>webman</title>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
hello <?=htmlspecialchars($name)?>
|
||||
</body>
|
||||
</html>
|
||||
67
server-api/composer.json
Normal file
67
server-api/composer.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"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",
|
||||
"webman/think-orm": "^2.1",
|
||||
"webman/redis-queue": "^2.1",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"alibabacloud/dysmsapi-20170525": "^4.3",
|
||||
"aliyuncs/oss-sdk-php": "^2.7",
|
||||
"qiniu/php-sdk": "^7.14"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"support\\Setup::run"
|
||||
],
|
||||
"setup-webman": [
|
||||
"support\\Setup::run"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
2069
server-api/composer.lock
generated
Normal file
2069
server-api/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server-api/config/app.php
Normal file
26
server-api/config/app.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Request;
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
'error_reporting' => E_ALL,
|
||||
'default_timezone' => 'Asia/Shanghai',
|
||||
'request_class' => Request::class,
|
||||
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
|
||||
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
];
|
||||
21
server-api/config/autoload.php
Normal file
21
server-api/config/autoload.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'files' => [
|
||||
base_path() . '/app/functions.php',
|
||||
base_path() . '/support/Request.php',
|
||||
base_path() . '/support/Response.php',
|
||||
]
|
||||
];
|
||||
19
server-api/config/bootstrap.php
Normal file
19
server-api/config/bootstrap.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
app\bootstrap\Dotenv::class,
|
||||
support\bootstrap\Session::class,
|
||||
Webman\ThinkOrm\ThinkOrm::class,
|
||||
];
|
||||
15
server-api/config/container.php
Normal file
15
server-api/config/container.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return new Webman\Container;
|
||||
15
server-api/config/dependence.php
Normal file
15
server-api/config/dependence.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [];
|
||||
17
server-api/config/exception.php
Normal file
17
server-api/config/exception.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'' => support\exception\Handler::class,
|
||||
];
|
||||
32
server-api/config/log.php
Normal file
32
server-api/config/log.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/webman.log',
|
||||
7, //$maxFiles
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
21
server-api/config/middleware.php
Normal file
21
server-api/config/middleware.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'' => [
|
||||
app\middleware\CorsMiddleware::class,
|
||||
app\middleware\AppAuthMiddleware::class,
|
||||
app\middleware\AdminAuthMiddleware::class,
|
||||
],
|
||||
];
|
||||
4
server-api/config/plugin/webman/redis-queue/app.php
Normal file
4
server-api/config/plugin/webman/redis-queue/app.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
return [
|
||||
'enable' => true,
|
||||
];
|
||||
7
server-api/config/plugin/webman/redis-queue/command.php
Normal file
7
server-api/config/plugin/webman/redis-queue/command.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Webman\RedisQueue\Command\MakeConsumerCommand;
|
||||
|
||||
return [
|
||||
MakeConsumerCommand::class
|
||||
];
|
||||
32
server-api/config/plugin/webman/redis-queue/log.php
Normal file
32
server-api/config/plugin/webman/redis-queue/log.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/redis-queue/queue.log',
|
||||
7, //$maxFiles
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
]
|
||||
],
|
||||
]
|
||||
];
|
||||
11
server-api/config/plugin/webman/redis-queue/process.php
Normal file
11
server-api/config/plugin/webman/redis-queue/process.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
return [
|
||||
'consumer' => [
|
||||
'handler' => Webman\RedisQueue\Process\Consumer::class,
|
||||
'count' => 8, // 可以设置多进程同时消费
|
||||
'constructor' => [
|
||||
// 消费者类目录
|
||||
'consumer_dir' => app_path() . '/queue/redis'
|
||||
]
|
||||
]
|
||||
];
|
||||
25
server-api/config/plugin/webman/redis-queue/redis.php
Normal file
25
server-api/config/plugin/webman/redis-queue/redis.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
return [
|
||||
'default' => [
|
||||
'host' => sprintf(
|
||||
'redis://%s:%s',
|
||||
$_ENV['REDIS_HOST'] ?? '127.0.0.1',
|
||||
$_ENV['REDIS_PORT'] ?? '6379'
|
||||
),
|
||||
'options' => [
|
||||
'auth' => $_ENV['REDIS_PASSWORD'] ?? null,
|
||||
'db' => (int)($_ENV['REDIS_DB'] ?? 0),
|
||||
'prefix' => $_ENV['REDIS_PREFIX'] ?? '',
|
||||
'max_attempts' => 5,
|
||||
'retry_seconds' => 5,
|
||||
],
|
||||
// Connection pool, supports only Swoole or Swow drivers.
|
||||
'pool' => [
|
||||
'max_connections' => 5,
|
||||
'min_connections' => 1,
|
||||
'wait_timeout' => 3,
|
||||
'idle_timeout' => 60,
|
||||
'heartbeat_interval' => 50,
|
||||
]
|
||||
],
|
||||
];
|
||||
62
server-api/config/process.php
Normal file
62
server-api/config/process.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
use app\process\Http;
|
||||
|
||||
global $argv;
|
||||
|
||||
return [
|
||||
'webman' => [
|
||||
'handler' => Http::class,
|
||||
'listen' => 'http://0.0.0.0:8787',
|
||||
'count' => cpu_count() * 4,
|
||||
'user' => '',
|
||||
'group' => '',
|
||||
'reusePort' => false,
|
||||
'eventLoop' => '',
|
||||
'context' => [],
|
||||
'constructor' => [
|
||||
'requestClass' => Request::class,
|
||||
'logger' => Log::channel('default'),
|
||||
'appPath' => app_path(),
|
||||
'publicPath' => public_path()
|
||||
]
|
||||
],
|
||||
// File update detection and automatic reload
|
||||
'monitor' => [
|
||||
'handler' => app\process\Monitor::class,
|
||||
'reloadable' => false,
|
||||
'constructor' => [
|
||||
// Monitor these directories
|
||||
'monitorDir' => array_merge([
|
||||
app_path(),
|
||||
config_path(),
|
||||
base_path() . '/process',
|
||||
base_path() . '/support',
|
||||
base_path() . '/resource',
|
||||
base_path() . '/.env',
|
||||
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
|
||||
// Files with these suffixes will be monitored
|
||||
'monitorExtensions' => [
|
||||
'php', 'html', 'htm', 'env'
|
||||
],
|
||||
'options' => [
|
||||
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
|
||||
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
275
server-api/config/route.php
Normal file
275
server-api/config/route.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use app\controller\app\HomeController;
|
||||
use app\controller\app\CatalogController;
|
||||
use app\controller\app\AppraisalController;
|
||||
use app\controller\app\OrdersController;
|
||||
use app\controller\app\ReportsController;
|
||||
use app\controller\app\VerifyController;
|
||||
use app\controller\app\MaterialTagsController as AppMaterialTagsController;
|
||||
use app\controller\app\MessagesController as AppMessagesController;
|
||||
use app\controller\app\SupplementController as AppSupplementController;
|
||||
use app\controller\app\TicketsController as AppTicketsController;
|
||||
use app\controller\app\ShippingController as AppShippingController;
|
||||
use app\controller\app\AddressesController as AppAddressesController;
|
||||
use app\controller\app\HelpCenterController as AppHelpCenterController;
|
||||
use app\controller\app\SettingsController as AppSettingsController;
|
||||
use app\controller\app\AuthController as AppAuthController;
|
||||
use app\controller\app\MineController as AppMineController;
|
||||
use app\controller\admin\DashboardController as AdminDashboardController;
|
||||
use app\controller\admin\OrdersController as AdminOrdersController;
|
||||
use app\controller\admin\CatalogController as AdminCatalogController;
|
||||
use app\controller\admin\ReportsController as AdminReportsController;
|
||||
use app\controller\admin\AppraisalTasksController as AdminAppraisalTasksController;
|
||||
use app\controller\admin\MessagesController as AdminMessagesController;
|
||||
use app\controller\admin\TicketsController as AdminTicketsController;
|
||||
use app\controller\admin\UsersController as AdminUsersController;
|
||||
use app\controller\admin\WarehousesController as AdminWarehousesController;
|
||||
use app\controller\admin\MaterialsController as AdminMaterialsController;
|
||||
use app\controller\admin\AccessController as AdminAccessController;
|
||||
use app\controller\admin\ContentsController as AdminContentsController;
|
||||
use app\controller\admin\SystemConfigsController as AdminSystemConfigsController;
|
||||
use app\controller\admin\AuthController as AdminAuthController;
|
||||
use app\controller\admin\CustomersController as AdminCustomersController;
|
||||
use app\controller\open\OrdersController as OpenOrdersController;
|
||||
|
||||
Route::get('/', [app\controller\IndexController::class, 'json']);
|
||||
Route::options('/api/app/appraisal/draft/create', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/appraisal/draft/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/appraisal/file/upload', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/appraisal/file/delete', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/appraisal/preview', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/appraisal/submit', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/message/read', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/messages/read-all', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/supplement/file/upload', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/supplement/file/delete', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/supplement/submit', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/ticket/create', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/ticket/reply', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/ticket/file/upload', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/ticket/file/delete', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/shipping/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/return-address/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/address/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/address/default', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/address/delete', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/settings/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/{path:.+}', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/auth/{path:.+}', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/admin/{path:.+}', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/open/v1/{path:.+}', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::get('/api/app/home/index', [HomeController::class, 'index']);
|
||||
Route::get('/api/app/content/page-visuals', [HomeController::class, 'pageVisuals']);
|
||||
Route::get('/api/app/catalog/categories', [CatalogController::class, 'categories']);
|
||||
Route::get('/api/app/catalog/brands', [CatalogController::class, 'brands']);
|
||||
Route::post('/api/app/appraisal/draft/create', [AppraisalController::class, 'createDraft']);
|
||||
Route::get('/api/app/appraisal/draft', [AppraisalController::class, 'draftDetail']);
|
||||
Route::post('/api/app/appraisal/draft/save', [AppraisalController::class, 'saveDraft']);
|
||||
Route::post('/api/app/appraisal/file/upload', [AppraisalController::class, 'uploadFile']);
|
||||
Route::post('/api/app/appraisal/file/delete', [AppraisalController::class, 'deleteFile']);
|
||||
Route::get('/api/app/appraisal/upload-template', [AppraisalController::class, 'uploadTemplate']);
|
||||
Route::post('/api/app/appraisal/preview', [AppraisalController::class, 'preview']);
|
||||
Route::post('/api/app/appraisal/submit', [AppraisalController::class, 'submit']);
|
||||
Route::get('/api/app/orders', [OrdersController::class, 'index']);
|
||||
Route::get('/api/app/order/detail', [OrdersController::class, 'detail']);
|
||||
Route::post('/api/app/order/return-address/save', [OrdersController::class, 'saveReturnAddress']);
|
||||
Route::get('/api/app/reports', [ReportsController::class, 'index']);
|
||||
Route::get('/api/app/report/detail', [ReportsController::class, 'detail']);
|
||||
Route::get('/api/app/verify', [VerifyController::class, 'show']);
|
||||
Route::get('/api/app/material-tag', [AppMaterialTagsController::class, 'show']);
|
||||
Route::post('/api/app/material-tag/verify', [AppMaterialTagsController::class, 'verify']);
|
||||
Route::get('/api/app/help-center', [AppHelpCenterController::class, 'index']);
|
||||
Route::get('/api/app/help-article/detail', [AppHelpCenterController::class, 'detail']);
|
||||
Route::post('/api/app/auth/send-code', [AppAuthController::class, 'sendCode']);
|
||||
Route::post('/api/app/auth/login/code', [AppAuthController::class, 'loginByCode']);
|
||||
Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByPassword']);
|
||||
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
||||
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
|
||||
Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']);
|
||||
Route::get('/api/app/mine/overview', [AppMineController::class, 'overview']);
|
||||
Route::get('/api/app/settings', [AppSettingsController::class, 'detail']);
|
||||
Route::post('/api/app/settings/save', [AppSettingsController::class, 'save']);
|
||||
Route::get('/api/app/messages/summary', [AppMessagesController::class, 'summary']);
|
||||
Route::get('/api/app/messages/meta', [AppMessagesController::class, 'meta']);
|
||||
Route::get('/api/app/messages', [AppMessagesController::class, 'index']);
|
||||
Route::post('/api/app/message/read', [AppMessagesController::class, 'read']);
|
||||
Route::post('/api/app/messages/read-all', [AppMessagesController::class, 'readAll']);
|
||||
Route::get('/api/app/order/supplement', [AppSupplementController::class, 'detail']);
|
||||
Route::post('/api/app/order/supplement/file/upload', [AppSupplementController::class, 'uploadFile']);
|
||||
Route::post('/api/app/order/supplement/file/delete', [AppSupplementController::class, 'deleteFile']);
|
||||
Route::post('/api/app/order/supplement/submit', [AppSupplementController::class, 'submit']);
|
||||
Route::get('/api/app/tickets/overview', [AppTicketsController::class, 'overview']);
|
||||
Route::get('/api/app/ticket/meta', [AppTicketsController::class, 'meta']);
|
||||
Route::get('/api/app/tickets', [AppTicketsController::class, 'index']);
|
||||
Route::get('/api/app/ticket/detail', [AppTicketsController::class, 'detail']);
|
||||
Route::post('/api/app/ticket/create', [AppTicketsController::class, 'create']);
|
||||
Route::post('/api/app/ticket/reply', [AppTicketsController::class, 'reply']);
|
||||
Route::post('/api/app/ticket/file/upload', [AppTicketsController::class, 'uploadFile']);
|
||||
Route::post('/api/app/ticket/file/delete', [AppTicketsController::class, 'deleteFile']);
|
||||
Route::get('/api/app/order/shipping', [AppShippingController::class, 'detail']);
|
||||
Route::post('/api/app/order/shipping/save', [AppShippingController::class, 'save']);
|
||||
Route::get('/api/app/addresses', [AppAddressesController::class, 'index']);
|
||||
Route::get('/api/app/address/detail', [AppAddressesController::class, 'detail']);
|
||||
Route::post('/api/app/address/save', [AppAddressesController::class, 'save']);
|
||||
Route::post('/api/app/address/default', [AppAddressesController::class, 'setDefault']);
|
||||
Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']);
|
||||
|
||||
Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
|
||||
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
|
||||
Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']);
|
||||
|
||||
Route::get('/api/admin/ping', function () {
|
||||
return api_success(['pong' => true]);
|
||||
});
|
||||
Route::post('/api/admin/auth/login', [AdminAuthController::class, 'login']);
|
||||
Route::get('/api/admin/auth/me', [AdminAuthController::class, 'me']);
|
||||
Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']);
|
||||
Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']);
|
||||
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
||||
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
||||
Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']);
|
||||
Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']);
|
||||
Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']);
|
||||
Route::post('/api/admin/order/return-logistics/save', [AdminOrdersController::class, 'saveReturnLogistics']);
|
||||
Route::post('/api/admin/order/return-logistics/receive', [AdminOrdersController::class, 'receiveReturnLogistics']);
|
||||
Route::get('/api/admin/catalog/overview', [AdminCatalogController::class, 'overview']);
|
||||
Route::get('/api/admin/catalog/categories', [AdminCatalogController::class, 'categories']);
|
||||
Route::get('/api/admin/catalog/brands', [AdminCatalogController::class, 'brands']);
|
||||
Route::get('/api/admin/catalog/upload-templates', [AdminCatalogController::class, 'uploadTemplates']);
|
||||
Route::get('/api/admin/catalog/appraisal-templates', [AdminCatalogController::class, 'appraisalTemplates']);
|
||||
Route::post('/api/admin/catalog/upload-template/sample-image/upload', [AdminCatalogController::class, 'uploadTemplateSampleImage']);
|
||||
Route::post('/api/admin/catalog/upload-template/sample-image/delete', [AdminCatalogController::class, 'deleteUploadTemplateSampleImage']);
|
||||
Route::post('/api/admin/catalog/category/save', [AdminCatalogController::class, 'saveCategory']);
|
||||
Route::post('/api/admin/catalog/brand/save', [AdminCatalogController::class, 'saveBrand']);
|
||||
Route::post('/api/admin/catalog/upload-templates/save', [AdminCatalogController::class, 'saveUploadTemplates']);
|
||||
Route::post('/api/admin/catalog/appraisal-templates/save', [AdminCatalogController::class, 'saveAppraisalTemplates']);
|
||||
Route::get('/api/admin/reports', [AdminReportsController::class, 'index']);
|
||||
Route::get('/api/admin/report/detail', [AdminReportsController::class, 'detail']);
|
||||
Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']);
|
||||
Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']);
|
||||
Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']);
|
||||
Route::get('/api/admin/appraisal-task/detail', [AdminAppraisalTasksController::class, 'detail']);
|
||||
Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksController::class, 'assignableAdmins']);
|
||||
Route::post('/api/admin/appraisal-task/assign', [AdminAppraisalTasksController::class, 'assign']);
|
||||
Route::post('/api/admin/appraisal-task/save-result', [AdminAppraisalTasksController::class, 'saveResult']);
|
||||
Route::post('/api/admin/appraisal-task/material-tag/bind', [AdminAppraisalTasksController::class, 'bindMaterialTag']);
|
||||
Route::post('/api/admin/appraisal-task/request-supplement', [AdminAppraisalTasksController::class, 'requestSupplement']);
|
||||
Route::post('/api/admin/appraisal-task/evidence/upload', [AdminAppraisalTasksController::class, 'uploadEvidenceFile']);
|
||||
Route::post('/api/admin/appraisal-task/evidence/delete', [AdminAppraisalTasksController::class, 'deleteEvidenceFile']);
|
||||
Route::get('/api/admin/messages/overview', [AdminMessagesController::class, 'overview']);
|
||||
Route::get('/api/admin/messages/templates', [AdminMessagesController::class, 'templates']);
|
||||
Route::get('/api/admin/messages/logs', [AdminMessagesController::class, 'logs']);
|
||||
Route::post('/api/admin/messages/template/save', [AdminMessagesController::class, 'saveTemplate']);
|
||||
Route::get('/api/admin/tickets/overview', [AdminTicketsController::class, 'overview']);
|
||||
Route::get('/api/admin/tickets', [AdminTicketsController::class, 'index']);
|
||||
Route::get('/api/admin/ticket/detail', [AdminTicketsController::class, 'detail']);
|
||||
Route::post('/api/admin/ticket/save', [AdminTicketsController::class, 'save']);
|
||||
Route::post('/api/admin/ticket/reply', [AdminTicketsController::class, 'reply']);
|
||||
Route::post('/api/admin/ticket/file/upload', [AdminTicketsController::class, 'uploadFile']);
|
||||
Route::post('/api/admin/ticket/file/delete', [AdminTicketsController::class, 'deleteFile']);
|
||||
Route::get('/api/admin/users/overview', [AdminUsersController::class, 'overview']);
|
||||
Route::get('/api/admin/users', [AdminUsersController::class, 'index']);
|
||||
Route::get('/api/admin/user/detail', [AdminUsersController::class, 'detail']);
|
||||
Route::post('/api/admin/user/save', [AdminUsersController::class, 'save']);
|
||||
Route::get('/api/admin/customers', [AdminCustomersController::class, 'index']);
|
||||
Route::get('/api/admin/customer/detail', [AdminCustomersController::class, 'detail']);
|
||||
Route::post('/api/admin/customer/save', [AdminCustomersController::class, 'save']);
|
||||
Route::post('/api/admin/customer/app/create', [AdminCustomersController::class, 'createApp']);
|
||||
Route::post('/api/admin/customer/app/status', [AdminCustomersController::class, 'updateAppStatus']);
|
||||
Route::post('/api/admin/customer/app/reset-secret', [AdminCustomersController::class, 'resetAppSecret']);
|
||||
Route::get('/api/admin/customer/orders', [AdminCustomersController::class, 'orders']);
|
||||
Route::get('/api/admin/customer/order/progress', [AdminCustomersController::class, 'orderProgress']);
|
||||
Route::get('/api/admin/customer/events', [AdminCustomersController::class, 'events']);
|
||||
Route::get('/api/admin/customer/deliveries', [AdminCustomersController::class, 'deliveries']);
|
||||
Route::post('/api/admin/customer/event/resend', [AdminCustomersController::class, 'resendEvent']);
|
||||
Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']);
|
||||
Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
|
||||
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
||||
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
||||
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
||||
Route::post('/api/admin/material/batch/create', [AdminMaterialsController::class, 'create']);
|
||||
Route::get('/api/admin/material/batch/download', [AdminMaterialsController::class, 'download']);
|
||||
Route::get('/api/admin/access/overview', [AdminAccessController::class, 'overview']);
|
||||
Route::get('/api/admin/access/admins', [AdminAccessController::class, 'admins']);
|
||||
Route::get('/api/admin/access/roles', [AdminAccessController::class, 'roles']);
|
||||
Route::get('/api/admin/access/permissions', [AdminAccessController::class, 'permissions']);
|
||||
Route::post('/api/admin/access/admin/save', [AdminAccessController::class, 'saveAdmin']);
|
||||
Route::post('/api/admin/access/role/save', [AdminAccessController::class, 'saveRole']);
|
||||
Route::get('/api/admin/content/bootstrap', [AdminContentsController::class, 'bootstrap']);
|
||||
Route::get('/api/admin/content/home', [AdminContentsController::class, 'home']);
|
||||
Route::post('/api/admin/content/image/upload', [AdminContentsController::class, 'uploadImage']);
|
||||
Route::post('/api/admin/content/home/save', [AdminContentsController::class, 'saveHome']);
|
||||
Route::get('/api/admin/content/policy', [AdminContentsController::class, 'policy']);
|
||||
Route::post('/api/admin/content/policy/save', [AdminContentsController::class, 'savePolicy']);
|
||||
Route::get('/api/admin/content/meta', [AdminContentsController::class, 'meta']);
|
||||
Route::post('/api/admin/content/meta/save', [AdminContentsController::class, 'saveMeta']);
|
||||
Route::get('/api/admin/content/help/articles', [AdminContentsController::class, 'helpArticles']);
|
||||
Route::post('/api/admin/content/help/article/save', [AdminContentsController::class, 'saveHelpArticle']);
|
||||
Route::post('/api/admin/content/help/article/delete', [AdminContentsController::class, 'deleteHelpArticle']);
|
||||
Route::get('/api/admin/system-configs', [AdminSystemConfigsController::class, 'index']);
|
||||
Route::post('/api/admin/system-configs/upload-file', [AdminSystemConfigsController::class, 'uploadFile']);
|
||||
Route::post('/api/admin/system-configs/save', [AdminSystemConfigsController::class, 'save']);
|
||||
23
server-api/config/server.php
Normal file
23
server-api/config/server.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
return [
|
||||
'event_loop' => '',
|
||||
'stop_timeout' => 2,
|
||||
'pid_file' => runtime_path() . '/webman.pid',
|
||||
'status_file' => runtime_path() . '/webman.status',
|
||||
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
||||
'log_file' => runtime_path() . '/logs/workerman.log',
|
||||
'max_package_size' => 10 * 1024 * 1024
|
||||
];
|
||||
65
server-api/config/session.php
Normal file
65
server-api/config/session.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Session\FileSessionHandler;
|
||||
use Webman\Session\RedisSessionHandler;
|
||||
use Webman\Session\RedisClusterSessionHandler;
|
||||
|
||||
return [
|
||||
|
||||
'type' => 'file', // or redis or redis_cluster
|
||||
|
||||
'handler' => FileSessionHandler::class,
|
||||
|
||||
'config' => [
|
||||
'file' => [
|
||||
'save_path' => runtime_path() . '/sessions',
|
||||
],
|
||||
'redis' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
'auth' => '',
|
||||
'timeout' => 2,
|
||||
'database' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
],
|
||||
'redis_cluster' => [
|
||||
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
|
||||
'timeout' => 2,
|
||||
'auth' => '',
|
||||
'prefix' => 'redis_session_',
|
||||
]
|
||||
],
|
||||
|
||||
'session_name' => 'PHPSID',
|
||||
|
||||
'auto_update_timestamp' => false,
|
||||
|
||||
'lifetime' => 7*24*60*60,
|
||||
|
||||
'cookie_lifetime' => 365*24*60*60,
|
||||
|
||||
'cookie_path' => '/',
|
||||
|
||||
'domain' => '',
|
||||
|
||||
'http_only' => true,
|
||||
|
||||
'secure' => false,
|
||||
|
||||
'same_site' => '',
|
||||
|
||||
'gc_probability' => [1, 1000],
|
||||
|
||||
];
|
||||
23
server-api/config/static.php
Normal file
23
server-api/config/static.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static file settings
|
||||
*/
|
||||
return [
|
||||
'enable' => true,
|
||||
'middleware' => [ // Static file Middleware
|
||||
//app\middleware\StaticFile::class,
|
||||
],
|
||||
];
|
||||
42
server-api/config/think-orm.php
Normal file
42
server-api/config/think-orm.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => 'mysql',
|
||||
'connections' => [
|
||||
'mysql' => [
|
||||
// 数据库类型
|
||||
'type' => 'mysql',
|
||||
// 服务器地址
|
||||
'hostname' => $_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||
// 数据库名
|
||||
'database' => $_ENV['DB_DATABASE'] ?? 'test',
|
||||
// 数据库用户名
|
||||
'username' => $_ENV['DB_USERNAME'] ?? 'root',
|
||||
// 数据库密码
|
||||
'password' => $_ENV['DB_PASSWORD'] ?? '',
|
||||
// 数据库连接端口
|
||||
'hostport' => $_ENV['DB_PORT'] ?? '3306',
|
||||
// 数据库连接参数
|
||||
'params' => [
|
||||
// 连接超时3秒
|
||||
\PDO::ATTR_TIMEOUT => 3,
|
||||
],
|
||||
// 数据库编码默认采用utf8
|
||||
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
|
||||
// 数据库表前缀
|
||||
'prefix' => $_ENV['DB_PREFIX'] ?? '',
|
||||
// 断线重连
|
||||
'break_reconnect' => true,
|
||||
// 连接池配置
|
||||
'pool' => [
|
||||
'max_connections' => 5, // 最大连接数
|
||||
'min_connections' => 1, // 最小连接数
|
||||
'wait_timeout' => 3, // 从连接池获取连接等待超时时间
|
||||
'idle_timeout' => 60, // 连接最大空闲时间,超过该时间会被回收
|
||||
'heartbeat_interval' => 50, // 心跳检测间隔,需要小于60秒
|
||||
],
|
||||
],
|
||||
],
|
||||
// 自定义分页类
|
||||
'paginator' => '',
|
||||
];
|
||||
25
server-api/config/translation.php
Normal file
25
server-api/config/translation.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
/**
|
||||
* Multilingual configuration
|
||||
*/
|
||||
return [
|
||||
// Default language
|
||||
'locale' => 'zh_CN',
|
||||
// Fallback language
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
// Folder where language files are stored
|
||||
'path' => base_path() . '/resource/translations',
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user