初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
130
app/Service/wx/WxAppService.php
Normal file
130
app/Service/wx/WxAppService.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\common\UtilsService;
|
||||
|
||||
/**
|
||||
* 小程序应用凭证读取
|
||||
*
|
||||
* 请求头 X-App-Code(或参数 app_code)决定用哪个品牌的 AppID。
|
||||
* 没带就用 .env 里 WX_DEFAULT_APP_CODE 兜底;都没有就取唯一启用的那条。
|
||||
* 库表(nl_wx_app)始终是主源,.env 只在「表里完全没有匹配行」时回落兜底,
|
||||
* 这样单品牌部署可以不进后台配置就跑起来,双品牌共用进程仍以表 + 请求头分流为主。
|
||||
*/
|
||||
class WxAppService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前请求对应的应用配置(含解密后的密钥)
|
||||
*
|
||||
* 解析顺序:
|
||||
* 1. 按 code(请求头 → .env 默认值)查 nl_wx_app
|
||||
* 2. 查不到且 .env 配了 AppID:返回仅含 app_id/name/code 的兜底配置,
|
||||
* 密钥仍尝试从表里「按 app_id 匹配」的那行解密;表里也没有就抛错
|
||||
* 指引运维去后台填写——密钥不能从 .env 读,安全红线
|
||||
* 3. .env 也没配:直接抛错
|
||||
*/
|
||||
public function current(): array
|
||||
{
|
||||
$code = $this->currentCode();
|
||||
$query = WxAppModel::where('deleted_at', 0)->where('status', 0);
|
||||
$app = $code !== '' ? $query->where('code', $code)->first() : $query->first();
|
||||
|
||||
// 库表命中:解密密钥列后返回
|
||||
if (!empty($app)) {
|
||||
return $this->loadWithSecrets($app);
|
||||
}
|
||||
|
||||
// 库表未命中,回落 .env(仅 AppID/名称,不含密钥)
|
||||
$envAppId = trim((string) config('nl.wx.env_app_id', ''));
|
||||
if ($envAppId === '') {
|
||||
// .env 没配 AppID 就没法兜底了,直接抛错
|
||||
UtilsService::getInstance()->errorThrow('未配置小程序应用(nl_wx_app),请先在后台添加或在 .env 配 WX_APP_ID');
|
||||
}
|
||||
|
||||
$envCode = trim((string) config('nl.wx.default_app_code', ''));
|
||||
$envName = trim((string) config('nl.wx.env_app_name', ''));
|
||||
|
||||
// 兜底场景:AppID 来自 .env,但密钥仍只能从库里按 AppID 找一行来解密
|
||||
// 这样老项目硬编码密钥的脏习惯不会回到 .env,密钥始终加密入库
|
||||
$secretRow = WxAppModel::where('app_id', $envAppId)
|
||||
->where('deleted_at', 0)
|
||||
->first(['app_secret', 'mch_key', 'mch_private_key']);
|
||||
if (empty($secretRow) || trim((string) ($secretRow['app_secret'] ?? '')) === '') {
|
||||
// 表里既没匹配行、或行里没填密钥,只能报错让运维去后台补
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,新增/编辑 AppID=' .
|
||||
$envAppId .
|
||||
' 的记录并填写 AppSecret(密钥加密入库,不要写进 .env)'
|
||||
);
|
||||
}
|
||||
|
||||
$encrypt = FieldEncryptService::getInstance();
|
||||
return [
|
||||
'id' => 0,
|
||||
'code' => $envCode,
|
||||
'name' => $envName,
|
||||
'app_id' => $envAppId,
|
||||
'app_secret' => $encrypt->decryptFromStorage((string) $secretRow['app_secret'], true),
|
||||
'mch_id' => '',
|
||||
'mch_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_key'] ?? ''), true),
|
||||
'mch_serial_no' => '',
|
||||
'mch_private_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_private_key'] ?? ''), true),
|
||||
'platform_public_key' => '',
|
||||
'notify_url' => '',
|
||||
'template_code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前请求声明的品牌标识
|
||||
*
|
||||
* 优先级:请求头 X-App-Code > 请求参数 app_code > .env 的 WX_DEFAULT_APP_CODE
|
||||
* 全部为空时返回空串,调用方会改走「取唯一启用行」逻辑
|
||||
*/
|
||||
public function currentCode(): string
|
||||
{
|
||||
$code = (string) (request()->header('X-App-Code') ?: request()->input('app_code', ''));
|
||||
$code = trim($code);
|
||||
if ($code !== '') {
|
||||
return $code;
|
||||
}
|
||||
// 请求没带品牌标识时回落 .env 默认值(单品牌部署兜底)
|
||||
return trim((string) config('nl.wx.default_app_code', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模型行的密钥列解密后转数组返回
|
||||
*/
|
||||
private function loadWithSecrets($app): array
|
||||
{
|
||||
$app = $app->toArray();
|
||||
$raw = WxAppModel::where('id', $app['id'])->first(['app_secret', 'mch_key', 'mch_private_key']);
|
||||
$encrypt = FieldEncryptService::getInstance();
|
||||
$secret = $encrypt->decryptFromStorage($raw['app_secret'] ?? '', true);
|
||||
if (trim((string) $secret) === '') {
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,编辑 code=' .
|
||||
($app['code'] ?? '') .
|
||||
'(AppID=' . ($app['app_id'] ?? '') . ')并填写 AppSecret'
|
||||
);
|
||||
}
|
||||
$app['app_secret'] = $secret;
|
||||
$app['mch_key'] = $encrypt->decryptFromStorage($raw['mch_key'] ?? '', true);
|
||||
$app['mch_private_key'] = $encrypt->decryptFromStorage($raw['mch_private_key'] ?? '', true);
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
71
app/Service/wx/WxAuthService.php
Normal file
71
app/Service/wx/WxAuthService.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\SerialNoService;
|
||||
|
||||
/**
|
||||
* 小程序登录
|
||||
*
|
||||
* 返回的 token 是服务端自签的(见 WxTokenService),session_key 只写库不外发。
|
||||
*/
|
||||
class WxAuthService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
private const DEFAULT_AVATAR = 'http://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png';
|
||||
|
||||
/**
|
||||
* code 登录:老用户更新 session_key,新用户建档并送一份默认清单
|
||||
*/
|
||||
public function login(array $params): array
|
||||
{
|
||||
$code = trim((string) ($params['code'] ?? ''));
|
||||
if ($code === '') {
|
||||
$this->utils->errorThrow('缺少 code');
|
||||
}
|
||||
$credentials = WxMiniService::getInstance()->jscode2session($code);
|
||||
$openId = (string) $credentials['openid'];
|
||||
$now = time();
|
||||
|
||||
$user = WxUserModel::where('open_id', $openId)->first();
|
||||
if (empty($user)) {
|
||||
$userId = WxUserModel::insertGetId([
|
||||
'open_id' => $openId,
|
||||
'session_key' => (string) ($credentials['session_key'] ?? ''),
|
||||
'avatar' => $params['avatar'] ?? self::DEFAULT_AVATAR,
|
||||
'nick_name' => $params['nick_name'] ?? '微信用户',
|
||||
'phone' => '',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
if (empty($userId)) {
|
||||
$this->utils->errorThrow('用户创建失败');
|
||||
}
|
||||
ListModel::insert([
|
||||
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
|
||||
'user_id' => $userId,
|
||||
'name' => '默认清单',
|
||||
'remark' => '系统默认生成的清单',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
WxUserModel::where('id', $userId)->update([
|
||||
'session_key' => (string) ($credentials['session_key'] ?? ''),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$userInfo = WxUserModel::where('id', $userId)->with('enterprise')->first();
|
||||
$userInfo = $userInfo ? $userInfo->toArray() : [];
|
||||
unset($userInfo['session_key']);
|
||||
|
||||
return [
|
||||
'token' => WxTokenService::getInstance()->issue((int) $userId, (string) ($credentials['app_code'] ?? '')),
|
||||
'user_info' => $userInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Service/wx/WxHomeService.php
Normal file
37
app/Service/wx/WxHomeService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\CarouselModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
|
||||
/**
|
||||
* 小程序首页:轮播与一级分类,都不要求登录
|
||||
*/
|
||||
class WxHomeService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
public function carousel(): mixed
|
||||
{
|
||||
// 只返回启用中的轮播;后台 status=1 表示隐藏
|
||||
return CarouselModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->orderBy('sort', 'asc')
|
||||
->get(['id', 'url', 'to_path', 'sort']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表,pid=0 为一级
|
||||
* 按 sort 升序,同值按 id(需已执行 05_cc_category_add_sort.sql)
|
||||
*/
|
||||
public function categoryList(int $pid = 0): mixed
|
||||
{
|
||||
return CategoryModel::where('pid', $pid)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('sort', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
215
app/Service/wx/WxListService.php
Normal file
215
app/Service/wx/WxListService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListItemModel;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Service\business\PriceService;
|
||||
use App\Service\business\SerialNoService;
|
||||
|
||||
/**
|
||||
* 小程序清单
|
||||
*
|
||||
* 清单是「加购物车」的语义但不含结算,下单由 WxOrderService 从清单快照生成。
|
||||
* 所有写操作都必须带 user_id 条件,否则改个 id 就能删别人的清单——老项目的 deleteItem 就是这个漏洞。
|
||||
*/
|
||||
class WxListService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的清单,带明细数量
|
||||
*
|
||||
* 兼容两种返回:
|
||||
* - 不传 page / pageSize(老调用方):返回全量扁平数组,保持向后兼容;
|
||||
* - 传了 page / pageSize:返回 { items, total, page, pageSize, has_more },供小程序真分页使用。
|
||||
* 判断「是否分页模式」只看请求里有没有 page,避免 pageSize 默认值造成歧义。
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
// 请求里带了 page 才走分页分支;pageSize 缺省 20,与列表页约定一致
|
||||
$page = request()->get('page');
|
||||
$paged = $page !== null && $page !== '';
|
||||
$page = max(1, (int) $page);
|
||||
$pageSize = max(1, (int) (request()->get('pageSize') ?? 20));
|
||||
|
||||
$base = ListModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0)->select(['id', 'list_id']),
|
||||
])->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
// 不分页:一次拿全,保持老结构
|
||||
if (!$paged) {
|
||||
$rows = $base->get();
|
||||
if ($rows->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
$rows = $rows->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['count'] = count($row['items'] ?? []);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// 分页:拿当前页 + 总数,前端按 has_more 决定是否继续 loadMore
|
||||
$total = (clone $base)->count();
|
||||
$rows = $base->forPage($page, $pageSize)->get();
|
||||
$items = $rows->isEmpty() ? [] : $rows->toArray();
|
||||
foreach ($items as &$row) {
|
||||
$row['count'] = count($row['items'] ?? []);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'has_more' => ($page * $pageSize) < $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单详情:带商品与规格,价格按当前用户的可见性与倍率处理
|
||||
*/
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$info = ListModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'items.catalogue',
|
||||
'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('id', $id)
|
||||
->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('清单不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
|
||||
$price = PriceService::getInstance();
|
||||
$showPrice = $this->canSeePrice();
|
||||
$multiplier = $this->priceMultiplier();
|
||||
foreach ($info['items'] as &$item) {
|
||||
$sheet = $item['catalogue']['price_sheet'] ?? [];
|
||||
if (!empty($sheet)) {
|
||||
$applied = $price->applyToRows($sheet, $showPrice, $multiplier);
|
||||
$item['catalogue']['price_sheet'] = $applied['rows'];
|
||||
}
|
||||
// 老前端读的是 product 这个键名,保留别名避免小程序改字段
|
||||
$item['product'] = $item['catalogue'] ?? null;
|
||||
}
|
||||
unset($item);
|
||||
$info['is_show_price'] = $showPrice;
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function create(array $params): mixed
|
||||
{
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$this->utils->errorThrow('请填写清单名称');
|
||||
}
|
||||
return ListModel::insertGetId([
|
||||
'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'),
|
||||
'user_id' => $this->userId,
|
||||
'name' => $name,
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id, array $params): mixed
|
||||
{
|
||||
return ListModel::where('id', $id)->where('user_id', $this->userId)->update([
|
||||
'name' => (string) ($params['name'] ?? ''),
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(array|int $ids): mixed
|
||||
{
|
||||
return ListModel::whereIn('id', (array) $ids)
|
||||
->where('user_id', $this->userId)
|
||||
->update(['deleted_at' => time(), 'updated_at' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删清单明细:先确认这些明细属于当前用户的清单
|
||||
*/
|
||||
public function deleteItem(array|int $ids): mixed
|
||||
{
|
||||
$ids = (array) $ids;
|
||||
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
|
||||
return ListItemModel::whereIn('id', $ids)
|
||||
->whereIn('list_id', $listIds)
|
||||
->update(['deleted_at' => time(), 'updated_at' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入清单;同一清单同一商品同一规格只留一条,重复则累加数量
|
||||
*/
|
||||
public function toCart(array $params): mixed
|
||||
{
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
$catalogueId = (int) ($params['product_id'] ?? $params['catalogue_id'] ?? 0);
|
||||
if ($listId <= 0 || $catalogueId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$list = ListModel::where('id', $listId)->where('user_id', $this->userId)->where('deleted_at', 0)->first();
|
||||
if (empty($list)) {
|
||||
$this->utils->errorThrow('清单不存在');
|
||||
}
|
||||
$priceSheetId = (int) ($params['price_sheet_id'] ?? 0);
|
||||
$quantity = max(1, (int) ($params['quantity'] ?? 1));
|
||||
|
||||
$exists = ListItemModel::where('list_id', $listId)
|
||||
->where('catalogue_id', $catalogueId)
|
||||
->where('price_sheet_id', $priceSheetId)
|
||||
->where('deleted_at', 0)
|
||||
->first();
|
||||
if (!empty($exists)) {
|
||||
return ListItemModel::where('id', $exists['id'])->update([
|
||||
'quantity' => (int) $exists['quantity'] + $quantity,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
return ListItemModel::insertGetId([
|
||||
'list_id' => $listId,
|
||||
'catalogue_id' => $catalogueId,
|
||||
'price_sheet_id' => $priceSheetId,
|
||||
'material_key' => (string) ($params['material_key'] ?? 'routine'),
|
||||
'quantity' => $quantity,
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改明细的规格与数量:转订单前用户要能在清单里补齐这些信息
|
||||
*/
|
||||
public function updateItem(array $params): mixed
|
||||
{
|
||||
$itemId = (int) ($params['id'] ?? 0);
|
||||
if ($itemId <= 0) {
|
||||
$this->utils->errorThrow('参数错误');
|
||||
}
|
||||
$listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id');
|
||||
$update = ['updated_at' => time()];
|
||||
if (array_key_exists('quantity', $params)) {
|
||||
$update['quantity'] = max(1, (int) $params['quantity']);
|
||||
}
|
||||
if (array_key_exists('price_sheet_id', $params)) {
|
||||
$update['price_sheet_id'] = (int) $params['price_sheet_id'];
|
||||
}
|
||||
if (array_key_exists('material_key', $params)) {
|
||||
$update['material_key'] = (string) $params['material_key'];
|
||||
}
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$update['remark'] = (string) $params['remark'];
|
||||
}
|
||||
return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update);
|
||||
}
|
||||
}
|
||||
94
app/Service/wx/WxMiniService.php
Normal file
94
app/Service/wx/WxMiniService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Service\common\RedisService;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 微信小程序开放接口客户端
|
||||
*
|
||||
* AppID / AppSecret 来自 nl_wx_app(加密存储),不再硬编码在源码里。
|
||||
* access_token 有 7200 秒有效期且并发刷新会互相顶掉,所以缓存进 Redis 复用。
|
||||
*/
|
||||
class WxMiniService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private string $baseUrl = 'https://api.weixin.qq.com/';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* code 换 openid 与 session_key
|
||||
*/
|
||||
public function jscode2session(string $code): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (($app['app_id'] ?? '') === '' || ($app['app_secret'] ?? '') === '') {
|
||||
UtilsService::getInstance()->errorThrow('小程序 AppID/AppSecret 未配置');
|
||||
}
|
||||
$result = Http::asJson()->get($this->baseUrl . 'sns/jscode2session', [
|
||||
'appid' => $app['app_id'],
|
||||
'secret' => $app['app_secret'],
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
])->json();
|
||||
if (!empty($result['errcode'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信登录失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
if (empty($result['openid'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信登录失败:未拿到 openid');
|
||||
}
|
||||
$result['app_code'] = (string) ($app['code'] ?? '');
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号快速验证:前端拿到的 code 换真实号码
|
||||
*/
|
||||
public function getPhoneNumber(string $phoneCode): string
|
||||
{
|
||||
$token = $this->accessToken();
|
||||
$result = Http::asJson()->post(
|
||||
$this->baseUrl . 'wxa/business/getuserphonenumber?access_token=' . $token,
|
||||
['code' => $phoneCode]
|
||||
)->json();
|
||||
if (!empty($result['errcode'])) {
|
||||
UtilsService::getInstance()->errorThrow('获取手机号失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
return (string) ($result['phone_info']['phoneNumber'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口调用凭证,按 appid 缓存
|
||||
*/
|
||||
public function accessToken(): string
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
$redis = RedisService::getInstance()->init(config('nl.redis.wechat_token'));
|
||||
$cached = $redis->get($app['app_id']);
|
||||
if (!empty($cached)) {
|
||||
return (string) $cached;
|
||||
}
|
||||
$result = Http::asJson()->get($this->baseUrl . 'cgi-bin/token', [
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $app['app_id'],
|
||||
'secret' => $app['app_secret'],
|
||||
])->json();
|
||||
if (empty($result['access_token'])) {
|
||||
UtilsService::getInstance()->errorThrow('获取 access_token 失败:' . ($result['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
// 提前 300 秒过期,避免临界点上拿到即将失效的 token
|
||||
$redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300));
|
||||
return (string) $result['access_token'];
|
||||
}
|
||||
}
|
||||
112
app/Service/wx/WxOrderService.php
Normal file
112
app/Service/wx/WxOrderService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\OrderCoreService;
|
||||
use App\Service\business\PriceService;
|
||||
|
||||
/**
|
||||
* 小程序订单:用户自己从清单下单、看订单、传凭证或调起微信支付
|
||||
*/
|
||||
class WxOrderService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的订单,可按状态筛
|
||||
*
|
||||
* 金额 text 必须在这里补:OrderCoreService::detail 才格式化金额,list 只走 paginate,
|
||||
* 而小程序订单列表卡片直接渲染 total_amount_text,不补就会出现 ¥0。
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$status = request()->get('status');
|
||||
$result = OrderModel::with([
|
||||
'items' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('user_id', $this->userId)
|
||||
->where('deleted_at', 0)
|
||||
->when($status !== null && $status !== '', fn ($query) => $query->where('status', (int) $status))
|
||||
->orderBy('id', 'desc')
|
||||
->paginate((int) request()->get('pageSize', 10))
|
||||
->toArray();
|
||||
// 复用后台同款格式化,保证两端金额展示一致
|
||||
$price = PriceService::getInstance();
|
||||
foreach ($result['data'] as &$item) {
|
||||
$item['total_amount_text'] = $price->centsToYuan((int) ($item['total_amount'] ?? 0));
|
||||
$item['paid_amount_text'] = $price->centsToYuan((int) ($item['paid_amount'] ?? 0));
|
||||
}
|
||||
unset($item);
|
||||
return [
|
||||
'page' => $result['current_page'],
|
||||
'size' => $result['per_page'],
|
||||
'page_count' => $result['last_page'],
|
||||
'total' => $result['total'],
|
||||
'items' => $result['data'],
|
||||
];
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$order = OrderCoreService::getInstance()->detail($id);
|
||||
if ((int) $order['user_id'] !== $this->userId) {
|
||||
$this->utils->errorThrow('无权查看该订单');
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单转订单
|
||||
*/
|
||||
public function createFromList(array $params): array
|
||||
{
|
||||
$listId = (int) ($params['list_id'] ?? 0);
|
||||
if ($listId <= 0) {
|
||||
$this->utils->errorThrow('请选择清单');
|
||||
}
|
||||
return OrderCoreService::getInstance()->createFromList($listId, $this->userId, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传转账凭证
|
||||
*/
|
||||
public function submitVoucher(array $params): array
|
||||
{
|
||||
$orderId = (int) ($params['order_id'] ?? 0);
|
||||
return OrderCoreService::getInstance()->submitVoucher($orderId, $params, $this->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起微信支付
|
||||
*/
|
||||
public function wechatPay(array $params): array
|
||||
{
|
||||
$orderId = (int) ($params['order_id'] ?? 0);
|
||||
$order = $this->detail($orderId);
|
||||
$user = WxUserModel::where('id', $this->userId)->first(['open_id']);
|
||||
if (empty($user['open_id'])) {
|
||||
$this->utils->errorThrow('缺少 openid,请重新登录');
|
||||
}
|
||||
OrderModel::where('id', $orderId)->update([
|
||||
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return WxPayService::getInstance()->jsapiOrder($order, (string) $user['open_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单(仅未付款可取消)
|
||||
*/
|
||||
public function cancel(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED, $this->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认收货
|
||||
*/
|
||||
public function complete(int $id): array
|
||||
{
|
||||
return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE, $this->userId);
|
||||
}
|
||||
}
|
||||
221
app/Service/wx/WxPayService.php
Normal file
221
app/Service/wx/WxPayService.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\OrderPaymentModel;
|
||||
use App\Service\business\OrderCoreService;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 微信支付 JSAPI(APIv3)
|
||||
*
|
||||
* 本期只把流程写完,商户号可以留空:configured() 为假时下单接口直接给出明确提示,
|
||||
* 不会半截调用微信。等拿到商户资料,只需在后台补齐 nl_wx_app 的 mch_* 字段即可启用。
|
||||
*
|
||||
* 三个不能省的点:金额单位是分(微信也用分,正好不用换算);
|
||||
* 回调必须验签;回调必须幂等(靠 out_trade_no 唯一索引 + 状态判断)。
|
||||
*/
|
||||
class WxPayService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private string $baseUrl = 'https://api.mch.weixin.qq.com';
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户资料是否齐全
|
||||
*/
|
||||
public function configured(array $app): bool
|
||||
{
|
||||
return ($app['mch_id'] ?? '') !== ''
|
||||
&& ($app['mch_serial_no'] ?? '') !== ''
|
||||
&& ($app['mch_private_key'] ?? '') !== ''
|
||||
&& ($app['notify_url'] ?? '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* JSAPI 下单,返回小程序 wx.requestPayment 所需参数
|
||||
*
|
||||
* @param array $order 订单行
|
||||
* @param string $openId 支付用户的 openid
|
||||
*/
|
||||
public function jsapiOrder(array $order, string $openId): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (!$this->configured($app)) {
|
||||
UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付');
|
||||
}
|
||||
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
|
||||
if ($amount <= 0) {
|
||||
UtilsService::getInstance()->errorThrow('该订单无需支付');
|
||||
}
|
||||
// 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no,
|
||||
// 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账
|
||||
$outTradeNo = 'WX' . $order['id'] . 'T' . time();
|
||||
OrderPaymentModel::insert([
|
||||
'order_id' => (int) $order['id'],
|
||||
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
|
||||
'amount' => $amount,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'status' => OrderPaymentModel::STATUS_AUDITING,
|
||||
'created_at' => time(),
|
||||
]);
|
||||
|
||||
$body = [
|
||||
'appid' => $app['app_id'],
|
||||
'mchid' => $app['mch_id'],
|
||||
'description' => '订单 ' . $order['order_no'],
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $app['notify_url'],
|
||||
'amount' => ['total' => $amount, 'currency' => 'CNY'],
|
||||
'payer' => ['openid' => $openId],
|
||||
];
|
||||
$path = '/v3/pay/transactions/jsapi';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $this->authorization('POST', $path, $payload, $app),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
])->withBody($payload, 'application/json')->post($this->baseUrl . $path);
|
||||
$result = $response->json();
|
||||
if (empty($result['prepay_id'])) {
|
||||
UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body()));
|
||||
}
|
||||
return $this->buildPayParams($app, (string) $result['prepay_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
*
|
||||
* @param array $headers Wechatpay-* 头
|
||||
* @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败)
|
||||
*/
|
||||
public function handleNotify(array $headers, string $rawBody): array
|
||||
{
|
||||
$app = WxAppService::getInstance()->current();
|
||||
if (!$this->verifySignature($headers, $rawBody, $app)) {
|
||||
return ['code' => 'FAIL', 'message' => '验签失败'];
|
||||
}
|
||||
$body = json_decode($rawBody, true) ?: [];
|
||||
$resource = $body['resource'] ?? [];
|
||||
$plain = $this->decryptResource($resource, (string) $app['mch_key']);
|
||||
if (empty($plain['out_trade_no'])) {
|
||||
return ['code' => 'FAIL', 'message' => '报文缺少订单号'];
|
||||
}
|
||||
if (($plain['trade_state'] ?? '') !== 'SUCCESS') {
|
||||
// 非成功态直接应答成功,避免微信持续重推
|
||||
return ['code' => 'SUCCESS', 'message' => 'OK'];
|
||||
}
|
||||
OrderCoreService::getInstance()->confirmWechatPay(
|
||||
(string) $plain['out_trade_no'],
|
||||
(string) ($plain['transaction_id'] ?? ''),
|
||||
(int) ($plain['amount']['total'] ?? 0)
|
||||
);
|
||||
return ['code' => 'SUCCESS', 'message' => 'OK'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序端调起支付的签名参数
|
||||
*/
|
||||
private function buildPayParams(array $app, string $prepayId): array
|
||||
{
|
||||
$timestamp = (string) time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$package = 'prepay_id=' . $prepayId;
|
||||
$message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n";
|
||||
return [
|
||||
'appId' => $app['app_id'],
|
||||
'timeStamp' => $timestamp,
|
||||
'nonceStr' => $nonce,
|
||||
'package' => $package,
|
||||
'signType' => 'RSA',
|
||||
'paySign' => $this->sign($message, (string) $app['mch_private_key']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* APIv3 Authorization 头
|
||||
*/
|
||||
private function authorization(string $method, string $path, string $body, array $app): string
|
||||
{
|
||||
$timestamp = time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
$signature = $this->sign($message, (string) $app['mch_private_key']);
|
||||
return sprintf(
|
||||
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
|
||||
$app['mch_id'],
|
||||
$nonce,
|
||||
$timestamp,
|
||||
$app['mch_serial_no'],
|
||||
$signature
|
||||
);
|
||||
}
|
||||
|
||||
private function sign(string $message, string $privateKey): string
|
||||
{
|
||||
$key = openssl_pkey_get_private($privateKey);
|
||||
if ($key === false) {
|
||||
UtilsService::getInstance()->errorThrow('商户私钥无效');
|
||||
}
|
||||
openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption');
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调验签
|
||||
*
|
||||
* 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。
|
||||
*/
|
||||
private function verifySignature(array $headers, string $rawBody, array $app): bool
|
||||
{
|
||||
$publicKey = (string) ($app['platform_public_key'] ?? '');
|
||||
if ($publicKey === '') {
|
||||
return false;
|
||||
}
|
||||
$timestamp = (string) ($headers['wechatpay-timestamp'] ?? '');
|
||||
$nonce = (string) ($headers['wechatpay-nonce'] ?? '');
|
||||
$signature = (string) ($headers['wechatpay-signature'] ?? '');
|
||||
if ($timestamp === '' || $nonce === '' || $signature === '') {
|
||||
return false;
|
||||
}
|
||||
// 时间戳偏移超过 5 分钟视为重放
|
||||
if (abs(time() - (int) $timestamp) > 300) {
|
||||
return false;
|
||||
}
|
||||
$message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n";
|
||||
$key = openssl_pkey_get_public($publicKey);
|
||||
if ($key === false) {
|
||||
return false;
|
||||
}
|
||||
return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密回调报文(AEAD_AES_256_GCM)
|
||||
*/
|
||||
private function decryptResource(array $resource, string $apiV3Key): array
|
||||
{
|
||||
$ciphertext = base64_decode((string) ($resource['ciphertext'] ?? ''));
|
||||
$nonce = (string) ($resource['nonce'] ?? '');
|
||||
$associated = (string) ($resource['associated_data'] ?? '');
|
||||
if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') {
|
||||
return [];
|
||||
}
|
||||
$tagLength = 16;
|
||||
$tag = substr($ciphertext, -$tagLength);
|
||||
$data = substr($ciphertext, 0, -$tagLength);
|
||||
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated);
|
||||
return $plain === false ? [] : (json_decode($plain, true) ?: []);
|
||||
}
|
||||
}
|
||||
125
app/Service/wx/WxProductService.php
Normal file
125
app/Service/wx/WxProductService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\CatalogueModel;
|
||||
use App\Models\business\CategoryModel;
|
||||
use App\Models\business\ImageModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\business\PriceService;
|
||||
|
||||
/**
|
||||
* 小程序商品列表与详情
|
||||
*
|
||||
* 价格倍率与可见性统一走 PriceService,后台与小程序共用同一套规则,
|
||||
* 避免出现「后台看原价、小程序看倍率价」对不上账的老问题。
|
||||
*/
|
||||
class WxProductService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 商品列表:分类点到二级时按父分类下的所有子分类查
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$title = (string) request()->get('title', '');
|
||||
$categoryId = request()->get('category_id');
|
||||
$childIds = [];
|
||||
if (!empty($categoryId)) {
|
||||
$childIds = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all();
|
||||
}
|
||||
|
||||
// 小程序前端发的是 size(见 pages/product|search getProductList),老接口曾用 pageSize,
|
||||
// 这里两者都接,避免改一边漏一边
|
||||
$pageSize = (int) (request()->get('pageSize') ?: request()->get('size') ?: 10);
|
||||
|
||||
$result = CatalogueModel::when($title !== '', function ($query) use ($title) {
|
||||
$query->where('title', 'like', '%' . $title . '%');
|
||||
})->when(!empty($categoryId) && empty($childIds), function ($query) use ($categoryId) {
|
||||
$query->where('category_id', $categoryId);
|
||||
})->when(!empty($childIds), function ($query) use ($childIds) {
|
||||
$query->whereIn('category_id', $childIds);
|
||||
})->where('deleted_at', 0)
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'page' => $result['current_page'],
|
||||
'size' => $result['per_page'],
|
||||
'page_count' => $result['last_page'],
|
||||
// 小程序 productWaterfall / product.vue 读的是 last_page(不是 page_count),两个键都给
|
||||
'last_page' => $result['last_page'],
|
||||
'total' => $result['total'],
|
||||
'items' => $result['data'],
|
||||
'data' => $result['data'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品详情
|
||||
*
|
||||
* 带 p_user_id 说明是代理商分享进来的:普通新用户会被绑到该代理商名下并继承其倍率,
|
||||
* 这是老项目的既有行为,不能改,否则代理商体系的价格会全部失效。
|
||||
*/
|
||||
public function detail(int $id, int $shareUserId = 0): array
|
||||
{
|
||||
if ($shareUserId > 0) {
|
||||
$this->inheritAgentPrice($shareUserId);
|
||||
}
|
||||
|
||||
$info = CatalogueModel::with([
|
||||
'category',
|
||||
'images' => fn ($query) => $query->where('deleted_at', 0),
|
||||
'priceSheet' => fn ($query) => $query->where('deleted_at', 0),
|
||||
])->where('deleted_at', 0)->find($id);
|
||||
if (empty($info)) {
|
||||
$this->utils->errorThrow('商品不存在');
|
||||
}
|
||||
$info = $info->toArray();
|
||||
|
||||
// 渲染图作为详情轮播
|
||||
$info['carousel'] = [];
|
||||
foreach ($info['images'] ?? [] as $image) {
|
||||
if ((int) $image['type'] === ImageModel::TYPE_RENDER) {
|
||||
$info['carousel'][] = $image['url'];
|
||||
}
|
||||
}
|
||||
|
||||
$showPrice = $this->canSeePrice();
|
||||
$applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier());
|
||||
$info['price_sheet'] = $applied['rows'];
|
||||
$info['is_show_price'] = $applied['is_show_price'];
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 继承分享代理商的倍率
|
||||
*
|
||||
* 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率,
|
||||
* 代理商改了倍率下级要跟着变。
|
||||
*/
|
||||
private function inheritAgentPrice(int $shareUserId): void
|
||||
{
|
||||
$isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1;
|
||||
$pid = (int) ($this->userInfo['pid'] ?? 0);
|
||||
if ($isAgent) {
|
||||
return;
|
||||
}
|
||||
if ($pid !== 0 && $pid !== $shareUserId) {
|
||||
return;
|
||||
}
|
||||
$agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first();
|
||||
if (empty($agent) || (int) $agent['is_p'] !== 1) {
|
||||
return;
|
||||
}
|
||||
$this->userInfo['show_price'] = 1;
|
||||
$this->userInfo['price_number'] = $agent['price_number'];
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'pid' => $shareUserId,
|
||||
'show_price' => 1,
|
||||
'price_number' => $agent['price_number'],
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
109
app/Service/wx/WxThemeService.php
Normal file
109
app/Service/wx/WxThemeService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\WxTemplateModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Models\WxAppModel;
|
||||
use App\Service\business\WxTemplatePresetService;
|
||||
use App\Service\business\WxTemplateSchemaService;
|
||||
|
||||
/**
|
||||
* 小程序主题下发(免授权)
|
||||
*
|
||||
* 小程序启动时拉一次并缓存本地,拿不到就用内置兜底主题——
|
||||
* 主题接口挂了不能导致小程序白屏。
|
||||
*/
|
||||
class WxThemeService extends BaseWxService
|
||||
{
|
||||
protected bool $needLogin = false;
|
||||
|
||||
/**
|
||||
* 当前生效主题
|
||||
*
|
||||
* 优先级:显式 code > 登录经销商专属 template_code > 品牌配置 template_code
|
||||
* > 该品牌默认模板 > 通用默认模板 > 内置预设
|
||||
*/
|
||||
public function current(string $code = ''): array
|
||||
{
|
||||
$appCode = WxAppService::getInstance()->currentCode();
|
||||
$template = null;
|
||||
|
||||
if ($code !== '') {
|
||||
$template = WxTemplateModel::where('code', $code)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
// 已登录经销商:可用专属模板覆盖品牌默认(未登录则 userInfo 为空,跳过)
|
||||
if (empty($template) && (int) ($this->userInfo['is_p'] ?? 0) === 1) {
|
||||
$userCode = trim((string) ($this->userInfo['template_code'] ?? ''));
|
||||
if ($userCode === '' && !empty($this->userInfo['id'])) {
|
||||
$userCode = (string) (WxUserModel::where('id', $this->userInfo['id'])
|
||||
->where('deleted_at', 0)
|
||||
->value('template_code') ?? '');
|
||||
}
|
||||
if ($userCode !== '') {
|
||||
$template = WxTemplateModel::where('code', $userCode)
|
||||
->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
}
|
||||
if (empty($template) && $appCode !== '') {
|
||||
$bound = (string) (WxAppModel::where('code', $appCode)
|
||||
->where('deleted_at', 0)
|
||||
->value('template_code') ?? '');
|
||||
if ($bound !== '') {
|
||||
$template = WxTemplateModel::where('code', $bound)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
}
|
||||
if (empty($template) && $appCode !== '') {
|
||||
$template = WxTemplateModel::where('app_code', $appCode)
|
||||
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
if (empty($template)) {
|
||||
$template = WxTemplateModel::where('app_code', '')
|
||||
->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first();
|
||||
}
|
||||
|
||||
$schema = WxTemplateSchemaService::getInstance();
|
||||
if (empty($template)) {
|
||||
$preset = WxTemplatePresetService::getInstance()->all()[0] ?? [];
|
||||
$tokens = $preset['tokens'] ?? [];
|
||||
$layout = $preset['layout'] ?? [];
|
||||
return [
|
||||
'code' => (string) ($preset['code'] ?? 'lux-champagne'),
|
||||
'name' => (string) ($preset['name'] ?? '轻奢·香槟金'),
|
||||
'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'),
|
||||
'version' => 0,
|
||||
'tokens' => $tokens,
|
||||
'layout' => $layout,
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$template = $template->toArray();
|
||||
$tokens = is_array($template['tokens']) ? $template['tokens'] : [];
|
||||
return [
|
||||
'code' => (string) $template['code'],
|
||||
'name' => (string) $template['name'],
|
||||
'style_tag' => (string) $template['style_tag'],
|
||||
'version' => (int) $template['version'],
|
||||
'tokens' => $tokens,
|
||||
'layout' => is_array($template['layout']) ? $template['layout'] : [],
|
||||
'css_vars' => $schema->toCssVariables($tokens),
|
||||
'fallback' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选风格列表,用于小程序里让用户自己换肤
|
||||
*/
|
||||
public function gallery(): mixed
|
||||
{
|
||||
$appCode = WxAppService::getInstance()->currentCode();
|
||||
return WxTemplateModel::where('deleted_at', 0)
|
||||
->where('status', 0)
|
||||
->when($appCode !== '', fn ($query) => $query->whereIn('app_code', ['', $appCode]))
|
||||
->orderBy('sort', 'asc')
|
||||
->get(['code', 'name', 'style_tag', 'preview']);
|
||||
}
|
||||
}
|
||||
104
app/Service/wx/WxTokenService.php
Normal file
104
app/Service/wx/WxTokenService.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\Models\business\WxUserModel;
|
||||
use App\Service\common\UtilsService;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
/**
|
||||
* 小程序用户令牌
|
||||
*
|
||||
* 老 lgp-wx-api 把微信的 session_key 当 token 直接返回客户端:
|
||||
* 它既是解密用户数据的密钥,又永不过期,泄露等于长期冒用身份。
|
||||
* 这里改成服务端自签 JWT,session_key 只留库里。
|
||||
*
|
||||
* payload 带 scope=wx,与后台管理端的 token 互不通用——
|
||||
* 否则一个小程序 token 就能打后台接口。
|
||||
*/
|
||||
class WxTokenService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
private const SCOPE = 'wx';
|
||||
|
||||
private string $secretKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$secret = (string) config('nl.jwt.secret', '');
|
||||
// 与后台同一个密钥但 scope 不同,验证时强制校验 scope,不存在越权互通
|
||||
$this->secretKey = strlen($secret) >= 32 ? $secret : hash('sha256', $secret !== '' ? $secret : 'nl_wx_jwt_fallback');
|
||||
}
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发令牌
|
||||
*/
|
||||
public function issue(int $userId, string $appCode = ''): string
|
||||
{
|
||||
$now = time();
|
||||
return JWT::encode([
|
||||
'iat' => $now,
|
||||
'exp' => $now + (int) config('nl.wx.token_ttl', 30 * 24 * 3600),
|
||||
'scope' => self::SCOPE,
|
||||
'uid' => $userId,
|
||||
'app' => $appCode,
|
||||
], $this->secretKey, 'HS256');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验令牌并返回用户;失败返回 null 由中间件统一响应
|
||||
*/
|
||||
public function resolveUser(?string $token): ?array
|
||||
{
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$payload = JWT::decode($token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
if (($payload->scope ?? '') !== self::SCOPE) {
|
||||
return null;
|
||||
}
|
||||
$userId = (int) ($payload->uid ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
// 每次请求回查用户:倍率、价格可见性、是否被停用都可能刚被后台改过
|
||||
$user = WxUserModel::where('id', $userId)->where('deleted_at', 0)->first();
|
||||
if (empty($user)) {
|
||||
return null;
|
||||
}
|
||||
$user = $user->toArray();
|
||||
unset($user['session_key']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前请求的小程序用户,取不到直接中断
|
||||
*/
|
||||
public function requireUser(): array
|
||||
{
|
||||
$user = request()->attributes->get('wx_user');
|
||||
if (!empty($user)) {
|
||||
return $user;
|
||||
}
|
||||
$user = $this->resolveUser(request()->bearerToken());
|
||||
if (empty($user)) {
|
||||
UtilsService::getInstance()->notAuth('请先登录');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
70
app/Service/wx/WxUploadService.php
Normal file
70
app/Service/wx/WxUploadService.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\FileModel;
|
||||
use App\Service\common\oss\OssRuntimeConfigService;
|
||||
use App\Service\common\oss\OssStorageFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 小程序上传入口(转账凭证等)。
|
||||
*
|
||||
* 不直接复用后台 UploadService:后者继承 BaseService,构造期会解后台 JWT,
|
||||
* 在 wx 组里走 BaseService 等于拿不到小程序身份、还会触发后台 token 校验。
|
||||
* 这里只复用底层工厂 OssRuntimeConfigService / OssStorageFactory,
|
||||
* 保证存储实现与后台一致、身份走 BaseWxService 的 cc_wx_user。
|
||||
*/
|
||||
class WxUploadService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 上传图片到当前启用的 OSS / 本地存储。
|
||||
*
|
||||
* 返回结构与后台 UploadService::uploadImage 对齐(url 键),
|
||||
* 小程序拿到 url 后塞进 voucher 字段,再调 wx/order/voucher 提交。
|
||||
*
|
||||
* @param mixed $file UploadedFile
|
||||
*/
|
||||
public function uploadImage($file): array|bool
|
||||
{
|
||||
if (empty($file)) {
|
||||
$this->utils->errorThrow('请选择图片');
|
||||
}
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
// 单独走 wx 命名空间避免与后台素材库扫描混目录
|
||||
$key = 'wx/voucher/' . date('Ymd') . '/' . 'wx_' . Str::random() . uniqid() . '.' . $ext;
|
||||
$storage = $this->resolveStorage();
|
||||
$result = $storage->uploadVideo($file, $key);
|
||||
if (!$result) {
|
||||
$this->utils->errorThrow('图片上传失败');
|
||||
}
|
||||
// 落一份上传流水到 nl_file,type=image,方便后台审计小程序上传的凭证
|
||||
FileModel::insert([
|
||||
'user_id' => $this->userId,
|
||||
'url' => $result['url'],
|
||||
'type' => FileModel::TYPE_IMAGE,
|
||||
'source' => FileModel::SOURCE_UPLOAD,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前启用的存储实现;配置异常时回退本地,避免整站上传不可用
|
||||
*/
|
||||
private function resolveStorage()
|
||||
{
|
||||
try {
|
||||
$config = OssRuntimeConfigService::getInstance()->getActiveConfig();
|
||||
return OssStorageFactory::getInstance()->make($config);
|
||||
} catch (\Throwable $e) {
|
||||
return OssStorageFactory::getInstance()->make([
|
||||
'driver' => 'local',
|
||||
'path_prefix' => 'uploads',
|
||||
'domain' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Service/wx/WxUserCenterService.php
Normal file
67
app/Service/wx/WxUserCenterService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\wx;
|
||||
|
||||
use App\BaseApp\BaseWxService;
|
||||
use App\Models\business\ListModel;
|
||||
use App\Models\business\OrderModel;
|
||||
use App\Models\business\WxUserModel;
|
||||
|
||||
/**
|
||||
* 小程序「我的」
|
||||
*/
|
||||
class WxUserCenterService extends BaseWxService
|
||||
{
|
||||
/**
|
||||
* 我的信息 + 清单/订单计数
|
||||
*/
|
||||
public function myInfo(): array
|
||||
{
|
||||
$listCount = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
|
||||
$orderCount = OrderModel::where('user_id', $this->userId)->where('deleted_at', 0)->count();
|
||||
$user = WxUserModel::with('enterprise')->where('id', $this->userId)->first();
|
||||
$user = $user ? $user->toArray() : $this->userInfo;
|
||||
unset($user['session_key']);
|
||||
return [
|
||||
'count' => $listCount,
|
||||
'order_count' => $orderCount,
|
||||
'user' => $user,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
*
|
||||
* 优先用微信手机号快速验证的 code 换真实号码(用户填的可能是假号);
|
||||
* 只有客户端拿不到 code 时才退回直接写入。
|
||||
*/
|
||||
public function bandPhone(array $params): array
|
||||
{
|
||||
$phone = trim((string) ($params['phone'] ?? ''));
|
||||
$phoneCode = trim((string) ($params['phone_code'] ?? ''));
|
||||
if ($phoneCode !== '') {
|
||||
$phone = WxMiniService::getInstance()->getPhoneNumber($phoneCode);
|
||||
}
|
||||
if ($phone === '') {
|
||||
$this->utils->errorThrow('手机号不能为空');
|
||||
}
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'phone' => $phone,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return ['phone' => $phone];
|
||||
}
|
||||
|
||||
public function updateNickName(string $nickName): array
|
||||
{
|
||||
$nickName = trim($nickName);
|
||||
if ($nickName === '') {
|
||||
$this->utils->errorThrow('昵称不能为空');
|
||||
}
|
||||
WxUserModel::where('id', $this->userId)->update([
|
||||
'nick_name' => $nickName,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
return ['nick_name' => $nickName];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user