更新若干功能

This commit is contained in:
2026-08-19 08:16:49 +08:00
parent 4979bb83d2
commit 72bc6502eb
56 changed files with 3627 additions and 343 deletions

View File

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

View File

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

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

View File

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

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

View File

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

View 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()), '已加入清单');
}
}

View File

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

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

View File

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

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

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

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

View File

@@ -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('登录状态已失效,请重新登录');

View File

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

View File

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

View File

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

View File

@@ -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) {

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

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

View File

@@ -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 会把价格清零
*/

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

View File

@@ -5,8 +5,8 @@ namespace App\Service\business;
/**
* 把预设的紧凑规格展开成完整设计令牌
*
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 JSON 手抄跑偏,
* 4 轻奢模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 JSON 手抄跑偏,
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
*/
class WxTemplatePresetService

View File

@@ -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/variantprops 只留安全短值
*/
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)) {

View File

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

View File

@@ -69,7 +69,11 @@ class JWTService
$payload = [
'iat' => $issuedAt,
'exp' => $expirationTime,
'data' => $data
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
'sub' => (string) ($data['id'] ?? ''),
// 与小程序 tokenscope=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);
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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::listFilesmarker 分页)
*
* 七牛只在还有下一页时才回 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 / 没填配置」两种情况前置拦掉
*
* 必须走 HTTPSSDK 默认 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);

View File

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

View File

@@ -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;
}
/**
* 当前请求声明的品牌标识
*

View File

@@ -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新用户建档并送一份默认清单

View File

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

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

View File

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

View File

@@ -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;
}
/**
* 可选风格列表,用于小程序里让用户自己换肤
*/