初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
@@ -23,11 +23,12 @@ class AdminService extends BaseService
|
||||
'open_id',
|
||||
'avatar',
|
||||
'nick_name',
|
||||
'password',
|
||||
// 不查 password / legacy_password:列表与详情都会直接回给前端,密码哈希不该出网
|
||||
'phone',
|
||||
'email',
|
||||
'code',
|
||||
'role_id',
|
||||
'department_id',
|
||||
'province_id',
|
||||
'city_id',
|
||||
'reg_ip',
|
||||
@@ -47,6 +48,7 @@ class AdminService extends BaseService
|
||||
'nick_name' => 'like',
|
||||
'email' => 'like',
|
||||
'role_id' => '=',
|
||||
'department_id' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
}
|
||||
@@ -60,14 +62,17 @@ class AdminService extends BaseService
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = [
|
||||
'role'
|
||||
'role',
|
||||
'department',
|
||||
];
|
||||
$result = $this->getPageList();
|
||||
|
||||
foreach ($result['items'] as &$v) {
|
||||
$v['ip_table'] = json_decode($v['ip_table'], true);
|
||||
$v['status_text'] = UserStatusEnum::from($v['status'])->description();
|
||||
$v['department_name'] = $v['department']['name'] ?? '';
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -95,6 +100,10 @@ class AdminService extends BaseService
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params['ip_table'] = json_encode([]);
|
||||
// open_id 是 NOT NULL 且无默认值,不显式赋值会插入失败
|
||||
$params['open_id'] = 'nl_' . bin2hex(random_bytes(15));
|
||||
// status 列默认值是 1(禁用),不显式写成正常态新账号一登录就被状态校验挡下
|
||||
$params['status'] = (int) ($params['status'] ?? UserStatusEnum::NORMAL->value);
|
||||
$params['password'] = password_hash($params['password'], PASSWORD_DEFAULT);
|
||||
// 手机号或邮箱任一重复都拒绝,且必须排除软删记录(deleted_at=0)。
|
||||
// 原写法 whereOr('email',...) 是 Laravel 动态 where 的空操作(等于只按 phone 判断且不排软删),
|
||||
@@ -363,7 +372,13 @@ class AdminService extends BaseService
|
||||
'affixTab' => !$menu->affix_tab,
|
||||
'order' => $menu->sort,
|
||||
'iframeSrc' => $menu->iframe_src,
|
||||
]
|
||||
// 库里有这几列但之前没往 meta 输出,等于菜单管理里配了也不生效
|
||||
'badge' => (string) $menu->badge,
|
||||
'badgeType' => $this->badgeType((int) $menu->badge_type),
|
||||
'badgeVariants' => $this->badgeVariants((int) $menu->badge_variants),
|
||||
],
|
||||
// vben5 路由的 query 是路由级字段而不是 meta,且必须是对象
|
||||
'query' => $this->decodeQuery((string) $menu->query),
|
||||
];
|
||||
}
|
||||
$result = $this->utils->tree($resultMenus);
|
||||
@@ -375,4 +390,48 @@ class AdminService extends BaseService
|
||||
RedisService::getInstance()->init(config('nl.redis.menu_key'))->set($this->roleId, json_encode($result), 3600);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前账号的权限码
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function codes(): array
|
||||
{
|
||||
return PermissionService::getInstance()->codes($this->roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* nl_menu.badge_type:0 dot 小红点 / 1 normal 文本
|
||||
*/
|
||||
private function badgeType(int $type): string
|
||||
{
|
||||
return $type === 1 ? 'normal' : 'dot';
|
||||
}
|
||||
|
||||
/**
|
||||
* nl_menu.badge_variants:0 default 1 destructive 2 primary 3 success 4 warning
|
||||
*/
|
||||
private function badgeVariants(int $variants): string
|
||||
{
|
||||
return match ($variants) {
|
||||
1 => 'destructive',
|
||||
2 => 'primary',
|
||||
3 => 'success',
|
||||
4 => 'warning',
|
||||
default => 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单默认查询参数:库里存的是 JSON 字符串,非法值按空对象处理,别让一行坏数据把整棵菜单打挂
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeQuery(string $query): array
|
||||
{
|
||||
if (trim($query) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($query, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
|
||||
203
app/Service/DepartmentService.php
Normal file
203
app/Service/DepartmentService.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\AdminModel;
|
||||
use App\Models\business\DepartmentModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 部门服务
|
||||
*
|
||||
* 老后台的 department/option 返回的是扁平列表,前端 BasicTree 拿到后其实渲染不出层级,
|
||||
* pid 白存了。这里 option 直接返回真正的树,账号列表左侧的部门树才能用。
|
||||
*/
|
||||
class DepartmentService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = DepartmentModel::class;
|
||||
$this->selectField = ['id', 'name', 'desc', 'status', 'pid', 'color', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'status' => '=', 'pid' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页列表,附带每个部门的账号数
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$ids = array_column($result['items'] ?? [], 'id');
|
||||
$counts = $this->countAdminByDepartment($ids);
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['admin_count'] = $counts[$item['id']] ?? 0;
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门树(账号列表左侧筛选树、部门表单的上级选择都用这个)
|
||||
*/
|
||||
public function option(): array
|
||||
{
|
||||
$rows = DepartmentModel::where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'pid', 'status', 'color'])
|
||||
->toArray();
|
||||
return $this->utils->tree($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门树下拉(部门表单选上级用)。带 $isSelect 时首位补「顶级部门」
|
||||
*/
|
||||
public function getTreeOption(bool $isSelect = false): array
|
||||
{
|
||||
$rows = DepartmentModel::where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'pid'])
|
||||
->toArray();
|
||||
$result = $this->utils->tree($rows);
|
||||
if ($isSelect) {
|
||||
array_unshift($result, ['id' => 0, 'name' => '顶级部门', 'pid' => 0]);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增部门
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$this->assertNameUnique((string) ($params['name'] ?? ''), (int) ($params['pid'] ?? 0));
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑部门
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$id = (int) $id;
|
||||
if (array_key_exists('name', $params)) {
|
||||
$this->assertNameUnique((string) $params['name'], (int) ($params['pid'] ?? 0), $id);
|
||||
}
|
||||
// 上级不能指向自己或自己的子孙,否则树会成环、递归建树直接栈溢出
|
||||
if (array_key_exists('pid', $params)) {
|
||||
$pid = (int) $params['pid'];
|
||||
if ($pid === $id) {
|
||||
$this->utils->errorThrow('上级部门不能是自己');
|
||||
}
|
||||
if ($pid > 0 && in_array($pid, $this->descendantIds($id), true)) {
|
||||
$this->utils->errorThrow('上级部门不能是自己的下级');
|
||||
}
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门:有子部门或仍有账号在用时拒绝,避免留下悬空引用
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$hasChild = DepartmentModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists();
|
||||
if ($hasChild) {
|
||||
$this->utils->errorThrow('存在下级部门,请先删除下级');
|
||||
}
|
||||
$inUse = AdminModel::whereIn('department_id', $ids)->where('deleted_at', 0)->exists();
|
||||
if ($inUse) {
|
||||
$this->utils->errorThrow('仍有账号属于该部门,请先调整账号所属部门');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用/启用部门
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save((int) $id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一父级下部门名不允许重复,否则树上出现两个同名节点没法分辨
|
||||
*/
|
||||
private function assertNameUnique(string $name, int $pid, int $exceptId = 0): void
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('部门名称不能为空');
|
||||
}
|
||||
$exists = DepartmentModel::where('name', $name)
|
||||
->where('pid', $pid)
|
||||
->where('deleted_at', 0)
|
||||
->when($exceptId > 0, fn ($q) => $q->where('id', '<>', $exceptId))
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$this->utils->errorThrow('同级下已存在同名部门');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某部门的全部子孙 id,用于成环校验
|
||||
*/
|
||||
private function descendantIds(int $id): array
|
||||
{
|
||||
$all = DepartmentModel::where('deleted_at', 0)->get(['id', 'pid']);
|
||||
$childMap = [];
|
||||
foreach ($all as $row) {
|
||||
$childMap[(int) $row->pid][] = (int) $row->id;
|
||||
}
|
||||
$result = [];
|
||||
$stack = $childMap[$id] ?? [];
|
||||
while (!empty($stack)) {
|
||||
$current = array_pop($stack);
|
||||
if (in_array($current, $result, true)) {
|
||||
continue;
|
||||
}
|
||||
$result[] = $current;
|
||||
foreach ($childMap[$current] ?? [] as $child) {
|
||||
$stack[] = $child;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门账号数:admin 在 mysql 连接、department 在 business 连接,跨连接不能 join,分两次查
|
||||
*/
|
||||
private function countAdminByDepartment(array $departmentIds): array
|
||||
{
|
||||
if (empty($departmentIds)) {
|
||||
return [];
|
||||
}
|
||||
return AdminModel::whereIn('department_id', $departmentIds)
|
||||
->where('deleted_at', 0)
|
||||
->groupBy('department_id')
|
||||
->selectRaw('department_id, COUNT(*) AS c')
|
||||
->pluck('c', 'department_id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
135
app/Service/FileFolderService.php
Normal file
135
app/Service/FileFolderService.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\FileFolderModel;
|
||||
use App\Models\FileModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 素材文件夹
|
||||
*
|
||||
* 纯逻辑目录:只改 nl_file.folder_id,不碰对象在 OSS 上的实际路径。
|
||||
*/
|
||||
class FileFolderService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = FileFolderModel::class;
|
||||
$this->selectField = ['id', 'pid', 'name', 'sort', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'pid' => '=', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表,带每个文件夹下的素材数
|
||||
*
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$ids = array_column($result['items'], 'id');
|
||||
$counts = empty($ids)
|
||||
? []
|
||||
: FileModel::whereIn('folder_id', $ids)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('folder_id, COUNT(*) as total')
|
||||
->groupBy('folder_id')
|
||||
->pluck('total', 'folder_id')
|
||||
->all();
|
||||
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['file_count'] = (int) ($counts[$item['id']] ?? 0);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录树,素材库左侧栏直接用它渲染
|
||||
*/
|
||||
public function option(): array
|
||||
{
|
||||
$rows = FileFolderModel::where('deleted_at', 0)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'pid', 'name', 'sort'])
|
||||
->toArray();
|
||||
return $this->utils->tree($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params['pid'] = $this->assertParent((int) ($params['pid'] ?? 0));
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('pid', $params)) {
|
||||
$pid = (int) $params['pid'];
|
||||
if ($pid === (int) $id) {
|
||||
$this->utils->errorThrow('上级文件夹不能是自己');
|
||||
}
|
||||
$params['pid'] = $this->assertParent($pid);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹:有子目录或仍有素材时拒绝
|
||||
*
|
||||
* 直接删会让里面的素材挂在一个查不到的 folder_id 上,
|
||||
* 按文件夹筛选时那批文件就再也点不出来 —— 文件还在,人找不到。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (FileFolderModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('存在子文件夹,请先删除子文件夹');
|
||||
}
|
||||
if (FileModel::whereIn('folder_id', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该文件夹下仍有素材,请先移动素材');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertParent(int $pid): int
|
||||
{
|
||||
if ($pid <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!FileFolderModel::where('id', $pid)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->notFound('上级文件夹不存在');
|
||||
}
|
||||
return $pid;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\FileModel;
|
||||
use App\Models\ProjectModel;
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\UserModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
@@ -36,22 +36,46 @@ class LoginService extends BaseNotAuthService
|
||||
$this->writeLoginLog(0, (string) $phone, '', 1, '用户或密码错误', $equipment, $browser);
|
||||
UtilsService::getInstance()->errorThrow('用户或密码错误!');
|
||||
}
|
||||
if (!password_verify($password, $userModel->password)) {
|
||||
// 老后台是无盐 sha1,没有明文无法预先转 bcrypt,所以 bcrypt 校验失败时再回落比对遗留密码
|
||||
$legacyHit = false;
|
||||
if (!password_verify($password, (string) $userModel->password)) {
|
||||
$legacyHit = $this->verifyLegacyPassword($userModel, (string) $password);
|
||||
if (!$legacyHit) {
|
||||
$this->writeLoginLog(
|
||||
(int) $userModel->id,
|
||||
(string) $phone,
|
||||
(string) $userModel->nick_name,
|
||||
1,
|
||||
'用户或密码错误',
|
||||
$equipment,
|
||||
$browser
|
||||
);
|
||||
UtilsService::getInstance()->errorThrow('用户或密码错误!');
|
||||
}
|
||||
}
|
||||
// 状态校验放在密码校验之后,避免未通过认证就泄露账号是否存在或被禁用
|
||||
if ((int) $userModel->status === 1) {
|
||||
$this->writeLoginLog(
|
||||
(int) $userModel->id,
|
||||
(string) $phone,
|
||||
(string) $userModel->nick_name,
|
||||
1,
|
||||
'用户或密码错误',
|
||||
'账号已被禁用',
|
||||
$equipment,
|
||||
$browser
|
||||
);
|
||||
UtilsService::getInstance()->errorThrow('用户或密码错误!');
|
||||
UtilsService::getInstance()->errorThrow('账号已被禁用,请联系管理员!');
|
||||
}
|
||||
$updateData = [
|
||||
'last_login_time' => get_time(),
|
||||
'updated_at' => get_time(),
|
||||
];
|
||||
if ($legacyHit) {
|
||||
// 命中遗留密码即刻升级成 bcrypt 并清空遗留列,下次登录走正常校验
|
||||
$updateData['password'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
$updateData['legacy_password'] = '';
|
||||
$updateData['legacy_password_expire_at'] = 0;
|
||||
}
|
||||
if ($userModel->ip !== get_ip()) {
|
||||
$ipTable = json_decode($userModel->ip_table, true);
|
||||
if (!in_array(get_ip(), $ipTable ?? [])) {
|
||||
@@ -60,26 +84,14 @@ class LoginService extends BaseNotAuthService
|
||||
$updateData['ip'] = get_ip();
|
||||
$updateData['ip_table'] = json_encode($ipTable);
|
||||
}
|
||||
$update = AdminModel::where('id', $userModel->id)->update($updateData);
|
||||
if (!$update) {
|
||||
$this->writeLoginLog(
|
||||
(int) $userModel->id,
|
||||
(string) $phone,
|
||||
(string) $userModel->nick_name,
|
||||
1,
|
||||
'更新登录信息失败',
|
||||
$equipment,
|
||||
$browser
|
||||
);
|
||||
UtilsService::getInstance()->errorThrow('更新失败!');
|
||||
}
|
||||
AdminModel::where('id', $userModel->id)->update($updateData);
|
||||
$userModel = AdminModel::with(['role:id,name,value'])->where('id', $userModel->id)->first();
|
||||
$this->writeLoginLog(
|
||||
(int) $userModel->id,
|
||||
(string) $userModel->phone,
|
||||
(string) $userModel->nick_name,
|
||||
0,
|
||||
'登录成功',
|
||||
$legacyHit ? '登录成功(遗留密码已升级)' : '登录成功',
|
||||
$equipment,
|
||||
$browser
|
||||
);
|
||||
@@ -90,16 +102,99 @@ class LoginService extends BaseNotAuthService
|
||||
'avatar' => $userModel->avatar,
|
||||
'email' => $userModel->email,
|
||||
'role_id' => $userModel->role_id,
|
||||
'role_name' => $userModel->role->name,
|
||||
'role_value' => $userModel->role->value,
|
||||
// 迁移过来的账号可能 role_id=0,关联为空时不能直接取属性
|
||||
'role_name' => $userModel->role->name ?? '',
|
||||
'role_value' => $userModel->role->value ?? '',
|
||||
'ip' => $userModel->ip,
|
||||
'ip_table' => $userModel->ip_table,
|
||||
];
|
||||
$token = JWTService::getInstance()->generateToken($result);
|
||||
$result['token'] = $token;
|
||||
// 前端据此提示尽快改密:无盐 sha1 可被彩虹表秒破,升级后也建议换新密码
|
||||
$result['legacy_password_upgraded'] = $legacyHit;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录:删掉 Redis 会话,手里那张 token 立刻作废
|
||||
*
|
||||
* 注册在免登录组,token 缺失或已过期都不报错——前端登出时本地 token 常常已经清掉了,
|
||||
* 这时报错只会让用户卡在退出流程里。
|
||||
*/
|
||||
public function logout(): array
|
||||
{
|
||||
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
|
||||
$userId = (int) ($decoded->data->id ?? 0);
|
||||
if ($userId > 0) {
|
||||
JWTService::getInstance()->revoke($userId);
|
||||
}
|
||||
return ['logout' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 续签 token
|
||||
*
|
||||
* 前端拦截器在 401 时调这里。签名 + Redis 会话都要通过,只是放宽 exp,
|
||||
* 所以「被登出」和「过期太久」两种情况仍然要求重新登录。
|
||||
* @throws Exception
|
||||
*/
|
||||
public function refresh(): array
|
||||
{
|
||||
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
|
||||
$data = isset($decoded->data) ? (array) $decoded->data : [];
|
||||
$userId = (int) ($data['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录');
|
||||
}
|
||||
// 会话还在才允许续签;顺带用库里的最新角色刷新 payload,改了角色不用等 token 过期
|
||||
$session = JWTService::getInstance()->getToken()->getUserInfo();
|
||||
$userModel = AdminModel::with(['role:id,name,value'])
|
||||
->where('id', $userId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (empty($userModel) || (int) $userModel->status === 1) {
|
||||
JWTService::getInstance()->revoke($userId);
|
||||
UtilsService::getInstance()->notAuth('账号不可用,请重新登录');
|
||||
}
|
||||
$payload = array_merge($session, [
|
||||
'id' => (int) $userModel->id,
|
||||
'phone' => $userModel->phone,
|
||||
'nick_name' => $userModel->nick_name,
|
||||
'avatar' => $userModel->avatar,
|
||||
'role_id' => (int) $userModel->role_id,
|
||||
'role_name' => $userModel->role->name ?? '',
|
||||
'role_value' => $userModel->role->value ?? '',
|
||||
]);
|
||||
return ['token' => JWTService::getInstance()->generateToken($payload)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 续签宽限期:token 过期后仍可续签的时长,超过就必须重新登录
|
||||
*/
|
||||
private function refreshTtl(): int
|
||||
{
|
||||
return (int) config('nl.jwt.refresh_ttl', 7 * 24 * 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对老系统的无盐 sha1 密码
|
||||
*
|
||||
* 只在 bcrypt 校验失败时调用。遗留列有失效时间,过期后一律走重置流程,
|
||||
* 因为无盐 sha1 可被彩虹表直接反查,不能无限期留着。
|
||||
*/
|
||||
private function verifyLegacyPassword(AdminModel $userModel, string $password): bool
|
||||
{
|
||||
$legacy = strtolower(trim((string) ($userModel->legacy_password ?? '')));
|
||||
if ($legacy === '') {
|
||||
return false;
|
||||
}
|
||||
$expireAt = (int) ($userModel->legacy_password_expire_at ?? 0);
|
||||
if ($expireAt > 0 && $expireAt < get_time()) {
|
||||
return false;
|
||||
}
|
||||
return hash_equals($legacy, sha1($password));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册管理员账号
|
||||
*
|
||||
@@ -112,6 +207,10 @@ class LoginService extends BaseNotAuthService
|
||||
*/
|
||||
public function register($phone, $password, $email, $code): array
|
||||
{
|
||||
// 这是后台管理端,自助注册默认关闭:开着等于任何人都能给自己开一个管理员账号
|
||||
if (!config('nl.register.enabled', false)) {
|
||||
UtilsService::getInstance()->errorThrow('后台不开放自助注册,请联系管理员创建账号');
|
||||
}
|
||||
$userModel = AdminModel::where('phone', $phone)->where('deleted_at', 0)->first();
|
||||
if ($userModel) {
|
||||
UtilsService::getInstance()->errorThrow('账号已被占用!');
|
||||
@@ -122,9 +221,14 @@ class LoginService extends BaseNotAuthService
|
||||
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'nick_name' => '新用户' . Str::random(),
|
||||
'avatar' => 'https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280',
|
||||
'role_id' => 2,
|
||||
// 迁移的老角色统一偏移到 100 起,脚手架预留的 2(默认角色)不受影响,仍可作为默认值
|
||||
'role_id' => (int) config('nl.register.default_role_id', 2),
|
||||
// open_id 是 NOT NULL 且无默认值,不显式赋值会直接插入失败
|
||||
'open_id' => 'nl_' . bin2hex(random_bytes(15)),
|
||||
'ip' => get_ip(),
|
||||
'ip_table' => json_encode([get_ip()]),
|
||||
// status 列默认值是 1(禁用),不显式写成 0 新账号登录会被状态校验挡下
|
||||
'status' => 0,
|
||||
'created_at' => get_time(),
|
||||
]);
|
||||
if (!$createModel) {
|
||||
@@ -137,8 +241,8 @@ class LoginService extends BaseNotAuthService
|
||||
'nick_name' => $userInfo->nick_name,
|
||||
'avatar' => $userInfo->avatar,
|
||||
'email' => $userInfo->email ?? '',
|
||||
'role_name' => $userInfo->role->name,
|
||||
'role_value' => $userInfo->role->value,
|
||||
'role_name' => $userInfo->role->name ?? '',
|
||||
'role_value' => $userInfo->role->value ?? '',
|
||||
'ip' => $userInfo->ip,
|
||||
'ip_table' => $userInfo->ip_table,
|
||||
];
|
||||
|
||||
692
app/Service/MaterialService.php
Normal file
692
app/Service/MaterialService.php
Normal file
@@ -0,0 +1,692 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\FileFolderModel;
|
||||
use App\Models\FileModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use App\Service\common\oss\OssRuntimeConfigService;
|
||||
use App\Service\common\oss\OssStorageFactory;
|
||||
use App\Service\common\oss\OssStorageInterface;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 素材库
|
||||
*
|
||||
* 三个动作串成一条链,顺序不能颠倒:
|
||||
* syncFromOss 把 bucket 里的对象补进 nl_file(历史文件根本没有上传记录)
|
||||
* scanReferences 按 config/media_refs 把「谁在用」数出来,回填 ref_count
|
||||
* reclaim 只删 ref_count = 0 且扫过的,先删远端再软删记录
|
||||
* 少了中间那步,刚同步进来的素材 ref_count 默认就是 0,一键回收等于清空 bucket,
|
||||
* 所以 reclaim 里对 last_scan_at 的校验不是冗余检查。
|
||||
*/
|
||||
class MaterialService extends BaseService
|
||||
{
|
||||
/** 单次同步默认条数:一屏够看,又不至于把请求跑到超时 */
|
||||
private const SYNC_DEFAULT_LIMIT = 200;
|
||||
|
||||
/** 单次同步上限,防止前端把 limit 传成十万 */
|
||||
private const SYNC_MAX_LIMIT = 1000;
|
||||
|
||||
/** 分批取值 / 分批回写的批大小 */
|
||||
private const CHUNK_SIZE = 1000;
|
||||
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Job 版本在 CLI / 队列里跑,那里没有 token;HTTP 入口仍然要求登录
|
||||
if (app()->runningInConsole()) {
|
||||
$this->isAuth = false;
|
||||
}
|
||||
parent::__construct();
|
||||
$this->model = FileModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'user_id', 'oss_config_id', 'folder_id', 'name', 'url', 'path', 'ext',
|
||||
'type', 'size', 'width', 'height', 'hash', 'ref_count', 'last_scan_at',
|
||||
'source', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'folder_id' => '=',
|
||||
'type' => '=',
|
||||
'ext' => '=',
|
||||
'oss_config_id' => '=',
|
||||
'source' => '=',
|
||||
];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材列表
|
||||
*
|
||||
* keyword 要同时命中 name / path / url,而基类的 queryField 只会按列 AND,
|
||||
* 拼不出这个 OR 组,所以这里自己组查询,返回结构与 getPageList 保持一致。
|
||||
*
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->getWhere();
|
||||
$keyword = trim((string) request()->get('keyword', ''));
|
||||
$unused = (int) request()->get('unused', 0);
|
||||
$searchTime = request()->get('search_time');
|
||||
|
||||
$query = FileModel::where($this->where)
|
||||
->when($keyword !== '', function ($q) use ($keyword) {
|
||||
$q->where(function ($sub) use ($keyword) {
|
||||
$sub->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('path', 'like', '%' . $keyword . '%')
|
||||
->orWhere('url', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
})
|
||||
->when($unused === 1, fn ($q) => $q->where('ref_count', 0))
|
||||
->when(!empty($searchTime), function ($q) use ($searchTime) {
|
||||
$this->getWhereBetween($searchTime);
|
||||
$q->whereBetween($this->whereBetween[0], $this->whereBetween[1]);
|
||||
});
|
||||
|
||||
return $this->toPage($query->select($this->selectField)->orderByDesc('id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情,带上文件夹名,免得前端为了显示一个名字再请求一次目录树
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->url = $this->media->toPublic($info->url);
|
||||
$info->folder_name = (string) (FileFolderModel::where('id', (int) $info->folder_id)->value('name') ?? '');
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只允许改素材名与所属文件夹
|
||||
*
|
||||
* path / hash / size 是同步结果,手工改会让引用扫描直接失准:
|
||||
* path 一旦被改歪,原本在用的素材就匹配不上任何引用,转头出现在可回收列表里。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$data = [];
|
||||
if (array_key_exists('name', $params)) {
|
||||
$data['name'] = mb_substr(trim((string) $params['name']), 0, 255);
|
||||
}
|
||||
if (array_key_exists('folder_id', $params)) {
|
||||
$data['folder_id'] = $this->assertFolder((int) $params['folder_id']);
|
||||
}
|
||||
if (empty($data)) {
|
||||
$this->utils->errorThrow('没有可更新的内容');
|
||||
}
|
||||
return $this->save($id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只软删素材记录,不动远端对象
|
||||
*
|
||||
* 远端删除必须走 reclaim 的二次校验;这里如果顺手删了远端,
|
||||
* 误删的文件就再也找不回来了,而软删记录随时可以恢复。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
return $this->del(is_array($ids) ? $ids : [$ids]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材概览:总量、占用空间与可回收部分
|
||||
*/
|
||||
public function stat(): array
|
||||
{
|
||||
$base = static fn () => FileModel::where('deleted_at', 0);
|
||||
$types = $base()->selectRaw('type, COUNT(*) as count')
|
||||
->groupBy('type')
|
||||
->orderBy('type')
|
||||
->get()
|
||||
->map(static fn ($row) => ['type' => (int) $row->type, 'count' => (int) $row->count])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'total' => $base()->count(),
|
||||
'total_size' => (int) $base()->sum('size'),
|
||||
'unused' => $base()->where('ref_count', 0)->count(),
|
||||
'unused_size' => (int) $base()->where('ref_count', 0)->sum('size'),
|
||||
'types' => $types,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 OSS 增量拉对象进素材表
|
||||
*
|
||||
* 一次只处理一页:返回的 next_marker 非空就带着它再调一次,前端点几下即可拉完。
|
||||
* 幂等靠三级匹配 —— 先按对象键,再按完整地址(换过域名的老记录),
|
||||
* 最后才按哈希兜住「地址早就变了、内容没变」的历史上传记录。
|
||||
* 反复调用只会更新 size / hash,不会重复插入。
|
||||
*
|
||||
* @param array $params oss_config_id / prefix / marker / limit
|
||||
* @throws Exception
|
||||
*/
|
||||
public function syncFromOss(array $params): array
|
||||
{
|
||||
$configId = (int) ($params['oss_config_id'] ?? 0);
|
||||
if ($configId <= 0) {
|
||||
$this->utils->errorThrow('请选择要同步的存储配置');
|
||||
}
|
||||
$limit = (int) ($params['limit'] ?? self::SYNC_DEFAULT_LIMIT);
|
||||
$limit = $limit > 0 ? min($limit, self::SYNC_MAX_LIMIT) : self::SYNC_DEFAULT_LIMIT;
|
||||
|
||||
$page = $this->driverOf($configId)->listObjects(
|
||||
(string) ($params['prefix'] ?? ''),
|
||||
(string) ($params['marker'] ?? ''),
|
||||
$limit
|
||||
);
|
||||
$items = array_values(array_filter(
|
||||
(array) ($page['items'] ?? []),
|
||||
static fn ($item) => !empty($item['key']) && !str_ends_with((string) $item['key'], '/')
|
||||
));
|
||||
$result = [
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'next_marker' => (string) ($page['next_marker'] ?? ''),
|
||||
'finished' => (bool) ($page['finished'] ?? true),
|
||||
];
|
||||
if (empty($items)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$byPath, $byUrl, $byHash] = $this->existingIndexOf($items);
|
||||
$now = time();
|
||||
$pending = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
$key = (string) $item['key'];
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$url = (string) ($item['url'] ?? '');
|
||||
$hash = (string) ($item['hash'] ?? '');
|
||||
$ext = strtolower((string) pathinfo($key, PATHINFO_EXTENSION));
|
||||
$row = $byPath[$key] ?? $byUrl[$url] ?? ($hash !== '' ? ($byHash[$hash] ?? null) : null);
|
||||
|
||||
if ($row !== null) {
|
||||
FileModel::where('id', $row->id)->update(
|
||||
$this->backfillOf($row, $key, $url, $hash, $ext, (int) ($item['size'] ?? 0), $configId, $now)
|
||||
);
|
||||
$result['updated']++;
|
||||
continue;
|
||||
}
|
||||
$pending[] = [
|
||||
'user_id' => $this->userId,
|
||||
'oss_config_id' => $configId,
|
||||
'folder_id' => 0,
|
||||
'name' => basename($key),
|
||||
'url' => $url,
|
||||
'path' => $key,
|
||||
'ext' => $ext,
|
||||
'type' => FileModel::typeOfExt($ext),
|
||||
'size' => (int) ($item['size'] ?? 0),
|
||||
'width' => 0,
|
||||
'height' => 0,
|
||||
'hash' => $hash,
|
||||
'ref_count' => 0,
|
||||
// 新补录的素材必须是 0:非 0 会被 reclaim 当成「扫过且没人用」直接删掉
|
||||
'last_scan_at' => 0,
|
||||
'source' => FileModel::SOURCE_OSS,
|
||||
// 用对象的实际修改时间当上传时间,否则批量补录出来的素材
|
||||
// 创建时间全挤在同一秒,「N 天前的无引用文件」这个筛选就没意义了
|
||||
'created_at' => (int) ($item['last_modified'] ?? 0) ?: $now,
|
||||
'updated_at' => 0,
|
||||
'deleted_at' => 0,
|
||||
];
|
||||
}
|
||||
if (!empty($pending)) {
|
||||
FileModel::insert($pending);
|
||||
$result['inserted'] = count($pending);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全库引用扫描,回填 ref_count 与 last_scan_at
|
||||
*
|
||||
* 匹配策略:先按完整对象键,再退回文件名兜底。上传时的文件名是随机串,
|
||||
* 现实中不会撞车;真撞车(同名多条)就放弃文件名兜底,宁可少算一次引用,
|
||||
* 也不能把引用记到错的素材头上 —— 记错的那一头会被判成可回收。
|
||||
*
|
||||
* 软删的业务行也照样算引用:那些记录随时可能被恢复,
|
||||
* 把它们的封面提前删掉等于让恢复出来的数据全是裂图。
|
||||
*/
|
||||
public function scanReferences(array $params = []): array
|
||||
{
|
||||
[$byPath, $byName] = $this->buildMaterialIndex();
|
||||
$counts = [];
|
||||
$scanned = 0;
|
||||
|
||||
foreach ((array) config('media_refs', []) as $ref) {
|
||||
$conn = (string) ($ref['connection'] ?? 'mysql');
|
||||
$table = (string) ($ref['table'] ?? '');
|
||||
$field = (string) ($ref['field'] ?? '');
|
||||
$kind = (string) ($ref['kind'] ?? 'single');
|
||||
if ($table === '' || $field === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$schema = Schema::connection($conn);
|
||||
if (!$schema->hasTable($table) || !$schema->hasColumn($table, $field)) {
|
||||
// 登记了但库里还没这列(比如 catalogue.video 尚未上线):跳过而不是报错,
|
||||
// 否则一个规划中的字段就能让整次扫描前功尽弃
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::connection($conn)->table($table)
|
||||
->select(['id', $field])
|
||||
->orderBy('id')
|
||||
->chunk(self::CHUNK_SIZE, function ($rows) use ($field, $kind, $byPath, $byName, &$counts, &$scanned) {
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row->{$field} ?? null;
|
||||
if ($raw === null || trim((string) $raw) === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->extractUrls((string) $raw, $kind) as $url) {
|
||||
$key = $this->normalizeRefKey($url);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$scanned++;
|
||||
$id = $byPath[$key] ?? ($byName[basename($key)] ?? null);
|
||||
if ($id !== null) {
|
||||
$counts[$id] = ($counts[$id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$now = time();
|
||||
// 先整表归零并盖上扫描时间戳,再把有引用的批量改回去:
|
||||
// 逐条 update 在几万条素材上是几万次往返
|
||||
FileModel::where('deleted_at', 0)->update(['ref_count' => 0, 'last_scan_at' => $now]);
|
||||
$grouped = [];
|
||||
foreach ($counts as $id => $count) {
|
||||
$grouped[(int) $count][] = (int) $id;
|
||||
}
|
||||
foreach ($grouped as $count => $ids) {
|
||||
foreach (array_chunk($ids, self::CHUNK_SIZE) as $slice) {
|
||||
FileModel::whereIn('id', $slice)->update(['ref_count' => $count]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'scanned' => $scanned,
|
||||
'referenced' => count($counts),
|
||||
'unused' => FileModel::where('deleted_at', 0)->where('ref_count', 0)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 无引用素材清单
|
||||
*
|
||||
* 除了 ref_count = 0 还要卡「上传超过 N 天」:用户上传完图片、表单还没提交时,
|
||||
* 这张图确实没人引用,但它马上就要被用上,不能出现在回收列表里。
|
||||
*/
|
||||
public function unusedList(array $params = []): array
|
||||
{
|
||||
$days = (int) ($params['days'] ?? 30);
|
||||
$days = $days > 0 ? $days : 30;
|
||||
$query = FileModel::where('deleted_at', 0)
|
||||
->where('ref_count', 0)
|
||||
->where('created_at', '<', time() - $days * 86400)
|
||||
->select($this->selectField)
|
||||
// 先给大文件,回收一页就能腾出可观的空间
|
||||
->orderByDesc('size')
|
||||
->orderByDesc('id');
|
||||
return $this->toPage($query, (int) ($params['pageSize'] ?? 20));
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收:删远端对象,成功后软删记录
|
||||
*
|
||||
* 两道闸门都不能省 —— ref_count = 0 说明扫描时没人用,last_scan_at > 0 说明真的扫过。
|
||||
* 单条失败不中断整批:一批几百个对象,前面已经删掉的必须留下软删记录,
|
||||
* 否则库里还挂着记录、远端对象已经没了,素材库里全是点开 404 的幽灵。
|
||||
*
|
||||
* @param array $ids 素材 ID
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reclaim(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('请选择要回收的素材');
|
||||
}
|
||||
|
||||
$rows = FileModel::whereIn('id', $ids)->where('deleted_at', 0)->get();
|
||||
$deleted = 0;
|
||||
$failed = [];
|
||||
$drivers = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) $row->id;
|
||||
if ((int) $row->last_scan_at <= 0) {
|
||||
$failed[] = ['id' => $id, 'reason' => '尚未扫描过引用,请先执行引用扫描'];
|
||||
continue;
|
||||
}
|
||||
if ((int) $row->ref_count !== 0) {
|
||||
$failed[] = ['id' => $id, 'reason' => '仍被引用 ' . (int) $row->ref_count . ' 处'];
|
||||
continue;
|
||||
}
|
||||
$key = trim((string) $row->path) !== ''
|
||||
? (string) $row->path
|
||||
: $this->normalizeRefKey((string) $row->url);
|
||||
if ($key === '') {
|
||||
$failed[] = ['id' => $id, 'reason' => '没有可定位的对象键,请先同步一次'];
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$configId = (int) $row->oss_config_id;
|
||||
if (!isset($drivers[$configId])) {
|
||||
$drivers[$configId] = $this->driverOf($configId);
|
||||
}
|
||||
if (!$drivers[$configId]->deleteObject($key)) {
|
||||
$failed[] = ['id' => $id, 'reason' => '远端删除失败'];
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$failed[] = ['id' => $id, 'reason' => $e->getMessage()];
|
||||
continue;
|
||||
}
|
||||
$now = time();
|
||||
FileModel::where('id', $id)->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$found = $rows->pluck('id')->map(static fn ($value) => (int) $value)->all();
|
||||
foreach (array_diff($ids, $found) as $missing) {
|
||||
$failed[] = ['id' => (int) $missing, 'reason' => '素材不存在或已删除'];
|
||||
}
|
||||
|
||||
return ['deleted' => $deleted, 'failed' => array_values($failed)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移动到文件夹(folder_id = 0 表示移回根目录)
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function moveToFolder(array $ids, int $folderId): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('请选择要移动的素材');
|
||||
}
|
||||
$folderId = $this->assertFolder($folderId);
|
||||
$moved = FileModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
|
||||
'folder_id' => $folderId,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return ['moved' => (int) $moved, 'folder_id' => $folderId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页结果统一成前端约定的结构
|
||||
*/
|
||||
private function toPage($query, int $pageSize = 0): array
|
||||
{
|
||||
$pageSize = $pageSize > 0 ? $pageSize : (int) request()->get('pageSize', 20);
|
||||
$result = $query->paginate($pageSize > 0 ? $pageSize : 20)->toArray();
|
||||
foreach ($result['data'] as &$item) {
|
||||
$item['url'] = $this->media->toPublic($item['url'] ?? '');
|
||||
}
|
||||
unset($item);
|
||||
return [
|
||||
'page' => $result['current_page'],
|
||||
'size' => $result['per_page'],
|
||||
'page_count' => $result['last_page'],
|
||||
'total' => $result['total'],
|
||||
'items' => $result['data'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次把本批可能命中的已有记录捞出来,避免逐条 select 打 N 次库
|
||||
*
|
||||
* byHash 只收 path still 为空的行:按内容哈希去认亲很容易把「两个键、同一份内容」
|
||||
* 的对象合成一条,只用来给还没记过对象键的历史上传记录补档。
|
||||
*
|
||||
* @param array<int, array> $items
|
||||
* @return array{0: array<string, mixed>, 1: array<string, mixed>, 2: array<string, mixed>}
|
||||
*/
|
||||
private function existingIndexOf(array $items): array
|
||||
{
|
||||
$paths = array_values(array_unique(array_column($items, 'key')));
|
||||
$urls = array_values(array_unique(array_filter(array_column($items, 'url'))));
|
||||
$hashes = array_values(array_unique(array_filter(array_column($items, 'hash'))));
|
||||
|
||||
$rows = FileModel::where('deleted_at', 0)
|
||||
->where(function ($q) use ($paths, $urls, $hashes) {
|
||||
$q->whereIn('path', $paths);
|
||||
if (!empty($urls)) {
|
||||
$q->orWhereIn('url', $urls);
|
||||
}
|
||||
if (!empty($hashes)) {
|
||||
$q->orWhere(function ($sub) use ($hashes) {
|
||||
$sub->where('path', '')->whereIn('hash', $hashes);
|
||||
});
|
||||
}
|
||||
})
|
||||
->get(['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']);
|
||||
|
||||
$byPath = [];
|
||||
$byUrl = [];
|
||||
$byHash = [];
|
||||
foreach ($rows as $row) {
|
||||
$path = (string) $row->path;
|
||||
$url = (string) $row->url;
|
||||
$hash = (string) $row->hash;
|
||||
if ($path !== '') {
|
||||
$byPath[$path] = $row;
|
||||
}
|
||||
if ($url !== '' && !isset($byUrl[$url])) {
|
||||
$byUrl[$url] = $row;
|
||||
}
|
||||
if ($path === '' && $hash !== '' && !isset($byHash[$hash])) {
|
||||
$byHash[$hash] = $row;
|
||||
}
|
||||
}
|
||||
return [$byPath, $byUrl, $byHash];
|
||||
}
|
||||
|
||||
/**
|
||||
* 已有记录的更新字段
|
||||
*
|
||||
* size / hash 每次都覆盖(对象可能被同名替换过),其余列只在原值为空时补,
|
||||
* 免得把用户在素材库里改过的名字、归过的文件夹又冲回默认值。
|
||||
*/
|
||||
private function backfillOf(
|
||||
mixed $row,
|
||||
string $key,
|
||||
string $url,
|
||||
string $hash,
|
||||
string $ext,
|
||||
int $size,
|
||||
int $configId,
|
||||
int $now
|
||||
): array {
|
||||
$update = ['size' => $size, 'updated_at' => $now];
|
||||
if ($hash !== '') {
|
||||
$update['hash'] = $hash;
|
||||
}
|
||||
if (trim((string) $row->path) === '') {
|
||||
$update['path'] = $key;
|
||||
}
|
||||
if (trim((string) $row->url) === '' && $url !== '') {
|
||||
$update['url'] = $url;
|
||||
}
|
||||
if (trim((string) $row->ext) === '' && $ext !== '') {
|
||||
$update['ext'] = $ext;
|
||||
$update['type'] = FileModel::typeOfExt($ext);
|
||||
}
|
||||
if (trim((string) $row->name) === '') {
|
||||
$update['name'] = basename($key);
|
||||
}
|
||||
if ((int) $row->oss_config_id === 0) {
|
||||
$update['oss_config_id'] = $configId;
|
||||
}
|
||||
return $update;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材索引:对象键 → id,文件名 → id
|
||||
*
|
||||
* 文件名索引里同名多条时置 null(放弃兜底),见 scanReferences 的说明。
|
||||
*
|
||||
* @return array{0: array<string, int>, 1: array<string, int|null>}
|
||||
*/
|
||||
private function buildMaterialIndex(): array
|
||||
{
|
||||
$byPath = [];
|
||||
$byName = [];
|
||||
FileModel::where('deleted_at', 0)
|
||||
->select(['id', 'path', 'url'])
|
||||
->orderBy('id')
|
||||
->chunk(self::CHUNK_SIZE, function ($rows) use (&$byPath, &$byName) {
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) $row->id;
|
||||
// path 与 url 都进索引:老记录只有 url,新记录两者都有
|
||||
foreach ([(string) $row->path, (string) $row->url] as $candidate) {
|
||||
$key = $this->normalizeRefKey($candidate);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$byPath[$key] = $id;
|
||||
$name = basename($key);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$byName[$name] = array_key_exists($name, $byName) && $byName[$name] !== $id
|
||||
? null
|
||||
: $id;
|
||||
}
|
||||
}
|
||||
});
|
||||
return [$byPath, $byName];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把引用地址收敛成能与素材 path 比对的对象键
|
||||
*
|
||||
* 同一个文件在库里可能是绝对地址、/storage 相对地址、带 ?imageView2 处理参数
|
||||
* 三种写法,不先归一化就只能匹配上碰巧写法一致的那批。
|
||||
*/
|
||||
private function normalizeRefKey(string $value): string
|
||||
{
|
||||
$value = $this->media->stripProcessParams($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$path = (string) (parse_url($value, PHP_URL_PATH) ?: $value);
|
||||
$path = ltrim((string) preg_replace('#/{2,}#', '/', rawurldecode($path)), '/');
|
||||
// 本地存储出库地址带 /storage 前缀,对象键里没有
|
||||
if (str_starts_with($path, 'storage/')) {
|
||||
$path = substr($path, 8);
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按登记的 kind 从字段值里取出地址
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractUrls(string $value, string $kind): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
return match ($kind) {
|
||||
'multi' => array_values(array_filter(
|
||||
array_map('trim', explode(',', $value)),
|
||||
static fn ($item) => $item !== ''
|
||||
)),
|
||||
'rich' => $this->extractFromRichText($value),
|
||||
default => [$value],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本 / Markdown 里的地址
|
||||
*
|
||||
* 三种写法都要抽:HTML 属性(src/href/poster)、内联样式的 url(…)、
|
||||
* Markdown 的 。只抽 src 会漏掉正文里手写的 Markdown 图片。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractFromRichText(string $content): array
|
||||
{
|
||||
$found = [];
|
||||
$patterns = [
|
||||
'#(?:src|href|poster|data-src)\s*=\s*[\'"]([^\'"]+)[\'"]#i',
|
||||
'#url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)#i',
|
||||
'#!\[[^\]]*\]\(\s*([^\s)]+)#',
|
||||
];
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
foreach ($matches[1] as $hit) {
|
||||
$hit = trim((string) $hit);
|
||||
if ($hit !== '') {
|
||||
$found[] = $hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($found));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取指定配置的存储驱动
|
||||
*
|
||||
* oss_config_id 为 0 的是扩表之前的老记录,只能按当前启用配置去删;
|
||||
* 换过存储的站点这类记录得先同步一次把 config_id 补上,否则会删错 bucket。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function driverOf(int $configId): OssStorageInterface
|
||||
{
|
||||
$runtime = OssRuntimeConfigService::getInstance();
|
||||
$config = $configId > 0 ? $runtime->getConfigById($configId) : $runtime->getActiveConfig();
|
||||
return OssStorageFactory::getInstance()->make($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 表示根目录,其余必须是存在的文件夹
|
||||
*
|
||||
* 不校验就会把素材移进一个查不到的 folder_id,那批文件在素材库里再也点不出来
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertFolder(int $folderId): int
|
||||
{
|
||||
if ($folderId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!FileFolderModel::where('id', $folderId)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->notFound('文件夹不存在');
|
||||
}
|
||||
return $folderId;
|
||||
}
|
||||
}
|
||||
226
app/Service/PermissionService.php
Normal file
226
app/Service/PermissionService.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\ApiEndpointModel;
|
||||
use App\Models\RoleEndpointRelationModel;
|
||||
use App\Service\common\RedisService;
|
||||
|
||||
/**
|
||||
* 接口级权限
|
||||
*
|
||||
* 权限码由接口路径推导:admin/list → admin:list,前端 v-access / TableAction 的 auth 直接用它。
|
||||
* 不继承 BaseService:中间件在鉴权阶段就要用它,而 BaseService 的构造函数本身会做鉴权,会绕成环。
|
||||
*/
|
||||
class PermissionService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
/** @var array<int, array{codes: array<int, string>, paths: array<int, string>}> 进程内缓存,一次请求里中间件与 codes 接口各要用一次 */
|
||||
private array $roleMemo = [];
|
||||
|
||||
/** @var null|array<string, int> 接口注册表 path => id */
|
||||
private ?array $pathMemo = null;
|
||||
|
||||
public static function getInstance(): static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员:代码里多处硬判断 role_id === 1 全量放行,这里保持一致
|
||||
*/
|
||||
public function isSuper(int $roleId): bool
|
||||
{
|
||||
return $roleId === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色可用的权限码
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function codes(int $roleId): array
|
||||
{
|
||||
return $this->load($roleId)['codes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断角色能否调用某接口
|
||||
*
|
||||
* @param string $path 已去掉 /api/ 前缀的路径,如 admin/list
|
||||
*/
|
||||
public function allows(int $roleId, string $path): bool
|
||||
{
|
||||
if ($this->isSuper($roleId)) {
|
||||
return true;
|
||||
}
|
||||
$path = trim($path, '/');
|
||||
if ($path === '' || in_array($path, (array) config('nl.api.permission.always_allow', []), true)) {
|
||||
return true;
|
||||
}
|
||||
$registered = $this->registeredPaths();
|
||||
if (!isset($registered[$path])) {
|
||||
// 没登记进接口注册表的接口:迁移期放行,strict 模式拒绝
|
||||
return !config('nl.api.permission.strict', false);
|
||||
}
|
||||
return in_array($path, $this->load($roleId)['paths'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口授权树:按控制器分组,供角色授权抽屉勾选
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function endpointTree(): array
|
||||
{
|
||||
$rows = ApiEndpointModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->orderBy('controller')
|
||||
->orderBy('url')
|
||||
->get(['id', 'url', 'name', 'method', 'controller'])
|
||||
->toArray();
|
||||
|
||||
$groups = [];
|
||||
foreach ($rows as $row) {
|
||||
$controller = $row['controller'] !== '' ? $row['controller'] : '未归类';
|
||||
if (!isset($groups[$controller])) {
|
||||
$groups[$controller] = [
|
||||
// 分组节点的 id 取负值,避免和真实 endpoint_id 混淆
|
||||
'id' => -1 * (count($groups) + 1),
|
||||
'title' => $controller,
|
||||
'code' => '',
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
$groups[$controller]['children'][] = [
|
||||
'id' => (int) $row['id'],
|
||||
'title' => $row['name'] !== '' ? $row['name'] : $row['url'],
|
||||
'code' => $this->pathToCode($row['url']),
|
||||
'url' => $row['url'],
|
||||
];
|
||||
}
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色已授权的接口 id
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function grantedIds(int $roleId): array
|
||||
{
|
||||
return RoleEndpointRelationModel::where('role_id', $roleId)
|
||||
->pluck('endpoint_id')
|
||||
->map(fn ($v) => (int) $v)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存角色的接口授权(全量覆盖)
|
||||
*/
|
||||
public function grant(int $roleId, array $endpointIds): bool
|
||||
{
|
||||
// 分组节点用的是负 id,落库前剔掉
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $endpointIds), fn ($id) => $id > 0)));
|
||||
$exists = $this->grantedIds($roleId);
|
||||
|
||||
$toDelete = array_diff($exists, $ids);
|
||||
if (!empty($toDelete)) {
|
||||
RoleEndpointRelationModel::where('role_id', $roleId)
|
||||
->whereIn('endpoint_id', array_values($toDelete))
|
||||
->delete();
|
||||
}
|
||||
$toAdd = array_diff($ids, $exists);
|
||||
if (!empty($toAdd)) {
|
||||
$now = time();
|
||||
RoleEndpointRelationModel::insert(array_map(
|
||||
fn ($id) => ['role_id' => $roleId, 'endpoint_id' => $id, 'created_at' => $now],
|
||||
array_values($toAdd)
|
||||
));
|
||||
}
|
||||
$this->clear($roleId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清角色权限缓存;不传角色则全清
|
||||
*/
|
||||
public function clear(?int $roleId = null): void
|
||||
{
|
||||
$this->pathMemo = null;
|
||||
$redis = RedisService::getInstance()->init(config('nl.redis.permission_key'));
|
||||
if ($roleId === null) {
|
||||
$this->roleMemo = [];
|
||||
$redis->delAll();
|
||||
return;
|
||||
}
|
||||
unset($this->roleMemo[$roleId]);
|
||||
$redis->del($roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* admin/list → admin:list
|
||||
*/
|
||||
public function pathToCode(string $path): string
|
||||
{
|
||||
return str_replace('/', ':', trim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{codes: array<int, string>, paths: array<int, string>}
|
||||
*/
|
||||
private function load(int $roleId): array
|
||||
{
|
||||
if (isset($this->roleMemo[$roleId])) {
|
||||
return $this->roleMemo[$roleId];
|
||||
}
|
||||
$redis = RedisService::getInstance()->init(config('nl.redis.permission_key'));
|
||||
$cached = $redis->get($roleId);
|
||||
if (!empty($cached)) {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded) && isset($decoded['codes'], $decoded['paths'])) {
|
||||
return $this->roleMemo[$roleId] = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isSuper($roleId)) {
|
||||
// 超管拿全量码:前端按码匹配,给 ['*'] 反而什么都匹配不上
|
||||
$paths = array_keys($this->registeredPaths());
|
||||
} else {
|
||||
$paths = ApiEndpointModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->whereIn('id', $this->grantedIds($roleId))
|
||||
->pluck('url')
|
||||
->all();
|
||||
}
|
||||
$paths = array_values(array_unique(array_map(fn ($p) => trim((string) $p, '/'), $paths)));
|
||||
$data = [
|
||||
'paths' => $paths,
|
||||
'codes' => array_map(fn ($p) => $this->pathToCode($p), $paths),
|
||||
];
|
||||
$redis->set($roleId, json_encode($data, JSON_UNESCAPED_UNICODE), 3600);
|
||||
return $this->roleMemo[$roleId] = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登记的接口路径表(path => id)
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function registeredPaths(): array
|
||||
{
|
||||
if ($this->pathMemo !== null) {
|
||||
return $this->pathMemo;
|
||||
}
|
||||
$rows = ApiEndpointModel::where('deleted_at', 0)
|
||||
->where('status', 1)
|
||||
->pluck('id', 'url')
|
||||
->all();
|
||||
$normalized = [];
|
||||
foreach ($rows as $url => $id) {
|
||||
$normalized[trim((string) $url, '/')] = (int) $id;
|
||||
}
|
||||
return $this->pathMemo = $normalized;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ class RoleService extends BaseService
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = RoleModel::class;
|
||||
$this->selectField = ['id', 'name', 'value', 'pid', 'desc', 'created_at'];
|
||||
$this->queryField = ['name' => 'like', 'value' => 'like', 'pid' => '='];
|
||||
$this->selectField = ['id', 'name', 'value', 'pid', 'desc', 'status', 'color', 'created_at'];
|
||||
$this->queryField = ['name' => 'like', 'value' => 'like', 'pid' => '=', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
@@ -97,13 +97,13 @@ class RoleService extends BaseService
|
||||
'created_at' => time()
|
||||
];
|
||||
}
|
||||
$userRoleBinding = RoleMenuRelationModel::insert($insertData);
|
||||
if (!$userRoleBinding) {
|
||||
// 只取消勾选、没有新增时 $insertData 为空,insert([]) 返回 false 会误判成失败
|
||||
if (!empty($insertData) && !RoleMenuRelationModel::insert($insertData)) {
|
||||
$this->utils->errorThrow('更新失败!');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
RedisService::getInstance()->init(config('nl.redis.menu_key'))->del($roleId);
|
||||
$this->flushRoleCache((int) $roleId);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->utils->errorThrow($e->getMessage());
|
||||
@@ -112,6 +112,50 @@ class RoleService extends BaseService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口授权树
|
||||
*/
|
||||
public function endpointTree(): array
|
||||
{
|
||||
return PermissionService::getInstance()->endpointTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色已授权的接口 id
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getEndpointIdsByRoleId($roleId): array
|
||||
{
|
||||
if (empty($roleId)) $this->utils->errorThrow('请选择角色');
|
||||
return PermissionService::getInstance()->grantedIds((int) $roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存角色接口授权
|
||||
* @throws Exception
|
||||
*/
|
||||
public function saveRoleEndpoint(int $roleId, array $endpointIds): bool
|
||||
{
|
||||
if ($roleId <= 0) $this->utils->errorThrow('请选择角色');
|
||||
if ($roleId === 1) $this->utils->errorThrow('超级管理员默认拥有全部接口权限,无需授权');
|
||||
return PermissionService::getInstance()->grant($roleId, $endpointIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用 / 停用角色
|
||||
*
|
||||
* 停用后该角色下的账号仍能登录但权限为空,所以要顺手清掉权限与菜单缓存
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
$id = (int) $id;
|
||||
if ($id === 1) $this->utils->errorThrow('超级管理员角色禁止停用');
|
||||
$result = $this->save($id, ['status' => (int) $status]);
|
||||
$this->flushRoleCache($id);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据详情
|
||||
* @param $id
|
||||
@@ -159,6 +203,19 @@ class RoleService extends BaseService
|
||||
if (array_intersect(array_map('intval', $ids), [1, 2])) {
|
||||
$this->utils->errorThrow('管理员角色禁止删除');
|
||||
}
|
||||
return $this->del($id);
|
||||
$result = $this->del($id);
|
||||
foreach ($ids as $roleId) {
|
||||
$this->flushRoleCache((int) $roleId);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色的菜单与权限都按角色缓存,改动后必须一起清,否则要等 1 小时才生效
|
||||
*/
|
||||
private function flushRoleCache(int $roleId): void
|
||||
{
|
||||
RedisService::getInstance()->init(config('nl.redis.menu_key'))->del($roleId);
|
||||
PermissionService::getInstance()->clear($roleId);
|
||||
}
|
||||
}
|
||||
|
||||
157
app/Service/WxAppConfigService.php
Normal file
157
app/Service/WxAppConfigService.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
|
||||
/**
|
||||
* 小程序应用配置管理(后台)
|
||||
*
|
||||
* 密钥列只写不读:列表与详情一律返回掩码,前端留空表示不修改。
|
||||
* 这么做的原因是老项目把 AppSecret 明文放源码里、还进了 git 历史。
|
||||
*/
|
||||
class WxAppConfigService extends BaseService
|
||||
{
|
||||
/**
|
||||
* @var array<int, string> 密文列
|
||||
*/
|
||||
private const SECRET_FIELDS = ['app_secret', 'mch_key', 'mch_private_key'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = WxAppModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url',
|
||||
'template_code', 'status', 'remark', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = ['code' => '=', 'name' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item = $this->withSecretFlags($item);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'name', 'code'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
return $this->withSecretFlags(is_array($info) ? $info : $info->toArray());
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->insert($this->encryptSecrets($params));
|
||||
}
|
||||
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
return $this->save($id, $this->encryptSecrets($params));
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 .env 的 WX_DEFAULT_APP_CODE / WX_APP_ID 引导落库一条空密钥记录
|
||||
* 方便首次部署:先建壳子,再进后台填 AppSecret(密钥仍不进 .env)
|
||||
*/
|
||||
public function initFromEnv(): array
|
||||
{
|
||||
$code = trim((string) config('nl.wx.default_app_code', ''));
|
||||
$appId = trim((string) config('nl.wx.env_app_id', ''));
|
||||
$name = trim((string) config('nl.wx.env_app_name', ''));
|
||||
if ($code === '' || $appId === '') {
|
||||
$this->utils->errorThrow('请先在 .env 配置 WX_DEFAULT_APP_CODE 与 WX_APP_ID,再点初始化');
|
||||
}
|
||||
$row = WxAppModel::where('code', $code)->where('deleted_at', 0)->first();
|
||||
if (!empty($row)) {
|
||||
$row->update([
|
||||
'app_id' => $appId,
|
||||
'name' => $name !== '' ? $name : $row->name,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return [
|
||||
'created' => false,
|
||||
'id' => (int) $row->id,
|
||||
'code' => $code,
|
||||
'app_id' => $appId,
|
||||
'app_secret_set' => trim((string) ($row->app_secret ?? '')) !== '',
|
||||
'message' => '已更新 AppID/名称,请编辑该行填写 AppSecret',
|
||||
];
|
||||
}
|
||||
$id = WxAppModel::insertGetId([
|
||||
'code' => $code,
|
||||
'name' => $name !== '' ? $name : $code,
|
||||
'app_id' => $appId,
|
||||
'status' => 0,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return [
|
||||
'created' => true,
|
||||
'id' => (int) $id,
|
||||
'code' => $code,
|
||||
'app_id' => $appId,
|
||||
'app_secret_set' => false,
|
||||
'message' => '已创建应用记录,请编辑填写 AppSecret',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密要落库的密钥;留空表示不改动
|
||||
*/
|
||||
private function encryptSecrets(array $params): array
|
||||
{
|
||||
$encrypt = FieldEncryptService::getInstance();
|
||||
foreach (self::SECRET_FIELDS as $field) {
|
||||
if (!array_key_exists($field, $params)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim((string) $params[$field]);
|
||||
if ($value === '') {
|
||||
unset($params[$field]);
|
||||
continue;
|
||||
}
|
||||
if ($encrypt->isEncrypted($value)) {
|
||||
continue;
|
||||
}
|
||||
$params[$field] = $encrypt->encryptForStorage($value);
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只告诉前端「配没配」,不回显密文也不回显明文
|
||||
*/
|
||||
private function withSecretFlags(array $row): array
|
||||
{
|
||||
$raw = WxAppModel::where('id', $row['id'] ?? 0)->first(self::SECRET_FIELDS);
|
||||
foreach (self::SECRET_FIELDS as $field) {
|
||||
$row[$field . '_set'] = !empty($raw[$field] ?? '');
|
||||
unset($row[$field]);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
83
app/Service/business/CardClassService.php
Normal file
83
app/Service/business/CardClassService.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CardClassModel;
|
||||
use App\Models\business\ColorcardModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 色卡分类
|
||||
*/
|
||||
class CardClassService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = CardClassModel::class;
|
||||
$this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
return $this->getPageList();
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (ColorcardModel::whereIn('card_class', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该分类下仍有色卡,请先调整色卡分类');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
124
app/Service/business/CarouselService.php
Normal file
124
app/Service/business/CarouselService.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CarouselModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 小程序轮播图
|
||||
*
|
||||
* 老 CarouselService 是从 CategoryService 复制来的,option() 去查 carousel 表上并不存在的
|
||||
* name / pid 两列,调用即报错。这里按轮播图自己的字段重写。
|
||||
*/
|
||||
class CarouselService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = CarouselModel::class;
|
||||
$this->selectField = ['id', 'url', 'to_path', 'sort', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['to_path' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$this->media->publicEach($result['items'], ['url']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$rows = CarouselModel::where('deleted_at', 0)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'url', 'to_path', 'sort'])
|
||||
->toArray();
|
||||
$this->media->publicEach($rows, ['url']);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->url = $this->media->toPublic($info->url);
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增:支持一次选多张图批量建轮播
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$urls = $params['url'] ?? '';
|
||||
$urls = is_array($urls) ? $urls : [$urls];
|
||||
$now = time();
|
||||
$sort = (int) ($params['sort'] ?? 0);
|
||||
|
||||
$rows = [];
|
||||
foreach ($urls as $index => $url) {
|
||||
$url = $this->media->toStorage(is_string($url) ? $url : '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'url' => $url,
|
||||
'to_path' => (string) ($params['to_path'] ?? ''),
|
||||
'sort' => $sort + $index,
|
||||
// 缺省显示;前端可传 status,批量建时统一用同一状态
|
||||
'status' => (int) ($params['status'] ?? 0),
|
||||
'created_at' => $now,
|
||||
];
|
||||
}
|
||||
if (empty($rows)) {
|
||||
$this->utils->errorThrow('请上传轮播图');
|
||||
}
|
||||
if (count($rows) === 1) {
|
||||
return $this->insert($rows[0]);
|
||||
}
|
||||
return CarouselModel::insert($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('url', $params)) {
|
||||
$params['url'] = $this->media->firstOf($params['url']);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
193
app/Service/business/CatalogueService.php
Normal file
193
app/Service/business/CatalogueService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
use App\Models\business\ImageModel;
|
||||
use App\Models\business\PriceSheetModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 商品图册
|
||||
*/
|
||||
class CatalogueService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = CatalogueModel::class;
|
||||
$this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '='];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表
|
||||
*
|
||||
* 两处沿用老行为:
|
||||
* 1. 按分类筛选时连同该分类的子分类一起查,否则选了父分类会一条都搜不到
|
||||
* 2. 报价单里 6 个历史材质列绝大多数为空,只把有值的列名回给前端(show_field),
|
||||
* 前端据此决定表格显示哪几列
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = ['category', 'priceSheet'];
|
||||
|
||||
$categoryId = (int) request()->get('category_id', 0);
|
||||
if ($categoryId > 0) {
|
||||
$ids = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all();
|
||||
$ids[] = $categoryId;
|
||||
$this->whereIn = ['category_id', $ids];
|
||||
}
|
||||
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['category_name'] = $item['category']['name'] ?? '';
|
||||
unset($item['category']);
|
||||
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
|
||||
$item['pdf'] = $this->media->toPublic($item['pdf'] ?? '');
|
||||
$item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'title as name', 'identifier'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情,带规格与两类相册
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->cover = $this->media->toPublic($info->cover);
|
||||
$info->pdf = $this->media->toPublic($info->pdf);
|
||||
|
||||
$priceSheet = PriceSheetModel::where('catalogue_id', $id)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->toArray();
|
||||
$info->show_field = $this->pickUsedMaterialFields($priceSheet);
|
||||
$info->price_sheet = $priceSheet;
|
||||
$info->render_images = $this->imagesOf($id, ImageModel::TYPE_RENDER);
|
||||
$info->physical_images = $this->imagesOf($id, ImageModel::TYPE_PHYSICAL);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params = $this->normalize($params);
|
||||
$this->assertIdentifierUnique((string) ($params['identifier'] ?? ''));
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$params = $this->normalize($params);
|
||||
if (array_key_exists('identifier', $params)) {
|
||||
$this->assertIdentifierUnique((string) $params['identifier'], (int) $id);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商品:连带软删它的规格与相册,否则会留下一堆查不到主体的孤儿数据
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$now = time();
|
||||
PriceSheetModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0)
|
||||
->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
ImageModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0)
|
||||
->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价单里真正有值的材质列
|
||||
*/
|
||||
private function pickUsedMaterialFields(array $priceSheetRows): array
|
||||
{
|
||||
$used = [];
|
||||
foreach ($priceSheetRows as $row) {
|
||||
foreach (PriceSheetModel::MATERIAL_FIELDS as $field) {
|
||||
if (!empty($row[$field] ?? '')) {
|
||||
$used[$field] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_keys($used);
|
||||
}
|
||||
|
||||
private function imagesOf(int|string $catalogueId, int $type): array
|
||||
{
|
||||
$rows = ImageModel::where('catalogue_id', $catalogueId)
|
||||
->where('type', $type)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get(['id', 'url', 'type'])
|
||||
->toArray();
|
||||
$this->media->publicEach($rows, ['url']);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function normalize(array $params): array
|
||||
{
|
||||
foreach (['cover', 'pdf'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$params[$field] = $this->media->firstOf($params[$field]);
|
||||
}
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品编号是小程序搜索和线下对单的依据,重复了就没法定位货品
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertIdentifierUnique(string $identifier, int $exceptId = 0): void
|
||||
{
|
||||
$identifier = trim($identifier);
|
||||
if ($identifier === '') {
|
||||
return;
|
||||
}
|
||||
$exists = CatalogueModel::where('identifier', $identifier)
|
||||
->where('deleted_at', 0)
|
||||
->when($exceptId > 0, fn ($q) => $q->where('id', '<>', $exceptId))
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$this->utils->errorThrow('商品编号已存在:' . $identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/Service/business/CategoryService.php
Normal file
121
app/Service/business/CategoryService.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 商品分类
|
||||
*/
|
||||
class CategoryService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = CategoryModel::class;
|
||||
$this->selectField = ['id', 'name', 'url', 'pid', 'sort', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'pid' => '=', 'status' => '='];
|
||||
// 有 sort 列后按排序值,同值再按 id,保证稳定
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$this->media->publicEach($result['items'], ['url']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类树。老接口在首位塞了一个 {id:0,name:'全部'} 供小程序做「全部」标签用,
|
||||
* 这里保留该行为,但只在显式要求时加,后台表单选上级时不需要它。
|
||||
*/
|
||||
public function option(): array
|
||||
{
|
||||
$withAll = filter_var(request()->get('with_all', false), FILTER_VALIDATE_BOOLEAN);
|
||||
$rows = CategoryModel::where('deleted_at', 0)
|
||||
->orderBy('sort', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get(['id', 'name', 'url', 'pid', 'sort'])
|
||||
->toArray();
|
||||
$this->media->publicEach($rows, ['url']);
|
||||
$tree = $this->utils->tree($rows);
|
||||
if ($withAll) {
|
||||
array_unshift($tree, ['id' => 0, 'name' => '全部', 'url' => '', 'pid' => 0]);
|
||||
}
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->url = $this->media->toPublic($info->url);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params['url'] = $this->media->firstOf($params['url'] ?? '');
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('url', $params)) {
|
||||
$params['url'] = $this->media->firstOf($params['url']);
|
||||
}
|
||||
if (array_key_exists('pid', $params)) {
|
||||
$pid = (int) $params['pid'];
|
||||
if ($pid === (int) $id) {
|
||||
$this->utils->errorThrow('上级分类不能是自己');
|
||||
}
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类:有子分类或仍挂着商品时拒绝,避免商品失去归属后在小程序里查不到
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (CategoryModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('存在子分类,请先删除子分类');
|
||||
}
|
||||
if (CatalogueModel::whereIn('category_id', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该分类下仍有商品,请先调整商品分类');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
96
app/Service/business/ColorcardService.php
Normal file
96
app/Service/business/ColorcardService.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\ColorcardModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 色卡
|
||||
*/
|
||||
class ColorcardService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = ColorcardModel::class;
|
||||
$this->selectField = ['id', 'card_class', 'company', 'price', 'description', 'cover', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['card_class' => '=', 'company' => '=', 'description' => 'like', 'status' => '='];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = ['cardClassInfo', 'companyInfo'];
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['card_class_name'] = $item['card_class_info']['name'] ?? '';
|
||||
$item['company_name'] = $item['company_info']['name'] ?? '';
|
||||
unset($item['card_class_info'], $item['company_info']);
|
||||
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'description as name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->cover = $this->media->toPublic($info->cover);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
if (array_key_exists('cover', $params)) {
|
||||
$params['cover'] = $this->media->firstOf($params['cover']);
|
||||
}
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('cover', $params)) {
|
||||
$params['cover'] = $this->media->firstOf($params['cover']);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
82
app/Service/business/CompanyService.php
Normal file
82
app/Service/business/CompanyService.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\ColorcardModel;
|
||||
use App\Models\business\CompanyModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 色卡所属公司
|
||||
*/
|
||||
class CompanyService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = CompanyModel::class;
|
||||
$this->selectField = ['id', 'name', 'phone', 'address', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'phone' => 'like', 'address' => 'like', 'status' => '='];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
return $this->getPageList();
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (ColorcardModel::whereIn('company', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该公司下仍有色卡,请先调整色卡所属公司');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
162
app/Service/business/EnterpriseService.php
Normal file
162
app/Service/business/EnterpriseService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\EnterpriseModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 企业管理
|
||||
*
|
||||
* 老项目里企业只是微信用户管理页顺手塞的两个字段(name/logo),
|
||||
* 没有独立模块,也没有联系人、税号、默认倍率,做不了对公结算。
|
||||
*/
|
||||
class EnterpriseService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = EnterpriseModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'name', 'logo', 'contact_name', 'phone', 'address',
|
||||
'tax_no', 'settle_type', 'price_number', 'status', 'remark',
|
||||
'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'name' => 'like',
|
||||
'contact_name' => 'like',
|
||||
'phone' => 'like',
|
||||
'status' => '=',
|
||||
];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$ids = array_column($result['items'], 'id');
|
||||
$counts = empty($ids)
|
||||
? []
|
||||
: WxUserModel::whereIn('enterprise_id', $ids)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('enterprise_id, COUNT(*) as total')
|
||||
->groupBy('enterprise_id')
|
||||
->pluck('total', 'enterprise_id')
|
||||
->all();
|
||||
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['logo'] = $this->media->toPublic($item['logo'] ?? '');
|
||||
$item['user_count'] = (int) ($counts[$item['id']] ?? 0);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉:SearchSelect 组件按关键词模糊搜索,所以支持 keyword 入参
|
||||
*/
|
||||
public function option(): mixed
|
||||
{
|
||||
$keyword = trim((string) request()->get('keyword', ''));
|
||||
$limit = (int) request()->get('limit', 30);
|
||||
$limit = $limit > 0 && $limit <= 100 ? $limit : 30;
|
||||
|
||||
return EnterpriseModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->when($keyword !== '', function ($q) use ($keyword) {
|
||||
$q->where(function ($sub) use ($keyword) {
|
||||
$sub->where('name', 'like', "%{$keyword}%")
|
||||
->orWhere('contact_name', 'like', "%{$keyword}%")
|
||||
->orWhere('phone', 'like', "%{$keyword}%");
|
||||
});
|
||||
})
|
||||
->orderBy('id', 'desc')
|
||||
->limit($limit)
|
||||
->get(['id', 'name', 'logo', 'contact_name', 'phone', 'price_number'])
|
||||
->map(function ($row) {
|
||||
$row->logo = $this->media->toPublic($row->logo);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情:带企业下的微信用户
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->logo = $this->media->toPublic($info->logo);
|
||||
$info->users = WxUserModel::where('enterprise_id', $id)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc')
|
||||
->get(['id', 'nick_name', 'phone', 'is_p', 'show_price', 'price_number', 'created_at'])
|
||||
->toArray();
|
||||
$info->user_count = count($info->users);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params = $this->normalize($params);
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$params = $this->normalize($params);
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除企业前先解绑用户,否则用户会挂在一个查不到的企业上
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (WxUserModel::whereIn('enterprise_id', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该企业下仍有微信用户,请先解绑用户');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
private function normalize(array $params): array
|
||||
{
|
||||
if (array_key_exists('logo', $params)) {
|
||||
$params['logo'] = $this->media->firstOf($params['logo']);
|
||||
}
|
||||
if (array_key_exists('price_number', $params)) {
|
||||
$number = $params['price_number'];
|
||||
$params['price_number'] = !is_numeric($number) || (float) $number <= 0
|
||||
? '1'
|
||||
: (string) $number;
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
83
app/Service/business/FactoryClassificationService.php
Normal file
83
app/Service/business/FactoryClassificationService.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\FactoryClassificationModel;
|
||||
use App\Models\business\FactoryInfoModel;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 工厂分类
|
||||
*/
|
||||
class FactoryClassificationService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = FactoryClassificationModel::class;
|
||||
$this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
return $this->getPageList();
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
if (FactoryInfoModel::whereIn('classification', $ids)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('该分类下仍有工厂,请先调整工厂分类');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
141
app/Service/business/FactoryImageService.php
Normal file
141
app/Service/business/FactoryImageService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\FactoryImageModel;
|
||||
use App\Models\business\FactoryInfoModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 工厂产品图
|
||||
*/
|
||||
class FactoryImageService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = FactoryImageModel::class;
|
||||
$this->selectField = ['id', 'factory', 'url', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['factory' => '=', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'desc'];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = ['factoryInfo'];
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['factory_name'] = $item['factory_info']['name'] ?? '';
|
||||
unset($item['factory_info']);
|
||||
$item['url'] = $this->media->toPublic($item['url'] ?? '');
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'url as name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->url = $this->media->toPublic($info->url);
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某工厂的全部产品图(老接口 factory-image/image-list)
|
||||
*/
|
||||
public function imageList(int $factoryId): array
|
||||
{
|
||||
if ($factoryId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = FactoryImageModel::where('factory', $factoryId)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get(['id', 'factory', 'url', 'status'])
|
||||
->toArray();
|
||||
$this->media->publicEach($rows, ['url']);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增:一次可上传多张
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$factoryId = (int) ($params['factory'] ?? 0);
|
||||
if (!FactoryInfoModel::where('id', $factoryId)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('工厂不存在');
|
||||
}
|
||||
$urls = $params['url'] ?? '';
|
||||
$urls = is_array($urls) ? $urls : [$urls];
|
||||
|
||||
$now = time();
|
||||
$rows = [];
|
||||
foreach ($urls as $url) {
|
||||
$url = $this->media->toStorage(is_string($url) ? $url : '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'factory' => $factoryId,
|
||||
'url' => $url,
|
||||
'created_at' => $now,
|
||||
];
|
||||
}
|
||||
if (empty($rows)) {
|
||||
$this->utils->errorThrow('请上传图片');
|
||||
}
|
||||
if (count($rows) === 1) {
|
||||
return $this->insert($rows[0]);
|
||||
}
|
||||
return FactoryImageModel::insert($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('url', $params)) {
|
||||
$params['url'] = $this->media->firstOf($params['url']);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
103
app/Service/business/FactoryInfoService.php
Normal file
103
app/Service/business/FactoryInfoService.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\FactoryImageModel;
|
||||
use App\Models\business\FactoryInfoModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 工厂管理
|
||||
*/
|
||||
class FactoryInfoService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = FactoryInfoModel::class;
|
||||
$this->selectField = ['id', 'name', 'phone', 'classification', 'cover', 'address', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['name' => 'like', 'phone' => 'like', 'classification' => '=', 'status' => '='];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = ['classificationInfo'];
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['classification_name'] = $item['classification_info']['name'] ?? '';
|
||||
unset($item['classification_info']);
|
||||
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->cover = $this->media->toPublic($info->cover);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
if (array_key_exists('cover', $params)) {
|
||||
$params['cover'] = $this->media->firstOf($params['cover']);
|
||||
}
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('cover', $params)) {
|
||||
$params['cover'] = $this->media->firstOf($params['cover']);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除工厂时连带软删它的产品图,避免留下查不到工厂的图片
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||||
if (empty($ids)) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$now = time();
|
||||
FactoryImageModel::whereIn('factory', $ids)->where('deleted_at', 0)
|
||||
->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
}
|
||||
151
app/Service/business/ImageService.php
Normal file
151
app/Service/business/ImageService.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\ImageModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 商品相册(渲染图 / 实物图)
|
||||
*/
|
||||
class ImageService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = ImageModel::class;
|
||||
$this->selectField = ['id', 'catalogue_id', 'url', 'type', 'status', 'created_at', 'updated_at'];
|
||||
$this->queryField = ['catalogue_id' => '=', 'type' => '=', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$this->media->publicEach($result['items'], ['url']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'url as name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = $this->getDetail($id);
|
||||
$info->url = $this->media->toPublic($info->url);
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染图列表(老接口 image/get-render-graph,入参是 catalogue_id)
|
||||
*/
|
||||
public function renderGraph(int $catalogueId): array
|
||||
{
|
||||
return $this->listByCatalogue($catalogueId, ImageModel::TYPE_RENDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实物图列表(老接口 image/get-physical-drawing,入参是 catalogue_id)
|
||||
*/
|
||||
public function physicalDrawing(int $catalogueId): array
|
||||
{
|
||||
return $this->listByCatalogue($catalogueId, ImageModel::TYPE_PHYSICAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增:前端图片组件一次能选多张,url 传数组时逐张入库
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$catalogueId = (int) ($params['catalogue_id'] ?? 0);
|
||||
if (!CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('商品不存在');
|
||||
}
|
||||
$type = (int) ($params['type'] ?? ImageModel::TYPE_RENDER);
|
||||
$urls = $params['url'] ?? '';
|
||||
$urls = is_array($urls) ? $urls : [$urls];
|
||||
|
||||
$now = time();
|
||||
$rows = [];
|
||||
foreach ($urls as $url) {
|
||||
$url = $this->media->toStorage(is_string($url) ? $url : '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'catalogue_id' => $catalogueId,
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'created_at' => $now,
|
||||
];
|
||||
}
|
||||
if (empty($rows)) {
|
||||
$this->utils->errorThrow('请上传图片');
|
||||
}
|
||||
if (count($rows) === 1) {
|
||||
return $this->insert($rows[0]);
|
||||
}
|
||||
return ImageModel::insert($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
if (array_key_exists('url', $params)) {
|
||||
$params['url'] = $this->media->firstOf($params['url']);
|
||||
}
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
private function listByCatalogue(int $catalogueId, int $type): array
|
||||
{
|
||||
if ($catalogueId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = ImageModel::where('catalogue_id', $catalogueId)
|
||||
->where('type', $type)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get(['id', 'catalogue_id', 'url', 'type', 'status'])
|
||||
->toArray();
|
||||
$this->media->publicEach($rows, ['url']);
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
198
app/Service/business/ListService.php
Normal file
198
app/Service/business/ListService.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
|
||||
/**
|
||||
* 清单管理(后台)
|
||||
*
|
||||
* 后台看清单是为了帮客户报价与下单,所以详情里带的是「按该用户倍率算过的价格」,
|
||||
* 与用户在小程序里看到的一致,否则电话里报的价和客户手机上的价对不上。
|
||||
*/
|
||||
class ListService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = ListModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark',
|
||||
'status', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'list_no' => 'like',
|
||||
'name' => 'like',
|
||||
'user_id' => '=',
|
||||
'enterprise_id' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
$this->with = ['user', 'enterprise'];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$keyword = trim((string) request()->get('user_keyword', ''));
|
||||
if ($keyword !== '') {
|
||||
// 前端只给一个「客户」输入框,昵称与手机号都要能搜到
|
||||
$userIds = WxUserModel::where('deleted_at', 0)
|
||||
->where(function ($query) use ($keyword) {
|
||||
$query->where('nick_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('phone', 'like', '%' . $keyword . '%');
|
||||
})->pluck('id')->all();
|
||||
$this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds];
|
||||
}
|
||||
|
||||
$result = $this->getPageList();
|
||||
$listIds = array_column($result['items'], 'id');
|
||||
$counts = ListItemModel::whereIn('list_id', $listIds)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('list_id, count(*) as total, sum(quantity) as quantity')
|
||||
->groupBy('list_id')
|
||||
->get()
|
||||
->keyBy('list_id');
|
||||
$orderCounts = OrderModel::whereIn('list_id', $listIds)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('list_id, count(*) as total')
|
||||
->groupBy('list_id')
|
||||
->get()
|
||||
->keyBy('list_id');
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0);
|
||||
$item['quantity'] = (int) ($counts[$item['id']]['quantity'] ?? 0);
|
||||
$item['order_count'] = (int) ($orderCounts[$item['id']]['total'] ?? 0);
|
||||
$item['user_name'] = $item['user']['nick_name'] ?? '';
|
||||
$item['user_phone'] = $item['user']['phone'] ?? '';
|
||||
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情:带明细与算过倍率的价格
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = ListModel::with([
|
||||
'user',
|
||||
'enterprise',
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'items.catalogue',
|
||||
'items.priceSheet',
|
||||
])->where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($info)) {
|
||||
return $this->utils->notFound('清单不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
$price = PriceService::getInstance();
|
||||
$multiplier = $info['user']['price_number'] ?? 1;
|
||||
$total = 0;
|
||||
foreach ($info['items'] as &$item) {
|
||||
$routine = $item['price_sheet']['routine'] ?? '';
|
||||
$unit = (int) $item['unit_price'];
|
||||
if ($unit <= 0) {
|
||||
$unit = $price->resolveUnitPrice($routine, (string) $item['material_key'], $multiplier);
|
||||
}
|
||||
$item['unit_price'] = $unit;
|
||||
$item['unit_price_text'] = $price->centsToYuan($unit);
|
||||
$item['total_price'] = $unit * max(1, (int) $item['quantity']);
|
||||
$item['routine_list'] = $price->formatRoutine($routine, true, $multiplier);
|
||||
$total += $item['total_price'];
|
||||
}
|
||||
unset($item);
|
||||
$info['total_amount'] = $total;
|
||||
$info['total_amount_text'] = $price->centsToYuan($total);
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params['list_no'] = SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no');
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
unset($params['list_no'], $params['user_id']);
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个用户的全部清单,用户详情模态框里用
|
||||
*/
|
||||
public function byUser(int $userId): array
|
||||
{
|
||||
$rows = ListModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([
|
||||
'id', 'list_no', 'name', 'status', 'created_at',
|
||||
])->toArray();
|
||||
$counts = ListItemModel::whereIn('list_id', array_column($rows, 'id'))
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('list_id, count(*) as total')
|
||||
->groupBy('list_id')
|
||||
->get()
|
||||
->keyBy('list_id');
|
||||
foreach ($rows as &$row) {
|
||||
$row['item_count'] = (int) ($counts[$row['id']]['total'] ?? 0);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台代客下单
|
||||
*/
|
||||
public function toOrder(int $listId, array $params): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->createFromList($listId, 0, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改明细(后台帮客户补规格与数量)
|
||||
*/
|
||||
public function saveItem(array $params): mixed
|
||||
{
|
||||
$itemId = (int) ($params['id'] ?? 0);
|
||||
if ($itemId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$update = ['updated_at' => time()];
|
||||
foreach (['price_sheet_id', 'quantity', 'unit_price'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$update[$field] = (int) $params[$field];
|
||||
}
|
||||
}
|
||||
foreach (['material_key', 'remark'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$update[$field] = (string) $params[$field];
|
||||
}
|
||||
}
|
||||
return ListItemModel::where('id', $itemId)->update($update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删明细
|
||||
*/
|
||||
public function deleteItem(array|int $ids): mixed
|
||||
{
|
||||
return ListItemModel::whereIn('id', (array) $ids)->update([
|
||||
'deleted_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
383
app/Service/business/OrderCoreService.php
Normal file
383
app/Service/business/OrderCoreService.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\OrderDeliveryModel;
|
||||
use App\Models\business\OrderItemModel;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\OrderPaymentModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 订单核心逻辑(不含鉴权)
|
||||
*
|
||||
* 后台(OrderService,管理员身份)与小程序(WxOrderService,微信用户身份)都要用同一套
|
||||
* 建单、收款、发货规则。鉴权基类不同,所以规则收在这个中立类里,两边只做身份校验与参数整理。
|
||||
*
|
||||
* 三条纪律:金额一律整数分;状态只能通过 transition 迁移;支付确认必须行锁 + 幂等。
|
||||
*/
|
||||
class OrderCoreService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
/**
|
||||
* 允许的状态迁移,其余一律拒绝
|
||||
*/
|
||||
private const TRANSITIONS = [
|
||||
OrderModel::STATUS_UNPAID => [OrderModel::STATUS_PAID, OrderModel::STATUS_CANCELLED],
|
||||
OrderModel::STATUS_PAID => [OrderModel::STATUS_SHIPPED, OrderModel::STATUS_CANCELLED],
|
||||
OrderModel::STATUS_SHIPPED => [OrderModel::STATUS_DONE],
|
||||
OrderModel::STATUS_DONE => [],
|
||||
OrderModel::STATUS_CANCELLED => [],
|
||||
];
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 由清单生成订单
|
||||
*
|
||||
* @param int $listId 清单 ID
|
||||
* @param int $userId 下单用户(cc_wx_user.id)
|
||||
* @param array $params receiver_name/receiver_phone/receiver_address/delivery_type/remark
|
||||
* @return array 新订单详情
|
||||
*/
|
||||
public function createFromList(int $listId, int $userId, array $params = []): array
|
||||
{
|
||||
$list = ListModel::where('id', $listId)->where('deleted_at', 0)->first();
|
||||
if (empty($list)) {
|
||||
UtilsService::getInstance()->errorThrow('清单不存在');
|
||||
}
|
||||
if ($userId > 0 && (int) $list['user_id'] !== $userId) {
|
||||
UtilsService::getInstance()->errorThrow('不能对他人的清单下单');
|
||||
}
|
||||
$userId = (int) $list['user_id'];
|
||||
|
||||
$items = ListItemModel::with([
|
||||
'catalogue',
|
||||
'priceSheet',
|
||||
])->where('list_id', $listId)->where('deleted_at', 0)->get();
|
||||
if ($items->isEmpty()) {
|
||||
UtilsService::getInstance()->errorThrow('清单里还没有商品');
|
||||
}
|
||||
|
||||
$user = WxUserModel::where('id', $userId)->first();
|
||||
$multiplier = $user['price_number'] ?? 1;
|
||||
$price = PriceService::getInstance();
|
||||
|
||||
$rows = [];
|
||||
$missing = [];
|
||||
$total = 0;
|
||||
foreach ($items as $item) {
|
||||
$catalogue = $item->catalogue;
|
||||
if (empty($catalogue)) {
|
||||
continue;
|
||||
}
|
||||
$sheet = $item->priceSheet;
|
||||
if (empty($sheet)) {
|
||||
// 老清单没有 price_sheet_id,缺规格的行必须让用户回清单补选,不能瞎猜一个价格
|
||||
$missing[] = $catalogue['title'] ?? ('#' . $item['catalogue_id']);
|
||||
continue;
|
||||
}
|
||||
$quantity = max(1, (int) $item['quantity']);
|
||||
$unitPrice = (int) $item['unit_price'];
|
||||
if ($unitPrice <= 0) {
|
||||
$unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier);
|
||||
}
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$total += $lineTotal;
|
||||
$rows[] = [
|
||||
'catalogue_id' => (int) $item['catalogue_id'],
|
||||
'price_sheet_id' => (int) $item['price_sheet_id'],
|
||||
'title' => (string) ($catalogue['title'] ?? ''),
|
||||
'cover' => (string) ($catalogue['cover'] ?? ''),
|
||||
'alias' => (string) ($catalogue['alias'] ?? ''),
|
||||
'specification' => (string) ($sheet['specification'] ?? ''),
|
||||
'dimension' => (string) ($sheet['dimension'] ?? ''),
|
||||
'material_key' => (string) ($item['material_key'] ?? 'routine'),
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $lineTotal,
|
||||
'remark' => (string) ($item['remark'] ?? ''),
|
||||
'created_at' => time(),
|
||||
];
|
||||
}
|
||||
if (!empty($missing)) {
|
||||
UtilsService::getInstance()->errorThrow('以下商品还没有选规格:' . implode('、', array_slice($missing, 0, 5)));
|
||||
}
|
||||
if (empty($rows)) {
|
||||
UtilsService::getInstance()->errorThrow('清单里没有可下单的商品');
|
||||
}
|
||||
|
||||
$orderId = 0;
|
||||
DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) {
|
||||
$now = time();
|
||||
$orderId = OrderModel::insertGetId([
|
||||
'order_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_ORDER, 'order', 'order_no'),
|
||||
'list_id' => (int) $list['id'],
|
||||
'user_id' => $userId,
|
||||
'enterprise_id' => (int) ($list['enterprise_id'] ?: ($user['enterprise_id'] ?? 0)),
|
||||
'total_amount' => $total,
|
||||
'delivery_type' => (int) ($params['delivery_type'] ?? 0),
|
||||
'receiver_name' => (string) ($params['receiver_name'] ?? ($user['nick_name'] ?? '')),
|
||||
'receiver_phone' => (string) ($params['receiver_phone'] ?? ($user['phone'] ?? '')),
|
||||
'receiver_address' => (string) ($params['receiver_address'] ?? ''),
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'status' => OrderModel::STATUS_UNPAID,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
foreach ($rows as &$row) {
|
||||
$row['order_id'] = $orderId;
|
||||
}
|
||||
unset($row);
|
||||
OrderItemModel::insert($rows);
|
||||
ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]);
|
||||
});
|
||||
|
||||
return $this->detail($orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单详情(含明细、支付记录、发货记录)
|
||||
*/
|
||||
public function detail(int $orderId): array
|
||||
{
|
||||
$order = OrderModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'payments' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'),
|
||||
'deliveries' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'),
|
||||
'user',
|
||||
'enterprise',
|
||||
])->where('id', $orderId)->where('deleted_at', 0)->first();
|
||||
if (empty($order)) {
|
||||
UtilsService::getInstance()->errorThrow('订单不存在');
|
||||
}
|
||||
$order = $order->toArray();
|
||||
$order['voucher_list'] = [];
|
||||
foreach ($order['payments'] ?? [] as $payment) {
|
||||
foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) {
|
||||
$order['voucher_list'][] = $image;
|
||||
}
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交转账凭证,进入待审核
|
||||
*/
|
||||
public function submitVoucher(int $orderId, array $params, int $userId = 0): array
|
||||
{
|
||||
$order = $this->lockOrder($orderId, $userId);
|
||||
if ((int) $order['pay_status'] === OrderModel::PAY_STATUS_PAID) {
|
||||
UtilsService::getInstance()->errorThrow('订单已付款');
|
||||
}
|
||||
$voucher = $params['voucher'] ?? '';
|
||||
$voucher = is_array($voucher) ? implode(',', array_filter($voucher)) : (string) $voucher;
|
||||
if ($voucher === '') {
|
||||
UtilsService::getInstance()->errorThrow('请上传转账凭证');
|
||||
}
|
||||
$amount = (int) ($params['amount'] ?? 0);
|
||||
if ($amount <= 0) {
|
||||
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
|
||||
}
|
||||
$now = time();
|
||||
DB::connection('business')->transaction(function () use ($orderId, $voucher, $amount, $now) {
|
||||
OrderPaymentModel::insert([
|
||||
'order_id' => $orderId,
|
||||
'pay_type' => OrderModel::PAY_TYPE_VOUCHER,
|
||||
'amount' => $amount,
|
||||
'voucher' => $voucher,
|
||||
// 转账没有微信单号,用订单 + 时间占位,仍受唯一索引约束防重复提交
|
||||
'out_trade_no' => 'TR' . $orderId . '_' . $now,
|
||||
'status' => OrderPaymentModel::STATUS_AUDITING,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'pay_type' => OrderModel::PAY_TYPE_VOUCHER,
|
||||
'pay_status' => OrderModel::PAY_STATUS_AUDITING,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
return $this->detail($orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核转账凭证
|
||||
*
|
||||
* @param int $status OrderPaymentModel::STATUS_CONFIRMED|STATUS_REJECTED
|
||||
*/
|
||||
public function auditPayment(int $paymentId, int $status, int $adminId, string $remark = ''): array
|
||||
{
|
||||
$orderId = 0;
|
||||
DB::connection('business')->transaction(function () use ($paymentId, $status, $adminId, $remark, &$orderId) {
|
||||
$payment = OrderPaymentModel::where('id', $paymentId)->lockForUpdate()->first();
|
||||
if (empty($payment)) {
|
||||
UtilsService::getInstance()->errorThrow('支付记录不存在');
|
||||
}
|
||||
if ((int) $payment['status'] !== OrderPaymentModel::STATUS_AUDITING) {
|
||||
UtilsService::getInstance()->errorThrow('该支付记录已处理');
|
||||
}
|
||||
$orderId = (int) $payment['order_id'];
|
||||
$now = time();
|
||||
OrderPaymentModel::where('id', $paymentId)->update([
|
||||
'status' => $status,
|
||||
'auditor_id' => $adminId,
|
||||
'audited_at' => $now,
|
||||
'audit_remark' => $remark,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ($status !== OrderPaymentModel::STATUS_CONFIRMED) {
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'pay_status' => OrderModel::PAY_STATUS_REJECTED,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$this->applyPaid($orderId, (int) $payment['amount'], $now);
|
||||
});
|
||||
return $this->detail($orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一笔收款并推进订单状态
|
||||
*
|
||||
* 收款可能分多笔,只有累计金额够了才算付清,否则停在部分收款。
|
||||
*/
|
||||
public function applyPaid(int $orderId, int $amount, int $now = 0): void
|
||||
{
|
||||
$now = $now ?: time();
|
||||
$order = OrderModel::where('id', $orderId)->lockForUpdate()->first();
|
||||
if (empty($order)) {
|
||||
UtilsService::getInstance()->errorThrow('订单不存在');
|
||||
}
|
||||
$paid = (int) $order['paid_amount'] + $amount;
|
||||
$update = [
|
||||
'paid_amount' => $paid,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($paid >= (int) $order['total_amount']) {
|
||||
$update['pay_status'] = OrderModel::PAY_STATUS_PAID;
|
||||
$update['paid_at'] = $now;
|
||||
if ($this->canTransition((int) $order['status'], OrderModel::STATUS_PAID)) {
|
||||
$update['status'] = OrderModel::STATUS_PAID;
|
||||
}
|
||||
}
|
||||
OrderModel::where('id', $orderId)->update($update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信支付回调落账(幂等)
|
||||
*
|
||||
* 微信会重复推送同一笔,靠 out_trade_no 唯一索引 + 状态判断挡住重复入账。
|
||||
*/
|
||||
public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool
|
||||
{
|
||||
$done = false;
|
||||
DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done) {
|
||||
$payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first();
|
||||
if (empty($payment)) {
|
||||
return;
|
||||
}
|
||||
if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) {
|
||||
$done = true;
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
OrderPaymentModel::where('id', $payment['id'])->update([
|
||||
'status' => OrderPaymentModel::STATUS_CONFIRMED,
|
||||
'transaction_id' => $transactionId,
|
||||
'amount' => $amount > 0 ? $amount : (int) $payment['amount'],
|
||||
'audited_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->applyPaid((int) $payment['order_id'], $amount > 0 ? $amount : (int) $payment['amount'], $now);
|
||||
$done = true;
|
||||
});
|
||||
return $done;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发货:物流 / 自提 / 公司配送
|
||||
*/
|
||||
public function ship(int $orderId, array $params, int $adminId): array
|
||||
{
|
||||
$order = $this->lockOrder($orderId);
|
||||
$type = (int) ($params['delivery_type'] ?? OrderModel::DELIVERY_EXPRESS);
|
||||
if ($type === OrderModel::DELIVERY_EXPRESS && trim((string) ($params['tracking_no'] ?? '')) === '') {
|
||||
UtilsService::getInstance()->errorThrow('请填写运单号');
|
||||
}
|
||||
if ($type === OrderModel::DELIVERY_PICKUP && trim((string) ($params['pickup_point'] ?? '')) === '') {
|
||||
UtilsService::getInstance()->errorThrow('请填写自提点');
|
||||
}
|
||||
if (!$this->canTransition((int) $order['status'], OrderModel::STATUS_SHIPPED)) {
|
||||
UtilsService::getInstance()->errorThrow('当前订单状态不允许发货');
|
||||
}
|
||||
$now = time();
|
||||
DB::connection('business')->transaction(function () use ($orderId, $params, $type, $adminId, $now) {
|
||||
OrderDeliveryModel::insert([
|
||||
'order_id' => $orderId,
|
||||
'delivery_type' => $type,
|
||||
'company' => (string) ($params['company'] ?? ''),
|
||||
'tracking_no' => (string) ($params['tracking_no'] ?? ''),
|
||||
'pickup_point' => (string) ($params['pickup_point'] ?? ''),
|
||||
'driver_info' => (string) ($params['driver_info'] ?? ''),
|
||||
'shipped_at' => $now,
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'operator_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'delivery_type' => $type,
|
||||
'status' => OrderModel::STATUS_SHIPPED,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
return $this->detail($orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态迁移(取消、完成等)
|
||||
*/
|
||||
public function transition(int $orderId, int $target, int $userId = 0): array
|
||||
{
|
||||
$order = $this->lockOrder($orderId, $userId);
|
||||
if (!$this->canTransition((int) $order['status'], $target)) {
|
||||
UtilsService::getInstance()->errorThrow('当前状态不允许该操作');
|
||||
}
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'status' => $target,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return $this->detail($orderId);
|
||||
}
|
||||
|
||||
public function canTransition(int $from, int $to): bool
|
||||
{
|
||||
return in_array($to, self::TRANSITIONS[$from] ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取订单并做归属校验($userId > 0 时限定本人)
|
||||
*/
|
||||
private function lockOrder(int $orderId, int $userId = 0): array
|
||||
{
|
||||
$order = OrderModel::where('id', $orderId)->where('deleted_at', 0)->first();
|
||||
if (empty($order)) {
|
||||
UtilsService::getInstance()->errorThrow('订单不存在');
|
||||
}
|
||||
if ($userId > 0 && (int) $order['user_id'] !== $userId) {
|
||||
UtilsService::getInstance()->errorThrow('无权操作该订单');
|
||||
}
|
||||
return $order->toArray();
|
||||
}
|
||||
}
|
||||
193
app/Service/business/OrderService.php
Normal file
193
app/Service/business/OrderService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\OrderPaymentModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
|
||||
/**
|
||||
* 订单管理(后台)
|
||||
*
|
||||
* 建单、收款、发货的规则都在 OrderCoreService,这里只做列表查询与管理员身份的透传。
|
||||
*/
|
||||
class OrderService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = OrderModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'order_no', 'list_id', 'user_id', 'enterprise_id', 'total_amount', 'paid_amount',
|
||||
'pay_type', 'pay_status', 'paid_at', 'delivery_type', 'receiver_name', 'receiver_phone',
|
||||
'receiver_address', 'status', 'remark', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'order_no' => 'like',
|
||||
'user_id' => '=',
|
||||
'enterprise_id' => '=',
|
||||
'status' => '=',
|
||||
'pay_status' => '=',
|
||||
'pay_type' => '=',
|
||||
'delivery_type' => '=',
|
||||
'receiver_phone' => 'like',
|
||||
];
|
||||
$this->with = ['user', 'enterprise'];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$keyword = trim((string) request()->get('user_keyword', ''));
|
||||
if ($keyword !== '') {
|
||||
$userIds = WxUserModel::where('deleted_at', 0)
|
||||
->where(function ($query) use ($keyword) {
|
||||
$query->where('nick_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('phone', 'like', '%' . $keyword . '%');
|
||||
})->pluck('id')->all();
|
||||
$this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds];
|
||||
}
|
||||
$result = $this->getPageList();
|
||||
$price = PriceService::getInstance();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['user_name'] = $item['user']['nick_name'] ?? '';
|
||||
$item['user_phone'] = $item['user']['phone'] ?? '';
|
||||
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
|
||||
$item['total_amount_text'] = $price->centsToYuan((int) $item['total_amount']);
|
||||
$item['paid_amount_text'] = $price->centsToYuan((int) $item['paid_amount']);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'order_no'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$order = OrderCoreService::getInstance()->detail((int) $id);
|
||||
$price = PriceService::getInstance();
|
||||
$order['total_amount_text'] = $price->centsToYuan((int) $order['total_amount']);
|
||||
$order['paid_amount_text'] = $price->centsToYuan((int) $order['paid_amount']);
|
||||
foreach ($order['items'] as &$item) {
|
||||
$item['unit_price_text'] = $price->centsToYuan((int) $item['unit_price']);
|
||||
$item['total_price_text'] = $price->centsToYuan((int) $item['total_price']);
|
||||
}
|
||||
unset($item);
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台代客建单
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
if ($listId <= 0) {
|
||||
$this->utils->errorThrow('请选择清单');
|
||||
}
|
||||
return OrderCoreService::getInstance()->createFromList($listId, 0, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只允许改收件信息与备注:金额与状态必须走各自的业务入口
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$allowed = array_intersect_key($params, array_flip([
|
||||
'receiver_name', 'receiver_phone', 'receiver_address', 'remark', 'delivery_type',
|
||||
]));
|
||||
if (empty($allowed)) {
|
||||
$this->utils->errorThrow('没有可修改的字段');
|
||||
}
|
||||
return $this->save($id, $allowed);
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核转账凭证
|
||||
*/
|
||||
public function auditPayment(array $params): array
|
||||
{
|
||||
$paymentId = (int) ($params['payment_id'] ?? 0);
|
||||
$pass = (int) ($params['status'] ?? 1) === 1;
|
||||
return OrderCoreService::getInstance()->auditPayment(
|
||||
$paymentId,
|
||||
$pass ? OrderPaymentModel::STATUS_CONFIRMED : OrderPaymentModel::STATUS_REJECTED,
|
||||
$this->userId,
|
||||
(string) ($params['remark'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发货
|
||||
*/
|
||||
public function ship(array $params): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->ship(
|
||||
(int) ($params['id'] ?? 0),
|
||||
$params,
|
||||
$this->userId
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
public function complete(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个用户的订单,用户详情模态框里用
|
||||
*/
|
||||
public function byUser(int $userId): array
|
||||
{
|
||||
$price = PriceService::getInstance();
|
||||
$rows = OrderModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([
|
||||
'id', 'order_no', 'total_amount', 'paid_amount', 'pay_status', 'status', 'created_at',
|
||||
])->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['total_amount_text'] = $price->centsToYuan((int) $row['total_amount']);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览:各状态数量与金额,给列表页顶部的统计条
|
||||
*/
|
||||
public function stat(): array
|
||||
{
|
||||
$price = PriceService::getInstance();
|
||||
$rows = OrderModel::where('deleted_at', 0)
|
||||
->selectRaw('status, count(*) as total, sum(total_amount) as amount')
|
||||
->groupBy('status')
|
||||
->get();
|
||||
$stat = ['total' => 0, 'amount' => 0, 'status' => []];
|
||||
foreach ($rows as $row) {
|
||||
$stat['total'] += (int) $row['total'];
|
||||
$stat['amount'] += (int) $row['amount'];
|
||||
$stat['status'][] = [
|
||||
'status' => (int) $row['status'],
|
||||
'count' => (int) $row['total'],
|
||||
'amount' => (int) $row['amount'],
|
||||
];
|
||||
}
|
||||
$stat['amount_text'] = $price->centsToYuan((int) $stat['amount']);
|
||||
$stat['auditing'] = OrderPaymentModel::where('deleted_at', 0)
|
||||
->where('status', OrderPaymentModel::STATUS_AUDITING)
|
||||
->count();
|
||||
return $stat;
|
||||
}
|
||||
}
|
||||
152
app/Service/business/PriceService.php
Normal file
152
app/Service/business/PriceService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
/**
|
||||
* 价格可见性与倍率
|
||||
*
|
||||
* 报价单的 routine 列是一串用 @ 分隔的价格,每段要么是纯数字,要么是「名称:价格」,
|
||||
* 乘倍率时只能乘价格段,把整段当数字乘会把名称吃掉。
|
||||
* show_price 为假时整个价格数组换成 ['****'],并回 is_show_price=false 给前端。
|
||||
*
|
||||
* 这段逻辑原先只存在于 lgp-wx-api 的 ProductService,后台完全没有,
|
||||
* 于是后台看到的是原价、小程序看到的是倍率价,对账时无从复现。收在这里两边共用。
|
||||
*/
|
||||
class PriceService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public const MASK = '****';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆分 routine 列
|
||||
*/
|
||||
public function split(?string $routine): array
|
||||
{
|
||||
$routine = trim((string) $routine);
|
||||
if ($routine === '') {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(array_map('trim', explode('@', $routine)), fn ($v) => $v !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户可见性与倍率格式化 routine
|
||||
*
|
||||
* @param bool $showPrice 是否可见价格
|
||||
* @param int|float|string $multiplier 价格倍率
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function formatRoutine(?string $routine, bool $showPrice, mixed $multiplier = 1): array
|
||||
{
|
||||
if (!$showPrice) {
|
||||
return [self::MASK];
|
||||
}
|
||||
$items = $this->split($routine);
|
||||
foreach ($items as &$item) {
|
||||
$item = $this->formatPrice($item, $multiplier);
|
||||
}
|
||||
unset($item);
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单段价格乘倍率。「名称:价格」只乘价格部分,兼容半角冒号与空格
|
||||
*/
|
||||
public function formatPrice(string $value, mixed $multiplier = 1): string
|
||||
{
|
||||
$multiplier = $this->normalizeMultiplier($multiplier);
|
||||
try {
|
||||
if (preg_match('/^[0-9.]+$/', $value)) {
|
||||
return bcmul($value, $multiplier, 0);
|
||||
}
|
||||
$normalized = str_replace([':', ' '], [':', ''], $value);
|
||||
$parts = explode(':', $normalized);
|
||||
if (array_key_exists(1, $parts) && preg_match('/^[0-9.]+$/', $parts[1])) {
|
||||
$parts[1] = bcmul($parts[1], $multiplier, 0);
|
||||
}
|
||||
return implode(':', $parts);
|
||||
} catch (\Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一组报价单行套上价格规则,返回值里 routine 变成数组
|
||||
*
|
||||
* @param array $rows price_sheet 行
|
||||
* @return array{rows: array, is_show_price: bool}
|
||||
*/
|
||||
public function applyToRows(array $rows, bool $showPrice, mixed $multiplier = 1): array
|
||||
{
|
||||
foreach ($rows as &$row) {
|
||||
$row['routine'] = $this->formatRoutine($row['routine'] ?? '', $showPrice, $multiplier);
|
||||
}
|
||||
unset($row);
|
||||
return ['rows' => $rows, 'is_show_price' => $showPrice];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个材质的单价,返回「分」
|
||||
*
|
||||
* 下单要的是一个确定的数字,而 routine 是给人看的字符串(可能是 "1200",
|
||||
* 也可能是 "布艺:1200@皮艺:1800")。这里按 materialKey 找对应段,
|
||||
* 找不到就退回第一个能解析出数字的段;一个都没有返回 0,由调用方决定报错还是放过。
|
||||
*
|
||||
* 金额一律整数分:老库价格是整数元,乘完倍率再 ×100,不引入浮点。
|
||||
*/
|
||||
public function resolveUnitPrice(?string $routine, string $materialKey = '', mixed $multiplier = 1): int
|
||||
{
|
||||
$items = $this->split($routine);
|
||||
if (empty($items)) {
|
||||
return 0;
|
||||
}
|
||||
$materialKey = trim($materialKey);
|
||||
$fallback = 0;
|
||||
foreach ($items as $item) {
|
||||
$normalized = str_replace([':', ' '], [':', ''], $item);
|
||||
$parts = explode(':', $normalized);
|
||||
$name = count($parts) > 1 ? $parts[0] : '';
|
||||
$value = count($parts) > 1 ? $parts[1] : $parts[0];
|
||||
if (!preg_match('/^[0-9.]+$/', $value)) {
|
||||
continue;
|
||||
}
|
||||
$yuan = (int) bcmul($value, $this->normalizeMultiplier($multiplier), 0);
|
||||
if ($materialKey !== '' && $materialKey !== 'routine' && $name === $materialKey) {
|
||||
return $yuan * 100;
|
||||
}
|
||||
if ($fallback === 0) {
|
||||
$fallback = $yuan * 100;
|
||||
}
|
||||
}
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分转元字符串,仅用于展示与导出
|
||||
*/
|
||||
public function centsToYuan(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 倍率兜底:库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零
|
||||
*/
|
||||
private function normalizeMultiplier(mixed $multiplier): string
|
||||
{
|
||||
if (!is_numeric($multiplier) || (float) $multiplier <= 0) {
|
||||
return '1';
|
||||
}
|
||||
return (string) $multiplier;
|
||||
}
|
||||
}
|
||||
214
app/Service/business/PriceSheetService.php
Normal file
214
app/Service/business/PriceSheetService.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\PriceSheetModel;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 报价单(商品规格 + 常规价)
|
||||
*
|
||||
* 老接口靠 specification-1a / dimension-1b / routine-1b 这种动态键接收多行表单,
|
||||
* 数量还得靠 count($params) 猜,多一个无关字段就会多循环一轮。
|
||||
* 新接口收 rows 数组,并提供 saveRows 一次性覆盖某商品的全部规格行——抽屉里就是这个语义。
|
||||
*/
|
||||
class PriceSheetService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = PriceSheetModel::class;
|
||||
$this->selectField = array_merge(
|
||||
['id', 'catalogue_id', 'specification', 'dimension'],
|
||||
PriceSheetModel::MATERIAL_FIELDS,
|
||||
['status', 'created_at', 'updated_at']
|
||||
);
|
||||
$this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
return $this->getPageList();
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'specification as name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
return $this->getDetail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增:单行或多行(rows)
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
$catalogueId = (int) ($params['catalogue_id'] ?? 0);
|
||||
$this->assertCatalogue($catalogueId);
|
||||
|
||||
$rows = $this->normalizeRows($catalogueId, $params);
|
||||
if (empty($rows)) {
|
||||
$this->utils->errorThrow('请至少填写一行规格');
|
||||
}
|
||||
if (count($rows) === 1) {
|
||||
return $this->insert($rows[0]);
|
||||
}
|
||||
return PriceSheetModel::insert($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
unset($params['rows']);
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖式保存某商品的全部规格行:带 id 的更新,不带的新增,界面上被删掉的软删。
|
||||
* 一次事务完成,避免中途失败留下半套规格。
|
||||
* @throws Exception
|
||||
*/
|
||||
public function saveRows(int $catalogueId, array $rows): bool
|
||||
{
|
||||
$this->assertCatalogue($catalogueId);
|
||||
|
||||
$now = time();
|
||||
$keepIds = [];
|
||||
DB::connection('business')->beginTransaction();
|
||||
try {
|
||||
foreach ($rows as $row) {
|
||||
$payload = $this->pickRowFields($row);
|
||||
if (trim((string) ($payload['specification'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$payload['catalogue_id'] = $catalogueId;
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$payload['updated_at'] = $now;
|
||||
PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->update($payload);
|
||||
$keepIds[] = $id;
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$keepIds[] = (int) PriceSheetModel::insertGetId($payload);
|
||||
}
|
||||
}
|
||||
|
||||
PriceSheetModel::where('catalogue_id', $catalogueId)
|
||||
->where('deleted_at', 0)
|
||||
->when(!empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
|
||||
->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
|
||||
DB::connection('business')->commit();
|
||||
} catch (Exception $e) {
|
||||
DB::connection('business')->rollBack();
|
||||
$this->utils->errorThrow($e->getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某商品的全部规格行,供报价单抽屉回填
|
||||
*/
|
||||
public function rowsOf(int $catalogueId): array
|
||||
{
|
||||
return PriceSheetModel::where('catalogue_id', $catalogueId)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id')
|
||||
->get($this->selectField)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容单行字段与 rows 数组两种入参
|
||||
*/
|
||||
private function normalizeRows(int $catalogueId, array $params): array
|
||||
{
|
||||
$now = time();
|
||||
$source = [];
|
||||
if (!empty($params['rows']) && is_array($params['rows'])) {
|
||||
$source = $params['rows'];
|
||||
} elseif (trim((string) ($params['specification'] ?? '')) !== '') {
|
||||
$source = [$params];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($source as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$payload = $this->pickRowFields($row);
|
||||
if (trim((string) ($payload['specification'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$payload['catalogue_id'] = $catalogueId;
|
||||
$payload['created_at'] = $now;
|
||||
$rows[] = $payload;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只取表里真实存在的列,把前端多传的字段挡在外面
|
||||
*/
|
||||
private function pickRowFields(array $row): array
|
||||
{
|
||||
$allowed = array_merge(['specification', 'dimension'], PriceSheetModel::MATERIAL_FIELDS);
|
||||
$payload = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (array_key_exists($field, $row)) {
|
||||
$payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : '';
|
||||
}
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertCatalogue(int $catalogueId): void
|
||||
{
|
||||
if ($catalogueId <= 0) {
|
||||
$this->utils->errorThrow('请选择商品');
|
||||
}
|
||||
$exists = CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists();
|
||||
if (!$exists) {
|
||||
$this->utils->errorThrow('商品不存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Service/business/SerialNoService.php
Normal file
61
app/Service/business/SerialNoService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 业务单号生成(清单 QD_ / 订单 DD_)
|
||||
*
|
||||
* 随机串剔除 0/O/1/I/L 这些肉眼分不清的字符——单号会被打印在纸质报价单上人工抄录。
|
||||
* 唯一索引兜底,撞号就重试;重试仍失败宁可报错,也不要为了成功而降级成可能重复的号。
|
||||
*/
|
||||
class SerialNoService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private const ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
||||
|
||||
public const PREFIX_LIST = 'QD_';
|
||||
public const PREFIX_ORDER = 'DD_';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个库内不存在的单号
|
||||
*
|
||||
* @param string $prefix self::PREFIX_*
|
||||
* @param string $table business 连接下的表名(不带前缀)
|
||||
* @param string $column 单号字段
|
||||
*/
|
||||
public function generate(string $prefix, string $table, string $column, int $tries = 10): string
|
||||
{
|
||||
$day = date('Ymd');
|
||||
for ($i = 0; $i < $tries; $i++) {
|
||||
$no = $prefix . $day . $this->randomPart(6);
|
||||
$exists = DB::connection('business')->table($table)->where($column, $no)->exists();
|
||||
if (!$exists) {
|
||||
return $no;
|
||||
}
|
||||
}
|
||||
// 连续撞 10 次说明随机源或并发量出了问题,静默返回可能重复的号比抛错危险得多
|
||||
throw new \RuntimeException('单号生成失败,请重试');
|
||||
}
|
||||
|
||||
private function randomPart(int $length): string
|
||||
{
|
||||
$max = strlen(self::ALPHABET) - 1;
|
||||
$out = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$out .= self::ALPHABET[random_int(0, $max)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
199
app/Service/business/WxTemplatePresetService.php
Normal file
199
app/Service/business/WxTemplatePresetService.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
/**
|
||||
* 把预设的紧凑规格展开成完整设计令牌
|
||||
*
|
||||
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
|
||||
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 份 JSON 手抄跑偏,
|
||||
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
|
||||
*/
|
||||
class WxTemplatePresetService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
/**
|
||||
* 字体与字号梯度档位(单位 rpx,小程序习惯用 rpx)
|
||||
*/
|
||||
private const SCALES = [
|
||||
'serif-elegant' => [
|
||||
'family' => 'Songti SC, Noto Serif SC, serif',
|
||||
'family_title' => 'Songti SC, Noto Serif SC, serif',
|
||||
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
|
||||
'size_lg' => '34rpx', 'size_xl' => '42rpx', 'size_title' => '52rpx',
|
||||
'weight_normal' => '400', 'weight_bold' => '600',
|
||||
'line_height' => '1.7', 'letter_spacing' => '1rpx',
|
||||
],
|
||||
'serif-book' => [
|
||||
'family' => 'Noto Serif SC, Georgia, serif',
|
||||
'family_title' => 'Noto Serif SC, Georgia, serif',
|
||||
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
|
||||
'size_lg' => '34rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
|
||||
'weight_normal' => '400', 'weight_bold' => '700',
|
||||
'line_height' => '1.8', 'letter_spacing' => '0',
|
||||
],
|
||||
'sans-refined' => [
|
||||
'family' => 'PingFang SC, HarmonyOS Sans, sans-serif',
|
||||
'family_title' => 'PingFang SC, HarmonyOS Sans, sans-serif',
|
||||
'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx',
|
||||
'size_lg' => '32rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx',
|
||||
'weight_normal' => '400', 'weight_bold' => '600',
|
||||
'line_height' => '1.6', 'letter_spacing' => '0',
|
||||
],
|
||||
'sans-compact' => [
|
||||
'family' => 'PingFang SC, Roboto, sans-serif',
|
||||
'family_title' => 'PingFang SC, Roboto, sans-serif',
|
||||
'size_xs' => '18rpx', 'size_sm' => '22rpx', 'size_md' => '26rpx',
|
||||
'size_lg' => '30rpx', 'size_xl' => '36rpx', 'size_title' => '42rpx',
|
||||
'weight_normal' => '400', 'weight_bold' => '700',
|
||||
'line_height' => '1.5', 'letter_spacing' => '0',
|
||||
],
|
||||
'sans-wide' => [
|
||||
'family' => 'PingFang SC, Inter, sans-serif',
|
||||
'family_title' => 'PingFang SC, Inter, sans-serif',
|
||||
'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx',
|
||||
'size_lg' => '36rpx', 'size_xl' => '44rpx', 'size_title' => '56rpx',
|
||||
'weight_normal' => '400', 'weight_bold' => '700',
|
||||
'line_height' => '1.6', 'letter_spacing' => '2rpx',
|
||||
],
|
||||
];
|
||||
|
||||
private const RADIUS = [
|
||||
'none' => ['none' => '0', 'sm' => '0', 'md' => '0', 'lg' => '0', 'xl' => '0', 'pill' => '0'],
|
||||
'sharp' => ['none' => '0', 'sm' => '2rpx', 'md' => '4rpx', 'lg' => '8rpx', 'xl' => '12rpx', 'pill' => '999rpx'],
|
||||
'soft' => ['none' => '0', 'sm' => '8rpx', 'md' => '12rpx', 'lg' => '20rpx', 'xl' => '28rpx', 'pill' => '999rpx'],
|
||||
'round' => ['none' => '0', 'sm' => '12rpx', 'md' => '20rpx', 'lg' => '32rpx', 'xl' => '44rpx', 'pill' => '999rpx'],
|
||||
'pill' => ['none' => '0', 'sm' => '20rpx', 'md' => '32rpx', 'lg' => '48rpx', 'xl' => '64rpx', 'pill' => '999rpx'],
|
||||
];
|
||||
|
||||
private const SHADOW = [
|
||||
'flat' => ['none' => 'none', 'sm' => 'none', 'md' => 'none', 'lg' => 'none'],
|
||||
'airy' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.04)', 'md' => '0 8rpx 24rpx rgba(0,0,0,0.06)', 'lg' => '0 16rpx 48rpx rgba(0,0,0,0.08)'],
|
||||
'soft' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.06)', 'md' => '0 8rpx 20rpx rgba(0,0,0,0.10)', 'lg' => '0 16rpx 40rpx rgba(0,0,0,0.14)'],
|
||||
'deep' => ['none' => 'none', 'sm' => '0 4rpx 12rpx rgba(0,0,0,0.16)', 'md' => '0 12rpx 32rpx rgba(0,0,0,0.24)', 'lg' => '0 24rpx 64rpx rgba(0,0,0,0.32)'],
|
||||
];
|
||||
|
||||
private const MOTION = [
|
||||
'snappy' => ['fast' => '120ms', 'base' => '180ms', 'slow' => '260ms', 'easing' => 'cubic-bezier(0.4,0,0.2,1)', 'easing_in' => 'cubic-bezier(0.4,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.2,1)'],
|
||||
'gentle' => ['fast' => '180ms', 'base' => '260ms', 'slow' => '400ms', 'easing' => 'cubic-bezier(0.25,0.1,0.25,1)', 'easing_in' => 'cubic-bezier(0.42,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.58,1)'],
|
||||
'silk' => ['fast' => '220ms', 'base' => '320ms', 'slow' => '520ms', 'easing' => 'cubic-bezier(0.22,1,0.36,1)', 'easing_in' => 'cubic-bezier(0.55,0,1,0.45)', 'easing_out' => 'cubic-bezier(0.16,1,0.3,1)'],
|
||||
'bouncy' => ['fast' => '160ms', 'base' => '280ms', 'slow' => '460ms', 'easing' => 'cubic-bezier(0.34,1.56,0.64,1)', 'easing_in' => 'cubic-bezier(0.36,0,0.66,-0.56)', 'easing_out' => 'cubic-bezier(0.34,1.56,0.64,1)'],
|
||||
];
|
||||
|
||||
private const SPACE = [
|
||||
'xxs' => '4rpx', 'xs' => '8rpx', 'sm' => '16rpx',
|
||||
'md' => '24rpx', 'lg' => '32rpx', 'xl' => '48rpx', 'page' => '32rpx',
|
||||
];
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部预设,已展开成入库可用的行
|
||||
*
|
||||
* @return array<int, array{code:string,name:string,style_tag:string,tokens:array,layout:array}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ((array) config('wx_templates', []) as $index => $preset) {
|
||||
$rows[] = [
|
||||
'code' => (string) $preset['code'],
|
||||
'name' => (string) $preset['name'],
|
||||
'style_tag' => (string) ($preset['style_tag'] ?? ''),
|
||||
'sort' => $index,
|
||||
'tokens' => $this->expandTokens($preset),
|
||||
'layout' => (array) ($preset['layout'] ?? []),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开单个预设的令牌
|
||||
*/
|
||||
public function expandTokens(array $preset): array
|
||||
{
|
||||
$palette = (array) ($preset['palette'] ?? []);
|
||||
$primary = (string) ($palette['primary'] ?? '#B08D57');
|
||||
$isDark = $this->isDark((string) ($palette['bg'] ?? '#FFFFFF'));
|
||||
return [
|
||||
'color' => [
|
||||
'primary' => $primary,
|
||||
'primary_soft' => $this->mix($primary, $isDark ? '#000000' : '#FFFFFF', 0.7),
|
||||
'primary_strong' => $this->mix($primary, '#000000', 0.2),
|
||||
'accent' => (string) ($palette['accent'] ?? $primary),
|
||||
'bg' => (string) ($palette['bg'] ?? '#FFFFFF'),
|
||||
'bg_soft' => $this->mix((string) ($palette['bg'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.04),
|
||||
'surface' => (string) ($palette['surface'] ?? '#FFFFFF'),
|
||||
'surface_soft' => $this->mix((string) ($palette['surface'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.03),
|
||||
'text' => (string) ($palette['text'] ?? '#18181B'),
|
||||
'text_soft' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.25),
|
||||
'text_muted' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.5),
|
||||
'border' => (string) ($palette['border'] ?? '#E4E4E7'),
|
||||
// 价格用主色的强化版,保证在浅色与深色底上都够醒目
|
||||
'price' => $this->mix($primary, '#000000', $isDark ? 0 : 0.12),
|
||||
'success' => '#16A34A',
|
||||
'warning' => '#D97706',
|
||||
'danger' => '#DC2626',
|
||||
'mask' => $isDark ? 'rgba(0,0,0,0.72)' : 'rgba(0,0,0,0.45)',
|
||||
],
|
||||
'font' => self::SCALES[$preset['scale'] ?? 'sans-refined'] ?? self::SCALES['sans-refined'],
|
||||
'radius' => self::RADIUS[$preset['radius'] ?? 'soft'] ?? self::RADIUS['soft'],
|
||||
'shadow' => self::SHADOW[$preset['shadow'] ?? 'soft'] ?? self::SHADOW['soft'],
|
||||
'space' => self::SPACE,
|
||||
'motion' => self::MOTION[$preset['motion'] ?? 'gentle'] ?? self::MOTION['gentle'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 两色按比例混合,用来派生 soft / strong 变体
|
||||
*
|
||||
* @param float $ratio target 的占比
|
||||
*/
|
||||
private function mix(string $color, string $target, float $ratio): string
|
||||
{
|
||||
[$r1, $g1, $b1] = $this->toRgb($color);
|
||||
[$r2, $g2, $b2] = $this->toRgb($target);
|
||||
$ratio = max(0, min(1, $ratio));
|
||||
return sprintf(
|
||||
'#%02X%02X%02X',
|
||||
(int) round($r1 + ($r2 - $r1) * $ratio),
|
||||
(int) round($g1 + ($g2 - $g1) * $ratio),
|
||||
(int) round($b1 + ($b2 - $b1) * $ratio)
|
||||
);
|
||||
}
|
||||
|
||||
private function isDark(string $color): bool
|
||||
{
|
||||
[$r, $g, $b] = $this->toRgb($color);
|
||||
// 感知亮度,低于 128 视作深色底
|
||||
return (0.299 * $r + 0.587 * $g + 0.114 * $b) < 128;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:int,2:int}
|
||||
*/
|
||||
private function toRgb(string $color): array
|
||||
{
|
||||
$hex = ltrim(trim($color), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
|
||||
return [255, 255, 255];
|
||||
}
|
||||
return [
|
||||
(int) hexdec(substr($hex, 0, 2)),
|
||||
(int) hexdec(substr($hex, 2, 2)),
|
||||
(int) hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
}
|
||||
186
app/Service/business/WxTemplateSchemaService.php
Normal file
186
app/Service/business/WxTemplateSchemaService.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\Service\common\UtilsService;
|
||||
|
||||
/**
|
||||
* 装修模板 JSON 的白名单校验
|
||||
*
|
||||
* 导入的 JSON 会被小程序当成 CSS 变量注入根节点,一份坏 JSON 能把线上小程序打成白屏,
|
||||
* 所以入库前必须过白名单:键名不在表里的丢弃,值必须是安全的短字符串。
|
||||
* 这里只允许「值」,不允许任何 CSS 语句片段(分号、url()、表达式一律拒绝)。
|
||||
*/
|
||||
class WxTemplateSchemaService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
/**
|
||||
* 允许的令牌键,分组 => 键名清单
|
||||
*/
|
||||
public const TOKEN_SCHEMA = [
|
||||
'color' => [
|
||||
'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft',
|
||||
'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border',
|
||||
'price', 'success', 'warning', 'danger', 'mask',
|
||||
],
|
||||
'font' => [
|
||||
'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg',
|
||||
'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing',
|
||||
],
|
||||
'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'],
|
||||
'shadow' => ['none', 'sm', 'md', 'lg'],
|
||||
'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'],
|
||||
'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 允许的布局键与可选值
|
||||
*/
|
||||
public const LAYOUT_SCHEMA = [
|
||||
'home' => [
|
||||
'hero' => ['banner', 'carousel', 'split', 'fullscreen'],
|
||||
'category' => ['grid', 'scroll', 'card', 'sidebar'],
|
||||
'product' => ['waterfall', 'list', 'grid', 'magazine'],
|
||||
],
|
||||
'product' => [
|
||||
'gallery' => ['swiper', 'stack', 'fullbleed'],
|
||||
'price' => ['inline', 'card', 'sticky'],
|
||||
'action' => ['fixed', 'inline'],
|
||||
],
|
||||
'list' => [
|
||||
'style' => ['card', 'table', 'timeline'],
|
||||
],
|
||||
'mine' => [
|
||||
'header' => ['gradient', 'image', 'plain'],
|
||||
'menu' => ['grid', 'list'],
|
||||
],
|
||||
'effect' => [
|
||||
'transition' => ['fade', 'slide', 'zoom', 'none'],
|
||||
'skeleton' => ['shimmer', 'pulse', 'none'],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 危险片段:出现即拒绝整个值
|
||||
*/
|
||||
private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import'];
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗令牌:只保留白名单键,值必须安全
|
||||
*
|
||||
* @param mixed $tokens 数组或 JSON 字符串
|
||||
* @param bool $strict true 时遇到非法值直接报错(导入场景),false 时静默丢弃
|
||||
*/
|
||||
public function sanitizeTokens(mixed $tokens, bool $strict = false): array
|
||||
{
|
||||
$tokens = $this->toArray($tokens);
|
||||
$clean = [];
|
||||
foreach (self::TOKEN_SCHEMA as $group => $keys) {
|
||||
$source = $tokens[$group] ?? [];
|
||||
if (!is_array($source)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($keys as $key) {
|
||||
if (!array_key_exists($key, $source)) {
|
||||
continue;
|
||||
}
|
||||
$value = $source[$key];
|
||||
if (!$this->isSafeValue($value)) {
|
||||
if ($strict) {
|
||||
UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$clean[$group][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项
|
||||
*/
|
||||
public function sanitizeLayout(mixed $layout, bool $strict = false): array
|
||||
{
|
||||
$layout = $this->toArray($layout);
|
||||
$clean = [];
|
||||
foreach (self::LAYOUT_SCHEMA as $page => $options) {
|
||||
$source = $layout[$page] ?? [];
|
||||
if (!is_array($source)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($options as $key => $allowed) {
|
||||
if (!array_key_exists($key, $source)) {
|
||||
continue;
|
||||
}
|
||||
$value = (string) $source[$key];
|
||||
if (!in_array($value, $allowed, true)) {
|
||||
if ($strict) {
|
||||
UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed));
|
||||
}
|
||||
$value = $allowed[0];
|
||||
}
|
||||
$clean[$page][$key] = $value;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序侧要的扁平 CSS 变量表:--color-primary 这种
|
||||
*/
|
||||
public function toCssVariables(array $tokens): array
|
||||
{
|
||||
$vars = [];
|
||||
foreach ($tokens as $group => $items) {
|
||||
if (!is_array($items)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($items as $key => $value) {
|
||||
$vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value;
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
private function isSafeValue(mixed $value): bool
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return true;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
$value = trim($value);
|
||||
if ($value === '' || mb_strlen($value) > 64) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::FORBIDDEN as $needle) {
|
||||
if (stripos($value, $needle) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function toArray(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = json_decode($value, true);
|
||||
if (!is_array($value)) {
|
||||
UtilsService::getInstance()->errorThrow('模板 JSON 解析失败');
|
||||
}
|
||||
}
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
}
|
||||
262
app/Service/business/WxTemplateService.php
Normal file
262
app/Service/business/WxTemplateService.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\WxTemplateModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 小程序装修模板管理(后台)
|
||||
*/
|
||||
class WxTemplateService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = WxTemplateModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'name', 'code', 'preview', 'style_tag', 'is_default', 'status',
|
||||
'app_code', 'version', 'sort', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'name' => 'like',
|
||||
'code' => 'like',
|
||||
'style_tag' => '=',
|
||||
'app_code' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
// 列表不回完整 tokens(体积大),但卡片预览需要色板与布局摘要
|
||||
$ids = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['id'] ?? 0),
|
||||
$result['items'] ?? []
|
||||
)));
|
||||
if ($ids === []) {
|
||||
return $result;
|
||||
}
|
||||
$extras = WxTemplateModel::whereIn('id', $ids)
|
||||
->get(['id', 'tokens', 'layout'])
|
||||
->keyBy('id');
|
||||
foreach ($result['items'] as &$item) {
|
||||
$extra = $extras[(int) $item['id']] ?? null;
|
||||
$tokens = is_array($extra?->tokens) ? $extra->tokens : [];
|
||||
$layout = is_array($extra?->layout) ? $extra->layout : [];
|
||||
$color = is_array($tokens['color'] ?? null) ? $tokens['color'] : [];
|
||||
$home = is_array($layout['home'] ?? null) ? $layout['home'] : [];
|
||||
$item['swatch'] = [
|
||||
'primary' => (string) ($color['primary'] ?? '#B08D57'),
|
||||
'accent' => (string) ($color['accent'] ?? ($color['primary'] ?? '#B08D57')),
|
||||
'bg' => (string) ($color['bg'] ?? '#FAFAF9'),
|
||||
'surface' => (string) ($color['surface'] ?? '#FFFFFF'),
|
||||
'text' => (string) ($color['text'] ?? '#18181B'),
|
||||
'border' => (string) ($color['border'] ?? '#E4E4E7'),
|
||||
];
|
||||
$item['layout_hint'] = [
|
||||
'hero' => (string) ($home['hero'] ?? 'banner'),
|
||||
'category' => (string) ($home['category'] ?? 'grid'),
|
||||
'product' => (string) ($home['product'] ?? 'grid'),
|
||||
];
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'name', 'code', 'style_tag'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
public function detail($id): mixed
|
||||
{
|
||||
// 详情是单条记录,没有 list 的字段裁剪压力;
|
||||
// tokens/layout 必须回显(编辑弹窗回填要用),不能用默认 selectField(它把这两个字段排除了)。
|
||||
$saved = $this->selectField;
|
||||
$this->selectField = ['*'];
|
||||
try {
|
||||
return $this->getDetail($id);
|
||||
} finally {
|
||||
$this->selectField = $saved;
|
||||
}
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$params = $this->normalize($params);
|
||||
return $this->insert($params);
|
||||
}
|
||||
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$params = $this->normalize($params, (int) $id);
|
||||
return $this->save($id, $params);
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
// 默认模板被删掉小程序就没样式可用了,必须先改默认再删
|
||||
$hasDefault = WxTemplateModel::whereIn('id', (array) $ids)->where('is_default', 1)->exists();
|
||||
if ($hasDefault) {
|
||||
$this->utils->errorThrow('默认模板不能删除,请先把其他模板设为默认');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设为默认(同一 app_code 下只能有一个默认)
|
||||
* 同时把 code 写入 nl_wx_app.template_code,保证小程序 current() 优先命中
|
||||
*/
|
||||
public function setDefault(int $id): bool
|
||||
{
|
||||
$template = WxTemplateModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($template)) {
|
||||
$this->utils->errorThrow('模板不存在');
|
||||
}
|
||||
DB::connection('business')->transaction(function () use ($template, $id) {
|
||||
WxTemplateModel::where('app_code', $template['app_code'])
|
||||
->where('id', '!=', $id)
|
||||
->update(['is_default' => 0, 'updated_at' => time()]);
|
||||
WxTemplateModel::where('id', $id)->update([
|
||||
'is_default' => 1,
|
||||
'status' => 0,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
});
|
||||
// 系统库 nl_wx_app:空 app_code 的全局模板同步到全部启用应用;有品牌则只同步该品牌
|
||||
$appQuery = \App\Models\WxAppModel::where('deleted_at', 0)->where('status', 0);
|
||||
$appCode = trim((string) ($template['app_code'] ?? ''));
|
||||
if ($appCode !== '') {
|
||||
$appQuery->where('code', $appCode);
|
||||
}
|
||||
$appQuery->update([
|
||||
'template_code' => (string) $template['code'],
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出:给出可直接再导入的 JSON
|
||||
*/
|
||||
public function export(array $ids): array
|
||||
{
|
||||
$rows = WxTemplateModel::whereIn('id', $ids)->where('deleted_at', 0)->get([
|
||||
'name', 'code', 'preview', 'style_tag', 'tokens', 'layout', 'app_code', 'version',
|
||||
]);
|
||||
return [
|
||||
'version' => 1,
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'templates' => $rows->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入:整包校验通过才落库
|
||||
*
|
||||
* 单条不合法就整包拒绝,不做「部分成功」——一半新一半旧的模板库更难排查。
|
||||
*/
|
||||
public function import(array $payload, bool $overwrite = false): array
|
||||
{
|
||||
$templates = $payload['templates'] ?? $payload;
|
||||
if (!is_array($templates) || empty($templates)) {
|
||||
$this->utils->errorThrow('导入内容为空');
|
||||
}
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
$rows = [];
|
||||
foreach ($templates as $index => $item) {
|
||||
$code = trim((string) ($item['code'] ?? ''));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
$this->utils->errorThrow('第 ' . ($index + 1) . ' 个模板缺少 code 或 name');
|
||||
}
|
||||
$rows[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'preview' => (string) ($item['preview'] ?? ''),
|
||||
'style_tag' => (string) ($item['style_tag'] ?? ''),
|
||||
'app_code' => (string) ($item['app_code'] ?? ''),
|
||||
'tokens' => json_encode($schema->sanitizeTokens($item['tokens'] ?? [], true), JSON_UNESCAPED_UNICODE),
|
||||
'layout' => json_encode($schema->sanitizeLayout($item['layout'] ?? [], true), JSON_UNESCAPED_UNICODE),
|
||||
'version' => max(1, (int) ($item['version'] ?? 1)),
|
||||
];
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
DB::connection('business')->transaction(function () use ($rows, $overwrite, &$inserted, &$updated, &$skipped) {
|
||||
foreach ($rows as $row) {
|
||||
$exists = WxTemplateModel::where('code', $row['code'])
|
||||
->where('app_code', $row['app_code'])
|
||||
->first();
|
||||
if (!empty($exists)) {
|
||||
if (!$overwrite) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$row['version'] = (int) $exists['version'] + 1;
|
||||
$row['updated_at'] = time();
|
||||
WxTemplateModel::where('id', $exists['id'])->update($row);
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
$row['created_at'] = time();
|
||||
WxTemplateModel::insert($row);
|
||||
$inserted++;
|
||||
}
|
||||
});
|
||||
return ['inserted' => $inserted, 'updated' => $updated, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用内置预设初始化模板库(20 套)
|
||||
*/
|
||||
public function initPresets(bool $overwrite = false): array
|
||||
{
|
||||
$presets = WxTemplatePresetService::getInstance()->all();
|
||||
$result = $this->import(['templates' => $presets], $overwrite);
|
||||
// 一套默认都没有的话,把第一套轻奢设为默认
|
||||
if (!WxTemplateModel::where('deleted_at', 0)->where('is_default', 1)->exists()) {
|
||||
$first = WxTemplateModel::where('deleted_at', 0)->orderBy('sort', 'asc')->first();
|
||||
if (!empty($first)) {
|
||||
$this->setDefault((int) $first['id']);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入库前清洗;code 在同一品牌下唯一
|
||||
*/
|
||||
private function normalize(array $params, int $excludeId = 0): array
|
||||
{
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
if (array_key_exists('tokens', $params)) {
|
||||
$params['tokens'] = json_encode($schema->sanitizeTokens($params['tokens'], true), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (array_key_exists('layout', $params)) {
|
||||
$params['layout'] = json_encode($schema->sanitizeLayout($params['layout'], true), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($params['code'])) {
|
||||
$duplicate = WxTemplateModel::where('code', $params['code'])
|
||||
->where('app_code', (string) ($params['app_code'] ?? ''))
|
||||
->when($excludeId > 0, fn ($query) => $query->where('id', '!=', $excludeId))
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
$this->utils->errorThrow('模板标识已存在');
|
||||
}
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
257
app/Service/business/WxUserService.php
Normal file
257
app/Service/business/WxUserService.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\EnterpriseModel;
|
||||
use App\Models\business\WxTemplateModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* 微信用户管理
|
||||
*
|
||||
* 三件事:认代理商(is_p)、给代理商设价格倍率(price_number)、把用户绑到企业;
|
||||
* 另可给经销商绑定专属装修模板(template_code)。
|
||||
* session_key 属于服务端凭据,selectField 里绝不带上。
|
||||
*/
|
||||
class WxUserService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = WxUserModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'open_id', 'phone', 'nick_name', 'avatar',
|
||||
'show_price', 'is_p', 'pid', 'enterprise_id', 'price_number', 'template_code',
|
||||
'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'nick_name' => 'like',
|
||||
'phone' => 'like',
|
||||
'is_p' => '=',
|
||||
'show_price' => '=',
|
||||
'enterprise_id' => '=',
|
||||
];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$this->with = ['enterprise'];
|
||||
$result = $this->getPageList();
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['enterprise_name'] = $item['enterprise']['name'] ?? '';
|
||||
unset($item['enterprise']);
|
||||
$item['avatar'] = $this->media->toPublic($item['avatar'] ?? '');
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'nick_name as name', 'phone'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情:带所属企业、上级代理商与下级数量
|
||||
* @throws Exception
|
||||
*/
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$this->with = ['enterprise'];
|
||||
$info = $this->getDetail($id);
|
||||
$info->avatar = $this->media->toPublic($info->avatar);
|
||||
$info->enterprise_name = $info->enterprise->name ?? '';
|
||||
$info->parent_name = $info->pid > 0
|
||||
? (string) (WxUserModel::where('id', $info->pid)->value('nick_name') ?? '')
|
||||
: '';
|
||||
$info->child_count = WxUserModel::where('pid', $info->id)->where('deleted_at', 0)->count();
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台不创建微信用户(用户只能由小程序授权登录产生)
|
||||
* @throws Exception
|
||||
*/
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->utils->errorThrow('微信用户由小程序授权登录产生,后台不支持新建');
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台只允许改这几项,避免把 open_id 之类的身份字段改花
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$allowed = ['nick_name', 'phone', 'enterprise_id', 'show_price', 'price_number', 'is_p', 'template_code'];
|
||||
$payload = array_intersect_key($params, array_flip($allowed));
|
||||
if (empty($payload)) {
|
||||
$this->utils->errorThrow('没有可更新的字段');
|
||||
}
|
||||
if (array_key_exists('price_number', $payload)) {
|
||||
$payload['price_number'] = $this->normalizeMultiplier($payload['price_number']);
|
||||
}
|
||||
if (array_key_exists('template_code', $payload)) {
|
||||
$payload['template_code'] = trim((string) $payload['template_code']);
|
||||
}
|
||||
return $this->save($id, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete($id): mixed
|
||||
{
|
||||
return $this->del(is_array($id) ? $id : [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换代理商身份
|
||||
*
|
||||
* 老接口是「翻转」语义(不传目标值,后端读当前值取反),前端的开关点快了就会和后端打反。
|
||||
* 这里改成显式传 is_p,同时保留不传时的翻转以兼容老调用。
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateUserIsP($id, mixed $isP = null): bool
|
||||
{
|
||||
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
$this->utils->errorThrow('用户不存在');
|
||||
}
|
||||
|
||||
$target = $isP === null || $isP === ''
|
||||
? ((int) $user->is_p === 1 ? 0 : 1)
|
||||
: (int) $isP;
|
||||
|
||||
// 认成代理商就默认可见价格、倍率归 1;取消代理商则收回价格可见性,并清空专属模板
|
||||
$data = $target === 1
|
||||
? ['is_p' => 1, 'show_price' => 1, 'price_number' => 1]
|
||||
: ['is_p' => 0, 'show_price' => 0, 'price_number' => 1, 'template_code' => ''];
|
||||
$data['updated_at'] = time();
|
||||
|
||||
WxUserModel::where('id', $id)->update($data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给经销商绑定专属装修模板(空字符串=跟随品牌默认)
|
||||
* @throws Exception
|
||||
*/
|
||||
public function bindTemplate($id, mixed $templateCode): bool
|
||||
{
|
||||
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
$this->utils->errorThrow('用户不存在');
|
||||
}
|
||||
if ((int) $user->is_p !== 1) {
|
||||
$this->utils->errorThrow('仅经销商可绑定专属模板');
|
||||
}
|
||||
$code = trim((string) $templateCode);
|
||||
if ($code !== '') {
|
||||
$exists = WxTemplateModel::where('code', $code)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
$this->utils->errorThrow('模板不存在或已停用');
|
||||
}
|
||||
}
|
||||
WxUserModel::where('id', $id)->update([
|
||||
'template_code' => $code,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置价格倍率(同时打开价格可见)
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateShowPrice($id, mixed $number): bool
|
||||
{
|
||||
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
$this->utils->errorThrow('用户不存在');
|
||||
}
|
||||
$multiplier = $this->normalizeMultiplier($number);
|
||||
WxUserModel::where('id', $id)->update([
|
||||
'show_price' => 1,
|
||||
'price_number' => $multiplier,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单独控制价格可见性(不动倍率)
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateShowPriceStatus($id, mixed $showPrice): bool
|
||||
{
|
||||
$user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
$this->utils->errorThrow('用户不存在');
|
||||
}
|
||||
WxUserModel::where('id', $id)->update([
|
||||
'show_price' => (int) $showPrice === 1 ? 1 : 0,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定企业
|
||||
* @throws Exception
|
||||
*/
|
||||
public function bindUser($id, $enterpriseId): bool
|
||||
{
|
||||
$enterpriseId = (int) $enterpriseId;
|
||||
if (!WxUserModel::where('id', $id)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('用户不存在');
|
||||
}
|
||||
if ($enterpriseId > 0 && !EnterpriseModel::where('id', $enterpriseId)->where('deleted_at', 0)->exists()) {
|
||||
$this->utils->errorThrow('企业不存在');
|
||||
}
|
||||
WxUserModel::where('id', $id)->update([
|
||||
'enterprise_id' => $enterpriseId,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该代理商名下的下级用户
|
||||
*/
|
||||
public function children(int $id): array
|
||||
{
|
||||
return WxUserModel::where('pid', $id)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc')
|
||||
->get(['id', 'nick_name', 'phone', 'price_number', 'show_price', 'created_at'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 倍率兜底,0 或负数会把价格直接乘成 0
|
||||
*/
|
||||
private function normalizeMultiplier(mixed $number): string
|
||||
{
|
||||
if (!is_numeric($number) || (float) $number <= 0) {
|
||||
return '1';
|
||||
}
|
||||
return (string) $number;
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,44 @@ class JWTService
|
||||
|
||||
return $this->generateToken((array)$decoded->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已过期但签名有效的 token
|
||||
*
|
||||
* 续签场景下 token 必然已经过期,正常 decode 会直接抛 ExpiredException。
|
||||
* 这里临时放宽 leeway 让 exp 校验通过,签名与 Redis 会话仍然照常校验,
|
||||
* 所以过期的 token 依旧不能凭空续签——会话被登出或超过宽限期就必须重新登录。
|
||||
*
|
||||
* @param int $leeway 允许的过期宽限秒数
|
||||
*/
|
||||
public function parseExpiringToken(int $leeway): ?object
|
||||
{
|
||||
$token = request()->bearerToken();
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
$origin = JWT::$leeway;
|
||||
JWT::$leeway = max(0, $leeway);
|
||||
try {
|
||||
return JWT::decode($token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
} finally {
|
||||
JWT::$leeway = $origin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废某个用户的登录态:删掉 Redis 会话,手里的 token 立即失效
|
||||
* (getUserInfo 拿不到会话就会 notAuth,所以不需要维护黑名单)
|
||||
*/
|
||||
public function revoke(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return RedisService::getInstance()->init(config('nl.redis.jwt'))->del($userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
100
app/Service/common/MediaUrlService.php
Normal file
100
app/Service/common/MediaUrlService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common;
|
||||
|
||||
/**
|
||||
* 媒体地址规整
|
||||
*
|
||||
* 老后台的约定是:入库存相对路径(/storage/xxx.jpg),出库用 asset() 拼成绝对地址;
|
||||
* 而 OSS 上传拿到的本来就是绝对地址。两种值混在同一列里,读写都得判断一次。
|
||||
* 这里把判断收在一处,业务 Service 只调 toPublic / toStorage。
|
||||
*/
|
||||
class MediaUrlService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 出库:相对路径补上站点域名,绝对地址原样返回
|
||||
*/
|
||||
public function toPublic(?string $path): string
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
if ($this->isAbsolute($path)) {
|
||||
return $path;
|
||||
}
|
||||
return rtrim((string) config('app.url'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 入库:本站域名下的地址存成相对路径,避免换域名后历史数据全指向旧域名;
|
||||
* 第三方 OSS 地址保持原样
|
||||
*/
|
||||
public function toStorage(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
$appUrl = rtrim((string) config('app.url'), '/');
|
||||
if ($appUrl !== '' && str_starts_with($url, $appUrl)) {
|
||||
return substr($url, strlen($appUrl)) ?: '';
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量出库,给 list 用
|
||||
*/
|
||||
public function publicEach(array &$rows, array $fields): void
|
||||
{
|
||||
foreach ($rows as &$row) {
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $row)) {
|
||||
$row[$field] = $this->toPublic($row[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端图片上传组件回传的是数组,取第一个;已是字符串则原样
|
||||
*/
|
||||
public function firstOf(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value[0] ?? '';
|
||||
}
|
||||
return $this->toStorage(is_string($value) ? $value : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉 OSS 处理参数(?imageView2/... 或 ?watermark/...),用于素材库按 path 做引用匹配
|
||||
*/
|
||||
public function stripProcessParams(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
$pos = strpos($url, '?');
|
||||
return $pos === false ? $url : substr($url, 0, $pos);
|
||||
}
|
||||
|
||||
private function isAbsolute(string $path): bool
|
||||
{
|
||||
return (bool) preg_match('#^(https?:)?//#i', $path);
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,29 @@ class RedisService
|
||||
{
|
||||
return $this->redis::del($this->prefix . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前前缀下的全部键
|
||||
*
|
||||
* 菜单这类按角色分片缓存的数据,改了菜单树要一次性失效所有角色的副本。
|
||||
* KEYS 的返回值带着 redis 客户端自身的 prefix,直接回传给 del 会二次拼前缀,所以要先剥掉。
|
||||
* @return int 删除的键数量
|
||||
*/
|
||||
public function delAll(): int
|
||||
{
|
||||
$keys = $this->redis::keys($this->prefix . '*');
|
||||
if (empty($keys)) {
|
||||
return 0;
|
||||
}
|
||||
$clientPrefix = (string) config('database.redis.options.prefix');
|
||||
$count = 0;
|
||||
foreach ($keys as $key) {
|
||||
if ($clientPrefix !== '' && str_starts_with($key, $clientPrefix)) {
|
||||
$key = substr($key, strlen($clientPrefix));
|
||||
}
|
||||
$this->redis::del($key);
|
||||
$count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,31 @@ class UploadService extends BaseService
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档(商品图册的 PDF、订单转账凭证的 PDF 等)
|
||||
*
|
||||
* 驱动层的 uploadVideo 就是通用的 put,图册 PDF 之前只能借 video 通道上传,
|
||||
* 结果 key 落在 spa/video 下,素材库按目录归类时全错位,故单独开一路。
|
||||
*
|
||||
* @param mixed $file 上传文件对象
|
||||
*/
|
||||
public function uploadDocument($file): array|bool
|
||||
{
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
$allowed = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'zip'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$this->utils->errorThrow('不支持的文件类型:' . $ext);
|
||||
}
|
||||
$key = 'spa/file/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
|
||||
$result = $this->uploadService->uploadVideo($file, $key);
|
||||
if (!$result) {
|
||||
$this->utils->errorThrow('文件上传失败');
|
||||
}
|
||||
FileService::getInstance()->create([
|
||||
'user_id' => $this->userId,
|
||||
'url' => $result['url'],
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,14 +243,20 @@ class UtilsService
|
||||
{
|
||||
foreach ($class as $key => $value) {
|
||||
|
||||
$reflection = new ReflectionClass($value);
|
||||
// 获取控制器$value的所有方法
|
||||
$methods = (new ReflectionClass($value))->getMethods();
|
||||
$methods = $reflection->getMethods();
|
||||
// 子类声明的排除清单:继承来的 CRUD 也会被反射到,不该暴露的在这里拦掉
|
||||
$exceptRoute = (array) ($reflection->getDefaultProperties()['exceptRoute'] ?? []);
|
||||
|
||||
// 注册路由
|
||||
foreach ($methods as $method) {
|
||||
// 获取方法注释 @Method
|
||||
$docComment = $method->getDocComment();
|
||||
if ($method->name === '__construct' || preg_match('/@Method\s+(NO)\b/', $docComment, $matches)) {
|
||||
if ($method->name === '__construct'
|
||||
|| in_array($method->name, $exceptRoute, true)
|
||||
|| preg_match('/@Method\s+(NO)\b/', (string) $docComment, $matches)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BaseApp\BaseService;
|
||||
use App\Models\oss\OssConfigModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\SystemConfigService;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* OSS 运行时配置:解析当前启用的存储配置并解密密钥,供上传工厂使用
|
||||
@@ -74,6 +75,32 @@ class OssRuntimeConfigService extends BaseService
|
||||
'extra_json' => null,
|
||||
];
|
||||
}
|
||||
return $this->formatConfig($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 获取明文配置
|
||||
*
|
||||
* 素材库要对指定 bucket 做列举与删除,不能只认「当前启用」那一份:
|
||||
* 换过存储之后老素材仍然躺在旧配置的 bucket 里,回收时必须拿旧配置去删。
|
||||
* 也因此这里不校验 status —— 配置被禁用不代表里面的对象不用管了。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getConfigById(int $id): array
|
||||
{
|
||||
$row = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (!$row) {
|
||||
$this->utils->notFound('存储配置不存在');
|
||||
}
|
||||
return $this->formatConfig($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库行转明文配置数组
|
||||
*/
|
||||
private function formatConfig(OssConfigModel $row): array
|
||||
{
|
||||
$enc = FieldEncryptService::getInstance();
|
||||
$extra = $row->extra_json;
|
||||
if (is_string($extra) && $extra !== '') {
|
||||
|
||||
@@ -31,4 +31,24 @@ interface OssStorageInterface
|
||||
* @return array{key:string,url:string}|false
|
||||
*/
|
||||
public function uploadVideo($filePath, string $key): bool|array;
|
||||
|
||||
/**
|
||||
* 分页列举对象
|
||||
*
|
||||
* 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿
|
||||
* PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。
|
||||
*
|
||||
* @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix
|
||||
* @param string $marker 上一页返回的 next_marker,首页传空串
|
||||
* @param int $limit 单页条数
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array;
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
*
|
||||
* @param string $key 对象键(缺 path_prefix 时由实现补齐)
|
||||
*/
|
||||
public function deleteObject(string $key): bool;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class AliyunStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -30,19 +33,65 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
return $this->uploadFile($filePath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举 bucket 对象(GET Bucket,V1 签名)
|
||||
*
|
||||
* prefix / marker / max-keys 都不是 OSS V1 的 sub-resource,不参与签名,
|
||||
* 所以 CanonicalizedResource 仍然只有 /bucket/,别照着 V4 的写法往里塞查询串。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$host = $this->host();
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$query = ['max-keys' => $this->boundedLimit($limit)];
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
if ($listPrefix !== '') {
|
||||
$query['prefix'] = $listPrefix;
|
||||
}
|
||||
if ($marker !== '') {
|
||||
$query['marker'] = $marker;
|
||||
}
|
||||
$response = Http::withHeaders([
|
||||
'Date' => $date,
|
||||
'Authorization' => 'OSS ' . $this->config['access_key'] . ':'
|
||||
. $this->signature('GET', '', $date, '/' . $bucket . '/'),
|
||||
])->get('https://' . $host . '/', $query);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow('阿里云列举对象失败:' . $response->body());
|
||||
}
|
||||
return $this->parseObjectListXml($response->body(), 'https://' . $host);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对象;OSS 对不存在的键也回 204,这里把它当成功处理
|
||||
*/
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$resource = '/' . $this->config['bucket'] . '/' . $key;
|
||||
$response = Http::withHeaders([
|
||||
'Date' => $date,
|
||||
'Authorization' => 'OSS ' . $this->config['access_key'] . ':'
|
||||
. $this->signature('DELETE', '', $date, $resource),
|
||||
])->delete('https://' . $this->host() . '/' . $key);
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 OSS V1 签名上传对象
|
||||
*/
|
||||
private function uploadFile($filePath, string $key): bool|array
|
||||
{
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$endpoint = (string) ($this->config['endpoint'] ?? '');
|
||||
$this->assertConfig();
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
|
||||
UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -53,19 +102,12 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$content = file_get_contents($path);
|
||||
$contentType = 'application/octet-stream';
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$resource = '/' . $bucket . '/' . $key;
|
||||
$stringToSign = "PUT\n\n{$contentType}\n{$date}\n{$resource}";
|
||||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $secretKey, true));
|
||||
$host = preg_replace('#^https?://#', '', rtrim($endpoint, '/'));
|
||||
// 支持传入 oss-cn-xxx.aliyuncs.com 或带 bucket 的域名
|
||||
if (!str_starts_with($host, $bucket . '.')) {
|
||||
$host = $bucket . '.' . $host;
|
||||
}
|
||||
$url = 'https://' . $host . '/' . $key;
|
||||
$signature = $this->signature('PUT', $contentType, $date, '/' . $bucket . '/' . $key);
|
||||
$url = 'https://' . $this->host() . '/' . $key;
|
||||
$response = Http::withHeaders([
|
||||
'Date' => $date,
|
||||
'Content-Type' => $contentType,
|
||||
'Authorization' => 'OSS ' . $accessKey . ':' . $signature,
|
||||
'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $signature,
|
||||
])->withBody($content, $contentType)->put($url);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body());
|
||||
@@ -73,4 +115,37 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS V1 签名:上传、列举、删除共用同一套 StringToSign 拼法
|
||||
*/
|
||||
private function signature(string $method, string $contentType, string $date, string $resource): string
|
||||
{
|
||||
$stringToSign = "{$method}\n\n{$contentType}\n{$date}\n{$resource}";
|
||||
return base64_encode(hash_hmac('sha1', $stringToSign, (string) $this->config['secret_key'], true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求主机名;支持配置里填 oss-cn-xxx.aliyuncs.com 或已带 bucket 的域名
|
||||
*/
|
||||
private function host(): string
|
||||
{
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$host = (string) preg_replace('#^https?://#', '', rtrim((string) ($this->config['endpoint'] ?? ''), '/'));
|
||||
if (!str_starts_with($host, $bucket . '.')) {
|
||||
$host = $bucket . '.' . $host;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private function assertConfig(): void
|
||||
{
|
||||
if (trim((string) ($this->config['access_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['secret_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['bucket'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['endpoint'] ?? '')) === ''
|
||||
) {
|
||||
UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class LocalhostStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
|
||||
protected array $config = [
|
||||
'domain' => '',
|
||||
'path_prefix' => '',
|
||||
@@ -45,6 +47,56 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫本地磁盘目录
|
||||
*
|
||||
* 本地盘没有 marker 这种服务端游标,用「已列举条数」当偏移量模拟:
|
||||
* 先把文件名排序固定顺序,再按偏移切页,这样反复调用能稳定推进。
|
||||
* 代价是同步期间新增文件会让偏移错位,但素材同步是幂等的,下一轮就自愈。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$listPrefix = rtrim($this->scopedListPrefix($prefix), '/');
|
||||
$offset = max(0, (int) $marker);
|
||||
$files = Storage::allFiles($listPrefix);
|
||||
sort($files);
|
||||
$page = array_slice($files, $offset, $this->boundedLimit($limit));
|
||||
|
||||
$items = [];
|
||||
foreach ($page as $file) {
|
||||
$key = $this->normalizeObjectKey($file);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$realPath = Storage::path($file);
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) Storage::size($file),
|
||||
// 本地盘没有服务端 ETag,用 md5 顶上,素材库靠它判重与校验
|
||||
'hash' => is_file($realPath) ? (string) md5_file($realPath) : '',
|
||||
'last_modified' => (int) Storage::lastModified($file),
|
||||
'url' => $this->publicUrlOf($key, asset('/storage/' . $key)),
|
||||
];
|
||||
}
|
||||
|
||||
$next = $offset + count($page);
|
||||
$finished = $next >= count($files);
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $finished ? '' : (string) $next,
|
||||
'finished' => $finished,
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入本地 storage;兼容 UploadedFile 与路径字符串
|
||||
*/
|
||||
|
||||
66
app/Service/common/upload/ObjectKeyNormalizeTrait.php
Normal file
66
app/Service/common/upload/ObjectKeyNormalizeTrait.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\upload;
|
||||
|
||||
/**
|
||||
* 对象键规整
|
||||
*
|
||||
* 五个驱动在列举 / 删除时对 key 的处理完全一致(补 path_prefix、压斜杠、拼 domain),
|
||||
* 抽出来免得同一段逻辑在五个文件里各写一遍、各改一半。
|
||||
* 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。
|
||||
*/
|
||||
trait ObjectKeyNormalizeTrait
|
||||
{
|
||||
/**
|
||||
* 补上 path_prefix 并压平重复斜杠
|
||||
*
|
||||
* 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象,
|
||||
* 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引,
|
||||
* 不统一就会落成两条记录,回收删掉其中一条后另一条变成指向已删对象的幽灵。
|
||||
*/
|
||||
protected function normalizeObjectKey(string $key): string
|
||||
{
|
||||
$key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && $key !== $prefix && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . $key;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举前缀:调用方没给就退回 path_prefix
|
||||
*
|
||||
* 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库,
|
||||
* 之后走回收流程就等于跨项目删文件。
|
||||
*/
|
||||
protected function scopedListPrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix !== '') {
|
||||
return $this->normalizeObjectKey($prefix);
|
||||
}
|
||||
$base = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
return $base === '' ? '' : $base . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼公开访问地址;未配置 domain 时回落到调用方给的默认地址
|
||||
*/
|
||||
protected function publicUrlOf(string $key, string $fallback = ''): string
|
||||
{
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
return $domain !== '' ? $domain . '/' . $key : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单页列举条数收口,避免上游把 limit 传成 0 或者十万
|
||||
*/
|
||||
protected function boundedLimit(int $limit): int
|
||||
{
|
||||
return max(1, min($limit, 1000));
|
||||
}
|
||||
}
|
||||
64
app/Service/common/upload/ObjectListXmlTrait.php
Normal file
64
app/Service/common/upload/ObjectListXmlTrait.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\upload;
|
||||
|
||||
use App\Service\common\UtilsService;
|
||||
|
||||
/**
|
||||
* ListBucketResult 解析
|
||||
*
|
||||
* 阿里云 GET Bucket、腾讯云 GET Bucket、S3 ListObjectsV2 回的都是同一套
|
||||
* <ListBucketResult><Contents>… 结构,只有续拉游标的节点名不同(NextMarker /
|
||||
* NextContinuationToken)。三个驱动共用本 trait,避免同一段 XML 遍历写三遍。
|
||||
* 使用方需要有 $this->config 以及 ObjectKeyNormalizeTrait 提供的 key 规整方法。
|
||||
*/
|
||||
trait ObjectListXmlTrait
|
||||
{
|
||||
/**
|
||||
* @param string $xml 响应体
|
||||
* @param string $urlBase 未配置 domain 时兜底拼地址用的主机前缀(不带尾斜杠)
|
||||
* @param string $tokenNode 续拉游标节点名
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
*/
|
||||
protected function parseObjectListXml(string $xml, string $urlBase, string $tokenNode = 'NextMarker'): array
|
||||
{
|
||||
$doc = @simplexml_load_string($xml);
|
||||
if ($doc === false) {
|
||||
UtilsService::getInstance()->errorThrow('列举结果解析失败,返回内容不是合法 XML');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$lastRawKey = '';
|
||||
$contents = isset($doc->Contents) ? $doc->Contents : [];
|
||||
foreach ($contents as $node) {
|
||||
$rawKey = (string) $node->Key;
|
||||
// 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值
|
||||
$lastRawKey = $rawKey;
|
||||
$key = $this->normalizeObjectKey($rawKey);
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
// 以 / 结尾的是控制台建目录留下的占位对象,不是素材
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) $node->Size,
|
||||
'hash' => strtolower(trim((string) $node->ETag, '"')),
|
||||
'last_modified' => (int) strtotime((string) $node->LastModified),
|
||||
'url' => $this->publicUrlOf($key, $urlBase . '/' . $key),
|
||||
];
|
||||
}
|
||||
|
||||
$truncated = filter_var((string) ($doc->IsTruncated ?? 'false'), FILTER_VALIDATE_BOOLEAN);
|
||||
$next = isset($doc->{$tokenNode}) ? (string) $doc->{$tokenNode} : '';
|
||||
if ($truncated && $next === '' && $tokenNode === 'NextMarker') {
|
||||
// 部分兼容实现只给 IsTruncated 不给 NextMarker,按协议可用本页最后一个 key 续拉
|
||||
$next = $lastRawKey;
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $truncated ? $next : '',
|
||||
'finished' => !$truncated || $next === '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -30,19 +33,60 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
return $this->uploadFile($filePath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举 bucket 对象(GET Bucket)
|
||||
*
|
||||
* 与上传不同,列举带查询参数,而 COS 签名 v5 把查询串算进 HttpString,
|
||||
* 且 q-url-param-list 必须和实际发出去的参数一一对应;因此这里自己拼查询串,
|
||||
* 不能交给 Http::get($url, $query) 去编码,否则签名和请求会对不上。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$this->assertConfig();
|
||||
$params = ['max-keys' => (string) $this->boundedLimit($limit)];
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
if ($listPrefix !== '') {
|
||||
$params['prefix'] = $listPrefix;
|
||||
}
|
||||
if ($marker !== '') {
|
||||
$params['marker'] = $marker;
|
||||
}
|
||||
ksort($params);
|
||||
$host = $this->host();
|
||||
$query = $this->buildQuery($params);
|
||||
$response = Http::withHeaders([
|
||||
'Host' => $host,
|
||||
'Authorization' => $this->authorization('GET', '/', $params),
|
||||
])->get('https://' . $host . '/?' . $query);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow('腾讯云列举对象失败:' . $response->body());
|
||||
}
|
||||
return $this->parseObjectListXml($response->body(), 'https://' . $host);
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
$host = $this->host();
|
||||
$urlPath = '/' . $key;
|
||||
$response = Http::withHeaders([
|
||||
'Host' => $host,
|
||||
'Authorization' => $this->authorization('DELETE', $urlPath, []),
|
||||
])->delete('https://' . $host . $urlPath);
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* COS 对象上传(Sign Algorithm=sha1)
|
||||
*/
|
||||
private function uploadFile($filePath, string $key): bool|array
|
||||
{
|
||||
$secretId = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$region = (string) ($this->config['region'] ?? '');
|
||||
$this->assertConfig();
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
if ($secretId === '' || $secretKey === '' || $bucket === '' || $region === '') {
|
||||
UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)');
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -51,25 +95,12 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
? $filePath->getRealPath()
|
||||
: (string) $filePath;
|
||||
$content = file_get_contents($path);
|
||||
$host = $bucket . '.cos.' . $region . '.myqcloud.com';
|
||||
$host = $this->host();
|
||||
$urlPath = '/' . ltrim($key, '/');
|
||||
$now = time();
|
||||
$keyTime = $now . ';' . ($now + 600);
|
||||
$signKey = hash_hmac('sha1', $keyTime, $secretKey);
|
||||
$httpString = strtolower('put') . "\n" . $urlPath . "\n\nhost=" . strtolower($host) . "\n";
|
||||
$stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n";
|
||||
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
||||
$authorization = 'q-sign-algorithm=sha1'
|
||||
. '&q-ak=' . $secretId
|
||||
. '&q-sign-time=' . $keyTime
|
||||
. '&q-key-time=' . $keyTime
|
||||
. '&q-header-list=host'
|
||||
. '&q-url-param-list='
|
||||
. '&q-signature=' . $signature;
|
||||
$url = 'https://' . $host . $urlPath;
|
||||
$response = Http::withHeaders([
|
||||
'Host' => $host,
|
||||
'Authorization' => $authorization,
|
||||
'Authorization' => $this->authorization('PUT', $urlPath, []),
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])->withBody($content, 'application/octet-stream')->put($url);
|
||||
if (!$response->successful()) {
|
||||
@@ -78,4 +109,61 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$publicUrl = $domain !== '' ? ($domain . $urlPath) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名 v5;上传、列举、删除共用,差别只在 method / 路径 / 查询参数
|
||||
*
|
||||
* @param array<string, string> $params 参与签名的查询参数(键需已排序)
|
||||
*/
|
||||
private function authorization(string $method, string $urlPath, array $params): string
|
||||
{
|
||||
$secretId = (string) $this->config['access_key'];
|
||||
$secretKey = (string) $this->config['secret_key'];
|
||||
$host = $this->host();
|
||||
$now = time();
|
||||
$keyTime = $now . ';' . ($now + 600);
|
||||
$signKey = hash_hmac('sha1', $keyTime, $secretKey);
|
||||
$paramList = implode(';', array_map('strtolower', array_keys($params)));
|
||||
$httpString = strtolower($method) . "\n" . $urlPath . "\n" . $this->buildQuery($params)
|
||||
. "\nhost=" . strtolower($host) . "\n";
|
||||
$stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n";
|
||||
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
||||
return 'q-sign-algorithm=sha1'
|
||||
. '&q-ak=' . $secretId
|
||||
. '&q-sign-time=' . $keyTime
|
||||
. '&q-key-time=' . $keyTime
|
||||
. '&q-header-list=host'
|
||||
. '&q-url-param-list=' . $paramList
|
||||
. '&q-signature=' . $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 rawurlencode 拼查询串:COS 要求 RFC3986 编码,http_build_query 会把空格编成 +
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
private function buildQuery(array $params): string
|
||||
{
|
||||
$pairs = [];
|
||||
foreach ($params as $name => $value) {
|
||||
$pairs[] = strtolower(rawurlencode((string) $name)) . '=' . rawurlencode((string) $value);
|
||||
}
|
||||
return implode('&', $pairs);
|
||||
}
|
||||
|
||||
private function host(): string
|
||||
{
|
||||
return $this->config['bucket'] . '.cos.' . $this->config['region'] . '.myqcloud.com';
|
||||
}
|
||||
|
||||
private function assertConfig(): void
|
||||
{
|
||||
if (trim((string) ($this->config['access_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['secret_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['bucket'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['region'] ?? '')) === ''
|
||||
) {
|
||||
UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Service\common\UtilsService;
|
||||
*/
|
||||
class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
|
||||
protected array $config = [
|
||||
'access_key' => '',
|
||||
'secret_key' => '',
|
||||
@@ -48,6 +50,54 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举空间对象(BucketManager::listFiles,marker 分页)
|
||||
*
|
||||
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$bucketMgr = $this->bucketManager();
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
[$ret, $err] = $bucketMgr->listFiles(
|
||||
$bucket,
|
||||
$this->scopedListPrefix($prefix),
|
||||
$marker !== '' ? $marker : null,
|
||||
$this->boundedLimit($limit)
|
||||
);
|
||||
if ($err !== null) {
|
||||
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err));
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ((array) ($ret['items'] ?? []) as $row) {
|
||||
$key = $this->normalizeObjectKey((string) ($row['key'] ?? ''));
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) ($row['fsize'] ?? 0),
|
||||
'hash' => (string) ($row['hash'] ?? ''),
|
||||
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
|
||||
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000),
|
||||
'url' => $this->publicUrlOf($key, $key),
|
||||
];
|
||||
}
|
||||
|
||||
$next = (string) ($ret['marker'] ?? '');
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $next,
|
||||
'finished' => $next === '',
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
return $this->deleteFile($this->normalizeObjectKey($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传到七牛;无 SDK 时抛业务异常引导安装或改本地
|
||||
*/
|
||||
@@ -93,4 +143,35 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
$err = $bucketMgr->delete($this->config['bucket'], $key);
|
||||
return $err === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
|
||||
*/
|
||||
private function bucketManager(): \Qiniu\Storage\BucketManager
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
||||
}
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
|
||||
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
|
||||
}
|
||||
return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK 的错误对象没有统一契约,转成人能看懂的一行字
|
||||
*/
|
||||
private function errorText(mixed $err): string
|
||||
{
|
||||
if (is_object($err) && method_exists($err, 'message')) {
|
||||
return (string) $err->message();
|
||||
}
|
||||
if (is_object($err) || is_array($err)) {
|
||||
return (string) json_encode($err, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return (string) $err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Service\common\upload;
|
||||
use App\BaseApp\BaseNotAuthService;
|
||||
use App\Service\common\oss\OssStorageInterface;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -31,21 +35,51 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
return $this->uploadFile($filePath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* ListObjectsV2;游标是 continuation-token,不是 V1 那种 marker
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$query = [
|
||||
'list-type' => '2',
|
||||
'max-keys' => (string) $this->boundedLimit($limit),
|
||||
];
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
if ($listPrefix !== '') {
|
||||
$query['prefix'] = $listPrefix;
|
||||
}
|
||||
if ($marker !== '') {
|
||||
$query['continuation-token'] = $marker;
|
||||
}
|
||||
$response = $this->signedRequest('GET', '/' . $bucket, $query);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($this->driver() . ' 列举对象失败:' . $response->body());
|
||||
}
|
||||
$endpoint = rtrim((string) $this->config['endpoint'], '/');
|
||||
return $this->parseObjectListXml($response->body(), $endpoint . '/' . $bucket, 'NextContinuationToken');
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
$response = $this->signedRequest('DELETE', '/' . $this->config['bucket'] . '/' . $key);
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* SigV4 PUT;endpoint 必填(如 https://s3.amazonaws.com 或 MinIO 地址)
|
||||
*/
|
||||
private function uploadFile($filePath, string $key): bool|array
|
||||
{
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$region = (string) ($this->config['region'] ?? 'us-east-1');
|
||||
$endpoint = rtrim((string) ($this->config['endpoint'] ?? ''), '/');
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
$driver = (string) ($this->config['driver'] ?? 'aws');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
|
||||
UtilsService::getInstance()->errorThrow(strtoupper($driver) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -54,37 +88,96 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
? $filePath->getRealPath()
|
||||
: (string) $filePath;
|
||||
$payload = file_get_contents($path);
|
||||
$response = $this->signedRequest('PUT', '/' . $bucket . '/' . $key, [], $payload);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($this->driver() . ' 上传失败:' . $response->body());
|
||||
}
|
||||
$url = rtrim((string) $this->config['endpoint'], '/') . $this->canonicalUri('/' . $bucket . '/' . $key);
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* SigV4 签名并发起请求
|
||||
*
|
||||
* 上传、列举、删除三条路的差别只是 method / 路径 / 查询参数 / 请求体,
|
||||
* 签名步骤一模一样,所以收在这里;三份复制粘贴改一处漏两处是必然的。
|
||||
* 走 path-style(endpoint/bucket/key),多数 MinIO 与 OBS 都接受。
|
||||
*
|
||||
* @param string $path 未编码的路径,如 /bucket/dir/a.jpg
|
||||
* @param array<string, string> $query 参与 CanonicalQueryString 的查询参数
|
||||
*/
|
||||
private function signedRequest(string $method, string $path, array $query = [], string $payload = ''): Response
|
||||
{
|
||||
$accessKey = (string) $this->config['access_key'];
|
||||
$secretKey = (string) $this->config['secret_key'];
|
||||
$region = (string) ($this->config['region'] ?? 'us-east-1');
|
||||
$endpoint = rtrim((string) $this->config['endpoint'], '/');
|
||||
$host = parse_url($endpoint, PHP_URL_HOST) ?: preg_replace('#^https?://#', '', $endpoint);
|
||||
// path-style: endpoint/bucket/key
|
||||
$canonicalUri = '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key));
|
||||
// 简化:多数 MinIO/OBS 接受 path-style
|
||||
$url = $endpoint . '/' . $bucket . '/' . $key;
|
||||
|
||||
$canonicalUri = $this->canonicalUri($path);
|
||||
ksort($query);
|
||||
$pairs = [];
|
||||
foreach ($query as $name => $value) {
|
||||
$pairs[] = rawurlencode((string) $name) . '=' . rawurlencode((string) $value);
|
||||
}
|
||||
$canonicalQuery = implode('&', $pairs);
|
||||
|
||||
$amzDate = gmdate('Ymd\THis\Z');
|
||||
$dateStamp = gmdate('Ymd');
|
||||
$payloadHash = hash('sha256', $payload);
|
||||
$canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$amzDate}\n";
|
||||
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
|
||||
$canonicalRequest = "PUT\n{$canonicalUri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
|
||||
$service = $driver === 'huawei' ? 's3' : 's3';
|
||||
$credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request";
|
||||
$canonicalRequest = strtoupper($method) . "\n{$canonicalUri}\n{$canonicalQuery}\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
|
||||
$credentialScope = "{$dateStamp}/{$region}/s3/aws4_request";
|
||||
$stringToSign = "AWS4-HMAC-SHA256\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
|
||||
$kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true);
|
||||
$kRegion = hash_hmac('sha256', $region, $kDate, true);
|
||||
$kService = hash_hmac('sha256', $service, $kRegion, true);
|
||||
$kService = hash_hmac('sha256', 's3', $kRegion, true);
|
||||
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
|
||||
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
|
||||
$authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $authorization,
|
||||
|
||||
$request = Http::withHeaders([
|
||||
'Authorization' => "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}",
|
||||
'x-amz-content-sha256' => $payloadHash,
|
||||
'x-amz-date' => $amzDate,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Host' => $host,
|
||||
])->withBody($payload, 'application/octet-stream')->put($url);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($driver . ' 上传失败:' . $response->body());
|
||||
]);
|
||||
$url = $endpoint . $canonicalUri . ($canonicalQuery !== '' ? '?' . $canonicalQuery : '');
|
||||
return match (strtoupper($method)) {
|
||||
'PUT' => $request->withBody($payload, 'application/octet-stream')->put($url),
|
||||
'DELETE' => $request->delete($url),
|
||||
default => $request->get($url),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐段编码路径:整段 rawurlencode 会把分隔符 / 也编掉,签名与实际路径就对不上了
|
||||
*/
|
||||
private function canonicalUri(string $path): string
|
||||
{
|
||||
$segments = array_map(
|
||||
static fn ($segment) => rawurlencode($segment),
|
||||
explode('/', ltrim($path, '/'))
|
||||
);
|
||||
return '/' . implode('/', $segments);
|
||||
}
|
||||
|
||||
private function driver(): string
|
||||
{
|
||||
return (string) ($this->config['driver'] ?? 'aws');
|
||||
}
|
||||
|
||||
private function assertConfig(): void
|
||||
{
|
||||
if (trim((string) ($this->config['access_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['secret_key'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['bucket'] ?? '')) === ''
|
||||
|| trim((string) ($this->config['endpoint'] ?? '')) === ''
|
||||
) {
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
strtoupper($this->driver()) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'
|
||||
);
|
||||
}
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
}
|
||||
|
||||
130
app/Service/wx/WxAppService.php
Normal file
130
app/Service/wx/WxAppService.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\common\UtilsService;
|
||||
|
||||
/**
|
||||
* 小程序应用凭证读取
|
||||
*
|
||||
* 请求头 X-App-Code(或参数 app_code)决定用哪个品牌的 AppID。
|
||||
* 没带就用 .env 里 WX_DEFAULT_APP_CODE 兜底;都没有就取唯一启用的那条。
|
||||
* 库表(nl_wx_app)始终是主源,.env 只在「表里完全没有匹配行」时回落兜底,
|
||||
* 这样单品牌部署可以不进后台配置就跑起来,双品牌共用进程仍以表 + 请求头分流为主。
|
||||
*/
|
||||
class WxAppService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前请求对应的应用配置(含解密后的密钥)
|
||||
*
|
||||
* 解析顺序:
|
||||
* 1. 按 code(请求头 → .env 默认值)查 nl_wx_app
|
||||
* 2. 查不到且 .env 配了 AppID:返回仅含 app_id/name/code 的兜底配置,
|
||||
* 密钥仍尝试从表里「按 app_id 匹配」的那行解密;表里也没有就抛错
|
||||
* 指引运维去后台填写——密钥不能从 .env 读,安全红线
|
||||
* 3. .env 也没配:直接抛错
|
||||
*/
|
||||
public function current(): array
|
||||
{
|
||||
$code = $this->currentCode();
|
||||
$query = WxAppModel::where('deleted_at', 0)->where('status', 0);
|
||||
$app = $code !== '' ? $query->where('code', $code)->first() : $query->first();
|
||||
|
||||
// 库表命中:解密密钥列后返回
|
||||
if (!empty($app)) {
|
||||
return $this->loadWithSecrets($app);
|
||||
}
|
||||
|
||||
// 库表未命中,回落 .env(仅 AppID/名称,不含密钥)
|
||||
$envAppId = trim((string) config('nl.wx.env_app_id', ''));
|
||||
if ($envAppId === '') {
|
||||
// .env 没配 AppID 就没法兜底了,直接抛错
|
||||
UtilsService::getInstance()->errorThrow('未配置小程序应用(nl_wx_app),请先在后台添加或在 .env 配 WX_APP_ID');
|
||||
}
|
||||
|
||||
$envCode = trim((string) config('nl.wx.default_app_code', ''));
|
||||
$envName = trim((string) config('nl.wx.env_app_name', ''));
|
||||
|
||||
// 兜底场景:AppID 来自 .env,但密钥仍只能从库里按 AppID 找一行来解密
|
||||
// 这样老项目硬编码密钥的脏习惯不会回到 .env,密钥始终加密入库
|
||||
$secretRow = WxAppModel::where('app_id', $envAppId)
|
||||
->where('deleted_at', 0)
|
||||
->first(['app_secret', 'mch_key', 'mch_private_key']);
|
||||
if (empty($secretRow) || trim((string) ($secretRow['app_secret'] ?? '')) === '') {
|
||||
// 表里既没匹配行、或行里没填密钥,只能报错让运维去后台补
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,新增/编辑 AppID=' .
|
||||
$envAppId .
|
||||
' 的记录并填写 AppSecret(密钥加密入库,不要写进 .env)'
|
||||
);
|
||||
}
|
||||
|
||||
$encrypt = FieldEncryptService::getInstance();
|
||||
return [
|
||||
'id' => 0,
|
||||
'code' => $envCode,
|
||||
'name' => $envName,
|
||||
'app_id' => $envAppId,
|
||||
'app_secret' => $encrypt->decryptFromStorage((string) $secretRow['app_secret'], true),
|
||||
'mch_id' => '',
|
||||
'mch_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_key'] ?? ''), true),
|
||||
'mch_serial_no' => '',
|
||||
'mch_private_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_private_key'] ?? ''), true),
|
||||
'platform_public_key' => '',
|
||||
'notify_url' => '',
|
||||
'template_code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前请求声明的品牌标识
|
||||
*
|
||||
* 优先级:请求头 X-App-Code > 请求参数 app_code > .env 的 WX_DEFAULT_APP_CODE
|
||||
* 全部为空时返回空串,调用方会改走「取唯一启用行」逻辑
|
||||
*/
|
||||
public function currentCode(): string
|
||||
{
|
||||
$code = (string) (request()->header('X-App-Code') ?: request()->input('app_code', ''));
|
||||
$code = trim($code);
|
||||
if ($code !== '') {
|
||||
return $code;
|
||||
}
|
||||
// 请求没带品牌标识时回落 .env 默认值(单品牌部署兜底)
|
||||
return trim((string) config('nl.wx.default_app_code', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模型行的密钥列解密后转数组返回
|
||||
*/
|
||||
private function loadWithSecrets($app): array
|
||||
{
|
||||
$app = $app->toArray();
|
||||
$raw = WxAppModel::where('id', $app['id'])->first(['app_secret', 'mch_key', 'mch_private_key']);
|
||||
$encrypt = FieldEncryptService::getInstance();
|
||||
$secret = $encrypt->decryptFromStorage($raw['app_secret'] ?? '', true);
|
||||
if (trim((string) $secret) === '') {
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,编辑 code=' .
|
||||
($app['code'] ?? '') .
|
||||
'(AppID=' . ($app['app_id'] ?? '') . ')并填写 AppSecret'
|
||||
);
|
||||
}
|
||||
$app['app_secret'] = $secret;
|
||||
$app['mch_key'] = $encrypt->decryptFromStorage($raw['mch_key'] ?? '', true);
|
||||
$app['mch_private_key'] = $encrypt->decryptFromStorage($raw['mch_private_key'] ?? '', true);
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
71
app/Service/wx/WxAuthService.php
Normal file
71
app/Service/wx/WxAuthService.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\SerialNoService;
|
||||
|
||||
/**
|
||||
* 小程序登录
|
||||
*
|
||||
* 返回的 token 是服务端自签的(见 WxTokenService),session_key 只写库不外发。
|
||||
*/
|
||||
class WxAuthService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
private const DEFAULT_AVATAR = 'http://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png';
|
||||
|
||||
/**
|
||||
* code 登录:老用户更新 session_key,新用户建档并送一份默认清单
|
||||
*/
|
||||
public function login(array $params): array
|
||||
{
|
||||
$code = trim((string) ($params['code'] ?? ''));
|
||||
if ($code === '') {
|
||||
$this->utils->errorThrow('缺少 code');
|
||||
}
|
||||
$credentials = WxMiniService::getInstance()->jscode2session($code);
|
||||
$openId = (string) $credentials['openid'];
|
||||
$now = time();
|
||||
|
||||
$user = WxUserModel::where('open_id', $openId)->first();
|
||||
if (empty($user)) {
|
||||
$userId = WxUserModel::insertGetId([
|
||||
'open_id' => $openId,
|
||||
'session_key' => (string) ($credentials['session_key'] ?? ''),
|
||||
'avatar' => $params['avatar'] ?? self::DEFAULT_AVATAR,
|
||||
'nick_name' => $params['nick_name'] ?? '微信用户',
|
||||
'phone' => '',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
if (empty($userId)) {
|
||||
$this->utils->errorThrow('用户创建失败');
|
||||
}
|
||||
ListModel::insert([
|
||||
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
|
||||
'user_id' => $userId,
|
||||
'name' => '默认清单',
|
||||
'remark' => '系统默认生成的清单',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
WxUserModel::where('id', $userId)->update([
|
||||
'session_key' => (string) ($credentials['session_key'] ?? ''),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$userInfo = WxUserModel::where('id', $userId)->with('enterprise')->first();
|
||||
$userInfo = $userInfo ? $userInfo->toArray() : [];
|
||||
unset($userInfo['session_key']);
|
||||
|
||||
return [
|
||||
'token' => WxTokenService::getInstance()->issue((int) $userId, (string) ($credentials['app_code'] ?? '')),
|
||||
'user_info' => $userInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Service/wx/WxHomeService.php
Normal file
37
app/Service/wx/WxHomeService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\CarouselModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
|
||||
/**
|
||||
* 小程序首页:轮播与一级分类,都不要求登录
|
||||
*/
|
||||
class WxHomeService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
public function carousel(): mixed
|
||||
{
|
||||
// 只返回启用中的轮播;后台 status=1 表示隐藏
|
||||
return CarouselModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->orderBy('sort', 'asc')
|
||||
->get(['id', 'url', 'to_path', 'sort']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表,pid=0 为一级
|
||||
* 按 sort 升序,同值按 id(需已执行 05_cc_category_add_sort.sql)
|
||||
*/
|
||||
public function categoryList(int $pid = 0): mixed
|
||||
{
|
||||
return CategoryModel::where('pid', $pid)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('sort', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
215
app/Service/wx/WxListService.php
Normal file
215
app/Service/wx/WxListService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Service\business\PriceService;
|
||||
use App\Service\business\SerialNoService;
|
||||
|
||||
/**
|
||||
* 小程序清单
|
||||
*
|
||||
* 清单是「加购物车」的语义但不含结算,下单由 WxOrderService 从清单快照生成。
|
||||
* 所有写操作都必须带 user_id 条件,否则改个 id 就能删别人的清单——老项目的 deleteItem 就是这个漏洞。
|
||||
*/
|
||||
class WxListService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的清单,带明细数量
|
||||
*
|
||||
* 兼容两种返回:
|
||||
* - 不传 page / pageSize(老调用方):返回全量扁平数组,保持向后兼容;
|
||||
* - 传了 page / pageSize:返回 { items, total, page, pageSize, has_more },供小程序真分页使用。
|
||||
* 判断「是否分页模式」只看请求里有没有 page,避免 pageSize 默认值造成歧义。
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
// 请求里带了 page 才走分页分支;pageSize 缺省 20,与列表页约定一致
|
||||
$page = request()->get('page');
|
||||
$paged = $page !== null && $page !== '';
|
||||
$page = max(1, (int) $page);
|
||||
$pageSize = max(1, (int) (request()->get('pageSize') ?? 20));
|
||||
|
||||
$base = ListModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0)->select(['id', 'list_id']),
|
||||
])->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
// 不分页:一次拿全,保持老结构
|
||||
if (!$paged) {
|
||||
$rows = $base->get();
|
||||
if ($rows->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
$rows = $rows->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['count'] = count($row['items'] ?? []);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// 分页:拿当前页 + 总数,前端按 has_more 决定是否继续 loadMore
|
||||
$total = (clone $base)->count();
|
||||
$rows = $base->forPage($page, $pageSize)->get();
|
||||
$items = $rows->isEmpty() ? [] : $rows->toArray();
|
||||
foreach ($items as &$row) {
|
||||
$row['count'] = count($row['items'] ?? []);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'has_more' => ($page * $pageSize) < $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单详情:带商品与规格,价格按当前用户的可见性与倍率处理
|
||||
*/
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$info = ListModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'items.catalogue',
|
||||
'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('id', $id)
|
||||
->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('清单不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
|
||||
$price = PriceService::getInstance();
|
||||
$showPrice = $this->canSeePrice();
|
||||
$multiplier = $this->priceMultiplier();
|
||||
foreach ($info['items'] as &$item) {
|
||||
$sheet = $item['catalogue']['price_sheet'] ?? [];
|
||||
if (!empty($sheet)) {
|
||||
$applied = $price->applyToRows($sheet, $showPrice, $multiplier);
|
||||
$item['catalogue']['price_sheet'] = $applied['rows'];
|
||||
}
|
||||
// 老前端读的是 product 这个键名,保留别名避免小程序改字段
|
||||
$item['product'] = $item['catalogue'] ?? null;
|
||||
}
|
||||
unset($item);
|
||||
$info['is_show_price'] = $showPrice;
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create(array $params): mixed
|
||||
{
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('请填写清单名称');
|
||||
}
|
||||
return ListModel::insertGetId([
|
||||
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
|
||||
'user_id' => $this->userId,
|
||||
'name' => $name,
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id, array $params): mixed
|
||||
{
|
||||
return ListModel::where('id', $id)->where('user_id', $this->userId)->update([
|
||||
'name' => (string) ($params['name'] ?? ''),
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(array|int $ids): mixed
|
||||
{
|
||||
return ListModel::whereIn('id', (array) $ids)
|
||||
->where('user_id', $this->userId)
|
||||
->update(['deleted_at' => time(), 'updated_at' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删清单明细:先确认这些明细属于当前用户的清单
|
||||
*/
|
||||
public function deleteItem(array|int $ids): mixed
|
||||
{
|
||||
$ids = (array) $ids;
|
||||
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
|
||||
return ListItemModel::whereIn('id', $ids)
|
||||
->whereIn('list_id', $listIds)
|
||||
->update(['deleted_at' => time(), 'updated_at' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入清单;同一清单同一商品同一规格只留一条,重复则累加数量
|
||||
*/
|
||||
public function toCart(array $params): mixed
|
||||
{
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
$catalogueId = (int) ($params['product_id'] ?? $params['catalogue_id'] ?? 0);
|
||||
if ($listId <= 0 || $catalogueId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$list = ListModel::where('id', $listId)->where('user_id', $this->userId)->where('deleted_at', 0)->first();
|
||||
if (empty($list)) {
|
||||
$this->utils->errorThrow('清单不存在');
|
||||
}
|
||||
$priceSheetId = (int) ($params['price_sheet_id'] ?? 0);
|
||||
$quantity = max(1, (int) ($params['quantity'] ?? 1));
|
||||
|
||||
$exists = ListItemModel::where('list_id', $listId)
|
||||
->where('catalogue_id', $catalogueId)
|
||||
->where('price_sheet_id', $priceSheetId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (!empty($exists)) {
|
||||
return ListItemModel::where('id', $exists['id'])->update([
|
||||
'quantity' => (int) $exists['quantity'] + $quantity,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
return ListItemModel::insertGetId([
|
||||
'list_id' => $listId,
|
||||
'catalogue_id' => $catalogueId,
|
||||
'price_sheet_id' => $priceSheetId,
|
||||
'material_key' => (string) ($params['material_key'] ?? 'routine'),
|
||||
'quantity' => $quantity,
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改明细的规格与数量:转订单前用户要能在清单里补齐这些信息
|
||||
*/
|
||||
public function updateItem(array $params): mixed
|
||||
{
|
||||
$itemId = (int) ($params['id'] ?? 0);
|
||||
if ($itemId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
|
||||
$update = ['updated_at' => time()];
|
||||
if (array_key_exists('quantity', $params)) {
|
||||
$update['quantity'] = max(1, (int) $params['quantity']);
|
||||
}
|
||||
if (array_key_exists('price_sheet_id', $params)) {
|
||||
$update['price_sheet_id'] = (int) $params['price_sheet_id'];
|
||||
}
|
||||
if (array_key_exists('material_key', $params)) {
|
||||
$update['material_key'] = (string) $params['material_key'];
|
||||
}
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$update['remark'] = (string) $params['remark'];
|
||||
}
|
||||
return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update);
|
||||
}
|
||||
}
|
||||
94
app/Service/wx/WxMiniService.php
Normal file
94
app/Service/wx/WxMiniService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Service\common\RedisService;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 微信小程序开放接口客户端
|
||||
*
|
||||
* AppID / AppSecret 来自 nl_wx_app(加密存储),不再硬编码在源码里。
|
||||
* access_token 有 7200 秒有效期且并发刷新会互相顶掉,所以缓存进 Redis 复用。
|
||||
*/
|
||||
class WxMiniService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private string $baseUrl = 'https://api.weixin.qq.com/';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* code 换 openid 与 session_key
|
||||
*/
|
||||
public function jscode2session(string $code): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (($app['app_id'] ?? '') === '' || ($app['app_secret'] ?? '') === '') {
|
||||
UtilsService::getInstance()->errorThrow('小程序 AppID/AppSecret 未配置');
|
||||
}
|
||||
$result = Http::asJson()->get($this->baseUrl . 'sns/jscode2session', [
|
||||
'appid' => $app['app_id'],
|
||||
'secret' => $app['app_secret'],
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
])->json();
|
||||
if (!empty($result['errcode'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信登录失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
if (empty($result['openid'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信登录失败:未拿到 openid');
|
||||
}
|
||||
$result['app_code'] = (string) ($app['code'] ?? '');
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号快速验证:前端拿到的 code 换真实号码
|
||||
*/
|
||||
public function getPhoneNumber(string $phoneCode): string
|
||||
{
|
||||
$token = $this->accessToken();
|
||||
$result = Http::asJson()->post(
|
||||
$this->baseUrl . 'wxa/business/getuserphonenumber?access_token=' . $token,
|
||||
['code' => $phoneCode]
|
||||
)->json();
|
||||
if (!empty($result['errcode'])) {
|
||||
UtilsService::getInstance()->errorThrow('获取手机号失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
return (string) ($result['phone_info']['phoneNumber'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口调用凭证,按 appid 缓存
|
||||
*/
|
||||
public function accessToken(): string
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
$redis = RedisService::getInstance()->init(config('nl.redis.wechat_token'));
|
||||
$cached = $redis->get($app['app_id']);
|
||||
if (!empty($cached)) {
|
||||
return (string) $cached;
|
||||
}
|
||||
$result = Http::asJson()->get($this->baseUrl . 'cgi-bin/token', [
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $app['app_id'],
|
||||
'secret' => $app['app_secret'],
|
||||
])->json();
|
||||
if (empty($result['access_token'])) {
|
||||
UtilsService::getInstance()->errorThrow('获取 access_token 失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
// 提前 300 秒过期,避免临界点上拿到即将失效的 token
|
||||
$redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300));
|
||||
return (string) $result['access_token'];
|
||||
}
|
||||
}
|
||||
112
app/Service/wx/WxOrderService.php
Normal file
112
app/Service/wx/WxOrderService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\OrderCoreService;
|
||||
use App\Service\business\PriceService;
|
||||
|
||||
/**
|
||||
* 小程序订单:用户自己从清单下单、看订单、传凭证或调起微信支付
|
||||
*/
|
||||
class WxOrderService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的订单,可按状态筛
|
||||
*
|
||||
* 金额 text 必须在这里补:OrderCoreService::detail 才格式化金额,list 只走 paginate,
|
||||
* 而小程序订单列表卡片直接渲染 total_amount_text,不补就会出现 ¥0。
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$status = request()->get('status');
|
||||
$result = OrderModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->when($status !== null && $status !== '', fn ($query) => $query->where('status', (int) $status))
|
||||
->orderBy('id', 'desc')
|
||||
->paginate((int) request()->get('pageSize', 10))
|
||||
->toArray();
|
||||
// 复用后台同款格式化,保证两端金额展示一致
|
||||
$price = PriceService::getInstance();
|
||||
foreach ($result['data'] as &$item) {
|
||||
$item['total_amount_text'] = $price->centsToYuan((int) ($item['total_amount'] ?? 0));
|
||||
$item['paid_amount_text'] = $price->centsToYuan((int) ($item['paid_amount'] ?? 0));
|
||||
}
|
||||
unset($item);
|
||||
return [
|
||||
'page' => $result['current_page'],
|
||||
'size' => $result['per_page'],
|
||||
'page_count' => $result['last_page'],
|
||||
'total' => $result['total'],
|
||||
'items' => $result['data'],
|
||||
];
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$order = OrderCoreService::getInstance()->detail($id);
|
||||
if ((int) $order['user_id'] !== $this->userId) {
|
||||
$this->utils->errorThrow('无权查看该订单');
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单转订单
|
||||
*/
|
||||
public function createFromList(array $params): array
|
||||
{
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
if ($listId <= 0) {
|
||||
$this->utils->errorThrow('请选择清单');
|
||||
}
|
||||
return OrderCoreService::getInstance()->createFromList($listId, $this->userId, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传转账凭证
|
||||
*/
|
||||
public function submitVoucher(array $params): array
|
||||
{
|
||||
$orderId = (int) ($params['order_id'] ?? 0);
|
||||
return OrderCoreService::getInstance()->submitVoucher($orderId, $params, $this->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起微信支付
|
||||
*/
|
||||
public function wechatPay(array $params): array
|
||||
{
|
||||
$orderId = (int) ($params['order_id'] ?? 0);
|
||||
$order = $this->detail($orderId);
|
||||
$user = WxUserModel::where('id', $this->userId)->first(['open_id']);
|
||||
if (empty($user['open_id'])) {
|
||||
$this->utils->errorThrow('缺少 openid,请重新登录');
|
||||
}
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return WxPayService::getInstance()->jsapiOrder($order, (string) $user['open_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单(仅未付款可取消)
|
||||
*/
|
||||
public function cancel(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED, $this->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认收货
|
||||
*/
|
||||
public function complete(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE, $this->userId);
|
||||
}
|
||||
}
|
||||
221
app/Service/wx/WxPayService.php
Normal file
221
app/Service/wx/WxPayService.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\OrderPaymentModel;
|
||||
use App\Service\business\OrderCoreService;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 微信支付 JSAPI(APIv3)
|
||||
*
|
||||
* 本期只把流程写完,商户号可以留空:configured() 为假时下单接口直接给出明确提示,
|
||||
* 不会半截调用微信。等拿到商户资料,只需在后台补齐 nl_wx_app 的 mch_* 字段即可启用。
|
||||
*
|
||||
* 三个不能省的点:金额单位是分(微信也用分,正好不用换算);
|
||||
* 回调必须验签;回调必须幂等(靠 out_trade_no 唯一索引 + 状态判断)。
|
||||
*/
|
||||
class WxPayService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private string $baseUrl = 'https://api.mch.weixin.qq.com';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户资料是否齐全
|
||||
*/
|
||||
public function configured(array $app): bool
|
||||
{
|
||||
return ($app['mch_id'] ?? '') !== ''
|
||||
&& ($app['mch_serial_no'] ?? '') !== ''
|
||||
&& ($app['mch_private_key'] ?? '') !== ''
|
||||
&& ($app['notify_url'] ?? '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* JSAPI 下单,返回小程序 wx.requestPayment 所需参数
|
||||
*
|
||||
* @param array $order 订单行
|
||||
* @param string $openId 支付用户的 openid
|
||||
*/
|
||||
public function jsapiOrder(array $order, string $openId): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (!$this->configured($app)) {
|
||||
UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付');
|
||||
}
|
||||
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
|
||||
if ($amount <= 0) {
|
||||
UtilsService::getInstance()->errorThrow('该订单无需支付');
|
||||
}
|
||||
// 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no,
|
||||
// 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账
|
||||
$outTradeNo = 'WX' . $order['id'] . 'T' . time();
|
||||
OrderPaymentModel::insert([
|
||||
'order_id' => (int) $order['id'],
|
||||
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
|
||||
'amount' => $amount,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'status' => OrderPaymentModel::STATUS_AUDITING,
|
||||
'created_at' => time(),
|
||||
]);
|
||||
|
||||
$body = [
|
||||
'appid' => $app['app_id'],
|
||||
'mchid' => $app['mch_id'],
|
||||
'description' => '订单 ' . $order['order_no'],
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $app['notify_url'],
|
||||
'amount' => ['total' => $amount, 'currency' => 'CNY'],
|
||||
'payer' => ['openid' => $openId],
|
||||
];
|
||||
$path = '/v3/pay/transactions/jsapi';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $this->authorization('POST', $path, $payload, $app),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
])->withBody($payload, 'application/json')->post($this->baseUrl . $path);
|
||||
$result = $response->json();
|
||||
if (empty($result['prepay_id'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body()));
|
||||
}
|
||||
return $this->buildPayParams($app, (string) $result['prepay_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
*
|
||||
* @param array $headers Wechatpay-* 头
|
||||
* @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败)
|
||||
*/
|
||||
public function handleNotify(array $headers, string $rawBody): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (!$this->verifySignature($headers, $rawBody, $app)) {
|
||||
return ['code' => 'FAIL', 'message' => '验签失败'];
|
||||
}
|
||||
$body = json_decode($rawBody, true) ?: [];
|
||||
$resource = $body['resource'] ?? [];
|
||||
$plain = $this->decryptResource($resource, (string) $app['mch_key']);
|
||||
if (empty($plain['out_trade_no'])) {
|
||||
return ['code' => 'FAIL', 'message' => '报文缺少订单号'];
|
||||
}
|
||||
if (($plain['trade_state'] ?? '') !== 'SUCCESS') {
|
||||
// 非成功态直接应答成功,避免微信持续重推
|
||||
return ['code' => 'SUCCESS', 'message' => 'OK'];
|
||||
}
|
||||
OrderCoreService::getInstance()->confirmWechatPay(
|
||||
(string) $plain['out_trade_no'],
|
||||
(string) ($plain['transaction_id'] ?? ''),
|
||||
(int) ($plain['amount']['total'] ?? 0)
|
||||
);
|
||||
return ['code' => 'SUCCESS', 'message' => 'OK'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序端调起支付的签名参数
|
||||
*/
|
||||
private function buildPayParams(array $app, string $prepayId): array
|
||||
{
|
||||
$timestamp = (string) time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$package = 'prepay_id=' . $prepayId;
|
||||
$message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n";
|
||||
return [
|
||||
'appId' => $app['app_id'],
|
||||
'timeStamp' => $timestamp,
|
||||
'nonceStr' => $nonce,
|
||||
'package' => $package,
|
||||
'signType' => 'RSA',
|
||||
'paySign' => $this->sign($message, (string) $app['mch_private_key']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* APIv3 Authorization 头
|
||||
*/
|
||||
private function authorization(string $method, string $path, string $body, array $app): string
|
||||
{
|
||||
$timestamp = time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
$signature = $this->sign($message, (string) $app['mch_private_key']);
|
||||
return sprintf(
|
||||
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
|
||||
$app['mch_id'],
|
||||
$nonce,
|
||||
$timestamp,
|
||||
$app['mch_serial_no'],
|
||||
$signature
|
||||
);
|
||||
}
|
||||
|
||||
private function sign(string $message, string $privateKey): string
|
||||
{
|
||||
$key = openssl_pkey_get_private($privateKey);
|
||||
if ($key === false) {
|
||||
UtilsService::getInstance()->errorThrow('商户私钥无效');
|
||||
}
|
||||
openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption');
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调验签
|
||||
*
|
||||
* 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。
|
||||
*/
|
||||
private function verifySignature(array $headers, string $rawBody, array $app): bool
|
||||
{
|
||||
$publicKey = (string) ($app['platform_public_key'] ?? '');
|
||||
if ($publicKey === '') {
|
||||
return false;
|
||||
}
|
||||
$timestamp = (string) ($headers['wechatpay-timestamp'] ?? '');
|
||||
$nonce = (string) ($headers['wechatpay-nonce'] ?? '');
|
||||
$signature = (string) ($headers['wechatpay-signature'] ?? '');
|
||||
if ($timestamp === '' || $nonce === '' || $signature === '') {
|
||||
return false;
|
||||
}
|
||||
// 时间戳偏移超过 5 分钟视为重放
|
||||
if (abs(time() - (int) $timestamp) > 300) {
|
||||
return false;
|
||||
}
|
||||
$message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n";
|
||||
$key = openssl_pkey_get_public($publicKey);
|
||||
if ($key === false) {
|
||||
return false;
|
||||
}
|
||||
return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密回调报文(AEAD_AES_256_GCM)
|
||||
*/
|
||||
private function decryptResource(array $resource, string $apiV3Key): array
|
||||
{
|
||||
$ciphertext = base64_decode((string) ($resource['ciphertext'] ?? ''));
|
||||
$nonce = (string) ($resource['nonce'] ?? '');
|
||||
$associated = (string) ($resource['associated_data'] ?? '');
|
||||
if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') {
|
||||
return [];
|
||||
}
|
||||
$tagLength = 16;
|
||||
$tag = substr($ciphertext, -$tagLength);
|
||||
$data = substr($ciphertext, 0, -$tagLength);
|
||||
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated);
|
||||
return $plain === false ? [] : (json_decode($plain, true) ?: []);
|
||||
}
|
||||
}
|
||||
125
app/Service/wx/WxProductService.php
Normal file
125
app/Service/wx/WxProductService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
use App\Models\business\ImageModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\PriceService;
|
||||
|
||||
/**
|
||||
* 小程序商品列表与详情
|
||||
*
|
||||
* 价格倍率与可见性统一走 PriceService,后台与小程序共用同一套规则,
|
||||
* 避免出现「后台看原价、小程序看倍率价」对不上账的老问题。
|
||||
*/
|
||||
class WxProductService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 商品列表:分类点到二级时按父分类下的所有子分类查
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$title = (string) request()->get('title', '');
|
||||
$categoryId = request()->get('category_id');
|
||||
$childIds = [];
|
||||
if (!empty($categoryId)) {
|
||||
$childIds = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all();
|
||||
}
|
||||
|
||||
// 小程序前端发的是 size(见 pages/product|search getProductList),老接口曾用 pageSize,
|
||||
// 这里两者都接,避免改一边漏一边
|
||||
$pageSize = (int) (request()->get('pageSize') ?: request()->get('size') ?: 10);
|
||||
|
||||
$result = CatalogueModel::when($title !== '', function ($query) use ($title) {
|
||||
$query->where('title', 'like', '%' . $title . '%');
|
||||
})->when(!empty($categoryId) && empty($childIds), function ($query) use ($categoryId) {
|
||||
$query->where('category_id', $categoryId);
|
||||
})->when(!empty($childIds), function ($query) use ($childIds) {
|
||||
$query->whereIn('category_id', $childIds);
|
||||
})->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'page' => $result['current_page'],
|
||||
'size' => $result['per_page'],
|
||||
'page_count' => $result['last_page'],
|
||||
// 小程序 productWaterfall / product.vue 读的是 last_page(不是 page_count),两个键都给
|
||||
'last_page' => $result['last_page'],
|
||||
'total' => $result['total'],
|
||||
'items' => $result['data'],
|
||||
'data' => $result['data'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品详情
|
||||
*
|
||||
* 带 p_user_id 说明是代理商分享进来的:普通新用户会被绑到该代理商名下并继承其倍率,
|
||||
* 这是老项目的既有行为,不能改,否则代理商体系的价格会全部失效。
|
||||
*/
|
||||
public function detail(int $id, int $shareUserId = 0): array
|
||||
{
|
||||
if ($shareUserId > 0) {
|
||||
$this->inheritAgentPrice($shareUserId);
|
||||
}
|
||||
|
||||
$info = CatalogueModel::with([
|
||||
'category',
|
||||
'images' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'priceSheet' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('deleted_at', 0)->find($id);
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('商品不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
|
||||
// 渲染图作为详情轮播
|
||||
$info['carousel'] = [];
|
||||
foreach ($info['images'] ?? [] as $image) {
|
||||
if ((int) $image['type'] === ImageModel::TYPE_RENDER) {
|
||||
$info['carousel'][] = $image['url'];
|
||||
}
|
||||
}
|
||||
|
||||
$showPrice = $this->canSeePrice();
|
||||
$applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier());
|
||||
$info['price_sheet'] = $applied['rows'];
|
||||
$info['is_show_price'] = $applied['is_show_price'];
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 继承分享代理商的倍率
|
||||
*
|
||||
* 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率,
|
||||
* 代理商改了倍率下级要跟着变。
|
||||
*/
|
||||
private function inheritAgentPrice(int $shareUserId): void
|
||||
{
|
||||
$isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1;
|
||||
$pid = (int) ($this->userInfo['pid'] ?? 0);
|
||||
if ($isAgent) {
|
||||
return;
|
||||
}
|
||||
if ($pid !== 0 && $pid !== $shareUserId) {
|
||||
return;
|
||||
}
|
||||
$agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first();
|
||||
if (empty($agent) || (int) $agent['is_p'] !== 1) {
|
||||
return;
|
||||
}
|
||||
$this->userInfo['show_price'] = 1;
|
||||
$this->userInfo['price_number'] = $agent['price_number'];
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'pid' => $shareUserId,
|
||||
'show_price' => 1,
|
||||
'price_number' => $agent['price_number'],
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
109
app/Service/wx/WxThemeService.php
Normal file
109
app/Service/wx/WxThemeService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\WxTemplateModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\business\WxTemplatePresetService;
|
||||
use App\Service\business\WxTemplateSchemaService;
|
||||
|
||||
/**
|
||||
* 小程序主题下发(免授权)
|
||||
*
|
||||
* 小程序启动时拉一次并缓存本地,拿不到就用内置兜底主题——
|
||||
* 主题接口挂了不能导致小程序白屏。
|
||||
*/
|
||||
class WxThemeService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
/**
|
||||
* 当前生效主题
|
||||
*
|
||||
* 优先级:显式 code > 登录经销商专属 template_code > 品牌配置 template_code
|
||||
* > 该品牌默认模板 > 通用默认模板 > 内置预设
|
||||
*/
|
||||
public function current(string $code = ''): array
|
||||
{
|
||||
$appCode = WxAppService::getInstance()->currentCode();
|
||||
$template = null;
|
||||
|
||||
if ($code !== '') {
|
||||
$template = WxTemplateModel::where('code', $code)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
// 已登录经销商:可用专属模板覆盖品牌默认(未登录则 userInfo 为空,跳过)
|
||||
if (empty($template) && (int) ($this->userInfo['is_p'] ?? 0) === 1) {
|
||||
$userCode = trim((string) ($this->userInfo['template_code'] ?? ''));
|
||||
if ($userCode === '' && !empty($this->userInfo['id'])) {
|
||||
$userCode = (string) (WxUserModel::where('id', $this->userInfo['id'])
|
||||
->where('deleted_at', 0)
|
||||
->value('template_code') ?? '');
|
||||
}
|
||||
if ($userCode !== '') {
|
||||
$template = WxTemplateModel::where('code', $userCode)
|
||||
->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
}
|
||||
if (empty($template) && $appCode !== '') {
|
||||
$bound = (string) (WxAppModel::where('code', $appCode)
|
||||
->where('deleted_at', 0)
|
||||
->value('template_code') ?? '');
|
||||
if ($bound !== '') {
|
||||
$template = WxTemplateModel::where('code', $bound)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
}
|
||||
if (empty($template) && $appCode !== '') {
|
||||
$template = WxTemplateModel::where('app_code', $appCode)
|
||||
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
if (empty($template)) {
|
||||
$template = WxTemplateModel::where('app_code', '')
|
||||
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
if (empty($template)) {
|
||||
$preset = WxTemplatePresetService::getInstance()->all()[0] ?? [];
|
||||
$tokens = $preset['tokens'] ?? [];
|
||||
$layout = $preset['layout'] ?? [];
|
||||
return [
|
||||
'code' => (string) ($preset['code'] ?? 'lux-champagne'),
|
||||
'name' => (string) ($preset['name'] ?? '轻奢·香槟金'),
|
||||
'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'),
|
||||
'version' => 0,
|
||||
'tokens' => $tokens,
|
||||
'layout' => $layout,
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$template = $template->toArray();
|
||||
$tokens = is_array($template['tokens']) ? $template['tokens'] : [];
|
||||
return [
|
||||
'code' => (string) $template['code'],
|
||||
'name' => (string) $template['name'],
|
||||
'style_tag' => (string) $template['style_tag'],
|
||||
'version' => (int) $template['version'],
|
||||
'tokens' => $tokens,
|
||||
'layout' => is_array($template['layout']) ? $template['layout'] : [],
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选风格列表,用于小程序里让用户自己换肤
|
||||
*/
|
||||
public function gallery(): mixed
|
||||
{
|
||||
$appCode = WxAppService::getInstance()->currentCode();
|
||||
return WxTemplateModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->when($appCode !== '', fn ($query) => $query->whereIn('app_code', ['', $appCode]))
|
||||
->orderBy('sort', 'asc')
|
||||
->get(['code', 'name', 'style_tag', 'preview']);
|
||||
}
|
||||
}
|
||||
104
app/Service/wx/WxTokenService.php
Normal file
104
app/Service/wx/WxTokenService.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\UtilsService;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
/**
|
||||
* 小程序用户令牌
|
||||
*
|
||||
* 老 lgp-wx-api 把微信的 session_key 当 token 直接返回客户端:
|
||||
* 它既是解密用户数据的密钥,又永不过期,泄露等于长期冒用身份。
|
||||
* 这里改成服务端自签 JWT,session_key 只留库里。
|
||||
*
|
||||
* payload 带 scope=wx,与后台管理端的 token 互不通用——
|
||||
* 否则一个小程序 token 就能打后台接口。
|
||||
*/
|
||||
class WxTokenService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private const SCOPE = 'wx';
|
||||
|
||||
private string $secretKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$secret = (string) config('nl.jwt.secret', '');
|
||||
// 与后台同一个密钥但 scope 不同,验证时强制校验 scope,不存在越权互通
|
||||
$this->secretKey = strlen($secret) >= 32 ? $secret : hash('sha256', $secret !== '' ? $secret : 'nl_wx_jwt_fallback');
|
||||
}
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发令牌
|
||||
*/
|
||||
public function issue(int $userId, string $appCode = ''): string
|
||||
{
|
||||
$now = time();
|
||||
return JWT::encode([
|
||||
'iat' => $now,
|
||||
'exp' => $now + (int) config('nl.wx.token_ttl', 30 * 24 * 3600),
|
||||
'scope' => self::SCOPE,
|
||||
'uid' => $userId,
|
||||
'app' => $appCode,
|
||||
], $this->secretKey, 'HS256');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验令牌并返回用户;失败返回 null 由中间件统一响应
|
||||
*/
|
||||
public function resolveUser(?string $token): ?array
|
||||
{
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$payload = JWT::decode($token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
if (($payload->scope ?? '') !== self::SCOPE) {
|
||||
return null;
|
||||
}
|
||||
$userId = (int) ($payload->uid ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
// 每次请求回查用户:倍率、价格可见性、是否被停用都可能刚被后台改过
|
||||
$user = WxUserModel::where('id', $userId)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
return null;
|
||||
}
|
||||
$user = $user->toArray();
|
||||
unset($user['session_key']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前请求的小程序用户,取不到直接中断
|
||||
*/
|
||||
public function requireUser(): array
|
||||
{
|
||||
$user = request()->attributes->get('wx_user');
|
||||
if (!empty($user)) {
|
||||
return $user;
|
||||
}
|
||||
$user = $this->resolveUser(request()->bearerToken());
|
||||
if (empty($user)) {
|
||||
UtilsService::getInstance()->notAuth('请先登录');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
70
app/Service/wx/WxUploadService.php
Normal file
70
app/Service/wx/WxUploadService.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\FileModel;
|
||||
use App\Service\common\oss\OssRuntimeConfigService;
|
||||
use App\Service\common\oss\OssStorageFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 小程序上传入口(转账凭证等)。
|
||||
*
|
||||
* 不直接复用后台 UploadService:后者继承 BaseService,构造期会解后台 JWT,
|
||||
* 在 wx 组里走 BaseService 等于拿不到小程序身份、还会触发后台 token 校验。
|
||||
* 这里只复用底层工厂 OssRuntimeConfigService / OssStorageFactory,
|
||||
* 保证存储实现与后台一致、身份走 BaseWxService 的 cc_wx_user。
|
||||
*/
|
||||
class WxUploadService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 上传图片到当前启用的 OSS / 本地存储。
|
||||
*
|
||||
* 返回结构与后台 UploadService::uploadImage 对齐(url 键),
|
||||
* 小程序拿到 url 后塞进 voucher 字段,再调 wx/order/voucher 提交。
|
||||
*
|
||||
* @param mixed $file UploadedFile
|
||||
*/
|
||||
public function uploadImage($file): array|bool
|
||||
{
|
||||
if (empty($file)) {
|
||||
$this->utils->errorThrow('请选择图片');
|
||||
}
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
// 单独走 wx 命名空间避免与后台素材库扫描混目录
|
||||
$key = 'wx/voucher/' . date('Ymd') . '/' . 'wx_' . Str::random() . uniqid() . '.' . $ext;
|
||||
$storage = $this->resolveStorage();
|
||||
$result = $storage->uploadVideo($file, $key);
|
||||
if (!$result) {
|
||||
$this->utils->errorThrow('图片上传失败');
|
||||
}
|
||||
// 落一份上传流水到 nl_file,type=image,方便后台审计小程序上传的凭证
|
||||
FileModel::insert([
|
||||
'user_id' => $this->userId,
|
||||
'url' => $result['url'],
|
||||
'type' => FileModel::TYPE_IMAGE,
|
||||
'source' => FileModel::SOURCE_UPLOAD,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前启用的存储实现;配置异常时回退本地,避免整站上传不可用
|
||||
*/
|
||||
private function resolveStorage()
|
||||
{
|
||||
try {
|
||||
$config = OssRuntimeConfigService::getInstance()->getActiveConfig();
|
||||
return OssStorageFactory::getInstance()->make($config);
|
||||
} catch (\Throwable $e) {
|
||||
return OssStorageFactory::getInstance()->make([
|
||||
'driver' => 'local',
|
||||
'path_prefix' => 'uploads',
|
||||
'domain' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Service/wx/WxUserCenterService.php
Normal file
67
app/Service/wx/WxUserCenterService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
|
||||
/**
|
||||
* 小程序「我的」
|
||||
*/
|
||||
class WxUserCenterService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的信息 + 清单/订单计数
|
||||
*/
|
||||
public function myInfo(): array
|
||||
{
|
||||
$listCount = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
|
||||
$orderCount = OrderModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
|
||||
$user = WxUserModel::with('enterprise')->where('id', $this->userId)->first();
|
||||
$user = $user ? $user->toArray() : $this->userInfo;
|
||||
unset($user['session_key']);
|
||||
return [
|
||||
'count' => $listCount,
|
||||
'order_count' => $orderCount,
|
||||
'user' => $user,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
*
|
||||
* 优先用微信手机号快速验证的 code 换真实号码(用户填的可能是假号);
|
||||
* 只有客户端拿不到 code 时才退回直接写入。
|
||||
*/
|
||||
public function bandPhone(array $params): array
|
||||
{
|
||||
$phone = trim((string) ($params['phone'] ?? ''));
|
||||
$phoneCode = trim((string) ($params['phone_code'] ?? ''));
|
||||
if ($phoneCode !== '') {
|
||||
$phone = WxMiniService::getInstance()->getPhoneNumber($phoneCode);
|
||||
}
|
||||
if ($phone === '') {
|
||||
$this->utils->errorThrow('手机号不能为空');
|
||||
}
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'phone' => $phone,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return ['phone' => $phone];
|
||||
}
|
||||
|
||||
public function updateNickName(string $nickName): array
|
||||
{
|
||||
$nickName = trim($nickName);
|
||||
if ($nickName === '') {
|
||||
$this->utils->errorThrow('昵称不能为空');
|
||||
}
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'nick_name' => $nickName,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return ['nick_name' => $nickName];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user