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

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