小程序登录优化

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
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
<?php
if (!function_exists('generate_unique_code')) {
function generate_unique_code($model, $column = 'code', $length = 6): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$max = strlen($characters) - 1;
do {
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $characters[random_int(0, $max)];
}
} while ($model::where($column, $code)->exists());
return $code;
}
}
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\MiniUserBindRequest;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,9 +20,7 @@ use Modules\Common\Http\Controllers\BaseController;
class MiniUserController extends BaseController
{
protected array $searchField = [
'type' => '=',
'store_id' => '=',
'supplier_id' => '=',
'status' => '=',
];
@@ -35,7 +32,7 @@ class MiniUserController extends BaseController
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name', 'supplier:id,name'))
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
@@ -43,7 +40,7 @@ class MiniUserController extends BaseController
}
/**
* 绑定门店/供应商(一个门店可绑多个账号,一个账号只绑一个主体
* 绑定门店(一个门店可绑多个账号,一个账号只绑一个门店
*/
#[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])]
public function bind(int $id, MiniUserBindRequest $request): JsonResponse
@@ -53,29 +50,16 @@ class MiniUserController extends BaseController
if (empty($user)) {
throw new RepositoryException('用户不存在');
}
if ((int) $validated['type'] === UserModel::TYPE_STORE) {
$store = StoreModel::find((int) $validated['store_id']);
if (empty($store)) {
throw new RepositoryException('门店不存在');
}
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::find((int) $validated['supplier_id']);
if (empty($supplier)) {
throw new RepositoryException('供应商不存在');
}
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
$store = StoreModel::find((int) $validated['store_id']);
if (empty($store)) {
throw new RepositoryException('门店不存在');
}
$user->store_id = $store->id;
$user->save();
return $this->success();
}
/** 启用/停用(停用后登录时检查 status 拒绝,token 鉴权拦截) */
/** 启用/停用 */
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
{
@@ -46,7 +46,9 @@ class StoreController extends BaseController
#[PostRoute(authorize: 'create')]
public function create(StoreFormRequest $request): JsonResponse
{
StoreModel::create($request->validated());
$validated = $request->validated();
$validated['code'] = generate_unique_code(StoreModel::class);
StoreModel::create($validated);
return $this->success();
}
+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], '已确认接单');
}
}
@@ -146,7 +146,6 @@ class StoreOrderController extends BaseController
private function notifyStore(StoreOrderModel $order): void
{
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('store_id', $order->store_id)
->where('status', UserModel::STATUS_NORMAL)
->pluck('id');
@@ -257,7 +257,6 @@ class ProductController extends BaseController
// 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('status', UserModel::STATUS_NORMAL)
->whereIn('store_id', function ($q) use ($percentLevelIds) {
$q->select('id')
@@ -5,7 +5,7 @@ namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 小程序用户绑定 验证(type=1 门店需 store_idtype=2 供应商需 supplier_id
* 小程序用户绑定 验证(绑定门店
*/
class MiniUserBindRequest extends BaseFormRequest
{
@@ -14,21 +14,15 @@ class MiniUserBindRequest extends BaseFormRequest
public function rules(): array
{
return [
'type' => 'required|integer|in:1,2',
'store_id' => 'required_if:type,1|nullable|integer|min:1',
'supplier_id' => 'required_if:type,2|nullable|integer|min:1',
'store_id' => 'required|integer|min:1',
];
}
public function messages(): array
{
return [
'type.required' => '请选择用户类型',
'type.in' => '用户类型只能是门店或供应商',
'store_id.required_if' => '绑定门店时必须选择门店',
'store_id.required' => '绑定门店时必须选择门店',
'store_id.min' => '门店ID不正确',
'supplier_id.required_if' => '绑定供应商时必须选择供应商',
'supplier_id.min' => '供应商ID不正确',
];
}
}
@@ -14,14 +14,8 @@ class StoreFormRequest extends BaseFormRequest
public function rules(): array
{
$unique = Rule::unique('store', 'code');
if ($this->isUpdate()) {
$unique = $unique->ignore($this->route('id'));
}
return [
'name' => 'required|string|max:100',
'code' => ['required', 'string', 'max:50', $unique],
'level_id' => 'required|integer|exists:customer_level,id',
'contact' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
@@ -37,8 +31,6 @@ class StoreFormRequest extends BaseFormRequest
return [
'name.required' => '门店名称不能为空',
'name.max' => '门店名称最长 100 个字符',
'code.required' => '门店编码不能为空',
'code.unique' => '门店编码已存在',
'level_id.required' => '请选择客户等级',
'level_id.exists' => '客户等级不存在',
'payment_cycle_days.integer' => '回款周期必须为整数',
+6 -34
View File
@@ -15,17 +15,10 @@ class UserModel extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/** 用户类型:待绑定(手机号未匹配到门店/供应商,需后台人工绑定) */
public const TYPE_PENDING = 0;
/** 用户类型:门店 */
public const TYPE_STORE = 1;
/** 用户类型:供应商 */
public const TYPE_SUPPLIER = 2;
/** 状态:停用 */
public const STATUS_DISABLED = 0;
public const int STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
public const int STATUS_NORMAL = 1;
protected $table = 'user';
@@ -45,38 +38,26 @@ class UserModel extends Authenticatable
'unionid',
'phone',
'avatar',
'type',
'store_id',
'supplier_id',
'status',
'last_login_at',
];
protected $casts = [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'type' => 'integer',
'email_verified_at' => 'datetime:Y-m-d H:i:s',
'last_login_at' => 'datetime:Y-m-d H:i:s',
'created_at' => 'datetime:Y-m-d H:i:s',
'store_id' => 'integer',
'supplier_id' => 'integer',
'status' => 'integer',
];
/**
* 关联门店type=1 时有效)
* 关联门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 关联供应商(type=2 时有效)
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
}
/**
* 用户通知
*/
@@ -84,13 +65,4 @@ class UserModel extends Authenticatable
{
return $this->hasMany(NoticeModel::class, 'user_id', 'id');
}
/**
* 是否已绑定业务主体(门店或供应商)
*/
public function isBound(): bool
{
return $this->type === self::TYPE_STORE && $this->store_id > 0
|| $this->type === self::TYPE_SUPPLIER && $this->supplier_id > 0;
}
}
+2 -1
View File
@@ -35,7 +35,8 @@
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"modules/Common/helpers.php"
"modules/Common/helpers.php",
"app/Helpers/functions.php"
]
},
"autoload-dev": {
-14
View File
@@ -29,9 +29,7 @@ class UserModelFactory extends Factory
'unionid' => '',
'phone' => '',
'avatar' => '',
'type' => UserModel::TYPE_PENDING,
'store_id' => 0,
'supplier_id' => 0,
'status' => UserModel::STATUS_NORMAL,
'last_login_at' => null,
];
@@ -43,22 +41,10 @@ class UserModelFactory extends Factory
public function forStore(int $storeId): static
{
return $this->state(fn () => [
'type' => UserModel::TYPE_STORE,
'store_id' => $storeId,
]);
}
/**
* 已绑定供应商的供应商用户
*/
public function forSupplier(int $supplierId): static
{
return $this->state(fn () => [
'type' => UserModel::TYPE_SUPPLIER,
'supplier_id' => $supplierId,
]);
}
/**
* 停用账号
*/
@@ -1,47 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('user')) {
Schema::create('user', function (Blueprint $table) {
$table->increments('id')->comment('用户ID');
$table->string('username', 20)->nullable()->unique()->comment('用户名(微信注册用户可为空)');
$table->string('password', 100)->nullable()->comment('密码(微信注册用户可为空)');
$table->string('nickname', 20)->default('')->comment('昵称');
$table->string('email', 50)->default('')->comment('邮箱');
$table->timestamp('email_verified_at')->nullable();
// 小程序用户扩展字段(微信授权登录,自动识别门店/供应商身份)
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID(小程序用户唯一标识)');
$table->string('unionid', 64)->default('')->comment('微信UnionID');
$table->string('phone', 20)->default('')->comment('手机号(微信授权获取,用于匹配门店/供应商)');
$table->string('avatar', 255)->default('')->comment('头像');
$table->integer('type')->default(0)->comment('用户类型(0待绑定 1门店 2供应商)');
$table->integer('store_id')->default(0)->comment('关联门店IDtype=1时有效)');
$table->integer('supplier_id')->default(0)->comment('关联供应商IDtype=2时有效)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
$table->rememberToken();
$table->timestamps();
$table->index(['type', 'store_id'], 'user_type_store_index');
$table->comment('APP用户表(含小程序用户)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user');
}
};
@@ -8,10 +8,34 @@ return new class extends Migration
{
/**
* Run the migrations.
* 基础档案:客户等级、门店、供应商、消息通知(小程序用户已并入 user 表)
* 基础档案
*/
public function up(): void
{
if (! Schema::hasTable('user')) {
Schema::create('user', function (Blueprint $table) {
$table->increments('id')->comment('用户ID');
$table->string('username', 20)->nullable()->unique()->comment('用户名');
$table->string('password', 100)->nullable()->comment('密码');
$table->string('nickname', 20)->default('')->comment('昵称');
$table->string('email', 50)->default('')->comment('邮箱');
$table->timestamp('email_verified_at')->nullable();
// 小程序用户扩展字段
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID');
$table->string('unionid', 64)->default('')->comment('微信UnionID');
$table->string('phone', 20)->default('')->comment('手机号');
$table->string('avatar', 255)->default('')->comment('头像');
$table->integer('store_id')->default(0)->comment('关联门店ID');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
$table->rememberToken();
$table->timestamps();
$table->index('store_id', 'user_type_index');
$table->index('openid', 'user_openid_index');
$table->comment('APP用户表(含小程序用户)');
});
}
// 客户等级表(价格体系按等级定价)
if (! Schema::hasTable('customer_level')) {
Schema::create('customer_level', function (Blueprint $table) {
@@ -35,7 +59,7 @@ return new class extends Migration
$table->string('contact', 50)->default('')->comment('联系人');
$table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),门店可自行修改,影响对账单应结算日期');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
@@ -62,8 +86,7 @@ return new class extends Migration
});
}
// 消息通知表(订单状态变更、价格调整等通知)
// 小程序用户已并入 user 表,不再单独建 mini_user 表
// 消息通知表
if (! Schema::hasTable('notice')) {
Schema::create('notice', function (Blueprint $table) {
$table->increments('id')->comment('通知ID');
@@ -86,6 +109,7 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('user');
Schema::dropIfExists('customer_level');
Schema::dropIfExists('store');
Schema::dropIfExists('supplier');
+97 -64
View File
@@ -12,7 +12,7 @@ use Symfony\Component\HttpClient\Response\MockResponse;
/**
* 小程序认证:EasyWeChat MockHttpClient 拦截微信调用、
* 登录自动注册、手机号绑定自动匹配、停用拒绝、双端 token 隔离
* 注册绑定门店、登录签发 token、停用拒绝、双端 token 隔离
*/
class MiniAuthTest extends ProcurementTestCase
{
@@ -56,9 +56,11 @@ class MiniAuthTest extends ProcurementTestCase
);
}
/** wx.login code → 自动注册用户签发 token */
public function test_login_creates_user_and_issues_token(): void
/** wx.login code → 注册用户签发 token */
public function test_login_issues_token_for_registered_user(): void
{
$store = StoreModel::factory()->create();
$user = UserModel::factory()->forStore($store->id)->create(['openid' => 'openid_test_001']);
$this->mockWechat(fn () => new MockResponse((string) json_encode([
'openid' => 'openid_test_001',
'session_key' => 'session_key_x',
@@ -67,14 +69,101 @@ class MiniAuthTest extends ProcurementTestCase
$response = $this->postJson('/mini/auth/login', ['code' => 'wx_code']);
$response->assertOk()
->assertJsonPath('success', true)
->assertJsonStructure(['data' => ['token', 'user' => ['id', 'type', 'store', 'supplier']]]);
->assertJsonStructure(['data' => ['token', 'user' => ['id']]]);
$this->assertNotEmpty($response->json('data.token'));
$this->assertNotNull($user->fresh()->last_login_at);
}
$user = UserModel::where('openid', 'openid_test_001')->first();
$this->assertNotNull($user, '应按 openid 自动创建用户');
$this->assertSame(UserModel::TYPE_PENDING, $user->type);
$this->assertNotNull($user->last_login_at);
/** openid 未注册 → 拒绝登录 */
public function test_login_rejects_unregistered_openid(): void
{
$this->mockWechat(fn () => new MockResponse((string) json_encode([
'openid' => 'openid_unknown',
'session_key' => 'sk',
])));
$this->postJson('/mini/auth/login', ['code' => 'wx_code'])
->assertOk()
->assertJsonPath('success', false);
$this->assertSame(0, UserModel::count());
}
/** 注册:门店编码绑定门店并签发 token */
public function test_register_binds_store_and_issues_token(): void
{
$store = StoreModel::factory()->create(['code' => 'ST001', 'phone' => '13800138000']);
$this->mockWechat(function (string $method, string $url): MockResponse {
if (str_contains($url, 'jscode2session')) {
return new MockResponse((string) json_encode([
'openid' => 'openid_reg_001',
'session_key' => 'sk',
]));
}
if (str_contains($url, 'cgi-bin/token')) {
return new MockResponse((string) json_encode([
'access_token' => 'mock_access_token',
'expires_in' => 7200,
]));
}
return new MockResponse((string) json_encode([
'errcode' => 0,
'phone_info' => ['phoneNumber' => '13800138000'],
]));
});
$response = $this->postJson('/mini/auth/register', [
'code' => 'wx_code',
'phoneCode' => 'phone_code',
'storeCode' => 'ST001',
]);
$response->assertOk()
->assertJsonPath('success', true)
->assertJsonStructure(['data' => ['token', 'user' => ['id', 'store_id']]]);
$this->assertNotEmpty($response->json('data.token'));
$user = UserModel::where('openid', 'openid_reg_001')->first();
$this->assertNotNull($user, '应按 openid 注册用户');
$this->assertSame($store->id, $user->store_id);
$this->assertSame('13800138000', $user->phone);
}
/** 注册:门店编码不存在 → 拒绝 */
public function test_register_rejects_unknown_store(): void
{
$this->mockWechat(fn () => new MockResponse((string) json_encode([
'openid' => 'openid_reg_002',
'session_key' => 'sk',
])));
$this->postJson('/mini/auth/register', [
'code' => 'wx_code',
'phoneCode' => 'phone_code',
'storeCode' => 'NOT_EXIST',
])
->assertOk()
->assertJsonPath('success', false);
$this->assertSame(0, UserModel::count());
}
/** 注册:openid 已注册 → 拒绝重复注册 */
public function test_register_rejects_duplicate_openid(): void
{
UserModel::factory()->create(['openid' => 'openid_dup']);
$this->mockWechat(fn () => new MockResponse((string) json_encode([
'openid' => 'openid_dup',
'session_key' => 'sk',
])));
$this->postJson('/mini/auth/register', [
'code' => 'wx_code',
'phoneCode' => 'phone_code',
'storeCode' => 'ST001',
])
->assertOk()
->assertJsonPath('success', false);
$this->assertSame(1, UserModel::count());
}
/** 停用账号拒绝登录 */
@@ -105,62 +194,6 @@ class MiniAuthTest extends ProcurementTestCase
$this->assertSame(0, UserModel::count());
}
/** 手机号绑定:按手机号自动匹配门店 */
public function test_phone_binding_matches_store(): void
{
$store = StoreModel::factory()->create(['phone' => '13800138000']);
$user = UserModel::factory()->create();
$this->actingAsMiniUser($user);
$this->mockWechat(function (string $method, string $url): MockResponse {
if (str_contains($url, '/cgi-bin/token')) {
return new MockResponse((string) json_encode([
'access_token' => 'mock_access_token',
'expires_in' => 7200,
]));
}
return new MockResponse((string) json_encode([
'errcode' => 0,
'phone_info' => ['phoneNumber' => '13800138000'],
]));
});
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
->assertOk()
->assertJsonPath('success', true);
$user = $user->fresh();
$this->assertSame('13800138000', $user->phone);
$this->assertSame(UserModel::TYPE_STORE, $user->type);
$this->assertSame($store->id, $user->store_id);
}
/** 手机号无匹配主体 → 保持待绑定 */
public function test_phone_no_match_stays_pending(): void
{
$user = UserModel::factory()->create();
$this->actingAsMiniUser($user);
$this->mockWechat(function (string $method, string $url): MockResponse {
if (str_contains($url, '/cgi-bin/token')) {
return new MockResponse((string) json_encode([
'access_token' => 'mock_access_token',
'expires_in' => 7200,
]));
}
return new MockResponse((string) json_encode([
'errcode' => 0,
'phone_info' => ['phoneNumber' => '19999999999'],
]));
});
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
->assertJsonPath('success', true);
$this->assertSame(UserModel::TYPE_PENDING, $user->fresh()->type);
}
/** 跨端隔离:后台 token 访问小程序接口 → 401 */
public function test_sys_token_cannot_access_mini(): void
{
+30 -4
View File
@@ -34,15 +34,41 @@ class ProductPriceTest extends ProcurementTestCase
$this->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格');
}
/** 门店未设置客户等级时拒绝展示价格 */
public function test_store_without_level_is_rejected(): void
/** 门店未设置客户等级:仍可浏览商品,但价格不可见(price=null) */
public function test_store_without_level_sees_products_without_price(): void
{
$store = StoreModel::factory()->create(['level_id' => 0]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
$this->getJson('/mini/product/list')
$response = $this->getJson('/mini/product/list');
$response->assertOk()->assertJsonPath('success', true);
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
$this->assertNotNull($row, '未设等级的门店仍应能看到商品');
$this->assertNull($row['price'], '未设等级时价格应为 null');
}
/** 未绑定门店:可浏览商品与分类,但价格不可见;加购仍被拦截(购买前置校验不变) */
public function test_unbound_user_can_browse_products_without_price_but_cannot_cart(): void
{
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
$this->actingAsMiniUser(UserModel::factory()->create()); // type=0 待绑定
$list = $this->getJson('/mini/product/list');
$list->assertOk()->assertJsonPath('success', true);
$row = collect($list->json('data.data'))->firstWhere('id', $product->id);
$this->assertNotNull($row, '未绑定门店应能看到商品');
$this->assertNull($row['price'], '未绑定门店不得看到价格');
$this->getJson('/mini/product/categories')
->assertOk()
->assertJsonPath('success', false);
->assertJsonPath('success', true);
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
->assertJsonPath('success', false)
->assertJsonPath('msg', '尚未绑定门店,请联系客服处理');
}
/** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */
+1 -4
View File
@@ -1,13 +1,10 @@
import createAxios from '@/utils/request';
export interface MiniUserBindParams {
/** 1门店 2供应商 */
type: number;
store_id?: number;
supplier_id?: number;
}
/** 绑定门店/供应商 */
/** 绑定门店 */
export async function bindMiniUser(id: number, data: MiniUserBindParams) {
return createAxios({
url: `/customer/miniUser/${id}/bind`,
-11
View File
@@ -1,5 +1,4 @@
import type IStore from '@/domain/iStore.ts';
import type ISupplier from '@/domain/iSupplier.ts';
/** 小程序用户 */
export default interface IMiniUser {
@@ -7,23 +6,13 @@ export default interface IMiniUser {
nickname?: string;
phone?: string;
avatar?: string;
/** 0待绑定 1门店 2供应商 */
type?: number;
store_id?: number;
supplier_id?: number;
store?: IStore;
supplier?: ISupplier;
status?: number;
last_login_at?: string;
created_at?: string;
}
export const MINI_USER_TYPE_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待绑定', color: 'default' },
1: { text: '门店', color: 'blue' },
2: { text: '供应商', color: 'purple' },
};
export const MINI_USER_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
+25 -94
View File
@@ -5,7 +5,6 @@ import {
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Tag,
@@ -18,20 +17,16 @@ import type {
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IMiniUser from '@/domain/iMiniUser.ts';
import { MINI_USER_STATUS_MAP, MINI_USER_TYPE_MAP } from '@/domain/iMiniUser.ts';
import { MINI_USER_STATUS_MAP } from '@/domain/iMiniUser.ts';
import type IStore from '@/domain/iStore.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import { bindMiniUser, toggleMiniUserStatus } from '@/api/customer/miniUser.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
interface BindFormValues {
type: number;
store_id?: number;
supplier_id?: number;
store_id: number;
}
/**
@@ -40,7 +35,6 @@ interface BindFormValues {
const MiniUserPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IMiniUser>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 绑定弹窗
const [bindOpen, setBindOpen] = useState(false);
@@ -50,15 +44,12 @@ const MiniUserPage: React.FC = () => {
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const openBind = (record: IMiniUser) => {
setBindTarget(record);
bindForm.setFieldsValue({
type: record.type && record.type > 0 ? record.type : undefined,
store_id: record.store_id || undefined,
supplier_id: record.supplier_id || undefined,
});
setBindOpen(true);
};
@@ -70,9 +61,7 @@ const MiniUserPage: React.FC = () => {
setBindLoading(true);
try {
await bindMiniUser(bindTarget.id, {
type: values.type,
store_id: values.type === 1 ? values.store_id : undefined,
supplier_id: values.type === 2 ? values.supplier_id : undefined,
store_id: values.store_id,
});
message.success('绑定成功');
setBindOpen(false);
@@ -118,36 +107,11 @@ const MiniUserPage: React.FC = () => {
render: (_, record) => record.phone || <Text type="secondary"></Text>,
},
{
title: '用户类型',
dataIndex: 'type',
valueType: 'select',
fieldProps: {
options: [
{ value: 0, label: '待绑定' },
{ value: 1, label: '门店' },
{ value: 2, label: '供应商' },
],
},
render: (_, record) => {
const item = MINI_USER_TYPE_MAP[record.type ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '绑定主体',
dataIndex: 'bound_name',
title: '绑定门店',
dataIndex: 'store_id',
hideInSearch: true,
render: (_, record) => {
if (record.type === 1) {
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
}
if (record.type === 2) {
return (
<Tag color="purple">{record.supplier?.name ?? `供应商#${record.supplier_id}`}</Tag>
);
}
return <Text type="secondary"></Text>;
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
},
},
{
@@ -173,6 +137,12 @@ const MiniUserPage: React.FC = () => {
align: 'center',
render: (_, record) => record.last_login_at ?? <Text type="secondary"></Text>,
},
{
title: '注册时间',
dataIndex: 'created_at',
hideInSearch: true,
align: 'center',
},
];
const operateRender: XinTableProps<IMiniUser>['operateRender'] = (record) => [
@@ -210,7 +180,7 @@ const MiniUserPage: React.FC = () => {
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
/
</Text>
</div>
<XinTable<IMiniUser> {...tableProps} />
@@ -231,60 +201,21 @@ const MiniUserPage: React.FC = () => {
className="mt-4"
>
<Form.Item
label="用户类型"
name="type"
rules={[{ required: true, message: '请选择用户类型' }]}
label="绑定门店"
name="store_id"
rules={[{ required: true, message: '请选择门店' }]}
>
<Radio.Group
options={[
{ label: '门店', value: 1 },
{ label: '供应商', value: 2 },
]}
optionType="button"
buttonStyle="solid"
<Select
showSearch={{
optionFilterProp: 'label'
}}
placeholder="选择门店"
options={stores.map((s) => ({
label: `${s.name}${s.code}`,
value: s.id,
}))}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 1) {
return (
<Form.Item
label="绑定门店"
name="store_id"
rules={[{ required: true, message: '请选择门店' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择门店"
options={stores.map((s) => ({
label: `${s.name}${s.code}`,
value: s.id,
}))}
/>
</Form.Item>
);
}
if (type === 2) {
return (
<Form.Item
label="绑定供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择供应商"
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
</Form>
</Modal>
</>
+11 -10
View File
@@ -41,8 +41,7 @@ const StorePage: React.FC = () => {
title: '门店编码',
dataIndex: 'code',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入门店编码' }],
hideInForm: true
},
{
title: '客户等级',
@@ -70,6 +69,14 @@ const StorePage: React.FC = () => {
valueType: 'text',
hideInSearch: true,
},
{
title: '门店地址',
dataIndex: 'address',
valueType: 'textarea',
hideInSearch: true,
fieldProps: { rows: 1 },
colProps: { span: 24 }
},
{
title: '回款周期(天)',
dataIndex: 'payment_cycle_days',
@@ -95,14 +102,6 @@ const StorePage: React.FC = () => {
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '门店地址',
dataIndex: 'address',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
{
title: '备注',
dataIndex: 'remark',
@@ -110,6 +109,7 @@ const StorePage: React.FC = () => {
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
colProps: { span: 24 }
},
];
@@ -121,6 +121,7 @@ const StorePage: React.FC = () => {
formProps: {
grid: true,
colProps: { span: 12 },
rowProps: { gutter: 20 },
layout: 'vertical',
},
modalProps: { width: 720 },
+613
View File
@@ -0,0 +1,613 @@
# 订货采购系统 · 小程序端 API 文档
> 版本:V1.1 更新日期:2026-08-07
> 适用:微信小程序门店端 / 供应商端;接口由后端 `app/Http/Controllers/Mini/` 提供(Laravel 12 + Sanctum)。
## 1. 通用说明
### 1.1 基础信息
| 项目 | 说明 |
|------|------|
| Base URL | `http://localhost:8000`(生产域名待定,通常为 HTTPS) |
| 数据格式 | JSON(请求/响应均 `Content-Type: application/json` |
| 金额字段 | 后端统一 `decimal` 字符串返回(如 `"13.00"`),下单/购物车金额**一律服务端重算**,前端传的金额字段会被忽略 |
### 1.2 认证方式
除「登录」接口外,全部接口需携带 `Authorization: Bearer <token>`(登录接口返回的 tokenSanctum plainTextToken)。
```http
Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
token 附带 `abilities: ["mini"]` 仅作来源标记;后端按 `users` guard 解析用户。
### 1.3 统一响应格式
成功:
```json
{ "success": true, "data": { ... } }
```
成功带提示:
```json
{ "success": true, "data": { ... }, "msg": "下单成功" }
```
失败(业务错误/验证错误均返回 HTTP 200,`success=false`):
```json
{ "success": false, "msg": "尚未绑定门店,请联系客服处理", "showType": 1 }
```
分页数据统一结构(`data` 字段内):
```json
{
"success": true,
"data": {
"data": [ ... ],
"total": 35,
"pageSize": 10,
"current": 1
}
}
```
### 1.4 角色前置校验
| 接口域 | 前置要求 | 未满足时提示 |
|--------|----------|--------------|
| 商品浏览(分类树/列表) | 仅需登录 | 无(未绑定门店也可浏览,`price` 为 null |
| 购物车/订单/对账单/门店设置 | 用户 `type=门店(1)` 且已绑定正常门店 | 「尚未绑定门店,请联系客服处理」 |
| 供应商采购单 | 用户 `type=供应商(2)` 且已绑定正常供应商 | 「尚未绑定供应商,请联系客服处理」 |
| 商品价格展示 | 门店已设置客户等级(`store.level_id > 0` | 未满足时 `price` 为 null,不影响浏览商品 |
| 加购/下单计价 | 门店已设置客户等级 | 「门店未设置客户等级,无法展示价格,请联系客服」 |
> 登录后未绑定身份的用户 `type=0`(待绑定):**可浏览商品分类与列表(价格不可见)**,但加购、下单等购买行为仍要求绑定门店;注册流程按门店编码(storeCode)直接绑定门店,或由后台人工绑定。
### 1.5 价格体系
- 商品价格按「门店客户等级」展示,同一商品不同等级价格不同
- 等级价格支持两种计价类型:
- **固定价**`price_type=0`):直接存储实际单价
- **成本百分比**`price_type=1`):实际价 = 成本价 × (100 + 上浮百分点) / 100
- 小程序端接口返回的 `price` 均为**换算后的实际价**;成本价为商业敏感数据,**不会**下发到小程序端
---
## 2. 认证
> 登录/注册均以 `wx.login()` 的 code 换取 openid 鉴权;注册流程同步绑定门店(storeCode)与手机号(phoneCode)。
### 2.1 微信登录
`POST /mini/auth/login`
`wx.login()` 获取的 code 换 openid。**仅已注册用户可登录**,未注册用户须先走 2.2 注册。
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | 是 | `wx.login` 的临时凭证 |
响应(`data`):
| 字段 | 类型 | 说明 |
|------|------|------|
| token | string | Bearer 令牌(后续请求头携带) |
| user | object | 用户信息(字段见下表) |
`user` 字段(user 表实际返回字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 用户ID |
| username | string | 用户名(注册时生成 `wx_xxxx` |
| nickname | string | 昵称(注册默认「微信用户」) |
| avatar | string | 头像 |
| phone | string | 手机号(未绑定为空) |
| store_id | int | 绑定门店ID0 未绑定) |
| status | int | 1 正常 / 0 停用 |
| openid / unionid / email | string | 微信标识 / 邮箱 |
| last_login_at / created_at / updated_at | string\|null | 时间 |
响应示例:
```json
{
"success": true,
"data": {
"token": "1|abc...",
"user": {
"id": 5, "username": "wx_66f1a2b3c4d5", "nickname": "微信用户", "avatar": "",
"phone": "", "store_id": 2, "status": 1,
"openid": "oXXXX", "unionid": "", "email": "",
"last_login_at": null, "created_at": "...", "updated_at": "..."
}
},
"msg": "登录成功"
}
```
错误:
- `code` 缺失 → 「登录参数格式错误」
- openid 未注册 → 「用户不存在!」
- code 无效 / 微信未配置 → 「微信登录失败:...」
> 注意:登录接口**不校验用户状态**,被停用用户仍可登录(其余接口由 `currentUser()` 前置校验拦截)。
### 2.2 微信注册
`POST /mini/auth/register`
新用户注册流程:code 换 openid → 校验未注册 → 按 storeCode 查门店 → phoneCode 换手机号 → 创建用户(绑定该门店)并返回 token。
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | 是 | `wx.login` 的临时凭证 |
| phoneCode | string | 是 | `wx.getPhoneNumber` 授权得到的 code |
| storeCode | string | 是 | 门店编码(后台门店管理维护) |
响应(`data`):`token` + `user`,结构同 2.1`msg` 为「登录成功」。
错误:
- `code`/`phoneCode` 缺失 → 「注册参数格式错误」;`storeCode` 缺失 → 「门店编码必须填写」
- 该微信已注册 → 「你的微信已经注册,请直接登录!」
- 门店编码不存在 → 「门店不存在!」
- 手机号换取失败 → 「获取手机号失败:...」(会中断注册流程)
> ⚠️ 现状说明:换取到的手机号写入 `mobile` 字段,但 user 表列为 `phone` 且不在模型 `$fillable` 白名单,**实际不会保存**(注册返回的 `user.phone` 为空)。注册即绑定门店(`store_id`),不存在「待绑定」用户状态。
### 2.3 当前用户信息
`GET /mini/auth/info`(需登录)
响应(`data`):`user` 对象(字段同 2.1)+ 关联门店(**仅本接口**加载 `store`,登录/注册响应不含):
| 字段 | 类型 | 说明 |
|------|------|------|
| user.store | object\|null | 绑定门店(未绑定为 null) |
`user.store` 字段(store 表):
| 字段 | 类型 | 说明 |
|------|------|------|
| id / name / code | - | 门店ID / 名称 / 编码 |
| level_id | int | 客户等级ID0 未设置) |
| level | object\|null | 客户等级 `{ id, name }`(等级价展示依据) |
| contact / phone / address | string | 联系人 / 电话 / 地址 |
| payment_cycle_days | int | 回款周期天数 |
| status | int | 1 正常 / 0 停用 |
错误:账号不存在(如已被删除)→ 「账号不存在」。
### 2.4 更新用户信息
`PUT miniauth/info`(需登录)
⚠️ **路由注意**:代码中 `#[PutRoute('auth/info')]` 缺少前导 `/`,实际注册路由为 `miniauth/info`(未正确拼接为 `/mini/auth/info`)。
请求参数(`UserUpdateInfoRequest` 校验,全部必填):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名 4-20 位 |
| nickname | string | 是 | 昵称 4-20 位 |
| gender | string | 是 | 性别 |
| email | string | 是 | 邮箱格式 |
| avatar_id | int | 是 | 头像ID |
| mobile | string | 是 | 手机号(`1[34578]` 开头 11 位) |
> ⚠️ 现状说明:该接口当前实现存在多处问题,实际调用**无法正常使用**——
> 1. 更新条件 `where('user_id', ...)`user 表不存在 `user_id` 列(主键为 `id`);
> 2. `auth('user')` guard 未定义(`config/auth.php` 仅有 `sys_users` / `users`),会抛「Auth guard [user] is not defined」;
> 3. 更新成功返回 `success=false` 与提示「更新成功」(使用了 `$this->error()`)。
> 建议后端修复后再联调。
---
## 3. 商品
### 3.1 商品分类树
`GET /mini/product/categories`(需登录)
返回分类树,**仅包含有上架商品的分类及其全部祖先**(保证树结构完整)。未绑定门店也可调用。
响应(`data`):分类树数组,节点字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 分类ID |
| parent_id | int | 父级分类ID0 为顶级) |
| name | string | 分类名称 |
| children | array | 子分类(递归) |
### 3.2 商品列表
`GET /mini/product/list?category_id=&keyword=&page=&pageSize=`(需登录)
请求参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| category_id | int | 否 | - | 分类ID过滤 |
| keyword | string | 否 | - | 搜索品名/规格(模糊) |
| page | int | 否 | 1 | 页码 |
| pageSize | int | 否 | 10 | 每页条数 |
响应(`data` 为分页结构),每项字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 商品ID |
| category_id | int | 分类ID |
| supplier_id | int | 默认供应商ID |
| name | string | 品名 |
| spec | string | 规格/包规 |
| unit | string | 计价单位 |
| content | string | 商品图文详情(HTML |
| **price** | string\|null | **当前门店等级的实际销售价**(未绑定门店或门店未设等级价为 null,此时仅可浏览不可购买) |
| images_arr | array | 商品图片数组(`{id, file_url, ...}` |
| sort / shelf_life / stock / status | - | 排序 / 保质期 / 库存 / 状态(仅返回上架商品) |
> 注意:只返回上架商品;`cost_price`、计价类型、上浮百分点等成本信息不会下发。
> 未绑定门店(`type=0`)或门店未设客户等级时,列表正常返回、`price` 为 null——价格不可见,加购/下单仍由前置校验拦截。
---
## 4. 购物车
> 购物车为下单前的编辑容器,同商品重复加购自动合并数量;提交订单复用「5.1 下单」接口。
### 4.1 加购
`POST /mini/cart`(需登录 + 门店 + 客户等级)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| product_id | int | 是 | 商品ID(须上架且已设本等级价格) |
| quantity | number | 是 | 数量(>0,最多 99999999.99 |
响应(`data`):
```json
{ "id": 12, "quantity": "2.50" }
```
提示:`已加入购物车`。错误:商品未设本等级价格 → 「商品「xx」未设置您所在等级的价格,无法加购」;超上限 → 「该商品在购物车中的数量已达上限」。
### 4.2 购物车列表
`GET /mini/cart`(需登录 + 门店)
响应(`data`):
| 字段 | 类型 | 说明 |
|------|------|------|
| items | array | 购物车项(倒序) |
| total_count | int | 总项数 |
| total_quantity | string | 可购项总数量 |
| total_amount | string | 可购项总金额 |
items 每项:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 购物车项ID |
| product_id | int | 商品ID |
| name / spec / unit | string | 商品快照 |
| image | string | 商品首图 URL |
| **price** | string\|null | **当前等级实际价**(商品下架或未设等级价为 null) |
| quantity | string | 数量 |
| amount | string\|null | 金额 = price × quantity(不可购为 null |
| status | int | 1 可购 / 0 商品下架、缺失或未设等级价 |
### 4.3 修改数量
`PUT /mini/cart/{id}`(需登录)
请求参数:`quantity`number,必填,>0)。
响应:`{ id, quantity }`,提示「已修改数量」。
### 4.4 删除单项
`DELETE /mini/cart/{id}`(需登录)
响应:`success=true`,提示「已删除」;不存在 → 「购物车项不存在」。
### 4.5 清空购物车
`DELETE /mini/cart`(需登录)
仅清空当前用户;响应:`success=true`,提示「购物车已清空」。
---
## 5. 门店订单
### 5.1 下单
`POST /mini/order`(需登录 + 门店 + 客户等级)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| items | array | 是 | 订单明细(至少 1 行) |
| items[].product_id | int | 是 | 商品ID(须上架) |
| items[].quantity | number | 是 | 数量(>0 |
| remark | string | 否 | 订单备注(≤255 字符) |
> 金额不接受前端传入:服务端按商品当前等级**实际价**逐行快照并重算 `amount` 与 `total_amount`。
响应(`data`):
```json
{ "id": 23, "order_no": "SO202608060001", "total_amount": "39.00" }
```
提示:「下单成功」。错误示例:存在已下架商品 → 「存在已下架或不存在的商品,请刷新后重试」;未设等级价 → 「商品「xx」未设置您所在等级的价格,无法下单」。
### 5.2 历史订单
`GET /mini/order?status=&page=&pageSize=`(需登录 + 门店,强制本店隔离)
请求参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| status | int | 否 | - | 0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 |
| page / pageSize | - | 否 | 1 / 10 | 分页 |
响应(`data` 分页结构),订单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 订单ID |
| order_no | string | 单号(SO + 日期 + 序列) |
| order_date | string | 订货日期(Y-m-d |
| total_quantity / total_amount | string | 总数量 / 总金额 |
| status | int | 状态(见上) |
| remark | string | 备注 |
### 5.3 周期汇总
`GET /mini/order/summary?period=day|week|month`(需登录 + 门店)
请求参数:`period`day/week/month,默认 month)。
响应(`data`):
```json
{
"period": "month",
"groups": [
{ "period_label": "2026-07", "total_amount": "1280.50", "total_quantity": "86.00", "order_count": 12 }
]
}
```
> `period_label` 格式:day=`Y-m-d`、week=`Y-W+周数`、month=`Y-m`;不含已取消订单;最多返回 50 组。
### 5.4 订单详情
`GET /mini/order/{id}`(需登录 + 门店,校验本店归属)
响应(`data`):订单对象 + `items` 数组(明细字段见下表)。
明细字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id / order_id | int | 明细ID / 订单ID |
| product_id / product_name / product_spec | - | 商品快照 |
| price | string | 下单时等级实际价快照 |
| quantity / weight | string | 数量 / 称重(默认 0 |
| amount | string | 金额 = price × quantity |
| remark | string | 行备注 |
### 5.5 取消订单
`PUT /mini/order/{id}/cancel`(需登录 + 门店)
仅「待汇总(0)」可取消;响应提示「订单已取消」;非待汇总 → 「仅待汇总的订单可以取消」。
---
## 6. 对账单(门店自助)
### 6.1 对账单列表
`GET /mini/statement?page=&pageSize=`(需登录 + 门店,仅本店)
响应(`data` 分页结构),对账单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 对账单ID |
| statement_no | string | 单号(ST + 日期 + 序列) |
| period_start / period_end | string | 对账周期 |
| total_amount | string | 总金额 |
| payment_cycle_days | int | 生成时快照的回款周期 |
| settlement_date | string\|null | 应结算日期 = 周期结束 + 回款周期天 |
| status | int | 0 待对账 / 1 已对账 / 2 已结算 |
| reconciled_at / settled_at | string\|null | 对账 / 结算时间 |
| remark | string | 备注 |
### 6.2 生成对账单
`POST /mini/statement/generate`(需登录 + 门店)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| period_start | string | 是 | 周期开始(Y-m-d |
| period_end | string | 是 | 周期结束(Y-m-d,不早于开始) |
响应(`data`):`{ id, statement_no, total_amount, settlement_date }`,提示「对账单已生成」。
> 快照当前回款周期计算结算日期。业务约束:周期内本店无订单 → 「周期内本店无订单数据,无法生成对账单」;周期内订单均已生成过对账单 → 「周期内的订单明细均已生成过对账单」。
### 6.3 对账单详情
`GET /mini/statement/{id}`(需登录 + 门店,校验归属)
响应(`data`):对账单对象 + `items` 数组,明细字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| order_id / order_item_id | int | 源订单 / 源明细ID |
| product_id / product_name | - | 商品快照 |
| price | string | 单价 |
| quantity / weight / amount | string | 数量 / 称重 / 金额 |
| is_reconciled | int | 0 未对账 / 1 已对账 |
| store_remark | string | 门店备注 |
### 6.4 导出对账单
`GET /mini/statement/{id}/export?format=xlsx|pdf`(需登录 + 门店,校验归属)
- `format` 默认 `xlsx`(支持 `xlsx` / `pdf`
- 返回文件流(附件下载,含中文文件名),非 JSON
---
## 7. 门店设置
### 7.1 修改回款周期
`PUT /mini/store/paymentCycle`(需登录 + 门店)
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| payment_cycle_days | int | 是 | 回款周期天数(≥0,无上限;0 = 当天结算) |
响应(`data`):`{ "payment_cycle_days": 1 }`,提示「回款周期已更新」。
> 该值影响后续生成对账单的 `settlement_date`(周期结束 + 回款周期天)。
---
## 8. 通知
### 8.1 通知列表
`GET /mini/notice?page=&pageSize=`(需登录)
返回本人通知 + 全员广播(本人已读的广播自动隐藏)。响应(`data` 分页结构 + 附加字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| unread_count | int | 未读总数 |
| 分页内字段 | - | 标准分页结构 |
通知字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 通知ID |
| type | string | `order` 订单 / `price` 价格变更 / `system` 系统 |
| title / content | string | 标题 / 内容 |
| data | object | 附加数据(价格变更通知含 `product_ids``level_ids` |
| is_read | int | 0 未读 / 1 已读 |
| read_at | string\|null | 已读时间 |
### 8.2 标记已读
`PUT /mini/notice/{id}/read`(需登录)
- 个人通知:直接标记已读
- 全员广播:复制一条本人专属已读记录(原广播对他人仍为未读)
响应:`success=true`;通知不存在 → 「通知不存在」。
---
## 9. 供应商端
### 9.1 收到的采购单
`GET /mini/supplier/purchases?page=&pageSize=`(需登录 + 供应商)
返回**含本供应商已发送明细**`is_sent=1`)的采购单(去重,按日期倒序)。
响应(`data` 分页结构),采购单字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 采购单ID |
| purchase_no | string | 单号(PO + 日期 + 序列) |
| purchase_date | string | 采购日期 |
| status | int | 0 待发送 / 1 部分发送 / 2 全部发送 / 3 已完成 |
| total_quantity / estimate_amount / actual_amount | string | 总数量 / 估算金额 / 实际金额 |
| remark | string | 备注 |
### 9.2 采购单明细
`GET /mini/supplier/purchases/{id}`(需登录 + 供应商)
仅返回**本供应商且已发送**的明细行。响应(`data`):
```json
{
"id": 3, "purchase_no": "PO202608060001", "purchase_date": "2026-08-06", "remark": "",
"items": [
{ "id": 11, "product_id": 2, "product_name": "大白菜", "product_spec": "10斤/箱",
"price": "4.00", "quantity": "10.00", "weight": "0.000", "amount": "40.00",
"sort": 1, "is_sent": 1, "sent_at": "...", "supplier_confirmed_at": null }
]
}
```
无本供应商明细 → 「该采购单无贵司的采购明细」。
### 9.3 确认接单
`PUT /mini/supplier/purchases/{id}/confirm`(需登录 + 供应商)
批量记录本供应商全部已发送明细的 `supplier_confirmed_at`(幂等,已确认的跳过)。
响应(`data`):`{ "confirmed": 2 }`(本次新确认条数),提示「已确认接单」。
---
## 10. 状态字典汇总
| 枚举 | 值 | 含义 |
|------|-----|------|
| 用户类型 user.type | 0 / 1 / 2 | 待绑定 / 门店 / 供应商 |
| 门店订单 status | 0 / 1 / 2 / 3 / 9 | 待汇总 / 已汇总 / 配送中 / 已完成 / 已取消 |
| 采购单 status | 0 / 1 / 2 / 3 | 待发送 / 部分发送 / 全部发送 / 已完成 |
| 采购明细 is_sent | 0 / 1 | 未发送 / 已发送 |
| 对账单 status | 0 / 1 / 2 | 待对账 / 已对账 / 已结算 |
| 对账明细 is_reconciled | 0 / 1 | 未对账 / 已对账 |
| 通知 type | order / price / system | 订单 / 价格变更 / 系统 |
| 通知 is_read | 0 / 1 | 未读 / 已读 |
| 商品 status | 0 / 1 | 下架 / 上架 |
| 门店/供应商 status | 0 / 1 | 停用 / 正常 |
## 11. 常见错误提示
| 提示语 | 触发场景 |
|--------|----------|
| 尚未绑定门店,请联系客服处理 | 购物车/订单/对账单等接口但用户未绑定门店(商品浏览不受限) |
| 尚未绑定供应商,请联系客服处理 | 供应商端接口但用户未绑定供应商 |
| 门店未设置客户等级,无法展示价格,请联系客服 | 门店 `level_id=0`(购物车/下单)——商品列表不报错,返回 `price=null` |
| 商品「xx」未设置您所在等级的价格,无法下单 | 下单商品缺本等级价格 |
| 存在已下架或不存在的商品,请刷新后重试 | 下单商品已下架 |
| 账号不存在或已被停用 | token 用户被停用 |
| 账号已被停用,请联系客服 | 登录时账号被停用 |