小程序登录优化
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: users(provider 指向 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_id,type=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' => '回款周期必须为整数',
|
||||
|
||||
@@ -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
@@ -35,7 +35,8 @@
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"modules/Common/helpers.php"
|
||||
"modules/Common/helpers.php",
|
||||
"app/Helpers/functions.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
@@ -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('关联门店ID(type=1时有效)');
|
||||
$table->integer('supplier_id')->default(0)->comment('关联供应商ID(type=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');
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,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`,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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>`(登录接口返回的 token,Sanctum 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 | 绑定门店ID(0 未绑定) |
|
||||
| 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 | 客户等级ID(0 未设置) |
|
||||
| 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 | 父级分类ID(0 为顶级) |
|
||||
| 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 用户被停用 |
|
||||
| 账号已被停用,请联系客服 | 登录时账号被停用 |
|
||||
Reference in New Issue
Block a user