初始化

缺陷:主题配色需要优化整体的同风格
This commit is contained in:
2026-08-14 23:21:21 +08:00
parent 6e2769c528
commit bcf54c2727
152 changed files with 13521 additions and 141 deletions

View File

@@ -19,6 +19,15 @@ alwaysApply: true
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(本仓库为 API此项约束 uniapp
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
# 数据库变更(强制 SQL禁止新建 PHP Migration
- **禁止**为业务/系统表 DDL 新建 `database/migrations/*.php`Laravel migrate 不作变更载体)
- **一律**写到 `database/sql/<YYYY-MM-DD>/`,文件名:`01_简短英文名.sql`、`02_...`(当天序号递增)
- 例:`database/sql/2026-08-14/01_cc_carousel_add_status.sql`
- 脚本尽量幂等(`information_schema` + `PREPARE`,风格对齐已有 `rbac_migrate_ddl.sql`
- 系统表结构若影响全新安装,同步回写 `public/nl_admin.sql`;业务表 `cc_*` 只放 `database/sql/`,不进 `nl_admin.sql`
- 运维在目标库手动执行对应日期目录下的 SQL不要指望 `php artisan migrate` 补列/补表
# 全局架构规范
## 分层架构Controller → Service → Model禁止跨层

View File

@@ -0,0 +1,40 @@
---
description: LGP 新后端开发约定与全项目群已知陷阱
alwaysApply: true
---
# 开发约定与已知陷阱
## 新后端 lgp-admin-plus-api
- 路由由 `UtilsService::autoRouteRegister()` **反射注册**,继承自 `BaseController` 的方法也会被注册成路由。不需要暴露的继承方法必须用 `@Method NO` 覆盖,否则每个控制器白送一套 `create/update/delete`
- 方法名 camelCase 会转成 kebab-case 路径:`myInfo` → `my-info`
- 缺 `@Method` 注解会注册成 `ANY`,务必显式写 `@Method GET` 或 `@Method POST`
- 响应统一走 `jok` / `jerr`,结构固定 `{code, message, result}`,成功 `code=0`。前端拦截器按 `result` 取数据,**不要用 `data`**
- 业务表 Model 覆盖 `$connection = 'business'`;系统表沿用默认 `mysql`
- 免鉴权 Service 继承 `BaseNotAuthService`,不要像老项目那样硬编码路径白名单
- 鉴权真正发生在 `BaseService::__construct`,绕过 BaseService 的方法就没有鉴权。新代码一律走 BaseService
- `nl_menu` 的 `keep_alive` 与 `affix_tab` 输出到 meta 时是**取反**的,插数据别弄反
- 第三方凭据用 `FieldEncryptService` 加密入库,不回显明文
- **库表变更禁止新建 PHP Migration**:写 `database/sql/<YYYY-MM-DD>/01_名字.sql`(当天序号递增、尽量幂等);系统表同步 `public/nl_admin.sql`,业务表 `cc_*` 只放 sql 目录
## 老项目只读
`lgp-api` / `lgp-wx-api` / `lgp-vben` / `lgp-vben-new` 处于迁移期,除安全修复外不要改动。
查业务逻辑可以读,新功能一律写在 `lgp-admin-plus-api` + `lgp-admin-plus`。
## 已知陷阱
- 老 `lgp-wx-api` 把微信 `session_key` 当 token 直接返回客户端,且 `AuthMiddleware` 先执行控制器再检查 token、只判空不验签。归并时必须改成服务端自签 token
- 老 `lgp-api` 密码是**无盐 sha1**,新后端是 bcrypt靠 `nl_admin.legacy_password` 惰性升级,遗留列有过期时间
- 老 `cc_role.id=1` 不是超管,而新 `nl_role.id=1` 是超管且代码硬判断全量放行。角色迁移必须做 ID 偏移
- 老前端 10 个模块的批量删除在静默 404后端只有 `template` 注册了 `delete-batch`
- `cc_price_sheet` 的 6 个材质列是死 schema`create()` 从不赋值、前端已注释,实际只有 `routine` 在用
- 图片水印不是后端接口,是前端拼 OSS URL 参数 `?watermark/2/text/...`
- 小程序 `baseURL` 硬编码在 `api/interceptor.js` 且靠注释切换,极易把测试地址发上线
- 小程序无分包配置uview-plus 全量进主包
## 安全红线
不要把任何密钥写进源码。老项目里七牛 AK/SK`UploadService.php`)、微信 AppSecret`WeChatService.php`)明文硬编码,
`.env.example` 里还提交了真实数据库密码——这些是待清理的历史欠账,不要照抄这个做法。

View File

@@ -0,0 +1,46 @@
---
description: LGP 项目群架构、各仓库角色与双品牌对应关系
alwaysApply: true
---
# LGP 项目地图
两个家具品牌共用一套代码,正从老架构迁往新架构。
## 新架构(开发主战场)
- `lgp-admin-plus` — 后台前端vben **5.7.0** monorepo主应用 `apps/web-antd`,上游是自研 `nl-admin-view`
- `lgp-admin-plus-api` — 后端,**Laravel 13**,位于 `sites/lgp-api/index/lgp-admin-plus-api`
## 老架构(迁移期只读,迁完归档)
- `lgp-vben` — 老后台前端vben 2.11.5
- `lgp-api` — 老后台后端Laravel 10
- `lgp-wx-api` — 老小程序后端Laravel 12本期归并进新后端的 `wx` 路由组
- `lgp-vben-new` — **已弃用**,与 `lgp-vben` 同源克隆,不要在里面做任何改动
## 小程序uni-app + Vue3 + uview-plus
- `lgp-wx` — 佛山铂尔曼AppID `wx679d36842570cea7`API `brm.wx.api.borman.top`
- `lgp-wx-new` — 维伦家具AppID `wx57b54060a579c31a`API `wx.api.borman.top`
两者页面与业务代码基本一致,差异只有 AppID、`baseURL` 与首页视觉。改一边通常要同步另一边。
## 双品牌
同一套后台代码部署两份,靠构建模式区分:`build:brm`(佛山铂尔曼)/ `build:wl`(维伦家具)。
两个品牌共用一套代码与数据库,任何业务改动会同时影响两家客户,上线前按品牌分别回归。
## 数据库
同一个库、两个连接(`config/database.php`
- `mysql`,前缀 `nl_` — 脚手架系统表admin / role / menu / oss_config / file / api_endpoint 等)
- `business`,前缀 `cc_` — 业务表catalogue / category / image / price_sheet / list / wx_user / enterprise 等)
业务数据零迁移,只迁账号与角色。业务 Model 必须覆盖 `protected $connection = 'business'`。
## 迁移范围
老后端注册 171 条路由但前端只调用 **79** 条;老小程序后端注册 35 条但小程序只调用 **14** 条。
迁移目标是这 93 条,其余是反射自动注册出来的死路由,不要跟着迁。

View File

@@ -19,7 +19,9 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# 新的数据库
# 数据库:同一个库两个连接
# mysql 前缀 DB_PREFIX → 脚手架系统表
# business 前缀 DB_BUSINESS_PREFIX → 业务表Model 继承 BaseBusinessModel
DB_CONNECTION=mysql
DB_HOST=mysql8
DB_PORT=3306
@@ -27,6 +29,7 @@ DB_DATABASE=cc_admin
DB_USERNAME=root
DB_PASSWORD=root
DB_PREFIX=nl_
DB_BUSINESS_PREFIX=cc_
SESSION_DRIVER=file
SESSION_LIFETIME=120
@@ -88,3 +91,19 @@ JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b
AI_DEFAULT_PROVIDER=deepseek
SPARK_API_PASSWORD=
DEEPSEEK_API_KEY=
# 小程序品牌声明(单品牌部署兜底用)
# 双品牌共用一份部署时,仍以 nl_wx_app 表 + 请求头 X-App-Code 分流为主
# AppSecret 不写这里,必须经后台「小程序应用配置」加密入库
WX_DEFAULT_APP_CODE=
WX_APP_ID=
WX_APP_NAME=
# weilun
# $this->appId = 'wx57b54060a579c31a';
# $this->appSecret = 'b1b1852d6c438893bfd2cc1cdd5114dc';
# boerman
# $this->appId = 'wx679d36842570cea7';
# $this->appSecret = '4f2bcb8ca2ed1aaa29ae82ce270338e3';

View File

@@ -18,3 +18,22 @@ php artisan sql:run-nl-admin --force
# 创建新数据库
php artisan sql:run-nl-admin --database=my_new_db
```
### 合并数据使用示例
```cmd
# 1. 补列(解除 my-info Unknown column department_id
# 在 Navicat 选中库 cc_new_stash执行 database/sql/rbac_migrate_ddl.sql
# 或mysql ... cc_new_stash < database/sql/rbac_migrate_ddl.sql
# 2. 迁移菜单 / 同步路由
php artisan lgp:menu-sync
php artisan lgp:endpoint-sync
# 3. 账号角色体检(只读)
php artisan lgp:rbac-audit
# 等价 SQLdatabase/sql/rbac_audit.sql
# 4. 账号角色迁移(有事务/ID 偏移/幂等,仍走 artisan
php artisan lgp:rbac-migrate --dry-run
php artisan lgp:rbac-migrate --force
```

View File

@@ -0,0 +1,15 @@
<?php
namespace App\BaseApp;
/**
* 业务表模型基类
*
* 业务表与系统表同库不同前缀(系统表 nl_ / 业务表 cc_
* 故业务表走独立的 business 连接。所有 cc_* 表的 Model 都应继承本类而不是 BaseModel。
* 代码生成器产出的 Model 默认继承 BaseModel用于业务表时需手工改成本类。
*/
class BaseBusinessModel extends BaseModel
{
protected $connection = 'business';
}

View File

@@ -14,6 +14,17 @@ class BaseController
protected array $notRequest = [];
protected array $updateField = [];
protected $service;
/**
* @var array<int, string> 不注册成路由的方法名
*
* autoRouteRegister 是反射整个类,继承来的方法也会被注册,
* 于是每个控制器都白得一套 list/option/detail/create/update/delete。
* 对不该有 CRUD 的控制器(比如登录),在子类里声明要排除的方法即可,
* 不必为了挂 @Method NO 去空覆写六个方法。
*/
protected array $exceptRoute = [];
public function __construct()
{
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\BaseApp;
use App\Service\common\UtilsService;
use App\Service\wx\WxTokenService;
/**
* 小程序业务基类
*
* BaseService 的区别只在身份来源:后台是 nl_admin小程序是 cc_wx_user。
* 分页与查询能力复用 BaseNotAuthService但不走它的构造那里解的是后台 token
*/
class BaseWxService extends BaseNotAuthService
{
/**
* @var bool 是否要求已登录
*/
protected bool $needLogin = true;
public function __construct()
{
$this->utils = UtilsService::getInstance();
$token = WxTokenService::getInstance();
if ($this->needLogin) {
$this->userInfo = $token->requireUser();
} else {
$this->userInfo = $token->resolveUser(request()->bearerToken()) ?? [];
}
$this->userId = (int) ($this->userInfo['id'] ?? 0);
}
/**
* 当前用户是否可见价格
*/
protected function canSeePrice(): bool
{
return (bool) ($this->userInfo['show_price'] ?? 0);
}
/**
* 当前用户的价格倍率
*/
protected function priceMultiplier(): mixed
{
return $this->userInfo['price_number'] ?? 1;
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace App\Console\Commands;
use App\Models\ApiEndpointModel;
use App\Service\PermissionService;
use Illuminate\Console\Command;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Support\Facades\Route;
use ReflectionMethod;
/**
* 把已注册的路由同步进 nl_api_endpoint
*
* 这张表同时是操作日志的匹配依据和接口级权限的授权清单,手工维护必然漏。
* 路由本身由 autoRouteRegister 反射生成,这里再反射一次路由表,
* 顺便把控制器方法的 PHPDoc 首行当作操作名,新增模块不用再补 SQL。
*/
class EndpointSyncCommand extends Command
{
protected $signature = 'lgp:endpoint-sync
{--dry-run : 只预演不写库}
{--prune : 把路由表里已不存在的接口标记为停用}';
protected $description = '同步路由到接口注册表 nl_api_endpoint幂等';
private bool $dryRun = false;
public function handle(): int
{
$this->dryRun = (bool) $this->option('dry-run');
if ($this->dryRun) {
$this->warn('预演模式:不会写入任何数据');
}
$noLog = (array) config('nl.log.no_insert', []);
$seen = [];
$created = 0;
$updated = 0;
foreach (Route::getRoutes() as $route) {
$url = $this->normalizeUri($route->uri());
if ($url === null) {
continue;
}
$method = $this->mapMethod($route);
$key = $url . '#' . $method;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$attributes = [
'name' => $this->resolveName($route, $url),
'description' => $this->resolveDescription($route),
'controller' => $this->resolveController($route),
'is_log' => in_array($url, $noLog, true) ? 0 : 1,
'status' => 1,
];
$exists = ApiEndpointModel::where('url', $url)->where('method', $method)->first();
if ($exists) {
// 名称与说明可能被人在后台改过,不覆盖;只补空值与纠正控制器归属
$diff = [];
foreach (['controller', 'status'] as $field) {
if ((string) $exists->{$field} !== (string) $attributes[$field]) {
$diff[$field] = $attributes[$field];
}
}
foreach (['name', 'description'] as $field) {
if ((string) $exists->{$field} === '' && $attributes[$field] !== '') {
$diff[$field] = $attributes[$field];
}
}
if ((int) $exists->deleted_at !== 0) {
$diff['deleted_at'] = 0;
}
if (!empty($diff)) {
$updated++;
$this->line(" ~ {$url} [{$method}] " . implode(', ', array_keys($diff)));
if (!$this->dryRun) {
$diff['updated_at'] = time();
ApiEndpointModel::where('id', $exists->id)->update($diff);
}
}
continue;
}
$created++;
$this->line(" + {$url} [{$method}] {$attributes['name']}");
if (!$this->dryRun) {
ApiEndpointModel::insert($attributes + [
'url' => $url,
'method' => $method,
'created_at' => time(),
'deleted_at' => 0,
]);
}
}
$stale = $this->findStale(array_keys($seen));
$this->newLine();
$this->info("新增 {$created} 条,更新 {$updated} 条,路由已不存在 " . count($stale) . ' 条');
if (!empty($stale)) {
foreach ($stale as $row) {
$this->line(" ? {$row->url} [{$row->method}] {$row->name}");
}
if ($this->option('prune') && !$this->dryRun) {
ApiEndpointModel::whereIn('id', array_column($stale, 'id'))->update([
'status' => 0,
'updated_at' => time(),
]);
$this->warn('以上接口已停用(未删除,避免历史操作日志失去关联)');
} else {
$this->warn('加 --prune 可把它们停用');
}
}
if (!$this->dryRun) {
PermissionService::getInstance()->clear();
$this->info('已清空角色权限缓存');
}
return self::SUCCESS;
}
/**
* 只收 /api/ 下的业务接口;带路径参数的路由不进注册表(授权与日志都按固定路径匹配)
*/
private function normalizeUri(string $uri): ?string
{
$uri = trim($uri, '/');
if (!str_starts_with($uri, 'api/')) {
return null;
}
$uri = trim(substr($uri, 4), '/');
if ($uri === '' || str_contains($uri, '{')) {
return null;
}
return $uri;
}
/**
* GET=1 POST=2 其他=0,与 ApiOpLogMiddleware 的映射保持一致
*/
private function mapMethod(RoutingRoute $route): int
{
$methods = array_diff($route->methods(), ['HEAD']);
if ($methods === ['GET']) {
return 1;
}
if ($methods === ['POST']) {
return 2;
}
return 0;
}
private function resolveController(RoutingRoute $route): string
{
$action = $route->getAction('controller');
if (!is_string($action) || !str_contains($action, '@')) {
return '';
}
return class_basename(explode('@', $action)[0]);
}
/**
* 取控制器方法 PHPDoc 的首行作为操作名,没有注释时回落成路径
*/
private function resolveName(RoutingRoute $route, string $url): string
{
$summary = $this->docSummary($route);
return $summary !== '' ? mb_substr($summary, 0, 64) : $url;
}
private function resolveDescription(RoutingRoute $route): string
{
$lines = $this->docLines($route);
array_shift($lines);
return mb_substr(trim(implode(' ', $lines)), 0, 255);
}
private function docSummary(RoutingRoute $route): string
{
return $this->docLines($route)[0] ?? '';
}
/**
* @return array<int, string> 去掉 @tag 行后的注释正文
*/
private function docLines(RoutingRoute $route): array
{
$action = $route->getAction('controller');
if (!is_string($action) || !str_contains($action, '@')) {
return [];
}
[$class, $method] = explode('@', $action);
try {
$doc = (new ReflectionMethod($class, $method))->getDocComment();
} catch (\Throwable $e) {
return [];
}
if (!is_string($doc)) {
return [];
}
$lines = [];
foreach (explode("\n", $doc) as $line) {
$line = trim(ltrim(trim($line), '/*'));
if ($line === '' || str_starts_with($line, '@')) {
continue;
}
$lines[] = $line;
}
return $lines;
}
/**
* @param array<int, string> $seenKeys url#method 组合
* @return array<int, object>
*/
private function findStale(array $seenKeys): array
{
$stale = [];
$rows = ApiEndpointModel::where('deleted_at', 0)->where('status', 1)->get(['id', 'url', 'method', 'name']);
foreach ($rows as $row) {
if (!in_array($row->url . '#' . (int) $row->method, $seenKeys, true)) {
$stale[] = (object) [
'id' => (int) $row->id,
'url' => (string) $row->url,
'method' => (int) $row->method,
'name' => (string) $row->name,
];
}
}
return $stale;
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Console\Commands;
use App\Models\MenuModel;
use App\Models\RoleMenuRelationModel;
use App\Service\common\RedisService;
use Illuminate\Console\Command;
/**
* config/lgp_menu.php 幂等同步 nl_menu
*
* name 是全局唯一键,同步以它为准:存在则按声明纠正字段,不存在则插入。
* pid 用父节点的 name 反查,所以配置里不出现任何自增 id反复执行结果一致。
* 同步完必须清菜单缓存,否则前端还会拿到 Redis 里的旧树。
*/
class MenuSyncCommand extends Command
{
protected $signature = 'lgp:menu-sync
{--dry-run : 只预演不写库}
{--grant-super : 同步后把新菜单补进超级管理员之外的角色(默认不动角色关系)}';
protected $description = '同步 LGP 业务菜单到 nl_menu幂等';
private bool $dryRun = false;
private int $created = 0;
private int $updated = 0;
public function handle(): int
{
$this->dryRun = (bool) $this->option('dry-run');
if ($this->dryRun) {
$this->warn('预演模式:不会写入任何数据');
}
$tree = config('lgp_menu', []);
if (empty($tree)) {
$this->error('config/lgp_menu.php 为空');
return self::FAILURE;
}
$this->walk($tree, 0);
$this->newLine();
$this->info("新增 {$this->created} 条,更新 {$this->updated}");
if (!$this->dryRun) {
// 菜单树按角色缓存在 Redis不清的话前端看不到新菜单
RedisService::getInstance()->init(config('nl.redis.menu_key'))->delAll();
$this->info('已清空菜单缓存');
$this->reportRoleBinding();
}
return self::SUCCESS;
}
/**
* @param array<int, array<string, mixed>> $nodes
*/
private function walk(array $nodes, int $pid): void
{
foreach ($nodes as $node) {
// parent 显式声明时优先(用于挂到脚手架已有菜单下),否则跟随递归层级
$parentId = isset($node['parent'])
? $this->resolveParentId((string) $node['parent'])
: $pid;
$id = $this->upsert($node, $parentId);
if (!empty($node['children'])) {
$this->walk($node['children'], $id);
}
}
}
private function resolveParentId(string $name): int
{
$id = (int) MenuModel::where('name', $name)->where('deleted_at', 0)->value('id');
if ($id === 0) {
$this->warn("父级菜单 {$name} 不存在,该节点挂到根目录");
}
return $id;
}
/**
* @param array<string, mixed> $node
* @return int 菜单 id预演模式下返回 0,子节点会挂到根,仅影响预演输出)
*/
private function upsert(array $node, int $pid): int
{
$isDirectory = ($node['component'] ?? '') === 'BasicLayout';
$attributes = [
'title' => (string) $node['title'],
'icon' => (string) ($node['icon'] ?? ''),
'path' => (string) $node['path'],
'component' => (string) $node['component'],
'redirect' => (string) ($node['redirect'] ?? ''),
// 取反字段0 开启缓存 / 0 固定标签
'keep_alive' => (int) ($node['keep_alive'] ?? ($isDirectory ? 0 : 1)),
'hide_in_menu' => (int) ($node['hide_in_menu'] ?? 0),
'affix_tab' => (int) ($node['affix_tab'] ?? 1),
'badge' => (string) ($node['badge'] ?? ''),
'badge_type' => (int) ($node['badge_type'] ?? 0),
'badge_variants' => (int) ($node['badge_variants'] ?? 0),
'iframe_src' => (string) ($node['iframe_src'] ?? ''),
'query' => (string) ($node['query'] ?? ''),
'pid' => $pid,
'sort' => (int) ($node['sort'] ?? 0),
];
$name = (string) $node['name'];
$exists = MenuModel::where('name', $name)->first();
if ($exists) {
$diff = array_filter(
$attributes,
fn ($value, $key) => (string) $exists->{$key} !== (string) $value,
ARRAY_FILTER_USE_BOTH
);
// 已软删的菜单同步时一并复活,否则改配置也救不回来
if ((int) $exists->deleted_at !== 0) {
$diff['deleted_at'] = 0;
}
if (empty($diff)) {
$this->line(" = {$attributes['title']} ({$name})");
return (int) $exists->id;
}
$this->line(" ~ {$attributes['title']} ({$name}) " . implode(', ', array_keys($diff)));
$this->updated++;
if (!$this->dryRun) {
$diff['updated_at'] = time();
MenuModel::where('id', $exists->id)->update($diff);
}
return (int) $exists->id;
}
$this->line(" + {$attributes['title']} ({$name})");
$this->created++;
if ($this->dryRun) {
return 0;
}
$attributes['created_at'] = time();
return (int) MenuModel::insertGetId($attributes);
}
/**
* 只提示不自动授权:给谁看哪些菜单是业务决策,命令替用户决定容易造成越权
*/
private function reportRoleBinding(): void
{
if (!$this->option('grant-super')) {
$bound = RoleMenuRelationModel::distinct()->count('role_id');
$this->newLine();
$this->warn("超级管理员role_id=1自动可见全部菜单其余 {$bound} 个已授权角色需到「角色管理」重新勾选新菜单。");
}
}
}

View File

@@ -0,0 +1,396 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 账号与角色迁移前的数据体检
*
* 老库 cc_user / cc_role / cc_user_role_relation 的结构与新库 nl_admin / nl_role 有几处不兼容,
* 直接搬运会静默出错(登录到错误的人、普通角色变超管、账号迁过去全是禁用)。
* 本命令只读不写,把这些隐患一次列清,供 lgp:rbac-migrate 之前决策。
*/
class RbacAuditCommand extends Command
{
protected $signature = 'lgp:rbac-audit
{--json= : 额外把结果写成 JSON 文件,便于留档比对}';
protected $description = '账号与角色迁移前数据体检(只读)';
/** 老库连接:业务表走 business 连接(前缀 cc_ */
private const OLD = 'business';
/** 新库连接:系统表走默认 mysql 连接(前缀 nl_ */
private const NEW = 'mysql';
/** 阻断级问题数量,决定退出码 */
private int $blockerCount = 0;
/** @var array<string, mixed> 汇总结果,供 --json 落盘 */
private array $report = [];
public function handle(): int
{
$this->info('账号与角色迁移前数据体检');
$this->line('老库连接:' . self::OLD . '(前缀 ' . DB::connection(self::OLD)->getTablePrefix() . '');
$this->line('新库连接:' . self::NEW . '(前缀 ' . DB::connection(self::NEW)->getTablePrefix() . '');
$this->newLine();
if (!$this->checkTables()) {
return self::FAILURE;
}
$this->sectionScale();
$this->sectionMultiRole();
$this->sectionDuplicatePhone();
$this->sectionInvalidPhone();
$this->sectionRoles();
$this->sectionRoleIdCollision();
$this->sectionPasswordFormat();
$this->sectionDepartment();
$this->sectionAccountVsPhone();
$this->newLine();
if ($this->blockerCount > 0) {
$this->error("发现 {$this->blockerCount} 项阻断级问题,请先处理再执行 lgp:rbac-migrate");
} else {
$this->info('未发现阻断级问题,可以执行 lgp:rbac-migrate --dry-run 预演');
}
if ($path = $this->option('json')) {
$this->writeJson($path);
}
return $this->blockerCount > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* 前置检查:两侧表都得在,否则后面每条查询都会抛异常;
* 同时检查 nl_admin / nl_role 是否已补齐 RBAC 迁移所需列(缺列时 my-info 等接口会直接 500
*/
private function checkTables(): bool
{
$missing = [];
foreach (['user', 'role', 'user_role_relation', 'role_menu_relation', 'department'] as $table) {
if (!DB::connection(self::OLD)->getSchemaBuilder()->hasTable($table)) {
$missing[] = DB::connection(self::OLD)->getTablePrefix() . $table;
}
}
foreach (['admin', 'role'] as $table) {
if (!DB::connection(self::NEW)->getSchemaBuilder()->hasTable($table)) {
$missing[] = DB::connection(self::NEW)->getTablePrefix() . $table;
}
}
if (!empty($missing)) {
$this->error('缺少表:' . implode('、', $missing));
$this->line('新库系统表请先执行 php artisan sql:run-nl-admin 安装');
return false;
}
// 代码已依赖这些列AdminService::selectField / LoginService 惰性升级),迁移前就必须存在
$missingColumns = [];
foreach ([
'admin' => ['department_id', 'legacy_password', 'legacy_password_expire_at'],
'role' => ['status', 'color'],
] as $table => $columns) {
foreach ($columns as $column) {
if (!DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column)) {
$missingColumns[] = DB::connection(self::NEW)->getTablePrefix() . $table . '.' . $column;
}
}
}
if (!empty($missingColumns)) {
$this->error('新库缺少列:' . implode('、', $missingColumns));
$this->line('请先执行 SQLdatabase/sql/rbac_migrate_ddl.sql幂等可重复跑');
$this->line('或直接跑php artisan lgp:rbac-migrate --force (会先幂等补列再迁数据)');
return false;
}
return true;
}
/**
* 迁移规模:先知道要搬多少行
*/
private function sectionScale(): void
{
$rows = [
['cc_user 待迁账号', $this->oldCount('user')],
['cc_role 待迁角色', $this->oldCount('role')],
['cc_department 部门(复用不迁)', $this->oldCount('department')],
['cc_user_role_relation 用户角色关系', DB::connection(self::OLD)->table('user_role_relation')->count()],
['cc_role_menu_relation 角色菜单关系', DB::connection(self::OLD)->table('role_menu_relation')->count()],
['nl_admin 现有账号', $this->newCount('admin')],
['nl_role 现有角色', $this->newCount('role')],
];
$this->line('<comment>[1] 迁移规模</comment>');
$this->table(['项目', '数量'], $rows);
$this->report['scale'] = collect($rows)->mapWithKeys(fn ($r) => [$r[0] => $r[1]])->all();
}
/**
* 多角色用户:老库是中间表多对多,新库是 nl_admin.role_id 单字段,多绑的必须人工决定取哪个
*
* 注意business 连接有前缀 cc_Laravel 会把别名 `ur` 编译成 `cc_ur`
* selectRaw 不会自动改写,必须手写带前缀的别名,否则报 Unknown column 'ur.user_id'
*/
private function sectionMultiRole(): void
{
$p = $this->oldPrefix();
$rows = DB::connection(self::OLD)->table('user_role_relation as ur')
->join('user as u', 'u.id', '=', 'ur.user_id')
->where('u.deleted_at', 0)
->groupBy('ur.user_id', 'u.account', 'u.nick_name')
->havingRaw('COUNT(*) > 1')
->selectRaw("{$p}ur.user_id, {$p}u.account, {$p}u.nick_name, COUNT(*) AS role_count, GROUP_CONCAT({$p}ur.role_id ORDER BY {$p}ur.role_id) AS role_ids")
->get();
$this->line('<comment>[2] 绑定了多个角色的用户</comment>(新库一个账号只有一个 role_id');
if ($rows->isEmpty()) {
$this->info(' 无,可安全一对一迁移');
$this->report['multi_role'] = [];
return;
}
$this->blockerCount++;
$this->table(
['user_id', 'account', 'nick_name', '角色数', 'role_ids'],
$rows->map(fn ($r) => [$r->user_id, $r->account, $r->nick_name, $r->role_count, $r->role_ids])->all()
);
$this->warn(' 需决策:取 role_id 最小者、还是给新系统补多角色支持');
$this->report['multi_role'] = $rows->toArray();
// 没有任何角色关系的账号同样要处理,否则迁过去 role_id=0 什么菜单都看不到
$noRole = DB::connection(self::OLD)->table('user as u')
->where('u.deleted_at', 0)
->whereNotExists(function ($q) {
$q->select(DB::raw(1))->from('user_role_relation as ur')->whereColumn('ur.user_id', 'u.id');
})
->get(['id', 'account', 'nick_name']);
if ($noRole->isNotEmpty()) {
$this->warn(' 另有 ' . $noRole->count() . ' 个账号没有任何角色关系,迁移后 role_id=0 将看不到任何菜单');
$this->report['no_role'] = $noRole->toArray();
}
}
/**
* 重复手机号:登录是 where('phone', ...)->first(),重复会登录到错误的人
*/
private function sectionDuplicatePhone(): void
{
$rows = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->groupBy('phone')
->havingRaw('COUNT(*) > 1')
->selectRaw('phone, COUNT(*) AS c, GROUP_CONCAT(id ORDER BY id) AS ids, GROUP_CONCAT(account ORDER BY id) AS accounts')
->get();
$this->line('<comment>[3] 重复手机号</comment>(新库登录按 phone 查询,重复会登错人)');
if ($rows->isEmpty()) {
$this->info(' 无重复');
$this->report['duplicate_phone'] = [];
return;
}
$this->blockerCount++;
$this->table(
['phone', '重复数', 'user_ids', 'accounts'],
$rows->map(fn ($r) => [$r->phone, $r->c, $r->ids, $r->accounts])->all()
);
$this->warn(' 必须先在老库消重,迁移后还要给 nl_admin.phone 补唯一索引');
$this->report['duplicate_phone'] = $rows->toArray();
}
/**
* 手机号格式:新库 nl_admin.phone char(11),超长会被截断,空值无法登录
*/
private function sectionInvalidPhone(): void
{
$rows = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereRaw("(phone = '' OR CHAR_LENGTH(phone) <> 11)")
->selectRaw('id, account, nick_name, phone, CHAR_LENGTH(phone) AS len')
->get();
$this->line('<comment>[4] 手机号长度异常</comment>(新库是 char(11),超长会截断)');
if ($rows->isEmpty()) {
$this->info(' 全部为 11 位');
$this->report['invalid_phone'] = [];
return;
}
$this->blockerCount++;
$this->table(
['id', 'account', 'nick_name', 'phone', '长度'],
$rows->map(fn ($r) => [$r->id, $r->account, $r->nick_name, $r->phone, $r->len])->all()
);
$this->warn(' 空手机号将完全无法登录,带区号/空格的要先清洗');
$this->report['invalid_phone'] = $rows->toArray();
}
/**
* 老角色清单:新库 nl_role 需要 value英文标识老库没这个字段逐个要指定
*/
private function sectionRoles(): void
{
$rows = DB::connection(self::OLD)->table('role')
->where('deleted_at', 0)
->orderBy('id')
->get(['id', 'name', 'desc', 'status', 'color']);
$counts = DB::connection(self::OLD)->table('user_role_relation as ur')
->join('user as u', 'u.id', '=', 'ur.user_id')
->where('u.deleted_at', 0)
->groupBy('ur.role_id')
->selectRaw("{$this->oldPrefix()}ur.role_id, COUNT(*) AS c")
->pluck('c', 'role_id');
$menuCounts = DB::connection(self::OLD)->table('role_menu_relation')
->groupBy('role_id')
->selectRaw('role_id, COUNT(*) AS c')
->pluck('c', 'role_id');
$this->line('<comment>[5] 老角色清单</comment>(新库 nl_role.value 必填,需为每个角色指定英文标识)');
$this->table(
['老 id', 'name', 'desc', 'status(0正常1禁用)', 'color', '账号数', '菜单数'],
$rows->map(fn ($r) => [
$r->id, $r->name, $r->desc, $r->status, $r->color,
$counts[$r->id] ?? 0, $menuCounts[$r->id] ?? 0,
])->all()
);
$this->report['old_roles'] = $rows->toArray();
}
/**
* 角色 ID 撞车nl_role.id=1 是超级管理员且代码里硬判断 role_id===1 全量放行,
* 老库 id=1 未必是超管,按原 ID 迁会把普通角色提权成超管
*/
private function sectionRoleIdCollision(): void
{
$this->line('<comment>[6] 角色 ID 撞车检查</comment>');
$oldOne = DB::connection(self::OLD)->table('role')->where('id', 1)->first();
$newOne = DB::connection(self::NEW)->table('role')->where('id', 1)->first();
if ($oldOne && $newOne) {
$this->table(
['库', 'id', 'name'],
[['老 cc_role', 1, $oldOne->name], ['新 nl_role', 1, $newOne->name]]
);
$this->warn(' 老 id=1 是「' . $oldOne->name . '」,新 id=1 是「' . $newOne->name . '」(硬编码全量放行)');
$this->warn(' 迁移必须做 ID 偏移,不能按原 ID 直搬');
$this->blockerCount++;
} else {
$this->info(' 老库无 id=1 角色,仍建议偏移以留出系统角色区间');
}
$maxOld = (int) DB::connection(self::OLD)->table('role')->max('id');
$this->line(' 老角色最大 id = ' . $maxOld . ',建议偏移量 100迁后占用 101..' . (100 + $maxOld) . '');
$this->report['role_id_collision'] = [
'old_role_1' => $oldOne->name ?? null,
'new_role_1' => $newOne->name ?? null,
'old_max_id' => $maxOld,
];
}
/**
* 密码格式:老库是无盐 sha140 hex非此格式的迁过去无法惰性升级只能走重置
*/
private function sectionPasswordFormat(): void
{
$total = $this->oldCount('user');
$sha1 = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereRaw("CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$'")
->count();
$empty = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)->where('password', '')->count();
$other = $total - $sha1 - $empty;
$this->line('<comment>[7] 密码格式</comment>(惰性升级依赖无盐 sha140 位 hex');
$this->table(['类型', '数量'], [
['标准 sha1可惰性升级', $sha1],
['空密码(必须重置)', $empty],
['其它格式(必须重置)', max($other, 0)],
]);
if ($empty > 0 || $other > 0) {
$this->warn(' 非 sha1 的账号迁移后需强制走密码重置流程');
}
$this->report['password_format'] = ['sha1' => $sha1, 'empty' => $empty, 'other' => max($other, 0)];
}
/**
* 部门引用cc_user.department 指向 cc_department.id悬空引用迁过去会显示空部门
*/
private function sectionDepartment(): void
{
$rows = DB::connection(self::OLD)->table('user as u')
->where('u.deleted_at', 0)
->where('u.department', '>', 0)
->whereNotExists(function ($q) {
$q->select(DB::raw(1))->from('department as d')
->whereColumn('d.id', 'u.department')->where('d.deleted_at', 0);
})
->get(['u.id', 'u.account', 'u.department']);
$this->line('<comment>[8] 部门悬空引用</comment>');
if ($rows->isEmpty()) {
$this->info(' 无悬空引用');
$this->report['orphan_department'] = [];
return;
}
$this->table(
['user_id', 'account', 'department已不存在'],
$rows->map(fn ($r) => [$r->id, $r->account, $r->department])->all()
);
$this->warn(' 这些账号迁移后 department_id 建议置 0');
$this->report['orphan_department'] = $rows->toArray();
}
/**
* account phone 的关系:老前端表单字段叫 account但后端一直是 where('phone', ...)
* 也就是用户实际输的是手机号。account 不参与登录,迁移时只留痕
*/
private function sectionAccountVsPhone(): void
{
$diff = DB::connection(self::OLD)->table('user')
->where('deleted_at', 0)
->whereColumn('account', '<>', 'phone')
->count();
$this->line('<comment>[9] account 与 phone 不一致的账号</comment>');
$this->line(' 共 ' . $diff . ' 个。account 从不参与登录(老 UserService::login 查的是 phone');
$this->line(' 迁移时把原 account 写进 nl_admin.desc 留痕即可,不新增列');
$this->report['account_phone_diff'] = $diff;
}
private function oldCount(string $table): int
{
return DB::connection(self::OLD)->table($table)->where('deleted_at', 0)->count();
}
private function newCount(string $table): int
{
return DB::connection(self::NEW)->table($table)->where('deleted_at', 0)->count();
}
/**
* 老库business表前缀selectRaw 里引用别名时必须手动拼上
* Laravel 会把 `as ur` 编译成 `as cc_ur`,但 selectRaw 字符串不会自动改写
*/
private function oldPrefix(): string
{
return DB::connection(self::OLD)->getTablePrefix();
}
private function writeJson(string $path): void
{
$full = str_starts_with($path, '/') ? $path : base_path($path);
@mkdir(dirname($full), 0755, true);
file_put_contents(
$full,
json_encode([
'generated_at' => date('Y-m-d H:i:s'),
'blocker_count' => $this->blockerCount,
'report' => $this->report,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->info('体检结果已写入:' . $full);
}
}

View File

@@ -0,0 +1,415 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 账号与角色迁移cc_user / cc_role / cc_user_role_relation nl_admin / nl_role
*
* 几处必须偏移或转换的地方(照原样搬会静默出错):
* - 角色 ID 偏移nl_role.id=1 是超管且代码里硬判断 role_id===1 全量放行,老库 id=1 未必是超管
* - 账号 ID 同样偏移:老 cc_user.id 1 起,会撞上安装包里的超管
* - 用户角色从多对多降级成 nl_admin.role_id 单字段,多绑的取 role_id 最小者
* - 密码是无盐 sha1 legacy_password 走惰性升级password 留空
* - nl_admin.open_id / ip_table NOT NULL 且无默认值,必须显式赋值
*
* 幂等:按 nl_admin.desc / nl_role.desc 里的迁移留痕判断是否已迁,重复执行只补未迁的行。
*/
class RbacMigrateCommand extends Command
{
protected $signature = 'lgp:rbac-migrate
{--dry-run : 只预演不写库}
{--offset=100 : 角色与账号的 ID 偏移量,避开系统预留区间}
{--legacy-days=90 : 遗留 sha1 密码的有效天数,过期强制重置}
{--value-map= : 角色 value 映射 JSON 文件,形如 {"管理员":"manager"}}
{--force : 跳过确认}';
protected $description = '账号与角色迁移cc_user/cc_role → nl_admin/nl_role';
private const OLD = 'business';
private const NEW = 'mysql';
/** 迁移留痕前缀,用于幂等判断 */
private const TAG = '[migrated:cc_user#';
private const ROLE_TAG = '[migrated:cc_role#';
private bool $dryRun = false;
private int $offset = 100;
/** @var array<int, int> 老角色 id => 新角色 id */
private array $roleMap = [];
public function handle(): int
{
$this->dryRun = (bool) $this->option('dry-run');
$this->offset = max(1, (int) $this->option('offset'));
if ($this->dryRun) {
$this->warn('预演模式:不会写入任何数据');
}
$this->ensureColumns();
if (!$this->dryRun && !$this->option('force')) {
$this->warn('即将向 nl_role / nl_admin 写入数据。建议先跑 lgp:rbac-audit 与 --dry-run。');
if (!$this->confirm('继续?', false)) {
$this->line('已取消');
return self::SUCCESS;
}
}
try {
if (!$this->dryRun) {
DB::connection(self::NEW)->beginTransaction();
}
$this->migrateRoles();
$this->migrateAdmins();
if (!$this->dryRun) {
DB::connection(self::NEW)->commit();
}
} catch (Throwable $e) {
if (!$this->dryRun && DB::connection(self::NEW)->transactionLevel() > 0) {
DB::connection(self::NEW)->rollBack();
}
$this->error('迁移失败已回滚:' . $e->getMessage());
return self::FAILURE;
}
$this->reportRoleMenu();
$this->newLine();
$this->info($this->dryRun ? '预演结束' : '迁移完成');
$this->line('后续手动步骤:');
$this->line(' 1. 按第十一节重建 nl_menu再按功能重绑角色菜单老 menu_id 不能直搬)');
$this->line(' 2. 确认手机号无重复后执行 database/sql/rbac_migrate_ddl.sql 里的 uk_phone 唯一索引');
return self::SUCCESS;
}
/**
* 幂等补列MySQL 不支持 ADD COLUMN IF NOT EXISTS这里查 information_schema 再决定
*/
private function ensureColumns(): void
{
$adds = [
'admin' => [
'department_id' => "ADD COLUMN `department_id` int NOT NULL DEFAULT 0 COMMENT '所属部门ID对应 cc_department.id' AFTER `role_id`",
'legacy_password' => "ADD COLUMN `legacy_password` varchar(64) NOT NULL DEFAULT '' COMMENT '老系统无盐sha1密码登录成功后清空' AFTER `password`",
'legacy_password_expire_at' => "ADD COLUMN `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT '遗留密码失效时间' AFTER `legacy_password`",
],
'role' => [
'status' => "ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态 0正常 1禁用' AFTER `desc`",
'color' => "ADD COLUMN `color` varchar(32) NOT NULL DEFAULT '' COMMENT '角色标签颜色' AFTER `status`",
],
];
foreach ($adds as $table => $columns) {
$missing = [];
foreach ($columns as $column => $clause) {
if (!$this->hasColumn($table, $column)) {
$missing[] = $clause;
}
}
if (empty($missing)) {
continue;
}
$physical = DB::connection(self::NEW)->getTablePrefix() . $table;
$sql = "ALTER TABLE `{$physical}` " . implode(', ', $missing);
$this->line('补列:' . $physical . ' → ' . count($missing) . ' 列');
if (!$this->dryRun) {
DB::connection(self::NEW)->statement($sql);
}
}
// 安装包里超管 status=1而列注释是「0正常 1禁用」补上状态校验后会被锁死先纠正
if (!$this->dryRun) {
DB::connection(self::NEW)->table('admin')->where('id', 1)->where('status', 1)->update(['status' => 0]);
}
}
private function hasColumn(string $table, string $column): bool
{
return DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column);
}
/**
* 角色迁移:新 id = id + offsetvalue 缺省生成 role_{老id}
*/
private function migrateRoles(): void
{
$this->newLine();
$this->line('<comment>迁移角色</comment>');
$valueMap = $this->loadValueMap();
$roles = DB::connection(self::OLD)->table('role')->where('deleted_at', 0)->orderBy('id')->get();
$rows = [];
$skipped = 0;
foreach ($roles as $role) {
$newId = (int) $role->id + $this->offset;
$this->roleMap[(int) $role->id] = $newId;
if (DB::connection(self::NEW)->table('role')->where('id', $newId)->exists()) {
$skipped++;
continue;
}
$value = $valueMap[$role->name] ?? ('role_' . $role->id);
$data = [
'id' => $newId,
'name' => mb_substr((string) $role->name, 0, 32),
'value' => mb_substr($value, 0, 32),
'pid' => 0,
'desc' => $this->tagged(self::ROLE_TAG, (int) $role->id, (string) $role->desc),
'status' => (int) $role->status,
'color' => (string) $role->color,
'created_at' => (int) $role->created_at,
'updated_at' => (int) $role->updated_at,
'deleted_at' => 0,
];
$rows[] = $data;
}
$this->table(
['老 id', '新 id', 'name', 'value', 'status'],
array_map(
fn ($r) => [$r['id'] - $this->offset, $r['id'], $r['name'], $r['value'], $r['status']],
$rows
)
);
if ($skipped > 0) {
$this->line('已存在跳过:' . $skipped . ' 个');
}
if (!$this->dryRun && !empty($rows)) {
DB::connection(self::NEW)->table('role')->insert($rows);
}
$this->info('角色新增 ' . count($rows) . ' 个');
}
/**
* 账号迁移:新 id = id + offset密码进 legacy_password 走惰性升级
*/
private function migrateAdmins(): void
{
$this->newLine();
$this->line('<comment>迁移账号</comment>');
// 多角色降级:取 role_id 最小者,与体检报告口径一致
$userRole = DB::connection(self::OLD)->table('user_role_relation')
->groupBy('user_id')
->selectRaw('user_id, MIN(role_id) AS role_id')
->pluck('role_id', 'user_id');
$validDept = DB::connection(self::OLD)->table('department')
->where('deleted_at', 0)->pluck('id')->all();
$validDept = array_flip(array_map('intval', $validDept));
$legacyExpire = time() + max(1, (int) $this->option('legacy-days')) * 86400;
$users = DB::connection(self::OLD)->table('user')->where('deleted_at', 0)->orderBy('id')->get();
$rows = [];
$skipped = 0;
$problems = [];
foreach ($users as $user) {
$newId = (int) $user->id + $this->offset;
if (DB::connection(self::NEW)->table('admin')->where('id', $newId)->exists()) {
$skipped++;
continue;
}
$phone = trim((string) $user->phone);
if ($phone === '' || mb_strlen($phone) !== 11) {
$problems[] = [$user->id, $user->account, $phone === '' ? '(空)' : $phone, '手机号非 11 位,已跳过'];
continue;
}
$legacy = $this->normalizeLegacyPassword((string) $user->password);
if ($legacy === '') {
$problems[] = [$user->id, $user->account, $phone, '密码非标准 sha1迁移后需走重置'];
}
$oldRoleId = (int) ($userRole[$user->id] ?? 0);
$newRoleId = $oldRoleId > 0 ? ($this->roleMap[$oldRoleId] ?? 0) : 0;
if ($newRoleId === 0) {
$problems[] = [$user->id, $user->account, $phone, '无角色,迁移后看不到菜单'];
}
$dept = (int) $user->department;
if ($dept > 0 && !isset($validDept[$dept])) {
$problems[] = [$user->id, $user->account, $phone, "部门 {$dept} 不存在,已置 0"];
$dept = 0;
}
$nickName = trim((string) $user->nick_name);
if ($nickName === '') {
$nickName = (string) $user->account;
}
$rows[] = [
'id' => $newId,
// NOT NULL 无默认值,格式对齐安装包里超管那行
'open_id' => 'nl_' . bin2hex(random_bytes(15)),
'avatar' => (string) $user->avatar,
'nick_name' => mb_substr($nickName, 0, 32),
// 新库是 bcrypt老 sha1 没有明文无法转换,先留空由 legacy_password 兜底
'password' => '',
'legacy_password' => $legacy,
'legacy_password_expire_at' => $legacy === '' ? 0 : $legacyExpire,
'phone' => $phone,
'email' => substr((string) $user->email, 0, 32),
'code' => '',
'role_id' => $newRoleId,
'department_id' => $dept,
'province_id' => 0,
'city_id' => 0,
'reg_ip' => 0,
'last_login_time' => $this->parseLastLogin((string) $user->last_login_at),
'ip' => (string) ($user->last_login_ip ?: '0'),
// NOT NULL 的 json 列,不给值会直接插入失败
'ip_table' => '[]',
'operation_password' => '0',
'desc' => $this->buildAdminDesc($user),
// 老库同样是 0正常 1禁用可以直搬但新表默认值是 1必须显式写
'status' => (int) $user->status,
'created_at' => (int) $user->created_at,
'updated_at' => (int) $user->updated_at,
'deleted_at' => 0,
];
}
if (!empty($problems)) {
$this->newLine();
$this->warn('需要关注的账号:');
$this->table(['老 id', 'account', 'phone', '说明'], $problems);
}
if (!$this->dryRun && !empty($rows)) {
foreach (array_chunk($rows, 200) as $chunk) {
DB::connection(self::NEW)->table('admin')->insert($chunk);
}
// 后续新建账号从迁移区间之后继续,避免自增撞上已占用的 id
$maxId = (int) DB::connection(self::NEW)->table('admin')->max('id');
$physical = DB::connection(self::NEW)->getTablePrefix() . 'admin';
DB::connection(self::NEW)->statement("ALTER TABLE `{$physical}` AUTO_INCREMENT = " . ($maxId + 1));
}
if ($skipped > 0) {
$this->line('已存在跳过:' . $skipped . ' 个');
}
$this->info('账号新增 ' . count($rows) . ' 个');
}
/**
* sha1 必须是 40 位纯 hex 才能参与惰性升级,其它格式一律留空走重置
*/
private function normalizeLegacyPassword(string $password): string
{
$password = strtolower(trim($password));
return preg_match('/^[0-9a-f]{40}$/', $password) === 1 ? $password : '';
}
/**
* last_login_at varchar可能是时间戳字符串也可能是日期文本
*/
private function parseLastLogin(string $value): int
{
$value = trim($value);
if ($value === '' || $value === '0') {
return 0;
}
if (ctype_digit($value)) {
return (int) $value;
}
$ts = strtotime($value);
return $ts === false ? 0 : $ts;
}
/**
* 备注里留迁移痕迹account 不参与登录(老 login 查的是 phone但对不上账时要能追溯
*/
private function buildAdminDesc(object $user): string
{
$parts = [];
$intro = trim((string) ($user->introduction ?? ''));
$address = trim((string) ($user->address ?? ''));
if ($intro !== '') {
$parts[] = $intro;
}
if ($address !== '') {
$parts[] = '所在地:' . $address;
}
$desc = implode('', $parts);
return $this->tagged(self::TAG, (int) $user->id, $desc, (string) $user->account);
}
/**
* 拼迁移留痕,同时作为幂等判断依据。超长时优先保留留痕
*/
private function tagged(string $tag, int $oldId, string $desc, string $account = ''): string
{
$mark = $tag . $oldId . ($account !== '' ? ' account=' . $account : '') . ']';
$full = $desc === '' ? $mark : $mark . ' ' . $desc;
return mb_substr($full, 0, 255);
}
/**
* 角色菜单关系不能按 menu_id 直搬:业务页面在 vben5 里路径全变了,老 menu_id 指向的菜单不复存在。
* 这里只导出「哪个角色能看哪些功能」的语义清单,供重建菜单后按功能重绑。
*/
private function reportRoleMenu(): void
{
$this->newLine();
$this->line('<comment>角色菜单语义参照(需人工重绑,不自动迁移)</comment>');
$rows = DB::connection(self::OLD)->table('role_menu_relation as rm')
->join('role as r', 'r.id', '=', 'rm.role_id')
->join('menu as m', 'm.id', '=', 'rm.menu_id')
->where('r.deleted_at', 0)
->where('m.deleted_at', 0)
->orderBy('r.id')
->orderBy('m.order_no')
->get(['r.id as role_id', 'r.name as role_name', 'm.title', 'm.router']);
if ($rows->isEmpty()) {
$this->line(' 老库无角色菜单关系');
return;
}
$grouped = [];
foreach ($rows as $row) {
$key = ($this->roleMap[(int) $row->role_id] ?? 0) . '|' . $row->role_name;
$grouped[$key][] = $row->title;
}
$table = [];
foreach ($grouped as $key => $titles) {
[$newRoleId, $roleName] = explode('|', $key, 2);
$table[] = [$newRoleId, $roleName, count($titles), implode('、', array_slice($titles, 0, 8)) . (count($titles) > 8 ? ' …' : '')];
}
$this->table(['新角色 id', '角色名', '菜单数', '可见功能(截断)'], $table);
}
/**
* 角色 value 映射:老 cc_role 没有 value 字段,缺省用 role_{老id}
* 想要可读的英文标识就传 --value-map
*/
private function loadValueMap(): array
{
$path = (string) $this->option('value-map');
if ($path === '') {
return [];
}
$full = str_starts_with($path, '/') ? $path : base_path($path);
if (!file_exists($full)) {
$this->warn('value-map 文件不存在,改用默认 role_{老id}' . $full);
return [];
}
$map = json_decode((string) file_get_contents($full), true);
return is_array($map) ? $map : [];
}
}

View File

@@ -17,7 +17,7 @@ class AdminController extends BaseController
$this->service = AdminService::getInstance();
$this->insertField = [ 'phone', 'nick_name', 'password', 'role_id' ];
$this->updateField = [ 'id', 'phone', 'nick_name', 'role_id' ];
$this->notRequest = ['qr_code', 'avatar', 'email', 'status'];
$this->notRequest = ['qr_code', 'avatar', 'email', 'status', 'department_id', 'desc'];
}
/**
@@ -115,7 +115,9 @@ class AdminController extends BaseController
}
/**
* 获取菜单列表
* 获取当前账号的权限码
*
* 码由接口路径推导admin/list admin:list),前端 v-access TableAction auth 用的就是它。
* @Method GET
* @return JsonResponse
* @throws Exception
@@ -123,7 +125,7 @@ class AdminController extends BaseController
public function codes(): JsonResponse
{
return jok(
[],
$this->service->codes(),
'获取成功'
);
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\CardClassService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 色卡分类
*/
class CardClassController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = CardClassService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
$this->notRequest = ['status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\CarouselService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 小程序轮播图
*/
class CarouselController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = CarouselService::getInstance();
$this->insertField = ['url'];
$this->updateField = ['id', 'url'];
$this->notRequest = ['to_path', 'sort', 'status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\CatalogueService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 商品图册
*/
class CatalogueController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = CatalogueService::getInstance();
$this->insertField = ['title', 'category_id', 'cover', 'identifier'];
$this->updateField = ['id', 'title', 'category_id', 'cover', 'identifier'];
$this->notRequest = ['alias', 'pdf', 'price', 'status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\CategoryService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 商品分类
*/
class CategoryController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = CategoryService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
$this->notRequest = ['url', 'pid', 'status', 'sort'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\ColorcardService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 色卡
*/
class ColorcardController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = ColorcardService::getInstance();
$this->insertField = ['card_class', 'company', 'cover'];
$this->updateField = ['id', 'card_class', 'company'];
$this->notRequest = ['price', 'description', 'cover', 'status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\CompanyService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 色卡所属公司
*/
class CompanyController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = CompanyService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
$this->notRequest = ['phone', 'address', 'status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\DepartmentService;
use Exception;
use Illuminate\Http\JsonResponse;
class DepartmentController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = DepartmentService::getInstance();
// insertField / updateField 既是必填校验清单,也是入库字段白名单,漏写的字段不会被收进 params
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
// 这几个允许缺省pid 缺省为顶级status 缺省为正常
$this->notRequest = ['pid', 'desc', 'status', 'color'];
}
/**
* 部门树下拉(表单选上级部门用)
* @Method GET
*/
public function treeOption(): JsonResponse
{
$isSelect = filter_var(request()->get('is_select', false), FILTER_VALIDATE_BOOLEAN);
return jok($this->service->getTreeOption($isSelect), '列表获取成功');
}
/**
* 停用 / 启用部门
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$id = request()->post('id');
$status = request()->post('status');
if (empty($id) || !in_array((string) $status, ['0', '1'], true)) {
return jerr('参数错误');
}
return jok($this->service->status($id, $status), '操作成功');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\EnterpriseService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 企业管理
*/
class EnterpriseController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = EnterpriseService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
$this->notRequest = [
'logo', 'contact_name', 'phone', 'address',
'tax_no', 'settle_type', 'price_number', 'status', 'remark',
];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\FactoryClassificationService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 工厂分类
*/
class FactoryClassificationController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = FactoryClassificationService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
$this->notRequest = ['status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\FactoryImageService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 工厂产品图
*/
class FactoryImageController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = FactoryImageService::getInstance();
$this->insertField = ['factory', 'url'];
$this->updateField = ['id', 'url'];
$this->notRequest = ['factory', 'status'];
}
/**
* 某工厂的全部产品图(入参 factory
* @Method GET
*/
public function imageList(): JsonResponse
{
return jok($this->service->imageList((int) request()->get('factory', 0)), '列表获取成功');
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\FactoryInfoService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 工厂管理
*/
class FactoryInfoController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = FactoryInfoService::getInstance();
$this->insertField = ['name', 'classification'];
$this->updateField = ['id', 'name', 'classification'];
$this->notRequest = ['phone', 'cover', 'address', 'status'];
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\FileFolderService;
/**
* 素材文件夹(标准 CRUD + 目录树)
*/
class FileFolderController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = FileFolderService::getInstance();
$this->insertField = ['name'];
$this->updateField = ['id', 'name'];
// pid = 0 就是根目录sort / status 也允许留空走默认值
$this->notRequest = ['pid', 'sort', 'status'];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\ImageService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 商品相册
*/
class ImageController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = ImageService::getInstance();
$this->insertField = ['catalogue_id', 'url', 'type'];
$this->updateField = ['id', 'url'];
$this->notRequest = ['catalogue_id', 'type', 'status'];
}
/**
* 渲染图列表(入参 catalogue_id
* @Method GET
*/
public function renderGraph(): JsonResponse
{
return jok($this->service->renderGraph((int) request()->get('catalogue_id', 0)), '列表获取成功');
}
/**
* 实物图列表(入参 catalogue_id
* @Method GET
*/
public function physicalDrawing(): JsonResponse
{
return jok($this->service->physicalDrawing((int) request()->get('catalogue_id', 0)), '列表获取成功');
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\ListService;
use Illuminate\Http\JsonResponse;
/**
* 清单管理
*/
class ListController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = ListService::getInstance();
$this->insertField = ['name', 'user_id'];
$this->updateField = ['id', 'name'];
$this->notRequest = ['remark', 'enterprise_id'];
}
/**
* 某用户的清单
* @Method GET
*/
public function byUser(): JsonResponse
{
return jok($this->service->byUser((int) request()->get('user_id', 0)));
}
/**
* 清单生成订单
* @Method POST
*/
public function toOrder(): JsonResponse
{
return jok(
$this->service->toOrder((int) request()->post('id', 0), request()->post()),
'订单已生成'
);
}
/**
* 保存清单明细
* @Method POST
*/
public function saveItem(): JsonResponse
{
return jok($this->service->saveItem(request()->post()), '保存成功');
}
/**
* 删除清单明细
* @Method POST
*/
public function deleteItem(): JsonResponse
{
return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功');
}
}

View File

@@ -8,7 +8,11 @@ use Illuminate\Http\JsonResponse;
class LoginController extends BaseController
{
//
/**
* 登录控制器注册在免登录组里,继承来的 CRUD 会变成 /api/list/api/create 这种
* 无需鉴权、指向 LoginService根本没有这些方法的路由必须排除
*/
protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
@@ -54,20 +58,32 @@ class LoginController extends BaseController
);
}
public function logout()
/**
* 退出登录
* @Method POST
* @throws \Exception
*/
public function logout(): JsonResponse
{
return jok(
[],
$this->service->logout(),
'退出成功'
);
}
public function codes()
/**
* 续签 token
*
* 前端拦截器在 401 时调用,此时 token 已过期,所以只能放在免登录组里,
* LoginService::refresh 自己校验签名与 Redis 会话。
* @Method POST
* @throws \Exception
*/
public function refresh(): JsonResponse
{
return jok(
[],
'获取成功'
$this->service->refresh(),
'续签成功'
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\common\UtilsService;
use App\Service\MaterialService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 素材库
*
* 素材记录只有两个来源:上传接口写入、从 OSS 反向同步补录,
* 所以没有「手工新增」,也不需要下拉选项 —— 把继承来的 create / option 排掉。
*/
class MaterialController extends BaseController
{
protected array $exceptRoute = ['create', 'option'];
public function __construct()
{
parent::__construct();
$this->service = MaterialService::getInstance();
// 只放行素材名与文件夹:对象键、体积、哈希都是同步结果,不接受手工改
$this->updateField = ['id', 'name', 'folder_id'];
$this->notRequest = ['name', 'folder_id'];
}
/**
* 素材概览统计
*
* @Method GET
* @throws Exception
*/
public function stat(): JsonResponse
{
return jok($this->service->stat(), '统计获取成功');
}
/**
* OSS 增量同步对象
*
* @Method POST
* @throws Exception
*/
public function syncFromOss(): JsonResponse
{
$this->insertField = ['oss_config_id'];
$this->notRequest = ['prefix', 'marker', 'limit'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->syncFromOss($params), '同步完成');
}
/**
* 全库引用扫描
*
* @Method POST
* @throws Exception
*/
public function scanReferences(): JsonResponse
{
// 表多、正文长时会跑几十秒,先把执行时限和内存放开
cc_set_time_limit();
return jok($this->service->scanReferences(request()->post()), '扫描完成');
}
/**
* 无引用素材清单
*
* @Method GET
* @throws Exception
*/
public function unusedList(): JsonResponse
{
return jok($this->service->unusedList(request()->query()), '列表获取成功');
}
/**
* 回收:删远端对象并软删记录
*
* @Method POST
* @throws Exception
*/
public function reclaim(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids) || !is_array($ids)) {
UtilsService::getInstance()->notFound('参数错误');
}
return jok($this->service->reclaim($ids), '回收完成');
}
/**
* 批量移动到文件夹
*
* @Method POST
* @throws Exception
*/
public function moveToFolder(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids) || !is_array($ids)) {
UtilsService::getInstance()->notFound('参数错误');
}
return jok(
$this->service->moveToFolder($ids, (int) request()->post('folder_id', 0)),
'移动成功'
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\OrderService;
use Illuminate\Http\JsonResponse;
/**
* 订单管理
*/
class OrderController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = OrderService::getInstance();
$this->insertField = ['list_id'];
$this->updateField = ['id'];
$this->notRequest = [
'receiver_name', 'receiver_phone', 'receiver_address',
'remark', 'delivery_type',
];
}
/**
* 概览统计
* @Method GET
*/
public function stat(): JsonResponse
{
return jok($this->service->stat());
}
/**
* 某用户的订单
* @Method GET
*/
public function byUser(): JsonResponse
{
return jok($this->service->byUser((int) request()->get('user_id', 0)));
}
/**
* 审核转账凭证
* @Method POST
*/
public function auditPayment(): JsonResponse
{
return jok($this->service->auditPayment(request()->post()), '处理成功');
}
/**
* 发货
* @Method POST
*/
public function ship(): JsonResponse
{
return jok($this->service->ship(request()->post()), '已发货');
}
/**
* 取消订单
* @Method POST
*/
public function cancel(): JsonResponse
{
return jok($this->service->cancel((int) request()->post('id', 0)), '已取消');
}
/**
* 完成订单
* @Method POST
*/
public function complete(): JsonResponse
{
return jok($this->service->complete((int) request()->post('id', 0)), '已完成');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\PriceSheetService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 报价单(商品规格)
*/
class PriceSheetController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = PriceSheetService::getInstance();
$this->insertField = ['catalogue_id'];
$this->updateField = ['id'];
$this->notRequest = ['specification', 'dimension', 'routine', 'rows', 'status'];
}
/**
* 某商品的全部规格行(报价单抽屉回填用)
* @Method GET
*/
public function rows(): JsonResponse
{
return jok($this->service->rowsOf((int) request()->get('catalogue_id', 0)), '列表获取成功');
}
/**
* 覆盖式保存某商品的全部规格行
* @Method POST
* @throws Exception
*/
public function saveRows(): JsonResponse
{
$this->insertField = ['catalogue_id'];
$this->notRequest = ['rows'];
$this->checkRequiredFields(request()->post());
return jok(
$this->service->saveRows(
(int) request()->post('catalogue_id'),
(array) request()->post('rows', [])
),
'保存成功'
);
}
/**
* 启用 / 禁用
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -17,6 +17,7 @@ class RoleController extends BaseController
parent::__construct();
$this->insertField = ['name', 'value', 'desc'];
$this->updateField = ['id', 'name', 'value', 'desc'];
$this->notRequest = ['status', 'color', 'pid'];
$this->service = RoleService::getInstance();
}
@@ -51,4 +52,52 @@ class RoleController extends BaseController
$this->service->saveRoleMenu($roleId, $menuIds)
);
}
/**
* 接口授权树:按控制器分组的全部可授权接口
* @Method GET
*/
public function endpointTree(): JsonResponse
{
return jok($this->service->endpointTree(), '获取成功');
}
/**
* 角色已授权的接口 id
* @Method GET
* @throws Exception
*/
public function getEndpointIdsByRoleIds(): JsonResponse
{
return jok($this->service->getEndpointIdsByRoleId(request()->get('role_id')));
}
/**
* 保存角色接口授权
* @Method POST
* @throws Exception
*/
public function saveRoleEndpoint(): JsonResponse
{
$this->insertField = ['role_id'];
$this->checkRequiredFields(request()->post());
return jok(
$this->service->saveRoleEndpoint(
(int) request()->post('role_id'),
(array) request()->post('endpoint_id', [])
)
);
}
/**
* 启用 / 停用角色
* @Method POST
* @throws Exception
*/
public function status(): JsonResponse
{
$this->insertField = ['id', 'status'];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功');
}
}

View File

@@ -44,4 +44,19 @@ class UploadController extends BaseController
'上传成功'
);
}
/**
* 文档上传PDF 等)
* @Method POST
* @return JsonResponse
* @throws \Exception
*/
public function file()
{
$file = request()->file('file');
return jok(
$this->service->uploadDocument($file),
'上传成功'
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\WxAppConfigService;
use Illuminate\Http\JsonResponse;
/**
* 小程序应用配置
*/
class WxAppController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = WxAppConfigService::getInstance();
$this->insertField = ['code', 'name', 'app_id'];
$this->updateField = ['id'];
$this->notRequest = [
'app_secret', 'mch_id', 'mch_key', 'mch_serial_no', 'mch_private_key',
'platform_public_key', 'notify_url', 'template_code', 'remark', 'name', 'app_id',
];
}
/**
* 启用/停用
* @Method POST
*/
public function status(): JsonResponse
{
return jok(
$this->service->status(request()->post('id'), request()->post('status')),
'操作成功'
);
}
/**
* .env 引导创建/更新一条应用壳子(不含 AppSecret
* @Method POST
*/
public function initFromEnv(): JsonResponse
{
return jok($this->service->initFromEnv(), '初始化成功');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\WxTemplateSchemaService;
use App\Service\business\WxTemplateService;
use Illuminate\Http\JsonResponse;
/**
* 小程序装修模板
*/
class WxTemplateController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = WxTemplateService::getInstance();
$this->insertField = ['name', 'code'];
$this->updateField = ['id'];
$this->notRequest = [
'preview', 'style_tag', 'tokens', 'layout', 'app_code', 'sort', 'name', 'code',
];
}
/**
* 启用/停用
* @Method POST
*/
public function status(): JsonResponse
{
return jok(
$this->service->status(request()->post('id'), request()->post('status')),
'操作成功'
);
}
/**
* 设为默认
* @Method POST
*/
public function setDefault(): JsonResponse
{
return jok($this->service->setDefault((int) request()->post('id', 0)), '已设为默认');
}
/**
* 导出模板 JSON
* @Method POST
*/
public function export(): JsonResponse
{
return jok($this->service->export((array) request()->post('ids', [])));
}
/**
* 导入模板 JSON
* @Method POST
*/
public function import(): JsonResponse
{
return jok(
$this->service->import((array) request()->post('payload', []), (bool) request()->post('overwrite', false)),
'导入完成'
);
}
/**
* 用内置 20 套预设初始化模板库
* @Method POST
*/
public function initPresets(): JsonResponse
{
return jok(
$this->service->initPresets((bool) request()->post('overwrite', false)),
'初始化完成'
);
}
/**
* 令牌与布局的可选项,前端渲染编辑表单用
* @Method GET
*/
public function schema(): JsonResponse
{
return jok([
'tokens' => WxTemplateSchemaService::TOKEN_SCHEMA,
'layout' => WxTemplateSchemaService::LAYOUT_SCHEMA,
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\WxUserService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 微信用户管理
*/
class WxUserController extends BaseController
{
/**
* 微信用户只能由小程序授权登录产生,后台没有新建入口
*/
protected array $exceptRoute = ['create'];
public function __construct()
{
parent::__construct();
$this->service = WxUserService::getInstance();
$this->updateField = ['id'];
$this->notRequest = ['nick_name', 'phone', 'enterprise_id', 'show_price', 'price_number', 'is_p'];
}
/**
* 设置 / 取消代理商身份
* @Method POST
* @throws Exception
*/
public function updateUserIsP(): JsonResponse
{
$this->insertField = ['id'];
$this->notRequest = ['is_p'];
$params = $this->checkRequiredFields(request()->post());
return jok(
$this->service->updateUserIsP($params['id'], $params['is_p'] ?? null),
'操作成功'
);
}
/**
* 设置价格倍率
* @Method POST
* @throws Exception
*/
public function updateShowPrice(): JsonResponse
{
$this->insertField = ['id', 'number'];
$this->notRequest = [];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->updateShowPrice($params['id'], $params['number']), '操作成功');
}
/**
* 单独开关价格可见性
* @Method POST
* @throws Exception
*/
public function updateShowPriceStatus(): JsonResponse
{
$this->insertField = ['id', 'show_price'];
$this->notRequest = [];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->updateShowPriceStatus($params['id'], $params['show_price']), '操作成功');
}
/**
* 绑定所属企业
* @Method POST
* @throws Exception
*/
public function bindUser(): JsonResponse
{
$this->insertField = ['id', 'enterprise_id'];
$this->notRequest = [];
$params = $this->checkRequiredFields(request()->post());
return jok($this->service->bindUser($params['id'], $params['enterprise_id']), '绑定成功');
}
/**
* 经销商绑定专属装修模板template_code =跟随品牌默认)
* @Method POST
* @throws Exception
*/
public function bindTemplate(): JsonResponse
{
$this->insertField = ['id'];
$this->notRequest = ['template_code'];
$params = $this->checkRequiredFields(request()->post());
return jok(
$this->service->bindTemplate($params['id'], $params['template_code'] ?? ''),
'模板绑定成功'
);
}
/**
* 该代理商名下的下级用户
* @Method GET
*/
public function children(): JsonResponse
{
return jok($this->service->children((int) request()->get('id', 0)), '列表获取成功');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxAuthService;
use Illuminate\Http\JsonResponse;
/**
* 小程序登录(免鉴权)
*/
class AuthController extends BaseController
{
/**
* 继承的 CRUD 会被 autoRouteRegister 反射成路由,登录组里绝不能白送这些
*/
protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxAuthService::getInstance();
}
/**
* 微信 code 登录
* @Method POST
*/
public function login(): JsonResponse
{
return jok(
$this->service->login(request()->post()),
'登录成功'
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxHomeService;
use Illuminate\Http\JsonResponse;
/**
* 小程序首页(免鉴权)
*/
class HomeController extends BaseController
{
protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxHomeService::getInstance();
}
/**
* 首页轮播
* @Method GET
*/
public function carousel(): JsonResponse
{
return jok($this->service->carousel());
}
/**
* 一级分类
* @Method GET
*/
public function categoryList(): JsonResponse
{
return jok($this->service->categoryList((int) request()->get('pid', 0)));
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxListService;
use Illuminate\Http\JsonResponse;
/**
* 小程序清单
*/
class ListController extends BaseController
{
protected array $exceptRoute = ['option'];
public function __construct()
{
parent::__construct();
$this->service = WxListService::getInstance();
}
/**
* 我的清单
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->list());
}
/**
* 清单详情
* @Method GET
*/
public function detail(): JsonResponse
{
return jok($this->service->detail((int) request()->get('id', 0)));
}
/**
* 新建清单
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '创建成功');
}
/**
* 改清单名称与备注
* @Method POST
*/
public function update(): JsonResponse
{
return jok(
$this->service->update((int) request()->post('id', 0), request()->post()),
'保存成功'
);
}
/**
* 删除清单
* @Method POST
*/
public function delete(): JsonResponse
{
return jok($this->service->delete(request()->post('ids', [])), '删除成功');
}
/**
* 删除清单明细
* @Method POST
*/
public function deleteItem(): JsonResponse
{
return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功');
}
/**
* 加入清单
* @Method POST
*/
public function toCart(): JsonResponse
{
return jok($this->service->toCart(request()->post()), '添加成功');
}
/**
* 改明细的规格与数量
* @Method POST
*/
public function updateItem(): JsonResponse
{
return jok($this->service->updateItem(request()->post()), '保存成功');
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxOrderService;
use Illuminate\Http\JsonResponse;
/**
* 小程序订单
*/
class OrderController extends BaseController
{
protected array $exceptRoute = ['option', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxOrderService::getInstance();
}
/**
* 我的订单
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->list());
}
/**
* 订单详情
* @Method GET
*/
public function detail(): JsonResponse
{
return jok($this->service->detail((int) request()->get('id', 0)));
}
/**
* 清单转订单
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->createFromList(request()->post()), '下单成功');
}
/**
* 上传转账凭证
* @Method POST
*/
public function voucher(): JsonResponse
{
return jok($this->service->submitVoucher(request()->post()), '已提交,等待审核');
}
/**
* 调起微信支付
* @Method POST
*/
public function wechatPay(): JsonResponse
{
return jok($this->service->wechatPay(request()->post()));
}
/**
* 取消订单
* @Method POST
*/
public function cancel(): JsonResponse
{
return jok($this->service->cancel((int) request()->post('id', 0)), '已取消');
}
/**
* 确认收货
* @Method POST
*/
public function complete(): JsonResponse
{
return jok($this->service->complete((int) request()->post('id', 0)), '已完成');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxHomeService;
use App\Service\wx\WxProductService;
use Illuminate\Http\JsonResponse;
/**
* 小程序商品
*/
class ProductController extends BaseController
{
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxProductService::getInstance();
}
/**
* 商品列表
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->list());
}
/**
* 商品详情p_user_id 为代理商分享来源
* @Method GET
*/
public function detail(): JsonResponse
{
return jok($this->service->detail(
(int) request()->get('id', 0),
(int) request()->get('p_user_id', 0)
));
}
/**
* 按父级取分类
* @Method GET
*/
public function categoryListByPid(): JsonResponse
{
return jok(WxHomeService::getInstance()->categoryList((int) request()->get('pid', 0)));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxPayService;
use App\Service\wx\WxThemeService;
use Illuminate\Http\JsonResponse;
/**
* 小程序主题下发与支付回调(均免鉴权)
*/
class ThemeController extends BaseController
{
protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxThemeService::getInstance();
}
/**
* 当前主题
* @Method GET
*/
public function theme(): JsonResponse
{
return jok($this->service->current((string) request()->get('code', '')));
}
/**
* 可选风格
* @Method GET
*/
public function themeGallery(): JsonResponse
{
return jok($this->service->gallery());
}
/**
* 微信支付回调
*
* 必须用原始报文验签:先 json_decode encode 回去,字节顺序变了签名就对不上。
* @Method POST
*/
public function payNotify(): JsonResponse
{
$headers = [];
foreach (['wechatpay-timestamp', 'wechatpay-nonce', 'wechatpay-signature', 'wechatpay-serial'] as $key) {
$headers[$key] = (string) request()->header($key, '');
}
$result = WxPayService::getInstance()->handleNotify($headers, request()->getContent());
return response()->json($result);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxUserCenterService;
use Illuminate\Http\JsonResponse;
/**
* 小程序「我的」
*/
class UserController extends BaseController
{
protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxUserCenterService::getInstance();
}
/**
* 我的信息
* @Method GET
*/
public function myInfo(): JsonResponse
{
return jok($this->service->myInfo());
}
/**
* 绑定手机号
* @Method POST
*/
public function bandPhone(): JsonResponse
{
return jok($this->service->bandPhone(request()->post()), '绑定成功');
}
/**
* 修改昵称
* @Method POST
*/
public function updateNickName(): JsonResponse
{
return jok(
$this->service->updateNickName((string) request()->post('nick_name', '')),
'保存成功'
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxUploadService;
use Illuminate\Http\JsonResponse;
/**
* 小程序上传(转账凭证等)。
*
* nl.wx 中间件鉴权(小程序登录态),路径 wx/upload/image。
* 仅暴露 image 方法,避免反射注册把 BaseController create/update/delete 挂成路由。
*/
class WxUploadController extends BaseController
{
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxUploadService::getInstance();
}
/**
* 图片上传(转账凭证)
* @Method POST
*/
public function image(): JsonResponse
{
$file = request()->file('file');
return jok(
$this->service->uploadImage($file),
'上传成功'
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Middleware;
use App\Enum\ErrorEnum;
use App\Service\common\JWTService;
use App\Service\PermissionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 登录态 + 接口级权限
*
* 原来鉴权只发生在 BaseService 的构造函数里,任何不经过 BaseService 的方法就是裸奔的;
* autoRouteRegister 会把继承来的方法也注册成路由,等于白送一批未受保护的入口。
* 这里把校验前移到中间件路由一进来就拦BaseService 里那层保留做兜底。
*/
class ApiAuthMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$path = $this->normalizePath($request->path());
if (in_array($path, (array) config('nl.api.white_list', []), true)) {
return $next($request);
}
try {
$userInfo = JWTService::getInstance()->getToken()->getUserInfo();
} catch (\Throwable $e) {
return $this->deny($e->getMessage() ?: '请先登录', ErrorEnum::NOT_AUTH);
}
if (empty($userInfo['id'])) {
return $this->deny('请先登录', ErrorEnum::NOT_AUTH);
}
$roleId = (int) ($userInfo['role_id'] ?? 0);
if (!PermissionService::getInstance()->allows($roleId, $path)) {
return $this->deny('没有该操作的权限,请联系管理员', ErrorEnum::NOT_PERMISSION);
}
// 后续无需再解 token 的地方可以直接取
$request->attributes->set('nl_user', $userInfo);
return $next($request);
}
private function normalizePath(string $path): string
{
$path = trim($path, '/');
if (str_starts_with($path, 'api/')) {
$path = substr($path, 4);
}
return trim($path, '/');
}
/**
* HTTP 200、业务码表达失败,与前端 request.ts 拦截器的约定一致
*/
private function deny(string $message, ErrorEnum $code): Response
{
return response()->json([
'code' => $code->value,
'message' => $message,
'result' => [],
'type' => 'error',
]);
}
}

View File

@@ -145,6 +145,8 @@ class ApiOpLogMiddleware
$maskKeys = [
'password', 'old_password', 'new_password', 'confirm_password',
'access_key', 'secret_key', 'api_key', 'token',
// 小程序登录凭证与商户密钥同样不能落进日志表
'code', 'phone_code', 'session_key', 'app_secret', 'mch_key', 'mch_private_key',
];
foreach ($maskKeys as $k) {
if (array_key_exists($k, $all) && $all[$k] !== '' && $all[$k] !== null) {

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Enum\ErrorEnum;
use App\Service\wx\WxTokenService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 小程序登录态
*
* 老项目的 AuthMiddleware 是先 $next($request) 跑完控制器、再检查 token
* 而且只判空不验签——等于没有鉴权。这里在进控制器之前就拦。
*/
class WxAuthMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = WxTokenService::getInstance()->resolveUser($request->bearerToken());
if (empty($user)) {
return response()->json([
'code' => ErrorEnum::NOT_AUTH->value,
'message' => '请先登录',
'result' => [],
'type' => 'error',
]);
}
if ((int) ($user['status'] ?? 0) === 1) {
return response()->json([
'code' => ErrorEnum::NOT_AUTH->value,
'message' => '账号已被停用,请联系客服',
'result' => [],
'type' => 'error',
]);
}
$request->attributes->set('wx_user', $user);
return $next($request);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Jobs;
use App\Service\MaterialService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 引用扫描任务
*
* 与接口版调的是同一个 Service 方法。留这个 Job 是为了以后能挂定时(比如每天夜里扫一次):
* ref_count 越新,回收判断越准,靠人记得点按钮迟早会漏。
* 这里不改任何队列配置,默认驱动下 dispatch 等于同步执行。
*/
class ScanMediaReferencesJob implements ShouldQueue
{
use Queueable;
/**
* @param array $params 预留给 MaterialService::scanReferences 的扫描参数
*/
public function __construct(public array $params = [])
{
}
public function handle(): void
{
// 全库逐表逐字段扫,默认 30 秒执行时限根本不够
cc_set_time_limit();
MaterialService::getInstance()->scanReferences($this->params);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Jobs;
use App\Service\MaterialService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* OSS 对象同步任务
*
* 同步本身是同步接口(前端点一次跑一批),这个 Job 只是把同一个 Service 方法
* 包成可入队的形式bucket 里几万个对象时不该让人一直点按钮,
* 以后把队列驱动切成 redis 就能 dispatch 它自己跑完全量。
* 这里不改任何队列配置,默认驱动下 dispatch 等于同步执行。
*/
class SyncOssObjectsJob implements ShouldQueue
{
use Queueable;
/**
* @param array $params MaterialService::syncFromOss 同构oss_config_id / prefix / marker / limit
*/
public function __construct(public array $params = [])
{
}
/**
* 一路续拉到 finished
*
* next_marker 跟上一轮一样说明驱动没有推进(配置或权限异常),必须跳出,
* 否则这里会变成一个不停打 OSS 的死循环。
*/
public function handle(): void
{
cc_set_time_limit();
$service = MaterialService::getInstance();
$params = $this->params;
$marker = (string) ($params['marker'] ?? '');
while (true) {
$params['marker'] = $marker;
$result = $service->syncFromOss($params);
$next = (string) ($result['next_marker'] ?? '');
if ((bool) ($result['finished'] ?? true) || $next === '' || $next === $marker) {
break;
}
$marker = $next;
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\BaseApp\BaseModel;
use App\Models\business\DepartmentModel;
use Illuminate\Database\Eloquent\Relations\HasOne;
class AdminModel extends BaseModel
@@ -20,4 +21,15 @@ class AdminModel extends BaseModel
{
return $this->hasOne(RoleModel::class, 'id', 'role_id');
}
/**
* 所属部门
*
* 部门表在 business 连接cc_ 前缀Eloquent 关联可以跨连接,
* 但不能和本表做 SQL join需要 join 时只能分两次查再在 PHP 里拼。
*/
public function department(): HasOne
{
return $this->hasOne(DepartmentModel::class, 'id', 'department_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 素材文件夹nl_file_folder
*
* 只做素材的逻辑归类,与对象在 OSS 上的实际路径无关:
* 移动素材如果跟着改对象键,已经发出去的历史地址会全部 404
*/
class FileFolderModel extends BaseModel
{
protected $table = 'file_folder';
protected $guarded = [];
protected $casts = [
'pid' => 'integer',
'sort' => 'integer',
'status' => 'integer',
];
}

View File

@@ -4,14 +4,71 @@ namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 文件 / 素材表nl_file
*
* 原表只是上传流水user_id + url。素材库扩了对象键、体积、哈希与引用计数之后
* 它同时充当素材主表type / source 的取值被同步、回收、前端筛选三处共用,
* 所以固化成常量,免得三边各写一套魔法数字、还各写错一个。
*/
class FileModel extends BaseModel
{
/*
* type 取值。0~4 是老表原有语义5、6 是素材库补的:
* 图册 PDF、报价 Excel 这类文件原先全落在「其他」里没法筛。
*/
public const TYPE_IMAGE = 0;
public const TYPE_VIDEO = 1;
public const TYPE_AUDIO = 2;
public const TYPE_EXCEL = 3;
public const TYPE_ARCHIVE = 4;
public const TYPE_DOCUMENT = 5;
public const TYPE_OTHER = 6;
/** source经上传接口进来的 */
public const SOURCE_UPLOAD = 0;
/** source从 OSS 反向列举补录的历史文件 */
public const SOURCE_OSS = 1;
protected $table = 'file';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $guarded = [];
/**
* 数值列显式转型MySQL 驱动会把 bigint / int 读成字符串,
* 前端拿 size 做体积换算、拿 ref_count 判断能否回收时会被字符串坑到
*
* created_at / updated_at 不在此列BaseModel 已经用访问器格式化成了日期串,
* 再加 cast 只会让两套逻辑互相打架
*/
protected $casts = [
'user_id' => 'integer',
'oss_config_id' => 'integer',
'folder_id' => 'integer',
'type' => 'integer',
'size' => 'integer',
'width' => 'integer',
'height' => 'integer',
'ref_count' => 'integer',
'last_scan_at' => 'integer',
'source' => 'integer',
];
/**
* 扩展名归类到 type
*
* OSS 反向同步时手上只有对象键,没有上传时的 MIME只能按扩展名判断
*/
public static function typeOfExt(string $ext): int
{
return match (strtolower(trim($ext, " \t\n\r\0\x0B."))) {
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'avif', 'heic' => self::TYPE_IMAGE,
'mp4', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'webm', 'm3u8', 'ts' => self::TYPE_VIDEO,
'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'amr' => self::TYPE_AUDIO,
'xls', 'xlsx', 'csv' => self::TYPE_EXCEL,
'zip', 'rar', '7z', 'tar', 'gz', 'bz2' => self::TYPE_ARCHIVE,
'pdf', 'doc', 'docx', 'ppt', 'pptx', 'txt', 'md' => self::TYPE_DOCUMENT,
default => self::TYPE_OTHER,
};
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
class RoleEndpointRelationModel extends BaseModel
{
protected $table = 'role_endpoint_relation';
protected $guarded = [];
public $timestamps = false;
}

20
app/Models/WxAppModel.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 小程序应用配置nl_wx_app
*
* app_secret / mch_key / mch_private_key 是密文列,读取必须走 WxAppService 解密,
* 任何接口都不要直接把这三列返回给前端。
*/
class WxAppModel extends BaseModel
{
protected $table = 'wx_app';
protected $guarded = [];
protected $hidden = ['app_secret', 'mch_key', 'mch_private_key'];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 色卡分类cc_card_class
*/
class CardClassModel extends BaseBusinessModel
{
protected $table = 'card_class';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 小程序轮播图cc_carousel
*/
class CarouselModel extends BaseBusinessModel
{
protected $table = 'carousel';
protected $guarded = [];
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 商品图册cc_catalogue
*
* 老项目关系名写成 cateGory序列化出来是 cate_gory前端还得记这个拼写。
* 新后端统一叫 category列表里另外拍平成 category_name。
*/
class CatalogueModel extends BaseBusinessModel
{
protected $table = 'catalogue';
protected $guarded = [];
public function category(): BelongsTo
{
return $this->belongsTo(CategoryModel::class, 'category_id', 'id');
}
public function priceSheet(): HasMany
{
return $this->hasMany(PriceSheetModel::class, 'catalogue_id', 'id');
}
public function images(): HasMany
{
return $this->hasMany(ImageModel::class, 'catalogue_id', 'id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 商品分类cc_category
*/
class CategoryModel extends BaseBusinessModel
{
protected $table = 'category';
protected $guarded = [];
public function children(): HasMany
{
return $this->hasMany(self::class, 'pid', 'id');
}
public function catalogues(): HasMany
{
return $this->hasMany(CatalogueModel::class, 'category_id', 'id');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 色卡cc_colorcard
*
* 外键列名是 card_class / company不带 _id沿用老表不改。
*/
class ColorcardModel extends BaseBusinessModel
{
protected $table = 'colorcard';
protected $guarded = [];
public function cardClassInfo(): BelongsTo
{
return $this->belongsTo(CardClassModel::class, 'card_class', 'id');
}
public function companyInfo(): BelongsTo
{
return $this->belongsTo(CompanyModel::class, 'company', 'id');
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 色卡所属公司cc_company
*/
class CompanyModel extends BaseBusinessModel
{
protected $table = 'company';
protected $guarded = [];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 部门表模型
*
* 直接复用老系统已有的 cc_department 结构正好够用id/name/desc/status/pid/color/时间戳),
* 部门数据零迁移。账号侧通过 nl_admin.department_id 关联。
*/
class DepartmentModel extends BaseBusinessModel
{
protected $table = 'department';
protected $guarded = [];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 企业cc_enterprise
*
* 老表只有 name / logo企业管理模块会补齐联系人、税号、结算方式、默认价格倍率等列。
*/
class EnterpriseModel extends BaseBusinessModel
{
protected $table = 'enterprise';
protected $guarded = [];
public function users(): HasMany
{
return $this->hasMany(WxUserModel::class, 'enterprise_id', 'id');
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 工厂分类cc_factory_classification
*/
class FactoryClassificationModel extends BaseBusinessModel
{
protected $table = 'factory_classification';
protected $guarded = [];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 工厂产品图cc_factory_image
*
* 外键列名就叫 factory不是 factory_id沿用老表不改。
*/
class FactoryImageModel extends BaseBusinessModel
{
protected $table = 'factory_image';
protected $guarded = [];
public function factoryInfo(): BelongsTo
{
return $this->belongsTo(FactoryInfoModel::class, 'factory', 'id');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 工厂管理cc_factory_info
*
* 外键列名就叫 classification不是 classification_id沿用老表不改。
*/
class FactoryInfoModel extends BaseBusinessModel
{
protected $table = 'factory_info';
protected $guarded = [];
public function classificationInfo(): BelongsTo
{
return $this->belongsTo(FactoryClassificationModel::class, 'classification', 'id');
}
public function images(): HasMany
{
return $this->hasMany(FactoryImageModel::class, 'factory', 'id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 商品相册cc_image
*
* type 1 渲染图 / 2 实物图,小程序详情页分两个列表展示
*/
class ImageModel extends BaseBusinessModel
{
protected $table = 'image';
protected $guarded = [];
public const TYPE_RENDER = 1;
public const TYPE_PHYSICAL = 2;
public function catalogue(): BelongsTo
{
return $this->belongsTo(CatalogueModel::class, 'catalogue_id', 'id');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 清单明细cc_list_item
*
* catalogue_id 指向商品图册price_sheet_id 指向具体规格行,
* 老库没有这个字段,所以历史数据这里是 0,转订单时要提示用户补选规格。
*/
class ListItemModel extends BaseBusinessModel
{
protected $table = 'list_item';
protected $guarded = [];
public function catalogue(): BelongsTo
{
return $this->belongsTo(CatalogueModel::class, 'catalogue_id', 'id');
}
public function priceSheet(): BelongsTo
{
return $this->belongsTo(PriceSheetModel::class, 'price_sheet_id', 'id');
}
public function list(): BelongsTo
{
return $this->belongsTo(ListModel::class, 'list_id', 'id');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 清单cc_list
*
* 一个小程序用户可以有多份清单,登录时会自动建一份「默认清单」。
*/
class ListModel extends BaseBusinessModel
{
protected $table = 'list';
protected $guarded = [];
public function items(): HasMany
{
return $this->hasMany(ListItemModel::class, 'list_id', 'id');
}
public function user(): BelongsTo
{
return $this->belongsTo(WxUserModel::class, 'user_id', 'id');
}
public function enterprise(): BelongsTo
{
return $this->belongsTo(EnterpriseModel::class, 'enterprise_id', 'id');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 发货记录cc_order_delivery
*
* 一个订单可能分批发货,所以是一对多而不是订单表上的几个字段。
*/
class OrderDeliveryModel extends BaseBusinessModel
{
protected $table = 'order_delivery';
protected $guarded = [];
public function order(): BelongsTo
{
return $this->belongsTo(OrderModel::class, 'order_id', 'id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 订单明细cc_order_item
*
* 标题、封面、规格、单价全部是下单那一刻的快照。catalogue_id 只用于溯源,
* 展示金额与规格时不要回查商品表,否则后台改价会改掉历史订单。
*/
class OrderItemModel extends BaseBusinessModel
{
protected $table = 'order_item';
protected $guarded = [];
public function order(): BelongsTo
{
return $this->belongsTo(OrderModel::class, 'order_id', 'id');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 订单cc_order
*
* 金额字段一律是整数分。状态用显式状态机(见 OrderService::transition
* 不要在业务代码里散落 if-else 直接改 status。
*/
class OrderModel extends BaseBusinessModel
{
protected $table = 'order';
protected $guarded = [];
public const STATUS_UNPAID = 0;
public const STATUS_PAID = 1;
public const STATUS_SHIPPED = 2;
public const STATUS_DONE = 3;
public const STATUS_CANCELLED = 4;
public const PAY_TYPE_VOUCHER = 1;
public const PAY_TYPE_WECHAT = 2;
public const PAY_STATUS_UNPAID = 0;
public const PAY_STATUS_AUDITING = 1;
public const PAY_STATUS_PAID = 2;
public const PAY_STATUS_REJECTED = 3;
public const DELIVERY_EXPRESS = 1;
public const DELIVERY_PICKUP = 2;
public const DELIVERY_COMPANY = 3;
public function items(): HasMany
{
return $this->hasMany(OrderItemModel::class, 'order_id', 'id');
}
public function payments(): HasMany
{
return $this->hasMany(OrderPaymentModel::class, 'order_id', 'id');
}
public function deliveries(): HasMany
{
return $this->hasMany(OrderDeliveryModel::class, 'order_id', 'id');
}
public function user(): BelongsTo
{
return $this->belongsTo(WxUserModel::class, 'user_id', 'id');
}
public function enterprise(): BelongsTo
{
return $this->belongsTo(EnterpriseModel::class, 'enterprise_id', 'id');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 支付记录cc_order_payment
*
* out_trade_no 有唯一索引,微信重复推送回调时靠它保证幂等。
*/
class OrderPaymentModel extends BaseBusinessModel
{
protected $table = 'order_payment';
protected $guarded = [];
public const STATUS_AUDITING = 0;
public const STATUS_CONFIRMED = 1;
public const STATUS_REJECTED = 2;
public function order(): BelongsTo
{
return $this->belongsTo(OrderModel::class, 'order_id', 'id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 报价单cc_price_sheet
*
* 表里除 routine 外还有 6 个材质列(岩板、科技绒…),老项目 create() 从不给它们赋值、
* 前端表单列也全注释了,属于没启用的死 schema。新后端只读不写展示时按非空过滤
* 真要做多材质报价请走 cc_price_sheet_item 那套设计,不要继续往这些列上加逻辑。
*/
class PriceSheetModel extends BaseBusinessModel
{
protected $table = 'price_sheet';
protected $guarded = [];
/**
* 历史材质列,仅用于展示过滤
*/
public const MATERIAL_FIELDS = [
'routine',
'plank_of_rock',
'science_and_technology_velvet',
'imitation_leather',
'genuine_leather',
'science_and_technology_cloth',
'microfiber_skin',
];
public function catalogue(): BelongsTo
{
return $this->belongsTo(CatalogueModel::class, 'catalogue_id', 'id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 小程序装修模板cc_wx_template
*
* tokens 存设计令牌(配色/字号/圆角/阴影/间距/动效曲线与时长),
* layout 存页面骨架(每个页面用哪种排布)。小程序不支持动态 <style>
* 所以令牌最终是注入到根节点的 CSS 变量,不能塞任意 CSS 文本进来。
*/
class WxTemplateModel extends BaseBusinessModel
{
protected $table = 'wx_template';
protected $guarded = [];
protected $casts = [
'tokens' => 'array',
'layout' => 'array',
];
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 小程序用户cc_wx_user
*
* is_p 代理商 / pid 上级代理商 / price_number 价格倍率 / show_price 价格是否可见 /
* template_code 经销商专属装修模板(空=跟随品牌默认)。
* session_key 是微信会话密钥,只能留在服务端,任何接口都不要把它输出出去。
*/
class WxUserModel extends BaseBusinessModel
{
protected $table = 'wx_user';
protected $guarded = [];
protected $hidden = ['session_key'];
public function enterprise(): BelongsTo
{
return $this->belongsTo(EnterpriseModel::class, 'enterprise_id', 'id');
}
/**
* 该代理商名下的下级用户
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'pid', 'id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'pid', 'id');
}
}

View File

@@ -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_type0 dot 小红点 / 1 normal 文本
*/
private function badgeType(int $type): string
{
return $type === 1 ? 'normal' : 'dot';
}
/**
* nl_menu.badge_variants0 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 : [];
}
}

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

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

View File

@@ -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;

View File

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

View 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 / 队列里跑,那里没有 tokenHTTP 入口仍然要求登录
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;
}
}

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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('商品不存在');
}
}
}

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