mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-23 02:24:27 +08:00
重构初始化
This commit is contained in:
@@ -2,49 +2,44 @@
|
||||
|
||||
namespace app\common\base;
|
||||
|
||||
use app\exception\ValidationException;
|
||||
use support\Context;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 控制器基础父类
|
||||
* HTTP 层基础控制器。
|
||||
*
|
||||
* 约定统一的 JSON 返回结构:
|
||||
* {
|
||||
* "code": 200,
|
||||
* "message": "success",
|
||||
* "data": ...
|
||||
* }
|
||||
* 统一提供响应封装、参数校验、请求上下文读取等通用能力。
|
||||
*/
|
||||
class BaseController
|
||||
{
|
||||
/**
|
||||
* 成功返回
|
||||
* 返回成功响应。
|
||||
*/
|
||||
protected function success(mixed $data = null, string $message = 'success', int $code = 200): Response
|
||||
protected function success(mixed $data = null, string $message = '操作成功', int $code = 200): Response
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败返回
|
||||
* 返回失败响应。
|
||||
*/
|
||||
protected function fail(string $message = 'error', int $code = 500, mixed $data = null): Response
|
||||
protected function fail(string $message = '操作失败', int $code = 500, mixed $data = null): Response
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一分页返回结构
|
||||
*
|
||||
* @param mixed $paginator Laravel/Eloquent paginator
|
||||
* 返回统一分页响应。
|
||||
*/
|
||||
protected function page(mixed $paginator): Response
|
||||
{
|
||||
@@ -58,28 +53,76 @@ class BaseController
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的 token 载荷
|
||||
* 通过校验器类验证请求数据。
|
||||
*
|
||||
* 从 AuthMiddleware 注入的用户信息中获取
|
||||
* @param class-string $validatorClass
|
||||
*/
|
||||
protected function currentUser(Request $request): ?array
|
||||
protected function validated(array $data, string $validatorClass, ?string $scene = null): array
|
||||
{
|
||||
return $request->user ?? null;
|
||||
$validator = $validatorClass::make($data);
|
||||
|
||||
if ($scene !== null) {
|
||||
$validator = $validator->withScene($scene);
|
||||
}
|
||||
|
||||
return $validator
|
||||
->withException(ValidationException::class)
|
||||
->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户ID
|
||||
* 获取中间件预处理后的标准化参数。
|
||||
*/
|
||||
protected function currentUserId(Request $request): int
|
||||
protected function payload(Request $request): array
|
||||
{
|
||||
return (int) ($request->userId ?? 0);
|
||||
$payload = (array) $request->all();
|
||||
$normalized = Context::get('mpay.normalized_input', []);
|
||||
|
||||
if (is_array($normalized) && !empty($normalized)) {
|
||||
$payload = array_replace($payload, $normalized);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取请求属性。
|
||||
*/
|
||||
protected function requestAttribute(Request $request, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Context::get($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中间件注入的当前管理员 ID。
|
||||
*/
|
||||
protected function currentAdminId(Request $request): int
|
||||
{
|
||||
return (int) $this->requestAttribute($request, 'auth.admin_id', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中间件注入的当前商户 ID。
|
||||
*/
|
||||
protected function currentMerchantId(Request $request): int
|
||||
{
|
||||
return (int) $this->requestAttribute($request, 'auth.merchant_id', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中间件注入的当前商户编号。
|
||||
*/
|
||||
protected function currentMerchantNo(Request $request): string
|
||||
{
|
||||
return (string) $this->requestAttribute($request, 'auth.merchant_no', '');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,35 +2,50 @@
|
||||
|
||||
namespace app\common\base;
|
||||
|
||||
use app\common\util\FormatHelper;
|
||||
use DateTimeInterface;
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* 所有业务模型的基础父类
|
||||
* 所有业务模型的基础父类。
|
||||
*
|
||||
* 统一主键、时间戳和默认批量赋值策略。
|
||||
*/
|
||||
class BaseModel extends Model
|
||||
{
|
||||
/**
|
||||
* 约定所有主键字段名
|
||||
* 默认主键字段名。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* 是否自动维护 created_at / updated_at
|
||||
* 是否自动维护 created_at / updated_at。
|
||||
*
|
||||
* 大部分业务表都有这两个字段,如不需要可在子类里覆盖为 false。
|
||||
* 大部分业务表都包含这两个字段,如有例外可在子类中覆盖为 false。
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 默认不禁止任何字段的批量赋值
|
||||
* 默认仅保护主键,其他字段按子类 fillable 约束。
|
||||
*
|
||||
* 建议在具体模型中按需设置 $fillable 或 $guarded。
|
||||
* 建议在具体模型中显式声明 $fillable。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [];
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* 统一模型时间字段的 JSON 输出格式。
|
||||
*
|
||||
* 避免前端收到 ISO8601(如 2026-04-02T01:50:40.000000Z)这类不直观的时间串,
|
||||
* 统一改为后台常用的本地展示格式。
|
||||
*/
|
||||
protected function serializeDate(DateTimeInterface $date): string
|
||||
{
|
||||
return FormatHelper::dateTime($date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\base;
|
||||
|
||||
use app\common\contracts\PayPluginInterface;
|
||||
use app\exceptions\PaymentException;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\exception\PaymentException;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -21,7 +21,7 @@ use support\Log;
|
||||
* - 子类可在 `init()` 中配置第三方 SDK(例如 yansongda/pay)或读取必填参数。
|
||||
*
|
||||
* 约定:
|
||||
* - 这里的 `$channelConfig` 来源通常是 `ma_pay_channel.config_json`,属于“通道级配置”。
|
||||
* - 这里的 `$channelConfig` 来源通常是 `ma_payment_plugin_conf.config`,并附带通道维度上下文。
|
||||
* - 业务级入参(如订单号、金额、回调地址等)不要混进 `$channelConfig`,应从 `pay()` 的 `$order` 参数获取。
|
||||
*/
|
||||
abstract class BasePayment implements PayPluginInterface
|
||||
@@ -108,6 +108,12 @@ abstract class BasePayment implements PayPluginInterface
|
||||
return $this->paymentInfo['link'] ?? '';
|
||||
}
|
||||
|
||||
/** 获取版本号 */
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->paymentInfo['version'] ?? '';
|
||||
}
|
||||
|
||||
// ==================== 能力声明 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,65 +2,194 @@
|
||||
|
||||
namespace app\common\base;
|
||||
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use support\Model;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 仓储层基础父类
|
||||
* 仓储层基础类。
|
||||
*
|
||||
* 封装单表常用的 CRUD / 分页操作,具体仓储继承后可扩展业务查询。
|
||||
* 封装通用 CRUD、条件查询、加锁查询和分页查询能力。
|
||||
*/
|
||||
abstract class BaseRepository
|
||||
{
|
||||
/**
|
||||
* @var Model
|
||||
* 当前仓储绑定的模型实例。
|
||||
*/
|
||||
protected Model $model;
|
||||
|
||||
|
||||
/**
|
||||
* 构造函数,绑定模型实例。
|
||||
*/
|
||||
public function __construct(Model $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主键查询
|
||||
* 获取查询构造器。
|
||||
*/
|
||||
public function find(int $id, array $columns = ['*']): ?Model
|
||||
public function query()
|
||||
{
|
||||
return $this->model->newQuery()->find($id, $columns);
|
||||
return $this->model->newQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建记录
|
||||
* 按主键查询记录。
|
||||
*/
|
||||
public function find(int|string $id, array $columns = ['*']): ?Model
|
||||
{
|
||||
return $this->query()->find($id, $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增记录。
|
||||
*/
|
||||
public function create(array $data): Model
|
||||
{
|
||||
return $this->model->newQuery()->create($data);
|
||||
return $this->query()->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键更新
|
||||
* 按主键更新记录。
|
||||
*/
|
||||
public function updateById(int $id, array $data): bool
|
||||
public function updateById(int|string $id, array $data): bool
|
||||
{
|
||||
return (bool) $this->model->newQuery()->whereKey($id)->update($data);
|
||||
return (bool) $this->query()->whereKey($id)->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键删除
|
||||
* 按唯一键更新记录。
|
||||
*/
|
||||
public function deleteById(int $id): bool
|
||||
public function updateByKey(int|string $key, array $data): bool
|
||||
{
|
||||
return (bool) $this->model->newQuery()->whereKey($id)->delete();
|
||||
return (bool) $this->query()->whereKey($key)->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单分页查询示例
|
||||
* 按条件批量更新记录。
|
||||
*/
|
||||
public function updateWhere(array $where, array $data): int
|
||||
{
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
}
|
||||
|
||||
return (int) $query->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键删除记录。
|
||||
*/
|
||||
public function deleteById(int|string $id): bool
|
||||
{
|
||||
return (bool) $this->query()->whereKey($id)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件批量删除记录。
|
||||
*/
|
||||
public function deleteWhere(array $where): int
|
||||
{
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
}
|
||||
|
||||
return (int) $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件获取首条记录。
|
||||
*/
|
||||
public function firstBy(array $where = [], array $columns = ['*']): ?Model
|
||||
{
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
}
|
||||
|
||||
return $query->first($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 先查后更,不存在则创建。
|
||||
*/
|
||||
public function updateOrCreate(array $where, array $data = []): Model
|
||||
{
|
||||
if ($where === []) {
|
||||
return $this->create($data);
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($where, $data): Model {
|
||||
$query = $this->query()->lockForUpdate();
|
||||
$query->where($where);
|
||||
|
||||
/** @var Model|null $model */
|
||||
$model = $query->first();
|
||||
if ($model) {
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
|
||||
return $model->refresh();
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->create(array_merge($where, $data));
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
$model = $this->firstBy($where);
|
||||
if (!$model) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
|
||||
return $model->refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件统计数量。
|
||||
*/
|
||||
public function countBy(array $where = []): int
|
||||
{
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断条件下是否存在记录。
|
||||
*/
|
||||
public function existsBy(array $where = []): bool
|
||||
{
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询。
|
||||
*
|
||||
* @param array $where ['字段' => 值],值为 null / '' 时会被忽略
|
||||
* @param array $where 条件数组,空值会被忽略
|
||||
*/
|
||||
public function paginate(array $where = [], int $page = 1, int $pageSize = 10, array $columns = ['*'])
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
$query = $this->query();
|
||||
|
||||
if (!empty($where)) {
|
||||
$query->where($where);
|
||||
|
||||
@@ -2,23 +2,172 @@
|
||||
|
||||
namespace app\common\base;
|
||||
|
||||
use app\common\util\FormatHelper;
|
||||
use support\Db;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 业务服务层基础父类
|
||||
* 业务服务层基础类。
|
||||
*
|
||||
* 统一承载业务单号生成、时间获取和事务封装等通用能力。
|
||||
*/
|
||||
class BaseService
|
||||
{
|
||||
/**
|
||||
* 事务封装
|
||||
* 生成业务单号。
|
||||
*
|
||||
* 使用方式:
|
||||
* $this->transaction(function () { ... });
|
||||
* 适用于 biz_no / pay_no / refund_no / settle_no / notify_no / ledger_no 等场景。
|
||||
* 默认使用时间前缀 + 随机数,保证可读性和基本唯一性。
|
||||
*/
|
||||
protected function generateNo(string $prefix = ''): string
|
||||
{
|
||||
$time = FormatHelper::timestamp(time(), 'YmdHis');
|
||||
$rand = (string) random_int(100000, 999999);
|
||||
|
||||
return $prefix === '' ? $time . $rand : $prefix . $time . $rand;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间字符串。
|
||||
*
|
||||
* 统一返回 `Y-m-d H:i:s` 格式,便于数据库写入和日志输出。
|
||||
*/
|
||||
protected function now(): string
|
||||
{
|
||||
return FormatHelper::timestamp(time());
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额格式化,单位为元。
|
||||
*/
|
||||
protected function formatAmount(int $amount): string
|
||||
{
|
||||
return FormatHelper::amount($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额格式化,0 时显示不限。
|
||||
*/
|
||||
protected function formatAmountOrUnlimited(int $amount): string
|
||||
{
|
||||
return FormatHelper::amountOrUnlimited($amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 次数格式化,0 时显示不限。
|
||||
*/
|
||||
protected function formatCountOrUnlimited(int $count): string
|
||||
{
|
||||
return FormatHelper::countOrUnlimited($count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 费率格式化,单位为百分点。
|
||||
*/
|
||||
protected function formatRate(int $basisPoints): string
|
||||
{
|
||||
return FormatHelper::rate($basisPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟格式化。
|
||||
*/
|
||||
protected function formatLatency(int $latencyMs): string
|
||||
{
|
||||
return FormatHelper::latency($latencyMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期格式化。
|
||||
*/
|
||||
protected function formatDate(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
return FormatHelper::date($value, $emptyText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期时间格式化。
|
||||
*/
|
||||
protected function formatDateTime(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
return FormatHelper::dateTime($value, $emptyText);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 文本格式化。
|
||||
*/
|
||||
protected function formatJson(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
return FormatHelper::json($value, $emptyText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射表文本转换。
|
||||
*/
|
||||
protected function textFromMap(int $value, array $map, string $default = '未知'): string
|
||||
{
|
||||
return FormatHelper::textFromMap($value, $map, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口凭证明文脱敏。
|
||||
*/
|
||||
protected function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string
|
||||
{
|
||||
return FormatHelper::maskCredentialValue($credentialValue, $maskShortValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型或对象归一化成数组。
|
||||
*/
|
||||
protected function normalizeModel(mixed $value): ?array
|
||||
{
|
||||
return FormatHelper::normalizeModel($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务封装。
|
||||
*
|
||||
* 适合单次数据库事务,不包含自动重试逻辑。
|
||||
*
|
||||
* @param callable $callback 事务体
|
||||
* @return mixed
|
||||
*/
|
||||
protected function transaction(callable $callback)
|
||||
{
|
||||
return Db::connection()->transaction(function () use ($callback) {
|
||||
return Db::transaction(function () use ($callback) {
|
||||
return $callback();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持重试的事务封装。
|
||||
*
|
||||
* 适合余额冻结、扣减、状态推进和幂等写入等容易发生锁冲突的场景。
|
||||
*/
|
||||
protected function transactionRetry(callable $callback, int $attempts = 3, int $sleepMs = 50)
|
||||
{
|
||||
$attempts = max(1, $attempts);
|
||||
|
||||
beginning:
|
||||
try {
|
||||
return $this->transaction($callback);
|
||||
} catch (Throwable $e) {
|
||||
$message = strtolower($e->getMessage());
|
||||
$retryable = str_contains($message, 'deadlock')
|
||||
|| str_contains($message, 'lock wait timeout')
|
||||
|| str_contains($message, 'try restarting transaction');
|
||||
|
||||
if (--$attempts > 0 && $retryable) {
|
||||
if ($sleepMs > 0) {
|
||||
usleep($sleepMs * 1000);
|
||||
}
|
||||
|
||||
goto beginning;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
39
app/common/constant/AuthConstant.php
Normal file
39
app/common/constant/AuthConstant.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 认证相关常量。
|
||||
*
|
||||
* 统一管理登录域、令牌状态、签名类型等枚举值。
|
||||
*/
|
||||
final class AuthConstant
|
||||
{
|
||||
public const GUARD_ADMIN = 1;
|
||||
public const GUARD_MERCHANT = 2;
|
||||
|
||||
public const JWT_ALG_HS256 = 'HS256';
|
||||
|
||||
public const TOKEN_STATUS_DISABLED = 0;
|
||||
public const TOKEN_STATUS_ENABLED = 1;
|
||||
|
||||
public const LOGIN_STATUS_DISABLED = 0;
|
||||
public const LOGIN_STATUS_ENABLED = 1;
|
||||
|
||||
public const API_SIGN_TYPE_MD5 = 0;
|
||||
|
||||
public static function signTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::API_SIGN_TYPE_MD5 => 'MD5',
|
||||
];
|
||||
}
|
||||
|
||||
public static function guardMap(): array
|
||||
{
|
||||
return [
|
||||
self::GUARD_ADMIN => 'admin',
|
||||
self::GUARD_MERCHANT => 'merchant',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/common/constant/CommonConstant.php
Normal file
31
app/common/constant/CommonConstant.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 通用状态常量。
|
||||
*/
|
||||
final class CommonConstant
|
||||
{
|
||||
public const STATUS_DISABLED = 0;
|
||||
public const STATUS_ENABLED = 1;
|
||||
|
||||
public const NO = 0;
|
||||
public const YES = 1;
|
||||
|
||||
public static function statusMap(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_DISABLED => '禁用',
|
||||
self::STATUS_ENABLED => '启用',
|
||||
];
|
||||
}
|
||||
|
||||
public static function yesNoMap(): array
|
||||
{
|
||||
return [
|
||||
self::NO => '否',
|
||||
self::YES => '是',
|
||||
];
|
||||
}
|
||||
}
|
||||
135
app/common/constant/FileConstant.php
Normal file
135
app/common/constant/FileConstant.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 文件相关常量。
|
||||
*/
|
||||
final class FileConstant
|
||||
{
|
||||
public const SOURCE_UPLOAD = 1;
|
||||
public const SOURCE_REMOTE_URL = 2;
|
||||
|
||||
public const VISIBILITY_PUBLIC = 1;
|
||||
public const VISIBILITY_PRIVATE = 2;
|
||||
|
||||
public const SCENE_IMAGE = 1;
|
||||
public const SCENE_CERTIFICATE = 2;
|
||||
public const SCENE_TEXT = 3;
|
||||
public const SCENE_OTHER = 4;
|
||||
|
||||
public const STORAGE_LOCAL = 1;
|
||||
public const STORAGE_ALIYUN_OSS = 2;
|
||||
public const STORAGE_TENCENT_COS = 3;
|
||||
public const STORAGE_REMOTE_URL = 4;
|
||||
|
||||
public const CONFIG_DEFAULT_ENGINE = 'file_storage_default_engine';
|
||||
public const CONFIG_LOCAL_PUBLIC_BASE_URL = 'file_storage_local_public_base_url';
|
||||
public const CONFIG_LOCAL_PUBLIC_DIR = 'file_storage_local_public_dir';
|
||||
public const CONFIG_LOCAL_PRIVATE_DIR = 'file_storage_local_private_dir';
|
||||
public const CONFIG_UPLOAD_MAX_SIZE_MB = 'file_storage_upload_max_size_mb';
|
||||
public const CONFIG_REMOTE_DOWNLOAD_LIMIT_MB = 'file_storage_remote_download_limit_mb';
|
||||
public const CONFIG_ALLOWED_EXTENSIONS = 'file_storage_allowed_extensions';
|
||||
public const CONFIG_OSS_ENDPOINT = 'file_storage_aliyun_oss_endpoint';
|
||||
public const CONFIG_OSS_BUCKET = 'file_storage_aliyun_oss_bucket';
|
||||
public const CONFIG_OSS_ACCESS_KEY_ID = 'file_storage_aliyun_oss_access_key_id';
|
||||
public const CONFIG_OSS_ACCESS_KEY_SECRET = 'file_storage_aliyun_oss_access_key_secret';
|
||||
public const CONFIG_OSS_PUBLIC_DOMAIN = 'file_storage_aliyun_oss_public_domain';
|
||||
public const CONFIG_OSS_REGION = 'file_storage_aliyun_oss_region';
|
||||
public const CONFIG_COS_REGION = 'file_storage_tencent_cos_region';
|
||||
public const CONFIG_COS_BUCKET = 'file_storage_tencent_cos_bucket';
|
||||
public const CONFIG_COS_SECRET_ID = 'file_storage_tencent_cos_secret_id';
|
||||
public const CONFIG_COS_SECRET_KEY = 'file_storage_tencent_cos_secret_key';
|
||||
public const CONFIG_COS_PUBLIC_DOMAIN = 'file_storage_tencent_cos_public_domain';
|
||||
|
||||
public static function sourceTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::SOURCE_UPLOAD => '上传',
|
||||
self::SOURCE_REMOTE_URL => '远程导入',
|
||||
];
|
||||
}
|
||||
|
||||
public static function visibilityMap(): array
|
||||
{
|
||||
return [
|
||||
self::VISIBILITY_PUBLIC => '公开',
|
||||
self::VISIBILITY_PRIVATE => '私有',
|
||||
];
|
||||
}
|
||||
|
||||
public static function sceneMap(): array
|
||||
{
|
||||
return [
|
||||
self::SCENE_IMAGE => '图片',
|
||||
self::SCENE_CERTIFICATE => '证书',
|
||||
self::SCENE_TEXT => '文本',
|
||||
self::SCENE_OTHER => '其他',
|
||||
];
|
||||
}
|
||||
|
||||
public static function storageEngineMap(): array
|
||||
{
|
||||
return [
|
||||
self::STORAGE_LOCAL => '本地存储',
|
||||
self::STORAGE_ALIYUN_OSS => '阿里云 OSS',
|
||||
self::STORAGE_TENCENT_COS => '腾讯云 COS',
|
||||
self::STORAGE_REMOTE_URL => '远程引用',
|
||||
];
|
||||
}
|
||||
|
||||
public static function selectableStorageEngineMap(): array
|
||||
{
|
||||
return [
|
||||
self::STORAGE_LOCAL => '本地存储',
|
||||
self::STORAGE_ALIYUN_OSS => '阿里云 OSS',
|
||||
self::STORAGE_TENCENT_COS => '腾讯云 COS',
|
||||
];
|
||||
}
|
||||
|
||||
public static function imageExtensionMap(): array
|
||||
{
|
||||
return [
|
||||
'jpg' => true,
|
||||
'jpeg' => true,
|
||||
'png' => true,
|
||||
'gif' => true,
|
||||
'webp' => true,
|
||||
'bmp' => true,
|
||||
'svg' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public static function certificateExtensionMap(): array
|
||||
{
|
||||
return [
|
||||
'pem' => true,
|
||||
'crt' => true,
|
||||
'cer' => true,
|
||||
'key' => true,
|
||||
'p12' => true,
|
||||
'pfx' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public static function textExtensionMap(): array
|
||||
{
|
||||
return [
|
||||
'txt' => true,
|
||||
'log' => true,
|
||||
'csv' => true,
|
||||
'json' => true,
|
||||
'xml' => true,
|
||||
'md' => true,
|
||||
'ini' => true,
|
||||
'conf' => true,
|
||||
'yaml' => true,
|
||||
'yml' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultAllowedExtensions(): array
|
||||
{
|
||||
return array_keys(self::imageExtensionMap() + self::certificateExtensionMap() + self::textExtensionMap());
|
||||
}
|
||||
}
|
||||
54
app/common/constant/LedgerConstant.php
Normal file
54
app/common/constant/LedgerConstant.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 账户流水相关枚举。
|
||||
*/
|
||||
final class LedgerConstant
|
||||
{
|
||||
public const BIZ_TYPE_PAY_FREEZE = 0;
|
||||
public const BIZ_TYPE_PAY_DEDUCT = 1;
|
||||
public const BIZ_TYPE_PAY_RELEASE = 2;
|
||||
public const BIZ_TYPE_SETTLEMENT_CREDIT = 3;
|
||||
public const BIZ_TYPE_REFUND_REVERSE = 4;
|
||||
public const BIZ_TYPE_MANUAL_ADJUST = 5;
|
||||
|
||||
public const EVENT_TYPE_CREATE = 0;
|
||||
public const EVENT_TYPE_SUCCESS = 1;
|
||||
public const EVENT_TYPE_FAILED = 2;
|
||||
public const EVENT_TYPE_REVERSE = 3;
|
||||
|
||||
public const DIRECTION_IN = 0;
|
||||
public const DIRECTION_OUT = 1;
|
||||
|
||||
public static function bizTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::BIZ_TYPE_PAY_FREEZE => '支付冻结',
|
||||
self::BIZ_TYPE_PAY_DEDUCT => '支付扣费',
|
||||
self::BIZ_TYPE_PAY_RELEASE => '支付释放',
|
||||
self::BIZ_TYPE_SETTLEMENT_CREDIT => '清算入账',
|
||||
self::BIZ_TYPE_REFUND_REVERSE => '退款冲正',
|
||||
self::BIZ_TYPE_MANUAL_ADJUST => '人工调整',
|
||||
];
|
||||
}
|
||||
|
||||
public static function eventTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::EVENT_TYPE_CREATE => '创建',
|
||||
self::EVENT_TYPE_SUCCESS => '成功',
|
||||
self::EVENT_TYPE_FAILED => '失败',
|
||||
self::EVENT_TYPE_REVERSE => '冲正',
|
||||
];
|
||||
}
|
||||
|
||||
public static function directionMap(): array
|
||||
{
|
||||
return [
|
||||
self::DIRECTION_IN => '入账',
|
||||
self::DIRECTION_OUT => '出账',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/common/constant/MerchantConstant.php
Normal file
35
app/common/constant/MerchantConstant.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 商户相关枚举。
|
||||
*/
|
||||
final class MerchantConstant
|
||||
{
|
||||
public const TYPE_PERSON = 0;
|
||||
public const TYPE_COMPANY = 1;
|
||||
public const TYPE_OTHER = 2;
|
||||
|
||||
public const RISK_LOW = 0;
|
||||
public const RISK_MEDIUM = 1;
|
||||
public const RISK_HIGH = 2;
|
||||
|
||||
public static function typeMap(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_PERSON => '个人',
|
||||
self::TYPE_COMPANY => '企业',
|
||||
self::TYPE_OTHER => '其他',
|
||||
];
|
||||
}
|
||||
|
||||
public static function riskLevelMap(): array
|
||||
{
|
||||
return [
|
||||
self::RISK_LOW => '低',
|
||||
self::RISK_MEDIUM => '中',
|
||||
self::RISK_HIGH => '高',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/common/constant/NotifyConstant.php
Normal file
70
app/common/constant/NotifyConstant.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 通知与回调相关枚举。
|
||||
*/
|
||||
final class NotifyConstant
|
||||
{
|
||||
public const NOTIFY_TYPE_ASYNC = 0;
|
||||
public const NOTIFY_TYPE_QUERY = 1;
|
||||
|
||||
public const CALLBACK_TYPE_ASYNC = 0;
|
||||
public const CALLBACK_TYPE_SYNC = 1;
|
||||
|
||||
public const VERIFY_STATUS_UNKNOWN = 0;
|
||||
public const VERIFY_STATUS_SUCCESS = 1;
|
||||
public const VERIFY_STATUS_FAILED = 2;
|
||||
|
||||
public const PROCESS_STATUS_PENDING = 0;
|
||||
public const PROCESS_STATUS_SUCCESS = 1;
|
||||
public const PROCESS_STATUS_FAILED = 2;
|
||||
|
||||
public const TASK_STATUS_PENDING = 0;
|
||||
public const TASK_STATUS_SUCCESS = 1;
|
||||
public const TASK_STATUS_FAILED = 2;
|
||||
|
||||
public static function notifyTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::NOTIFY_TYPE_ASYNC => '异步通知',
|
||||
self::NOTIFY_TYPE_QUERY => '查单',
|
||||
];
|
||||
}
|
||||
|
||||
public static function callbackTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::CALLBACK_TYPE_ASYNC => '异步通知',
|
||||
self::CALLBACK_TYPE_SYNC => '同步返回',
|
||||
];
|
||||
}
|
||||
|
||||
public static function verifyStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::VERIFY_STATUS_UNKNOWN => '未知',
|
||||
self::VERIFY_STATUS_SUCCESS => '成功',
|
||||
self::VERIFY_STATUS_FAILED => '失败',
|
||||
];
|
||||
}
|
||||
|
||||
public static function processStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::PROCESS_STATUS_PENDING => '待处理',
|
||||
self::PROCESS_STATUS_SUCCESS => '成功',
|
||||
self::PROCESS_STATUS_FAILED => '失败',
|
||||
];
|
||||
}
|
||||
|
||||
public static function taskStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::TASK_STATUS_PENDING => '待通知',
|
||||
self::TASK_STATUS_SUCCESS => '成功',
|
||||
self::TASK_STATUS_FAILED => '失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/common/constant/RouteConstant.php
Normal file
55
app/common/constant/RouteConstant.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 路由与通道编排相关枚举。
|
||||
*/
|
||||
final class RouteConstant
|
||||
{
|
||||
public const CHANNEL_TYPE_PLATFORM_COLLECT = 0;
|
||||
public const CHANNEL_TYPE_MERCHANT_SELF = 1;
|
||||
|
||||
public const CHANNEL_MODE_COLLECT = 0;
|
||||
public const CHANNEL_MODE_SELF = 1;
|
||||
|
||||
/**
|
||||
* 轮询组模式:按编排顺序依次轮询可用通道。
|
||||
*/
|
||||
public const ROUTE_MODE_ORDER = 0;
|
||||
|
||||
/**
|
||||
* 轮询组模式:按通道权重随机选择可用通道。
|
||||
*/
|
||||
public const ROUTE_MODE_WEIGHTED = 1;
|
||||
|
||||
/**
|
||||
* 轮询组模式:优先选择默认启用通道,默认不可用时回退到首个可用通道。
|
||||
*/
|
||||
public const ROUTE_MODE_FIRST_AVAILABLE = 2;
|
||||
|
||||
public static function channelTypeMap(): array
|
||||
{
|
||||
return [
|
||||
self::CHANNEL_TYPE_PLATFORM_COLLECT => '平台代收',
|
||||
self::CHANNEL_TYPE_MERCHANT_SELF => '商户自有',
|
||||
];
|
||||
}
|
||||
|
||||
public static function channelModeMap(): array
|
||||
{
|
||||
return [
|
||||
self::CHANNEL_MODE_COLLECT => '代收',
|
||||
self::CHANNEL_MODE_SELF => '自收',
|
||||
];
|
||||
}
|
||||
|
||||
public static function routeModeMap(): array
|
||||
{
|
||||
return [
|
||||
self::ROUTE_MODE_ORDER => '顺序依次轮询',
|
||||
self::ROUTE_MODE_WEIGHTED => '权重随机轮询',
|
||||
self::ROUTE_MODE_FIRST_AVAILABLE => '默认启用通道',
|
||||
];
|
||||
}
|
||||
}
|
||||
157
app/common/constant/TradeConstant.php
Normal file
157
app/common/constant/TradeConstant.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constant;
|
||||
|
||||
/**
|
||||
* 交易、订单与结算相关枚举。
|
||||
*/
|
||||
final class TradeConstant
|
||||
{
|
||||
public const SETTLEMENT_CYCLE_D0 = 0;
|
||||
public const SETTLEMENT_CYCLE_D1 = 1;
|
||||
public const SETTLEMENT_CYCLE_D7 = 2;
|
||||
public const SETTLEMENT_CYCLE_T1 = 3;
|
||||
public const SETTLEMENT_CYCLE_OTHER = 4;
|
||||
|
||||
public const ORDER_STATUS_CREATED = 0;
|
||||
public const ORDER_STATUS_PAYING = 1;
|
||||
public const ORDER_STATUS_SUCCESS = 2;
|
||||
public const ORDER_STATUS_FAILED = 3;
|
||||
public const ORDER_STATUS_CLOSED = 4;
|
||||
public const ORDER_STATUS_TIMEOUT = 5;
|
||||
|
||||
public const FEE_STATUS_NONE = 0;
|
||||
public const FEE_STATUS_FROZEN = 1;
|
||||
public const FEE_STATUS_DEDUCTED = 2;
|
||||
public const FEE_STATUS_RELEASED = 3;
|
||||
|
||||
public const SETTLEMENT_STATUS_NONE = 0;
|
||||
public const SETTLEMENT_STATUS_PENDING = 1;
|
||||
public const SETTLEMENT_STATUS_SETTLED = 2;
|
||||
public const SETTLEMENT_STATUS_REVERSED = 3;
|
||||
|
||||
public const REFUND_STATUS_CREATED = 0;
|
||||
public const REFUND_STATUS_PROCESSING = 1;
|
||||
public const REFUND_STATUS_SUCCESS = 2;
|
||||
public const REFUND_STATUS_FAILED = 3;
|
||||
public const REFUND_STATUS_CLOSED = 4;
|
||||
|
||||
public static function settlementCycleMap(): array
|
||||
{
|
||||
return [
|
||||
self::SETTLEMENT_CYCLE_D0 => 'D0',
|
||||
self::SETTLEMENT_CYCLE_D1 => 'D1',
|
||||
self::SETTLEMENT_CYCLE_D7 => 'D7',
|
||||
self::SETTLEMENT_CYCLE_T1 => 'T1',
|
||||
self::SETTLEMENT_CYCLE_OTHER => 'OTHER',
|
||||
];
|
||||
}
|
||||
|
||||
public static function orderStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::ORDER_STATUS_CREATED => '待创建',
|
||||
self::ORDER_STATUS_PAYING => '支付中',
|
||||
self::ORDER_STATUS_SUCCESS => '成功',
|
||||
self::ORDER_STATUS_FAILED => '失败',
|
||||
self::ORDER_STATUS_CLOSED => '关闭',
|
||||
self::ORDER_STATUS_TIMEOUT => '超时',
|
||||
];
|
||||
}
|
||||
|
||||
public static function feeStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::FEE_STATUS_NONE => '无',
|
||||
self::FEE_STATUS_FROZEN => '冻结',
|
||||
self::FEE_STATUS_DEDUCTED => '已扣',
|
||||
self::FEE_STATUS_RELEASED => '已释放',
|
||||
];
|
||||
}
|
||||
|
||||
public static function settlementStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::SETTLEMENT_STATUS_NONE => '无',
|
||||
self::SETTLEMENT_STATUS_PENDING => '待清算',
|
||||
self::SETTLEMENT_STATUS_SETTLED => '已清算',
|
||||
self::SETTLEMENT_STATUS_REVERSED => '已冲正',
|
||||
];
|
||||
}
|
||||
|
||||
public static function refundStatusMap(): array
|
||||
{
|
||||
return [
|
||||
self::REFUND_STATUS_CREATED => '待创建',
|
||||
self::REFUND_STATUS_PROCESSING => '处理中',
|
||||
self::REFUND_STATUS_SUCCESS => '成功',
|
||||
self::REFUND_STATUS_FAILED => '失败',
|
||||
self::REFUND_STATUS_CLOSED => '关闭',
|
||||
];
|
||||
}
|
||||
|
||||
public static function orderMutableStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::ORDER_STATUS_CREATED,
|
||||
self::ORDER_STATUS_PAYING,
|
||||
];
|
||||
}
|
||||
|
||||
public static function orderTerminalStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::ORDER_STATUS_SUCCESS,
|
||||
self::ORDER_STATUS_FAILED,
|
||||
self::ORDER_STATUS_CLOSED,
|
||||
self::ORDER_STATUS_TIMEOUT,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isOrderTerminalStatus(int $status): bool
|
||||
{
|
||||
return in_array($status, self::orderTerminalStatuses(), true);
|
||||
}
|
||||
|
||||
public static function refundMutableStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::REFUND_STATUS_CREATED,
|
||||
self::REFUND_STATUS_PROCESSING,
|
||||
self::REFUND_STATUS_FAILED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function refundTerminalStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::REFUND_STATUS_SUCCESS,
|
||||
self::REFUND_STATUS_CLOSED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isRefundTerminalStatus(int $status): bool
|
||||
{
|
||||
return in_array($status, self::refundTerminalStatuses(), true);
|
||||
}
|
||||
|
||||
public static function settlementMutableStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::SETTLEMENT_STATUS_PENDING,
|
||||
];
|
||||
}
|
||||
|
||||
public static function settlementTerminalStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::SETTLEMENT_STATUS_SETTLED,
|
||||
self::SETTLEMENT_STATUS_REVERSED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isSettlementTerminalStatus(int $status): bool
|
||||
{
|
||||
return in_array($status, self::settlementTerminalStatuses(), true);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\constants;
|
||||
|
||||
/**
|
||||
* 通用是/否布尔枚举
|
||||
* 可复用在 is_admin 等字段
|
||||
*/
|
||||
class YesNo
|
||||
{
|
||||
public const NO = 0;
|
||||
public const YES = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\enums;
|
||||
|
||||
/**
|
||||
* 菜单类型枚举
|
||||
* 对应表:menus.type
|
||||
* 1 目录 2 菜单 3 按钮
|
||||
*/
|
||||
class MenuType
|
||||
{
|
||||
/**
|
||||
* 目录
|
||||
*/
|
||||
public const DIRECTORY = 1;
|
||||
|
||||
/**
|
||||
* 菜单
|
||||
*/
|
||||
public const MENU = 2;
|
||||
|
||||
/**
|
||||
* 按钮(权限点)
|
||||
*/
|
||||
public const BUTTON = 3;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\contracts;
|
||||
namespace app\common\interface;
|
||||
|
||||
/**
|
||||
* 支付插件“基础契约”接口
|
||||
@@ -11,7 +11,7 @@ namespace app\common\contracts;
|
||||
* - `PaymentInterface`:支付动作能力(下单/查询/关单/退款/回调)。
|
||||
*
|
||||
* 约定:
|
||||
* - `init()` 会在每次发起支付/退款等动作前由服务层调用,用于注入该通道的 `config_json`。
|
||||
* - `init()` 会在每次发起支付/退款等动作前由服务层调用,用于注入该通道对应的插件配置。
|
||||
* - 元信息方法应为“纯读取”,不要依赖外部状态或数据库。
|
||||
*/
|
||||
interface PayPluginInterface
|
||||
@@ -19,19 +19,28 @@ interface PayPluginInterface
|
||||
/**
|
||||
* 初始化插件(注入通道配置)
|
||||
*
|
||||
* 典型来源:`ma_pay_channel.config_json`(以及服务层合并的 enabled_products 等)。
|
||||
* 典型来源:`ma_payment_plugin_conf.config`,并由服务层额外合并通道信息、支付方式声明等上下文。
|
||||
* 插件应在这里完成:缓存配置、初始化 SDK/HTTP 客户端等。
|
||||
*
|
||||
* @param array<string, mixed> $channelConfig
|
||||
*/
|
||||
public function init(array $channelConfig): void;
|
||||
|
||||
/** 插件代码(与 ma_pay_plugin.plugin_code 对应) */
|
||||
/** 插件代码(与 ma_payment_plugin.code 对应) */
|
||||
public function getCode(): string;
|
||||
|
||||
/** 插件名称(用于后台展示) */
|
||||
public function getName(): string;
|
||||
|
||||
/** 插件作者名称(用于后台展示) */
|
||||
public function getAuthorName(): string;
|
||||
|
||||
/** 插件作者链接(用于后台展示) */
|
||||
public function getAuthorLink(): string;
|
||||
|
||||
/** 插件版本号(用于后台展示) */
|
||||
public function getVersion(): string;
|
||||
|
||||
/**
|
||||
* 插件声明支持的支付方式编码
|
||||
*
|
||||
@@ -39,6 +48,9 @@ interface PayPluginInterface
|
||||
*/
|
||||
public function getEnabledPayTypes(): array;
|
||||
|
||||
/** 插件声明支持的转账方式编码 */
|
||||
public function getEnabledTransferTypes(): array;
|
||||
|
||||
/**
|
||||
* 插件配置结构(用于后台渲染表单/校验)
|
||||
*
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\contracts;
|
||||
namespace app\common\interface;
|
||||
|
||||
use app\exceptions\PaymentException;
|
||||
use app\exception\PaymentException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
@@ -24,11 +24,12 @@ interface PaymentInterface
|
||||
* 统一下单
|
||||
*
|
||||
* @param array<string, mixed> $order 订单数据,通常包含:
|
||||
* - order_id: 系统订单号
|
||||
* - mch_no: 商户号
|
||||
* - amount: 金额(元)
|
||||
* - order_id: 系统支付单号,建议直接使用 pay_no
|
||||
* - amount: 金额(分)
|
||||
* - subject: 商品标题
|
||||
* - body: 商品描述
|
||||
* - callback_url: 第三方异步回调地址(回调到本系统)
|
||||
* - return_url: 支付完成跳转地址
|
||||
* @return array<string, mixed> 支付参数,需包含 pay_params、chan_order_no、chan_trade_no
|
||||
* @throws PaymentException 下单失败、渠道异常、参数错误等
|
||||
*/
|
||||
@@ -59,9 +60,9 @@ interface PaymentInterface
|
||||
* 申请退款
|
||||
*
|
||||
* @param array<string, mixed> $order 退款数据,通常包含:
|
||||
* - order_id: 原订单号
|
||||
* - order_id: 原支付单号
|
||||
* - chan_order_no: 渠道订单号
|
||||
* - refund_amount: 退款金额
|
||||
* - refund_amount: 退款金额(分)
|
||||
* - refund_no: 退款单号
|
||||
* @return array<string, mixed> 退款结果,通常包含 success、chan_refund_no、msg
|
||||
* @throws PaymentException 退款失败、渠道异常等
|
||||
@@ -75,15 +76,24 @@ interface PaymentInterface
|
||||
*
|
||||
* @param Request $request 支付渠道的异步通知请求(GET/POST 参数)
|
||||
* @return array<string, mixed> 解析结果,通常包含:
|
||||
* - status: 支付状态
|
||||
* - pay_order_id: 系统订单号
|
||||
* - success: 是否支付成功
|
||||
* - status: 插件解析出的渠道状态文本
|
||||
* - pay_order_id: 系统支付单号
|
||||
* - chan_trade_no: 渠道交易号
|
||||
* - amount: 支付金额
|
||||
* - chan_order_no: 渠道订单号
|
||||
* - amount: 支付金额(分)
|
||||
* - paid_at: 支付成功时间
|
||||
* @throws PaymentException 验签失败、数据异常等
|
||||
*/
|
||||
public function notify(Request $request): array;
|
||||
|
||||
/**
|
||||
* 回调处理成功时返回给第三方的平台响应。
|
||||
*/
|
||||
public function notifySuccess(): string|Response;
|
||||
|
||||
/**
|
||||
* 回调处理失败时返回给第三方的平台响应。
|
||||
*/
|
||||
public function notifyFail(): string|Response;
|
||||
}
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
namespace app\common\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
/**
|
||||
* 全局跨域中间件
|
||||
* 处理前后端分离项目中的跨域请求问题
|
||||
* 全局跨域中间件。
|
||||
*
|
||||
* 统一处理预检请求和跨域响应头。
|
||||
*/
|
||||
class Cors implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
* 处理请求。
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param callable $handler 下一个中间件处理函数
|
||||
* @return Response 响应对象
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
namespace app\common\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* Class StaticFile
|
||||
* @package app\middleware
|
||||
*/
|
||||
class StaticFile implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
// Access to files beginning with. Is prohibited
|
||||
if (strpos($request->path(), '/.') !== false) {
|
||||
return response('<h1>403 forbidden</h1>', 403);
|
||||
}
|
||||
/** @var Response $response */
|
||||
$response = $handler($request);
|
||||
// Add cross domain HTTP header
|
||||
/*$response->withHeaders([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);*/
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\contracts\PaymentInterface;
|
||||
use app\exceptions\PaymentException;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\PaymentException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
@@ -16,43 +17,83 @@ use Yansongda\Supports\Collection;
|
||||
/**
|
||||
* 支付宝支付插件(基于 yansongda/pay ~3.7)
|
||||
*
|
||||
* 支持:web(电脑网站)、h5(手机网站)、scan(扫码)、app(APP 支付)
|
||||
* 支持:web(电脑网站)、h5(手机网站)、app(APP 支付)、mini(小程序)、pos(刷卡)、scan(扫码)、transfer(转账)
|
||||
*
|
||||
* 通道配置:app_id, app_secret_cert, app_public_cert_path, alipay_public_cert_path,
|
||||
* alipay_root_cert_path, notify_url, return_url, mode(0正式/1沙箱)
|
||||
* alipay_root_cert_path, mode(0正式/1沙箱)
|
||||
*/
|
||||
class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
{
|
||||
private const PRODUCT_WEB = 'alipay_web';
|
||||
private const PRODUCT_H5 = 'alipay_h5';
|
||||
private const PRODUCT_APP = 'alipay_app';
|
||||
private const PRODUCT_MINI = 'alipay_mini';
|
||||
private const PRODUCT_POS = 'alipay_pos';
|
||||
private const PRODUCT_SCAN = 'alipay_scan';
|
||||
private const PRODUCT_TRANSFER = 'alipay_transfer';
|
||||
|
||||
private const DEFAULT_ENABLED_PRODUCTS = [
|
||||
self::PRODUCT_H5,
|
||||
];
|
||||
|
||||
private const PRODUCT_ACTION_MAP = [
|
||||
self::PRODUCT_WEB => 'web',
|
||||
self::PRODUCT_H5 => 'h5',
|
||||
self::PRODUCT_APP => 'app',
|
||||
self::PRODUCT_MINI => 'mini',
|
||||
self::PRODUCT_POS => 'pos',
|
||||
self::PRODUCT_SCAN => 'scan',
|
||||
self::PRODUCT_TRANSFER => 'transfer',
|
||||
];
|
||||
|
||||
private const ACTION_PRODUCT_MAP = [
|
||||
'web' => self::PRODUCT_WEB,
|
||||
'h5' => self::PRODUCT_H5,
|
||||
'app' => self::PRODUCT_APP,
|
||||
'mini' => self::PRODUCT_MINI,
|
||||
'pos' => self::PRODUCT_POS,
|
||||
'scan' => self::PRODUCT_SCAN,
|
||||
'transfer' => self::PRODUCT_TRANSFER,
|
||||
];
|
||||
|
||||
protected array $paymentInfo = [
|
||||
'code' => 'alipay',
|
||||
'name' => '支付宝直连',
|
||||
'author' => '',
|
||||
'link' => '',
|
||||
'pay_types' => ['alipay'],
|
||||
'transfer_types' => [],
|
||||
'author' => '技术老胡',
|
||||
'link' => 'https://www.baidu.com',
|
||||
'version' => '1.0.0',
|
||||
'pay_types' => ['alipay', 'alipay_app'],
|
||||
'transfer_types' => ['alipay', 'alipay_app'],
|
||||
'config_schema' => [
|
||||
'fields' => [
|
||||
['field' => 'app_id', 'label' => '应用ID', 'type' => 'text', 'required' => true],
|
||||
['field' => 'app_secret_cert', 'label' => '应用私钥', 'type' => 'textarea', 'required' => true],
|
||||
['field' => 'app_public_cert_path', 'label' => '应用公钥证书路径', 'type' => 'text', 'required' => true],
|
||||
['field' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'text', 'required' => true],
|
||||
['field' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'text', 'required' => true],
|
||||
['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => true],
|
||||
['field' => 'return_url', 'label' => '同步跳转地址', 'type' => 'text', 'required' => false],
|
||||
['field' => 'mode', 'label' => '环境', 'type' => 'select', 'options' => [['value' => '0', 'label' => '正式'], ['value' => '1', 'label' => '沙箱']]],
|
||||
["type" => "input", "field" => "app_id", "title" => "应用ID", "value" => "", "props" => ["placeholder" => "请输入应用ID"], "validate" => [["required" => true, "message" => "应用ID不能为空"]]],
|
||||
["type" => "textarea", "field" => "app_secret_cert", "title" => "应用私钥", "value" => "", "props" => ["placeholder" => "请输入应用私钥", "rows" => 4], "validate" => [["required" => true, "message" => "应用私钥不能为空"]]],
|
||||
["type" => "input", "field" => "app_public_cert_path", "title" => "应用公钥证书路径", "value" => "", "props" => ["placeholder" => "请输入应用公钥证书路径"], "validate" => [["required" => true, "message" => "应用公钥证书路径不能为空"]]],
|
||||
["type" => "input", "field" => "alipay_public_cert_path", "title" => "支付宝公钥证书路径", "value" => "", "props" => ["placeholder" => "请输入支付宝公钥证书路径"], "validate" => [["required" => true, "message" => "支付宝公钥证书路径不能为空"]]],
|
||||
["type" => "input", "field" => "alipay_root_cert_path", "title" => "支付宝根证书路径", "value" => "", "props" => ["placeholder" => "请输入支付宝根证书路径"], "validate" => [["required" => true, "message" => "支付宝根证书路径不能为空"]]],
|
||||
[
|
||||
"type" => "checkbox",
|
||||
"field" => "enabled_products",
|
||||
"title" => "已开通产品",
|
||||
"value" => self::DEFAULT_ENABLED_PRODUCTS,
|
||||
"options" => [
|
||||
["value" => self::PRODUCT_WEB, "label" => "web - 网页支付"],
|
||||
["value" => self::PRODUCT_H5, "label" => "h5 - H5 支付"],
|
||||
["value" => self::PRODUCT_APP, "label" => "app - APP 支付"],
|
||||
["value" => self::PRODUCT_MINI, "label" => "mini - 小程序支付"],
|
||||
["value" => self::PRODUCT_POS, "label" => "pos - 刷卡支付"],
|
||||
["value" => self::PRODUCT_SCAN, "label" => "scan - 扫码支付"],
|
||||
["value" => self::PRODUCT_TRANSFER, "label" => "transfer - 账户转账"],
|
||||
],
|
||||
"validate" => [["required" => true, "message" => "请至少选择一个已开通产品"]],
|
||||
],
|
||||
["type" => "select", "field" => "mode", "title" => "环境", "value" => "0", "props" => ["placeholder" => "请选择环境"], "options" => [["value" => "0", "label" => "正式"], ["value" => "1", "label" => "沙箱"]]],
|
||||
],
|
||||
];
|
||||
|
||||
private const PRODUCT_WEB = 'alipay_web';
|
||||
private const PRODUCT_H5 = 'alipay_h5';
|
||||
private const PRODUCT_SCAN = 'alipay_scan';
|
||||
private const PRODUCT_APP = 'alipay_app';
|
||||
|
||||
public function init(array $channelConfig): void
|
||||
{
|
||||
parent::init($channelConfig);
|
||||
Pay::config([
|
||||
$config = [
|
||||
'alipay' => [
|
||||
'default' => [
|
||||
'app_id' => $this->getConfig('app_id', ''),
|
||||
@@ -65,47 +106,229 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
'mode' => (int)($this->getConfig('mode', Pay::MODE_NORMAL)),
|
||||
],
|
||||
],
|
||||
]);
|
||||
];
|
||||
Pay::config(array_merge($config, ['_force' => true]));
|
||||
}
|
||||
|
||||
private function chooseProduct(array $order): string
|
||||
private function chooseProduct(array $order, bool $validateEnabled = true): string
|
||||
{
|
||||
$enabled = $this->channelConfig['enabled_products'] ?? ['alipay_web', 'alipay_h5', 'alipay_scan'];
|
||||
$env = $order['_env'] ?? 'pc';
|
||||
$map = ['pc' => self::PRODUCT_WEB, 'h5' => self::PRODUCT_H5, 'alipay' => self::PRODUCT_APP];
|
||||
$prefer = $map[$env] ?? self::PRODUCT_WEB;
|
||||
$enabled = $this->normalizeEnabledProducts($this->channelConfig['enabled_products'] ?? self::DEFAULT_ENABLED_PRODUCTS);
|
||||
$explicit = $this->resolveExplicitProduct($order);
|
||||
if ($explicit !== null) {
|
||||
if ($validateEnabled && !in_array($explicit, $enabled, true)) {
|
||||
throw new PaymentException('支付宝产品未开通:' . $this->productAction($explicit), 402);
|
||||
}
|
||||
|
||||
return $explicit;
|
||||
}
|
||||
|
||||
$env = strtolower((string) ($order['_env'] ?? $order['device'] ?? 'pc'));
|
||||
$map = [
|
||||
'pc' => self::PRODUCT_WEB,
|
||||
'web' => self::PRODUCT_WEB,
|
||||
'desktop' => self::PRODUCT_WEB,
|
||||
'mobile' => self::PRODUCT_H5,
|
||||
'h5' => self::PRODUCT_H5,
|
||||
'wechat' => self::PRODUCT_H5,
|
||||
'qq' => self::PRODUCT_H5,
|
||||
'alipay' => self::PRODUCT_APP,
|
||||
'app' => self::PRODUCT_APP,
|
||||
'mini' => self::PRODUCT_MINI,
|
||||
'pos' => self::PRODUCT_POS,
|
||||
'scan' => self::PRODUCT_SCAN,
|
||||
'transfer' => self::PRODUCT_TRANSFER,
|
||||
];
|
||||
$prefer = $map[$env] ?? self::PRODUCT_WEB;
|
||||
|
||||
$payTypeCode = strtolower((string) ($order['pay_type_code'] ?? $order['type_code'] ?? ''));
|
||||
if ($payTypeCode === 'alipay_app') {
|
||||
$prefer = self::PRODUCT_APP;
|
||||
}
|
||||
|
||||
if (!$validateEnabled) {
|
||||
return $prefer;
|
||||
}
|
||||
|
||||
return in_array($prefer, $enabled, true) ? $prefer : ($enabled[0] ?? self::PRODUCT_WEB);
|
||||
}
|
||||
|
||||
private function normalizeEnabledProducts(mixed $products): array
|
||||
{
|
||||
if (is_string($products)) {
|
||||
$decoded = json_decode($products, true);
|
||||
$products = is_array($decoded) ? $decoded : [$products];
|
||||
}
|
||||
|
||||
if (!is_array($products)) {
|
||||
return self::DEFAULT_ENABLED_PRODUCTS;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($products as $product) {
|
||||
$value = strtolower(trim((string) $product));
|
||||
if ($value !== '') {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
|
||||
return $normalized !== [] ? $normalized : self::DEFAULT_ENABLED_PRODUCTS;
|
||||
}
|
||||
|
||||
private function resolveExplicitProduct(array $order): ?string
|
||||
{
|
||||
$context = $this->collectOrderContext($order);
|
||||
$candidates = [
|
||||
$context['pay_product'] ?? null,
|
||||
$context['product'] ?? null,
|
||||
$context['alipay_product'] ?? null,
|
||||
$context['pay_action'] ?? null,
|
||||
$context['action'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$product = $this->normalizeProductCode($candidate);
|
||||
if ($product !== null) {
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeProductCode(mixed $value): ?string
|
||||
{
|
||||
$value = strtolower(trim((string) $value));
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset(self::ACTION_PRODUCT_MAP[$value])) {
|
||||
return self::ACTION_PRODUCT_MAP[$value];
|
||||
}
|
||||
|
||||
if (isset(self::PRODUCT_ACTION_MAP[$value])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function productAction(string $product): string
|
||||
{
|
||||
return self::PRODUCT_ACTION_MAP[$product] ?? $product;
|
||||
}
|
||||
|
||||
private function collectOrderContext(array $order): array
|
||||
{
|
||||
$context = $order;
|
||||
$extra = isset($order['extra']) && is_array($order['extra']) ? $order['extra'] : [];
|
||||
if ($extra !== []) {
|
||||
$context = array_merge($context, $extra);
|
||||
}
|
||||
|
||||
$param = $this->normalizeParamBag($context['param'] ?? null);
|
||||
if ($param !== []) {
|
||||
$context = array_merge($context, $param);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private function normalizeParamBag(mixed $param): array
|
||||
{
|
||||
if (is_array($param)) {
|
||||
return $param;
|
||||
}
|
||||
|
||||
if (is_string($param) && $param !== '') {
|
||||
$decoded = json_decode($param, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
parse_str($param, $parsed);
|
||||
if (is_array($parsed) && $parsed !== []) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function buildBasePayParams(array $params): array
|
||||
{
|
||||
$base = [
|
||||
'out_trade_no' => (string) ($params['out_trade_no'] ?? ''),
|
||||
'total_amount' => FormatHelper::amount((int) ($params['amount'] ?? 0)),
|
||||
'subject' => (string) ($params['subject'] ?? ''),
|
||||
];
|
||||
|
||||
$body = (string) ($params['body'] ?? '');
|
||||
if ($body !== '') {
|
||||
$base['body'] = $body;
|
||||
}
|
||||
|
||||
$returnUrl = (string) ($params['_return_url'] ?? '');
|
||||
if ($returnUrl !== '') {
|
||||
$base['_return_url'] = $returnUrl;
|
||||
}
|
||||
|
||||
$notifyUrl = (string) ($params['_notify_url'] ?? '');
|
||||
if ($notifyUrl !== '') {
|
||||
$base['_notify_url'] = $notifyUrl;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function extractCollectionValue(Collection $result, array $keys, mixed $default = ''): mixed
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$value = $result->get($key);
|
||||
if ($value !== null && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$orderId = $order['order_id'] ?? $order['mch_no'] ?? '';
|
||||
$amount = (float)($order['amount'] ?? 0);
|
||||
$orderId = (string) ($order['order_id'] ?? $order['pay_no'] ?? '');
|
||||
$amount = (int) ($order['amount'] ?? 0);
|
||||
$subject = (string)($order['subject'] ?? '');
|
||||
$body = (string)($order['body'] ?? '');
|
||||
$extra = $order['extra'] ?? [];
|
||||
$returnUrl = $extra['return_url'] ?? $this->getConfig('return_url', '');
|
||||
$notifyUrl = $this->getConfig('notify_url', '');
|
||||
$returnUrl = (string) ($order['return_url'] ?? $extra['return_url'] ?? $this->getConfig('return_url', ''));
|
||||
$notifyUrl = (string) ($order['callback_url'] ?? $this->getConfig('notify_url', ''));
|
||||
|
||||
$params = [
|
||||
if ($orderId === '' || $amount <= 0 || $subject === '') {
|
||||
throw new PaymentException('支付宝下单参数不完整', 402);
|
||||
}
|
||||
|
||||
$params = $this->buildBasePayParams([
|
||||
'out_trade_no' => $orderId,
|
||||
'total_amount' => sprintf('%.2f', $amount),
|
||||
'subject' => $subject,
|
||||
];
|
||||
if ($returnUrl !== '') {
|
||||
$params['_return_url'] = $returnUrl;
|
||||
}
|
||||
if ($notifyUrl !== '') {
|
||||
$params['_notify_url'] = $notifyUrl;
|
||||
}
|
||||
'amount' => $amount,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'_return_url' => $returnUrl,
|
||||
'_notify_url' => $notifyUrl,
|
||||
]);
|
||||
|
||||
$product = $this->chooseProduct($order);
|
||||
|
||||
try {
|
||||
return match ($product) {
|
||||
self::PRODUCT_WEB => $this->doWeb($params),
|
||||
self::PRODUCT_H5 => $this->doH5($params),
|
||||
self::PRODUCT_SCAN => $this->doScan($params),
|
||||
self::PRODUCT_APP => $this->doApp($params),
|
||||
self::PRODUCT_WEB => $this->doWeb($params),
|
||||
self::PRODUCT_H5 => $this->doH5($params),
|
||||
self::PRODUCT_SCAN => $this->doScan($params),
|
||||
self::PRODUCT_APP => $this->doApp($params),
|
||||
self::PRODUCT_MINI => $this->doMini($params, $order),
|
||||
self::PRODUCT_POS => $this->doPos($params, $order),
|
||||
self::PRODUCT_TRANSFER => $this->doTransfer($params, $order),
|
||||
default => throw new PaymentException('不支持的支付宝产品:' . $product, 402),
|
||||
};
|
||||
} catch (PaymentException $e) {
|
||||
@@ -120,7 +343,16 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
$response = Pay::alipay()->web($params);
|
||||
$body = $response instanceof ResponseInterface ? (string)$response->getBody() : '';
|
||||
return [
|
||||
'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body],
|
||||
'pay_product' => self::PRODUCT_WEB,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_WEB),
|
||||
'pay_params' => [
|
||||
'type' => 'form',
|
||||
'method' => 'POST',
|
||||
'action' => '',
|
||||
'html' => $body,
|
||||
'pay_product' => self::PRODUCT_WEB,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_WEB),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => '',
|
||||
];
|
||||
@@ -135,7 +367,16 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
$response = Pay::alipay()->h5($params);
|
||||
$body = $response instanceof ResponseInterface ? (string)$response->getBody() : '';
|
||||
return [
|
||||
'pay_params' => ['type' => 'form', 'method' => 'POST', 'action' => '', 'html' => $body],
|
||||
'pay_product' => self::PRODUCT_H5,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_H5),
|
||||
'pay_params' => [
|
||||
'type' => 'form',
|
||||
'method' => 'POST',
|
||||
'action' => '',
|
||||
'html' => $body,
|
||||
'pay_product' => self::PRODUCT_H5,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_H5),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => '',
|
||||
];
|
||||
@@ -147,7 +388,15 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
$result = Pay::alipay()->scan($params);
|
||||
$qrCode = $result->get('qr_code', '');
|
||||
return [
|
||||
'pay_params' => ['type' => 'qrcode', 'qrcode_url' => $qrCode, 'qrcode_data' => $qrCode],
|
||||
'pay_product' => self::PRODUCT_SCAN,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_SCAN),
|
||||
'pay_params' => [
|
||||
'type' => 'qrcode',
|
||||
'qrcode_url' => $qrCode,
|
||||
'qrcode_data' => $qrCode,
|
||||
'pay_product' => self::PRODUCT_SCAN,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_SCAN),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => $result->get('trade_no', ''),
|
||||
];
|
||||
@@ -159,28 +408,161 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
$result = Pay::alipay()->app($params);
|
||||
$orderStr = $result->get('order_string', '');
|
||||
return [
|
||||
'pay_params' => ['type' => 'jsapi', 'order_str' => $orderStr, 'urlscheme' => $orderStr],
|
||||
'pay_product' => self::PRODUCT_APP,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_APP),
|
||||
'pay_params' => [
|
||||
'type' => 'jsapi',
|
||||
'order_str' => $orderStr,
|
||||
'urlscheme' => $orderStr,
|
||||
'pay_product' => self::PRODUCT_APP,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_APP),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => $result->get('trade_no', ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function doMini(array $params, array $order): array
|
||||
{
|
||||
$context = $this->collectOrderContext($order);
|
||||
$buyerId = trim((string) ($context['buyer_id'] ?? ''));
|
||||
if ($buyerId === '') {
|
||||
throw new PaymentException('支付宝小程序支付缺少 buyer_id', 402);
|
||||
}
|
||||
|
||||
$miniParams = array_merge($params, [
|
||||
'buyer_id' => $buyerId,
|
||||
]);
|
||||
|
||||
/** @var Collection $result */
|
||||
$result = Pay::alipay()->mini($miniParams);
|
||||
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_trade_no'], '');
|
||||
|
||||
return [
|
||||
'pay_product' => self::PRODUCT_MINI,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_MINI),
|
||||
'pay_params' => [
|
||||
'type' => 'mini',
|
||||
'trade_no' => $tradeNo,
|
||||
'buyer_id' => $buyerId,
|
||||
'pay_product' => self::PRODUCT_MINI,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_MINI),
|
||||
'raw' => $result->all(),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => $tradeNo,
|
||||
];
|
||||
}
|
||||
|
||||
private function doPos(array $params, array $order): array
|
||||
{
|
||||
$context = $this->collectOrderContext($order);
|
||||
$authCode = trim((string) ($context['auth_code'] ?? ''));
|
||||
if ($authCode === '') {
|
||||
throw new PaymentException('支付宝刷卡支付缺少 auth_code', 402);
|
||||
}
|
||||
|
||||
$posParams = array_merge($params, [
|
||||
'auth_code' => $authCode,
|
||||
]);
|
||||
|
||||
/** @var Collection $result */
|
||||
$result = Pay::alipay()->pos($posParams);
|
||||
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_trade_no'], '');
|
||||
|
||||
return [
|
||||
'pay_product' => self::PRODUCT_POS,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_POS),
|
||||
'pay_params' => [
|
||||
'type' => 'pos',
|
||||
'trade_no' => $tradeNo,
|
||||
'auth_code' => $authCode,
|
||||
'pay_product' => self::PRODUCT_POS,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_POS),
|
||||
'raw' => $result->all(),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => $tradeNo,
|
||||
];
|
||||
}
|
||||
|
||||
private function doTransfer(array $params, array $order): array
|
||||
{
|
||||
$context = $this->collectOrderContext($order);
|
||||
$payeeInfo = $this->normalizeParamBag($context['payee_info'] ?? null);
|
||||
if ($payeeInfo === []) {
|
||||
throw new PaymentException('支付宝转账缺少 payee_info', 402);
|
||||
}
|
||||
|
||||
$transferParams = [
|
||||
'out_biz_no' => $params['out_trade_no'],
|
||||
'trans_amount' => $params['total_amount'],
|
||||
'payee_info' => $payeeInfo,
|
||||
];
|
||||
|
||||
$notifyUrl = (string) ($params['_notify_url'] ?? '');
|
||||
if ($notifyUrl !== '') {
|
||||
$transferParams['_notify_url'] = $notifyUrl;
|
||||
}
|
||||
|
||||
$orderTitle = trim((string) ($context['order_title'] ?? $context['subject'] ?? ''));
|
||||
if ($orderTitle !== '') {
|
||||
$transferParams['order_title'] = $orderTitle;
|
||||
}
|
||||
|
||||
$remark = trim((string) ($context['remark'] ?? $context['body'] ?? ''));
|
||||
if ($remark !== '') {
|
||||
$transferParams['remark'] = $remark;
|
||||
}
|
||||
|
||||
/** @var Collection $result */
|
||||
$result = Pay::alipay()->transfer($transferParams);
|
||||
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], '');
|
||||
|
||||
return [
|
||||
'pay_product' => self::PRODUCT_TRANSFER,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_TRANSFER),
|
||||
'pay_params' => [
|
||||
'type' => 'transfer',
|
||||
'trade_no' => $tradeNo,
|
||||
'out_biz_no' => $transferParams['out_biz_no'],
|
||||
'trans_amount' => $transferParams['trans_amount'],
|
||||
'payee_info' => $payeeInfo,
|
||||
'pay_product' => self::PRODUCT_TRANSFER,
|
||||
'pay_action' => $this->productAction(self::PRODUCT_TRANSFER),
|
||||
'raw' => $result->all(),
|
||||
],
|
||||
'chan_order_no' => $params['out_trade_no'],
|
||||
'chan_trade_no' => $tradeNo,
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $order): array
|
||||
{
|
||||
$outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? '';
|
||||
$product = $this->chooseProduct($order, false);
|
||||
$action = $this->productAction($product);
|
||||
$outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? '');
|
||||
$queryParams = $action === 'transfer'
|
||||
? ['out_biz_no' => $outTradeNo, '_action' => $action]
|
||||
: ['out_trade_no' => $outTradeNo, '_action' => $action];
|
||||
|
||||
try {
|
||||
/** @var Collection $result */
|
||||
$result = Pay::alipay()->query(['out_trade_no' => $outTradeNo]);
|
||||
$tradeStatus = $result->get('trade_status', '');
|
||||
$tradeNo = $result->get('trade_no', '');
|
||||
$totalAmount = (float)$result->get('total_amount', 0);
|
||||
$status = in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus;
|
||||
$result = Pay::alipay()->query($queryParams);
|
||||
$tradeStatus = (string) $result->get('trade_status', $result->get('status', ''));
|
||||
$tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], '');
|
||||
$totalAmount = (string) $this->extractCollectionValue($result, ['total_amount', 'trans_amount', 'amount'], '0');
|
||||
$status = match ($action) {
|
||||
'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? 'success' : $tradeStatus,
|
||||
default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus,
|
||||
};
|
||||
|
||||
return [
|
||||
'pay_product' => $product,
|
||||
'pay_action' => $action,
|
||||
'status' => $status,
|
||||
'chan_trade_no' => $tradeNo,
|
||||
'pay_amount' => $totalAmount,
|
||||
'pay_amount' => (int) round(((float) $totalAmount) * 100),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝查询失败:' . $e->getMessage(), 402);
|
||||
@@ -189,11 +571,21 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
|
||||
public function close(array $order): array
|
||||
{
|
||||
$outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? '';
|
||||
$product = $this->chooseProduct($order, false);
|
||||
$action = $this->productAction($product);
|
||||
if ($action === 'transfer') {
|
||||
throw new PaymentException('支付宝转账不支持关单', 402);
|
||||
}
|
||||
|
||||
$outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? '');
|
||||
$closeParams = [
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'_action' => $action,
|
||||
];
|
||||
|
||||
try {
|
||||
Pay::alipay()->close(['out_trade_no' => $outTradeNo]);
|
||||
return ['success' => true, 'msg' => '关闭成功'];
|
||||
Pay::alipay()->close($closeParams);
|
||||
return ['success' => true, 'msg' => '关闭成功', 'pay_product' => $product, 'pay_action' => $action];
|
||||
} catch (\Throwable $e) {
|
||||
throw new PaymentException('支付宝关单失败:' . $e->getMessage(), 402);
|
||||
}
|
||||
@@ -201,9 +593,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
|
||||
public function refund(array $order): array
|
||||
{
|
||||
$outTradeNo = $order['chan_order_no'] ?? $order['order_id'] ?? '';
|
||||
$refundAmount = (float)($order['refund_amount'] ?? 0);
|
||||
$refundNo = $order['refund_no'] ?? $order['order_id'] . '_' . time();
|
||||
$product = $this->chooseProduct($order, false);
|
||||
$action = $this->productAction($product);
|
||||
$outTradeNo = (string) ($order['chan_order_no'] ?? $order['order_id'] ?? $order['out_trade_no'] ?? '');
|
||||
$refundAmount = (int) ($order['refund_amount'] ?? 0);
|
||||
$refundNo = (string) ($order['refund_no'] ?? (($order['order_id'] ?? 'refund') . '_' . time()));
|
||||
$refundReason = (string)($order['refund_reason'] ?? '');
|
||||
|
||||
if ($outTradeNo === '' || $refundAmount <= 0) {
|
||||
@@ -211,9 +605,10 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
}
|
||||
|
||||
$params = [
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'refund_amount' => sprintf('%.2f', $refundAmount),
|
||||
'out_request_no' => $refundNo,
|
||||
$action === 'transfer' ? 'out_biz_no' : 'out_trade_no' => $outTradeNo,
|
||||
'refund_amount' => FormatHelper::amount($refundAmount),
|
||||
'out_request_no' => $refundNo,
|
||||
'_action' => $action,
|
||||
];
|
||||
if ($refundReason !== '') {
|
||||
$params['refund_reason'] = $refundReason;
|
||||
@@ -227,9 +622,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
|
||||
if ($code === '10000' || $code === 10000) {
|
||||
return [
|
||||
'success' => true,
|
||||
'chan_refund_no'=> $result->get('trade_no', $refundNo),
|
||||
'msg' => '退款成功',
|
||||
'success' => true,
|
||||
'pay_product' => $product,
|
||||
'pay_action' => $action,
|
||||
'chan_refund_no' => (string) $this->extractCollectionValue($result, ['trade_no', 'refund_no', 'out_request_no'], $refundNo),
|
||||
'msg' => '退款成功',
|
||||
];
|
||||
}
|
||||
throw new PaymentException($subMsg ?: '退款失败', 402);
|
||||
@@ -250,17 +647,21 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
$tradeStatus = $result->get('trade_status', '');
|
||||
$outTradeNo = $result->get('out_trade_no', '');
|
||||
$tradeNo = $result->get('trade_no', '');
|
||||
$totalAmount = (float)$result->get('total_amount', 0);
|
||||
$totalAmount = (string) $result->get('total_amount', '0');
|
||||
$paidAt = (string) $result->get('gmt_payment', '');
|
||||
|
||||
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
|
||||
throw new PaymentException('回调状态异常:' . $tradeStatus, 402);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'pay_order_id' => $outTradeNo,
|
||||
'chan_trade_no'=> $tradeNo,
|
||||
'amount' => $totalAmount,
|
||||
'success' => true,
|
||||
'status' => 'success',
|
||||
'pay_order_id' => $outTradeNo,
|
||||
'chan_order_no' => $outTradeNo,
|
||||
'chan_trade_no' => $tradeNo,
|
||||
'amount' => (int) round(((float) $totalAmount) * 100),
|
||||
'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null,
|
||||
];
|
||||
} catch (PaymentException $e) {
|
||||
throw $e;
|
||||
@@ -268,6 +669,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402);
|
||||
}
|
||||
}
|
||||
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\base\BasePayment;
|
||||
use app\common\contracts\PaymentInterface;
|
||||
use app\exceptions\PaymentException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 拉卡拉支付插件(最小可用示例)
|
||||
*
|
||||
* 目的:先把 API 下单链路跑通,让现有 DB 配置(ma_pay_plugin=lakala)可用。
|
||||
* 后续你可以把这里替换为真实拉卡拉对接逻辑(HTTP 下单、验签回调等)。
|
||||
*/
|
||||
class LakalaPayment extends BasePayment implements PaymentInterface
|
||||
{
|
||||
protected array $paymentInfo = [
|
||||
'code' => 'lakala',
|
||||
'name' => '拉卡拉(示例)',
|
||||
'author' => '',
|
||||
'link' => '',
|
||||
'pay_types' => ['alipay', 'wechat'],
|
||||
'transfer_types' => [],
|
||||
'config_schema' => [
|
||||
'fields' => [
|
||||
['field' => 'notify_url', 'label' => '异步通知地址', 'type' => 'text', 'required' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$orderId = (string)($order['order_id'] ?? '');
|
||||
$amount = (string)($order['amount'] ?? '0.00');
|
||||
$extra = is_array($order['extra'] ?? null) ? $order['extra'] : [];
|
||||
|
||||
if ($orderId === '') {
|
||||
throw new PaymentException('缺少订单号', 402);
|
||||
}
|
||||
|
||||
// 这里先返回“可联调”的 pay_params:默认给一个 qrcode 字符串
|
||||
// 真实实现中应调用拉卡拉下单接口,返回二维码链接/支付链接/预支付信息等。
|
||||
$qrcode = $extra['mock_qrcode'] ?? ('LAKALA_MOCK_QRCODE:' . $orderId . ':' . $amount);
|
||||
|
||||
return [
|
||||
'pay_params' => [
|
||||
'type' => 'qrcode',
|
||||
'qrcode_url' => $qrcode,
|
||||
'qrcode_data'=> $qrcode,
|
||||
],
|
||||
'chan_order_no' => $orderId,
|
||||
'chan_trade_no' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $order): array
|
||||
{
|
||||
throw new PaymentException('LakalaPayment::query 暂未实现', 402);
|
||||
}
|
||||
|
||||
public function close(array $order): array
|
||||
{
|
||||
throw new PaymentException('LakalaPayment::close 暂未实现', 402);
|
||||
}
|
||||
|
||||
public function refund(array $order): array
|
||||
{
|
||||
throw new PaymentException('LakalaPayment::refund 暂未实现', 402);
|
||||
}
|
||||
|
||||
public function notify(Request $request): array
|
||||
{
|
||||
throw new PaymentException('LakalaPayment::notify 暂未实现', 402);
|
||||
}
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
188
app/common/util/FormatHelper.php
Normal file
188
app/common/util/FormatHelper.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\util;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* 通用格式化帮助类。
|
||||
*
|
||||
* 集中处理金额、时间、JSON、映射文案和脱敏逻辑,避免各服务层重复实现。
|
||||
*/
|
||||
class FormatHelper
|
||||
{
|
||||
/**
|
||||
* 金额格式化,单位为元。
|
||||
*/
|
||||
public static function amount(int $amount): string
|
||||
{
|
||||
return number_format($amount / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额格式化,0 时显示不限。
|
||||
*/
|
||||
public static function amountOrUnlimited(int $amount): string
|
||||
{
|
||||
return $amount > 0 ? self::amount($amount) : '不限';
|
||||
}
|
||||
|
||||
/**
|
||||
* 次数格式化,0 时显示不限。
|
||||
*/
|
||||
public static function countOrUnlimited(int $count): string
|
||||
{
|
||||
return $count > 0 ? (string) $count : '不限';
|
||||
}
|
||||
|
||||
/**
|
||||
* 费率格式化,单位为百分点。
|
||||
*/
|
||||
public static function rate(int $basisPoints): string
|
||||
{
|
||||
return number_format($basisPoints / 100, 2, '.', '') . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟格式化。
|
||||
*/
|
||||
public static function latency(int $latencyMs): string
|
||||
{
|
||||
return $latencyMs > 0 ? $latencyMs . ' ms' : '0 ms';
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期格式化。
|
||||
*/
|
||||
public static function date(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
return self::formatTemporalValue($value, 'Y-m-d', $emptyText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期时间格式化。
|
||||
*/
|
||||
public static function dateTime(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
return self::formatTemporalValue($value, 'Y-m-d H:i:s', $emptyText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按时间戳格式化。
|
||||
*/
|
||||
public static function timestamp(int $timestamp, string $pattern = 'Y-m-d H:i:s', string $emptyText = ''): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return $emptyText;
|
||||
}
|
||||
|
||||
return date($pattern, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 文本格式化。
|
||||
*/
|
||||
public static function json(mixed $value, string $emptyText = ''): string
|
||||
{
|
||||
if ($value === null || $value === '' || $value === []) {
|
||||
return $emptyText;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$encoded = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
return $encoded !== false ? $encoded : $emptyText;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
return $encoded !== false ? $encoded : $emptyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射表文本转换。
|
||||
*/
|
||||
public static function textFromMap(int $value, array $map, string $default = '未知'): string
|
||||
{
|
||||
return (string) ($map[$value] ?? $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口凭证明文脱敏。
|
||||
*/
|
||||
public static function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string
|
||||
{
|
||||
$credentialValue = trim($credentialValue);
|
||||
if ($credentialValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$length = strlen($credentialValue);
|
||||
if ($length <= 8) {
|
||||
return $maskShortValue ? str_repeat('*', $length) : $credentialValue;
|
||||
}
|
||||
|
||||
return substr($credentialValue, 0, 4) . '****' . substr($credentialValue, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型或对象归一化成数组。
|
||||
*/
|
||||
public static function normalizeModel(mixed $value): ?array
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (method_exists($value, 'toArray')) {
|
||||
$data = $value->toArray();
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一格式化时间值。
|
||||
*/
|
||||
private static function formatTemporalValue(mixed $value, string $pattern, string $emptyText): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $emptyText;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$text = trim($value);
|
||||
return $text === '' ? $emptyText : $text;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format($pattern);
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'format')) {
|
||||
return $value->format($pattern);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
254
app/common/util/JwtTokenManager.php
Normal file
254
app/common/util/JwtTokenManager.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\util;
|
||||
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* JWT 工具类,负责签发、验证和撤销登录态。
|
||||
*
|
||||
* 设计说明:
|
||||
* - JWT 只负责承载身份声明,不保存业务权限细节。
|
||||
* - Redis 保存会话态,支持主动注销、过期控制和最近访问时间更新。
|
||||
* - guard 用于区分不同登录域,例如管理员和商户。
|
||||
* - Redis Key 推荐由配置中的前缀 + jti 组成,例如:
|
||||
* `mpay:auth:admin:{jti}`、`mpay:auth:merchant:{jti}`。
|
||||
*/
|
||||
class JwtTokenManager
|
||||
{
|
||||
/**
|
||||
* 签发 JWT,并把会话态写入 Redis。
|
||||
*
|
||||
* @param string $guard 认证域名称,例如 admin 或 merchant
|
||||
* @param array $claims JWT 自定义声明,通常包含主体 ID 等核心身份信息
|
||||
* @param array $sessionData Redis 会话数据,通常包含用户展示信息和登录上下文
|
||||
* @param int|null $ttlSeconds 有效期,单位秒;为空时使用 guard 默认 TTL
|
||||
* @return array{token:string,expires_in:int,jti:string,claims:array,session:array}
|
||||
*/
|
||||
public function issue(string $guard, array $claims, array $sessionData, ?int $ttlSeconds = null): array
|
||||
{
|
||||
$guardConfig = $this->guardConfig($guard);
|
||||
$this->assertHmacSecretLength($guard, (string) $guardConfig['secret']);
|
||||
$ttlSeconds = max(60, $ttlSeconds ?? (int) $guardConfig['ttl']);
|
||||
|
||||
$now = time();
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
$payload = array_merge([
|
||||
'iss' => (string) config('auth.issuer', 'mpay'),
|
||||
'iat' => $now,
|
||||
'nbf' => $now,
|
||||
'exp' => $now + $ttlSeconds,
|
||||
'jti' => $jti,
|
||||
'guard' => $guard,
|
||||
], $claims);
|
||||
|
||||
$token = JWT::encode($payload, (string) $guardConfig['secret'], 'HS256');
|
||||
|
||||
$session = array_merge($sessionData, [
|
||||
'guard' => $guard,
|
||||
'jti' => $jti,
|
||||
'issued_at' => FormatHelper::timestamp($now),
|
||||
'expires_at' => FormatHelper::timestamp($now + $ttlSeconds),
|
||||
]);
|
||||
|
||||
$this->storeSession($guard, $jti, $session, $ttlSeconds);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'expires_in' => $ttlSeconds,
|
||||
'jti' => $jti,
|
||||
'claims' => $payload,
|
||||
'session' => $session,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT,并恢复对应的 Redis 会话数据。
|
||||
*
|
||||
* 说明:
|
||||
* - 先校验签名和过期时间。
|
||||
* - 再通过 jti 反查 Redis 会话,确保 token 仍然有效。
|
||||
* - 每次命中会刷新最近访问时间。
|
||||
*
|
||||
* @return array{claims:array,session:array}|null
|
||||
*/
|
||||
public function verify(string $guard, string $token, string $ip = '', string $userAgent = ''): ?array
|
||||
{
|
||||
$payload = $this->decode($guard, $token);
|
||||
if ($payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jti = (string) ($payload['jti'] ?? '');
|
||||
if ($jti === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = $this->session($guard, $jti);
|
||||
if ($session === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$expiresAt = (int) ($payload['exp'] ?? 0);
|
||||
$ttl = max(1, $expiresAt - $now);
|
||||
|
||||
$session['last_used_at'] = FormatHelper::timestamp($now);
|
||||
if ($ip !== '') {
|
||||
$session['last_used_ip'] = $ip;
|
||||
}
|
||||
if ($userAgent !== '') {
|
||||
$session['user_agent'] = $userAgent;
|
||||
}
|
||||
|
||||
$this->storeSession($guard, $jti, $session, $ttl);
|
||||
|
||||
return [
|
||||
'claims' => $payload,
|
||||
'session' => $session,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 token 撤销登录态。
|
||||
*
|
||||
* 适用于主动退出登录场景。
|
||||
*/
|
||||
public function revoke(string $guard, string $token): bool
|
||||
{
|
||||
$payload = $this->decode($guard, $token);
|
||||
if ($payload === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$jti = (string) ($payload['jti'] ?? '');
|
||||
if ($jti === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) Redis::connection()->del($this->sessionKey($guard, $jti));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 jti 直接撤销登录态。
|
||||
*
|
||||
* 适用于已经掌握会话标识但没有原始 token 的补偿清理场景。
|
||||
*/
|
||||
public function revokeByJti(string $guard, string $jti): bool
|
||||
{
|
||||
if ($jti === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) Redis::connection()->del($this->sessionKey($guard, $jti));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 jti 获取会话数据。
|
||||
*
|
||||
* 返回值来自 Redis,若已过期或不存在则返回 null。
|
||||
*/
|
||||
public function session(string $guard, string $jti): ?array
|
||||
{
|
||||
$raw = Redis::connection()->get($this->sessionKey($guard, $jti));
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = json_decode($raw, true);
|
||||
return is_array($session) ? $session : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码并校验 JWT。
|
||||
*
|
||||
* 只做签名、过期和 guard 校验,不处理 Redis 会话。
|
||||
*/
|
||||
protected function decode(string $guard, string $token): ?array
|
||||
{
|
||||
$guardConfig = $this->guardConfig($guard);
|
||||
$this->assertHmacSecretLength($guard, (string) $guardConfig['secret']);
|
||||
|
||||
try {
|
||||
JWT::$leeway = (int) config('auth.leeway', 30);
|
||||
$payload = JWT::decode($token, new Key((string) $guardConfig['secret'], 'HS256'));
|
||||
} catch (ExpiredException|SignatureInvalidException|Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(json_encode($payload, JSON_UNESCAPED_UNICODE), true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($data['guard'] ?? '') !== $guard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将会话数据写入 Redis,并设置 TTL。
|
||||
*/
|
||||
protected function storeSession(string $guard, string $jti, array $session, int $ttlSeconds): void
|
||||
{
|
||||
Redis::connection()->setEx(
|
||||
$this->sessionKey($guard, $jti),
|
||||
max(60, $ttlSeconds),
|
||||
json_encode($session, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Redis 会话键。
|
||||
*
|
||||
* 最终格式由 guard 对应的 redis_prefix 加上 jti 组成。
|
||||
*/
|
||||
protected function sessionKey(string $guard, string $jti): string
|
||||
{
|
||||
return $this->guardConfig($guard)['redis_prefix'] . $jti;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定认证域的配置。
|
||||
*
|
||||
* @throws \InvalidArgumentException 当 guard 未配置时抛出
|
||||
*/
|
||||
protected function guardConfig(string $guard): array
|
||||
{
|
||||
$guards = (array) config('auth.guards', []);
|
||||
if (!isset($guards[$guard])) {
|
||||
throw new \InvalidArgumentException("Unknown auth guard: {$guard}");
|
||||
}
|
||||
|
||||
return $guards[$guard];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 HS256 密钥长度,避免 firebase/php-jwt 抛出底层异常。
|
||||
*/
|
||||
protected function assertHmacSecretLength(string $guard, string $secret): void
|
||||
{
|
||||
if (strlen($secret) >= 32) {
|
||||
return;
|
||||
}
|
||||
|
||||
$envNames = match ($guard) {
|
||||
'admin' => 'AUTH_ADMIN_JWT_SECRET or AUTH_JWT_SECRET',
|
||||
'merchant' => 'AUTH_MERCHANT_JWT_SECRET or AUTH_JWT_SECRET',
|
||||
default => 'the configured JWT secret',
|
||||
};
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'JWT secret for guard "%s" is too short for HS256. Please set %s to at least 32 ASCII characters.',
|
||||
$guard,
|
||||
$envNames
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
/**
|
||||
* 易支付签名工具(MD5)
|
||||
*
|
||||
* 规则:
|
||||
* - 排除 sign、sign_type
|
||||
* - 排除空值(null / '')
|
||||
* - 按字段名 ASCII 升序排序
|
||||
* - k=v&...&key=app_secret
|
||||
* - MD5 后转小写(兼容大小写比较)
|
||||
*/
|
||||
final class EpayUtil
|
||||
{
|
||||
/**
|
||||
* 生成签名字符串
|
||||
*
|
||||
* @param array<string, mixed> $params 请求参数
|
||||
*/
|
||||
public static function make(array $params, string $secret): string
|
||||
{
|
||||
unset($params['sign'], $params['sign_type']);
|
||||
|
||||
$filtered = [];
|
||||
foreach ($params as $k => $v) {
|
||||
if ($v === null) {
|
||||
continue;
|
||||
}
|
||||
if (is_string($v) && trim($v) === '') {
|
||||
continue;
|
||||
}
|
||||
$filtered[$k] = is_bool($v) ? ($v ? '1' : '0') : (string)$v;
|
||||
}
|
||||
|
||||
ksort($filtered);
|
||||
|
||||
$pairs = [];
|
||||
foreach ($filtered as $k => $v) {
|
||||
$pairs[] = $k . '=' . $v;
|
||||
}
|
||||
|
||||
$pairs[] = 'key=' . $secret;
|
||||
|
||||
return strtolower(md5(implode('&', $pairs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验签名
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public static function verify(array $params, string $secret): bool
|
||||
{
|
||||
$sign = strtolower((string)($params['sign'] ?? ''));
|
||||
if ($sign === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(self::make($params, $secret), $sign);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
class JwtUtil
|
||||
{
|
||||
/**
|
||||
* 生成 JWT
|
||||
*/
|
||||
public static function generateToken(array $payloadBase): string
|
||||
{
|
||||
$config = config('jwt', []);
|
||||
$secret = $config['secret'] ?? 'mpay-secret';
|
||||
$ttl = (int)($config['ttl'] ?? 7200);
|
||||
$alg = $config['alg'] ?? 'HS256';
|
||||
|
||||
$now = time();
|
||||
$payload = array_merge($payloadBase, [
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
]);
|
||||
|
||||
return JWT::encode($payload, $secret, $alg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JWT
|
||||
*/
|
||||
public static function parseToken(string $token): array
|
||||
{
|
||||
$config = config('jwt', []);
|
||||
$secret = $config['secret'] ?? 'mpay-secret';
|
||||
$alg = $config['alg'] ?? 'HS256';
|
||||
|
||||
$decoded = JWT::decode($token, new Key($secret, $alg));
|
||||
return json_decode(json_encode($decoded, JSON_UNESCAPED_UNICODE), true) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ttl(秒)
|
||||
*/
|
||||
public static function getTtl(): int
|
||||
{
|
||||
$config = config('jwt', []);
|
||||
return (int)($config['ttl'] ?? 7200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存前缀
|
||||
*/
|
||||
public static function getCachePrefix(): string
|
||||
{
|
||||
$config = config('jwt', []);
|
||||
return $config['cache_prefix'] ?? 'token_';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user