first commit
This commit is contained in:
24
app/common/exception/BusinessException.php
Normal file
24
app/common/exception/BusinessException.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace app\common\exception;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Exception\ExceptionHandler;
|
||||
|
||||
class BusinessException extends Exception
|
||||
{
|
||||
protected $data;
|
||||
|
||||
public function __construct(string $message = "", int $code = 400, $data = null, Throwable $previous = null)
|
||||
{
|
||||
$this->data = $data;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getData()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
15
app/common/model/AdminToken.php
Normal file
15
app/common/model/AdminToken.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AdminToken extends Model
|
||||
{
|
||||
protected $table = 'admin_tokens';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['token_hash', 'updated_at'];
|
||||
protected $casts = [
|
||||
'expired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
17
app/common/model/AdminUser.php
Normal file
17
app/common/model/AdminUser.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AdminUser extends Model
|
||||
{
|
||||
protected $table = 'admin_users';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['password_hash', 'updated_at'];
|
||||
|
||||
public function roles()
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'admin_roles', 'admin_id', 'role_id');
|
||||
}
|
||||
}
|
||||
|
||||
20
app/common/model/Order.php
Normal file
20
app/common/model/Order.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
protected $table = 'orders';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
// 隐藏时间戳等不必要字段,保持前端接口整洁
|
||||
protected $hidden = ['updated_at'];
|
||||
|
||||
// 关联订单流转日志 (时间轴)
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(OrderLog::class, 'order_id', 'id')->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
11
app/common/model/OrderLog.php
Normal file
11
app/common/model/OrderLog.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrderLog extends Model
|
||||
{
|
||||
protected $table = 'order_logs';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/common/model/PaymentTransaction.php
Normal file
15
app/common/model/PaymentTransaction.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentTransaction extends Model
|
||||
{
|
||||
protected $table = 'payment_transactions';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at'];
|
||||
|
||||
protected $casts = [
|
||||
'raw_json' => 'array',
|
||||
];
|
||||
}
|
||||
12
app/common/model/Permission.php
Normal file
12
app/common/model/Permission.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
protected $table = 'permissions';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at'];
|
||||
}
|
||||
|
||||
26
app/common/model/Report.php
Normal file
26
app/common/model/Report.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Report extends Model
|
||||
{
|
||||
protected $table = 'reports';
|
||||
protected $guarded = [];
|
||||
|
||||
// Cast JSON fields automatically
|
||||
protected $casts = [
|
||||
'flaws_json' => 'array',
|
||||
'images_json' => 'array',
|
||||
];
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class, 'order_id');
|
||||
}
|
||||
|
||||
public function inspector()
|
||||
{
|
||||
return $this->belongsTo(AdminUser::class, 'inspector_id');
|
||||
}
|
||||
}
|
||||
17
app/common/model/Role.php
Normal file
17
app/common/model/Role.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
protected $table = 'roles';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at'];
|
||||
|
||||
public function permissions()
|
||||
{
|
||||
return $this->belongsToMany(Permission::class, 'role_permissions', 'role_id', 'permission_id');
|
||||
}
|
||||
}
|
||||
|
||||
12
app/common/model/User.php
Normal file
12
app/common/model/User.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
protected $table = 'users';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at'];
|
||||
}
|
||||
|
||||
15
app/common/model/UserToken.php
Normal file
15
app/common/model/UserToken.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserToken extends Model
|
||||
{
|
||||
protected $table = 'user_tokens';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['token_hash', 'updated_at'];
|
||||
protected $casts = [
|
||||
'expired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
12
app/common/model/UserWechatIdentity.php
Normal file
12
app/common/model/UserWechatIdentity.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserWechatIdentity extends Model
|
||||
{
|
||||
protected $table = 'user_wechat_identities';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at'];
|
||||
}
|
||||
|
||||
12
app/common/model/WechatApp.php
Normal file
12
app/common/model/WechatApp.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WechatApp extends Model
|
||||
{
|
||||
protected $table = 'wechat_apps';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at', 'app_secret'];
|
||||
}
|
||||
|
||||
11
app/common/model/WechatMerchant.php
Normal file
11
app/common/model/WechatMerchant.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WechatMerchant extends Model
|
||||
{
|
||||
protected $table = 'wechat_merchants';
|
||||
protected $guarded = [];
|
||||
protected $hidden = ['updated_at', 'api_v3_key', 'private_key_pem', 'apiclient_cert_path', 'apiclient_key_path'];
|
||||
}
|
||||
90
app/common/service/AuthService.php
Normal file
90
app/common/service/AuthService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserToken;
|
||||
use app\common\model\AdminUser;
|
||||
use app\common\model\AdminToken;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
public static function issueUserToken(User $user): string
|
||||
{
|
||||
$ttl = intval(getenv('USER_TOKEN_TTL') ?: 604800);
|
||||
$token = generateToken();
|
||||
$hash = hashToken($token);
|
||||
|
||||
UserToken::create([
|
||||
'user_id' => $user->id,
|
||||
'token_hash' => $hash,
|
||||
'expired_at' => $ttl > 0 ? Carbon::now()->addSeconds($ttl) : null,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function getUserByToken(?string $token): ?User
|
||||
{
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
$hash = hashToken($token);
|
||||
$row = UserToken::where('token_hash', $hash)->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if ($row->expired_at && $row->expired_at->lt(Carbon::now())) {
|
||||
return null;
|
||||
}
|
||||
return User::find($row->user_id);
|
||||
}
|
||||
|
||||
public static function revokeUserToken(?string $token): void
|
||||
{
|
||||
if (!$token) {
|
||||
return;
|
||||
}
|
||||
UserToken::where('token_hash', hashToken($token))->delete();
|
||||
}
|
||||
|
||||
public static function issueAdminToken(AdminUser $admin): string
|
||||
{
|
||||
$ttl = intval(getenv('ADMIN_TOKEN_TTL') ?: 86400);
|
||||
$token = generateToken();
|
||||
$hash = hashToken($token);
|
||||
|
||||
AdminToken::create([
|
||||
'admin_id' => $admin->id,
|
||||
'token_hash' => $hash,
|
||||
'expired_at' => $ttl > 0 ? Carbon::now()->addSeconds($ttl) : null,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function getAdminByToken(?string $token): ?AdminUser
|
||||
{
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
$hash = hashToken($token);
|
||||
$row = AdminToken::where('token_hash', $hash)->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if ($row->expired_at && $row->expired_at->lt(Carbon::now())) {
|
||||
return null;
|
||||
}
|
||||
return AdminUser::find($row->admin_id);
|
||||
}
|
||||
|
||||
public static function revokeAdminToken(?string $token): void
|
||||
{
|
||||
if (!$token) {
|
||||
return;
|
||||
}
|
||||
AdminToken::where('token_hash', hashToken($token))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
126
app/common/service/OrderFlowService.php
Normal file
126
app/common/service/OrderFlowService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\Order;
|
||||
use app\common\model\OrderLog;
|
||||
use Exception;
|
||||
|
||||
class OrderFlowService
|
||||
{
|
||||
/**
|
||||
* 模拟创建订单事务
|
||||
*/
|
||||
public static function createOrder(array $params, int $userId)
|
||||
{
|
||||
$orderNo = 'AXY' . date('YmdHis') . rand(100, 999);
|
||||
|
||||
// 1. 创建主订单
|
||||
$order = Order::create([
|
||||
'order_no' => $orderNo,
|
||||
'user_id' => $userId,
|
||||
'category' => $params['category'] ?? '奢品包袋',
|
||||
'service_type' => $params['service_type'] ?? '真伪鉴定',
|
||||
'brand' => $params['brand'] ?? '未知品牌',
|
||||
'model' => $params['model'] ?? '',
|
||||
'remark' => $params['remark'] ?? '',
|
||||
'is_fast' => $params['is_fast'] ?? 0,
|
||||
'total_price' => $params['total_price'] ?? 49.00,
|
||||
'status' => 'wait_pay',
|
||||
]);
|
||||
|
||||
// 2. 写入初始流转日志
|
||||
self::addLog($order->id, 'create', '订单已创建', '等待用户支付', 'user', $userId);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟支付成功
|
||||
*/
|
||||
public static function payOrder(Order $order)
|
||||
{
|
||||
if ($order->status !== 'wait_pay') {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$order->status = 'shipping'; // 待寄送
|
||||
$order->pay_time = date('Y-m-d H:i:s');
|
||||
$order->pay_status = 'paid';
|
||||
$order->save();
|
||||
|
||||
self::addLog($order->id, 'pay_success', '支付成功', '请尽快寄出物品', 'user', $order->user_id);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户填写物流并发货
|
||||
*/
|
||||
public static function userShip(Order $order, string $expressCompany, string $expressNo)
|
||||
{
|
||||
if ($order->status !== 'shipping') {
|
||||
throw new Exception("当前状态不允许发货");
|
||||
}
|
||||
|
||||
$order->express_company = $expressCompany;
|
||||
$order->express_no = $expressNo;
|
||||
$order->status = 'wait_receive'; // 等待平台收件(可复用为在途)
|
||||
$order->save();
|
||||
|
||||
self::addLog($order->id, 'user_ship', '物品已寄出', "物流公司: {$expressCompany}, 单号: {$expressNo}", 'user', $order->user_id);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台确认收件并开始鉴定
|
||||
*/
|
||||
public static function adminReceive(Order $order, int $adminId)
|
||||
{
|
||||
if ($order->status !== 'wait_receive' && $order->status !== 'shipping') {
|
||||
throw new Exception("当前状态不允许收件");
|
||||
}
|
||||
|
||||
$order->status = 'inspecting';
|
||||
$order->save();
|
||||
|
||||
self::addLog($order->id, 'admin_receive', '平台已收件', '物品已入库,即将开始鉴定', 'admin', $adminId);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
public static function adminReturnShip(Order $order, int $adminId, string $expressCompany, string $expressNo)
|
||||
{
|
||||
if ($order->status !== 'finished') {
|
||||
throw new Exception("当前状态不允许回寄");
|
||||
}
|
||||
if ($expressCompany === '' || $expressNo === '') {
|
||||
throw new Exception("回寄物流信息不完整");
|
||||
}
|
||||
|
||||
$order->return_express_company = $expressCompany;
|
||||
$order->return_express_no = $expressNo;
|
||||
$order->return_ship_time = date('Y-m-d H:i:s');
|
||||
$order->status = 'return_shipping';
|
||||
$order->save();
|
||||
|
||||
self::addLog($order->id, 'return_ship', '已回寄', "物流公司: {$expressCompany}, 单号: {$expressNo}", 'admin', $adminId);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入时间轴日志
|
||||
*/
|
||||
public static function addLog(int $orderId, string $actionType, string $title, string $desc = '', string $operatorType = 'system', int $operatorId = 0)
|
||||
{
|
||||
return OrderLog::create([
|
||||
'order_id' => $orderId,
|
||||
'action_type' => $actionType,
|
||||
'title' => $title,
|
||||
'description' => $desc,
|
||||
'operator_type' => $operatorType,
|
||||
'operator_id' => $operatorId
|
||||
]);
|
||||
}
|
||||
}
|
||||
255
app/common/service/PaymentService.php
Normal file
255
app/common/service/PaymentService.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\Order;
|
||||
use app\common\model\PaymentTransaction;
|
||||
use app\common\model\WechatMerchant;
|
||||
use app\common\model\UserWechatIdentity;
|
||||
use Illuminate\Database\Capsule\Manager as DB;
|
||||
|
||||
class PaymentService
|
||||
{
|
||||
public static function createWechatJsapiPay(Order $order, string $appId, string $openid = ''): array
|
||||
{
|
||||
if ($order->status !== 'wait_pay') {
|
||||
throw new \RuntimeException('订单状态不正确');
|
||||
}
|
||||
|
||||
$merchant = self::selectWechatMerchantForJsapi($order, $appId);
|
||||
if (!$merchant) {
|
||||
throw new \RuntimeException('未配置可用的微信商户号');
|
||||
}
|
||||
|
||||
$notifyUrl = (string)($merchant->notify_url ?? '');
|
||||
if ($notifyUrl === '') {
|
||||
$notifyUrl = (string)(getenv('WECHATPAY_NOTIFY_URL') ?: '');
|
||||
}
|
||||
if ($notifyUrl === '') {
|
||||
throw new \RuntimeException('回调地址未配置');
|
||||
}
|
||||
|
||||
$outTradeNo = self::genOutTradeNo($order);
|
||||
$amountFen = (int)round(((float)$order->total_price) * 100);
|
||||
$description = '安心验-鉴定订单 ' . $order->order_no;
|
||||
|
||||
$openid = trim($openid);
|
||||
if ($openid === '') {
|
||||
$identity = UserWechatIdentity::where('user_id', $order->user_id)->where('app_id', $appId)->first();
|
||||
if ($identity) {
|
||||
$openid = (string)$identity->openid;
|
||||
}
|
||||
}
|
||||
if ($openid === '') {
|
||||
throw new \RuntimeException('缺少 openid,无法发起 JSAPI 支付');
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$tx = PaymentTransaction::create([
|
||||
'order_id' => $order->id,
|
||||
'channel' => 'wechat',
|
||||
'merchant_id' => $merchant->id,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'amount' => $order->total_price,
|
||||
'status' => 'created',
|
||||
]);
|
||||
|
||||
$order->pay_channel = 'wechat';
|
||||
$order->pay_status = 'paying';
|
||||
$order->pay_merchant_id = $merchant->id;
|
||||
$order->pay_out_trade_no = $outTradeNo;
|
||||
$order->save();
|
||||
|
||||
$client = new WechatPayV3Client($merchant);
|
||||
$resp = $client->createJsapiTransaction($outTradeNo, $description, $amountFen, $notifyUrl, $openid);
|
||||
|
||||
$tx->prepay_id = $resp['prepay_id'] ?? null;
|
||||
$tx->raw_json = $resp;
|
||||
$tx->save();
|
||||
|
||||
if (!$tx->prepay_id) {
|
||||
throw new \RuntimeException('微信支付下单失败:缺少 prepay_id');
|
||||
}
|
||||
|
||||
$payParams = $client->buildJsapiPayParams($appId, $tx->prepay_id);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return [
|
||||
'channel' => 'wechat',
|
||||
'pay_type' => 'jsapi',
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'merchant' => [
|
||||
'id' => $merchant->id,
|
||||
'name' => $merchant->name,
|
||||
'mode' => $merchant->mode,
|
||||
'mch_id' => $merchant->mch_id,
|
||||
],
|
||||
'pay_params' => $payParams,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function createWechatNativePay(Order $order): array
|
||||
{
|
||||
if ($order->status !== 'wait_pay') {
|
||||
throw new \RuntimeException('订单状态不正确');
|
||||
}
|
||||
|
||||
$merchant = self::selectWechatMerchant($order);
|
||||
if (!$merchant) {
|
||||
throw new \RuntimeException('未配置可用的微信商户号');
|
||||
}
|
||||
|
||||
$notifyUrl = (string)($merchant->notify_url ?? '');
|
||||
if ($notifyUrl === '') {
|
||||
$notifyUrl = (string)(getenv('WECHATPAY_NOTIFY_URL') ?: '');
|
||||
}
|
||||
if ($notifyUrl === '') {
|
||||
throw new \RuntimeException('回调地址未配置');
|
||||
}
|
||||
|
||||
$outTradeNo = self::genOutTradeNo($order);
|
||||
$amountFen = (int)round(((float)$order->total_price) * 100);
|
||||
$description = '安心验-鉴定订单 ' . $order->order_no;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$tx = PaymentTransaction::create([
|
||||
'order_id' => $order->id,
|
||||
'channel' => 'wechat',
|
||||
'merchant_id' => $merchant->id,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'amount' => $order->total_price,
|
||||
'status' => 'created',
|
||||
]);
|
||||
|
||||
$order->pay_channel = 'wechat';
|
||||
$order->pay_status = 'paying';
|
||||
$order->pay_merchant_id = $merchant->id;
|
||||
$order->pay_out_trade_no = $outTradeNo;
|
||||
$order->save();
|
||||
|
||||
$client = new WechatPayV3Client($merchant);
|
||||
$resp = $client->createNativeTransaction($outTradeNo, $description, $amountFen, $notifyUrl);
|
||||
|
||||
$tx->prepay_id = $resp['prepay_id'] ?? null;
|
||||
$tx->code_url = $resp['code_url'] ?? null;
|
||||
$tx->raw_json = $resp;
|
||||
$tx->save();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return [
|
||||
'channel' => 'wechat',
|
||||
'pay_type' => 'native',
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'code_url' => $tx->code_url,
|
||||
'merchant' => [
|
||||
'id' => $merchant->id,
|
||||
'name' => $merchant->name,
|
||||
'mode' => $merchant->mode,
|
||||
'mch_id' => $merchant->mch_id,
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function selectWechatMerchant(Order $order): ?WechatMerchant
|
||||
{
|
||||
$list = WechatMerchant::where('status', 1)
|
||||
->whereIn('mode', ['direct', 'service_provider'])
|
||||
->whereNotNull('serial_no')->where('serial_no', '<>', '')
|
||||
->whereNotNull('api_v3_key')->where('api_v3_key', '<>', '')
|
||||
->where(function ($q) {
|
||||
$q->where(function ($q2) {
|
||||
$q2->whereNotNull('private_key_pem')->where('private_key_pem', '<>', '');
|
||||
})->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('apiclient_key_path')->where('apiclient_key_path', '<>', '');
|
||||
});
|
||||
})
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
if ($list->count() === 0) return null;
|
||||
if ($list->count() === 1) return $list->first();
|
||||
|
||||
$default = $list->firstWhere('is_default', 1);
|
||||
if ($default) return $default;
|
||||
|
||||
$idx = abs(crc32((string)$order->order_no)) % $list->count();
|
||||
return $list->values()->get($idx);
|
||||
}
|
||||
|
||||
public static function selectWechatMerchantForJsapi(Order $order, string $appId): ?WechatMerchant
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '') {
|
||||
throw new \RuntimeException('缺少 app_id');
|
||||
}
|
||||
|
||||
$list = WechatMerchant::where('status', 1)
|
||||
->whereIn('mode', ['direct', 'service_provider'])
|
||||
->whereNotNull('serial_no')->where('serial_no', '<>', '')
|
||||
->whereNotNull('api_v3_key')->where('api_v3_key', '<>', '')
|
||||
->where(function ($q) {
|
||||
$q->where(function ($q2) {
|
||||
$q2->whereNotNull('private_key_pem')->where('private_key_pem', '<>', '');
|
||||
})->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('apiclient_key_path')->where('apiclient_key_path', '<>', '');
|
||||
});
|
||||
})
|
||||
->get()
|
||||
->filter(function ($m) use ($appId) {
|
||||
$mode = (string)($m->mode ?? '');
|
||||
if ($mode === 'direct') {
|
||||
return (string)($m->app_id ?? '') === $appId;
|
||||
}
|
||||
if ($mode === 'service_provider') {
|
||||
$subAppId = (string)($m->sub_app_id ?? '');
|
||||
if ($subAppId !== '') return $subAppId === $appId;
|
||||
return (string)($m->app_id ?? '') === $appId;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
->values();
|
||||
|
||||
if ($list->count() === 0) return null;
|
||||
if ($list->count() === 1) return $list->first();
|
||||
|
||||
$default = $list->firstWhere('is_default', 1);
|
||||
if ($default) return $default;
|
||||
|
||||
$idx = abs(crc32((string)$order->order_no)) % $list->count();
|
||||
return $list->get($idx);
|
||||
}
|
||||
|
||||
private static function genOutTradeNo(Order $order): string
|
||||
{
|
||||
return 'AXY' . date('YmdHis') . $order->id . random_int(1000, 9999);
|
||||
}
|
||||
|
||||
private static function resolveJsapiAppId(WechatMerchant $merchant): string
|
||||
{
|
||||
$mode = (string)($merchant->mode ?? 'direct');
|
||||
$appId = (string)($merchant->app_id ?? '');
|
||||
$subAppId = (string)($merchant->sub_app_id ?? '');
|
||||
|
||||
if ($mode === 'direct') {
|
||||
if ($appId === '') throw new \RuntimeException('商户 AppID 未配置');
|
||||
return $appId;
|
||||
}
|
||||
if ($mode === 'service_provider') {
|
||||
if ($subAppId !== '') return $subAppId;
|
||||
if ($appId === '') throw new \RuntimeException('服务商 AppID 未配置');
|
||||
return $appId;
|
||||
}
|
||||
throw new \RuntimeException('该商户类型暂不支持 JSAPI');
|
||||
}
|
||||
}
|
||||
276
app/common/service/WechatPayV3Client.php
Normal file
276
app/common/service/WechatPayV3Client.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\WechatMerchant;
|
||||
|
||||
class WechatPayV3Client
|
||||
{
|
||||
private WechatMerchant $merchant;
|
||||
|
||||
public function __construct(WechatMerchant $merchant)
|
||||
{
|
||||
$this->merchant = $merchant;
|
||||
}
|
||||
|
||||
public function createNativeTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array
|
||||
{
|
||||
$body = $this->buildPayBody($outTradeNo, $description, $amountFen, $notifyUrl);
|
||||
$resp = $this->request('POST', '/v3/pay/transactions/native', $body);
|
||||
|
||||
$data = json_decode($resp['body'], true) ?: [];
|
||||
if ($resp['status'] >= 200 && $resp['status'] < 300) {
|
||||
return $data;
|
||||
}
|
||||
$message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']);
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
public function createJsapiTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array
|
||||
{
|
||||
$body = $this->buildJsapiPayBody($outTradeNo, $description, $amountFen, $notifyUrl, $openid);
|
||||
$resp = $this->request('POST', '/v3/pay/transactions/jsapi', $body);
|
||||
|
||||
$data = json_decode($resp['body'], true) ?: [];
|
||||
if ($resp['status'] >= 200 && $resp['status'] < 300) {
|
||||
return $data;
|
||||
}
|
||||
$message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']);
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
public function buildJsapiPayParams(string $appId, string $prepayId): array
|
||||
{
|
||||
$mchId = (string)$this->merchant->mch_id;
|
||||
$serialNo = (string)($this->merchant->serial_no ?? '');
|
||||
$privateKeyPem = (string)($this->merchant->private_key_pem ?? '');
|
||||
if ($privateKeyPem === '') {
|
||||
$privateKeyPem = $this->loadKeyPemFromFile();
|
||||
}
|
||||
if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') {
|
||||
throw new \RuntimeException('微信支付密钥未配置(mch_id/serial_no/private_key_pem)');
|
||||
}
|
||||
|
||||
$timeStamp = (string)time();
|
||||
$nonceStr = bin2hex(random_bytes(16));
|
||||
$package = 'prepay_id=' . $prepayId;
|
||||
|
||||
$signStr = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n";
|
||||
$privateKey = openssl_pkey_get_private($privateKeyPem);
|
||||
if (!$privateKey) {
|
||||
throw new \RuntimeException('私钥格式错误');
|
||||
}
|
||||
openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
|
||||
return [
|
||||
'appId' => $appId,
|
||||
'timeStamp' => $timeStamp,
|
||||
'nonceStr' => $nonceStr,
|
||||
'package' => $package,
|
||||
'signType' => 'RSA',
|
||||
'paySign' => base64_encode($signature),
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyPlatformSignature(string $timestamp, string $nonce, string $body, string $signature): bool
|
||||
{
|
||||
$certPath = getenv('WECHATPAY_PLATFORM_CERT_PATH') ?: '';
|
||||
if ($certPath === '' || !file_exists($certPath)) {
|
||||
throw new \RuntimeException('平台证书未配置');
|
||||
}
|
||||
$cert = file_get_contents($certPath);
|
||||
$publicKey = openssl_pkey_get_public($cert);
|
||||
if (!$publicKey) {
|
||||
throw new \RuntimeException('平台证书读取失败');
|
||||
}
|
||||
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
$ok = openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256);
|
||||
return $ok === 1;
|
||||
}
|
||||
|
||||
public function decryptNotifyResource(array $resource, string $apiV3Key): array
|
||||
{
|
||||
$ciphertext = (string)($resource['ciphertext'] ?? '');
|
||||
$nonce = (string)($resource['nonce'] ?? '');
|
||||
$aad = (string)($resource['associated_data'] ?? '');
|
||||
if ($ciphertext === '' || $nonce === '') {
|
||||
throw new \RuntimeException('回调报文不完整');
|
||||
}
|
||||
|
||||
$cipherRaw = base64_decode($ciphertext);
|
||||
$tag = substr($cipherRaw, -16);
|
||||
$data = substr($cipherRaw, 0, -16);
|
||||
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
|
||||
if ($plain === false) {
|
||||
throw new \RuntimeException('回调报文解密失败');
|
||||
}
|
||||
return json_decode($plain, true) ?: [];
|
||||
}
|
||||
|
||||
private function buildPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array
|
||||
{
|
||||
$mode = (string)($this->merchant->mode ?? 'direct');
|
||||
$mchId = (string)$this->merchant->mch_id;
|
||||
$appId = (string)($this->merchant->app_id ?? '');
|
||||
$subMchId = (string)($this->merchant->sub_mch_id ?? '');
|
||||
$subAppId = (string)($this->merchant->sub_app_id ?? '');
|
||||
|
||||
if ($mode === 'direct') {
|
||||
if ($appId === '') {
|
||||
throw new \RuntimeException('商户 AppID 未配置');
|
||||
}
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'mchid' => $mchId,
|
||||
'description' => $description,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
|
||||
];
|
||||
}
|
||||
if ($mode === 'service_provider') {
|
||||
if ($appId === '' || $subMchId === '') {
|
||||
throw new \RuntimeException('服务商模式配置不完整');
|
||||
}
|
||||
$body = [
|
||||
'sp_appid' => $appId,
|
||||
'sp_mchid' => $mchId,
|
||||
'sub_mchid' => $subMchId,
|
||||
'description' => $description,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
|
||||
];
|
||||
if ($subAppId !== '') {
|
||||
$body['sub_appid'] = $subAppId;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
throw new \RuntimeException('第三方支付模式未配置下单方式');
|
||||
}
|
||||
|
||||
private function buildJsapiPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array
|
||||
{
|
||||
$openid = trim($openid);
|
||||
if ($openid === '') {
|
||||
throw new \RuntimeException('openid 不能为空');
|
||||
}
|
||||
|
||||
$mode = (string)($this->merchant->mode ?? 'direct');
|
||||
$mchId = (string)$this->merchant->mch_id;
|
||||
$appId = (string)($this->merchant->app_id ?? '');
|
||||
$subMchId = (string)($this->merchant->sub_mch_id ?? '');
|
||||
$subAppId = (string)($this->merchant->sub_app_id ?? '');
|
||||
|
||||
if ($mode === 'direct') {
|
||||
if ($appId === '') {
|
||||
throw new \RuntimeException('商户 AppID 未配置');
|
||||
}
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'mchid' => $mchId,
|
||||
'description' => $description,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
|
||||
'payer' => ['openid' => $openid],
|
||||
];
|
||||
}
|
||||
|
||||
if ($mode === 'service_provider') {
|
||||
if ($appId === '' || $subMchId === '') {
|
||||
throw new \RuntimeException('服务商模式配置不完整');
|
||||
}
|
||||
$body = [
|
||||
'sp_appid' => $appId,
|
||||
'sp_mchid' => $mchId,
|
||||
'sub_mchid' => $subMchId,
|
||||
'description' => $description,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
|
||||
'payer' => [],
|
||||
];
|
||||
if ($subAppId !== '') {
|
||||
$body['sub_appid'] = $subAppId;
|
||||
$body['payer']['sub_openid'] = $openid;
|
||||
} else {
|
||||
$body['payer']['sp_openid'] = $openid;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('第三方支付模式未配置下单方式');
|
||||
}
|
||||
|
||||
private function request(string $method, string $path, array $body): array
|
||||
{
|
||||
$mchId = (string)$this->merchant->mch_id;
|
||||
$serialNo = (string)($this->merchant->serial_no ?? '');
|
||||
$privateKeyPem = (string)($this->merchant->private_key_pem ?? '');
|
||||
if ($privateKeyPem === '') {
|
||||
$privateKeyPem = $this->loadKeyPemFromFile();
|
||||
}
|
||||
|
||||
if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') {
|
||||
throw new \RuntimeException('微信支付密钥未配置(mch_id/serial_no/private_key_pem)');
|
||||
}
|
||||
|
||||
$bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
$timestamp = (string)time();
|
||||
$nonceStr = bin2hex(random_bytes(16));
|
||||
|
||||
$signStr = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $bodyJson . "\n";
|
||||
$privateKey = openssl_pkey_get_private($privateKeyPem);
|
||||
if (!$privateKey) {
|
||||
throw new \RuntimeException('私钥格式错误');
|
||||
}
|
||||
openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
$signature = base64_encode($signature);
|
||||
|
||||
$authorization = sprintf(
|
||||
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
|
||||
$mchId,
|
||||
$nonceStr,
|
||||
$timestamp,
|
||||
$serialNo,
|
||||
$signature
|
||||
);
|
||||
|
||||
$ch = curl_init('https://api.mch.weixin.qq.com' . $path);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
'Authorization: ' . $authorization,
|
||||
'User-Agent: anxinyan-webman',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
|
||||
$respBody = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($respBody === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new \RuntimeException('微信支付请求失败: ' . $err);
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => $respBody,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadKeyPemFromFile(): string
|
||||
{
|
||||
$path = (string)($this->merchant->apiclient_key_path ?? '');
|
||||
if ($path === '') return '';
|
||||
$real = realpath($path);
|
||||
$base = realpath(runtime_path() . '/wechatpay/merchants');
|
||||
if (!$real || !$base) return '';
|
||||
if (strpos($real, $base) !== 0) return '';
|
||||
if (!is_file($real)) return '';
|
||||
$pem = file_get_contents($real);
|
||||
return is_string($pem) ? $pem : '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user