mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-23 10:34:25 +08:00
重构初始化
This commit is contained in:
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
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user