This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

17
server-api/.env.example Normal file
View 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
View File

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

18
server-api/Dockerfile Normal file
View 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
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
server-api/README.md Normal file
View File

@@ -0,0 +1,70 @@
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
<h1>webman</h1>
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
<h1>学习</h1>
<ul>
<li>
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
</li>
<li>
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
</li>
<li>
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
</li>
<li>
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
</li>
<li>
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
</li>
</ul>
<div style="float:left;padding-bottom:30px;">
<h1>赞助商</h1>
<h4>特别赞助</h4>
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
</a>
<h4>铂金赞助</h4>
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
</div>
<div style="float:left;padding-bottom:30px;clear:both">
<h1>请作者喝咖啡</h1>
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
<br>
<b>如果您觉得webman对您有所帮助欢迎捐赠。</b>
</div>
<div style="clear: both">
<h1>LICENSE</h1>
The webman is open-sourced software licensed under the MIT.
</div>
</div>

View File

@@ -0,0 +1,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;
}
}

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

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

File diff suppressed because it is too large Load Diff

View 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([], '已退出登录');
}
}

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

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

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

View 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' => '已登记回寄运单,等待用户签收',
],
],
]);
}
}

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

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

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

View 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' ? '中检合作机构' : '安心验';
}
}

View 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/uploadsOSS 模式写入阿里云对象存储。',
'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('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
}
}
}

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

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

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

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

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

View 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([], '已退出登录');
}
}

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

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

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

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

View 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 => '全部',
};
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class StaticFile implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
// Access to files beginning with. Is prohibited
if (strpos($request->path(), '/.') !== false) {
return response('<h1>403 forbidden</h1>', 403);
}
/** @var Response $response */
$response = $handler($request);
// Add cross domain HTTP header
/*$response->withHeaders([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
]);*/
return $response;
}
}

View File

@@ -0,0 +1,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';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class CatalogCategory extends BaseModel
{
protected $table = 'catalog_categories';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class Order extends BaseModel
{
protected $table = 'orders';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class OrderProduct extends BaseModel
{
protected $table = 'order_products';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class OrderSupplementTask extends BaseModel
{
protected $table = 'order_supplement_tasks';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class OrderSupplementTaskItem extends BaseModel
{
protected $table = 'order_supplement_task_items';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace app\model;
class OrderTimeline extends BaseModel
{
protected $table = 'order_timelines';
protected $updateTime = null;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class Report extends BaseModel
{
protected $table = 'reports';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class ReportContent extends BaseModel
{
protected $table = 'report_contents';
}

View File

@@ -0,0 +1,8 @@
<?php
namespace app\model;
class ReportVerify extends BaseModel
{
protected $table = 'report_verifies';
}

View File

@@ -0,0 +1,29 @@
<?php
namespace app\model;
use support\Model;
class Test extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'test';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
}

View File

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

View File

@@ -0,0 +1,305 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\process;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Workerman\Timer;
use Workerman\Worker;
/**
* Class FileMonitor
* @package process
*/
class Monitor
{
/**
* @var array
*/
protected array $paths = [];
/**
* @var array
*/
protected array $extensions = [];
/**
* @var array
*/
protected array $loadedFiles = [];
/**
* @var int
*/
protected int $ppid = 0;
/**
* Pause monitor
* @return void
*/
public static function pause(): void
{
file_put_contents(static::lockFile(), time());
}
/**
* Resume monitor
* @return void
*/
public static function resume(): void
{
clearstatcache();
if (is_file(static::lockFile())) {
unlink(static::lockFile());
}
}
/**
* Whether monitor is paused
* @return bool
*/
public static function isPaused(): bool
{
clearstatcache();
return file_exists(static::lockFile());
}
/**
* Lock file
* @return string
*/
protected static function lockFile(): string
{
return runtime_path('monitor.lock');
}
/**
* FileMonitor constructor.
* @param $monitorDir
* @param $monitorExtensions
* @param array $options
*/
public function __construct($monitorDir, $monitorExtensions, array $options = [])
{
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
static::resume();
$this->paths = (array)$monitorDir;
$this->extensions = $monitorExtensions;
foreach (get_included_files() as $index => $file) {
$this->loadedFiles[$file] = $index;
if (strpos($file, 'webman-framework/src/support/App.php')) {
break;
}
}
if (!Worker::getAllWorkers()) {
return;
}
$disableFunctions = explode(',', ini_get('disable_functions'));
if (in_array('exec', $disableFunctions, true)) {
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
} else {
if ($options['enable_file_monitor'] ?? true) {
Timer::add(1, function () {
$this->checkAllFilesChange();
});
}
}
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
}
}
/**
* @param $monitorDir
* @return bool
*/
public function checkFilesChange($monitorDir): bool
{
static $lastMtime, $tooManyFilesCheck;
if (!$lastMtime) {
$lastMtime = time();
}
clearstatcache();
if (!is_dir($monitorDir)) {
if (!is_file($monitorDir)) {
return false;
}
$iterator = [new SplFileInfo($monitorDir)];
} else {
// recursive traversal directory
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
$iterator = new RecursiveIteratorIterator($dirIterator);
}
$count = 0;
foreach ($iterator as $file) {
$count ++;
/** @var SplFileInfo $file */
if (is_dir($file->getRealPath())) {
continue;
}
// check mtime
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
$lastMtime = $file->getMTime();
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
continue;
}
$var = 0;
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
if ($var) {
continue;
}
// send SIGUSR1 signal to master process for reload
if (DIRECTORY_SEPARATOR === '/') {
if ($masterPid = $this->getMasterPid()) {
echo $file . " updated and reload\n";
posix_kill($masterPid, SIGUSR1);
} else {
echo "Master process has gone away and can not reload\n";
}
return true;
}
echo $file . " updated and reload\n";
return true;
}
}
if (!$tooManyFilesCheck && $count > 1000) {
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
$tooManyFilesCheck = 1;
}
return false;
}
/**
* @return int
*/
public function getMasterPid(): int
{
if ($this->ppid === 0) {
return 0;
}
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
echo "Master process has gone away\n";
return $this->ppid = 0;
}
if (PHP_OS_FAMILY !== 'Linux') {
return $this->ppid;
}
$cmdline = "/proc/$this->ppid/cmdline";
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
// Process not exist
$this->ppid = 0;
}
return $this->ppid;
}
/**
* @return bool
*/
public function checkAllFilesChange(): bool
{
if (static::isPaused()) {
return false;
}
foreach ($this->paths as $path) {
if ($this->checkFilesChange($path)) {
return true;
}
}
return false;
}
/**
* @param $memoryLimit
* @return void
*/
public function checkMemory($memoryLimit): void
{
if (static::isPaused() || $memoryLimit <= 0) {
return;
}
$masterPid = $this->getMasterPid();
if ($masterPid <= 0) {
echo "Master process has gone away\n";
return;
}
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
return;
}
foreach (explode(' ', $children) as $pid) {
$pid = (int)$pid;
$statusFile = "/proc/$pid/status";
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
continue;
}
$mem = 0;
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
$mem = $match[1];
}
$mem = (int)($mem / 1024);
if ($mem >= $memoryLimit) {
posix_kill($pid, SIGINT);
}
}
}
/**
* Get memory limit
* @param $memoryLimit
* @return int
*/
protected function getMemoryLimit($memoryLimit): int
{
if ($memoryLimit === 0) {
return 0;
}
$usePhpIni = false;
if (!$memoryLimit) {
$memoryLimit = ini_get('memory_limit');
$usePhpIni = true;
}
if ($memoryLimit == -1) {
return 0;
}
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
$memoryLimit = (int)$memoryLimit;
if ($unit === 'g') {
$memoryLimit = 1024 * $memoryLimit;
} else if ($unit === 'k') {
$memoryLimit = ($memoryLimit / 1024);
} else if ($unit === 'm') {
$memoryLimit = (int)($memoryLimit);
} else if ($unit === 't') {
$memoryLimit = (1024 * 1024 * $memoryLimit);
} else {
$memoryLimit = ($memoryLimit / (1024 * 1024));
}
if ($memoryLimit < 50) {
$memoryLimit = 50;
}
if ($usePhpIni) {
$memoryLimit = (0.8 * $memoryLimit);
}
return (int)$memoryLimit;
}
}

View File

@@ -0,0 +1,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.
}
}
}
}

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View 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 已使用');
}
}
}

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

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

View 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, '/')) ?: '';
}
}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/favicon.ico"/>
<title>webman</title>
</head>
<body>
hello <?=htmlspecialchars($name)?>
</body>
</html>

67
server-api/composer.json Normal file
View 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

File diff suppressed because it is too large Load Diff

26
server-api/config/app.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
return [
'debug' => true,
'error_reporting' => E_ALL,
'default_timezone' => 'Asia/Shanghai',
'request_class' => Request::class,
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => false,
];

View File

@@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'files' => [
base_path() . '/app/functions.php',
base_path() . '/support/Request.php',
base_path() . '/support/Response.php',
]
];

View File

@@ -0,0 +1,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,
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return new Webman\Container;

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

View File

@@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'' => support\exception\Handler::class,
];

32
server-api/config/log.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/webman.log',
7, //$maxFiles
Monolog\Logger::DEBUG,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [null, 'Y-m-d H:i:s', true],
],
]
],
],
];

View File

@@ -0,0 +1,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,
],
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'enable' => true,
];

View File

@@ -0,0 +1,7 @@
<?php
use Webman\RedisQueue\Command\MakeConsumerCommand;
return [
MakeConsumerCommand::class
];

View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/redis-queue/queue.log',
7, //$maxFiles
Monolog\Logger::DEBUG,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [null, 'Y-m-d H:i:s', true],
],
]
],
]
];

View File

@@ -0,0 +1,11 @@
<?php
return [
'consumer' => [
'handler' => Webman\RedisQueue\Process\Consumer::class,
'count' => 8, // 可以设置多进程同时消费
'constructor' => [
// 消费者类目录
'consumer_dir' => app_path() . '/queue/redis'
]
]
];

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

View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Log;
use support\Request;
use app\process\Http;
global $argv;
return [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787',
'count' => cpu_count() * 4,
'user' => '',
'group' => '',
'reusePort' => false,
'eventLoop' => '',
'context' => [],
'constructor' => [
'requestClass' => Request::class,
'logger' => Log::channel('default'),
'appPath' => app_path(),
'publicPath' => public_path()
]
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,
'reloadable' => false,
'constructor' => [
// Monitor these directories
'monitorDir' => array_merge([
app_path(),
config_path(),
base_path() . '/process',
base_path() . '/support',
base_path() . '/resource',
base_path() . '/.env',
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
// Files with these suffixes will be monitored
'monitorExtensions' => [
'php', 'html', 'htm', 'env'
],
'options' => [
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
]
];

275
server-api/config/route.php Normal file
View 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']);

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'status_file' => runtime_path() . '/webman.status',
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024
];

View File

@@ -0,0 +1,65 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Session\FileSessionHandler;
use Webman\Session\RedisSessionHandler;
use Webman\Session\RedisClusterSessionHandler;
return [
'type' => 'file', // or redis or redis_cluster
'handler' => FileSessionHandler::class,
'config' => [
'file' => [
'save_path' => runtime_path() . '/sessions',
],
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
'timeout' => 2,
'database' => '',
'prefix' => 'redis_session_',
],
'redis_cluster' => [
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
'timeout' => 2,
'auth' => '',
'prefix' => 'redis_session_',
]
],
'session_name' => 'PHPSID',
'auto_update_timestamp' => false,
'lifetime' => 7*24*60*60,
'cookie_lifetime' => 365*24*60*60,
'cookie_path' => '/',
'domain' => '',
'http_only' => true,
'secure' => false,
'same_site' => '',
'gc_probability' => [1, 1000],
];

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Static file settings
*/
return [
'enable' => true,
'middleware' => [ // Static file Middleware
//app\middleware\StaticFile::class,
],
];

View File

@@ -0,0 +1,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' => '',
];

View File

@@ -0,0 +1,25 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Multilingual configuration
*/
return [
// Default language
'locale' => 'zh_CN',
// Fallback language
'fallback_locale' => ['zh_CN', 'en'],
// Folder where language files are stored
'path' => base_path() . '/resource/translations',
];

Some files were not shown because too many files have changed in this diff Show More