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,253 @@
<?php
namespace app\support;
use support\think\Db;
class AdminAccessService
{
public function bootstrapDefaults(): void
{
$this->syncPermissions();
$superAdminRoleId = $this->ensureSuperAdminRole();
$this->ensureDefaultOperationRoles();
$this->ensureDefaultAdmin($superAdminRoleId);
}
public function permissionDefinitions(): array
{
return [
['name' => '查看工作台', 'code' => 'dashboard.view', 'module' => 'dashboard', 'action' => 'view'],
['name' => '管理订单', 'code' => 'orders.manage', 'module' => 'orders', 'action' => 'manage'],
['name' => '管理鉴定任务', 'code' => 'appraisal_tasks.manage', 'module' => 'appraisal_tasks', 'action' => 'manage'],
['name' => '管理商品资料', 'code' => 'catalog.manage', 'module' => 'catalog', 'action' => 'manage'],
['name' => '管理报告', 'code' => 'reports.manage', 'module' => 'reports', 'action' => 'manage'],
['name' => '管理消息', 'code' => 'messages.manage', 'module' => 'messages', 'action' => 'manage'],
['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'],
['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'],
['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'],
['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'],
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
['name' => '管理系统配置', 'code' => 'system.manage', 'module' => 'system_config', 'action' => 'manage'],
];
}
public function moduleText(string $module): string
{
return match ($module) {
'dashboard' => '工作台',
'orders' => '订单中心',
'appraisal_tasks' => '鉴定作业台',
'catalog' => '商品资料中心',
'reports' => '报告中心',
'messages' => '消息中心',
'tickets' => '客服与售后',
'users' => '用户管理',
'customers' => '客户管理',
'warehouses' => '仓库中心',
'materials' => '物料管理',
'access' => '权限中心',
'system_config' => '系统配置',
default => $module,
};
}
public function statusText(string $status): string
{
return match ($status) {
'enabled' => '启用中',
'disabled' => '已停用',
default => $status,
};
}
private function syncPermissions(): void
{
$now = date('Y-m-d H:i:s');
foreach ($this->permissionDefinitions() as $item) {
$exists = Db::name('admin_permissions')->where('code', $item['code'])->find();
$payload = [
'name' => $item['name'],
'code' => $item['code'],
'module' => $item['module'],
'action' => $item['action'],
'updated_at' => $now,
];
if ($exists) {
Db::name('admin_permissions')->where('id', $exists['id'])->update($payload);
} else {
try {
$payload['created_at'] = $now;
Db::name('admin_permissions')->insert($payload);
} catch (\Throwable $e) {
// Ignore duplicate insert caused by concurrent bootstrap.
}
}
}
}
private function ensureSuperAdminRole(): int
{
$now = date('Y-m-d H:i:s');
$role = Db::name('admin_roles')->where('code', 'super_admin')->find();
if ($role) {
Db::name('admin_roles')->where('id', $role['id'])->update([
'name' => '超级管理员',
'status' => 'enabled',
'updated_at' => $now,
]);
$roleId = (int)$role['id'];
} else {
$roleId = (int)Db::name('admin_roles')->insertGetId([
'name' => '超级管理员',
'code' => 'super_admin',
'status' => 'enabled',
'created_at' => $now,
'updated_at' => $now,
]);
}
$permissionIds = Db::name('admin_permissions')->column('id');
foreach ($permissionIds as $permissionId) {
$exists = Db::name('admin_role_permissions')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->find();
if (!$exists) {
try {
Db::name('admin_role_permissions')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'created_at' => $now,
]);
} catch (\Throwable $e) {
// Ignore duplicate insert caused by concurrent bootstrap.
}
}
}
return $roleId;
}
private function ensureDefaultOperationRoles(): void
{
$this->ensureRoleWithPermissions('appraiser', '鉴定师', [
'dashboard.view',
'appraisal_tasks.manage',
'reports.manage',
]);
$this->ensureRoleWithPermissions('reviewer', '报告管理员', [
'dashboard.view',
'appraisal_tasks.manage',
'reports.manage',
]);
$this->ensureRoleWithPermissions('material_manager', '物料管理员', [
'dashboard.view',
'materials.manage',
]);
}
private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int
{
$now = date('Y-m-d H:i:s');
$role = Db::name('admin_roles')->where('code', $code)->find();
if ($role) {
Db::name('admin_roles')->where('id', $role['id'])->update([
'name' => $name,
'status' => 'enabled',
'updated_at' => $now,
]);
$roleId = (int)$role['id'];
} else {
$roleId = (int)Db::name('admin_roles')->insertGetId([
'name' => $name,
'code' => $code,
'status' => 'enabled',
'created_at' => $now,
'updated_at' => $now,
]);
}
$permissionIds = Db::name('admin_permissions')
->whereIn('code', $permissionCodes)
->column('id');
$permissionIds = array_map('intval', $permissionIds);
$existingPermissionIds = array_map(
'intval',
Db::name('admin_role_permissions')->where('role_id', $roleId)->column('permission_id')
);
$obsoletePermissionIds = array_values(array_diff($existingPermissionIds, $permissionIds));
if ($obsoletePermissionIds) {
Db::name('admin_role_permissions')
->where('role_id', $roleId)
->whereIn('permission_id', $obsoletePermissionIds)
->delete();
}
$missingPermissionIds = array_values(array_diff($permissionIds, $existingPermissionIds));
foreach ($missingPermissionIds as $permissionId) {
try {
Db::name('admin_role_permissions')->insert([
'role_id' => $roleId,
'permission_id' => (int)$permissionId,
'created_at' => $now,
]);
} catch (\Throwable $e) {
// Ignore duplicate insert caused by concurrent bootstrap.
}
}
return $roleId;
}
private function ensureDefaultAdmin(int $superAdminRoleId): void
{
$now = date('Y-m-d H:i:s');
$admin = Db::name('admin_users')->order('id', 'asc')->find();
$defaultPasswordHash = password_hash('Admin@123456', PASSWORD_BCRYPT);
if ($admin) {
if (($admin['password'] ?? '') === '' || ($admin['password'] ?? '') === 'not-used') {
Db::name('admin_users')->where('id', $admin['id'])->update([
'password' => $defaultPasswordHash,
'updated_at' => $now,
]);
}
$adminId = (int)$admin['id'];
} else {
$adminId = (int)Db::name('admin_users')->insertGetId([
'name' => '系统管理员',
'mobile' => '13800138000',
'email' => 'admin@anxinyan.local',
'password' => $defaultPasswordHash,
'status' => 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
$relation = Db::name('admin_role_relations')
->where('admin_user_id', $adminId)
->where('role_id', $superAdminRoleId)
->find();
if (!$relation) {
try {
Db::name('admin_role_relations')->insert([
'admin_user_id' => $adminId,
'role_id' => $superAdminRoleId,
'created_at' => $now,
]);
} catch (\Throwable $e) {
// Ignore duplicate insert caused by concurrent bootstrap.
}
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace app\support;
use Webman\Http\Request;
use support\think\Db;
class AdminAuthService
{
public function __construct()
{
$this->ensureTokenTable();
(new AdminAccessService())->bootstrapDefaults();
}
public function login(string $mobile, string $password, Request $request): array
{
$admin = Db::name('admin_users')->where('mobile', $mobile)->find();
if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
if (!password_verify($password, (string)$admin['password'])) {
throw new \RuntimeException('手机号或密码错误');
}
$token = bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $token);
$now = date('Y-m-d H:i:s');
$expireTime = date('Y-m-d H:i:s', time() + 7 * 24 * 3600);
// Allow concurrent logins across devices/browsers. Only clean up this user's expired tokens.
Db::name('admin_api_tokens')
->where('admin_user_id', $admin['id'])
->where('expire_time', '<', $now)
->delete();
Db::name('admin_api_tokens')->insert([
'admin_user_id' => (int)$admin['id'],
'token_hash' => $tokenHash,
'expire_time' => $expireTime,
'last_active_at' => $now,
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('admin_users')->where('id', $admin['id'])->update([
'last_login_at' => $now,
'updated_at' => $now,
]);
return [
'token' => $token,
'admin_info' => $this->adminInfo((int)$admin['id']),
];
}
public function logout(Request $request): void
{
$token = $this->extractToken($request);
if ($token === '') {
return;
}
Db::name('admin_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
}
public function current(Request $request): ?array
{
$token = $this->extractToken($request);
if ($token === '') {
return null;
}
$record = Db::name('admin_api_tokens')
->where('token_hash', hash('sha256', $token))
->find();
if (!$record) {
return null;
}
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
Db::name('admin_api_tokens')->where('id', $record['id'])->delete();
return null;
}
$admin = Db::name('admin_users')->where('id', $record['admin_user_id'])->find();
if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') {
return null;
}
Db::name('admin_api_tokens')->where('id', $record['id'])->update([
'last_active_at' => date('Y-m-d H:i:s'),
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->adminInfo((int)$admin['id']);
}
public function hasPermission(array $adminInfo, string $permissionCode): bool
{
if ($permissionCode === '') {
return true;
}
return in_array($permissionCode, $adminInfo['permission_codes'], true);
}
private function adminInfo(int $adminUserId): array
{
$admin = Db::name('admin_users')->where('id', $adminUserId)->find();
$roleIds = Db::name('admin_role_relations')->where('admin_user_id', $adminUserId)->column('role_id');
$roles = $roleIds ? Db::name('admin_roles')->whereIn('id', $roleIds)->select()->toArray() : [];
$permissionIds = $roleIds ? Db::name('admin_role_permissions')->whereIn('role_id', $roleIds)->column('permission_id') : [];
$permissions = $permissionIds ? Db::name('admin_permissions')->whereIn('id', $permissionIds)->select()->toArray() : [];
return [
'id' => (int)($admin['id'] ?? 0),
'name' => $admin['name'] ?? '',
'mobile' => $admin['mobile'] ?? '',
'email' => $admin['email'] ?? '',
'status' => $admin['status'] ?? 'enabled',
'role_names' => array_values(array_map(fn (array $item) => $item['name'], $roles)),
'permission_codes' => array_values(array_unique(array_map(fn (array $item) => $item['code'], $permissions))),
];
}
private function extractToken(Request $request): string
{
$authorization = trim((string)$request->header('authorization', ''));
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
return trim($matches[1]);
}
return '';
}
private function ensureTokenTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS admin_api_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expire_time DATETIME NOT NULL,
last_active_at DATETIME NULL DEFAULT NULL,
last_ip VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_admin_api_tokens_token_hash (token_hash),
KEY idx_admin_api_tokens_admin_user_id (admin_user_id),
KEY idx_admin_api_tokens_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台登录Token';
SQL);
}
}

View File

@@ -0,0 +1,441 @@
<?php
namespace app\support;
use Webman\Http\Request;
use support\think\Db;
class AppAuthService
{
public function __construct()
{
$this->ensurePasswordColumn();
$this->ensureTokenTable();
$this->ensureSmsCodeTable();
}
public function sendLoginCode(string $mobile, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法发送验证码');
}
$scene = 'login';
$now = time();
$latest = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->order('id', 'desc')
->find();
if ($latest) {
$retryAt = strtotime((string)$latest['created_at']) + 60;
if ($retryAt > $now) {
throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now)));
}
}
$todayStart = date('Y-m-d 00:00:00');
$todayCount = (int)Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->where('created_at', '>=', $todayStart)
->count();
if ($todayCount >= 20) {
throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试');
}
$code = (string)random_int(100000, 999999);
$nowText = date('Y-m-d H:i:s', $now);
$expireTime = date('Y-m-d H:i:s', $now + 300);
$sendResult = null;
$sendStatus = 'failed';
$failedReason = '';
try {
$sendResult = (new AppSmsService())->sendLoginCode($mobile, $code);
$sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success';
} catch (\Throwable $e) {
$failedReason = $this->truncateText($e->getMessage(), 250);
}
Db::name('sms_code_logs')->insert([
'mobile' => $mobile,
'scene' => $scene,
'code_hash' => $this->codeHash($mobile, $scene, $code),
'send_status' => $sendStatus,
'provider' => $sendResult['provider'] ?? 'aliyun_sms',
'template_code' => $this->systemConfig('sms', 'login_template_code'),
'request_id' => $sendResult['request_id'] ?? '',
'biz_id' => $sendResult['biz_id'] ?? '',
'failed_reason' => $failedReason,
'expire_time' => $expireTime,
'used_at' => null,
'send_ip' => $request->getRealIp(),
'created_at' => $nowText,
'updated_at' => $nowText,
]);
if ($sendStatus === 'failed') {
throw new \RuntimeException($failedReason ?: '验证码发送失败');
}
$payload = [
'mobile' => $mobile,
'scene' => $scene,
'expire_seconds' => 300,
'retry_after_seconds' => 60,
];
if (($sendResult['debug_code'] ?? null) !== null) {
$payload['debug_code'] = $sendResult['debug_code'];
}
return $payload;
}
public function loginByCode(string $mobile, string $code, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
$now = date('Y-m-d H:i:s');
Db::name('sms_code_logs')->where('id', $record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用');
}
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '安心验用户' . substr($mobile, -4),
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
}
$this->syncMobileAuth($userId, $mobile, $now);
return $this->issueToken($userId, $request, 'sms_code');
}
public function loginByPassword(string $mobile, string $password, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$password = trim($password);
if ($password === '') {
throw new \RuntimeException('密码不能为空');
}
$user = Db::name('users')->where('mobile', $mobile)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$passwordHash = (string)($user['password'] ?? '');
if ($passwordHash === '') {
throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录');
}
if (!password_verify($password, $passwordHash)) {
throw new \RuntimeException('手机号或密码错误');
}
$this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s'));
return $this->issueToken((int)$user['id'], $request, 'password');
}
public function logout(Request $request): void
{
$token = $this->extractToken($request);
if ($token === '') {
return;
}
Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
}
public function current(Request $request): ?array
{
$token = $this->extractToken($request);
if ($token === '') {
return null;
}
$record = Db::name('user_api_tokens')
->where('token_hash', hash('sha256', $token))
->find();
if (!$record) {
return null;
}
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
Db::name('user_api_tokens')->where('id', $record['id'])->delete();
return null;
}
$user = Db::name('users')->where('id', $record['user_id'])->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
return null;
}
Db::name('user_api_tokens')->where('id', $record['id'])->update([
'last_active_at' => date('Y-m-d H:i:s'),
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->userInfo((int)$user['id']);
}
public function savePassword(int $userId, string $currentPassword, string $newPassword): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$currentHash = (string)($user['password'] ?? '');
$hadPassword = $currentHash !== '';
if ($currentHash !== '') {
if ($currentPassword === '') {
throw new \RuntimeException('请输入当前密码');
}
if (!password_verify($currentPassword, $currentHash)) {
throw new \RuntimeException('当前密码错误');
}
}
$this->validateNewPassword($newPassword);
Db::name('users')->where('id', $userId)->update([
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
'updated_at' => date('Y-m-d H:i:s'),
]);
return [
'user_id' => $userId,
'password_set' => true,
'had_password' => $hadPassword,
];
}
private function issueToken(int $userId, Request $request, string $authType): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$token = bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $token);
$now = date('Y-m-d H:i:s');
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
Db::name('user_api_tokens')->insert([
'user_id' => $userId,
'token_hash' => $tokenHash,
'auth_type' => $authType,
'expire_time' => $expireTime,
'last_active_at' => $now,
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('users')->where('id', $userId)->update([
'last_login_at' => $now,
'updated_at' => $now,
]);
return [
'token' => $token,
'user_info' => $this->userInfo($userId),
];
}
private function userInfo(int $userId): array
{
$user = Db::name('users')->where('id', $userId)->find();
return [
'id' => (int)($user['id'] ?? 0),
'nickname' => $user['nickname'] ?: '安心验用户',
'mobile' => $user['mobile'] ?? '',
'avatar' => $user['avatar'] ?? '',
'status' => $user['status'] ?? 'enabled',
'password_set' => ((string)($user['password'] ?? '')) !== '',
];
}
private function syncMobileAuth(int $userId, string $mobile, string $now): void
{
$existing = Db::name('user_auths')
->where('auth_type', 'mobile')
->where('auth_key', $mobile)
->find();
$payload = [
'user_id' => $userId,
'auth_type' => 'mobile',
'auth_key' => $mobile,
'auth_open_id' => '',
'auth_union_id' => '',
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function normalizeMobile(string $mobile): string
{
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
if (!preg_match('/^1\d{10}$/', $mobile)) {
throw new \RuntimeException('请输入正确的手机号');
}
return $mobile;
}
private function validateNewPassword(string $password): void
{
if (mb_strlen($password) < 8) {
throw new \RuntimeException('密码长度不能少于 8 位');
}
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) {
throw new \RuntimeException('密码需同时包含字母和数字');
}
}
private function codeHash(string $mobile, string $scene, string $code): string
{
return hash('sha256', implode('|', [$mobile, $scene, $code]));
}
private function systemConfig(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function extractToken(Request $request): string
{
$authorization = trim((string)$request->header('authorization', ''));
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
return trim($matches[1]);
}
return '';
}
private function ensurePasswordColumn(): void
{
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
if ($column) {
return;
}
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
}
private function ensureTokenTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS user_api_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
auth_type VARCHAR(32) NOT NULL DEFAULT 'password',
expire_time DATETIME NOT NULL,
last_active_at DATETIME NULL DEFAULT NULL,
last_ip VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_api_tokens_token_hash (token_hash),
KEY idx_user_api_tokens_user_id (user_id),
KEY idx_user_api_tokens_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token';
SQL);
}
private function ensureSmsCodeTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS sms_code_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
mobile VARCHAR(32) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'login',
code_hash VARCHAR(64) NOT NULL,
send_status VARCHAR(32) NOT NULL DEFAULT 'success',
provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms',
template_code VARCHAR(64) NOT NULL DEFAULT '',
request_id VARCHAR(128) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
failed_reason VARCHAR(255) NOT NULL DEFAULT '',
expire_time DATETIME NOT NULL,
used_at DATETIME NULL DEFAULT NULL,
send_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_sms_code_logs_mobile_scene (mobile, scene),
KEY idx_sms_code_logs_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录';
SQL);
}
private function truncateText(string $value, int $maxLength): string
{
if (mb_strlen($value) <= $maxLength) {
return $value;
}
return mb_substr($value, 0, $maxLength);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace app\support;
use AlibabaCloud\SDK\Dysmsapi\V20170525\Dysmsapi;
use AlibabaCloud\SDK\Dysmsapi\V20170525\Models\SendSmsRequest;
use Darabonba\OpenApi\Models\Config;
use support\think\Db;
class AppSmsService
{
public function sendLoginCode(string $mobile, string $code): array
{
$this->bootstrapCaBundle();
$config = $this->config();
if (!$this->isConfigured($config)) {
if ($this->isDebugMode()) {
return [
'provider' => 'debug',
'request_id' => 'debug',
'biz_id' => 'debug',
'raw_body' => [
'Code' => 'OK',
'Message' => 'DEBUG_SMS_BYPASS',
],
'debug_code' => $code,
];
}
throw new \RuntimeException('短信配置未完成,请先在后台系统配置中填写阿里云短信参数');
}
$client = new Dysmsapi(new Config([
'accessKeyId' => $config['access_key_id'],
'accessKeySecret' => $config['access_key_secret'],
'regionId' => $config['region_id'] ?: 'cn-hangzhou',
'endpoint' => $config['endpoint'] ?: null,
]));
$response = $client->sendSms(new SendSmsRequest([
'phoneNumbers' => $mobile,
'signName' => $config['sign_name'],
'templateCode' => $config['login_template_code'],
'templateParam' => json_encode(['code' => $code], JSON_UNESCAPED_UNICODE),
]));
$body = $response->body ? $response->body->toMap() : [];
$responseCode = (string)($body['Code'] ?? '');
if ($responseCode !== 'OK') {
throw new \RuntimeException((string)($body['Message'] ?? '短信发送失败'));
}
return [
'provider' => 'aliyun_sms',
'request_id' => (string)($body['RequestId'] ?? ''),
'biz_id' => (string)($body['BizId'] ?? ''),
'raw_body' => $body,
'debug_code' => null,
];
}
private function config(): array
{
$rows = Db::name('system_configs')
->where('config_group', 'sms')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$map[$row['config_key']] = trim((string)($row['config_value'] ?? ''));
}
return [
'access_key_id' => $map['access_key_id'] ?? '',
'access_key_secret' => $map['access_key_secret'] ?? '',
'sign_name' => $map['sign_name'] ?? '',
'login_template_code' => $map['login_template_code'] ?? '',
'region_id' => $map['region_id'] ?? 'cn-hangzhou',
'endpoint' => $map['endpoint'] ?? '',
];
}
private function isConfigured(array $config): bool
{
return $config['access_key_id'] !== ''
&& $config['access_key_secret'] !== ''
&& $config['sign_name'] !== ''
&& $config['login_template_code'] !== '';
}
private function isDebugMode(): bool
{
return in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true);
}
private function bootstrapCaBundle(): void
{
if (ini_get('curl.cainfo') || ini_get('openssl.cafile')) {
return;
}
foreach ($this->candidateCaFiles() as $path) {
if (!is_file($path)) {
continue;
}
ini_set('curl.cainfo', $path);
ini_set('openssl.cafile', $path);
putenv('CURL_CA_BUNDLE=' . $path);
putenv('SSL_CERT_FILE=' . $path);
return;
}
}
private function candidateCaFiles(): array
{
return [
'/etc/ssl/cert.pem',
'/private/etc/ssl/cert.pem',
'/opt/homebrew/etc/openssl@3/cert.pem',
'/usr/local/etc/openssl@3/cert.pem',
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace app\support;
use support\Request;
use function parse_url;
use function str_starts_with;
use function strtolower;
class AppraisalEvidenceService
{
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic'];
private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'm4v', 'webm', 'avi', 'mpeg', 'mpg'];
private const PDF_EXTENSIONS = ['pdf'];
public function upload(Request $request, string $inputName = 'file'): array
{
$file = $request->file($inputName);
if (!$file || !$file->isValid()) {
throw new \RuntimeException('上传文件无效');
}
$extension = strtolower($file->getUploadExtension() ?: '');
$fileType = $this->detectFileType($extension);
if ($fileType === 'file') {
throw new \RuntimeException('仅支持上传图片、视频或 PDF 文件');
}
$filename = sprintf('evidence_%s.%s', uniqid(), $extension ?: 'dat');
$relativeDir = 'uploads/appraisal-evidence/' . date('Ymd');
$relativePath = $relativeDir . '/' . $filename;
$this->storage()->putUploadedFile($file, $relativePath);
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
return [
'file_id' => md5($relativePath),
'file_url' => $fileUrl,
'thumbnail_url' => $fileType === 'image' ? $fileUrl : '',
'name' => $file->getUploadName(),
'file_type' => $fileType,
'mime_type' => $this->mimeType($fileType, $extension),
];
}
public function delete(string $fileUrl): void
{
$relativePath = $this->storage()->storagePath($fileUrl);
if (!str_starts_with($relativePath, 'uploads/appraisal-evidence/')) {
return;
}
$this->storage()->delete($relativePath);
}
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
{
if (is_string($attachments) && $attachments !== '') {
$decoded = json_decode($attachments, true);
$attachments = is_array($decoded) ? $decoded : [];
}
if (!is_array($attachments)) {
return [];
}
$list = [];
foreach ($attachments as $item) {
if (!is_array($item)) {
continue;
}
$fileUrl = trim((string)($item['file_url'] ?? ''));
if ($fileUrl === '') {
continue;
}
$name = trim((string)($item['name'] ?? ''));
$mimeType = trim((string)($item['mime_type'] ?? ''));
$fileType = trim((string)($item['file_type'] ?? ''));
if ($fileType === '') {
$path = parse_url('/' . $this->storage()->storagePath($fileUrl), PHP_URL_PATH) ?: '';
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$fileType = $this->detectFileType($extension);
if ($mimeType === '') {
$mimeType = $this->mimeType($fileType, $extension);
}
}
$storedFileUrl = $this->storage()->storagePath($fileUrl);
$storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : ''))));
$list[] = [
'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))),
'file_url' => $forStorage
? '/' . $storedFileUrl
: ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl),
'thumbnail_url' => $forStorage
? ($storedThumbnailUrl !== '' ? '/' . $storedThumbnailUrl : '')
: ($request
? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : ''))), $request)
: trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : '')))),
'name' => $name,
'file_type' => $fileType ?: 'file',
'mime_type' => $mimeType,
];
}
return $list;
}
public function detectFileType(string $extension): string
{
if (in_array($extension, self::IMAGE_EXTENSIONS, true)) {
return 'image';
}
if (in_array($extension, self::VIDEO_EXTENSIONS, true)) {
return 'video';
}
if (in_array($extension, self::PDF_EXTENSIONS, true)) {
return 'pdf';
}
return 'file';
}
private function mimeType(string $fileType, string $extension): string
{
return match ($fileType) {
'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')),
'video' => 'video/' . ($extension ?: 'mp4'),
'pdf' => 'application/pdf',
default => 'application/octet-stream',
};
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace app\support;
use support\Request;
use function md5;
use function sprintf;
use function str_starts_with;
use function strtolower;
class CatalogTemplateSampleImageService
{
public function upload(Request $request, string $inputName = 'file'): array
{
$file = $request->file($inputName);
if (!$file || !$file->isValid()) {
throw new \RuntimeException('上传文件无效');
}
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片');
}
$filename = sprintf('upload_template_sample_%s.%s', uniqid(), $extension);
$relativeDir = 'uploads/upload-template-samples/' . date('Ymd');
$relativePath = $relativeDir . '/' . $filename;
$this->storage()->putUploadedFile($file, $relativePath);
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
return [
'file_id' => md5($relativePath),
'file_url' => $fileUrl,
'thumbnail_url' => $fileUrl,
'name' => $file->getUploadName(),
];
}
public function delete(string $fileUrl): void
{
$relativePath = $this->storage()->storagePath($fileUrl);
if (!str_starts_with($relativePath, 'uploads/upload-template-samples/')) {
return;
}
$this->storage()->delete($relativePath);
}
public function normalizeUrl(string $fileUrl, Request $request): string
{
return $this->storage()->normalizeUrl($fileUrl, $request);
}
public function storagePath(string $fileUrl): string
{
return $this->storage()->storagePath($fileUrl);
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace app\support;
use support\Request;
use function md5;
use function sprintf;
use function strtolower;
class ContentImageService
{
public function upload(Request $request, string $inputName = 'file'): array
{
$file = $request->file($inputName);
if (!$file || !$file->isValid()) {
throw new \RuntimeException('上传文件无效');
}
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片');
}
$filename = sprintf('content_image_%s.%s', uniqid(), $extension);
$relativeDir = 'uploads/content-images/' . date('Ymd');
$relativePath = $relativeDir . '/' . $filename;
$this->storage()->putUploadedFile($file, $relativePath);
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
return [
'file_id' => md5($relativePath),
'file_url' => $fileUrl,
'thumbnail_url' => $fileUrl,
'name' => $file->getUploadName(),
];
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
<?php
namespace app\support;
use support\think\Db;
class EnterpriseCustomerService
{
public function generateCustomerCode(): string
{
do {
$code = 'ENT' . date('Ymd') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
} while (Db::name('enterprise_customers')->where('customer_code', $code)->find());
return $code;
}
public function generateAppKey(): string
{
do {
$key = 'ak_' . bin2hex(random_bytes(12));
} while (Db::name('enterprise_customer_apps')->where('app_key', $key)->find());
return $key;
}
public function generateAppSecret(): string
{
return 'sk_' . bin2hex(random_bytes(24));
}
public function encryptSecret(string $secret): string
{
$key = $this->secretKey();
$iv = random_bytes(16);
$cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) {
throw new \RuntimeException('应用密钥加密失败');
}
return base64_encode($iv . $cipher);
}
public function decryptSecret(string $cipherText): string
{
$raw = base64_decode($cipherText, true);
if ($raw === false || strlen($raw) <= 16) {
return '';
}
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$secret = openssl_decrypt($cipher, 'AES-256-CBC', $this->secretKey(), OPENSSL_RAW_DATA, $iv);
return is_string($secret) ? $secret : '';
}
public function ensureVirtualUser(array $customer): int
{
$existingUserId = (int)($customer['user_id'] ?? 0);
if ($existingUserId > 0 && Db::name('users')->where('id', $existingUserId)->find()) {
return $existingUserId;
}
$now = date('Y-m-d H:i:s');
$mobile = 'ENT' . substr(hash('sha256', (string)($customer['customer_code'] ?? $customer['id'] ?? '')), 0, 20) . '@V';
$userId = (int)Db::name('users')->insertGetId([
'nickname' => (string)($customer['customer_name'] ?? '大客户'),
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('enterprise_customers')->where('id', $customer['id'])->update([
'user_id' => $userId,
'updated_at' => $now,
]);
return $userId;
}
public function formatCustomer(array $item): array
{
return [
'id' => (int)$item['id'],
'customer_code' => (string)$item['customer_code'],
'customer_name' => (string)$item['customer_name'],
'contact_name' => (string)($item['contact_name'] ?? ''),
'contact_mobile' => (string)($item['contact_mobile'] ?? ''),
'contact_email' => (string)($item['contact_email'] ?? ''),
'settlement_type' => (string)($item['settlement_type'] ?? 'monthly'),
'settlement_type_text' => '月结',
'user_id' => (int)($item['user_id'] ?? 0),
'webhook_url' => (string)($item['webhook_url'] ?? ''),
'webhook_enabled' => (bool)($item['webhook_enabled'] ?? false),
'status' => (string)($item['status'] ?? 'enabled'),
'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用',
'remark' => (string)($item['remark'] ?? ''),
'created_at' => (string)($item['created_at'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
];
}
public function formatApp(array $item): array
{
return [
'id' => (int)$item['id'],
'customer_id' => (int)$item['customer_id'],
'app_name' => (string)($item['app_name'] ?? ''),
'app_key' => (string)$item['app_key'],
'secret_last4' => (string)($item['secret_last4'] ?? ''),
'status' => (string)($item['status'] ?? 'enabled'),
'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用',
'last_used_at' => (string)($item['last_used_at'] ?? ''),
'created_at' => (string)($item['created_at'] ?? ''),
];
}
private function secretKey(): string
{
$seed = $_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? 'anxinyan-enterprise-secret-key';
return hash('sha256', (string)$seed, true);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace app\support;
use support\Request;
use support\think\Db;
class EnterpriseOpenApiAuthService
{
public function authenticate(Request $request): array
{
$appKey = trim((string)$request->header('x-axy-app-key', ''));
$timestamp = trim((string)$request->header('x-axy-timestamp', ''));
$nonce = trim((string)$request->header('x-axy-nonce', ''));
$signature = trim((string)$request->header('x-axy-signature', ''));
if ($appKey === '' || $timestamp === '' || $nonce === '' || $signature === '') {
throw new \RuntimeException('开放接口鉴权头不完整');
}
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {
throw new \RuntimeException('请求时间戳已过期');
}
$app = Db::name('enterprise_customer_apps')->where('app_key', $appKey)->find();
if (!$app || ($app['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('应用 Key 不存在或已停用');
}
$customer = Db::name('enterprise_customers')->where('id', $app['customer_id'])->find();
if (!$customer || ($customer['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('客户不存在或已停用');
}
$this->storeNonce($appKey, $nonce, (int)$timestamp);
$secret = (new EnterpriseCustomerService())->decryptSecret((string)($app['app_secret_cipher'] ?? ''));
if ($secret === '') {
throw new \RuntimeException('应用密钥不可用');
}
$rawBody = $request->rawBody();
$pathWithQuery = $request->uri();
$base = strtoupper($request->method()) . $pathWithQuery . $timestamp . $nonce . hash('sha256', $rawBody);
$expected = hash_hmac('sha256', $base, $secret);
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('请求签名无效');
}
Db::name('enterprise_customer_apps')->where('id', $app['id'])->update([
'last_used_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return [
'app' => $app,
'customer' => $customer,
];
}
private function storeNonce(string $appKey, string $nonce, int $timestamp): void
{
$expireBefore = date('Y-m-d H:i:s', time() - 600);
Db::name('enterprise_api_nonces')->where('created_at', '<', $expireBefore)->delete();
try {
Db::name('enterprise_api_nonces')->insert([
'app_key' => $appKey,
'nonce' => $nonce,
'request_timestamp' => $timestamp,
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
throw new \RuntimeException('请求 nonce 已使用');
}
}
}

View File

@@ -0,0 +1,449 @@
<?php
namespace app\support;
use support\Request;
use support\think\Db;
class EnterpriseOrderService
{
public function createOrder(array $customer, array $payload, Request $request): array
{
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
if ($externalOrderNo === '') {
throw new \InvalidArgumentException('external_order_no 不能为空');
}
$payloadHash = hash('sha256', json_encode($this->normalizePayloadForHash($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$existingRef = Db::name('enterprise_customer_order_refs')
->where('customer_id', (int)$customer['id'])
->where('external_order_no', $externalOrderNo)
->find();
if ($existingRef) {
if (($existingRef['payload_hash'] ?? '') !== $payloadHash) {
throw new \RuntimeException('external_order_no 已存在,但请求内容不一致');
}
return [
'idempotent' => true,
'order' => $this->buildOrderProgress((int)$customer['id'], $existingRef, (string)$customer['customer_code']),
];
}
$serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan'));
if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) {
throw new \InvalidArgumentException('service_provider 无效');
}
$serviceConfig = $this->serviceConfig($serviceProvider);
$product = $this->normalizeProduct((array)($payload['product_info'] ?? []));
$returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? []));
$materials = $this->normalizeMaterials((array)($payload['materials'] ?? []));
$now = date('Y-m-d H:i:s');
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
$userId = (new EnterpriseCustomerService())->ensureVirtualUser($customer);
$productName = $this->resolveProductName($product);
Db::startTrans();
try {
$orderId = (int)Db::name('orders')->insertGetId([
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'user_id' => $userId,
'service_mode' => 'physical',
'service_provider' => $serviceProvider,
'payment_status' => 'paid',
'order_status' => 'pending_shipping',
'display_status' => '待寄送商品',
'estimated_finish_time' => $estimated,
'source_channel' => 'enterprise_push',
'source_customer_id' => $customer['customer_code'],
'pay_amount' => $serviceConfig['price'],
'paid_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_products')->insert(array_merge($product, [
'order_id' => $orderId,
'product_name' => $productName,
'product_cover' => $materials[0]['file_url'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]));
$extra = (array)($payload['extra_info'] ?? []);
Db::name('order_extras')->insert([
'order_id' => $orderId,
'purchase_channel' => trim((string)($extra['purchase_channel'] ?? '')),
'purchase_price' => (float)($extra['purchase_price'] ?? 0),
'purchase_date' => $extra['purchase_date'] ?? null,
'usage_status' => trim((string)($extra['usage_status'] ?? '')),
'condition_desc' => trim((string)($extra['condition_desc'] ?? '')),
'has_accessories' => !empty($extra['has_accessories']) ? 1 : 0,
'accessories_json' => json_encode(array_values((array)($extra['accessories'] ?? [])), JSON_UNESCAPED_UNICODE),
'remark' => trim((string)($extra['remark'] ?? '')),
'created_at' => $now,
'updated_at' => $now,
]);
if ($returnAddress) {
Db::name('order_return_addresses')->insert(array_merge($returnAddress, [
'order_id' => $orderId,
'user_address_id' => null,
'created_at' => $now,
'updated_at' => $now,
]));
}
$shippingTarget = (new WarehouseService())->bindOrderTarget(
$orderId,
$serviceProvider,
!empty($product['category_id']) ? (int)$product['category_id'] : null,
null
);
$this->insertMaterials($orderId, $materials, $now);
$this->insertTimelines($orderId, $now, $shippingTarget);
$this->insertTask($orderId, $serviceProvider, $estimated, $now);
$this->insertInboundLogistics($orderId, $this->normalizeInboundLogistics($payload), $now);
Db::name('enterprise_customer_order_refs')->insert([
'customer_id' => (int)$customer['id'],
'external_order_no' => $externalOrderNo,
'order_id' => $orderId,
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'payload_hash' => $payloadHash,
'created_at' => $now,
'updated_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'order_created', [
'product_name' => $productName,
'pay_amount' => (float)$serviceConfig['price'],
]);
$ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find();
return [
'idempotent' => false,
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
public function findOrder(array $customer, string $externalOrderNo = '', string $orderNo = ''): array
{
$query = Db::name('enterprise_customer_order_refs')->where('customer_id', (int)$customer['id']);
if ($externalOrderNo !== '') {
$query->where('external_order_no', $externalOrderNo);
} elseif ($orderNo !== '') {
$query->where('order_no', $orderNo);
} else {
throw new \InvalidArgumentException('external_order_no 或 order_no 不能为空');
}
$ref = $query->find();
if (!$ref) {
throw new \RuntimeException('订单不存在');
}
return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']);
}
public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array
{
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
$timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray();
$sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$report = Db::name('reports')->where('order_id', (int)$order['id'])->order('id', 'desc')->find();
$verify = $report ? (Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: null) : null;
return [
'customer_id' => $customerCode !== '' ? $customerCode : (string)$customerId,
'customer_code' => $customerCode !== '' ? $customerCode : (string)$customerId,
'external_order_no' => (string)$ref['external_order_no'],
'order_id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
'payment_status' => (string)$order['payment_status'],
'pay_amount' => (float)$order['pay_amount'],
'estimated_finish_time' => (string)($order['estimated_finish_time'] ?? ''),
'created_at' => (string)$order['created_at'],
'timeline' => array_map(fn(array $item) => [
'node_code' => (string)$item['node_code'],
'node_text' => (string)$item['node_text'],
'node_desc' => (string)$item['node_desc'],
'occurred_at' => (string)$item['occurred_at'],
], $timeline),
'inbound_logistics' => $this->formatLogistics($sendLogistics),
'return_logistics' => $this->formatLogistics($returnLogistics),
'report_summary' => $report ? [
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'report_status' => (string)$report['report_status'],
'publish_time' => (string)($report['publish_time'] ?? ''),
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['verify_qrcode_url'] ?? ''),
'verify_status' => (string)($verify['verify_status'] ?? ''),
] : null,
];
}
private function normalizePayloadForHash(array $payload): array
{
ksort($payload);
foreach ($payload as &$value) {
if (is_array($value)) {
$value = $this->normalizePayloadForHash($value);
}
}
unset($value);
return $payload;
}
private function normalizeProduct(array $product): array
{
$productName = trim((string)($product['product_name'] ?? ''));
return [
'category_id' => !empty($product['category_id']) ? (int)$product['category_id'] : null,
'category_name' => trim((string)($product['category_name'] ?? '')),
'brand_id' => !empty($product['brand_id']) ? (int)$product['brand_id'] : null,
'brand_name' => trim((string)($product['brand_name'] ?? '')),
'color' => trim((string)($product['color'] ?? '')),
'size_spec' => trim((string)($product['size_spec'] ?? '')),
'serial_no' => trim((string)($product['serial_no'] ?? '')),
'product_name' => $productName,
];
}
private function normalizeReturnAddress(array $address): ?array
{
$requiredKeys = ['consignee', 'mobile', 'province', 'city', 'district', 'detail_address'];
$hasAnyValue = false;
foreach ($requiredKeys as $key) {
if (trim((string)($address[$key] ?? '')) !== '') {
$hasAnyValue = true;
break;
}
}
if (!$hasAnyValue) {
return null;
}
foreach ($requiredKeys as $key) {
if (trim((string)($address[$key] ?? '')) === '') {
throw new \InvalidArgumentException("return_address.{$key} 不能为空");
}
}
return [
'consignee' => trim((string)$address['consignee']),
'mobile' => trim((string)$address['mobile']),
'province' => trim((string)$address['province']),
'city' => trim((string)$address['city']),
'district' => trim((string)$address['district']),
'detail_address' => trim((string)$address['detail_address']),
];
}
private function normalizeMaterials(array $materials): array
{
$list = [];
foreach ($materials as $index => $item) {
if (is_string($item)) {
$url = trim($item);
$itemCode = 'material_' . ($index + 1);
$itemName = '鉴定资料';
} elseif (is_array($item)) {
$url = trim((string)($item['file_url'] ?? $item['url'] ?? ''));
$itemCode = trim((string)($item['item_code'] ?? 'material_' . ($index + 1)));
$itemName = trim((string)($item['item_name'] ?? '鉴定资料'));
} else {
$url = '';
$itemCode = 'material_' . ($index + 1);
$itemName = '鉴定资料';
}
if ($url === '' || !preg_match('/^https?:\/\//i', $url)) {
throw new \InvalidArgumentException('materials 只支持 http/https 图片 URL');
}
$list[] = [
'item_code' => $itemCode,
'item_name' => $itemName,
'file_url' => $url,
'thumbnail_url' => is_array($item) ? trim((string)($item['thumbnail_url'] ?? $url)) : $url,
'is_required' => is_array($item) && !empty($item['is_required']) ? 1 : 0,
];
}
return $list;
}
private function normalizeInboundLogistics(array $payload): array
{
$logistics = (array)($payload['inbound_logistics'] ?? []);
if (!empty($payload['express_company']) || !empty($payload['tracking_no'])) {
$logistics = array_merge($logistics, [
'express_company' => $payload['express_company'] ?? ($logistics['express_company'] ?? ''),
'tracking_no' => $payload['tracking_no'] ?? ($logistics['tracking_no'] ?? ''),
]);
}
return $logistics;
}
private function resolveProductName(array $product): string
{
$productName = trim((string)($product['product_name'] ?? ''));
if ($productName !== '') {
return $productName;
}
return trim(($product['brand_name'] ?? '') . ' ' . ($product['category_name'] ?? ''));
}
private function serviceConfig(string $serviceProvider): array
{
$configs = [
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
];
if (isset($configs[$serviceProvider])) {
return $configs[$serviceProvider];
}
return $configs['anxinyan'];
}
private function insertMaterials(int $orderId, array $materials, string $now): void
{
foreach ($materials as $item) {
$uploadItemId = (int)Db::name('order_upload_items')->insertGetId([
'order_id' => $orderId,
'template_id' => null,
'item_code' => $item['item_code'],
'item_name' => $item['item_name'],
'is_required' => $item['is_required'],
'source_type' => 'initial',
'status' => 'uploaded',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_upload_files')->insert([
'order_upload_item_id' => $uploadItemId,
'file_id' => md5($item['file_url']),
'file_url' => $item['file_url'],
'thumbnail_url' => $item['thumbnail_url'],
'quality_status' => 'uploaded',
'quality_message' => '',
'uploaded_by_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
private function insertTimelines(int $orderId, string $now, array $shippingTarget): void
{
Db::name('order_timelines')->insertAll([
[
'order_id' => $orderId,
'node_code' => 'created',
'node_text' => '下单成功',
'node_desc' => '大客户订单已推送并创建成功',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
],
[
'order_id' => $orderId,
'node_code' => 'pending_shipping',
'node_text' => '待寄送商品',
'node_desc' => sprintf('请将商品寄送至%s', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
],
]);
}
private function insertTask(int $orderId, string $serviceProvider, string $estimated, string $now): void
{
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => $serviceProvider,
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => $estimated,
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function insertInboundLogistics(int $orderId, array $logistics, string $now): void
{
$expressCompany = trim((string)($logistics['express_company'] ?? ''));
$trackingNo = trim((string)($logistics['tracking_no'] ?? ''));
if ($expressCompany === '' || $trackingNo === '') {
return;
}
$latestDesc = sprintf('客户已提交寄送运单:%s %s等待鉴定中心签收。', $expressCompany, $trackingNo);
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $orderId,
'logistics_type' => 'send_to_center',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'submitted',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => '',
'created_at' => $now,
]);
}
private function formatLogistics(?array $logistics): ?array
{
if (!$logistics) {
return null;
}
return [
'express_company' => (string)$logistics['express_company'],
'tracking_no' => (string)$logistics['tracking_no'],
'tracking_status' => (string)$logistics['tracking_status'],
'latest_desc' => (string)$logistics['latest_desc'],
'latest_time' => (string)($logistics['latest_time'] ?? ''),
];
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace app\support;
use support\think\Db;
class EnterpriseWebhookService
{
public function recordOrderEvent(int $orderId, string $eventCode, array $data = [], bool $sendNow = true): ?array
{
$ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find();
if (!$ref) {
return null;
}
$customer = Db::name('enterprise_customers')->where('id', (int)$ref['customer_id'])->find();
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$customer || !$order) {
return null;
}
$eventMeta = $this->eventMeta($eventCode);
$payload = [
'event_code' => $eventCode,
'event_text' => $eventMeta['event_text'],
'customer_id' => (string)$customer['customer_code'],
'customer_code' => (string)$customer['customer_code'],
'external_order_no' => (string)$ref['external_order_no'],
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'status_code' => $eventMeta['status_code'],
'status_text' => $eventMeta['status_text'],
'occurred_at' => date('Y-m-d H:i:s'),
'data' => $data,
];
$eventId = (int)Db::name('enterprise_order_events')->insertGetId([
'customer_id' => (int)$customer['id'],
'order_id' => $orderId,
'external_order_no' => (string)$ref['external_order_no'],
'event_code' => $eventCode,
'event_text' => $eventMeta['event_text'],
'status_code' => $eventMeta['status_code'],
'status_text' => $eventMeta['status_text'],
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
'occurred_at' => $payload['occurred_at'],
'created_at' => date('Y-m-d H:i:s'),
]);
$payload['event_id'] = $eventId;
if ($sendNow) {
$this->deliverEvent($eventId, false);
}
return $payload;
}
public function deliverEvent(int $eventId, bool $manual = false): array
{
$event = Db::name('enterprise_order_events')->where('id', $eventId)->find();
if (!$event) {
throw new \RuntimeException('事件不存在');
}
$customer = Db::name('enterprise_customers')->where('id', (int)$event['customer_id'])->find();
if (!$customer) {
throw new \RuntimeException('客户不存在');
}
$url = trim((string)($customer['webhook_url'] ?? ''));
$appKey = (string)Db::name('enterprise_customer_apps')
->where('customer_id', (int)$customer['id'])
->where('status', 'enabled')
->order('id', 'asc')
->value('app_key');
$attemptNo = (int)Db::name('enterprise_webhook_deliveries')->where('event_id', $eventId)->count() + 1;
if (!(bool)($customer['webhook_enabled'] ?? false) || $url === '' || $appKey === '') {
$delivery = $this->saveDelivery($event, $url, $appKey, $attemptNo, 'skipped', 0, '', 'Webhook未启用或配置不完整', $manual);
return ['delivery' => $delivery, 'sent' => false];
}
$payload = json_decode((string)$event['payload_json'], true);
if (!is_array($payload)) {
$payload = [];
}
$payload['event_id'] = $eventId;
$result = $this->postJson($url, $payload, $appKey);
$status = ($result['http_status'] >= 200 && $result['http_status'] < 300 && $result['error_message'] === '') ? 'success' : 'failed';
$delivery = $this->saveDelivery(
$event,
$url,
$appKey,
$attemptNo,
$status,
$result['http_status'],
$result['response_body'],
$result['error_message'],
$manual
);
return ['delivery' => $delivery, 'sent' => $status === 'success'];
}
public function formatEvent(array $item): array
{
return [
'id' => (int)$item['id'],
'customer_id' => (int)$item['customer_id'],
'order_id' => (int)$item['order_id'],
'external_order_no' => (string)$item['external_order_no'],
'event_code' => (string)$item['event_code'],
'event_text' => (string)$item['event_text'],
'status_code' => (string)$item['status_code'],
'status_text' => (string)$item['status_text'],
'occurred_at' => (string)$item['occurred_at'],
'created_at' => (string)$item['created_at'],
];
}
public function formatDelivery(array $item): array
{
return [
'id' => (int)$item['id'],
'event_id' => (int)$item['event_id'],
'customer_id' => (int)$item['customer_id'],
'webhook_url' => (string)$item['webhook_url'],
'app_key' => (string)$item['app_key'],
'attempt_no' => (int)$item['attempt_no'],
'delivery_status' => (string)$item['delivery_status'],
'delivery_status_text' => $this->deliveryStatusText((string)$item['delivery_status']),
'http_status' => (int)$item['http_status'],
'response_body' => (string)($item['response_body'] ?? ''),
'error_message' => (string)($item['error_message'] ?? ''),
'is_manual' => (bool)($item['is_manual'] ?? false),
'sent_at' => (string)($item['sent_at'] ?? ''),
'created_at' => (string)($item['created_at'] ?? ''),
];
}
private function eventMeta(string $eventCode): array
{
return match ($eventCode) {
'order_created' => ['event_text' => '订单创建', 'status_code' => 'pending_shipping', 'status_text' => '待寄送商品'],
'inbound_received' => ['event_text' => '快递已到仓', 'status_code' => 'received', 'status_text' => '鉴定中心已收货'],
'appraising' => ['event_text' => '物品鉴定中', 'status_code' => 'appraising', 'status_text' => '物品鉴定中'],
'appraisal_finished' => ['event_text' => '物品鉴定完成', 'status_code' => 'generating_report', 'status_text' => '物品鉴定完成'],
'report_published' => ['event_text' => '报告已发布', 'status_code' => 'report_published', 'status_text' => '报告已发布'],
'return_shipped' => ['event_text' => '物品已寄回', 'status_code' => 'return_shipped', 'status_text' => '物品已寄回'],
'completed' => ['event_text' => '订单已完成', 'status_code' => 'completed', 'status_text' => '已完成'],
'supplement_required' => ['event_text' => '需要补充资料', 'status_code' => 'pending_supplement', 'status_text' => '需要补充资料'],
default => ['event_text' => $eventCode, 'status_code' => $eventCode, 'status_text' => $eventCode],
};
}
private function postJson(string $url, array $payload, string $appKey): array
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-AXY-App-Key: ' . $appKey,
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'http_status' => $httpStatus,
'response_body' => is_string($response) ? substr($response, 0, 2000) : '',
'error_message' => $errno ? $error : '',
];
}
private function saveDelivery(
array $event,
string $url,
string $appKey,
int $attemptNo,
string $status,
int $httpStatus,
string $responseBody,
string $errorMessage,
bool $manual
): array {
$now = date('Y-m-d H:i:s');
$id = (int)Db::name('enterprise_webhook_deliveries')->insertGetId([
'event_id' => (int)$event['id'],
'customer_id' => (int)$event['customer_id'],
'webhook_url' => $url,
'app_key' => $appKey,
'attempt_no' => $attemptNo,
'delivery_status' => $status,
'http_status' => $httpStatus,
'response_body' => $responseBody,
'error_message' => $errorMessage,
'is_manual' => $manual ? 1 : 0,
'sent_at' => $status === 'skipped' ? null : $now,
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('enterprise_webhook_deliveries')->where('id', $id)->find() ?: [];
}
private function deliveryStatusText(string $status): string
{
return match ($status) {
'success' => '推送成功',
'failed' => '推送失败',
'skipped' => '已跳过',
default => $status,
};
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace app\support;
use support\think\Db;
class FileStorageConfigService
{
private const GROUP = 'file_storage';
public function getConfig(): array
{
$rows = Db::name('system_configs')
->where('config_group', self::GROUP)
->column('config_value', 'config_key');
return [
'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')),
'public_base_url' => trim((string)($rows['public_base_url'] ?? '')),
'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')),
'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')),
'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')),
'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')),
'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')),
'oss_path_prefix' => trim((string)($rows['oss_path_prefix'] ?? '')),
'qiniu_bucket' => trim((string)($rows['qiniu_bucket'] ?? '')),
'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')),
'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')),
'qiniu_bucket_domain' => trim((string)($rows['qiniu_bucket_domain'] ?? '')),
'qiniu_path_prefix' => trim((string)($rows['qiniu_path_prefix'] ?? '')),
];
}
public function clearCache(): void
{
// noop; kept for call-site compatibility.
}
public function driver(): string
{
return $this->getConfig()['driver'];
}
public function isOss(): bool
{
return $this->driver() === 'oss';
}
public function isQiniu(): bool
{
return $this->driver() === 'qiniu';
}
public function assertReady(): void
{
if ($this->isOss()) {
$config = $this->getConfig();
$requiredKeys = [
'oss_endpoint' => 'OSS Endpoint',
'oss_bucket' => 'OSS Bucket',
'oss_access_key_id' => 'OSS AccessKey ID',
'oss_access_key_secret' => 'OSS AccessKey Secret',
];
foreach ($requiredKeys as $key => $label) {
if (trim((string)($config[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('%s 未配置,当前无法使用 OSS 存储', $label));
}
}
return;
}
if ($this->isQiniu()) {
$config = $this->getConfig();
$requiredKeys = [
'qiniu_bucket' => '七牛 Bucket',
'qiniu_access_key' => '七牛 AccessKey',
'qiniu_secret_key' => '七牛 SecretKey',
];
foreach ($requiredKeys as $key => $label) {
if (trim((string)($config[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('%s 未配置,当前无法使用七牛云存储', $label));
}
}
}
}
public function publicBaseUrl(): string
{
$config = $this->getConfig();
if ($config['public_base_url'] !== '') {
return $this->normalizeBaseUrl($config['public_base_url']);
}
if ($this->isOss() && $config['oss_bucket_domain'] !== '') {
return $this->normalizeBaseUrl($config['oss_bucket_domain']);
}
if ($this->isOss() && $config['oss_endpoint'] !== '' && $config['oss_bucket'] !== '') {
return $this->normalizeBaseUrl(sprintf(
'https://%s.%s',
$config['oss_bucket'],
$this->normalizeEndpointHost($config['oss_endpoint'])
));
}
if ($this->isQiniu() && $config['qiniu_bucket_domain'] !== '') {
return $this->normalizeBaseUrl($config['qiniu_bucket_domain']);
}
$notifyUrl = Db::name('system_configs')
->where('config_group', 'payment')
->where('config_key', 'notify_url')
->value('config_value');
if (is_string($notifyUrl) && trim($notifyUrl) !== '') {
return $this->extractOrigin($notifyUrl);
}
return '';
}
public function bucket(): string
{
return $this->getConfig()['oss_bucket'];
}
public function qiniuBucket(): string
{
return $this->getConfig()['qiniu_bucket'];
}
public function endpoint(): string
{
return $this->normalizeEndpointHost($this->getConfig()['oss_endpoint']);
}
public function accessKeyId(): string
{
return $this->getConfig()['oss_access_key_id'];
}
public function qiniuAccessKey(): string
{
return $this->getConfig()['qiniu_access_key'];
}
public function accessKeySecret(): string
{
return $this->getConfig()['oss_access_key_secret'];
}
public function qiniuSecretKey(): string
{
return $this->getConfig()['qiniu_secret_key'];
}
public function objectKey(string $relativePath): string
{
$relativePath = ltrim($relativePath, '/');
$config = $this->getConfig();
$prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/');
if ($prefix === '') {
return $relativePath;
}
return $prefix . '/' . $relativePath;
}
public function removePathPrefix(string $objectKey): string
{
$objectKey = ltrim($objectKey, '/');
$config = $this->getConfig();
$prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/');
if ($prefix === '') {
return $objectKey;
}
if (str_starts_with($objectKey, $prefix . '/')) {
return substr($objectKey, strlen($prefix) + 1);
}
return $objectKey;
}
public function normalizeDriver(string $value): string
{
return in_array($value, ['oss', 'qiniu'], true) ? $value : 'local';
}
public function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
return '';
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function extractOrigin(string $url): string
{
$parts = parse_url(trim($url));
$scheme = (string)($parts['scheme'] ?? '');
$host = (string)($parts['host'] ?? '');
$port = (string)($parts['port'] ?? '');
if ($host === '') {
return $this->normalizeBaseUrl($url);
}
$origin = ($scheme !== '' ? $scheme : 'https') . '://' . $host;
if ($port !== '' && !(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
$origin .= ':' . $port;
}
return rtrim($origin, '/');
}
public function normalizeEndpointHost(string $endpoint): string
{
$endpoint = trim($endpoint);
if ($endpoint === '') {
return '';
}
if (preg_match('/^https?:\/\//i', $endpoint)) {
$host = parse_url($endpoint, PHP_URL_HOST);
if (is_string($host) && $host !== '') {
return $host;
}
}
return preg_replace('#^https?://#i', '', rtrim($endpoint, '/')) ?: '';
}
}

View File

@@ -0,0 +1,363 @@
<?php
namespace app\support;
use OSS\OssClient;
use Qiniu\Auth as QiniuAuth;
use Qiniu\Config as QiniuConfig;
use Qiniu\Storage\BucketManager as QiniuBucketManager;
use Qiniu\Storage\UploadManager as QiniuUploadManager;
use support\Request;
use function dirname;
use function file_exists;
use function file_put_contents;
use function is_dir;
use function is_file;
use function ltrim;
use function mkdir;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;
class FileStorageService
{
private static bool $caBundleInitialized = false;
private ?OssClient $ossClient = null;
private ?QiniuAuth $qiniuAuth = null;
public function publicUrl(Request $request, string $value): string
{
return $this->assetUrlService()->buildUrl($request, $this->storagePath($value));
}
public function normalizeUrl(string $value, Request $request): string
{
return $this->assetUrlService()->normalizeUrl($value, $request);
}
public function storagePath(string $value): string
{
return ltrim($this->assetUrlService()->storagePath($value), '/');
}
public function putUploadedFile(mixed $file, string $relativePath): void
{
$relativePath = $this->storagePath($relativePath);
if ($this->configService()->isOss()) {
$this->configService()->assertReady();
$realPath = $file->getRealPath();
if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) {
throw new \RuntimeException('上传文件无效');
}
$this->ossClient()->uploadFile(
$this->configService()->bucket(),
$this->configService()->objectKey($relativePath),
$realPath
);
return;
}
if ($this->configService()->isQiniu()) {
$this->configService()->assertReady();
$realPath = $file->getRealPath();
if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) {
throw new \RuntimeException('上传文件无效');
}
$key = $this->configService()->objectKey($relativePath);
$this->qiniuUploadFile($realPath, $key);
return;
}
$target = public_path() . '/' . $relativePath;
if (!is_dir(dirname($target))) {
mkdir(dirname($target), 0775, true);
}
$file->move($target);
}
public function putContents(string $relativePath, string $content): void
{
$relativePath = $this->storagePath($relativePath);
if ($this->configService()->isOss()) {
$this->configService()->assertReady();
$tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_oss_');
if ($tmpFile === false) {
throw new \RuntimeException('无法创建临时文件');
}
file_put_contents($tmpFile, $content);
try {
$this->ossClient()->uploadFile(
$this->configService()->bucket(),
$this->configService()->objectKey($relativePath),
$tmpFile
);
} finally {
if (file_exists($tmpFile)) {
@unlink($tmpFile);
}
}
return;
}
if ($this->configService()->isQiniu()) {
$this->configService()->assertReady();
$tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_qiniu_');
if ($tmpFile === false) {
throw new \RuntimeException('无法创建临时文件');
}
file_put_contents($tmpFile, $content);
try {
$key = $this->configService()->objectKey($relativePath);
$this->qiniuUploadFile($tmpFile, $key);
} finally {
if (file_exists($tmpFile)) {
@unlink($tmpFile);
}
}
return;
}
$target = public_path() . '/' . $relativePath;
if (!is_dir(dirname($target))) {
mkdir(dirname($target), 0775, true);
}
file_put_contents($target, $content);
}
public function exists(string $value): bool
{
$relativePath = $this->storagePath($value);
if ($relativePath === '') {
return false;
}
if ($this->configService()->isOss()) {
$this->configService()->assertReady();
return $this->ossClient()->doesObjectExist(
$this->configService()->bucket(),
$this->configService()->objectKey($relativePath)
);
}
if ($this->configService()->isQiniu()) {
$this->configService()->assertReady();
[$ret, $err] = $this->qiniuBucketManager()->stat(
$this->configService()->qiniuBucket(),
$this->configService()->objectKey($relativePath)
);
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
[$ret, $err] = $this->qiniuBucketManager(false)->stat(
$this->configService()->qiniuBucket(),
$this->configService()->objectKey($relativePath)
);
}
if ($err === null) {
return true;
}
if ((int)$err->code() === 612) {
return false;
}
throw new \RuntimeException('七牛云文件检查失败:' . $err->message());
}
return is_file(public_path() . '/' . $relativePath);
}
public function delete(string $value): void
{
$relativePath = $this->storagePath($value);
if ($relativePath === '') {
return;
}
if ($this->configService()->isOss()) {
$this->configService()->assertReady();
$this->ossClient()->deleteObject(
$this->configService()->bucket(),
$this->configService()->objectKey($relativePath)
);
return;
}
if ($this->configService()->isQiniu()) {
$this->configService()->assertReady();
[$ret, $err] = $this->qiniuBucketManager()->delete(
$this->configService()->qiniuBucket(),
$this->configService()->objectKey($relativePath)
);
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
[$ret, $err] = $this->qiniuBucketManager(false)->delete(
$this->configService()->qiniuBucket(),
$this->configService()->objectKey($relativePath)
);
}
if ($err !== null && (int)$err->code() !== 612) {
throw new \RuntimeException('七牛云删除失败:' . $err->message());
}
return;
}
$fullPath = public_path() . '/' . $relativePath;
if (file_exists($fullPath) && is_file($fullPath)) {
@unlink($fullPath);
}
}
private function ossClient(): OssClient
{
if ($this->ossClient instanceof OssClient) {
return $this->ossClient;
}
$this->configService()->assertReady();
$this->ensureCaBundleConfigured();
return $this->ossClient = new OssClient(
$this->configService()->accessKeyId(),
$this->configService()->accessKeySecret(),
$this->configService()->endpoint()
);
}
private function qiniuAuth(): QiniuAuth
{
if ($this->qiniuAuth instanceof QiniuAuth) {
return $this->qiniuAuth;
}
$this->configService()->assertReady();
$this->ensureCaBundleConfigured();
return $this->qiniuAuth = new QiniuAuth(
$this->configService()->qiniuAccessKey(),
$this->configService()->qiniuSecretKey()
);
}
private function qiniuConfig(bool $useHttps = true): QiniuConfig
{
$config = new QiniuConfig();
$config->useHTTPS = $useHttps;
$config->useCdnDomains = false;
return $config;
}
private function qiniuUploadManager(bool $useHttps = true): QiniuUploadManager
{
return new QiniuUploadManager($this->qiniuConfig($useHttps));
}
private function qiniuBucketManager(bool $useHttps = true): QiniuBucketManager
{
return new QiniuBucketManager(
$this->qiniuAuth(),
$this->qiniuConfig($useHttps)
);
}
private function qiniuUploadFile(string $filePath, string $key): void
{
$token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key);
try {
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath);
} catch (\Throwable $e) {
$err = $e;
}
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
try {
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath);
} catch (\Throwable $e) {
$err = $e;
}
}
if ($err !== null) {
throw new \RuntimeException('七牛云上传失败:' . $this->qiniuErrorMessage($err));
}
}
private function shouldRetryQiniuWithoutHttps(mixed $err): bool
{
return stripos($this->qiniuErrorMessage($err), 'SSL certificate problem') !== false;
}
private function qiniuErrorMessage(mixed $err): string
{
if ($err instanceof \Throwable) {
return $err->getMessage();
}
if (is_object($err) && method_exists($err, 'message')) {
return (string)$err->message();
}
if (is_string($err) && $err !== '') {
return $err;
}
return '未知错误';
}
private function assetUrlService(): PublicAssetUrlService
{
return new PublicAssetUrlService();
}
private function configService(): FileStorageConfigService
{
return new FileStorageConfigService();
}
private function ensureCaBundleConfigured(): void
{
if (self::$caBundleInitialized) {
return;
}
$currentCurlCa = (string)ini_get('curl.cainfo');
$currentOpensslCa = (string)ini_get('openssl.cafile');
if (($currentCurlCa !== '' && is_file($currentCurlCa)) || ($currentOpensslCa !== '' && is_file($currentOpensslCa))) {
self::$caBundleInitialized = true;
return;
}
$candidates = [
'/etc/ssl/cert.pem',
'/private/etc/ssl/cert.pem',
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/opt/homebrew/etc/openssl@3/cert.pem',
'/usr/local/etc/openssl@3/cert.pem',
];
foreach ($candidates as $path) {
if (!is_file($path)) {
continue;
}
@ini_set('curl.cainfo', $path);
@ini_set('openssl.cafile', $path);
self::$caBundleInitialized = true;
return;
}
}
}

View File

@@ -0,0 +1,704 @@
<?php
namespace app\support;
use support\Request;
use support\think\Db;
class MaterialTagService
{
private const VERIFY_CODE_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const MAX_BATCH_COUNT = 10000;
public function createBatch(int $count, string $remark, int $adminId, string $adminName): array
{
if ($count < 1 || $count > self::MAX_BATCH_COUNT) {
throw new \InvalidArgumentException('单批生成数量需在 1-10000 之间');
}
$h5BaseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
if ($h5BaseUrl === '') {
throw new \InvalidArgumentException('请先在系统配置中填写 H5 页面根地址');
}
$now = date('Y-m-d H:i:s');
$batchNo = $this->generateUniqueBatchNo();
Db::startTrans();
try {
$batchId = (int)Db::name('material_batches')->insertGetId([
'batch_no' => $batchNo,
'total_count' => $count,
'remark' => mb_substr($remark, 0, 500),
'download_count' => 0,
'created_by' => $adminId,
'created_by_name' => $adminName,
'created_at' => $now,
'updated_at' => $now,
]);
$rows = [];
$pendingTokens = [];
for ($i = 0; $i < $count; $i++) {
$token = $this->generateUniqueToken($pendingTokens);
$pendingTokens[$token] = true;
$rows[] = [
'batch_id' => $batchId,
'qr_token' => $token,
'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl),
'verify_code' => $this->generateVerifyCode(),
'bind_status' => 'unbound',
'scan_count' => 0,
'verify_count' => 0,
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($rows, 500) as $chunk) {
Db::name('material_tag_codes')->insertAll($chunk);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'id' => $batchId,
'batch_no' => $batchNo,
'total_count' => $count,
'remark' => $remark,
];
}
public function listBatches(array $filters): array
{
$keyword = trim((string)($filters['keyword'] ?? ''));
$qrUrl = trim((string)($filters['qr_url'] ?? ''));
$verifyCode = trim((string)($filters['verify_code'] ?? ''));
$dateStart = trim((string)($filters['date_start'] ?? ''));
$dateEnd = trim((string)($filters['date_end'] ?? ''));
$query = Db::name('material_batches')->alias('b')->field('b.*')->order('b.id', 'desc');
$matchedCodeRows = [];
$codeSearchValue = $qrUrl !== '' ? $qrUrl : ($verifyCode !== '' ? $verifyCode : $keyword);
if ($codeSearchValue !== '') {
$searchToken = $this->extractToken($codeSearchValue) ?: $codeSearchValue;
$codeQuery = Db::name('material_tag_codes')
->where(function ($builder) use ($codeSearchValue, $qrUrl, $verifyCode, $searchToken) {
if ($qrUrl !== '') {
$builder->whereRaw('(qr_url LIKE :qr_url OR qr_token = :qr_token)', [
'qr_url' => "%{$qrUrl}%",
'qr_token' => $searchToken,
]);
return;
}
if ($verifyCode !== '') {
$builder->where('verify_code', $verifyCode);
return;
}
$builder->whereRaw('(qr_url LIKE :keyword_qr_url OR qr_token = :keyword_qr_token OR verify_code = :keyword_verify_code)', [
'keyword_qr_url' => "%{$codeSearchValue}%",
'keyword_qr_token' => $searchToken,
'keyword_verify_code' => $codeSearchValue,
]);
});
$matchedCodeRows = $codeQuery->order('id', 'asc')->select()->toArray();
if (!$matchedCodeRows) {
return [];
}
$query->whereIn('b.id', array_values(array_unique(array_map(fn (array $item) => (int)$item['batch_id'], $matchedCodeRows))));
}
if ($dateStart !== '') {
$query->where('b.created_at', '>=', $dateStart . (strlen($dateStart) === 10 ? ' 00:00:00' : ''));
}
if ($dateEnd !== '') {
$query->where('b.created_at', '<=', $dateEnd . (strlen($dateEnd) === 10 ? ' 23:59:59' : ''));
}
$rows = $query->select()->toArray();
if (!$rows) {
return [];
}
$batchIds = array_map(fn (array $item) => (int)$item['id'], $rows);
$boundCounts = Db::name('material_tag_codes')
->whereIn('batch_id', $batchIds)
->where('bind_status', 'bound')
->group('batch_id')
->column('COUNT(*) AS c', 'batch_id');
$matchedByBatch = [];
if ($matchedCodeRows) {
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $matchedCodeRows))));
foreach ($matchedCodeRows as $codeRow) {
$batchId = (int)$codeRow['batch_id'];
$matchedByBatch[$batchId][] = $this->formatTagCode($codeRow, $reportMap[(int)($codeRow['report_id'] ?? 0)] ?? null);
}
}
return array_map(function (array $row) use ($boundCounts, $matchedByBatch) {
$id = (int)$row['id'];
return [
'id' => $id,
'batch_no' => (string)$row['batch_no'],
'total_count' => (int)$row['total_count'],
'bound_count' => (int)($boundCounts[$id] ?? 0),
'download_count' => (int)$row['download_count'],
'remark' => (string)($row['remark'] ?? ''),
'created_by_name' => (string)($row['created_by_name'] ?? ''),
'last_downloaded_at' => (string)($row['last_downloaded_at'] ?? ''),
'created_at' => (string)$row['created_at'],
'matched_codes' => $matchedByBatch[$id] ?? [],
];
}, $rows);
}
public function detail(int $batchId, string $keyword = ''): array
{
$batch = Db::name('material_batches')->where('id', $batchId)->find();
if (!$batch) {
throw new \RuntimeException('物料批次不存在', 404);
}
$query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc');
$keyword = trim($keyword);
if ($keyword !== '') {
$token = $this->extractToken($keyword);
$query->where(function ($builder) use ($keyword, $token) {
$builder->whereRaw('(qr_url LIKE :detail_qr_url OR verify_code = :detail_verify_code OR qr_token = :detail_qr_token)', [
'detail_qr_url' => "%{$keyword}%",
'detail_verify_code' => $keyword,
'detail_qr_token' => $token ?: $keyword,
]);
});
}
$codes = $query->select()->toArray();
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes))));
return [
'batch' => [
'id' => (int)$batch['id'],
'batch_no' => (string)$batch['batch_no'],
'total_count' => (int)$batch['total_count'],
'download_count' => (int)$batch['download_count'],
'remark' => (string)($batch['remark'] ?? ''),
'created_by_name' => (string)($batch['created_by_name'] ?? ''),
'last_downloaded_at' => (string)($batch['last_downloaded_at'] ?? ''),
'created_at' => (string)$batch['created_at'],
],
'codes' => array_map(fn (array $row) => $this->formatTagCode($row, $reportMap[(int)($row['report_id'] ?? 0)] ?? null), $codes),
];
}
public function downloadBatch(int $batchId, Request $request): array
{
$batch = Db::name('material_batches')->where('id', $batchId)->find();
if (!$batch) {
throw new \RuntimeException('物料批次不存在', 404);
}
$codes = Db::name('material_tag_codes')
->where('batch_id', $batchId)
->order('id', 'asc')
->field(['qr_url', 'verify_code'])
->select()
->toArray();
$filename = sprintf('material-batch-%s.xlsx', preg_replace('/[^a-zA-Z0-9_-]/', '-', (string)$batch['batch_no']));
$binary = $this->buildXlsxBinary($codes);
$now = date('Y-m-d H:i:s');
$adminId = (int)$request->header('x-admin-id', 0);
$adminName = trim((string)$request->header('x-admin-name', ''));
Db::startTrans();
try {
Db::name('material_batches')->where('id', $batchId)->update([
'download_count' => (int)$batch['download_count'] + 1,
'last_downloaded_at' => $now,
'updated_at' => $now,
]);
Db::name('material_batch_download_logs')->insert([
'batch_id' => $batchId,
'operator_id' => $adminId,
'operator_name' => $adminName,
'ip' => substr((string)$request->getRealIp(), 0, 64),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'downloaded_at' => $now,
'created_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'filename' => $filename,
'content' => $binary,
];
}
public function bindTagToReportByTask(int $taskId, string $input, Request $request): array
{
$tag = $this->findTagByInput($input);
if (!$tag) {
throw new \InvalidArgumentException('吊牌二维码不存在');
}
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
}
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
if (!$task) {
throw new \RuntimeException('任务不存在', 404);
}
$report = Db::name('reports')
->where('order_id', (int)$task['order_id'])
->where('report_type', 'appraisal')
->order('id', 'desc')
->find();
if (!$report) {
throw new \InvalidArgumentException('请先提交鉴定结论生成报告草稿后再绑定吊牌');
}
if (($report['report_status'] ?? '') === 'published') {
throw new \InvalidArgumentException('报告已发布,不能再绑定或更换吊牌');
}
$existing = Db::name('material_tag_codes')->where('report_id', (int)$report['id'])->find();
if ($existing) {
throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定');
}
$now = date('Y-m-d H:i:s');
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
'report_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'bind_status' => 'bound',
'bound_task_id' => $taskId,
'bound_order_id' => (int)$task['order_id'],
'bound_by' => (int)$request->header('x-admin-id', 0),
'bound_by_name' => trim((string)$request->header('x-admin-name', '')),
'bound_at' => $now,
'updated_at' => $now,
]);
$fresh = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find();
return $this->formatTagCode($fresh ?: $tag, [
'id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_status' => (string)$report['report_status'],
]);
}
public function findBoundTagForReport(int $reportId): ?array
{
if ($reportId <= 0) {
return null;
}
$tag = Db::name('material_tag_codes')->where('report_id', $reportId)->find();
if (!$tag) {
return null;
}
$report = Db::name('reports')->where('id', $reportId)->find();
return $this->formatTagCode($tag, $report ?: null);
}
public function showPublicTag(string $token, Request $request): array
{
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
if (!$tag) {
throw new \RuntimeException('吊牌不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
'scan_count' => (int)$tag['scan_count'] + 1,
'last_scanned_at' => $now,
'updated_at' => $now,
]);
$this->insertScanLog($tag, 'scan', false, $request, $now);
$tag['scan_count'] = (int)$tag['scan_count'] + 1;
$tag['last_scanned_at'] = $now;
$report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null;
if (!$report) {
return [
'tag_status' => 'unbound',
'status_text' => '吊牌尚未关联报告',
'message' => '该吊牌已完成建码,但暂未绑定鉴定报告。',
'qr_token' => (string)$tag['qr_token'],
'qr_url' => (string)$tag['qr_url'],
'scan_count' => (int)$tag['scan_count'],
'verify_count' => (int)$tag['verify_count'],
'report_summary' => null,
'product_summary' => [],
'result_summary' => [],
'verify_passed' => false,
];
}
if (($report['report_status'] ?? '') !== 'published') {
return [
'tag_status' => 'pending_report',
'status_text' => '报告生成中',
'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。',
'qr_token' => (string)$tag['qr_token'],
'qr_url' => (string)$tag['qr_url'],
'scan_count' => (int)$tag['scan_count'],
'verify_count' => (int)$tag['verify_count'],
'report_summary' => [
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'institution_name' => (string)$report['institution_name'],
'publish_time' => (string)($report['publish_time'] ?? ''),
],
'product_summary' => [],
'result_summary' => [],
'verify_passed' => false,
];
}
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find() ?: [];
return [
'tag_status' => 'published',
'status_text' => '报告已发布',
'message' => '该吊牌已关联正式鉴定报告,可输入吊牌验真编码完成组合验真。',
'qr_token' => (string)$tag['qr_token'],
'qr_url' => (string)$tag['qr_url'],
'scan_count' => (int)$tag['scan_count'],
'verify_count' => (int)$tag['verify_count'],
'report_summary' => [
'report_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'institution_name' => (string)$report['institution_name'],
'publish_time' => (string)($report['publish_time'] ?? ''),
],
'product_summary' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
'result_summary' => $this->decodeJsonField($content['result_snapshot_json'] ?? null),
'verify_passed' => false,
];
}
public function verifyPublicTag(string $token, string $reportNo, string $verifyCode, Request $request): array
{
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
if (!$tag) {
throw new \RuntimeException('吊牌不存在', 404);
}
$report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null;
$passed = $report
&& ($report['report_status'] ?? '') === 'published'
&& hash_equals((string)$tag['verify_code'], $verifyCode)
&& (string)$report['report_no'] === $reportNo;
$now = date('Y-m-d H:i:s');
if ($passed) {
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
'verify_count' => (int)$tag['verify_count'] + 1,
'last_verified_at' => $now,
'updated_at' => $now,
]);
}
$this->insertScanLog($tag, 'verify_code', $passed, $request, $now, $verifyCode, $reportNo);
if (!$passed) {
return [
'verify_passed' => false,
'verify_message' => '验真编码与当前吊牌或报告不匹配,请核对后重试。',
'verify_count' => (int)$tag['verify_count'],
];
}
return [
'verify_passed' => true,
'verify_message' => '组合验真通过,该吊牌二维码、报告编号与验真编码匹配。',
'verify_count' => (int)$tag['verify_count'] + 1,
];
}
public function extractToken(string $input): string
{
$value = trim($input);
if ($value === '') {
return '';
}
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5);
$parts = parse_url($decoded);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
if (!empty($query['token'])) {
return trim((string)$query['token']);
}
}
$fragment = (string)($parts['fragment'] ?? '');
if ($fragment !== '') {
$questionPos = strpos($fragment, '?');
if ($questionPos !== false) {
parse_str(substr($fragment, $questionPos + 1), $query);
if (!empty($query['token'])) {
return trim((string)$query['token']);
}
}
}
if (preg_match('/(?:^|[?&])token=([^&#]+)/', $decoded, $matches)) {
return trim((string)rawurldecode($matches[1]));
}
return preg_match('/^[a-zA-Z0-9_-]{16,80}$/', $value) ? $value : '';
}
private function findTagByInput(string $input): ?array
{
$value = trim($input);
if ($value === '') {
return null;
}
$token = $this->extractToken($value);
if ($token !== '') {
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
if ($tag) {
return $tag;
}
}
return Db::name('material_tag_codes')->where('qr_url', $value)->find() ?: null;
}
private function formatTagCode(array $row, ?array $report): array
{
return [
'id' => (int)$row['id'],
'batch_id' => (int)$row['batch_id'],
'qr_token' => (string)$row['qr_token'],
'qr_url' => (string)$row['qr_url'],
'verify_code' => (string)$row['verify_code'],
'bind_status' => (string)$row['bind_status'],
'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定',
'report_id' => (int)($row['report_id'] ?? 0),
'report_no' => (string)($row['report_no'] ?: ($report['report_no'] ?? '')),
'report_status' => (string)($report['report_status'] ?? ''),
'scan_count' => (int)($row['scan_count'] ?? 0),
'verify_count' => (int)($row['verify_count'] ?? 0),
'bound_at' => (string)($row['bound_at'] ?? ''),
'bound_by_name' => (string)($row['bound_by_name'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
private function loadReportMap(array $reportIds): array
{
$reportIds = array_values(array_unique(array_filter(array_map('intval', $reportIds))));
if (!$reportIds) {
return [];
}
$rows = Db::name('reports')->whereIn('id', $reportIds)->select()->toArray();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['id']] = $row;
}
return $map;
}
private function buildMaterialTagUrl(string $token, string $baseUrl): string
{
return $baseUrl . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
}
private function generateUniqueBatchNo(): string
{
for ($i = 0; $i < 20; $i++) {
$candidate = sprintf('MAT-%s-%04d', date('YmdHis'), random_int(0, 9999));
if (!Db::name('material_batches')->where('batch_no', $candidate)->find()) {
return $candidate;
}
}
return 'MAT-' . date('YmdHis') . '-' . bin2hex(random_bytes(3));
}
private function generateUniqueToken(array $pendingTokens): string
{
for ($i = 0; $i < 30; $i++) {
$candidate = 'mt_' . bin2hex(random_bytes(16));
if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) {
return $candidate;
}
}
throw new \RuntimeException('二维码 token 生成失败,请重试');
}
private function generateVerifyCode(): string
{
$code = '';
$max = strlen(self::VERIFY_CODE_CHARS) - 1;
for ($i = 0; $i < 6; $i++) {
$code .= self::VERIFY_CODE_CHARS[random_int(0, $max)];
}
return $code;
}
private function getSystemConfigValue(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function normalizeH5BaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
return '';
}
return rtrim($baseUrl, '/');
}
private function decodeJsonField(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
return json_decode($value, true) ?: [];
}
return [];
}
private function insertScanLog(array $tag, string $verifyType, bool $passed, Request $request, string $now, string $verifyCode = '', string $reportNo = ''): void
{
Db::name('material_tag_scan_logs')->insert([
'tag_code_id' => (int)$tag['id'],
'batch_id' => (int)$tag['batch_id'],
'report_id' => (int)($tag['report_id'] ?? 0) ?: null,
'report_no' => $reportNo !== '' ? $reportNo : (string)($tag['report_no'] ?? ''),
'verify_type' => $verifyType,
'verify_code_input' => $verifyCode,
'verify_passed' => $passed ? 1 : 0,
'ip' => substr((string)$request->getRealIp(), 0, 64),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'scanned_at' => $now,
'created_at' => $now,
]);
}
private function buildXlsxBinary(array $rows): string
{
if (!class_exists(\ZipArchive::class)) {
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成 Excel');
}
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
if ($tmpFile === false) {
throw new \RuntimeException('临时文件创建失败');
}
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
@unlink($tmpFile);
throw new \RuntimeException('Excel 文件创建失败');
}
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
$zip->close();
$content = file_get_contents($tmpFile);
@unlink($tmpFile);
if ($content === false) {
throw new \RuntimeException('Excel 文件读取失败');
}
return $content;
}
private function xlsxContentTypesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
. '<Default Extension="xml" ContentType="application/xml"/>'
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
. '</Types>';
}
private function xlsxRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
. '</Relationships>';
}
private function xlsxWorkbookXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
. '</workbook>';
}
private function xlsxWorkbookRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
. '</Relationships>';
}
private function xlsxSheetXml(array $rows): string
{
$sheetRows = [
['二维码链接', '验真编码'],
...array_map(fn (array $row) => [(string)$row['qr_url'], (string)$row['verify_code']], $rows),
];
$xmlRows = [];
foreach ($sheetRows as $rowIndex => $row) {
$excelRow = $rowIndex + 1;
$xmlRows[] = sprintf(
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
$excelRow,
$excelRow,
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
$excelRow,
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
. '</worksheet>';
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace app\support;
use support\think\Db;
class MessageDispatcher
{
public function sendInboxEvent(string $eventCode, array $context): bool
{
$userId = (int)($context['user_id'] ?? 0);
$bizType = trim((string)($context['biz_type'] ?? ''));
$bizId = (int)($context['biz_id'] ?? 0);
if ($userId <= 0 || $bizType === '' || $bizId <= 0) {
return false;
}
if (!$this->shouldSendByPreference($userId, $eventCode)) {
return false;
}
$rule = Db::name('message_rules')
->where('event_code', $eventCode)
->where('channel', 'inbox')
->where('is_enabled', 1)
->order('id', 'asc')
->find();
if (!$rule) {
$disabledRule = Db::name('message_rules')
->where('event_code', $eventCode)
->where('channel', 'inbox')
->find();
if ($disabledRule) {
return false;
}
}
$template = null;
if ($rule) {
$template = Db::name('message_templates')
->where('id', (int)$rule['template_id'])
->where('channel', 'inbox')
->where('is_enabled', 1)
->find();
}
$alreadySent = Db::name('message_logs')
->where('user_id', $userId)
->where('biz_type', $bizType)
->where('biz_id', $bizId)
->where('channel', 'inbox')
->where('status', 'sent')
->find();
if ($alreadySent) {
return false;
}
$title = $this->renderTemplate(
$template['title'] ?? (string)($context['fallback_title'] ?? ''),
$context
);
$content = $this->renderTemplate(
$template['content'] ?? (string)($context['fallback_content'] ?? ''),
$context
);
if ($title === '' && $content === '') {
return false;
}
$now = date('Y-m-d H:i:s');
Db::name('user_messages')->insert([
'user_id' => $userId,
'title' => $title !== '' ? $title : '系统通知',
'content' => $content,
'biz_type' => $bizType,
'biz_id' => $bizId,
'is_read' => 0,
'read_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('message_logs')->insert([
'user_id' => $userId,
'template_id' => $template['id'] ?? null,
'biz_type' => $bizType,
'biz_id' => $bizId,
'channel' => 'inbox',
'status' => 'sent',
'fail_reason' => '',
'sent_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
return true;
}
private function shouldSendByPreference(int $userId, string $eventCode): bool
{
$preferenceKey = $this->preferenceKeyForEvent($eventCode);
if ($preferenceKey === '') {
return true;
}
$configValue = Db::name('system_configs')
->where('config_group', 'user_settings')
->where('config_key', 'user_' . $userId)
->value('config_value');
if (!is_string($configValue) || $configValue === '') {
return $this->defaultPreference($preferenceKey);
}
$decoded = json_decode($configValue, true);
if (!is_array($decoded)) {
return $this->defaultPreference($preferenceKey);
}
if (!array_key_exists($preferenceKey, $decoded)) {
return $this->defaultPreference($preferenceKey);
}
return (bool)$decoded[$preferenceKey];
}
private function preferenceKeyForEvent(string $eventCode): string
{
return match ($eventCode) {
'order_created' => 'notify_order',
'return_shipped' => 'notify_order',
'return_received' => 'notify_order',
'report_published' => 'notify_report',
'supplement_required' => 'notify_supplement',
'ticket_reply', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'notify_ticket',
default => '',
};
}
private function defaultPreference(string $key): bool
{
return match ($key) {
'marketing_notify', 'privacy_mode' => false,
default => true,
};
}
private function renderTemplate(string $text, array $context): string
{
if ($text === '') {
return '';
}
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', function (array $matches) use ($context) {
$key = $matches[1];
if (!array_key_exists($key, $context)) {
return '';
}
$value = $context[$key];
if (is_scalar($value) || $value === null) {
return (string)$value;
}
return '';
}, $text) ?? $text;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace app\support;
use support\Request;
class PublicAssetUrlService
{
public function buildUrl(Request $request, string $relativePath): string
{
$relativePath = ltrim($relativePath, '/');
$publicPath = $this->configService()->isOss()
? $this->configService()->objectKey($relativePath)
: $relativePath;
return $this->resolveBaseUrl($request) . '/' . ltrim($publicPath, '/');
}
public function normalizeUrl(string $value, Request $request): string
{
$value = trim($value);
if ($value === '') {
return '';
}
$parts = parse_url($value);
$path = (string)($parts['path'] ?? '');
$query = (string)($parts['query'] ?? '');
$fragment = (string)($parts['fragment'] ?? '');
$host = strtolower((string)($parts['host'] ?? ''));
$scheme = (string)($parts['scheme'] ?? '');
if ($scheme === '' && $host === '') {
return $this->appendQueryAndFragment(
$this->buildUrl($request, $path !== '' ? $path : $value),
$query,
$fragment
);
}
if ($path !== '' && $this->shouldRewriteHost($host)) {
return $this->appendQueryAndFragment(
$this->buildUrl($request, $path),
$query,
$fragment
);
}
return $value;
}
public function storagePath(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
$path = parse_url($value, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
$path = ltrim($value, '/');
return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path;
}
$path = ltrim($path, '/');
return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path;
}
private function resolveBaseUrl(Request $request): string
{
$configured = $this->configService()->publicBaseUrl();
if ($configured !== '') {
return $configured;
}
$configured = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ($_ENV['APP_PUBLIC_BASE_URL'] ?? '')));
if ($configured !== '') {
return $this->normalizeBaseUrl($configured);
}
$scheme = trim((string)($request->header('x-forwarded-proto') ?: 'http'));
$host = trim((string)($request->header('x-forwarded-host') ?: $request->header('host') ?: $request->host()));
if (($commaPos = strpos($host, ',')) !== false) {
$host = trim(substr($host, 0, $commaPos));
}
$port = trim((string)($request->header('x-forwarded-port') ?: ''));
if ($port !== '' && strpos($host, ':') === false) {
if (!(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
$host .= ':' . $port;
}
}
return $this->normalizeBaseUrl($scheme . '://' . $host);
}
private function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
return '';
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function configService(): FileStorageConfigService
{
return new FileStorageConfigService();
}
private function shouldRewriteHost(string $host): bool
{
if ($host === '') {
return false;
}
if (in_array($host, ['localhost', '127.0.0.1', '0.0.0.0', 'host.docker.internal'], true)) {
return true;
}
return preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/', $host) === 1;
}
private function appendQueryAndFragment(string $url, string $query, string $fragment): string
{
if ($query !== '') {
$url .= '?' . $query;
}
if ($fragment !== '') {
$url .= '#' . $fragment;
}
return $url;
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace app\support;
class ReportPdfGenerator
{
public function generate(array $payload): string
{
$content = $this->buildContentStream($payload);
$objects = [
1 => '<< /Type /Catalog /Pages 2 0 R >>',
2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
3 => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
4 => sprintf("<< /Length %d >>\nstream\n%s\nendstream", strlen($content), $content),
5 => '<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>',
6 => '<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>',
];
return $this->renderPdf($objects);
}
private function buildContentStream(array $payload): string
{
$title = $this->normalizeText((string)($payload['report_title'] ?? '鉴定报告'));
$serviceProviderText = $this->normalizeText((string)($payload['service_provider_text'] ?? '-'));
$institutionName = $this->normalizeText((string)($payload['institution_name'] ?? '-'));
$reportNo = $this->normalizeText((string)($payload['report_no'] ?? '-'));
$publishTime = $this->normalizeText((string)($payload['publish_time'] ?? '-'));
$resultText = $this->normalizeText((string)($payload['result_text'] ?? '-'));
$resultDesc = $this->normalizeText((string)($payload['result_desc'] ?? '-'));
$productName = $this->normalizeText((string)($payload['product_name'] ?? '-'));
$categoryBrand = $this->normalizeText((string)($payload['category_brand'] ?? '-'));
$specInfo = $this->normalizeText((string)($payload['spec_info'] ?? '-'));
$appraisers = $this->normalizeText((string)($payload['appraisers'] ?? '-'));
$conditionGrade = $this->normalizeText((string)($payload['condition_grade'] ?? '-'));
$valuationRange = $this->normalizeText((string)($payload['valuation_range'] ?? '-'));
$verifyInfo = $this->normalizeText((string)($payload['verify_info'] ?? '-'));
$riskNotice = $this->normalizeText((string)($payload['risk_notice_text'] ?? '-'));
$blocks = [];
$y = 790;
$blocks[] = $this->textBlock($title, 52, $y, 20);
$y -= 32;
$blocks[] = $this->textBlock('正式报告凭证,请以编号验真结果为准。', 52, $y, 10);
$y -= 30;
foreach ([
sprintf('报告编号:%s', $reportNo),
sprintf('出具机构:%s', $institutionName),
sprintf('出具时间:%s', $publishTime),
sprintf('服务类型:%s', $serviceProviderText),
] as $line) {
$blocks[] = $this->textBlock($line, 52, $y, 12);
$y -= 22;
}
$y -= 8;
$blocks[] = $this->textBlock(sprintf('鉴定结论:%s', $resultText), 52, $y, 16);
$y -= 26;
foreach ($this->wrapText('结果说明:' . $resultDesc, 30) as $line) {
$blocks[] = $this->textBlock($line, 52, $y, 11);
$y -= 18;
}
$y -= 8;
foreach ([
sprintf('商品名称:%s', $productName),
sprintf('品类 / 品牌:%s', $categoryBrand),
sprintf('颜色 / 规格:%s', $specInfo),
sprintf('鉴定师:%s', $appraisers),
sprintf('成色评级:%s', $conditionGrade),
sprintf('估值区间:%s', $valuationRange),
sprintf('验真信息:%s', $verifyInfo),
] as $line) {
$blocks[] = $this->textBlock($line, 52, $y, 11);
$y -= 20;
}
$y -= 8;
foreach ($this->wrapText('风险说明:' . $riskNotice, 30) as $line) {
$blocks[] = $this->textBlock($line, 52, $y, 10);
$y -= 17;
}
if ($y > 48) {
$blocks[] = $this->textBlock('安心验鉴定平台', 52, 42, 9);
}
return implode("\n", array_filter($blocks));
}
private function wrapText(string $text, int $maxUnits): array
{
$normalized = $this->normalizeText($text);
if ($normalized === '') {
return ['-'];
}
$chars = preg_split('//u', $normalized, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$lines = [];
$current = '';
$width = 0.0;
foreach ($chars as $char) {
$charWidth = strlen($char) === 1 && ord($char) < 128 ? 0.5 : 1.0;
if ($current !== '' && $width + $charWidth > $maxUnits) {
$lines[] = $current;
$current = '';
$width = 0.0;
}
$current .= $char;
$width += $charWidth;
}
if ($current !== '') {
$lines[] = $current;
}
return $lines ?: ['-'];
}
private function textBlock(string $text, int $x, int $y, int $fontSize): string
{
if ($text === '') {
return '';
}
return sprintf(
"BT\n/F1 %d Tf\n1 0 0 1 %d %d Tm\n<%s> Tj\nET",
$fontSize,
$x,
$y,
strtoupper(bin2hex(mb_convert_encoding($text, 'UCS-2BE', 'UTF-8')))
);
}
private function normalizeText(string $text): string
{
$text = trim(str_replace(["\r\n", "\r", "\n", "\t"], [' ', ' ', ' ', ' '], $text));
return preg_replace('/\s+/u', ' ', $text) ?: '';
}
private function renderPdf(array $objects): string
{
$pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
$offsets = [];
foreach ($objects as $id => $body) {
$offsets[$id] = strlen($pdf);
$pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $id, $body);
}
$xrefPosition = strlen($pdf);
$pdf .= sprintf("xref\n0 %d\n", count($objects) + 1);
$pdf .= "0000000000 65535 f \n";
foreach ($objects as $id => $_body) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$id]);
}
$pdf .= sprintf(
"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF",
count($objects) + 1,
$xrefPosition
);
return $pdf;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace app\support;
use support\Request;
use function str_starts_with;
use function strtolower;
class TicketAttachmentService
{
public function upload(Request $request, string $inputName = 'file'): array
{
$file = $request->file($inputName);
if (!$file || !$file->isValid()) {
throw new \RuntimeException('上传文件无效');
}
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
$filename = sprintf('ticket_%s.%s', uniqid(), $extension);
$relativeDir = 'uploads/tickets/' . date('Ymd');
$relativePath = $relativeDir . '/' . $filename;
$this->storage()->putUploadedFile($file, $relativePath);
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
return [
'file_id' => md5($relativePath),
'file_url' => $fileUrl,
'thumbnail_url' => $fileUrl,
'name' => $file->getUploadName(),
];
}
public function delete(string $fileUrl): void
{
$relativePath = $this->storage()->storagePath($fileUrl);
if (!str_starts_with($relativePath, 'uploads/tickets/')) {
return;
}
$this->storage()->delete($relativePath);
}
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
{
if (is_string($attachments) && $attachments !== '') {
$decoded = json_decode($attachments, true);
$attachments = is_array($decoded) ? $decoded : [];
}
if (!is_array($attachments)) {
return [];
}
$list = [];
foreach ($attachments as $item) {
if (!is_array($item)) {
continue;
}
$fileUrl = trim((string)($item['file_url'] ?? ''));
if ($fileUrl === '') {
continue;
}
$storedFileUrl = $this->storage()->storagePath($fileUrl);
$storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? $fileUrl)));
$list[] = [
'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))),
'file_url' => $forStorage
? '/' . $storedFileUrl
: ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl),
'thumbnail_url' => $forStorage
? '/' . $storedThumbnailUrl
: ($request ? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? $fileUrl)), $request) : trim((string)($item['thumbnail_url'] ?? $fileUrl))),
'name' => trim((string)($item['name'] ?? '')),
];
}
return $list;
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
}

View File

@@ -0,0 +1,574 @@
<?php
namespace app\support;
use support\think\Db;
class WarehouseService
{
public function __construct()
{
$this->ensureWarehouseTable();
$this->ensureWarehouseRuleColumns();
$this->ensureOrderTargetTable();
$this->bootstrapDefaults();
}
public function overviewCards(): array
{
return [
[
'title' => '仓库总数',
'value' => (int)Db::name('shipping_warehouses')->count(),
'desc' => '当前已维护的检测中心 / 收货仓库数量',
],
[
'title' => '启用仓库',
'value' => (int)Db::name('shipping_warehouses')->where('status', 'enabled')->count(),
'desc' => '当前对前台寄送页可见的仓库数量',
],
[
'title' => '安心验仓库',
'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'anxinyan')->count(),
'desc' => '归属安心验服务的默认收货中心数量',
],
[
'title' => '中检仓库',
'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'zhongjian')->count(),
'desc' => '归属中检服务的默认收货中心数量',
],
];
}
public function list(): array
{
$rows = Db::name('shipping_warehouses')
->order('service_provider', 'asc')
->order('is_default', 'desc')
->order('sort_order', 'asc')
->order('id', 'desc')
->select()
->toArray();
return array_map(fn(array $item) => $this->formatWarehouse($item), $rows);
}
public function save(array $payload, int $id = 0): int
{
$now = date('Y-m-d H:i:s');
$serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan'));
$status = trim((string)($payload['status'] ?? 'enabled'));
$supportedCategoryIds = $this->normalizeIntArray($payload['supported_category_ids'] ?? []);
$serviceAreaProvinces = $this->normalizeStringArray($payload['service_area_provinces'] ?? []);
$serviceAreaCities = $this->normalizeStringArray($payload['service_area_cities'] ?? []);
$warehouseCode = trim((string)($payload['warehouse_code'] ?? ''));
if ($warehouseCode === '') {
$warehouseCode = $this->generateWarehouseCode($serviceProvider);
}
$existsByCode = Db::name('shipping_warehouses')
->where('warehouse_code', $warehouseCode)
->when($id > 0, fn($query) => $query->where('id', '<>', $id))
->find();
if ($existsByCode) {
throw new \RuntimeException('仓库编码已存在,请更换后重试');
}
$data = [
'warehouse_name' => trim((string)($payload['warehouse_name'] ?? '')),
'warehouse_code' => $warehouseCode,
'warehouse_type' => 'detection_center',
'service_provider' => $serviceProvider,
'receiver_name' => trim((string)($payload['receiver_name'] ?? '')),
'receiver_mobile' => trim((string)($payload['receiver_mobile'] ?? '')),
'province' => trim((string)($payload['province'] ?? '')),
'city' => trim((string)($payload['city'] ?? '')),
'district' => trim((string)($payload['district'] ?? '')),
'detail_address' => trim((string)($payload['detail_address'] ?? '')),
'service_time' => trim((string)($payload['service_time'] ?? '')),
'notice' => trim((string)($payload['notice'] ?? '')),
'supported_category_ids_json' => $supportedCategoryIds ? json_encode($supportedCategoryIds, JSON_UNESCAPED_UNICODE) : null,
'service_area_provinces_json' => $serviceAreaProvinces ? json_encode($serviceAreaProvinces, JSON_UNESCAPED_UNICODE) : null,
'service_area_cities_json' => $serviceAreaCities ? json_encode($serviceAreaCities, JSON_UNESCAPED_UNICODE) : null,
'status' => $status !== '' ? $status : 'enabled',
'is_default' => !empty($payload['is_default']) ? 1 : 0,
'sort_order' => (int)($payload['sort_order'] ?? 0),
'remark' => trim((string)($payload['remark'] ?? '')),
'updated_at' => $now,
];
$this->validatePayload($data);
Db::startTrans();
try {
if ((int)$data['is_default'] === 1) {
Db::name('shipping_warehouses')
->where('service_provider', $serviceProvider)
->update([
'is_default' => 0,
'updated_at' => $now,
]);
}
if ($id > 0) {
Db::name('shipping_warehouses')->where('id', $id)->update($data);
$warehouseId = $id;
} else {
$data['created_at'] = $now;
$warehouseId = (int)Db::name('shipping_warehouses')->insertGetId($data);
}
if ((int)$data['is_default'] !== 1) {
$currentDefault = Db::name('shipping_warehouses')
->where('service_provider', $serviceProvider)
->where('status', 'enabled')
->where('is_default', 1)
->find();
if (!$currentDefault) {
Db::name('shipping_warehouses')->where('id', $warehouseId)->update([
'is_default' => 1,
'updated_at' => $now,
]);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $warehouseId;
}
public function resolveForShipping(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
{
$options = $this->optionsForOrder($serviceProvider, $categoryId, $userAddress);
if (!$options) {
return [
'warehouse_id' => 0,
'warehouse_name' => '',
'warehouse_code' => '',
'receiver_name' => '',
'receiver_mobile' => '',
'province' => '',
'city' => '',
'district' => '',
'detail_address' => '',
'service_time' => '',
'notice' => '',
];
}
$matched = $options[0];
return [
'warehouse_id' => (int)$matched['id'],
'warehouse_name' => $matched['warehouse_name'],
'warehouse_code' => $matched['warehouse_code'],
'receiver_name' => $matched['receiver_name'],
'receiver_mobile' => $matched['receiver_mobile'],
'province' => $matched['province'],
'city' => $matched['city'],
'district' => $matched['district'],
'detail_address' => $matched['detail_address'],
'service_time' => $matched['service_time'],
'notice' => $matched['notice'],
];
}
public function bindOrderTarget(int $orderId, string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
{
$snapshot = $this->resolveForShipping($serviceProvider, $categoryId, $userAddress);
$now = date('Y-m-d H:i:s');
$payload = [
'order_id' => $orderId,
'warehouse_id' => (int)($snapshot['warehouse_id'] ?? 0) ?: null,
'warehouse_name' => $snapshot['warehouse_name'] ?? '',
'warehouse_code' => $snapshot['warehouse_code'] ?? '',
'service_provider' => $serviceProvider,
'receiver_name' => $snapshot['receiver_name'] ?? '',
'receiver_mobile' => $snapshot['receiver_mobile'] ?? '',
'province' => $snapshot['province'] ?? '',
'city' => $snapshot['city'] ?? '',
'district' => $snapshot['district'] ?? '',
'detail_address' => $snapshot['detail_address'] ?? '',
'service_time' => $snapshot['service_time'] ?? '',
'notice' => $snapshot['notice'] ?? '',
'updated_at' => $now,
];
$exists = Db::name('order_shipping_targets')->where('order_id', $orderId)->find();
if ($exists) {
Db::name('order_shipping_targets')->where('order_id', $orderId)->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('order_shipping_targets')->insert($payload);
}
return $payload;
}
public function getOrderTarget(int $orderId): ?array
{
$row = Db::name('order_shipping_targets')->where('order_id', $orderId)->find();
if (!$row) {
return null;
}
return [
'warehouse_id' => (int)($row['warehouse_id'] ?? 0),
'warehouse_name' => $row['warehouse_name'] ?? '',
'warehouse_code' => $row['warehouse_code'] ?? '',
'receiver_name' => $row['receiver_name'] ?? '',
'receiver_mobile' => $row['receiver_mobile'] ?? '',
'province' => $row['province'] ?? '',
'city' => $row['city'] ?? '',
'district' => $row['district'] ?? '',
'detail_address' => $row['detail_address'] ?? '',
'service_time' => $row['service_time'] ?? '',
'notice' => $row['notice'] ?? '',
];
}
public function optionsForOrder(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array
{
$rows = Db::name('shipping_warehouses')
->where('status', 'enabled')
->where('service_provider', $serviceProvider)
->select()
->toArray();
if (!$rows) {
$rows = Db::name('shipping_warehouses')
->where('status', 'enabled')
->select()
->toArray();
}
$list = array_map(fn(array $item) => $this->formatWarehouse($item), $rows);
foreach ($list as &$item) {
$item['match_score'] = $this->matchScore($item, $categoryId, $userAddress);
$item['is_recommended'] = $item['match_score'] >= 300;
$item['recommended_reason'] = $this->recommendedReason($item, $categoryId, $userAddress);
}
unset($item);
usort($list, static function (array $left, array $right) {
if ($left['match_score'] === $right['match_score']) {
if ((int)$left['is_default'] === (int)$right['is_default']) {
if ((int)$left['sort_order'] === (int)$right['sort_order']) {
return (int)$left['id'] <=> (int)$right['id'];
}
return (int)$left['sort_order'] <=> (int)$right['sort_order'];
}
return (int)$right['is_default'] <=> (int)$left['is_default'];
}
return (int)$right['match_score'] <=> (int)$left['match_score'];
});
return $list;
}
private function formatWarehouse(array $item): array
{
$supportedCategoryIds = $this->decodeIntArray($item['supported_category_ids_json'] ?? null);
$serviceAreaProvinces = $this->decodeStringArray($item['service_area_provinces_json'] ?? null);
$serviceAreaCities = $this->decodeStringArray($item['service_area_cities_json'] ?? null);
$categoryNames = [];
if ($supportedCategoryIds) {
$categoryNames = Db::name('catalog_categories')
->whereIn('id', $supportedCategoryIds)
->column('name');
}
return [
'id' => (int)$item['id'],
'warehouse_name' => $item['warehouse_name'],
'warehouse_code' => $item['warehouse_code'],
'warehouse_type' => $item['warehouse_type'],
'warehouse_type_text' => '检测中心 / 收货仓库',
'service_provider' => $item['service_provider'],
'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
'receiver_name' => $item['receiver_name'],
'receiver_mobile' => $item['receiver_mobile'],
'province' => $item['province'],
'city' => $item['city'],
'district' => $item['district'],
'detail_address' => $item['detail_address'],
'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])),
'service_time' => $item['service_time'],
'notice' => $item['notice'],
'supported_category_ids' => $supportedCategoryIds,
'supported_category_names' => array_values($categoryNames),
'service_area_provinces' => $serviceAreaProvinces,
'service_area_cities' => $serviceAreaCities,
'status' => $item['status'],
'status_text' => $item['status'] === 'enabled' ? '启用中' : '已停用',
'is_default' => (bool)$item['is_default'],
'sort_order' => (int)$item['sort_order'],
'remark' => $item['remark'] ?? '',
'created_at' => $item['created_at'] ?? '',
'updated_at' => $item['updated_at'] ?? '',
];
}
private function validatePayload(array $data): void
{
foreach (['warehouse_name', 'receiver_name', 'receiver_mobile', 'province', 'city', 'district', 'detail_address', 'service_time'] as $field) {
if (trim((string)($data[$field] ?? '')) === '') {
throw new \RuntimeException('请完整填写仓库名称、收件信息与地址');
}
}
}
private function normalizeIntArray(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(static function ($item) {
$int = (int)$item;
return $int > 0 ? $int : null;
}, $value))));
}
private function normalizeStringArray(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$items = [];
foreach ($value as $item) {
$text = trim((string)$item);
if ($text === '') {
continue;
}
$items[] = $text;
}
return array_values(array_unique($items));
}
private function decodeIntArray(mixed $value): array
{
if (is_array($value)) {
return $this->normalizeIntArray($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $this->normalizeIntArray($decoded) : [];
}
return [];
}
private function decodeStringArray(mixed $value): array
{
if (is_array($value)) {
return $this->normalizeStringArray($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $this->normalizeStringArray($decoded) : [];
}
return [];
}
private function generateWarehouseCode(string $serviceProvider): string
{
$prefix = $serviceProvider === 'zhongjian' ? 'ZJ' : 'AXY';
return sprintf('%s-WH-%s', $prefix, date('YmdHis'));
}
private function ensureWarehouseTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS shipping_warehouses (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
warehouse_name VARCHAR(128) NOT NULL DEFAULT '',
warehouse_code VARCHAR(64) NOT NULL DEFAULT '',
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'detection_center',
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
receiver_name VARCHAR(64) NOT NULL DEFAULT '',
receiver_mobile VARCHAR(32) NOT NULL DEFAULT '',
province VARCHAR(64) NOT NULL DEFAULT '',
city VARCHAR(64) NOT NULL DEFAULT '',
district VARCHAR(64) NOT NULL DEFAULT '',
detail_address VARCHAR(255) NOT NULL DEFAULT '',
service_time VARCHAR(128) NOT NULL DEFAULT '',
notice VARCHAR(500) NOT NULL DEFAULT '',
supported_category_ids_json JSON NULL,
service_area_provinces_json JSON NULL,
service_area_cities_json JSON NULL,
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_shipping_warehouses_code (warehouse_code),
KEY idx_shipping_warehouses_service_provider (service_provider),
KEY idx_shipping_warehouses_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货仓库 / 检测中心';
SQL);
}
private function ensureWarehouseRuleColumns(): void
{
$columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_provinces_json'");
if (!$columns) {
Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_provinces_json JSON NULL AFTER supported_category_ids_json");
}
$columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_cities_json'");
if (!$columns) {
Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_cities_json JSON NULL AFTER service_area_provinces_json");
}
}
private function ensureOrderTargetTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS order_shipping_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
warehouse_id BIGINT UNSIGNED NULL DEFAULT NULL,
warehouse_name VARCHAR(128) NOT NULL DEFAULT '',
warehouse_code VARCHAR(64) NOT NULL DEFAULT '',
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
receiver_name VARCHAR(64) NOT NULL DEFAULT '',
receiver_mobile VARCHAR(32) NOT NULL DEFAULT '',
province VARCHAR(64) NOT NULL DEFAULT '',
city VARCHAR(64) NOT NULL DEFAULT '',
district VARCHAR(64) NOT NULL DEFAULT '',
detail_address VARCHAR(255) NOT NULL DEFAULT '',
service_time VARCHAR(128) NOT NULL DEFAULT '',
notice VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_shipping_targets_order_id (order_id),
KEY idx_order_shipping_targets_warehouse_id (warehouse_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单锁定仓库快照'
SQL);
}
private function bootstrapDefaults(): void
{
$count = (int)Db::name('shipping_warehouses')->count();
if ($count > 0) {
return;
}
$now = date('Y-m-d H:i:s');
Db::name('shipping_warehouses')->insertAll([
[
'warehouse_name' => '安心验鉴定中心',
'warehouse_code' => 'AXY-WH-DEFAULT',
'warehouse_type' => 'detection_center',
'service_provider' => 'anxinyan',
'receiver_name' => '安心验鉴定中心',
'receiver_mobile' => '400-800-1314',
'province' => '广东省',
'city' => '深圳市',
'district' => '南山区',
'detail_address' => '科技园鉴定路 88 号 安心验收件中心',
'service_time' => '周一至周日 09:30-18:30',
'notice' => '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。',
'supported_category_ids_json' => null,
'service_area_provinces_json' => null,
'service_area_cities_json' => null,
'status' => 'enabled',
'is_default' => 1,
'sort_order' => 1,
'remark' => '默认仓库',
'created_at' => $now,
'updated_at' => $now,
],
[
'warehouse_name' => '中检合作鉴定中心',
'warehouse_code' => 'ZJ-WH-DEFAULT',
'warehouse_type' => 'detection_center',
'service_provider' => 'zhongjian',
'receiver_name' => '中检合作鉴定中心',
'receiver_mobile' => '400-800-1314',
'province' => '广东省',
'city' => '深圳市',
'district' => '南山区',
'detail_address' => '科技园鉴定路 88 号 安心验中检收件中心',
'service_time' => '周一至周日 09:30-18:30',
'notice' => '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。',
'supported_category_ids_json' => null,
'service_area_provinces_json' => null,
'service_area_cities_json' => null,
'status' => 'enabled',
'is_default' => 1,
'sort_order' => 1,
'remark' => '默认仓库',
'created_at' => $now,
'updated_at' => $now,
],
]);
}
private function matchScore(array $warehouse, ?int $categoryId, ?array $userAddress): int
{
$score = 0;
if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) {
$score += 200;
}
if ($userAddress) {
$province = trim((string)($userAddress['province'] ?? ''));
$city = trim((string)($userAddress['city'] ?? ''));
if ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) {
$score += 120;
}
if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) {
$score += 180;
}
}
if ($warehouse['is_default']) {
$score += 40;
}
return $score;
}
private function recommendedReason(array $warehouse, ?int $categoryId, ?array $userAddress): string
{
$reasons = [];
if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) {
$reasons[] = '匹配当前品类';
}
if ($userAddress) {
$province = trim((string)($userAddress['province'] ?? ''));
$city = trim((string)($userAddress['city'] ?? ''));
if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) {
$reasons[] = '匹配当前城市';
} elseif ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) {
$reasons[] = '匹配当前省份';
}
}
if (!$reasons && $warehouse['is_default']) {
$reasons[] = '默认仓库';
}
return implode(' / ', $reasons);
}
}