初始化

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

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Service\wx;
use App\Models\WxAppModel;
use App\Service\common\FieldEncryptService;
use App\Service\common\UtilsService;
/**
* 小程序应用凭证读取
*
* 请求头 X-App-Code或参数 app_code决定用哪个品牌的 AppID。
* 没带就用 .env WX_DEFAULT_APP_CODE 兜底;都没有就取唯一启用的那条。
* 库表nl_wx_app始终是主源.env 只在「表里完全没有匹配行」时回落兜底,
* 这样单品牌部署可以不进后台配置就跑起来,双品牌共用进程仍以表 + 请求头分流为主。
*/
class WxAppService
{
private static mixed $_instance;
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 取当前请求对应的应用配置(含解密后的密钥)
*
* 解析顺序:
* 1. code请求头 .env 默认值)查 nl_wx_app
* 2. 查不到且 .env 配了 AppID返回仅含 app_id/name/code 的兜底配置,
* 密钥仍尝试从表里「按 app_id 匹配」的那行解密;表里也没有就抛错
* 指引运维去后台填写——密钥不能从 .env 读,安全红线
* 3. .env 也没配:直接抛错
*/
public function current(): array
{
$code = $this->currentCode();
$query = WxAppModel::where('deleted_at', 0)->where('status', 0);
$app = $code !== '' ? $query->where('code', $code)->first() : $query->first();
// 库表命中:解密密钥列后返回
if (!empty($app)) {
return $this->loadWithSecrets($app);
}
// 库表未命中,回落 .env仅 AppID/名称,不含密钥)
$envAppId = trim((string) config('nl.wx.env_app_id', ''));
if ($envAppId === '') {
// .env 没配 AppID 就没法兜底了,直接抛错
UtilsService::getInstance()->errorThrow('未配置小程序应用nl_wx_app请先在后台添加或在 .env 配 WX_APP_ID');
}
$envCode = trim((string) config('nl.wx.default_app_code', ''));
$envName = trim((string) config('nl.wx.env_app_name', ''));
// 兜底场景AppID 来自 .env但密钥仍只能从库里按 AppID 找一行来解密
// 这样老项目硬编码密钥的脏习惯不会回到 .env密钥始终加密入库
$secretRow = WxAppModel::where('app_id', $envAppId)
->where('deleted_at', 0)
->first(['app_secret', 'mch_key', 'mch_private_key']);
if (empty($secretRow) || trim((string) ($secretRow['app_secret'] ?? '')) === '') {
// 表里既没匹配行、或行里没填密钥,只能报错让运维去后台补
UtilsService::getInstance()->errorThrow(
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,新增/编辑 AppID=' .
$envAppId .
' 的记录并填写 AppSecret密钥加密入库不要写进 .env'
);
}
$encrypt = FieldEncryptService::getInstance();
return [
'id' => 0,
'code' => $envCode,
'name' => $envName,
'app_id' => $envAppId,
'app_secret' => $encrypt->decryptFromStorage((string) $secretRow['app_secret'], true),
'mch_id' => '',
'mch_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_key'] ?? ''), true),
'mch_serial_no' => '',
'mch_private_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_private_key'] ?? ''), true),
'platform_public_key' => '',
'notify_url' => '',
'template_code' => '',
];
}
/**
* 当前请求声明的品牌标识
*
* 优先级:请求头 X-App-Code > 请求参数 app_code > .env WX_DEFAULT_APP_CODE
* 全部为空时返回空串,调用方会改走「取唯一启用行」逻辑
*/
public function currentCode(): string
{
$code = (string) (request()->header('X-App-Code') ?: request()->input('app_code', ''));
$code = trim($code);
if ($code !== '') {
return $code;
}
// 请求没带品牌标识时回落 .env 默认值(单品牌部署兜底)
return trim((string) config('nl.wx.default_app_code', ''));
}
/**
* 把模型行的密钥列解密后转数组返回
*/
private function loadWithSecrets($app): array
{
$app = $app->toArray();
$raw = WxAppModel::where('id', $app['id'])->first(['app_secret', 'mch_key', 'mch_private_key']);
$encrypt = FieldEncryptService::getInstance();
$secret = $encrypt->decryptFromStorage($raw['app_secret'] ?? '', true);
if (trim((string) $secret) === '') {
UtilsService::getInstance()->errorThrow(
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,编辑 code=' .
($app['code'] ?? '') .
'AppID=' . ($app['app_id'] ?? '') . ')并填写 AppSecret'
);
}
$app['app_secret'] = $secret;
$app['mch_key'] = $encrypt->decryptFromStorage($raw['mch_key'] ?? '', true);
$app['mch_private_key'] = $encrypt->decryptFromStorage($raw['mch_private_key'] ?? '', true);
return $app;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\ListModel;
use App\Models\business\WxUserModel;
use App\Service\business\SerialNoService;
/**
* 小程序登录
*
* 返回的 token 是服务端自签的(见 WxTokenServicesession_key 只写库不外发。
*/
class WxAuthService extends BaseWxService
{
protected bool $needLogin = false;
private const DEFAULT_AVATAR = 'http://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png';
/**
* code 登录:老用户更新 session_key新用户建档并送一份默认清单
*/
public function login(array $params): array
{
$code = trim((string) ($params['code'] ?? ''));
if ($code === '') {
$this->utils->errorThrow('缺少 code');
}
$credentials = WxMiniService::getInstance()->jscode2session($code);
$openId = (string) $credentials['openid'];
$now = time();
$user = WxUserModel::where('open_id', $openId)->first();
if (empty($user)) {
$userId = WxUserModel::insertGetId([
'open_id' => $openId,
'session_key' => (string) ($credentials['session_key'] ?? ''),
'avatar' => $params['avatar'] ?? self::DEFAULT_AVATAR,
'nick_name' => $params['nick_name'] ?? '微信用户',
'phone' => '',
'created_at' => $now,
]);
if (empty($userId)) {
$this->utils->errorThrow('用户创建失败');
}
ListModel::insert([
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
'user_id' => $userId,
'name' => '默认清单',
'remark' => '系统默认生成的清单',
'created_at' => $now,
]);
} else {
$userId = (int) $user['id'];
WxUserModel::where('id', $userId)->update([
'session_key' => (string) ($credentials['session_key'] ?? ''),
'updated_at' => $now,
]);
}
$userInfo = WxUserModel::where('id', $userId)->with('enterprise')->first();
$userInfo = $userInfo ? $userInfo->toArray() : [];
unset($userInfo['session_key']);
return [
'token' => WxTokenService::getInstance()->issue((int) $userId, (string) ($credentials['app_code'] ?? '')),
'user_info' => $userInfo,
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\CarouselModel;
use App\Models\business\CategoryModel;
/**
* 小程序首页:轮播与一级分类,都不要求登录
*/
class WxHomeService extends BaseWxService
{
protected bool $needLogin = false;
public function carousel(): mixed
{
// 只返回启用中的轮播;后台 status=1 表示隐藏
return CarouselModel::where('deleted_at', 0)
->where('status', 0)
->orderBy('sort', 'asc')
->get(['id', 'url', 'to_path', 'sort']);
}
/**
* 分类列表pid=0 为一级
* sort 升序,同值按 id需已执行 05_cc_category_add_sort.sql
*/
public function categoryList(int $pid = 0): mixed
{
return CategoryModel::where('pid', $pid)
->where('deleted_at', 0)
->orderBy('sort', 'asc')
->orderBy('id', 'asc')
->get();
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\ListItemModel;
use App\Models\business\ListModel;
use App\Service\business\PriceService;
use App\Service\business\SerialNoService;
/**
* 小程序清单
*
* 清单是「加购物车」的语义但不含结算,下单由 WxOrderService 从清单快照生成。
* 所有写操作都必须带 user_id 条件,否则改个 id 就能删别人的清单——老项目的 deleteItem 就是这个漏洞。
*/
class WxListService extends BaseWxService
{
/**
* 我的清单,带明细数量
*
* 兼容两种返回:
* - 不传 page / pageSize老调用方返回全量扁平数组保持向后兼容
* - 传了 page / pageSize返回 { items, total, page, pageSize, has_more },供小程序真分页使用。
* 判断「是否分页模式」只看请求里有没有 page避免 pageSize 默认值造成歧义。
*/
public function list(): array
{
// 请求里带了 page 才走分页分支pageSize 缺省 20与列表页约定一致
$page = request()->get('page');
$paged = $page !== null && $page !== '';
$page = max(1, (int) $page);
$pageSize = max(1, (int) (request()->get('pageSize') ?? 20));
$base = ListModel::with([
'items' => fn ($query) => $query->where('deleted_at', 0)->select(['id', 'list_id']),
])->where('user_id', $this->userId)
->where('deleted_at', 0)
->orderBy('id', 'desc');
// 不分页:一次拿全,保持老结构
if (!$paged) {
$rows = $base->get();
if ($rows->isEmpty()) {
return [];
}
$rows = $rows->toArray();
foreach ($rows as &$row) {
$row['count'] = count($row['items'] ?? []);
}
unset($row);
return $rows;
}
// 分页:拿当前页 + 总数,前端按 has_more 决定是否继续 loadMore
$total = (clone $base)->count();
$rows = $base->forPage($page, $pageSize)->get();
$items = $rows->isEmpty() ? [] : $rows->toArray();
foreach ($items as &$row) {
$row['count'] = count($row['items'] ?? []);
}
unset($row);
return [
'items' => $items,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'has_more' => ($page * $pageSize) < $total,
];
}
/**
* 清单详情:带商品与规格,价格按当前用户的可见性与倍率处理
*/
public function detail(int $id): array
{
$info = ListModel::with([
'items' => fn ($query) => $query->where('deleted_at', 0),
'items.catalogue',
'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0),
])->where('id', $id)
->where('user_id', $this->userId)
->where('deleted_at', 0)
->first();
if (empty($info)) {
$this->utils->errorThrow('清单不存在');
}
$info = $info->toArray();
$price = PriceService::getInstance();
$showPrice = $this->canSeePrice();
$multiplier = $this->priceMultiplier();
foreach ($info['items'] as &$item) {
$sheet = $item['catalogue']['price_sheet'] ?? [];
if (!empty($sheet)) {
$applied = $price->applyToRows($sheet, $showPrice, $multiplier);
$item['catalogue']['price_sheet'] = $applied['rows'];
}
// 老前端读的是 product 这个键名,保留别名避免小程序改字段
$item['product'] = $item['catalogue'] ?? null;
}
unset($item);
$info['is_show_price'] = $showPrice;
return $info;
}
public function create(array $params): mixed
{
$name = trim((string) ($params['name'] ?? ''));
if ($name === '') {
$this->utils->errorThrow('请填写清单名称');
}
return ListModel::insertGetId([
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
'user_id' => $this->userId,
'name' => $name,
'remark' => (string) ($params['remark'] ?? ''),
'created_at' => time(),
]);
}
public function update(int $id, array $params): mixed
{
return ListModel::where('id', $id)->where('user_id', $this->userId)->update([
'name' => (string) ($params['name'] ?? ''),
'remark' => (string) ($params['remark'] ?? ''),
'updated_at' => time(),
]);
}
public function delete(array|int $ids): mixed
{
return ListModel::whereIn('id', (array) $ids)
->where('user_id', $this->userId)
->update(['deleted_at' => time(), 'updated_at' => time()]);
}
/**
* 删清单明细:先确认这些明细属于当前用户的清单
*/
public function deleteItem(array|int $ids): mixed
{
$ids = (array) $ids;
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
return ListItemModel::whereIn('id', $ids)
->whereIn('list_id', $listIds)
->update(['deleted_at' => time(), 'updated_at' => time()]);
}
/**
* 加入清单;同一清单同一商品同一规格只留一条,重复则累加数量
*/
public function toCart(array $params): mixed
{
$listId = (int) ($params['list_id'] ?? 0);
$catalogueId = (int) ($params['product_id'] ?? $params['catalogue_id'] ?? 0);
if ($listId <= 0 || $catalogueId <= 0) {
$this->utils->errorThrow('参数错误');
}
$list = ListModel::where('id', $listId)->where('user_id', $this->userId)->where('deleted_at', 0)->first();
if (empty($list)) {
$this->utils->errorThrow('清单不存在');
}
$priceSheetId = (int) ($params['price_sheet_id'] ?? 0);
$quantity = max(1, (int) ($params['quantity'] ?? 1));
$exists = ListItemModel::where('list_id', $listId)
->where('catalogue_id', $catalogueId)
->where('price_sheet_id', $priceSheetId)
->where('deleted_at', 0)
->first();
if (!empty($exists)) {
return ListItemModel::where('id', $exists['id'])->update([
'quantity' => (int) $exists['quantity'] + $quantity,
'updated_at' => time(),
]);
}
return ListItemModel::insertGetId([
'list_id' => $listId,
'catalogue_id' => $catalogueId,
'price_sheet_id' => $priceSheetId,
'material_key' => (string) ($params['material_key'] ?? 'routine'),
'quantity' => $quantity,
'remark' => (string) ($params['remark'] ?? ''),
'created_at' => time(),
]);
}
/**
* 改明细的规格与数量:转订单前用户要能在清单里补齐这些信息
*/
public function updateItem(array $params): mixed
{
$itemId = (int) ($params['id'] ?? 0);
if ($itemId <= 0) {
$this->utils->errorThrow('参数错误');
}
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
$update = ['updated_at' => time()];
if (array_key_exists('quantity', $params)) {
$update['quantity'] = max(1, (int) $params['quantity']);
}
if (array_key_exists('price_sheet_id', $params)) {
$update['price_sheet_id'] = (int) $params['price_sheet_id'];
}
if (array_key_exists('material_key', $params)) {
$update['material_key'] = (string) $params['material_key'];
}
if (array_key_exists('remark', $params)) {
$update['remark'] = (string) $params['remark'];
}
return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Service\wx;
use App\Service\common\RedisService;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 微信小程序开放接口客户端
*
* AppID / AppSecret 来自 nl_wx_app加密存储不再硬编码在源码里。
* access_token 7200 秒有效期且并发刷新会互相顶掉,所以缓存进 Redis 复用。
*/
class WxMiniService
{
private static mixed $_instance;
private string $baseUrl = 'https://api.weixin.qq.com/';
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* code openid session_key
*/
public function jscode2session(string $code): array
{
$app = WxAppService::getInstance()->current();
if (($app['app_id'] ?? '') === '' || ($app['app_secret'] ?? '') === '') {
UtilsService::getInstance()->errorThrow('小程序 AppID/AppSecret 未配置');
}
$result = Http::asJson()->get($this->baseUrl . 'sns/jscode2session', [
'appid' => $app['app_id'],
'secret' => $app['app_secret'],
'js_code' => $code,
'grant_type' => 'authorization_code',
])->json();
if (!empty($result['errcode'])) {
UtilsService::getInstance()->errorThrow('微信登录失败:' . ($result['errmsg'] ?? '未知错误'));
}
if (empty($result['openid'])) {
UtilsService::getInstance()->errorThrow('微信登录失败:未拿到 openid');
}
$result['app_code'] = (string) ($app['code'] ?? '');
return $result;
}
/**
* 手机号快速验证:前端拿到的 code 换真实号码
*/
public function getPhoneNumber(string $phoneCode): string
{
$token = $this->accessToken();
$result = Http::asJson()->post(
$this->baseUrl . 'wxa/business/getuserphonenumber?access_token=' . $token,
['code' => $phoneCode]
)->json();
if (!empty($result['errcode'])) {
UtilsService::getInstance()->errorThrow('获取手机号失败:' . ($result['errmsg'] ?? '未知错误'));
}
return (string) ($result['phone_info']['phoneNumber'] ?? '');
}
/**
* 接口调用凭证,按 appid 缓存
*/
public function accessToken(): string
{
$app = WxAppService::getInstance()->current();
$redis = RedisService::getInstance()->init(config('nl.redis.wechat_token'));
$cached = $redis->get($app['app_id']);
if (!empty($cached)) {
return (string) $cached;
}
$result = Http::asJson()->get($this->baseUrl . 'cgi-bin/token', [
'grant_type' => 'client_credential',
'appid' => $app['app_id'],
'secret' => $app['app_secret'],
])->json();
if (empty($result['access_token'])) {
UtilsService::getInstance()->errorThrow('获取 access_token 失败:' . ($result['errmsg'] ?? '未知错误'));
}
// 提前 300 秒过期,避免临界点上拿到即将失效的 token
$redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300));
return (string) $result['access_token'];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\OrderModel;
use App\Models\business\WxUserModel;
use App\Service\business\OrderCoreService;
use App\Service\business\PriceService;
/**
* 小程序订单:用户自己从清单下单、看订单、传凭证或调起微信支付
*/
class WxOrderService extends BaseWxService
{
/**
* 我的订单,可按状态筛
*
* 金额 text 必须在这里补OrderCoreService::detail 才格式化金额list 只走 paginate
* 而小程序订单列表卡片直接渲染 total_amount_text不补就会出现 ¥0。
*/
public function list(): array
{
$status = request()->get('status');
$result = OrderModel::with([
'items' => fn ($query) => $query->where('deleted_at', 0),
])->where('user_id', $this->userId)
->where('deleted_at', 0)
->when($status !== null && $status !== '', fn ($query) => $query->where('status', (int) $status))
->orderBy('id', 'desc')
->paginate((int) request()->get('pageSize', 10))
->toArray();
// 复用后台同款格式化,保证两端金额展示一致
$price = PriceService::getInstance();
foreach ($result['data'] as &$item) {
$item['total_amount_text'] = $price->centsToYuan((int) ($item['total_amount'] ?? 0));
$item['paid_amount_text'] = $price->centsToYuan((int) ($item['paid_amount'] ?? 0));
}
unset($item);
return [
'page' => $result['current_page'],
'size' => $result['per_page'],
'page_count' => $result['last_page'],
'total' => $result['total'],
'items' => $result['data'],
];
}
public function detail(int $id): array
{
$order = OrderCoreService::getInstance()->detail($id);
if ((int) $order['user_id'] !== $this->userId) {
$this->utils->errorThrow('无权查看该订单');
}
return $order;
}
/**
* 清单转订单
*/
public function createFromList(array $params): array
{
$listId = (int) ($params['list_id'] ?? 0);
if ($listId <= 0) {
$this->utils->errorThrow('请选择清单');
}
return OrderCoreService::getInstance()->createFromList($listId, $this->userId, $params);
}
/**
* 上传转账凭证
*/
public function submitVoucher(array $params): array
{
$orderId = (int) ($params['order_id'] ?? 0);
return OrderCoreService::getInstance()->submitVoucher($orderId, $params, $this->userId);
}
/**
* 调起微信支付
*/
public function wechatPay(array $params): array
{
$orderId = (int) ($params['order_id'] ?? 0);
$order = $this->detail($orderId);
$user = WxUserModel::where('id', $this->userId)->first(['open_id']);
if (empty($user['open_id'])) {
$this->utils->errorThrow('缺少 openid请重新登录');
}
OrderModel::where('id', $orderId)->update([
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
'updated_at' => time(),
]);
return WxPayService::getInstance()->jsapiOrder($order, (string) $user['open_id']);
}
/**
* 取消订单(仅未付款可取消)
*/
public function cancel(int $id): array
{
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED, $this->userId);
}
/**
* 确认收货
*/
public function complete(int $id): array
{
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE, $this->userId);
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Service\wx;
use App\Models\business\OrderModel;
use App\Models\business\OrderPaymentModel;
use App\Service\business\OrderCoreService;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 微信支付 JSAPIAPIv3
*
* 本期只把流程写完商户号可以留空configured() 为假时下单接口直接给出明确提示,
* 不会半截调用微信。等拿到商户资料,只需在后台补齐 nl_wx_app mch_* 字段即可启用。
*
* 三个不能省的点:金额单位是分(微信也用分,正好不用换算);
* 回调必须验签;回调必须幂等(靠 out_trade_no 唯一索引 + 状态判断)。
*/
class WxPayService
{
private static mixed $_instance;
private string $baseUrl = 'https://api.mch.weixin.qq.com';
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 商户资料是否齐全
*/
public function configured(array $app): bool
{
return ($app['mch_id'] ?? '') !== ''
&& ($app['mch_serial_no'] ?? '') !== ''
&& ($app['mch_private_key'] ?? '') !== ''
&& ($app['notify_url'] ?? '') !== '';
}
/**
* JSAPI 下单,返回小程序 wx.requestPayment 所需参数
*
* @param array $order 订单行
* @param string $openId 支付用户的 openid
*/
public function jsapiOrder(array $order, string $openId): array
{
$app = WxAppService::getInstance()->current();
if (!$this->configured($app)) {
UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付');
}
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
if ($amount <= 0) {
UtilsService::getInstance()->errorThrow('该订单无需支付');
}
// 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no
// 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账
$outTradeNo = 'WX' . $order['id'] . 'T' . time();
OrderPaymentModel::insert([
'order_id' => (int) $order['id'],
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
'amount' => $amount,
'out_trade_no' => $outTradeNo,
'status' => OrderPaymentModel::STATUS_AUDITING,
'created_at' => time(),
]);
$body = [
'appid' => $app['app_id'],
'mchid' => $app['mch_id'],
'description' => '订单 ' . $order['order_no'],
'out_trade_no' => $outTradeNo,
'notify_url' => $app['notify_url'],
'amount' => ['total' => $amount, 'currency' => 'CNY'],
'payer' => ['openid' => $openId],
];
$path = '/v3/pay/transactions/jsapi';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$response = Http::withHeaders([
'Authorization' => $this->authorization('POST', $path, $payload, $app),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])->withBody($payload, 'application/json')->post($this->baseUrl . $path);
$result = $response->json();
if (empty($result['prepay_id'])) {
UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body()));
}
return $this->buildPayParams($app, (string) $result['prepay_id']);
}
/**
* 处理支付回调
*
* @param array $headers Wechatpay-*
* @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败)
*/
public function handleNotify(array $headers, string $rawBody): array
{
$app = WxAppService::getInstance()->current();
if (!$this->verifySignature($headers, $rawBody, $app)) {
return ['code' => 'FAIL', 'message' => '验签失败'];
}
$body = json_decode($rawBody, true) ?: [];
$resource = $body['resource'] ?? [];
$plain = $this->decryptResource($resource, (string) $app['mch_key']);
if (empty($plain['out_trade_no'])) {
return ['code' => 'FAIL', 'message' => '报文缺少订单号'];
}
if (($plain['trade_state'] ?? '') !== 'SUCCESS') {
// 非成功态直接应答成功,避免微信持续重推
return ['code' => 'SUCCESS', 'message' => 'OK'];
}
OrderCoreService::getInstance()->confirmWechatPay(
(string) $plain['out_trade_no'],
(string) ($plain['transaction_id'] ?? ''),
(int) ($plain['amount']['total'] ?? 0)
);
return ['code' => 'SUCCESS', 'message' => 'OK'];
}
/**
* 小程序端调起支付的签名参数
*/
private function buildPayParams(array $app, string $prepayId): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$package = 'prepay_id=' . $prepayId;
$message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n";
return [
'appId' => $app['app_id'],
'timeStamp' => $timestamp,
'nonceStr' => $nonce,
'package' => $package,
'signType' => 'RSA',
'paySign' => $this->sign($message, (string) $app['mch_private_key']),
];
}
/**
* APIv3 Authorization
*/
private function authorization(string $method, string $path, string $body, array $app): string
{
$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
$signature = $this->sign($message, (string) $app['mch_private_key']);
return sprintf(
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$app['mch_id'],
$nonce,
$timestamp,
$app['mch_serial_no'],
$signature
);
}
private function sign(string $message, string $privateKey): string
{
$key = openssl_pkey_get_private($privateKey);
if ($key === false) {
UtilsService::getInstance()->errorThrow('商户私钥无效');
}
openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption');
return base64_encode($signature);
}
/**
* 回调验签
*
* 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。
*/
private function verifySignature(array $headers, string $rawBody, array $app): bool
{
$publicKey = (string) ($app['platform_public_key'] ?? '');
if ($publicKey === '') {
return false;
}
$timestamp = (string) ($headers['wechatpay-timestamp'] ?? '');
$nonce = (string) ($headers['wechatpay-nonce'] ?? '');
$signature = (string) ($headers['wechatpay-signature'] ?? '');
if ($timestamp === '' || $nonce === '' || $signature === '') {
return false;
}
// 时间戳偏移超过 5 分钟视为重放
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n";
$key = openssl_pkey_get_public($publicKey);
if ($key === false) {
return false;
}
return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1;
}
/**
* 解密回调报文AEAD_AES_256_GCM
*/
private function decryptResource(array $resource, string $apiV3Key): array
{
$ciphertext = base64_decode((string) ($resource['ciphertext'] ?? ''));
$nonce = (string) ($resource['nonce'] ?? '');
$associated = (string) ($resource['associated_data'] ?? '');
if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') {
return [];
}
$tagLength = 16;
$tag = substr($ciphertext, -$tagLength);
$data = substr($ciphertext, 0, -$tagLength);
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated);
return $plain === false ? [] : (json_decode($plain, true) ?: []);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\CatalogueModel;
use App\Models\business\CategoryModel;
use App\Models\business\ImageModel;
use App\Models\business\WxUserModel;
use App\Service\business\PriceService;
/**
* 小程序商品列表与详情
*
* 价格倍率与可见性统一走 PriceService后台与小程序共用同一套规则
* 避免出现「后台看原价、小程序看倍率价」对不上账的老问题。
*/
class WxProductService extends BaseWxService
{
/**
* 商品列表:分类点到二级时按父分类下的所有子分类查
*/
public function list(): array
{
$title = (string) request()->get('title', '');
$categoryId = request()->get('category_id');
$childIds = [];
if (!empty($categoryId)) {
$childIds = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all();
}
// 小程序前端发的是 size见 pages/product|search getProductList老接口曾用 pageSize
// 这里两者都接,避免改一边漏一边
$pageSize = (int) (request()->get('pageSize') ?: request()->get('size') ?: 10);
$result = CatalogueModel::when($title !== '', function ($query) use ($title) {
$query->where('title', 'like', '%' . $title . '%');
})->when(!empty($categoryId) && empty($childIds), function ($query) use ($categoryId) {
$query->where('category_id', $categoryId);
})->when(!empty($childIds), function ($query) use ($childIds) {
$query->whereIn('category_id', $childIds);
})->where('deleted_at', 0)
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return [
'page' => $result['current_page'],
'size' => $result['per_page'],
'page_count' => $result['last_page'],
// 小程序 productWaterfall / product.vue 读的是 last_page不是 page_count两个键都给
'last_page' => $result['last_page'],
'total' => $result['total'],
'items' => $result['data'],
'data' => $result['data'],
];
}
/**
* 商品详情
*
* p_user_id 说明是代理商分享进来的:普通新用户会被绑到该代理商名下并继承其倍率,
* 这是老项目的既有行为,不能改,否则代理商体系的价格会全部失效。
*/
public function detail(int $id, int $shareUserId = 0): array
{
if ($shareUserId > 0) {
$this->inheritAgentPrice($shareUserId);
}
$info = CatalogueModel::with([
'category',
'images' => fn ($query) => $query->where('deleted_at', 0),
'priceSheet' => fn ($query) => $query->where('deleted_at', 0),
])->where('deleted_at', 0)->find($id);
if (empty($info)) {
$this->utils->errorThrow('商品不存在');
}
$info = $info->toArray();
// 渲染图作为详情轮播
$info['carousel'] = [];
foreach ($info['images'] ?? [] as $image) {
if ((int) $image['type'] === ImageModel::TYPE_RENDER) {
$info['carousel'][] = $image['url'];
}
}
$showPrice = $this->canSeePrice();
$applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier());
$info['price_sheet'] = $applied['rows'];
$info['is_show_price'] = $applied['is_show_price'];
return $info;
}
/**
* 继承分享代理商的倍率
*
* 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率,
* 代理商改了倍率下级要跟着变。
*/
private function inheritAgentPrice(int $shareUserId): void
{
$isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1;
$pid = (int) ($this->userInfo['pid'] ?? 0);
if ($isAgent) {
return;
}
if ($pid !== 0 && $pid !== $shareUserId) {
return;
}
$agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first();
if (empty($agent) || (int) $agent['is_p'] !== 1) {
return;
}
$this->userInfo['show_price'] = 1;
$this->userInfo['price_number'] = $agent['price_number'];
WxUserModel::where('id', $this->userId)->update([
'pid' => $shareUserId,
'show_price' => 1,
'price_number' => $agent['price_number'],
'updated_at' => time(),
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\WxTemplateModel;
use App\Models\business\WxUserModel;
use App\Models\WxAppModel;
use App\Service\business\WxTemplatePresetService;
use App\Service\business\WxTemplateSchemaService;
/**
* 小程序主题下发(免授权)
*
* 小程序启动时拉一次并缓存本地,拿不到就用内置兜底主题——
* 主题接口挂了不能导致小程序白屏。
*/
class WxThemeService extends BaseWxService
{
protected bool $needLogin = false;
/**
* 当前生效主题
*
* 优先级:显式 code > 登录经销商专属 template_code > 品牌配置 template_code
* > 该品牌默认模板 > 通用默认模板 > 内置预设
*/
public function current(string $code = ''): array
{
$appCode = WxAppService::getInstance()->currentCode();
$template = null;
if ($code !== '') {
$template = WxTemplateModel::where('code', $code)->where('deleted_at', 0)->where('status', 0)->first();
}
// 已登录经销商:可用专属模板覆盖品牌默认(未登录则 userInfo 为空,跳过)
if (empty($template) && (int) ($this->userInfo['is_p'] ?? 0) === 1) {
$userCode = trim((string) ($this->userInfo['template_code'] ?? ''));
if ($userCode === '' && !empty($this->userInfo['id'])) {
$userCode = (string) (WxUserModel::where('id', $this->userInfo['id'])
->where('deleted_at', 0)
->value('template_code') ?? '');
}
if ($userCode !== '') {
$template = WxTemplateModel::where('code', $userCode)
->where('deleted_at', 0)->where('status', 0)->first();
}
}
if (empty($template) && $appCode !== '') {
$bound = (string) (WxAppModel::where('code', $appCode)
->where('deleted_at', 0)
->value('template_code') ?? '');
if ($bound !== '') {
$template = WxTemplateModel::where('code', $bound)->where('deleted_at', 0)->where('status', 0)->first();
}
}
if (empty($template) && $appCode !== '') {
$template = WxTemplateModel::where('app_code', $appCode)
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
}
if (empty($template)) {
$template = WxTemplateModel::where('app_code', '')
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
}
$schema = WxTemplateSchemaService::getInstance();
if (empty($template)) {
$preset = WxTemplatePresetService::getInstance()->all()[0] ?? [];
$tokens = $preset['tokens'] ?? [];
$layout = $preset['layout'] ?? [];
return [
'code' => (string) ($preset['code'] ?? 'lux-champagne'),
'name' => (string) ($preset['name'] ?? '轻奢·香槟金'),
'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'),
'version' => 0,
'tokens' => $tokens,
'layout' => $layout,
'css_vars' => $schema->toCssVariables($tokens),
'fallback' => true,
];
}
$template = $template->toArray();
$tokens = is_array($template['tokens']) ? $template['tokens'] : [];
return [
'code' => (string) $template['code'],
'name' => (string) $template['name'],
'style_tag' => (string) $template['style_tag'],
'version' => (int) $template['version'],
'tokens' => $tokens,
'layout' => is_array($template['layout']) ? $template['layout'] : [],
'css_vars' => $schema->toCssVariables($tokens),
'fallback' => false,
];
}
/**
* 可选风格列表,用于小程序里让用户自己换肤
*/
public function gallery(): mixed
{
$appCode = WxAppService::getInstance()->currentCode();
return WxTemplateModel::where('deleted_at', 0)
->where('status', 0)
->when($appCode !== '', fn ($query) => $query->whereIn('app_code', ['', $appCode]))
->orderBy('sort', 'asc')
->get(['code', 'name', 'style_tag', 'preview']);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Service\wx;
use App\Models\business\WxUserModel;
use App\Service\common\UtilsService;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
/**
* 小程序用户令牌
*
* lgp-wx-api 把微信的 session_key token 直接返回客户端:
* 它既是解密用户数据的密钥,又永不过期,泄露等于长期冒用身份。
* 这里改成服务端自签 JWTsession_key 只留库里。
*
* payload scope=wx与后台管理端的 token 互不通用——
* 否则一个小程序 token 就能打后台接口。
*/
class WxTokenService
{
private static mixed $_instance;
private const SCOPE = 'wx';
private string $secretKey;
public function __construct()
{
$secret = (string) config('nl.jwt.secret', '');
// 与后台同一个密钥但 scope 不同,验证时强制校验 scope不存在越权互通
$this->secretKey = strlen($secret) >= 32 ? $secret : hash('sha256', $secret !== '' ? $secret : 'nl_wx_jwt_fallback');
}
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 签发令牌
*/
public function issue(int $userId, string $appCode = ''): string
{
$now = time();
return JWT::encode([
'iat' => $now,
'exp' => $now + (int) config('nl.wx.token_ttl', 30 * 24 * 3600),
'scope' => self::SCOPE,
'uid' => $userId,
'app' => $appCode,
], $this->secretKey, 'HS256');
}
/**
* 校验令牌并返回用户;失败返回 null 由中间件统一响应
*/
public function resolveUser(?string $token): ?array
{
if (empty($token)) {
return null;
}
try {
$payload = JWT::decode($token, new Key($this->secretKey, 'HS256'));
} catch (\Throwable) {
return null;
}
if (($payload->scope ?? '') !== self::SCOPE) {
return null;
}
$userId = (int) ($payload->uid ?? 0);
if ($userId <= 0) {
return null;
}
// 每次请求回查用户:倍率、价格可见性、是否被停用都可能刚被后台改过
$user = WxUserModel::where('id', $userId)->where('deleted_at', 0)->first();
if (empty($user)) {
return null;
}
$user = $user->toArray();
unset($user['session_key']);
return $user;
}
/**
* 取当前请求的小程序用户,取不到直接中断
*/
public function requireUser(): array
{
$user = request()->attributes->get('wx_user');
if (!empty($user)) {
return $user;
}
$user = $this->resolveUser(request()->bearerToken());
if (empty($user)) {
UtilsService::getInstance()->notAuth('请先登录');
}
return $user;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\FileModel;
use App\Service\common\oss\OssRuntimeConfigService;
use App\Service\common\oss\OssStorageFactory;
use Illuminate\Support\Str;
/**
* 小程序上传入口(转账凭证等)。
*
* 不直接复用后台 UploadService后者继承 BaseService构造期会解后台 JWT
* wx 组里走 BaseService 等于拿不到小程序身份、还会触发后台 token 校验。
* 这里只复用底层工厂 OssRuntimeConfigService / OssStorageFactory
* 保证存储实现与后台一致、身份走 BaseWxService cc_wx_user。
*/
class WxUploadService extends BaseWxService
{
/**
* 上传图片到当前启用的 OSS / 本地存储。
*
* 返回结构与后台 UploadService::uploadImage 对齐url 键),
* 小程序拿到 url 后塞进 voucher 字段,再调 wx/order/voucher 提交。
*
* @param mixed $file UploadedFile
*/
public function uploadImage($file): array|bool
{
if (empty($file)) {
$this->utils->errorThrow('请选择图片');
}
$ext = $file->getClientOriginalExtension();
// 单独走 wx 命名空间避免与后台素材库扫描混目录
$key = 'wx/voucher/' . date('Ymd') . '/' . 'wx_' . Str::random() . uniqid() . '.' . $ext;
$storage = $this->resolveStorage();
$result = $storage->uploadVideo($file, $key);
if (!$result) {
$this->utils->errorThrow('图片上传失败');
}
// 落一份上传流水到 nl_filetype=image方便后台审计小程序上传的凭证
FileModel::insert([
'user_id' => $this->userId,
'url' => $result['url'],
'type' => FileModel::TYPE_IMAGE,
'source' => FileModel::SOURCE_UPLOAD,
'created_at' => time(),
'updated_at' => time(),
]);
return $result;
}
/**
* 解析当前启用的存储实现;配置异常时回退本地,避免整站上传不可用
*/
private function resolveStorage()
{
try {
$config = OssRuntimeConfigService::getInstance()->getActiveConfig();
return OssStorageFactory::getInstance()->make($config);
} catch (\Throwable $e) {
return OssStorageFactory::getInstance()->make([
'driver' => 'local',
'path_prefix' => 'uploads',
'domain' => '',
]);
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\ListModel;
use App\Models\business\OrderModel;
use App\Models\business\WxUserModel;
/**
* 小程序「我的」
*/
class WxUserCenterService extends BaseWxService
{
/**
* 我的信息 + 清单/订单计数
*/
public function myInfo(): array
{
$listCount = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
$orderCount = OrderModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
$user = WxUserModel::with('enterprise')->where('id', $this->userId)->first();
$user = $user ? $user->toArray() : $this->userInfo;
unset($user['session_key']);
return [
'count' => $listCount,
'order_count' => $orderCount,
'user' => $user,
];
}
/**
* 绑定手机号
*
* 优先用微信手机号快速验证的 code 换真实号码(用户填的可能是假号);
* 只有客户端拿不到 code 时才退回直接写入。
*/
public function bandPhone(array $params): array
{
$phone = trim((string) ($params['phone'] ?? ''));
$phoneCode = trim((string) ($params['phone_code'] ?? ''));
if ($phoneCode !== '') {
$phone = WxMiniService::getInstance()->getPhoneNumber($phoneCode);
}
if ($phone === '') {
$this->utils->errorThrow('手机号不能为空');
}
WxUserModel::where('id', $this->userId)->update([
'phone' => $phone,
'updated_at' => time(),
]);
return ['phone' => $phone];
}
public function updateNickName(string $nickName): array
{
$nickName = trim($nickName);
if ($nickName === '') {
$this->utils->errorThrow('昵称不能为空');
}
WxUserModel::where('id', $this->userId)->update([
'nick_name' => $nickName,
'updated_at' => time(),
]);
return ['nick_name' => $nickName];
}
}