小程序登录优化

This commit is contained in:
liu
2026-08-10 08:55:22 +08:00
parent c6f69c5c55
commit df7631c7a9
25 changed files with 954 additions and 549 deletions
+73 -82
View File
@@ -3,134 +3,125 @@
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Http\Requests\UserUpdateInfoRequest;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use App\Services\WechatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序认证(微信登录 / 手机号绑定 / 当前用户信息)
*
* authGuard: usersprovider 指向 UserModel,与后台 sys_users 天然隔离);
* authorize: true 仅要求登录(sanctum + authGuard:users),不做细粒度权限点;
* token abilities ['mini'] 作来源标记。
* 小程序认证
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class AuthController extends BaseMiniController
{
/** 小程序登录wx.login 的 code → openid → 自动注册/登录 → 签发 token */
/** 小程序登录 */
#[PostRoute('/auth/login', authorize: false)]
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'code' => 'required|string',
$validated = $request->validate([
'code' => 'required|string'
], [
'code.required' => '缺少登录凭证 code',
'code.required' => '登录参数格式错误',
'code.string' => '登录参数格式错误',
]);
$session = app(WechatService::class)->code2Session($data['code']);
$session = app(WechatService::class)->code2Session($validated['code']);
$user = UserModel::firstOrNew(['openid' => $session['openid']]);
$isNew = ! $user->exists;
$user = UserModel::where('openid', $session['openid'])->first();
if ($user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号已被停用,请联系客服');
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
return $this->error('账号不存在或已被停用');
}
if ($isNew) {
$user->type = UserModel::TYPE_PENDING;
$user->status = UserModel::STATUS_NORMAL;
}
if (! empty($session['unionid'])) {
$user->unionid = $session['unionid'];
}
$user->last_login_at = now();
$user->last_login_at = date('Y-m-d H:i:s');
$user->save();
$token = $user->createToken('mini', ['mini'])->plainTextToken;
$token = $user->createToken($user->openid)->toArray();
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
], $isNew ? '注册成功' : '登录成功');
'token' => $token['plainTextToken'],
'user' => $user->toArray(),
], __('user.login_success'));
}
/**
* 绑定手机号:phoneCode 换手机号 → 按手机号自动匹配门店/供应商
* (命中门店 → type=1+store_id;命中供应商 → type=2+supplier_id;都不命中 → 保持待绑定,后台人工处理)
*/
#[PostRoute('/auth/phone', authorize: true)]
public function phone(Request $request): JsonResponse
/** 小程序注册 */
#[PostRoute('/auth/register', authorize: false)]
public function register(Request $request): JsonResponse
{
$data = $request->validate([
$validated = $request->validate([
'code' => 'required|string',
'phoneCode' => 'required|string',
'storeCode' => 'required|string'
], [
'phoneCode.required' => '缺少手机号授权凭证 phoneCode',
'code.required' => '注册参数格式错误',
'code.string' => '注册参数格式错误',
'phoneCode.required' => '注册参数格式错误',
'phoneCode.string' => '注册参数格式错误',
'storeCode.required' => '门店编码必须填写',
'storeCode.string' => '注册参数格式错误',
]);
$phone = app(WechatService::class)->getPhone($data['phoneCode']);
$user = $this->currentUser($request);
$user->phone = $phone;
if (! $user->isBound()) {
$store = StoreModel::where('phone', $phone)
->where('status', StoreModel::STATUS_NORMAL)
->first();
if ($store !== null) {
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::where('phone', $phone)
->where('status', SupplierModel::STATUS_NORMAL)
->first();
if ($supplier !== null) {
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
}
}
$store = StoreModel::where('code', $validated['storeCode'])->first();
if (!$store) {
return $this->error('门店不存在!');
}
$user->save();
// 通过 code 换取 openid、session_key、unionid
$session = app(WechatService::class)->code2Session($validated['code']);
$user = UserModel::where('openid', $session['openid'])->first();
if ($user) {
return $this->error('你的微信已经注册,请直接登录!');
}
$userData = [
'openid' => $session['openid'],
'unionid' => $session['unionid'] ?? '',
'username' => 'wx_'.uniqid(),
'nickname' => '微信用户',
'store_id' => $store->id,
'avatar' => '',
'password' => '',
'last_login_at' => date('Y-m-d H:i:s'),
];
$phone = app(WechatService::class)->getPhone($validated['phoneCode']);
$userData['phone'] = $phone ?? '';
$user = UserModel::create($userData);
$token = $user->createToken($user->username)->toArray();
return $this->success([
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
]);
'token' => $token['plainTextToken'],
'user' => $user->toArray(),
], __('user.login_success'));
}
/** 当前用户信息(含门店客户等级 —— 全局价格体系依据 / 供应商信息) */
#[GetRoute('/auth/info', authorize: true)]
/** 当前用户信息 */
#[GetRoute('/auth/info')]
public function info(Request $request): JsonResponse
{
$user = UserModel::with(['store.level:id,name', 'supplier:id,name'])
$user = UserModel::with(['store.level:id,name'])
->find($request->user()->id);
if ($user === null) {
throw new RepositoryException('账号不存在');
}
return $this->success(['user' => $this->formatUser($user)]);
return $this->success($user->toArray());
}
/**
* 小程序端用户信息输出结构
*
* @return array<string, mixed>
*/
private function formatUser(UserModel $user): array
#[PutRoute('auth/info')]
public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse
{
return [
'id' => $user->id,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'phone' => $user->phone,
'type' => $user->type,
'store' => $user->store,
'supplier' => $user->supplier,
];
UserModel::where('user_id', auth('user')->id())->update($request->validated());
return $this->error('更新成功');
}
}
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Http\Request;
use Modules\Common\Http\Controllers\BaseController;
@@ -13,7 +12,7 @@ use Modules\Common\Http\Controllers\BaseController;
* 小程序端控制器基类
*
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
* 提供当前用户获取与门店/供应商绑定前置校验。
* 提供当前用户获取与门店绑定前置校验。
*/
abstract class BaseMiniController extends BaseController
{
@@ -26,7 +25,6 @@ abstract class BaseMiniController extends BaseController
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号不存在或已被停用');
}
return $user;
}
@@ -35,7 +33,7 @@ abstract class BaseMiniController extends BaseController
*/
protected function ensureStoreBound(UserModel $user): StoreModel
{
if ($user->type !== UserModel::TYPE_STORE || $user->store_id <= 0) {
if ($user->store_id <= 0) {
throw new RepositoryException('尚未绑定门店,请联系客服处理');
}
$store = StoreModel::find($user->store_id);
@@ -47,18 +45,20 @@ abstract class BaseMiniController extends BaseController
}
/**
* 供应商端前置校验:type=供应商 且 supplier_id>0 且供应商正常
* 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错)
*
* 供商品浏览等「弱前置」场景使用:未绑定门店仍可浏览商品,仅价格不可见。
*/
protected function ensureSupplierBound(UserModel $user): SupplierModel
protected function boundStore(UserModel $user): ?StoreModel
{
if ($user->type !== UserModel::TYPE_SUPPLIER || $user->supplier_id <= 0) {
throw new RepositoryException('尚未绑定供应商,请联系客服处理');
if ($user->store_id <= 0) {
return null;
}
$supplier = SupplierModel::find($user->supplier_id);
if ($supplier === null || $supplier->status !== SupplierModel::STATUS_NORMAL) {
throw new RepositoryException('供应商不存在或已停用,请联系客服处理');
$store = StoreModel::find($user->store_id);
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
return null;
}
return $supplier;
return $store;
}
}
+10 -11
View File
@@ -16,34 +16,34 @@ use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\SystemTool\Models\SysFileModel;
use Throwable;
/**
* 小程序购物车(门店订货车:加购 / 列表 / 改数量 / 删项 / 清空)
* 提交订货单复用 POST /mini/order,购物车仅作前置编辑容器
* 小程序购物车
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class CartController extends BaseMiniController
{
/** decimal(10,2) 上限 */
private const MAX_QUANTITY = '99999999.99';
private const string MAX_QUANTITY = '99999999.99';
/**
* 加购:商品上架 + 门店有等级价(与下单一致 fail-fast),同商品合并累加
* 加购
* @throws Throwable
*/
#[PostRoute('/cart', authorize: true)]
#[PostRoute('/cart')]
public function store(MiniCartRequest $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法加购,请联系客服');
return $this->error('门店未设置客户等级,无法加购,请联系客服');
}
$productId = (int) $request->validated('product_id');
// Eloquent 查询自带 SoftDeletes 全局作用域:软删除/下架一并在内
$product = ProductModel::where('status', ProductModel::STATUS_ON)->find($productId);
if ($product === null) {
throw new RepositoryException('商品不存在或已下架,请刷新后重试');
return $this->error('商品不存在或已下架,请刷新后重试');
}
// 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购)
$hasPrice = ProductPriceModel::query()
@@ -51,7 +51,7 @@ class CartController extends BaseMiniController
->where('level_id', $store->level_id)
->exists();
if (! $hasPrice) {
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法加购');
return $this->error('商品「' . $product->name . '」价格未设置,无法加购');
}
$quantity = (string) $request->validated('quantity');
@@ -88,8 +88,7 @@ class CartController extends BaseMiniController
}
/**
* 购物车列表:当前用户全部项 + 实时等级价,逐项服务端 bcmul 算金额;
* status=1 可购 / 0 商品下架、缺失或未设等级价;汇总只统计可购项
* 购物车列表
*/
#[GetRoute('/cart', authorize: true)]
public function index(Request $request): JsonResponse
@@ -12,7 +12,10 @@ use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序商品(分类树 + 列表,价格 = 当前门店客户等级价
* 小程序商品(分类树 + 列表)
*
* 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null);
* 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class ProductController extends BaseMiniController
@@ -53,22 +56,23 @@ class ProductController extends BaseMiniController
}
/**
* 商品列表:价格取当前门店客户等级价(未绑等级的门店报错)
* 商品列表:价格取当前门店客户等级价;
* 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截);
* ?category_id=&keyword=&page=&pageSize=
*/
#[GetRoute('/product/list', authorize: true)]
public function products(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法展示价格,请联系客服');
}
$levelId = $this->boundStore($user)?->level_id ?? 0;
$query = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->with('category:id,name')
->with(['prices' => static fn ($q) => $q->where('level_id', $store->level_id)]);
->with('category:id,name');
if ($levelId > 0) {
$query->with(['prices' => static fn ($q) => $q->where('level_id', $levelId)]);
}
$categoryId = (int) $request->input('category_id', 0);
if ($categoryId > 0) {
@@ -88,7 +92,8 @@ class ProductController extends BaseMiniController
->paginate($pageSize)
->toArray();
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;未设等级价为 null
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;
// 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底)
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤
foreach ($data['data'] as &$row) {
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
@@ -1,104 +0,0 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序供应商端(接收采购单 / 明细 / 确认接单)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class SupplierController extends BaseMiniController
{
/** 收到的采购单:含本供应商 is_sent=1 明细的采购单(去重) */
#[GetRoute('/supplier/purchases', authorize: true)]
public function purchases(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchaseIds = PurchaseOrderItemModel::query()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->distinct()
->pluck('purchase_id');
$data = PurchaseOrderModel::query()
->whereIn('id', $purchaseIds)
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 采购单明细:仅本供应商且已发送的明细行 */
#[GetRoute('/supplier/purchases/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
return $this->success([
'id' => $purchase->id,
'purchase_no' => $purchase->purchase_no,
'purchase_date' => $purchase->purchase_date?->toDateString(),
'remark' => $purchase->remark,
'items' => $items->toArray(),
]);
}
/** 确认接单:本供应商已发送明细批量记录 supplier_confirmed_at(幂等) */
#[PutRoute('/supplier/purchases/{id}/confirm', authorize: true, where: ['id' => '[0-9]+'])]
public function confirm(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
$now = now();
$confirmed = 0;
foreach ($items as $item) {
if ($item->supplier_confirmed_at === null) {
$item->supplier_confirmed_at = $now;
$item->save();
$confirmed++;
}
}
return $this->success(['confirmed' => $confirmed], '已确认接单');
}
}