更新若干功能
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\BaseApp;
|
||||
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\UtilsService;
|
||||
use App\Service\wx\WxTokenService;
|
||||
|
||||
@@ -45,4 +46,40 @@ class BaseWxService extends BaseNotAuthService
|
||||
{
|
||||
return $this->userInfo['price_number'] ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开分享链接时继承代理商倍率
|
||||
*
|
||||
* 商品详情、套餐详情共用:只有「还没有上级、自己也不是代理商」的已登录用户会被绑定;
|
||||
* 已绑同一上级时同步最新倍率。未登录不写库,只影响本次会话价。
|
||||
*/
|
||||
protected function inheritAgentPrice(int $shareUserId): void
|
||||
{
|
||||
if ($shareUserId <= 0) {
|
||||
return;
|
||||
}
|
||||
$isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1;
|
||||
$pid = (int) ($this->userInfo['pid'] ?? 0);
|
||||
if ($isAgent) {
|
||||
return;
|
||||
}
|
||||
if ($pid !== 0 && $pid !== $shareUserId) {
|
||||
return;
|
||||
}
|
||||
$agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first();
|
||||
if (empty($agent) || (int) $agent['is_p'] !== 1) {
|
||||
return;
|
||||
}
|
||||
$this->userInfo['show_price'] = 1;
|
||||
$this->userInfo['price_number'] = $agent['price_number'];
|
||||
if ($this->userId <= 0) {
|
||||
return;
|
||||
}
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'pid' => $shareUserId,
|
||||
'show_price' => 1,
|
||||
'price_number' => $agent['price_number'],
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ class MaterialController extends BaseController
|
||||
*/
|
||||
public function syncFromOss(): JsonResponse
|
||||
{
|
||||
// 七牛要先查区域再列举,再加一批 insert,默认 30s 很容易被网关/PHP 掐死
|
||||
cc_set_time_limit();
|
||||
$this->insertField = ['oss_config_id'];
|
||||
$this->notRequest = ['prefix', 'marker', 'limit'];
|
||||
$params = $this->checkRequiredFields(request()->post());
|
||||
|
||||
52
app/Http/Controllers/Api/PackageController.php
Normal file
52
app/Http/Controllers/Api/PackageController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\business\PackageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 后台套餐搭配
|
||||
*/
|
||||
class PackageController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = PackageService::getInstance();
|
||||
$this->insertField = ['name'];
|
||||
$this->updateField = ['id', 'name'];
|
||||
$this->notRequest = ['cover', 'subtitle', 'remark', 'is_hot', 'sort', 'status', 'package_amount'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上架 / 下架
|
||||
* @Method POST
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
return jok(
|
||||
$this->service->status((int) request()->post('id'), request()->post('status')),
|
||||
'操作成功'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搭配明细并重算原价 / 补差价
|
||||
* @Method POST
|
||||
*/
|
||||
public function saveItems(): JsonResponse
|
||||
{
|
||||
return jok($this->service->saveItems(request()->post()), '搭配已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 搭配搜索:带回封面和规格,无有效报价的规格 selectable=false
|
||||
* @Method GET
|
||||
*/
|
||||
public function searchCatalog(): JsonResponse
|
||||
{
|
||||
return jok($this->service->searchCatalog());
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class WxAppController extends BaseController
|
||||
$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',
|
||||
'platform_public_key', 'notify_url', 'template_code', 'package_enabled', 'remark', 'name', 'app_id',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
61
app/Http/Controllers/Api/WxCardSchemeController.php
Normal file
61
app/Http/Controllers/Api/WxCardSchemeController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\business\WxCardSchemeService;
|
||||
use App\Service\business\WxTemplateSchemaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 小程序卡片画布方案
|
||||
*/
|
||||
class WxCardSchemeController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = WxCardSchemeService::getInstance();
|
||||
$this->insertField = ['name', 'code'];
|
||||
$this->updateField = ['id'];
|
||||
$this->notRequest = [
|
||||
'preview', 'layers', 'is_preset', 'app_code', 'sort', 'name', 'code', 'status',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 另存为自定义方案
|
||||
* @Method POST
|
||||
*/
|
||||
public function saveAs(): JsonResponse
|
||||
{
|
||||
return jok($this->service->saveAs(request()->post()), '已另存为方案');
|
||||
}
|
||||
|
||||
/**
|
||||
* 灌入 15 套内置预设
|
||||
* @Method POST
|
||||
*/
|
||||
public function initPresets(): JsonResponse
|
||||
{
|
||||
return jok(
|
||||
$this->service->initPresets((bool) request()->post('overwrite', false)),
|
||||
'卡片预设初始化完成'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层白名单,画布编辑器渲染控件用
|
||||
* @Method GET
|
||||
*/
|
||||
public function schema(): JsonResponse
|
||||
{
|
||||
return jok([
|
||||
'types' => WxTemplateSchemaService::LAYER_TYPES,
|
||||
'binds' => WxTemplateSchemaService::LAYER_BINDS,
|
||||
'fonts' => WxTemplateSchemaService::LAYER_FONTS,
|
||||
'faces' => WxTemplateSchemaService::LAYER_FACES,
|
||||
'assets' => WxTemplateSchemaService::DECO_ASSETS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class WxTemplateController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 用内置 20 套预设初始化模板库
|
||||
* 用内置 4 套轻奢预设初始化模板库,并顺带灌 15 套卡片方案
|
||||
* @Method POST
|
||||
*/
|
||||
public function initPresets(): JsonResponse
|
||||
@@ -78,14 +78,11 @@ class WxTemplateController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌与布局的可选项,前端渲染编辑表单用
|
||||
* 令牌与布局的可选项,装修工作室渲染表单用
|
||||
* @Method GET
|
||||
*/
|
||||
public function schema(): JsonResponse
|
||||
{
|
||||
return jok([
|
||||
'tokens' => WxTemplateSchemaService::TOKEN_SCHEMA,
|
||||
'layout' => WxTemplateSchemaService::LAYOUT_SCHEMA,
|
||||
]);
|
||||
return jok(WxTemplateSchemaService::getInstance()->describe());
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Http/Controllers/Wx/PackageController.php
Normal file
51
app/Http/Controllers/Wx/PackageController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Wx;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\wx\WxPackageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 小程序套餐(热门/列表/详情免登录,一件加入走 Service 内登录校验)
|
||||
*/
|
||||
class PackageController extends BaseController
|
||||
{
|
||||
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = WxPackageService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页热门套餐
|
||||
* @Method GET
|
||||
*/
|
||||
public function hot(): JsonResponse
|
||||
{
|
||||
return jok($this->service->hot());
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐详情,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 POST
|
||||
*/
|
||||
public function toList(): JsonResponse
|
||||
{
|
||||
return jok($this->service->toList(request()->post()), '已加入清单');
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,8 @@ class ApiOpLogMiddleware
|
||||
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
|
||||
}
|
||||
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
|
||||
return (int) ($decoded->data->id ?? 0);
|
||||
$data = \App\Service\common\JWTService::getInstance()->extractUserData($decoded);
|
||||
return (int) ($data['id'] ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
63
app/Http/Middleware/WxHttpsRewriteMiddleware.php
Normal file
63
app/Http/Middleware/WxHttpsRewriteMiddleware.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 小程序响应 HTTPS 改写
|
||||
*
|
||||
* 对齐老 lgp-wx-api ApiMiddleware::checkHttp:成功包(code=0)里递归把
|
||||
* result 中的 http:// 换成 https://,满足微信小程序对图片等资源的 HTTPS 要求。
|
||||
* 仅挂在 /api/wx/*,不改管理端接口;不写库,只做出站改写。
|
||||
*/
|
||||
class WxHttpsRewriteMiddleware
|
||||
{
|
||||
/**
|
||||
* 处理完控制器后改写成功响应的 result
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
if (!$response instanceof JsonResponse) {
|
||||
return $response;
|
||||
}
|
||||
$payload = $response->getData(true);
|
||||
if (!is_array($payload)) {
|
||||
return $response;
|
||||
}
|
||||
// 与 jok 约定一致:成功 code 为 0;字符串 "0" 一并兼容
|
||||
if (($payload['code'] ?? null) != 0 || !array_key_exists('result', $payload)) {
|
||||
return $response;
|
||||
}
|
||||
$payload['result'] = $this->checkHttp($payload['result']);
|
||||
$response->setData($payload);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归把字符串/数组/对象中的 http:// 换成 https://
|
||||
* 不用 PHPUnit 的 isObject,对象先转数组再走
|
||||
*/
|
||||
private function checkHttp(mixed $data): mixed
|
||||
{
|
||||
if (is_object($data)) {
|
||||
$data = json_decode(json_encode($data), true);
|
||||
}
|
||||
if (is_string($data)) {
|
||||
return str_replace('http://', 'https://', $data);
|
||||
}
|
||||
if (is_array($data)) {
|
||||
array_walk_recursive($data, function (&$value) {
|
||||
if (is_string($value)) {
|
||||
$value = str_replace('http://', 'https://', $value);
|
||||
}
|
||||
});
|
||||
return $data;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,29 @@ class FileModel extends BaseModel
|
||||
'source' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 从对象键取出能落库的扩展名(小写、不带点、最长 20)
|
||||
*
|
||||
* 七牛历史上会把处理参数写进文件名:
|
||||
* b_xxx.jpeg~tplv-a9rns2rl98-downsize_watermark_1_5
|
||||
* pathinfo 会把整段 ~ 后面都当成扩展名,varchar(20) 直接截断报错,
|
||||
* 整批 insert 跟着失败。这里先剥查询串和 ~ 后缀,再取真正的后缀。
|
||||
* 微信保存的 .pic / .pic_hd 按 jpg 认,否则会被打成「其他」。
|
||||
*/
|
||||
public static function extOfKey(string $key): string
|
||||
{
|
||||
$name = basename(str_replace('\\', '/', $key));
|
||||
$cut = strpbrk($name, '?~#');
|
||||
if ($cut !== false) {
|
||||
$name = substr($name, 0, strlen($name) - strlen($cut));
|
||||
}
|
||||
$ext = strtolower(trim((string) pathinfo($name, PATHINFO_EXTENSION), " \t\n\r\0\x0B."));
|
||||
if ($ext === 'pic' || $ext === 'pic_hd') {
|
||||
return 'jpg';
|
||||
}
|
||||
return $ext === '' ? '' : substr($ext, 0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展名归类到 type
|
||||
*
|
||||
@@ -62,7 +85,7 @@ class FileModel extends BaseModel
|
||||
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,
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'avif', 'heic', 'pic', 'pic_hd' => 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,
|
||||
|
||||
33
app/Models/business/PackageItemModel.php
Normal file
33
app/Models/business/PackageItemModel.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\business;
|
||||
|
||||
use App\BaseApp\BaseBusinessModel;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 套餐搭配明细(cc_package_item)
|
||||
*
|
||||
* 每行必须带 price_sheet_id,否则一件加入清单后无法按规格下单。
|
||||
*/
|
||||
class PackageItemModel extends BaseBusinessModel
|
||||
{
|
||||
protected $table = 'package_item';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PackageModel::class, 'package_id', 'id');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
24
app/Models/business/PackageModel.php
Normal file
24
app/Models/business/PackageModel.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\business;
|
||||
|
||||
use App\BaseApp\BaseBusinessModel;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 家具套餐(cc_package)
|
||||
*
|
||||
* original_amount 是明细规格价之和,package_amount 是独立售价。
|
||||
* 两列都是倍率=1 的基准分,展示/下单时再乘用户 price_number。
|
||||
*/
|
||||
class PackageModel extends BaseBusinessModel
|
||||
{
|
||||
protected $table = 'package';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PackageItemModel::class, 'package_id', 'id');
|
||||
}
|
||||
}
|
||||
21
app/Models/business/WxCardSchemeModel.php
Normal file
21
app/Models/business/WxCardSchemeModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\business;
|
||||
|
||||
use App\BaseApp\BaseBusinessModel;
|
||||
|
||||
/**
|
||||
* 小程序卡片画布方案(cc_wx_card_scheme)
|
||||
*
|
||||
* layers 是图层数组,小程序 ThemeCanvasCard 与后台画布编辑器共用同一份 JSON。
|
||||
*/
|
||||
class WxCardSchemeModel extends BaseBusinessModel
|
||||
{
|
||||
protected $table = 'wx_card_scheme';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'layers' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -124,7 +124,7 @@ class LoginService extends BaseNotAuthService
|
||||
public function logout(): array
|
||||
{
|
||||
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
|
||||
$userId = (int) ($decoded->data->id ?? 0);
|
||||
$userId = (int) (JWTService::getInstance()->extractUserData($decoded)['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
JWTService::getInstance()->revoke($userId);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class LoginService extends BaseNotAuthService
|
||||
public function refresh(): array
|
||||
{
|
||||
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
|
||||
$data = isset($decoded->data) ? (array) $decoded->data : [];
|
||||
$data = JWTService::getInstance()->extractUserData($decoded);
|
||||
$userId = (int) ($data['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录');
|
||||
|
||||
@@ -28,11 +28,11 @@ use Throwable;
|
||||
*/
|
||||
class MaterialService extends BaseService
|
||||
{
|
||||
/** 单次同步默认条数:一屏够看,又不至于把请求跑到超时 */
|
||||
private const SYNC_DEFAULT_LIMIT = 200;
|
||||
/** 单次同步默认条数:七牛列举 + 入库要压在网关超时之前 */
|
||||
private const SYNC_DEFAULT_LIMIT = 80;
|
||||
|
||||
/** 单次同步上限,防止前端把 limit 传成十万 */
|
||||
private const SYNC_MAX_LIMIT = 1000;
|
||||
/** 单次同步上限;再大前端默认 10s、1panel 60s 都容易掐断 */
|
||||
private const SYNC_MAX_LIMIT = 200;
|
||||
|
||||
/** 分批取值 / 分批回写的批大小 */
|
||||
private const CHUNK_SIZE = 1000;
|
||||
@@ -198,6 +198,7 @@ class MaterialService extends BaseService
|
||||
$result = [
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'listed' => count($items),
|
||||
'next_marker' => (string) ($page['next_marker'] ?? ''),
|
||||
'finished' => (bool) ($page['finished'] ?? true),
|
||||
];
|
||||
@@ -217,7 +218,7 @@ class MaterialService extends BaseService
|
||||
$seen[$key] = true;
|
||||
$url = (string) ($item['url'] ?? '');
|
||||
$hash = (string) ($item['hash'] ?? '');
|
||||
$ext = strtolower((string) pathinfo($key, PATHINFO_EXTENSION));
|
||||
$ext = FileModel::extOfKey($key);
|
||||
$row = $byPath[$key] ?? $byUrl[$url] ?? ($hash !== '' ? ($byHash[$hash] ?? null) : null);
|
||||
|
||||
if ($row !== null) {
|
||||
@@ -252,7 +253,10 @@ class MaterialService extends BaseService
|
||||
];
|
||||
}
|
||||
if (!empty($pending)) {
|
||||
FileModel::insert($pending);
|
||||
// 上百行一条 INSERT 容易把 SQL 撑到数兆,拆开更稳也更快提交
|
||||
foreach (array_chunk($pending, 50) as $slice) {
|
||||
FileModel::insert($slice);
|
||||
}
|
||||
$result['inserted'] = count($pending);
|
||||
}
|
||||
return $result;
|
||||
@@ -473,12 +477,33 @@ class MaterialService extends BaseService
|
||||
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'))));
|
||||
|
||||
$select = ['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id'];
|
||||
$byPath = [];
|
||||
$byUrl = [];
|
||||
$byHash = [];
|
||||
// 先只走 path 索引:OSS 拉取的对象都有真实 key,一条 whereIn 比 path/url/hash 三路 OR 快得多
|
||||
if (!empty($paths)) {
|
||||
foreach (FileModel::where('deleted_at', 0)->whereIn('path', $paths)->get($select) as $row) {
|
||||
$path = (string) $row->path;
|
||||
if ($path !== '') {
|
||||
$byPath[$path] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$missed = array_values(array_filter(
|
||||
$items,
|
||||
static fn ($item) => !isset($byPath[(string) ($item['key'] ?? '')])
|
||||
));
|
||||
if ($missed === []) {
|
||||
return [$byPath, $byUrl, $byHash];
|
||||
}
|
||||
$urls = array_values(array_unique(array_filter(array_column($missed, 'url'))));
|
||||
$hashes = array_values(array_unique(array_filter(array_column($missed, 'hash'))));
|
||||
if ($urls === [] && $hashes === []) {
|
||||
return [$byPath, $byUrl, $byHash];
|
||||
}
|
||||
$rows = FileModel::where('deleted_at', 0)
|
||||
->where(function ($q) use ($paths, $urls, $hashes) {
|
||||
$q->whereIn('path', $paths);
|
||||
->where(function ($q) use ($urls, $hashes) {
|
||||
if (!empty($urls)) {
|
||||
$q->orWhereIn('url', $urls);
|
||||
}
|
||||
@@ -488,22 +513,14 @@ class MaterialService extends BaseService
|
||||
});
|
||||
}
|
||||
})
|
||||
->get(['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']);
|
||||
|
||||
$byPath = [];
|
||||
$byUrl = [];
|
||||
$byHash = [];
|
||||
->get($select);
|
||||
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])) {
|
||||
if ((string) $row->path === '' && $hash !== '' && !isset($byHash[$hash])) {
|
||||
$byHash[$hash] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class WxAppConfigService extends BaseService
|
||||
$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',
|
||||
'template_code', 'package_enabled', 'status', 'remark', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = ['code' => '=', 'name' => 'like', 'status' => '='];
|
||||
$this->orderBy = ['name' => 'id', 'sort' => 'asc'];
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BaseApp\BaseService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\PriceSheetModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,8 @@ class ListService extends BaseService
|
||||
$this->model = ListModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark',
|
||||
'status', 'created_at', 'updated_at',
|
||||
'status', 'package_id', 'package_amount', 'original_amount',
|
||||
'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'list_no' => 'like',
|
||||
@@ -113,6 +115,13 @@ class ListService extends BaseService
|
||||
unset($item);
|
||||
$info['total_amount'] = $total;
|
||||
$info['total_amount_text'] = $price->centsToYuan($total);
|
||||
$payable = PackageQuoteService::getInstance()->listPayable(
|
||||
(int) ($info['package_id'] ?? 0),
|
||||
(int) ($info['package_amount'] ?? 0),
|
||||
(int) ($info['original_amount'] ?? 0),
|
||||
$info['items']
|
||||
);
|
||||
$info = array_merge($info, PackageQuoteService::getInstance()->withText($payable));
|
||||
return $info;
|
||||
}
|
||||
|
||||
@@ -171,8 +180,12 @@ class ListService extends BaseService
|
||||
if ($itemId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$item = ListItemModel::with('list.user')->where('id', $itemId)->where('deleted_at', 0)->first();
|
||||
if (empty($item)) {
|
||||
$this->utils->errorThrow('明细不存在');
|
||||
}
|
||||
$update = ['updated_at' => time()];
|
||||
foreach (['price_sheet_id', 'quantity', 'unit_price'] as $field) {
|
||||
foreach (['price_sheet_id', 'quantity'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$update[$field] = (int) $params[$field];
|
||||
}
|
||||
@@ -182,6 +195,20 @@ class ListService extends BaseService
|
||||
$update[$field] = (string) $params[$field];
|
||||
}
|
||||
}
|
||||
// 改规格后按该客户倍率重算当前单价,套餐清单才能按差额补差价
|
||||
$sheetId = (int) ($update['price_sheet_id'] ?? $item['price_sheet_id']);
|
||||
$material = (string) ($update['material_key'] ?? $item['material_key'] ?? 'routine');
|
||||
if ($sheetId > 0) {
|
||||
$sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first();
|
||||
if (!empty($sheet)) {
|
||||
$multiplier = $item['list']['user']['price_number'] ?? 1;
|
||||
$update['unit_price'] = PriceService::getInstance()->resolveUnitPrice(
|
||||
(string) ($sheet['routine'] ?? ''),
|
||||
$material,
|
||||
$multiplier
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListItemModel::where('id', $itemId)->update($update);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Service\business;
|
||||
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Service\business\PackageQuoteService;
|
||||
use App\Models\business\OrderDeliveryModel;
|
||||
use App\Models\business\OrderItemModel;
|
||||
use App\Models\business\OrderModel;
|
||||
@@ -118,6 +119,16 @@ class OrderCoreService
|
||||
if (empty($rows)) {
|
||||
UtilsService::getInstance()->errorThrow('清单里没有可下单的商品');
|
||||
}
|
||||
// 套餐清单:总额用套餐价 + 变更差价,明细行仍按当前规格单价快照
|
||||
if ((int) ($list['package_id'] ?? 0) > 0) {
|
||||
$payable = PackageQuoteService::getInstance()->listPayable(
|
||||
(int) $list['package_id'],
|
||||
(int) ($list['package_amount'] ?? 0),
|
||||
(int) ($list['original_amount'] ?? 0),
|
||||
$rows
|
||||
);
|
||||
$total = (int) $payable['payable_amount'];
|
||||
}
|
||||
|
||||
$orderId = 0;
|
||||
DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) {
|
||||
@@ -163,6 +174,20 @@ class OrderCoreService
|
||||
UtilsService::getInstance()->errorThrow('订单不存在');
|
||||
}
|
||||
$order = $order->toArray();
|
||||
$sourceList = ListModel::where('id', (int) ($order['list_id'] ?? 0))->first();
|
||||
if (!empty($sourceList) && (int) ($sourceList['package_id'] ?? 0) > 0) {
|
||||
$quote = PackageQuoteService::getInstance();
|
||||
$order['package_id'] = (int) $sourceList['package_id'];
|
||||
$order['package_amount'] = (int) $sourceList['package_amount'];
|
||||
$order['original_amount'] = (int) $sourceList['original_amount'];
|
||||
$order['diff_amount'] = (int) $order['total_amount'] - (int) $sourceList['package_amount'];
|
||||
$order = array_merge($order, $quote->withText([
|
||||
'package_amount' => $order['package_amount'],
|
||||
'original_amount' => $order['original_amount'],
|
||||
'diff_amount' => $order['diff_amount'],
|
||||
'payable_amount' => (int) $order['total_amount'],
|
||||
]));
|
||||
}
|
||||
$order['voucher_list'] = [];
|
||||
foreach ($order['payments'] ?? [] as $payment) {
|
||||
foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) {
|
||||
|
||||
98
app/Service/business/PackageQuoteService.php
Normal file
98
app/Service/business/PackageQuoteService.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
/**
|
||||
* 套餐报价与补差价
|
||||
*
|
||||
* 后台存倍率=1 的基准分;小程序展示/下单再乘用户倍率。
|
||||
* 清单应付用「套餐价 + 当前明细合计 − 原价快照」,删行、换规格、加散件都落在这一条公式里。
|
||||
*/
|
||||
class PackageQuoteService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台改搭配:原价变了就把差额补到套餐报价上,不允许报负价
|
||||
*/
|
||||
public function applyOriginalDiff(int $oldOriginal, int $newOriginal, int $packageAmount): int
|
||||
{
|
||||
return max(0, $packageAmount + ($newOriginal - $oldOriginal));
|
||||
}
|
||||
|
||||
/**
|
||||
* 基准价乘用户倍率,仍回整数分
|
||||
*/
|
||||
public function applyMultiplier(int $cents, mixed $multiplier = 1): int
|
||||
{
|
||||
if ($cents <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$price = PriceService::getInstance();
|
||||
$yuan = $price->centsToYuan($cents);
|
||||
return $price->yuanToCents($yuan, $multiplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐清单应付
|
||||
*
|
||||
* 未绑套餐:应付 = 当前明细合计。
|
||||
* 已绑套餐:应付 = 套餐价快照 + 当前合计 − 原价快照(删除的套餐行会从原价里扣掉)。
|
||||
*
|
||||
* @param array<int, array{unit_price?:int,quantity?:int}> $items
|
||||
* @return array{package_amount:int,original_amount:int,diff_amount:int,payable_amount:int,items_amount:int}
|
||||
*/
|
||||
public function listPayable(int $packageId, int $packageAmount, int $originalAmount, array $items): array
|
||||
{
|
||||
$itemsAmount = 0;
|
||||
foreach ($items as $item) {
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
$itemsAmount += (int) ($item['unit_price'] ?? 0) * $qty;
|
||||
}
|
||||
if ($packageId <= 0) {
|
||||
return [
|
||||
'package_amount' => 0,
|
||||
'original_amount' => $itemsAmount,
|
||||
'diff_amount' => 0,
|
||||
'payable_amount' => $itemsAmount,
|
||||
'items_amount' => $itemsAmount,
|
||||
];
|
||||
}
|
||||
$payable = max(0, $packageAmount + $itemsAmount - $originalAmount);
|
||||
return [
|
||||
'package_amount' => $packageAmount,
|
||||
'original_amount' => $originalAmount,
|
||||
'diff_amount' => $payable - $packageAmount,
|
||||
'payable_amount' => $payable,
|
||||
'items_amount' => $itemsAmount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给金额数组补 *_text 元字段,方便前端直接渲染
|
||||
*
|
||||
* @param array<string, int> $amounts
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
public function withText(array $amounts, bool $showPrice = true): array
|
||||
{
|
||||
$price = PriceService::getInstance();
|
||||
foreach (['package_amount', 'original_amount', 'diff_amount', 'payable_amount', 'items_amount', 'discount_amount'] as $key) {
|
||||
if (!array_key_exists($key, $amounts)) {
|
||||
continue;
|
||||
}
|
||||
$cents = (int) $amounts[$key];
|
||||
$amounts[$key . '_text'] = $showPrice ? $price->centsToYuan($cents) : PriceService::MASK;
|
||||
}
|
||||
return $amounts;
|
||||
}
|
||||
}
|
||||
317
app/Service/business/PackageService.php
Normal file
317
app/Service/business/PackageService.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\PackageItemModel;
|
||||
use App\Models\business\PackageModel;
|
||||
use App\Models\business\PriceSheetModel;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 后台套餐
|
||||
*
|
||||
* 明细必须带规格。保存明细时后端重算原价;套餐报价若前端没传,就按原价差额自动补。
|
||||
*/
|
||||
class PackageService extends BaseService
|
||||
{
|
||||
private MediaUrlService $media;
|
||||
|
||||
private PackageQuoteService $quote;
|
||||
|
||||
private PriceService $price;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = PackageModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'name', 'cover', 'subtitle', 'remark',
|
||||
'original_amount', 'package_amount', 'is_hot', 'sort', 'status',
|
||||
'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'name' => 'like',
|
||||
'status' => '=',
|
||||
'is_hot' => '=',
|
||||
];
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
$this->quote = PackageQuoteService::getInstance();
|
||||
$this->price = PriceService::getInstance();
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$ids = array_column($result['items'], 'id');
|
||||
$counts = PackageItemModel::whereIn('package_id', $ids)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('package_id, count(*) as total')
|
||||
->groupBy('package_id')
|
||||
->get()
|
||||
->keyBy('package_id');
|
||||
foreach ($result['items'] as &$item) {
|
||||
$item['cover'] = $this->media->toPublic($item['cover'] ?? '');
|
||||
$item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0);
|
||||
$item = $this->withAmountText($item);
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'name'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$info = PackageModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0)->orderBy('sort')->orderBy('id'),
|
||||
'items.catalogue',
|
||||
'items.priceSheet',
|
||||
])->where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('套餐不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
$info['cover'] = $this->media->toPublic($info['cover'] ?? '');
|
||||
foreach ($info['items'] as &$item) {
|
||||
$item['title'] = $item['catalogue']['title'] ?? '';
|
||||
$item['cover'] = $this->media->toPublic($item['catalogue']['cover'] ?? '');
|
||||
$item['specification'] = $item['price_sheet']['specification'] ?? '';
|
||||
$item['dimension'] = $item['price_sheet']['dimension'] ?? '';
|
||||
$item['unit_price_text'] = $this->price->centsToYuan((int) ($item['unit_price'] ?? 0));
|
||||
}
|
||||
unset($item);
|
||||
return $this->withAmountText($info);
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
$row = $this->normalizeMain($params);
|
||||
$row['created_at'] = time();
|
||||
$id = PackageModel::insertGetId($row);
|
||||
if (!empty($params['items']) && is_array($params['items'])) {
|
||||
$this->replaceItems((int) $id, $params['items'], $params);
|
||||
}
|
||||
return $this->detail($id);
|
||||
}
|
||||
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$row = $this->normalizeMain($params, (int) $id);
|
||||
$this->save($id, $row);
|
||||
return $this->detail($id);
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
$ids = (array) $ids;
|
||||
$now = time();
|
||||
PackageModel::whereIn('id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
PackageItemModel::whereIn('package_id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function status($id, $status): mixed
|
||||
{
|
||||
return $this->save($id, ['status' => (int) $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搭配搜索:商品封面 + 规格报价。
|
||||
* 单价用和保存时同一套 resolveUnitPrice,没有数字报价的规格标成不可选,避免搜到却存不进去。
|
||||
*/
|
||||
public function searchCatalog(): array
|
||||
{
|
||||
$keyword = trim((string) request()->input('keyword', ''));
|
||||
$limit = max(1, min(50, (int) request()->input('limit', 20)));
|
||||
|
||||
$query = CatalogueModel::query()
|
||||
->where('deleted_at', 0)
|
||||
->with([
|
||||
'priceSheet' => fn ($q) => $q->where('deleted_at', 0)->orderBy('id'),
|
||||
]);
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $keyword . '%';
|
||||
$query->where(function ($inner) use ($like) {
|
||||
$inner->where('title', 'like', $like)
|
||||
->orWhere('identifier', 'like', $like)
|
||||
->orWhere('alias', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('id')->limit($limit)->get()->toArray();
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$sheets = [];
|
||||
foreach ($row['price_sheet'] ?? [] as $sheet) {
|
||||
$unit = $this->price->resolveUnitPrice((string) ($sheet['routine'] ?? ''), 'routine', 1);
|
||||
$sheets[] = [
|
||||
'id' => (int) ($sheet['id'] ?? 0),
|
||||
'specification' => (string) ($sheet['specification'] ?? ''),
|
||||
'dimension' => (string) ($sheet['dimension'] ?? ''),
|
||||
'unit_price' => $unit,
|
||||
'unit_price_text' => $this->price->centsToYuan($unit),
|
||||
'selectable' => $unit > 0,
|
||||
];
|
||||
}
|
||||
$items[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => (string) ($row['title'] ?? ''),
|
||||
'identifier' => (string) ($row['identifier'] ?? ''),
|
||||
'cover' => $this->media->toPublic($row['cover'] ?? ''),
|
||||
'sheets' => $sheets,
|
||||
];
|
||||
}
|
||||
return ['items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* 整表替换搭配明细,并按原价差额补套餐报价
|
||||
*/
|
||||
public function saveItems(array $params): mixed
|
||||
{
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
$this->utils->errorThrow('请选择套餐');
|
||||
}
|
||||
$pkg = PackageModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($pkg)) {
|
||||
$this->utils->errorThrow('套餐不存在');
|
||||
}
|
||||
$this->replaceItems($id, $params['items'] ?? [], $params);
|
||||
return $this->detail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗主表字段。package_amount 按元进来,转成分;没传报价时新建默认等于原价。
|
||||
*/
|
||||
private function normalizeMain(array $params, int $id = 0): array
|
||||
{
|
||||
$row = [
|
||||
'updated_at' => time(),
|
||||
];
|
||||
if (array_key_exists('name', $params)) {
|
||||
$name = trim((string) $params['name']);
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('请填写套餐名称');
|
||||
}
|
||||
$row['name'] = $name;
|
||||
} elseif ($id === 0) {
|
||||
$this->utils->errorThrow('请填写套餐名称');
|
||||
}
|
||||
if (array_key_exists('cover', $params)) {
|
||||
$row['cover'] = $this->media->firstOf($params['cover']);
|
||||
}
|
||||
foreach (['subtitle', 'remark'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$row[$field] = (string) $params[$field];
|
||||
}
|
||||
}
|
||||
foreach (['is_hot', 'sort', 'status'] as $field) {
|
||||
if (array_key_exists($field, $params)) {
|
||||
$row[$field] = (int) $params[$field];
|
||||
}
|
||||
}
|
||||
if (array_key_exists('package_amount', $params)) {
|
||||
$row['package_amount'] = max(0, $this->price->yuanToCents($params['package_amount']));
|
||||
}
|
||||
if ($id === 0 && !isset($row['package_amount'])) {
|
||||
$row['package_amount'] = 0;
|
||||
$row['original_amount'] = 0;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验规格、写入明细、重算原价。前端传了 package_amount(元)就用它,否则按差额补。
|
||||
*/
|
||||
private function replaceItems(int $packageId, array $items, array $params): void
|
||||
{
|
||||
$pkg = PackageModel::where('id', $packageId)->where('deleted_at', 0)->first();
|
||||
$oldOriginal = (int) ($pkg['original_amount'] ?? 0);
|
||||
$oldQuote = (int) ($pkg['package_amount'] ?? 0);
|
||||
|
||||
$rows = [];
|
||||
$now = time();
|
||||
$sort = 0;
|
||||
$newOriginal = 0;
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$catalogueId = (int) ($item['catalogue_id'] ?? 0);
|
||||
$sheetId = (int) ($item['price_sheet_id'] ?? 0);
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
if ($catalogueId <= 0 || $sheetId <= 0) {
|
||||
$this->utils->errorThrow('每件商品都必须选择规格');
|
||||
}
|
||||
$sheet = PriceSheetModel::where('id', $sheetId)
|
||||
->where('catalogue_id', $catalogueId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (empty($sheet)) {
|
||||
$this->utils->errorThrow('规格不存在或不属于该商品');
|
||||
}
|
||||
$unit = $this->price->resolveUnitPrice(
|
||||
(string) ($sheet['routine'] ?? ''),
|
||||
(string) ($item['material_key'] ?? 'routine'),
|
||||
1
|
||||
);
|
||||
if ($unit <= 0) {
|
||||
$this->utils->errorThrow('规格缺少有效报价');
|
||||
}
|
||||
$newOriginal += $unit * $qty;
|
||||
$rows[] = [
|
||||
'package_id' => $packageId,
|
||||
'catalogue_id' => $catalogueId,
|
||||
'price_sheet_id' => $sheetId,
|
||||
'material_key' => (string) ($item['material_key'] ?? 'routine'),
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit,
|
||||
'sort' => (int) ($item['sort'] ?? $sort),
|
||||
'remark' => (string) ($item['remark'] ?? ''),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'deleted_at' => 0,
|
||||
];
|
||||
$sort++;
|
||||
}
|
||||
|
||||
$quote = array_key_exists('package_amount', $params)
|
||||
? max(0, $this->price->yuanToCents($params['package_amount']))
|
||||
: $this->quote->applyOriginalDiff($oldOriginal, $newOriginal, $oldQuote);
|
||||
|
||||
DB::connection('business')->transaction(function () use ($packageId, $rows, $now, $newOriginal, $quote) {
|
||||
PackageItemModel::where('package_id', $packageId)->where('deleted_at', 0)->update([
|
||||
'deleted_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if (!empty($rows)) {
|
||||
PackageItemModel::insert($rows);
|
||||
}
|
||||
PackageModel::where('id', $packageId)->update([
|
||||
'original_amount' => $newOriginal,
|
||||
'package_amount' => $quote,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 补原价/套餐价/优惠的元文字段
|
||||
*/
|
||||
private function withAmountText(array $row): array
|
||||
{
|
||||
$original = (int) ($row['original_amount'] ?? 0);
|
||||
$quote = (int) ($row['package_amount'] ?? 0);
|
||||
$row['discount_amount'] = max(0, $original - $quote);
|
||||
return $this->quote->withText($row);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,19 @@ class PriceService
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 元转分。套餐报价后台按元录入,入库前走这里,避免前端自己乘 100 对不齐。
|
||||
* 可选倍率:小程序把基准套餐价乘用户 price_number 时复用。
|
||||
*/
|
||||
public function yuanToCents(mixed $yuan, mixed $multiplier = 1): int
|
||||
{
|
||||
if (!is_numeric($yuan)) {
|
||||
return 0;
|
||||
}
|
||||
$scaled = bcmul((string) $yuan, $this->normalizeMultiplier($multiplier), 2);
|
||||
return (int) bcmul($scaled, '100', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 倍率兜底:库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零
|
||||
*/
|
||||
|
||||
233
app/Service/business/WxCardSchemeService.php
Normal file
233
app/Service/business/WxCardSchemeService.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\business;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\business\WxCardSchemeModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 卡片画布方案:15 套预设 + 运营另存为的自定义方案
|
||||
*/
|
||||
class WxCardSchemeService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->model = WxCardSchemeModel::class;
|
||||
$this->selectField = [
|
||||
'id', 'name', 'code', 'preview', 'is_preset', 'app_code',
|
||||
'sort', 'status', 'created_at', 'updated_at',
|
||||
];
|
||||
$this->queryField = [
|
||||
'name' => 'like',
|
||||
'code' => 'like',
|
||||
'app_code' => '=',
|
||||
'is_preset' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
$this->orderBy = ['name' => 'sort', 'sort' => 'asc'];
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$result = $this->getPageList();
|
||||
$ids = array_values(array_filter(array_map(
|
||||
static fn ($row) => (int) ($row['id'] ?? 0),
|
||||
$result['items'] ?? []
|
||||
)));
|
||||
if ($ids === []) {
|
||||
return $result;
|
||||
}
|
||||
$extras = WxCardSchemeModel::whereIn('id', $ids)->get(['id', 'layers'])->keyBy('id');
|
||||
foreach ($result['items'] as &$item) {
|
||||
$extra = $extras[(int) $item['id']] ?? null;
|
||||
$item['layers'] = is_array($extra?->layers) ? $extra->layers : [];
|
||||
}
|
||||
unset($item);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function option(): mixed
|
||||
{
|
||||
$this->optionField = ['id', 'name', 'code', 'is_preset'];
|
||||
return $this->getOption();
|
||||
}
|
||||
|
||||
public function detail($id): mixed
|
||||
{
|
||||
$saved = $this->selectField;
|
||||
$this->selectField = ['*'];
|
||||
try {
|
||||
return $this->getDetail($id);
|
||||
} finally {
|
||||
$this->selectField = $saved;
|
||||
}
|
||||
}
|
||||
|
||||
public function create($params): mixed
|
||||
{
|
||||
return $this->insert($this->normalize($params));
|
||||
}
|
||||
|
||||
public function update($id, $params): mixed
|
||||
{
|
||||
$row = WxCardSchemeModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (empty($row)) {
|
||||
$this->utils->errorThrow('方案不存在');
|
||||
}
|
||||
// 预设只允许改 layers / name / preview,不许改 code 以免小程序引用断裂
|
||||
if ((int) $row['is_preset'] === 1) {
|
||||
$params['code'] = $row['code'];
|
||||
$params['is_preset'] = 1;
|
||||
}
|
||||
return $this->save($id, $this->normalize($params, (int) $id));
|
||||
}
|
||||
|
||||
public function delete($ids): mixed
|
||||
{
|
||||
$hasPreset = WxCardSchemeModel::whereIn('id', (array) $ids)->where('is_preset', 1)->exists();
|
||||
if ($hasPreset) {
|
||||
$this->utils->errorThrow('内置预设不能删除,请另存为方案后再改');
|
||||
}
|
||||
return $this->del($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前图层另存为新方案(运营自由设计后的落点)
|
||||
*/
|
||||
public function saveAs(array $params): array
|
||||
{
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('请填写方案名称');
|
||||
}
|
||||
$code = trim((string) ($params['code'] ?? ''));
|
||||
if ($code === '') {
|
||||
$code = 'custom-' . substr(md5($name . microtime(true)), 0, 8);
|
||||
}
|
||||
$id = $this->insert($this->normalize([
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'preview' => (string) ($params['preview'] ?? ''),
|
||||
'layers' => $params['layers'] ?? [],
|
||||
'is_preset' => 0,
|
||||
'app_code' => (string) ($params['app_code'] ?? ''),
|
||||
'sort' => (int) ($params['sort'] ?? 100),
|
||||
'status' => 0,
|
||||
]));
|
||||
return ['id' => $id, 'code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 config/wx_card_schemes.php 灌 30 套预设
|
||||
*/
|
||||
public function initPresets(bool $overwrite = false): array
|
||||
{
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$now = time();
|
||||
DB::connection('business')->transaction(function () use ($schema, $overwrite, $now, &$inserted, &$updated, &$skipped) {
|
||||
foreach ((array) config('wx_card_schemes', []) as $index => $preset) {
|
||||
$code = (string) ($preset['code'] ?? '');
|
||||
$name = (string) ($preset['name'] ?? '');
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$layers = $schema->sanitizeLayers($preset['layers'] ?? [], true);
|
||||
$row = [
|
||||
'name' => $name,
|
||||
'code' => $code,
|
||||
'preview' => (string) ($preset['preview'] ?? ''),
|
||||
'layers' => json_encode($layers, JSON_UNESCAPED_UNICODE),
|
||||
'is_preset' => 1,
|
||||
'app_code' => '',
|
||||
'sort' => $index,
|
||||
'status' => 0,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$exists = WxCardSchemeModel::where('code', $code)->where('app_code', '')->first();
|
||||
if (!empty($exists)) {
|
||||
if (!$overwrite) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
WxCardSchemeModel::where('id', $exists['id'])->update($row);
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
$row['created_at'] = $now;
|
||||
WxCardSchemeModel::insert($row);
|
||||
$inserted++;
|
||||
}
|
||||
});
|
||||
return ['inserted' => $inserted, 'updated' => $updated, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 code 列表取方案(主题下发用);库没有时回退到配置预设
|
||||
*
|
||||
* 必须是 static:小程序 GET wx/theme 会调这里,不能走 getInstance()。
|
||||
* 本类继承 BaseService,构造函数会按后台 JWT 验 token,小程序 token 会被直接拒掉。
|
||||
*
|
||||
* @param array<int, string> $codes
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public static function mapByCodes(array $codes): array
|
||||
{
|
||||
$codes = array_values(array_unique(array_filter(array_map('strval', $codes))));
|
||||
if ($codes === []) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
try {
|
||||
$rows = WxCardSchemeModel::whereIn('code', $codes)
|
||||
->where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->get(['code', 'name', 'layers', 'is_preset']);
|
||||
foreach ($rows as $row) {
|
||||
$map[(string) $row['code']] = [
|
||||
'code' => (string) $row['code'],
|
||||
'name' => (string) $row['name'],
|
||||
'layers' => is_array($row['layers']) ? $row['layers'] : [],
|
||||
'is_preset' => (int) $row['is_preset'],
|
||||
];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 表尚未建时走配置兜底,避免主题接口 500 把小程序打白
|
||||
}
|
||||
foreach ((array) config('wx_card_schemes', []) as $preset) {
|
||||
$code = (string) ($preset['code'] ?? '');
|
||||
if ($code === '' || isset($map[$code]) || !in_array($code, $codes, true)) {
|
||||
continue;
|
||||
}
|
||||
$map[$code] = [
|
||||
'code' => $code,
|
||||
'name' => (string) ($preset['name'] ?? $code),
|
||||
'layers' => (array) ($preset['layers'] ?? []),
|
||||
'is_preset' => 1,
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function normalize(array $params, int $excludeId = 0): array
|
||||
{
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
if (array_key_exists('layers', $params)) {
|
||||
$params['layers'] = json_encode($schema->sanitizeLayers($params['layers'], true), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($params['code'])) {
|
||||
$duplicate = WxCardSchemeModel::where('code', $params['code'])
|
||||
->where('app_code', (string) ($params['app_code'] ?? ''))
|
||||
->when($excludeId > 0, fn ($query) => $query->where('id', '!=', $excludeId))
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
$this->utils->errorThrow('方案标识已存在');
|
||||
}
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace App\Service\business;
|
||||
/**
|
||||
* 把预设的紧凑规格展开成完整设计令牌
|
||||
*
|
||||
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
|
||||
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 份 JSON 手抄跑偏,
|
||||
* 4 套轻奢模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
|
||||
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免多份 JSON 手抄跑偏,
|
||||
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
|
||||
*/
|
||||
class WxTemplatePresetService
|
||||
|
||||
@@ -39,28 +39,234 @@ class WxTemplateSchemaService
|
||||
*/
|
||||
public const LAYOUT_SCHEMA = [
|
||||
'home' => [
|
||||
'hero' => ['banner', 'carousel', 'split', 'fullscreen'],
|
||||
'category' => ['grid', 'scroll', 'card', 'sidebar'],
|
||||
'product' => ['waterfall', 'list', 'grid', 'magazine'],
|
||||
'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
|
||||
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
|
||||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||||
'product' => ['waterfall', 'list', 'grid'],
|
||||
],
|
||||
'product' => [
|
||||
'gallery' => ['swiper', 'stack', 'fullbleed'],
|
||||
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
|
||||
'price' => ['inline', 'card', 'sticky'],
|
||||
'action' => ['fixed', 'inline'],
|
||||
'action' => ['fixed', 'inline', 'split'],
|
||||
],
|
||||
'list' => [
|
||||
'style' => ['card', 'table', 'timeline'],
|
||||
'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
|
||||
],
|
||||
'mine' => [
|
||||
'header' => ['gradient', 'image', 'plain'],
|
||||
'menu' => ['grid', 'list'],
|
||||
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
|
||||
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
|
||||
],
|
||||
'effect' => [
|
||||
'transition' => ['fade', 'slide', 'zoom', 'none'],
|
||||
'skeleton' => ['shimmer', 'pulse', 'none'],
|
||||
// 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换
|
||||
'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'],
|
||||
'icon' => ['line', 'fill', 'duotone', 'block'],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 装修工作室可编排的页面与区块类型
|
||||
*/
|
||||
public const PAGE_SECTION_TYPES = [
|
||||
'home' => ['search', 'hero', 'category', 'package', 'product'],
|
||||
'package' => ['list'],
|
||||
'catalog' => ['filter', 'list'],
|
||||
'search' => ['search', 'list'],
|
||||
'product' => ['gallery', 'info', 'spec', 'price', 'action'],
|
||||
'cart' => ['list'],
|
||||
'mine' => ['header', 'menu'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 各区块允许的变体(与旧 LAYOUT_SCHEMA 对齐,方便小程序复用)
|
||||
*/
|
||||
public const SECTION_VARIANTS = [
|
||||
'search' => ['bar', 'overlay', 'pill', 'float'],
|
||||
'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
|
||||
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
|
||||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||||
'product' => ['waterfall', 'list', 'grid'],
|
||||
'filter' => ['chip', 'bar', 'sidebar', 'hidden'],
|
||||
'list' => [
|
||||
'card', 'table', 'timeline', 'waterfall', 'grid', 'list',
|
||||
'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo',
|
||||
'ticket', 'stacked',
|
||||
],
|
||||
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
|
||||
'info' => ['plain', 'editorial', 'split', 'overlay'],
|
||||
'spec' => ['plain', 'table', 'chips', 'cards'],
|
||||
'price' => ['inline', 'card', 'sticky'],
|
||||
'action' => ['fixed', 'inline', 'split'],
|
||||
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
|
||||
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 同一 type 在不同页面含义不同:列表是封面+型号,清单是收藏夹卡片
|
||||
*/
|
||||
public const PAGE_SECTION_VARIANTS = [
|
||||
'home' => [
|
||||
'product' => ['waterfall', 'grid', 'list'],
|
||||
'package' => ['card', 'scroll', 'featured', 'magazine'],
|
||||
],
|
||||
'package' => [
|
||||
'list' => ['card', 'featured', 'magazine', 'list'],
|
||||
],
|
||||
'catalog' => [
|
||||
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
|
||||
],
|
||||
'search' => [
|
||||
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
|
||||
],
|
||||
'cart' => [
|
||||
'list' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
|
||||
],
|
||||
];
|
||||
|
||||
public const PAGE_SKINS = ['shop', 'magazine'];
|
||||
public const PAGE_DENSITIES = ['compact', 'regular', 'airy'];
|
||||
public const PAGE_FRAMES = ['none', 'inset', 'ornament'];
|
||||
public const CHROME_TABBAR = ['plain', 'line', 'pill', 'dot'];
|
||||
public const CHROME_TABBAR_ANIM = ['none', 'fade', 'slide', 'spring'];
|
||||
public const CHROME_NAVBAR = ['solid', 'line'];
|
||||
public const LAYER_FACES = ['sans', 'serif', 'kai', 'script'];
|
||||
public const LAYER_TONES = [
|
||||
'primary', 'primary_soft', 'primary_strong', 'accent',
|
||||
'surface', 'surface_soft', 'bg_soft', 'text', 'text_soft', 'text_muted', 'border',
|
||||
];
|
||||
|
||||
/**
|
||||
* 工作室样式画廊用的中文名
|
||||
*/
|
||||
public const SECTION_VARIANT_LABELS = [
|
||||
'search' => [
|
||||
'bar' => '搜索条',
|
||||
'overlay' => '压在封面',
|
||||
'pill' => '胶囊',
|
||||
'float' => '悬浮',
|
||||
],
|
||||
'hero' => [
|
||||
'banner' => '横幅',
|
||||
'carousel' => '轮播',
|
||||
'split' => '分栏',
|
||||
'fullscreen' => '全屏',
|
||||
'stack' => '堆叠',
|
||||
'coverflow' => '封面流',
|
||||
'fade' => '叠化',
|
||||
'peek' => '露边',
|
||||
'cube' => '立方',
|
||||
'caption' => '刊名条',
|
||||
],
|
||||
'category' => [
|
||||
'grid' => '宫格',
|
||||
'scroll' => '横滑',
|
||||
'card' => '卡片',
|
||||
'sidebar' => '侧栏',
|
||||
'pills' => '胶囊',
|
||||
'mosaic' => '马赛克',
|
||||
'featured' => '首图',
|
||||
'tile' => '瓷砖',
|
||||
'contents' => '刊页目录',
|
||||
],
|
||||
'package' => [
|
||||
'card' => '卡片',
|
||||
'scroll' => '横滑',
|
||||
'featured' => '首图',
|
||||
'magazine' => '刊名条',
|
||||
],
|
||||
'product' => [
|
||||
'waterfall' => '瀑布',
|
||||
'grid' => '双列',
|
||||
'list' => '单列',
|
||||
],
|
||||
'filter' => [
|
||||
'chip' => '分类条',
|
||||
'bar' => '仅搜索',
|
||||
'sidebar' => '侧栏',
|
||||
'hidden' => '隐藏',
|
||||
],
|
||||
'list' => [
|
||||
'waterfall' => '瀑布',
|
||||
'grid' => '双列',
|
||||
'list' => '单列',
|
||||
'masonry' => '砌石',
|
||||
'featured' => '首图',
|
||||
'shelf' => '货架',
|
||||
'compact' => '紧凑',
|
||||
'airy' => '疏朗',
|
||||
'mosaic' => '马赛克',
|
||||
'duo' => '对开',
|
||||
'card' => '卡片',
|
||||
'table' => '表格',
|
||||
'timeline' => '时间线',
|
||||
'ticket' => '票根',
|
||||
'stacked' => '层叠',
|
||||
],
|
||||
'gallery' => [
|
||||
'swiper' => '轮播',
|
||||
'stack' => '叠图',
|
||||
'fullbleed' => '全出血',
|
||||
'peek' => '露边',
|
||||
'fade' => '叠化',
|
||||
'coverflow' => '封面流',
|
||||
'mosaic' => '拼贴',
|
||||
'filmstrip' => '胶卷',
|
||||
],
|
||||
'info' => [
|
||||
'plain' => '常规',
|
||||
'editorial' => '编辑',
|
||||
'split' => '分栏',
|
||||
'overlay' => '压字',
|
||||
],
|
||||
'spec' => [
|
||||
'plain' => '规格表',
|
||||
'table' => '表格',
|
||||
'chips' => '芯片',
|
||||
'cards' => '卡片',
|
||||
],
|
||||
'price' => [
|
||||
'inline' => '跟规格走',
|
||||
'card' => '独立报价卡',
|
||||
'sticky' => '吸顶报价',
|
||||
],
|
||||
'action' => [
|
||||
'fixed' => '钉在底部',
|
||||
'inline' => '跟正文走',
|
||||
'split' => '左右拆开',
|
||||
],
|
||||
'header' => [
|
||||
'gradient' => '渐变',
|
||||
'image' => '头图',
|
||||
'plain' => '素底',
|
||||
'split' => '分栏',
|
||||
'editorial' => '编辑',
|
||||
],
|
||||
'menu' => [
|
||||
'grid' => '宫格',
|
||||
'list' => '列表',
|
||||
'card' => '卡片',
|
||||
'tile' => '瓷砖',
|
||||
'compact' => '紧凑',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 卡片图层允许的 type / bind / font
|
||||
*/
|
||||
public const LAYER_TYPES = ['photo', 'text', 'deco', 'shape'];
|
||||
public const LAYER_BINDS = ['cover', 'gallery', 'upload', 'name', 'price', 'custom'];
|
||||
public const LAYER_FONTS = ['title', 'body', 'caption', 'price'];
|
||||
public const DECO_ASSETS = [
|
||||
'paper-edge', 'pearl-line', 'walnut-frame', 'gold-foil', 'washi-1',
|
||||
'botanical', 'museum-mat', 'folio-line', 'wax-seal', 'ribbon',
|
||||
'newsprint', 'gold-corner', 'handwriting', 'arch-mat', 'double-frame',
|
||||
'inset-shadow', 'torn-edge', 'film-sprocket', 'corner-bracket',
|
||||
'emboss-plate', 'circle-crop', 'vignette', 'folio-number', 'stamp-ring',
|
||||
'lace-corner', 'thin-rule', 'shadow-deck', 'cap-rail', 'gutter-fold',
|
||||
'pearl-bead', 'vert-caption',
|
||||
];
|
||||
|
||||
/**
|
||||
* 危险片段:出现即拒绝整个值
|
||||
*/
|
||||
@@ -108,7 +314,10 @@ class WxTemplateSchemaService
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项
|
||||
* 清洗布局:旧枚举 + pages.sections + card_scheme
|
||||
*
|
||||
* 非法枚举在宽松模式退回第一项;strict(导入)直接报错。
|
||||
* pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。
|
||||
*/
|
||||
public function sanitizeLayout(mixed $layout, bool $strict = false): array
|
||||
{
|
||||
@@ -133,9 +342,362 @@ class WxTemplateSchemaService
|
||||
$clean[$page][$key] = $value;
|
||||
}
|
||||
}
|
||||
$scheme = trim((string) ($layout['card_scheme'] ?? ''));
|
||||
if ($scheme !== '' && $this->isSafeCode($scheme)) {
|
||||
$clean['card_scheme'] = $scheme;
|
||||
}
|
||||
if (isset($layout['pages']) && is_array($layout['pages'])) {
|
||||
$clean['pages'] = $this->sanitizePages($layout['pages'], $strict);
|
||||
}
|
||||
if (isset($layout['page_presets']) && is_array($layout['page_presets'])) {
|
||||
$clean['page_presets'] = $this->sanitizePagePresets($layout['page_presets']);
|
||||
}
|
||||
if (isset($layout['chrome']) && is_array($layout['chrome'])) {
|
||||
$chrome = [];
|
||||
$tabbar = (string) ($layout['chrome']['tabbar'] ?? '');
|
||||
$navbar = (string) ($layout['chrome']['navbar'] ?? '');
|
||||
$anim = (string) ($layout['chrome']['tabbar_anim'] ?? '');
|
||||
if (in_array($tabbar, self::CHROME_TABBAR, true)) {
|
||||
$chrome['tabbar'] = $tabbar;
|
||||
} elseif ($strict && $tabbar !== '') {
|
||||
UtilsService::getInstance()->errorThrow('chrome.tabbar 只能是:' . implode('/', self::CHROME_TABBAR));
|
||||
}
|
||||
if (in_array($navbar, self::CHROME_NAVBAR, true)) {
|
||||
$chrome['navbar'] = $navbar;
|
||||
} elseif ($strict && $navbar !== '') {
|
||||
UtilsService::getInstance()->errorThrow('chrome.navbar 只能是:' . implode('/', self::CHROME_NAVBAR));
|
||||
}
|
||||
if (in_array($anim, self::CHROME_TABBAR_ANIM, true)) {
|
||||
$chrome['tabbar_anim'] = $anim;
|
||||
} elseif ($strict && $anim !== '') {
|
||||
UtilsService::getInstance()->errorThrow('chrome.tabbar_anim 只能是:' . implode('/', self::CHROME_TABBAR_ANIM));
|
||||
}
|
||||
foreach (['tabbar_color', 'tabbar_color_on'] as $colorKey) {
|
||||
$raw = trim((string) ($layout['chrome'][$colorKey] ?? ''));
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->isSafeHexColor($raw)) {
|
||||
$chrome[$colorKey] = $raw;
|
||||
} elseif ($strict) {
|
||||
UtilsService::getInstance()->errorThrow('chrome.' . $colorKey . ' 必须是 #RGB 或 #RRGGBB');
|
||||
}
|
||||
}
|
||||
if ($chrome !== []) {
|
||||
$clean['chrome'] = $chrome;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗页面区块列表:只留白名单 type/variant,props 只留安全短值
|
||||
*/
|
||||
public function sanitizePages(array $pages, bool $strict = false): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
|
||||
$pageBlock = $pages[$page] ?? [];
|
||||
if (!is_array($pageBlock)) {
|
||||
continue;
|
||||
}
|
||||
$source = $pageBlock['sections'] ?? $pageBlock;
|
||||
if (!is_array($source)) {
|
||||
continue;
|
||||
}
|
||||
// 兼容 { sections: [] } 与直接数组
|
||||
$list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source;
|
||||
$sections = [];
|
||||
foreach ($list as $index => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
if (!in_array($type, $types, true)) {
|
||||
if ($strict) {
|
||||
UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$allowed = $this->variantsFor($page, $type);
|
||||
$variant = (string) ($item['variant'] ?? $allowed[0]);
|
||||
// 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改
|
||||
if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') {
|
||||
$variant = 'waterfall';
|
||||
}
|
||||
if (!in_array($variant, $allowed, true)) {
|
||||
if ($strict) {
|
||||
UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed));
|
||||
}
|
||||
$variant = $allowed[0];
|
||||
}
|
||||
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
|
||||
? (string) $item['id']
|
||||
: $type . '-' . $index;
|
||||
$sections[] = [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
'variant' => $variant,
|
||||
'visible' => !empty($item['visible']) || !array_key_exists('visible', $item),
|
||||
'props' => $this->sanitizeSectionProps($item['props'] ?? []),
|
||||
];
|
||||
}
|
||||
$row = ['sections' => $sections];
|
||||
$skin = (string) ($pageBlock['skin'] ?? '');
|
||||
$density = (string) ($pageBlock['density'] ?? '');
|
||||
$frame = (string) ($pageBlock['frame'] ?? '');
|
||||
if (in_array($skin, self::PAGE_SKINS, true)) {
|
||||
$row['skin'] = $skin;
|
||||
}
|
||||
if (in_array($density, self::PAGE_DENSITIES, true)) {
|
||||
$row['density'] = $density;
|
||||
}
|
||||
if (in_array($frame, self::PAGE_FRAMES, true)) {
|
||||
$row['frame'] = $frame;
|
||||
}
|
||||
$pageScheme = trim((string) ($pageBlock['card_scheme'] ?? ''));
|
||||
if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) {
|
||||
$row['card_scheme'] = $pageScheme;
|
||||
}
|
||||
$caption = $pageBlock['card_caption'] ?? '';
|
||||
if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) {
|
||||
$row['card_caption'] = $caption;
|
||||
}
|
||||
$clean[$page] = $row;
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗卡片图层:坐标数值化,非法 type 丢弃
|
||||
*/
|
||||
public function sanitizeLayers(mixed $layers, bool $strict = false): array
|
||||
{
|
||||
$layers = $this->toArray($layers);
|
||||
$clean = [];
|
||||
foreach ($layers as $index => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
if (!in_array($type, self::LAYER_TYPES, true)) {
|
||||
if ($strict) {
|
||||
UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
|
||||
? (string) $item['id']
|
||||
: 'layer-' . $index;
|
||||
$row = [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
'x' => $this->num($item['x'] ?? 0),
|
||||
'y' => $this->num($item['y'] ?? 0),
|
||||
'w' => $this->num($item['w'] ?? 40),
|
||||
'h' => $this->num($item['h'] ?? 40),
|
||||
'rotate' => $this->num($item['rotate'] ?? 0),
|
||||
'z' => (int) ($item['z'] ?? 1),
|
||||
'visible' => !array_key_exists('visible', $item) || !empty($item['visible']),
|
||||
'locked' => !empty($item['locked']),
|
||||
];
|
||||
if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) {
|
||||
$row['bind'] = (string) $item['bind'];
|
||||
}
|
||||
if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) {
|
||||
$row['font'] = (string) $item['font'];
|
||||
}
|
||||
foreach (['font_zh', 'font_en'] as $faceKey) {
|
||||
$face = (string) ($item[$faceKey] ?? '');
|
||||
if ($face !== '' && in_array($face, self::LAYER_FACES, true)) {
|
||||
$row[$faceKey] = $face;
|
||||
}
|
||||
}
|
||||
if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) {
|
||||
$row['asset'] = (string) $item['asset'];
|
||||
}
|
||||
$tone = (string) ($item['tone'] ?? '');
|
||||
if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) {
|
||||
$row['tone'] = $tone;
|
||||
}
|
||||
if (isset($item['fill']) && $this->isSafeValue($item['fill'])) {
|
||||
$row['fill'] = (string) $item['fill'];
|
||||
}
|
||||
if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) {
|
||||
$row['text'] = $item['text'];
|
||||
}
|
||||
if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) {
|
||||
$row['src'] = (string) $item['src'];
|
||||
}
|
||||
$clean[] = $row;
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某页某区块允许的变体:先看页面覆盖,再回落到全局
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function variantsFor(string $page, string $type): array
|
||||
{
|
||||
$override = self::PAGE_SECTION_VARIANTS[$page][$type] ?? null;
|
||||
if (is_array($override) && $override !== []) {
|
||||
return $override;
|
||||
}
|
||||
return self::SECTION_VARIANTS[$type] ?? ['plain'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作室拉一次就能画样式画廊,不必再写死中文名
|
||||
*/
|
||||
public function describe(): array
|
||||
{
|
||||
$pages = [];
|
||||
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
|
||||
$modules = [];
|
||||
foreach ($types as $type) {
|
||||
$variants = $this->variantsFor($page, $type);
|
||||
$labels = self::SECTION_VARIANT_LABELS[$type] ?? [];
|
||||
$modules[] = [
|
||||
'type' => $type,
|
||||
'variants' => array_map(static function (string $value) use ($labels) {
|
||||
return [
|
||||
'value' => $value,
|
||||
'label' => $labels[$value] ?? $value,
|
||||
];
|
||||
}, $variants),
|
||||
];
|
||||
}
|
||||
$pages[] = [
|
||||
'key' => $page,
|
||||
'modules' => $modules,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'tokens' => self::TOKEN_SCHEMA,
|
||||
'layout' => self::LAYOUT_SCHEMA,
|
||||
'pages' => self::PAGE_SECTION_TYPES,
|
||||
'variants' => self::SECTION_VARIANTS,
|
||||
'page_variants' => self::PAGE_SECTION_VARIANTS,
|
||||
'labels' => self::SECTION_VARIANT_LABELS,
|
||||
'studio' => $pages,
|
||||
'page_presets' => $this->describePagePresets(),
|
||||
'card_families' => $this->describeCardFamilies(),
|
||||
'chrome' => [
|
||||
'tabbar' => self::CHROME_TABBAR,
|
||||
'tabbar_anim' => self::CHROME_TABBAR_ANIM,
|
||||
'navbar' => self::CHROME_NAVBAR,
|
||||
],
|
||||
'layer_faces' => self::LAYER_FACES,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作室整页预设画廊:只下发 code/name/家族,套用时再取完整 sections
|
||||
*/
|
||||
public function describePagePresets(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ((array) config('wx_page_presets', []) as $page => $items) {
|
||||
foreach ((array) $items as $item) {
|
||||
if (!is_array($item) || empty($item['code'])) {
|
||||
continue;
|
||||
}
|
||||
$out[$page][] = [
|
||||
'code' => (string) $item['code'],
|
||||
'name' => (string) ($item['name'] ?? $item['code']),
|
||||
'family' => (string) ($item['family'] ?? ''),
|
||||
'skin' => (string) ($item['skin'] ?? 'shop'),
|
||||
'card_scheme' => (string) ($item['card_scheme'] ?? ''),
|
||||
'density' => (string) ($item['density'] ?? 'regular'),
|
||||
'frame' => (string) ($item['frame'] ?? 'none'),
|
||||
'hero' => $this->presetHeroHint($item),
|
||||
'gallery' => $this->presetTypeHint($item, 'gallery'),
|
||||
'list' => $this->presetListHint($item),
|
||||
'sections' => is_array($item['sections'] ?? null) ? $item['sections'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 code 取完整预设(工作室一键套用)
|
||||
*/
|
||||
public function pagePresetByCode(string $code): ?array
|
||||
{
|
||||
foreach ((array) config('wx_page_presets', []) as $items) {
|
||||
foreach ((array) $items as $item) {
|
||||
if (is_array($item) && (string) ($item['code'] ?? '') === $code) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片家族:工作室缩略旁标轻奢 / 欧美 / 手作
|
||||
*/
|
||||
public function describeCardFamilies(): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ((array) config('wx_card_schemes', []) as $item) {
|
||||
$code = (string) ($item['code'] ?? '');
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$family = (string) ($item['family'] ?? 'lux');
|
||||
$map[$code] = match ($family) {
|
||||
'editorial' => '欧美',
|
||||
'atelier' => '手作',
|
||||
default => '轻奢',
|
||||
};
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只留各页合法的预设 code
|
||||
*/
|
||||
private function sanitizePagePresets(array $presets): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach (array_keys(self::PAGE_SECTION_TYPES) as $page) {
|
||||
$code = trim((string) ($presets[$page] ?? ''));
|
||||
if ($code !== '' && $this->isSafeCode($code)) {
|
||||
$clean[$page] = $code;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function presetHeroHint(array $item): string
|
||||
{
|
||||
foreach ((array) ($item['sections'] ?? []) as $section) {
|
||||
if (($section['type'] ?? '') === 'hero') {
|
||||
return (string) ($section['variant'] ?? '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function presetListHint(array $item): string
|
||||
{
|
||||
return $this->presetTypeHint($item, 'list');
|
||||
}
|
||||
|
||||
private function presetTypeHint(array $item, string $type): string
|
||||
{
|
||||
foreach ((array) ($item['sections'] ?? []) as $section) {
|
||||
if (($section['type'] ?? '') === $type) {
|
||||
return (string) ($section['variant'] ?? '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序侧要的扁平 CSS 变量表:--color-primary 这种
|
||||
*/
|
||||
@@ -153,6 +715,58 @@ class WxTemplateSchemaService
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块 props:只留高度/列数/间距/卡片方案,避免塞任意 CSS
|
||||
*/
|
||||
private function sanitizeSectionProps(mixed $props): array
|
||||
{
|
||||
if (!is_array($props)) {
|
||||
return [];
|
||||
}
|
||||
$clean = [];
|
||||
foreach (['height', 'cols', 'spacing'] as $key) {
|
||||
if (isset($props[$key]) && is_numeric($props[$key])) {
|
||||
$clean[$key] = (int) $props[$key];
|
||||
}
|
||||
}
|
||||
if (!empty($props['card_scheme']) && $this->isSafeCode((string) $props['card_scheme'])) {
|
||||
$clean['card_scheme'] = (string) $props['card_scheme'];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** 底栏独立色只收 hex,拒绝 CSS 语句 */
|
||||
private function isSafeHexColor(string $value): bool
|
||||
{
|
||||
return preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value) === 1;
|
||||
}
|
||||
|
||||
private function isSafeCode(string $value): bool
|
||||
{
|
||||
return $value !== '' && preg_match('/^[a-zA-Z0-9_-]{1,50}$/', $value) === 1;
|
||||
}
|
||||
|
||||
private function isSafeUrl(string $value): bool
|
||||
{
|
||||
if ($value === '' || mb_strlen($value) > 500) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::FORBIDDEN as $needle) {
|
||||
if ($needle === 'url(') {
|
||||
continue;
|
||||
}
|
||||
if (stripos($value, $needle) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (bool) preg_match('/^https?:\\/\\//i', $value);
|
||||
}
|
||||
|
||||
private function num(mixed $value): float
|
||||
{
|
||||
return is_numeric($value) ? (float) $value : 0;
|
||||
}
|
||||
|
||||
private function isSafeValue(mixed $value): bool
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
|
||||
@@ -220,7 +220,8 @@ class WxTemplateService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 用内置预设初始化模板库(20 套)
|
||||
* 用内置 4 套轻奢预设初始化模板库,并灌 15 套卡片方案
|
||||
* overwrite=true 时仅覆盖同 code;旧 code 不变则保留客户已改内容
|
||||
*/
|
||||
public function initPresets(bool $overwrite = false): array
|
||||
{
|
||||
@@ -233,6 +234,7 @@ class WxTemplateService extends BaseService
|
||||
$this->setDefault((int) $first['id']);
|
||||
}
|
||||
}
|
||||
$result['card_schemes'] = WxCardSchemeService::getInstance()->initPresets($overwrite);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,11 @@ class JWTService
|
||||
$payload = [
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $expirationTime,
|
||||
'data' => $data
|
||||
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
|
||||
'sub' => (string) ($data['id'] ?? ''),
|
||||
// 与小程序 token(scope=wx)区分,两边共用 JWT_SECRET 但不能互认
|
||||
'scope' => 'admin',
|
||||
'data' => $data,
|
||||
];
|
||||
RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data));
|
||||
return JWT::encode($payload, $this->secretKey, 'HS256');
|
||||
@@ -83,9 +87,14 @@ class JWTService
|
||||
public function parseToken(): ?object
|
||||
{
|
||||
try {
|
||||
if (empty($this->token)) return UtilsService::getInstance()->notAuth('请先登录');
|
||||
if (empty($this->token)) {
|
||||
return UtilsService::getInstance()->notAuth('请先登录');
|
||||
}
|
||||
return JWT::decode($this->token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (Exception $e) {
|
||||
if ((int) $e->getCode() === 401) {
|
||||
throw $e;
|
||||
}
|
||||
return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -100,14 +109,59 @@ class JWTService
|
||||
{
|
||||
try {
|
||||
$jwt = $this->parseToken();
|
||||
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($jwt->data->id);
|
||||
if (empty($user)) return UtilsService::getInstance()->notAuth('登录状态过期');
|
||||
return json_decode($user, true);
|
||||
} catch (Exception $e) {
|
||||
$payload = json_decode(json_encode($jwt), true);
|
||||
if (is_array($payload) && ($payload['scope'] ?? '') === 'wx') {
|
||||
return UtilsService::getInstance()->notAuth('请使用后台账号登录');
|
||||
}
|
||||
$data = $this->extractUserData($jwt);
|
||||
$userId = (int) ($data['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return UtilsService::getInstance()->notAuth('Token 无效,请重新登录');
|
||||
}
|
||||
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($userId);
|
||||
if (empty($user)) {
|
||||
// Redis 会话丢了但签名有效:用 payload 顶住,避免刚登录就被踢
|
||||
return $data;
|
||||
}
|
||||
$decoded = json_decode($user, true);
|
||||
return is_array($decoded) ? $decoded : $data;
|
||||
} catch (\Throwable $e) {
|
||||
if ((int) $e->getCode() === 401) {
|
||||
throw $e;
|
||||
}
|
||||
return UtilsService::getInstance()->notAuth('【2】Token解析失败!请重新登录:'. $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从解码后的 JWT 取出用户数据
|
||||
*
|
||||
* 本系统签发的是 { data: { id, ... } };有的网关/旧 token 会把字段摊到顶层,
|
||||
* 或只留 sub。这里都认,避免再出现 Undefined property::$data。
|
||||
*/
|
||||
public function extractUserData(?object $jwt): array
|
||||
{
|
||||
if (!is_object($jwt)) {
|
||||
return [];
|
||||
}
|
||||
$payload = json_decode(json_encode($jwt), true);
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
$data = $payload['data'] ?? null;
|
||||
if (is_array($data) && $data !== []) {
|
||||
return $data;
|
||||
}
|
||||
if (is_object($data)) {
|
||||
return (array) $data;
|
||||
}
|
||||
$id = $payload['id'] ?? $payload['sub'] ?? 0;
|
||||
if ((int) $id > 0) {
|
||||
return array_merge($payload, ['id' => (int) $id]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 续签 JWT Token
|
||||
* @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效
|
||||
@@ -116,11 +170,12 @@ class JWTService
|
||||
public function refreshToken(): ?string
|
||||
{
|
||||
$decoded = $this->parseToken();
|
||||
if ($decoded === null || !property_exists($decoded, 'data')) {
|
||||
$data = $this->extractUserData($decoded);
|
||||
if (($data['id'] ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->generateToken((array)$decoded->data);
|
||||
return $this->generateToken($data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,14 @@ class MediaUrlService
|
||||
public function firstOf(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value[0] ?? '';
|
||||
$first = $value[0] ?? '';
|
||||
if (is_array($first)) {
|
||||
$value = $first['url'] ?? $first['uid'] ?? '';
|
||||
} elseif (isset($value['url'])) {
|
||||
$value = $value['url'];
|
||||
} else {
|
||||
$value = $first;
|
||||
}
|
||||
}
|
||||
return $this->toStorage(is_string($value) ? $value : '');
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ class OssRuntimeConfigService extends BaseService
|
||||
'id' => (int) $row->id,
|
||||
'driver' => (string) $row->driver,
|
||||
'name' => (string) $row->name,
|
||||
'access_key' => $enc->decryptFromStorage((string) ($row->access_key ?? ''), true),
|
||||
'secret_key' => $enc->decryptFromStorage((string) ($row->secret_key ?? ''), true),
|
||||
'access_key' => $this->decryptSecret($enc, (string) ($row->access_key ?? ''), 'AccessKey'),
|
||||
'secret_key' => $this->decryptSecret($enc, (string) ($row->secret_key ?? ''), 'SecretKey'),
|
||||
'endpoint' => (string) ($row->endpoint ?? ''),
|
||||
'region' => (string) ($row->region ?? ''),
|
||||
'bucket' => (string) ($row->bucket ?? ''),
|
||||
@@ -121,4 +121,21 @@ class OssRuntimeConfigService extends BaseService
|
||||
'extra_json' => $extra,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密库内密钥;密文在但解不开时必须说清楚,不能静默变空串
|
||||
*
|
||||
* 以前 silentFail=true,解密失败后驱动只看到空 AccessKey,
|
||||
* 素材拉取就会报「七牛云配置不完整」,运营以为没填,其实是 ENCRYPT_KEY 对不上。
|
||||
*/
|
||||
private function decryptSecret(FieldEncryptService $enc, string $raw, string $label): string
|
||||
{
|
||||
$plain = $enc->decryptFromStorage($raw, true);
|
||||
if ($raw !== '' && $plain === '' && $enc->isEncrypted($raw)) {
|
||||
$this->utils->errorThrow(
|
||||
'存储配置的' . $label . '解密失败,请到「系统配置 → 存储」重新填写密钥后保存'
|
||||
);
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ interface OssStorageInterface
|
||||
* 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿
|
||||
* PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。
|
||||
*
|
||||
* @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix
|
||||
* @param string $prefix 只列举该前缀,留空表示整个 Bucket(不套 path_prefix)
|
||||
* @param string $marker 上一页返回的 next_marker,首页传空串
|
||||
* @param int $limit 单页条数
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
@@ -48,7 +48,7 @@ interface OssStorageInterface
|
||||
/**
|
||||
* 删除对象
|
||||
*
|
||||
* @param string $key 对象键(缺 path_prefix 时由实现补齐)
|
||||
* @param string $key 对象键(素材库存的是列举/上传后的真实路径,不再补 path_prefix)
|
||||
*/
|
||||
public function deleteObject(string $key): bool;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
|
||||
$items = [];
|
||||
foreach ($page as $file) {
|
||||
$key = $this->normalizeObjectKey($file);
|
||||
$key = $this->normalizeListedKey($file);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,26 @@ namespace App\Service\common\upload;
|
||||
/**
|
||||
* 对象键规整
|
||||
*
|
||||
* 五个驱动在列举 / 删除时对 key 的处理完全一致(补 path_prefix、压斜杠、拼 domain),
|
||||
* 抽出来免得同一段逻辑在五个文件里各写一遍、各改一半。
|
||||
* 上传用 normalizeObjectKey(可补 path_prefix),
|
||||
* 列举 / 删除用 normalizeListedKey(只压斜杠,用服务端真实 key)。
|
||||
* 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。
|
||||
*/
|
||||
trait ObjectKeyNormalizeTrait
|
||||
{
|
||||
/**
|
||||
* 补上 path_prefix 并压平重复斜杠
|
||||
* 压平重复斜杠,不补 path_prefix
|
||||
*
|
||||
* 列举结果必须走这里:OSS 回的 key 就是对象真实路径。
|
||||
* 若再套一层 path_prefix,根目录的历史文件(如 b_xxx.jpg)会被改写成
|
||||
* uploads/b_xxx.jpg,素材库入库地址全 404。
|
||||
*/
|
||||
protected function normalizeListedKey(string $key): string
|
||||
{
|
||||
return ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 / 删除用:缺 path_prefix 时补上,并压平重复斜杠
|
||||
*
|
||||
* 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象,
|
||||
* 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引,
|
||||
@@ -20,7 +32,7 @@ trait ObjectKeyNormalizeTrait
|
||||
*/
|
||||
protected function normalizeObjectKey(string $key): string
|
||||
{
|
||||
$key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
@@ -32,19 +44,16 @@ trait ObjectKeyNormalizeTrait
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举前缀:调用方没给就退回 path_prefix
|
||||
* 列举前缀:只认调用方传入的值,留空 = 扫整个 Bucket
|
||||
*
|
||||
* 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库,
|
||||
* 之后走回收流程就等于跨项目删文件。
|
||||
* 以前空前缀会偷偷套配置里的 path_prefix。种子数据把七牛写成 uploads,
|
||||
* 而本站历史文件都在根目录(b_*.jpg),一拉就是空列表。
|
||||
* 素材库文案也是「留空表示整个 Bucket」,这里必须跟文案一致;
|
||||
* 要收窄范围请在同步抽屉里显式填前缀。
|
||||
*/
|
||||
protected function scopedListPrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix !== '') {
|
||||
return $this->normalizeObjectKey($prefix);
|
||||
}
|
||||
$base = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
return $base === '' ? '' : $base . '/';
|
||||
return $this->normalizeListedKey($prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ trait ObjectListXmlTrait
|
||||
$rawKey = (string) $node->Key;
|
||||
// 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值
|
||||
$lastRawKey = $rawKey;
|
||||
$key = $this->normalizeObjectKey($rawKey);
|
||||
$key = $this->normalizeListedKey($rawKey);
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
// 以 / 结尾的是控制台建目录留下的占位对象,不是素材
|
||||
continue;
|
||||
|
||||
@@ -67,7 +67,7 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
'path_prefix' => '',
|
||||
];
|
||||
|
||||
/** 同一次请求里复用,避免每页列举都去 UC 查一遍区域 */
|
||||
private ?\Qiniu\Storage\BucketManager $cachedBucketMgr = null;
|
||||
|
||||
private string $cachedBucketMgrKey = '';
|
||||
|
||||
/**
|
||||
* 注入解密后的运行时配置
|
||||
*/
|
||||
public function withConfig(array $config): static
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
$this->cachedBucketMgr = null;
|
||||
$this->cachedBucketMgrKey = '';
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -54,14 +61,17 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
* 列举空间对象(BucketManager::listFiles,marker 分页)
|
||||
*
|
||||
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
|
||||
* 列举结果的 key 必须用服务端原值,不能再套 path_prefix,
|
||||
* 否则根目录历史文件会被改写成 uploads/b_xxx.jpg。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$bucketMgr = $this->bucketManager();
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
[$ret, $err] = $bucketMgr->listFiles(
|
||||
$bucket,
|
||||
$this->scopedListPrefix($prefix),
|
||||
$listPrefix !== '' ? $listPrefix : null,
|
||||
$marker !== '' ? $marker : null,
|
||||
$this->boundedLimit($limit)
|
||||
);
|
||||
@@ -69,9 +79,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err));
|
||||
}
|
||||
|
||||
$ret = is_array($ret) ? $ret : [];
|
||||
$rawItems = $ret['items'] ?? $ret['Items'] ?? [];
|
||||
// 个别 SDK 版本成功时直接回文件数组,而不是 {items: [...]}
|
||||
if ($rawItems === [] && isset($ret[0]) && is_array($ret[0])) {
|
||||
$rawItems = $ret;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ((array) ($ret['items'] ?? []) as $row) {
|
||||
$key = $this->normalizeObjectKey((string) ($row['key'] ?? ''));
|
||||
foreach ((array) $rawItems as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->normalizeListedKey((string) ($row['key'] ?? ''));
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
continue;
|
||||
}
|
||||
@@ -81,7 +101,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
'hash' => (string) ($row['hash'] ?? ''),
|
||||
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
|
||||
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000),
|
||||
'url' => $this->publicUrlOf($key, $key),
|
||||
'url' => $this->publicUrlOf($key),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,7 +115,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
return $this->deleteFile($this->normalizeObjectKey($key));
|
||||
return $this->deleteFile($this->normalizeListedKey($key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,30 +155,50 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
|
||||
private function deleteFile(string $key): bool
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
return false;
|
||||
}
|
||||
$auth = new \Qiniu\Auth($this->config['access_key'], $this->config['secret_key']);
|
||||
$bucketMgr = new \Qiniu\Storage\BucketManager($auth);
|
||||
$err = $bucketMgr->delete($this->config['bucket'], $key);
|
||||
$err = $this->bucketManager()->delete((string) $this->config['bucket'], $key);
|
||||
return $err === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
|
||||
*
|
||||
* 必须走 HTTPS:SDK 默认 HTTP,部分机房出网只放行 443,列举会直接空失败。
|
||||
*/
|
||||
private function bucketManager(): \Qiniu\Storage\BucketManager
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
||||
}
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
|
||||
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
|
||||
$accessKey = trim((string) ($this->config['access_key'] ?? ''));
|
||||
$secretKey = trim((string) ($this->config['secret_key'] ?? ''));
|
||||
$bucket = trim((string) ($this->config['bucket'] ?? ''));
|
||||
$missing = [];
|
||||
if ($accessKey === '') {
|
||||
$missing[] = 'AccessKey';
|
||||
}
|
||||
return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey));
|
||||
if ($secretKey === '') {
|
||||
$missing[] = 'SecretKey';
|
||||
}
|
||||
if ($bucket === '') {
|
||||
$missing[] = 'Bucket';
|
||||
}
|
||||
if ($missing !== []) {
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'七牛云配置不完整,缺少:' . implode('、', $missing) . '。请到「系统配置 → 存储」重新填写后保存'
|
||||
);
|
||||
}
|
||||
$cacheKey = $accessKey . "\0" . $bucket;
|
||||
if ($this->cachedBucketMgr !== null && $this->cachedBucketMgrKey === $cacheKey) {
|
||||
return $this->cachedBucketMgr;
|
||||
}
|
||||
$sdkConfig = new \Qiniu\Config();
|
||||
$sdkConfig->useHTTPS = true;
|
||||
$this->cachedBucketMgr = new \Qiniu\Storage\BucketManager(
|
||||
new \Qiniu\Auth($accessKey, $secretKey),
|
||||
$sdkConfig
|
||||
);
|
||||
$this->cachedBucketMgrKey = $cacheKey;
|
||||
return $this->cachedBucketMgr;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +207,13 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
private function errorText(mixed $err): string
|
||||
{
|
||||
if (is_object($err) && method_exists($err, 'message')) {
|
||||
return (string) $err->message();
|
||||
$text = trim((string) $err->message());
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
if (is_object($err) && method_exists($err, 'code')) {
|
||||
return 'HTTP ' . $err->code();
|
||||
}
|
||||
if (is_object($err) || is_array($err)) {
|
||||
return (string) json_encode($err, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -64,7 +64,7 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ class WxAppService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前品牌是否启用套餐。只读开关列,不解密密钥,主题和下发列表都能安全调用。
|
||||
*/
|
||||
public function packageEnabled(): bool
|
||||
{
|
||||
$code = $this->currentCode();
|
||||
$query = WxAppModel::where('deleted_at', 0)->where('status', 0);
|
||||
$app = $code !== ''
|
||||
? (clone $query)->where('code', $code)->first(['package_enabled'])
|
||||
: $query->first(['package_enabled']);
|
||||
return (int) ($app['package_enabled'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前请求声明的品牌标识
|
||||
*
|
||||
|
||||
@@ -16,7 +16,7 @@ class WxAuthService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
private const DEFAULT_AVATAR = 'http://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png';
|
||||
private const DEFAULT_AVATAR = 'https://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png';
|
||||
|
||||
/**
|
||||
* code 登录:老用户更新 session_key,新用户建档并送一份默认清单
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Service\wx;
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\PriceSheetModel;
|
||||
use App\Service\business\PackageQuoteService;
|
||||
use App\Service\business\PriceService;
|
||||
use App\Service\business\SerialNoService;
|
||||
|
||||
@@ -78,6 +80,7 @@ class WxListService extends BaseWxService
|
||||
$info = ListModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'items.catalogue',
|
||||
'items.priceSheet',
|
||||
'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('id', $id)
|
||||
->where('user_id', $this->userId)
|
||||
@@ -93,6 +96,23 @@ class WxListService extends BaseWxService
|
||||
$multiplier = $this->priceMultiplier();
|
||||
foreach ($info['items'] as &$item) {
|
||||
$sheet = $item['catalogue']['price_sheet'] ?? [];
|
||||
$rawRoutine = '';
|
||||
foreach ($sheet as $line) {
|
||||
if ((int) ($line['id'] ?? 0) === (int) ($item['price_sheet_id'] ?? 0)) {
|
||||
$rawRoutine = (string) ($line['routine'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($rawRoutine === '') {
|
||||
$rawRoutine = (string) ($item['price_sheet']['routine'] ?? '');
|
||||
}
|
||||
if ((int) ($item['unit_price'] ?? 0) <= 0) {
|
||||
$item['unit_price'] = $price->resolveUnitPrice(
|
||||
$rawRoutine,
|
||||
(string) ($item['material_key'] ?? 'routine'),
|
||||
$multiplier
|
||||
);
|
||||
}
|
||||
if (!empty($sheet)) {
|
||||
$applied = $price->applyToRows($sheet, $showPrice, $multiplier);
|
||||
$item['catalogue']['price_sheet'] = $applied['rows'];
|
||||
@@ -102,6 +122,13 @@ class WxListService extends BaseWxService
|
||||
}
|
||||
unset($item);
|
||||
$info['is_show_price'] = $showPrice;
|
||||
$payable = PackageQuoteService::getInstance()->listPayable(
|
||||
(int) ($info['package_id'] ?? 0),
|
||||
(int) ($info['package_amount'] ?? 0),
|
||||
(int) ($info['original_amount'] ?? 0),
|
||||
$info['items']
|
||||
);
|
||||
$info = array_merge($info, PackageQuoteService::getInstance()->withText($payable, $showPrice));
|
||||
return $info;
|
||||
}
|
||||
|
||||
@@ -210,6 +237,22 @@ class WxListService extends BaseWxService
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$update['remark'] = (string) $params['remark'];
|
||||
}
|
||||
$item = ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->first();
|
||||
if (empty($item)) {
|
||||
$this->utils->errorThrow('明细不存在');
|
||||
}
|
||||
$sheetId = (int) ($update['price_sheet_id'] ?? $item['price_sheet_id']);
|
||||
$material = (string) ($update['material_key'] ?? $item['material_key'] ?? 'routine');
|
||||
if ($sheetId > 0) {
|
||||
$sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first();
|
||||
if (!empty($sheet)) {
|
||||
$update['unit_price'] = PriceService::getInstance()->resolveUnitPrice(
|
||||
(string) ($sheet['routine'] ?? ''),
|
||||
$material,
|
||||
$this->priceMultiplier()
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update);
|
||||
}
|
||||
}
|
||||
|
||||
274
app/Service/wx/WxPackageService.php
Normal file
274
app/Service/wx/WxPackageService.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\PackageItemModel;
|
||||
use App\Models\business\PackageModel;
|
||||
use App\Service\business\PackageQuoteService;
|
||||
use App\Service\business\PriceService;
|
||||
use App\Service\business\SerialNoService;
|
||||
use App\Service\common\MediaUrlService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 小程序套餐:热门/列表/详情免登录,一件加入清单要登录
|
||||
*
|
||||
* 应用没开套餐时列表直接空,避免关了开关首页还露出卡片。
|
||||
*/
|
||||
class WxPackageService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
private MediaUrlService $media;
|
||||
|
||||
private PackageQuoteService $quote;
|
||||
|
||||
private PriceService $price;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->media = MediaUrlService::getInstance();
|
||||
$this->quote = PackageQuoteService::getInstance();
|
||||
$this->price = PriceService::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页热门
|
||||
*/
|
||||
public function hot(): array
|
||||
{
|
||||
if (!$this->featureEnabled()) {
|
||||
return [];
|
||||
}
|
||||
$rows = PackageModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->where('is_hot', 1)
|
||||
->orderBy('sort')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
return $this->mapCards($rows->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐 tab 列表;keyword / name 按名称、副标题模糊查
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
if (!$this->featureEnabled()) {
|
||||
return [];
|
||||
}
|
||||
$page = max(1, (int) request()->get('page', 1));
|
||||
$pageSize = max(1, (int) request()->get('pageSize', 20));
|
||||
$keyword = trim((string) request()->get('keyword', request()->get('name', '')));
|
||||
$query = PackageModel::where('deleted_at', 0)->where('status', 0)->orderBy('sort')->orderBy('id', 'desc');
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($inner) use ($keyword) {
|
||||
$inner->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('subtitle', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query->forPage($page, $pageSize)->get()->toArray();
|
||||
return [
|
||||
'items' => $this->mapCards($rows),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'has_more' => ($page * $pageSize) < $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐详情(含搭配与价格)
|
||||
*
|
||||
* 带 p_user_id 时与商品详情同一套:代理商分享进来的普通用户继承倍率。
|
||||
*/
|
||||
public function detail(int $id, int $shareUserId = 0): array
|
||||
{
|
||||
if ($shareUserId > 0) {
|
||||
$this->inheritAgentPrice($shareUserId);
|
||||
}
|
||||
if (!$this->featureEnabled()) {
|
||||
$this->utils->errorThrow('套餐功能未启用');
|
||||
}
|
||||
$info = PackageModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0)->orderBy('sort')->orderBy('id'),
|
||||
'items.catalogue',
|
||||
'items.priceSheet',
|
||||
])->where('id', $id)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('套餐不存在或已下架');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
$show = $this->canSeePrice();
|
||||
$multiplier = $this->priceMultiplier();
|
||||
$info['cover'] = $this->media->toPublic($info['cover'] ?? '');
|
||||
$info['item_count'] = count($info['items'] ?? []);
|
||||
foreach ($info['items'] as &$item) {
|
||||
$item['title'] = $item['catalogue']['title'] ?? '';
|
||||
$item['cover'] = $this->media->toPublic($item['catalogue']['cover'] ?? '');
|
||||
$item['specification'] = $item['price_sheet']['specification'] ?? '';
|
||||
$item['dimension'] = $item['price_sheet']['dimension'] ?? '';
|
||||
$unit = $this->quote->applyMultiplier((int) ($item['unit_price'] ?? 0), $multiplier);
|
||||
$item['unit_price'] = $unit;
|
||||
$item['unit_price_text'] = $show ? $this->price->centsToYuan($unit) : PriceService::MASK;
|
||||
$item['product'] = $item['catalogue'] ?? null;
|
||||
}
|
||||
unset($item);
|
||||
return $this->withWxAmounts($info, $show, $multiplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一件加入清单:记下套餐价/原价和每行快照,供以后补差价
|
||||
*/
|
||||
public function toList(array $params): array
|
||||
{
|
||||
// 写操作必须登录,构造时 needLogin=false 是为了列表免鉴权
|
||||
if ($this->userId <= 0) {
|
||||
$this->utils->errorThrow('请先登录');
|
||||
}
|
||||
if (!$this->featureEnabled()) {
|
||||
$this->utils->errorThrow('套餐功能未启用');
|
||||
}
|
||||
$packageId = (int) ($params['package_id'] ?? 0);
|
||||
$detail = $this->detail($packageId);
|
||||
$items = $detail['items'] ?? [];
|
||||
if (empty($items)) {
|
||||
$this->utils->errorThrow('套餐还没有搭配商品');
|
||||
}
|
||||
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
$list = null;
|
||||
if ($listId > 0) {
|
||||
$list = ListModel::where('id', $listId)
|
||||
->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (empty($list)) {
|
||||
$this->utils->errorThrow('清单不存在');
|
||||
}
|
||||
$bound = (int) ($list['package_id'] ?? 0);
|
||||
if ($bound > 0 && $bound !== $packageId) {
|
||||
$this->utils->errorThrow('该清单已绑定其他套餐,请新建清单');
|
||||
}
|
||||
}
|
||||
|
||||
// detail() 已经乘过用户倍率,这里直接快照,不能再乘一次
|
||||
$packageAmount = (int) ($detail['package_amount'] ?? 0);
|
||||
$originalAmount = (int) ($detail['original_amount'] ?? 0);
|
||||
|
||||
$newListId = 0;
|
||||
DB::connection('business')->transaction(function () use (
|
||||
&$newListId, $list, $packageId, $packageAmount, $originalAmount, $items, $detail
|
||||
) {
|
||||
$now = time();
|
||||
if (empty($list)) {
|
||||
$newListId = ListModel::insertGetId([
|
||||
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
|
||||
'user_id' => $this->userId,
|
||||
'name' => (string) ($detail['name'] ?? '套餐'),
|
||||
'remark' => '',
|
||||
'package_id' => $packageId,
|
||||
'package_amount' => $packageAmount,
|
||||
'original_amount' => $originalAmount,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$newListId = (int) $list['id'];
|
||||
$update = ['updated_at' => $now];
|
||||
if ((int) ($list['package_id'] ?? 0) === 0) {
|
||||
$update['package_id'] = $packageId;
|
||||
$update['package_amount'] = $packageAmount;
|
||||
$update['original_amount'] = $originalAmount;
|
||||
}
|
||||
ListModel::where('id', $newListId)->update($update);
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$catalogueId = (int) ($item['catalogue_id'] ?? 0);
|
||||
$sheetId = (int) ($item['price_sheet_id'] ?? 0);
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
$unit = (int) ($item['unit_price'] ?? 0);
|
||||
$exists = ListItemModel::where('list_id', $newListId)
|
||||
->where('catalogue_id', $catalogueId)
|
||||
->where('price_sheet_id', $sheetId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (!empty($exists)) {
|
||||
ListItemModel::where('id', $exists['id'])->update([
|
||||
'quantity' => (int) $exists['quantity'] + $qty,
|
||||
'unit_price' => $unit,
|
||||
'package_item_id' => (int) ($item['id'] ?? 0),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
ListItemModel::insert([
|
||||
'list_id' => $newListId,
|
||||
'catalogue_id' => $catalogueId,
|
||||
'price_sheet_id' => $sheetId,
|
||||
'material_key' => (string) ($item['material_key'] ?? 'routine'),
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit,
|
||||
'package_item_id' => (int) ($item['id'] ?? 0),
|
||||
'snapshot_unit_price' => $unit,
|
||||
'snapshot_quantity' => $qty,
|
||||
'remark' => (string) ($item['remark'] ?? ''),
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return ['list_id' => $newListId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前品牌是否打开套餐
|
||||
*/
|
||||
public function featureEnabled(): bool
|
||||
{
|
||||
return WxAppService::getInstance()->packageEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片列表:封面、件数、有价权才带价格
|
||||
*/
|
||||
private function mapCards(array $rows): array
|
||||
{
|
||||
$ids = array_column($rows, 'id');
|
||||
$counts = PackageItemModel::whereIn('package_id', $ids)
|
||||
->where('deleted_at', 0)
|
||||
->selectRaw('package_id, count(*) as total')
|
||||
->groupBy('package_id')
|
||||
->get()
|
||||
->keyBy('package_id');
|
||||
$show = $this->canSeePrice();
|
||||
$multiplier = $this->priceMultiplier();
|
||||
foreach ($rows as &$row) {
|
||||
$row['cover'] = $this->media->toPublic($row['cover'] ?? '');
|
||||
$row['item_count'] = (int) ($counts[$row['id']]['total'] ?? 0);
|
||||
$row = $this->withWxAmounts($row, $show, $multiplier);
|
||||
unset($row['remark']);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把基准价乘倍率,并按价权决定是否打码
|
||||
*/
|
||||
private function withWxAmounts(array $row, bool $show, mixed $multiplier): array
|
||||
{
|
||||
$original = $this->quote->applyMultiplier((int) ($row['original_amount'] ?? 0), $multiplier);
|
||||
$package = $this->quote->applyMultiplier((int) ($row['package_amount'] ?? 0), $multiplier);
|
||||
$row['original_amount'] = $original;
|
||||
$row['package_amount'] = $package;
|
||||
$row['discount_amount'] = max(0, $original - $package);
|
||||
$row['is_show_price'] = $show;
|
||||
return $this->quote->withText($row, $show);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
use App\Models\business\ImageModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\PriceService;
|
||||
|
||||
/**
|
||||
@@ -92,34 +91,4 @@ class WxProductService extends BaseWxService
|
||||
$info['is_show_price'] = $applied['is_show_price'];
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 继承分享代理商的倍率
|
||||
*
|
||||
* 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率,
|
||||
* 代理商改了倍率下级要跟着变。
|
||||
*/
|
||||
private function inheritAgentPrice(int $shareUserId): void
|
||||
{
|
||||
$isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1;
|
||||
$pid = (int) ($this->userInfo['pid'] ?? 0);
|
||||
if ($isAgent) {
|
||||
return;
|
||||
}
|
||||
if ($pid !== 0 && $pid !== $shareUserId) {
|
||||
return;
|
||||
}
|
||||
$agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first();
|
||||
if (empty($agent) || (int) $agent['is_p'] !== 1) {
|
||||
return;
|
||||
}
|
||||
$this->userInfo['show_price'] = 1;
|
||||
$this->userInfo['price_number'] = $agent['price_number'];
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'pid' => $shareUserId,
|
||||
'show_price' => 1,
|
||||
'price_number' => $agent['price_number'],
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\WxTemplateModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\business\WxCardSchemeService;
|
||||
use App\Service\business\WxTemplatePresetService;
|
||||
use App\Service\business\WxTemplateSchemaService;
|
||||
|
||||
@@ -68,32 +69,88 @@ class WxThemeService extends BaseWxService
|
||||
$preset = WxTemplatePresetService::getInstance()->all()[0] ?? [];
|
||||
$tokens = $preset['tokens'] ?? [];
|
||||
$layout = $preset['layout'] ?? [];
|
||||
return [
|
||||
'code' => (string) ($preset['code'] ?? 'lux-champagne'),
|
||||
'name' => (string) ($preset['name'] ?? '轻奢·香槟金'),
|
||||
'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'),
|
||||
'version' => 0,
|
||||
'tokens' => $tokens,
|
||||
'layout' => $layout,
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => true,
|
||||
];
|
||||
return $this->packTheme(
|
||||
(string) ($preset['code'] ?? 'lux-champagne'),
|
||||
(string) ($preset['name'] ?? '轻奢·香槟金'),
|
||||
(string) ($preset['style_tag'] ?? '轻奢'),
|
||||
0,
|
||||
$tokens,
|
||||
$layout,
|
||||
$schema,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
$template = $template->toArray();
|
||||
$tokens = is_array($template['tokens']) ? $template['tokens'] : [];
|
||||
$layout = is_array($template['layout']) ? $template['layout'] : [];
|
||||
return $this->packTheme(
|
||||
(string) $template['code'],
|
||||
(string) $template['name'],
|
||||
(string) $template['style_tag'],
|
||||
(int) $template['version'],
|
||||
$tokens,
|
||||
$layout,
|
||||
$schema,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装主题包:令牌 + 布局 + 本模板引用到的卡片方案
|
||||
*/
|
||||
private function packTheme(
|
||||
string $code,
|
||||
string $name,
|
||||
string $styleTag,
|
||||
int $version,
|
||||
array $tokens,
|
||||
array $layout,
|
||||
WxTemplateSchemaService $schema,
|
||||
bool $fallback,
|
||||
): array {
|
||||
return [
|
||||
'code' => (string) $template['code'],
|
||||
'name' => (string) $template['name'],
|
||||
'style_tag' => (string) $template['style_tag'],
|
||||
'version' => (int) $template['version'],
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'style_tag' => $styleTag,
|
||||
'version' => $version,
|
||||
'tokens' => $tokens,
|
||||
'layout' => is_array($template['layout']) ? $template['layout'] : [],
|
||||
'layout' => $layout,
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => false,
|
||||
'card_schemes' => WxCardSchemeService::mapByCodes($this->collectSchemeCodes($layout)),
|
||||
'features' => [
|
||||
'package_enabled' => WxAppService::getInstance()->packageEnabled(),
|
||||
],
|
||||
'fallback' => $fallback,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从根 card_scheme、页级 pages.*.card_scheme、区块 props.card_scheme 收集引用
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function collectSchemeCodes(array $layout): array
|
||||
{
|
||||
$codes = [];
|
||||
if (!empty($layout['card_scheme'])) {
|
||||
$codes[] = (string) $layout['card_scheme'];
|
||||
}
|
||||
foreach ((array) ($layout['pages'] ?? []) as $page) {
|
||||
$pageScheme = (string) ($page['card_scheme'] ?? '');
|
||||
if ($pageScheme !== '') {
|
||||
$codes[] = $pageScheme;
|
||||
}
|
||||
foreach ((array) ($page['sections'] ?? []) as $section) {
|
||||
$code = (string) ($section['props']['card_scheme'] ?? '');
|
||||
if ($code !== '') {
|
||||
$codes[] = $code;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选风格列表,用于小程序里让用户自己换肤
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user