热门角色不仅是灵感来源,更是你的效率助手。通过精挑细选的角色提示词,你可以快速生成高质量内容、提升创作灵感,并找到最契合你需求的解决方案。让创作更轻松,让价值更直接!
我们根据不同用户需求,持续更新角色库,让你总能找到合适的灵感入口。
本提示词专为PHP开发场景设计,能够根据用户需求生成结构完整、符合编码规范的PHP类代码。通过精确的角色设定和任务分解,确保生成的代码具备清晰的命名空间、完整的类结构、适当的访问修饰符和规范的注释文档。特别适用于Web开发、API接口开发、业务逻辑封装等场景,帮助开发者快速构建高质量的PHP类文件,提升开发效率和代码可维护性。
<?php
declare(strict_types=1);
namespace App\Security\Jwt;
use DateInterval;
use DateTimeImmutable;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
/**
* JwtTokenService
*
* 负责生成、验证与刷新基于 HS256 的 JWT。
* 特性:
* - HS256 签名与常量时间比对
* - 过期(exp)、生效时间(nbf)、签发时间(iat)校验
* - 支持自定义 claims 与 scope(作为任意自定义 claim)
* - 时钟偏移(leeway)容错
* - 黑名单(撤销)机制,基于 PSR-16 缓存存储
* - 刷新令牌,在刷新窗口内刷新并维持会话上限(absolute lifetime)
*
* 约定:
* - 使用 `r_exp` claim 作为刷新窗口截止时间(absolute lifetime),单位为 Unix 时间戳
* - 使用 `jti` 作为令牌唯一标识,用于黑名单撤销
* - Header 固定为 {alg: HS256, typ: JWT}
*
* 安全注意:
* - 仅允许 HS256,拒绝算法降级
* - 使用 hash_equals 进行常量时间签名比对
* - 不在代码中硬编码密钥,密钥通过依赖注入传入
*/
final class JwtTokenService
{
private const ALG = 'HS256';
private const TYP = 'JWT';
private const REFRESH_EXP_CLAIM = 'r_exp';
private string $secret;
private ?string $issuer;
private ?string $audience;
private int $defaultTtl;
private int $refreshTtl;
private int $leeway;
private CacheInterface $cache;
private string $blacklistPrefix;
/**
* @param string $secret HS256 对称密钥(建议32字节以上随机值)
* @param CacheInterface $cache PSR-16 缓存实例,用于黑名单
* @param string|null $issuer 签发方(iss),encode时默认写入;decode时若非空则进行校验
* @param string|null $audience 受众(aud),encode时默认写入;decode时若非空则进行校验
* @param int $defaultTtl 默认有效期(秒)
* @param int $leeway 时钟偏移容错(秒),用于 exp/nbf/iat 校验
* @param int $refreshTtl 刷新窗口长度(秒),r_exp = iat + refreshTtl
* @param string $blacklistPrefix 黑名单缓存键前缀
*/
public function __construct(
string $secret,
CacheInterface $cache,
?string $issuer = null,
?string $audience = null,
int $defaultTtl = 3600,
int $leeway = 60,
int $refreshTtl = 1209600,
string $blacklistPrefix = 'jwt:blacklist:'
) {
if ($secret === '') {
throw new \InvalidArgumentException('Secret must not be empty.');
}
if ($defaultTtl <= 0) {
throw new \InvalidArgumentException('Default TTL must be a positive integer (seconds).');
}
if ($leeway < 0) {
throw new \InvalidArgumentException('Leeway must be a non-negative integer (seconds).');
}
if ($refreshTtl < 0) {
throw new \InvalidArgumentException('Refresh TTL must be a non-negative integer (seconds).');
}
$this->secret = $secret;
$this->cache = $cache;
$this->issuer = $issuer;
$this->audience = $audience;
$this->defaultTtl = $defaultTtl;
$this->refreshTtl = $refreshTtl;
$this->leeway = $leeway;
$this->blacklistPrefix = $blacklistPrefix;
}
/**
* 生成 JWT。
*
* 必含:iat, exp, jti
* 可含:iss, aud(若在构造函数中配置)
* 支持自定义 claims(例如 sub、scope/scopes、自定义业务字段)。
*
* @param array<string,mixed> $customClaims 附加或覆盖 claims(exp/iat/jti/r_exp 不可覆盖)
* @param int|null $ttlSeconds 自定义有效期(秒),为空时使用默认 TTL
* @return string 已签名的 JWT
*/
public function encode(array $customClaims = [], ?int $ttlSeconds = null): string
{
$now = $this->now();
$ttl = $ttlSeconds ?? $this->defaultTtl;
if ($ttl <= 0) {
throw new \InvalidArgumentException('TTL seconds must be a positive integer.');
}
$iat = $now->getTimestamp();
$exp = $iat + $ttl;
$jti = $this->generateJti();
// 保护保留字段,禁止外部覆盖
unset($customClaims['exp'], $customClaims['iat'], $customClaims['jti'], $customClaims[self::REFRESH_EXP_CLAIM]);
$claims = $customClaims + [
'iat' => $iat,
'exp' => $exp,
'jti' => $jti,
];
if ($this->issuer !== null) {
$claims['iss'] = $this->issuer;
}
if ($this->audience !== null) {
$claims['aud'] = $this->audience;
}
// 设置刷新窗口(absolute lifetime)
if ($this->refreshTtl > 0) {
$claims[self::REFRESH_EXP_CLAIM] = $iat + $this->refreshTtl;
}
$header = [
'alg' => self::ALG,
'typ' => self::TYP,
];
$jwt = $this->sign($header, $claims);
return $jwt;
}
/**
* 解码并验证 JWT,包含签名、exp/nbf/iat、iss/aud、黑名单等校验。
*
* @param string $jwt
* @param bool $checkBlacklist 是否检查黑名单(默认开启)
* @return array<string,mixed> 令牌 claims
*
* @throws \RuntimeException 验证失败抛出异常
*/
public function decode(string $jwt, bool $checkBlacklist = true): array
{
[$header, $claims] = $this->parseAndVerify($jwt);
$this->validateClaims($claims, false);
if ($checkBlacklist && $this->isRevoked($claims)) {
throw new \RuntimeException('Token has been revoked.');
}
return $claims;
}
/**
* 刷新 JWT。
*
* 规则:
* - 始终验证签名
* - 允许原令牌已过期,但必须在刷新窗口内(r_exp)且未被撤销
* - 继承原令牌的大多数 claims(保留字段 iat/exp/jti/r_exp 会重置/继承策略见下)
* - 刷新后:
* - iat/exp/jti 重置
* - r_exp 保持为原令牌的 r_exp(不延长绝对会话寿命)
* - 可通过 $mergeClaims 覆盖或追加业务 claims(但不能覆盖保留字段)
* - 可选自动撤销旧令牌,防止并发重放
*
* @param string $jwt
* @param int|null $newTtlSeconds 新令牌有效期,默认为构造参数 defaultTtl
* @param array<string,mixed> $mergeClaims 刷新时合并的业务 claims(exp/iat/jti/r_exp 不可覆盖)
* @param bool $revokeOld 是否撤销旧令牌(默认 true)
* @return string 新的 JWT
*/
public function refresh(
string $jwt,
?int $newTtlSeconds = null,
array $mergeClaims = [],
bool $revokeOld = true
): string {
[$header, $claims] = $this->parseAndVerify($jwt);
// 允许过期,但需要其他校验通过(iat/nbf/iss/aud)
$this->validateClaims($claims, true);
if ($this->isRevoked($claims)) {
throw new \RuntimeException('Token has been revoked and cannot be refreshed.');
}
if (!isset($claims[self::REFRESH_EXP_CLAIM]) || !is_int($claims[self::REFRESH_EXP_CLAIM])) {
throw new \RuntimeException('Token is not refreshable (missing r_exp).');
}
$now = $this->now()->getTimestamp();
if ($now > ($claims[self::REFRESH_EXP_CLAIM] + $this->leeway)) {
throw new \RuntimeException('Refresh window has expired.');
}
// 生成新令牌,继承:iss、aud、sub、以及剩余自定义 claims
// 移除保留字段
unset($claims['exp'], $claims['iat'], $claims['jti']);
// 保持 r_exp 不变(absolute lifetime)
$rExp = $claims[self::REFRESH_EXP_CLAIM];
// 合并外部 claims(保护保留字段)
unset($mergeClaims['exp'], $mergeClaims['iat'], $mergeClaims['jti'], $mergeClaims[self::REFRESH_EXP_CLAIM]);
$nextClaims = $claims + $mergeClaims;
// 强制保持 iss/aud 一致性为服务配置(若提供)
if ($this->issuer !== null) {
$nextClaims['iss'] = $this->issuer;
}
if ($this->audience !== null) {
$nextClaims['aud'] = $this->audience;
}
$ttl = $newTtlSeconds ?? $this->defaultTtl;
if ($ttl <= 0) {
throw new \InvalidArgumentException('New TTL seconds must be a positive integer.');
}
$nowDt = $this->now();
$nextClaims['iat'] = $nowDt->getTimestamp();
$nextClaims['exp'] = $nowDt->getTimestamp() + $ttl;
$nextClaims['jti'] = $this->generateJti();
$nextClaims[self::REFRESH_EXP_CLAIM] = $rExp;
$newJwt = $this->sign(['alg' => self::ALG, 'typ' => self::TYP], $nextClaims);
if ($revokeOld) {
$this->revoke($jwt);
}
return $newJwt;
}
/**
* 撤销(加入黑名单)指定 JWT。
*
* TTL 规则:
* - 若令牌仍未过期:黑名单 TTL = 剩余有效期 + leeway
* - 若令牌已过期但仍在刷新窗口内:黑名单 TTL = 剩余刷新窗口期 + leeway
* - 二者都已过:使用最小 TTL(例如 1 小时)以防缓存风暴;也可传入 $ttlSeconds 覆盖
*
* @param string $jwt
* @param int|null $ttlSeconds 自定义黑名单项 TTL(秒),为空则根据令牌状态计算
* @return void
*/
public function revoke(string $jwt, ?int $ttlSeconds = null): void
{
try {
[$header, $claims] = $this->parseAndVerify($jwt);
} catch (\Throwable) {
// 即使无效也尝试解析 jti 失败则直接返回,避免抛出敏感信息
return;
}
if (!isset($claims['jti']) || !is_string($claims['jti'])) {
return;
}
$now = $this->now()->getTimestamp();
$ttl = $ttlSeconds;
if ($ttl === null) {
$ttl = 3600; // fallback 最小 TTL
if (isset($claims['exp']) && is_int($claims['exp']) && $now <= ($claims['exp'] + $this->leeway)) {
$ttl = max(1, ($claims['exp'] - $now) + $this->leeway);
} elseif (
isset($claims[self::REFRESH_EXP_CLAIM]) && is_int($claims[self::REFRESH_EXP_CLAIM]) &&
$now <= ($claims[self::REFRESH_EXP_CLAIM] + $this->leeway)
) {
$ttl = max(1, ($claims[self::REFRESH_EXP_CLAIM] - $now) + $this->leeway);
}
}
try {
$this->cache->set($this->blacklistKey($claims['jti']), true, $ttl);
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Failed to write revoke entry to cache.', 0, $e);
}
}
/**
* 私有:解析并验证签名与 Header。
*
* @param string $jwt
* @return array{0: array<string,mixed>, 1: array<string,mixed>}
*/
private function parseAndVerify(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new \RuntimeException('Invalid token format.');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
$headerJson = $this->b64UrlDecode($encodedHeader);
$payloadJson = $this->b64UrlDecode($encodedPayload);
$signature = $this->b64UrlDecode($encodedSignature);
/** @var array<string,mixed> $header */
$header = $this->jsonDecode($headerJson);
/** @var array<string,mixed> $claims */
$claims = $this->jsonDecode($payloadJson);
// Header 校验:alg 必须是 HS256,typ 建议为 JWT
if (!isset($header['alg']) || $header['alg'] !== self::ALG) {
throw new \RuntimeException('Unsupported or missing alg.');
}
if (isset($header['typ']) && $header['typ'] !== self::TYP) {
throw new \RuntimeException('Invalid token type.');
}
// 验证签名
$signingInput = $encodedHeader . '.' . $encodedPayload;
$expectedSignature = hash_hmac('sha256', $signingInput, $this->secret, true);
if (!hash_equals($expectedSignature, $signature)) {
throw new \RuntimeException('Signature verification failed.');
}
return [$header, $claims];
}
/**
* 私有:校验 claims。
*
* @param array<string,mixed> $claims
* @param bool $allowExpired 是否允许过期(用于 refresh)
* @return void
*/
private function validateClaims(array $claims, bool $allowExpired): void
{
$now = $this->now()->getTimestamp();
// iat
if (!isset($claims['iat']) || !is_int($claims['iat'])) {
throw new \RuntimeException('Missing or invalid iat.');
}
if ($claims['iat'] > ($now + $this->leeway)) {
throw new \RuntimeException('Token used before issued (iat in future).');
}
// nbf
if (isset($claims['nbf'])) {
if (!is_int($claims['nbf'])) {
throw new \RuntimeException('Invalid nbf.');
}
if (($now + $this->leeway) < $claims['nbf']) {
throw new \RuntimeException('Token is not yet valid (nbf).');
}
}
// exp
if (isset($claims['exp'])) {
if (!is_int($claims['exp'])) {
throw new \RuntimeException('Invalid exp.');
}
if (!$allowExpired && ($now > ($claims['exp'] + $this->leeway))) {
throw new \RuntimeException('Token has expired.');
}
} elseif (!$allowExpired) {
// 对外 decode 默认要求 exp 存在
throw new \RuntimeException('Missing exp.');
}
// iss
if ($this->issuer !== null) {
if (!isset($claims['iss']) || !is_string($claims['iss']) || $claims['iss'] !== $this->issuer) {
throw new \RuntimeException('Invalid issuer (iss).');
}
}
// aud
if ($this->audience !== null) {
if (!isset($claims['aud']) || !is_string($claims['aud']) || $claims['aud'] !== $this->audience) {
throw new \RuntimeException('Invalid audience (aud).');
}
}
// jti
if (!isset($claims['jti']) || !is_string($claims['jti']) || $claims['jti'] === '') {
throw new \RuntimeException('Missing or invalid jti.');
}
}
/**
* 私有:判断是否在黑名单内。
*
* @param array<string,mixed> $claims
* @return bool
*/
private function isRevoked(array $claims): bool
{
if (!isset($claims['jti']) || !is_string($claims['jti'])) {
return true;
}
try {
/** @var mixed $flag */
$flag = $this->cache->get($this->blacklistKey($claims['jti']));
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Failed to query revoke cache.', 0, $e);
}
return $flag === true;
}
/**
* 私有:签名并返回 JWT。
*
* @param array<string,mixed> $header
* @param array<string,mixed> $claims
* @return string
*/
private function sign(array $header, array $claims): string
{
$encodedHeader = $this->b64UrlEncode($this->jsonEncode($header));
$encodedPayload = $this->b64UrlEncode($this->jsonEncode($claims));
$signingInput = $encodedHeader . '.' . $encodedPayload;
$signature = hash_hmac('sha256', $signingInput, $this->secret, true);
$encodedSignature = $this->b64UrlEncode($signature);
return $signingInput . '.' . $encodedSignature;
}
/**
* Base64URL 编码(无填充)。
*/
private function b64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Base64URL 解码(自动补全填充)。
*/
private function b64UrlDecode(string $data): string
{
$remainder = strlen($data) % 4;
if ($remainder > 0) {
$data .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($data, '-_', '+/'), true);
if ($decoded === false) {
throw new \RuntimeException('Base64URL decoding failed.');
}
return $decoded;
}
/**
* JSON 编码(开启异常抛出)。
*
* @param array<string,mixed> $value
*/
private function jsonEncode(array $value): string
{
try {
$json = json_encode(
$value,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
assert(is_string($json));
return $json;
} catch (\JsonException $e) {
throw new \RuntimeException('JSON encoding failed.', 0, $e);
}
}
/**
* JSON 解码(关联数组,开启异常抛出)。
*
* @return array<string,mixed>
*/
private function jsonDecode(string $json): array
{
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \RuntimeException('Decoded JSON is not an array.');
}
return $data;
} catch (\JsonException $e) {
throw new \RuntimeException('JSON decoding failed.', 0, $e);
}
}
/**
* 生成随机 JTI(Base64URL)。
*/
private function generateJti(): string
{
return $this->b64UrlEncode(random_bytes(16));
}
/**
* 统一时间源,可用于测试替换。
*/
private function now(): DateTimeImmutable
{
return new DateTimeImmutable('now');
}
/**
* 黑名单 key。
*/
private function blacklistKey(string $jti): string
{
return $this->blacklistPrefix . $jti;
}
}
命名空间设计思路
类结构设计 rationale
主要方法功能说明
使用注意事项
使用的PHP特性
设计模式应用
性能优化考虑
<?php
declare(strict_types=1);
namespace App\Http;
use CURLFile;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Class RestHttpClient
*
* 轻量级 HTTP 客户端封装:
* - 提供 GET/POST/PUT/DELETE 通用方法
* - 内置:重试(指数退避+抖动)、超时控制、连接池(cURL Share 复用连接)、统一错误处理
* - 支持:签名 Header、JSON/Form/Multipart(文件)/原始流 提交
* - 集中日志、可注入 TraceId 追踪
*
* 说明:
* 1) 依赖 ext-curl;连接池基于 cURL Share 复用 DNS/SSL 会话/连接
* 2) 默认对 429/5xx 做重试,对 GET/PUT/DELETE 等幂等方法自动重试;POST 如需重试需显式开启 idempotent=true
* 3) 默认对 4xx/5xx 抛出异常(可通过 throw_on_http_error=false 改为返回响应)
*/
final class RestHttpClient
{
private const DEFAULT_USER_AGENT = 'RestHttpClient/1.0 (+https://example.com)';
private const DEFAULT_TRACE_HEADER = 'X-Trace-Id';
/** @var resource|null */
private static $curlShare = null;
private readonly ?LoggerInterface $logger;
/** @var array<string,string> */
private array $defaultHeaders;
/** @var array<string,mixed> */
private array $clientConfig;
private readonly ?SignerInterface $signer;
private readonly TraceIdProviderInterface $traceIdProvider;
/**
* @param array<string,string> $defaultHeaders 默认公共头
* @param array<string,mixed> $clientConfig 客户端级配置(可被每次调用 options 覆盖)
* 可选键:
* - base_uri: string 基础URL(相对路径将拼接)
* - connect_timeout_ms: int 连接超时(默认 2000ms)
* - timeout_ms: int 整体超时(默认 5000ms)
* - max_attempts: int 最大重试次数(默认 3)
* - retry_backoff_factor: float 退避倍数(默认 2.0)
* - retry_delay_ms: int 初始重试延迟(默认 100ms)
* - max_retry_delay_ms: int 最大重试延迟(默认 2000ms)
* - retry_on_status: int[] 需重试的HTTP状态(默认 [429,500,502,503,504])
* - retry_on_network_errors: bool 网络错误是否重试(默认 true)
* - retry_on_methods: string[] 幂等方法(默认 ['GET','HEAD','PUT','DELETE','OPTIONS'])
* - follow_redirects: bool 是否跟随重定向(默认 true)
* - max_redirects: int 最大重定向次数(默认 3)
* - enable_connection_pool: bool 开启连接池(默认 true)
* - throw_on_http_error: bool 非2xx是否抛异常(默认 true)
* - trace_header: string 追踪头名称(默认 X-Trace-Id)
* - auto_sign: bool 是否自动签名(默认 false)
*/
public function __construct(
?LoggerInterface $logger = null,
?SignerInterface $signer = null,
?TraceIdProviderInterface $traceIdProvider = null,
array $defaultHeaders = [],
array $clientConfig = []
) {
if (!\extension_loaded('curl')) {
throw new RuntimeException('ext-curl is required by RestHttpClient.');
}
$this->logger = $logger;
$this->signer = $signer;
$this->traceIdProvider = $traceIdProvider ?? new UuidV4TraceIdProvider();
$this->defaultHeaders = $this->normalizeHeaders(array_merge([
'User-Agent' => self::DEFAULT_USER_AGENT,
'Accept' => '*/*',
'Connection' => 'keep-alive',
], $defaultHeaders));
$this->clientConfig = array_merge([
'base_uri' => '',
'connect_timeout_ms' => 2000,
'timeout_ms' => 5000,
'max_attempts' => 3,
'retry_backoff_factor' => 2.0,
'retry_delay_ms' => 100,
'max_retry_delay_ms' => 2000,
'retry_on_status' => [429, 500, 502, 503, 504],
'retry_on_network_errors' => true,
'retry_on_methods' => ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'],
'follow_redirects' => true,
'max_redirects' => 3,
'enable_connection_pool' => true,
'throw_on_http_error' => true,
'trace_header' => self::DEFAULT_TRACE_HEADER,
'auto_sign' => false,
], $clientConfig);
if ($this->clientConfig['enable_connection_pool']) {
$this->initCurlShare();
}
}
/**
* 发送 GET 请求
*
* @param array<string,scalar|array|null> $query
* @param array<string,string> $headers
* @param array<string,mixed> $options
* @throws RestHttpException
*/
public function get(string $url, array $query = [], array $headers = [], array $options = []): RestHttpResponse
{
return $this->request('GET', $url, [
'query' => $query,
'headers' => $headers,
], $options);
}
/**
* 发送 POST 请求
*
* Body 提交方式通过 options 指定:
* - json: array|object -> JSON 编码
* - form_params: array -> application/x-www-form-urlencoded
* - multipart: array<array{name:string,contents:string|CURLFile,filename?:string,headers?:array<string,string>}>
* - raw: string -> 原始字节流(可自行设置 Content-Type)
*
* @param array<string,string> $headers
* @param array<string,mixed> $options
* @throws RestHttpException
*/
public function post(string $url, array $headers = [], array $options = []): RestHttpResponse
{
return $this->request('POST', $url, [
'headers' => $headers,
], $options);
}
/**
* 发送 PUT 请求
*
* @param array<string,string> $headers
* @param array<string,mixed> $options
* @throws RestHttpException
*/
public function put(string $url, array $headers = [], array $options = []): RestHttpResponse
{
return $this->request('PUT', $url, [
'headers' => $headers,
], $options);
}
/**
* 发送 DELETE 请求
*
* @param array<string,scalar|array|null> $query
* @param array<string,string> $headers
* @param array<string,mixed> $options
* @throws RestHttpException
*/
public function delete(string $url, array $query = [], array $headers = [], array $options = []): RestHttpResponse
{
return $this->request('DELETE', $url, [
'query' => $query,
'headers' => $headers,
], $options);
}
/**
* 通用请求入口
*
* options 支持:
* - query: array 查询参数
* - headers: array 额外头
* - json: array|object JSON 体
* - form_params: array 表单体
* - multipart: array 表单-文件体(见 post() 注释)
* - raw: string 原始字节
* - idempotent: bool POST 是否视为可重试(默认 false)
* - sign: bool 是否签名(默认 由 auto_sign 控制)
* - timeout_ms / connect_timeout_ms: int 覆盖超时
* - max_attempts / retry_backoff_factor / retry_delay_ms / max_retry_delay_ms / retry_on_status / retry_on_network_errors / retry_on_methods
* - follow_redirects / max_redirects
* - throw_on_http_error: bool 覆盖行为
* - trace_id / trace_header: 自定义追踪
*
* @param array{query?:array,headers?:array} $params
* @param array<string,mixed> $options
* @throws RestHttpException
*/
public function request(string $method, string $url, array $params = [], array $options = []): RestHttpResponse
{
$method = strtoupper($method);
$t0 = \hrtime(true);
// 合并配置
$cfg = $this->mergeOptions($this->clientConfig, $options);
// 组装 URL(base_uri + path + query)
$uri = $this->buildUrl($url, $cfg['base_uri'] ?? '', $params['query'] ?? $options['query'] ?? []);
// 头部合并与注入 TraceId
$traceHeader = (string)($cfg['trace_header'] ?? self::DEFAULT_TRACE_HEADER);
$traceId = (string)($options['trace_id'] ?? $this->traceIdProvider->getTraceId());
$headers = $this->normalizeHeaders(array_merge(
$this->defaultHeaders,
$params['headers'] ?? [],
$options['headers'] ?? []
));
if ($traceHeader !== '') {
$headers[$traceHeader] = $traceId;
}
// Body 构建(互斥:json | form_params | multipart | raw)
$bodyTuple = $this->buildBody($options, $headers);
$postFields = $bodyTuple['post_fields'];
$rawBody = $bodyTuple['raw_body'];
$headers = $bodyTuple['headers'];
// 签名(如启用)
$shouldSign = isset($options['sign']) ? (bool)$options['sign'] : (bool)$cfg['auto_sign'];
if ($shouldSign && $this->signer !== null) {
$sigHeaders = $this->signer->sign($method, $uri, $headers, $rawBody);
$headers = $this->normalizeHeaders(array_merge($headers, $sigHeaders));
}
$attempt = 0;
$maxAttempts = max(1, (int)$cfg['max_attempts']);
$retryableMethods = array_map('strtoupper', (array)$cfg['retry_on_methods']);
$idempotent = in_array($method, $retryableMethods, true) || (bool)($options['idempotent'] ?? false);
$lastException = null;
$response = null;
while ($attempt < $maxAttempts) {
$attempt++;
try {
$response = $this->executeCurl(
method: $method,
url: $uri,
headers: $headers,
postFields: $postFields,
rawBody: $rawBody,
connectTimeoutMs: (int)$cfg['connect_timeout_ms'],
timeoutMs: (int)$cfg['timeout_ms'],
followRedirects: (bool)$cfg['follow_redirects'],
maxRedirects: (int)$cfg['max_redirects'],
withConnectionPool: (bool)$cfg['enable_connection_pool'],
);
// 非 2xx 处理
if ($response->statusCode < 200 || $response->statusCode >= 300) {
$shouldThrow = (bool)$cfg['throw_on_http_error'];
$shouldRetry = $this->shouldRetryHttp(
status: $response->statusCode,
retryOnStatus: (array)$cfg['retry_on_status']
);
// 记录
$this->logHttp($method, $uri, $traceId, $attempt, $t0, $response->statusCode, $shouldRetry);
if ($shouldRetry && $idempotent && $attempt < $maxAttempts) {
$this->sleepBackoff($attempt, (int)$cfg['retry_delay_ms'], (float)$cfg['retry_backoff_factor'], (int)$cfg['max_retry_delay_ms']);
continue;
}
if ($shouldThrow) {
throw RestHttpException::httpError(
message: 'HTTP error response',
method: $method,
uri: $uri,
statusCode: $response->statusCode,
headers: $response->headers,
body: $response->body,
traceId: $traceId
);
}
} else {
// 正常 2xx
$this->logHttp($method, $uri, $traceId, $attempt, $t0, $response->statusCode, false);
}
return $response;
} catch (RestHttpException $e) {
$lastException = $e;
$isNetwork = $e->isNetworkError();
$retryNet = (bool)$cfg['retry_on_network_errors'];
$shouldRetry = $isNetwork && $retryNet && ($idempotent || (bool)($options['idempotent'] ?? false));
$this->logException($method, $uri, $traceId, $attempt, $t0, $e, $shouldRetry);
if ($shouldRetry && $attempt < $maxAttempts) {
$this->sleepBackoff($attempt, (int)$cfg['retry_delay_ms'], (float)$cfg['retry_backoff_factor'], (int)$cfg['max_retry_delay_ms']);
continue;
}
throw $e;
} catch (\Throwable $e) {
$lastException = $e;
$wrapped = RestHttpException::networkError(
message: $e->getMessage(),
method: $method,
uri: $uri,
traceId: $traceId,
previous: $e
);
$retryNet = (bool)$cfg['retry_on_network_errors'];
$shouldRetry = $retryNet && ($idempotent || (bool)($options['idempotent'] ?? false));
$this->logException($method, $uri, $traceId, $attempt, $t0, $wrapped, $shouldRetry);
if ($shouldRetry && $attempt < $maxAttempts) {
$this->sleepBackoff($attempt, (int)$cfg['retry_delay_ms'], (float)$cfg['retry_backoff_factor'], (int)$cfg['max_retry_delay_ms']);
continue;
}
throw $wrapped;
}
}
// 理论上不会到此
if ($lastException instanceof RestHttpException) {
throw $lastException;
}
throw RestHttpException::networkError(
message: 'Unknown error after retries',
method: $method,
uri: $uri,
traceId: $traceId
);
}
/**
* 执行底层 cURL 调用
*
* @param array<string,string> $headers
* @return RestHttpResponse
* @throws RestHttpException
*/
private function executeCurl(
string $method,
string $url,
array $headers,
null|array $postFields,
null|string $rawBody,
int $connectTimeoutMs,
int $timeoutMs,
bool $followRedirects,
int $maxRedirects,
bool $withConnectionPool
): RestHttpResponse {
$ch = \curl_init();
if ($ch === false) {
throw RestHttpException::networkError('Failed to init curl', $method, $url);
}
// Header 处理(数组形式)
$headerLines = [];
foreach ($headers as $k => $v) {
$headerLines[] = $k . ': ' . $v;
}
$responseHeaders = [];
$headerCollector = static function ($ch, string $headerLine) use (&$responseHeaders): int {
$len = strlen($headerLine);
$headerLine = trim($headerLine);
if ($headerLine === '' || strpos($headerLine, ':') === false) {
return $len;
}
[$name, $value] = array_map('trim', explode(':', $headerLine, 2));
$normalized = self::normalizeHeaderName($name);
if (!isset($responseHeaders[$normalized])) {
$responseHeaders[$normalized] = $value;
} else {
// 合并同名多值
$responseHeaders[$normalized] .= ', ' . $value;
}
return $len;
};
$options = [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_HEADERFUNCTION => $headerCollector,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_FOLLOWLOCATION => $followRedirects,
CURLOPT_MAXREDIRS => $maxRedirects,
CURLOPT_ENCODING => '', // 接受所有压缩
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, // 优先 HTTP/2(回退到1.1)
CURLOPT_TCP_KEEPALIVE => 1,
CURLOPT_TCP_KEEPIDLE => 30,
CURLOPT_TCP_KEEPINTVL => 10,
];
if ($withConnectionPool && self::$curlShare) {
$options[CURLOPT_SHARE] = self::$curlShare;
}
// 设置 Body
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
if ($postFields !== null) {
// multipart 或 form
$options[CURLOPT_POSTFIELDS] = $postFields;
} elseif ($rawBody !== null) {
$options[CURLOPT_POSTFIELDS] = $rawBody;
}
}
if (\curl_setopt_array($ch, $options) === false) {
\curl_close($ch);
throw RestHttpException::networkError('Failed to set curl options', $method, $url);
}
$body = \curl_exec($ch);
$errno = \curl_errno($ch);
$error = \curl_error($ch);
$httpCode = (int)(\curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: 0);
\curl_close($ch);
if ($errno !== 0) {
throw RestHttpException::networkError(
message: sprintf('cURL error [%d]: %s', $errno, $error ?: 'unknown'),
method: $method,
uri: $url
);
}
return new RestHttpResponse(
statusCode: $httpCode,
headers: $responseHeaders,
body: is_string($body) ? $body : ''
);
}
/**
* 构建请求 Body 并返回 [post_fields, raw_body, headers]
*
* @param array<string,mixed> $options
* @param array<string,string> $headers
* @return array{post_fields:?array, raw_body:?string, headers:array<string,string>}
*/
private function buildBody(array $options, array $headers): array
{
$hasJson = array_key_exists('json', $options);
$hasForm = array_key_exists('form_params', $options);
$hasMultipart = array_key_exists('multipart', $options);
$hasRaw = array_key_exists('raw', $options);
$count = (int)$hasJson + (int)$hasForm + (int)$hasMultipart + (int)$hasRaw;
if ($count > 1) {
throw new RuntimeException('Only one of json, form_params, multipart, raw can be provided.');
}
if ($hasJson) {
$json = $options['json'];
$encoded = json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$headers = $this->ensureHeader($headers, 'Content-Type', 'application/json; charset=utf-8');
return ['post_fields' => null, 'raw_body' => $encoded, 'headers' => $headers];
}
if ($hasForm) {
$form = (array)$options['form_params'];
$headers = $this->ensureHeader($headers, 'Content-Type', 'application/x-www-form-urlencoded; charset=utf-8');
// 让 cURL 处理 application/x-www-form-urlencoded 时,传入字符串更可靠
$encoded = http_build_query($form, '', '&', PHP_QUERY_RFC3986);
return ['post_fields' => $encoded, 'raw_body' => null, 'headers' => $headers];
}
if ($hasMultipart) {
$parts = (array)$options['multipart'];
// 交由 cURL 构建 multipart/form-data,传数组 + CURLFile 即可
// 注意:不要手动设置 Content-Type(含 boundary),交由 cURL 自动生成更安全
$headers = $this->removeHeader($headers, 'Content-Type');
$postFields = [];
foreach ($parts as $part) {
if (!isset($part['name'], $part['contents'])) {
throw new RuntimeException('Each multipart part requires "name" and "contents".');
}
$name = (string)$part['name'];
$contents = $part['contents'];
if ($contents instanceof CURLFile) {
$postFields[$name] = $contents;
} elseif (is_string($contents)) {
$postFields[$name] = $contents;
} else {
throw new RuntimeException('Multipart contents must be string or CURLFile.');
}
}
return ['post_fields' => $postFields, 'raw_body' => null, 'headers' => $headers];
}
if ($hasRaw) {
$raw = (string)$options['raw'];
// 若未指定 Content-Type,默认二进制流
if (!$this->hasHeader($headers, 'Content-Type')) {
$headers['Content-Type'] = 'application/octet-stream';
}
return ['post_fields' => null, 'raw_body' => $raw, 'headers' => $headers];
}
// 无 Body
return ['post_fields' => null, 'raw_body' => null, 'headers' => $headers];
}
/**
* 指数退避 + 抖动
*/
private function sleepBackoff(int $attempt, int $baseDelayMs, float $factor, int $maxDelayMs): void
{
$delay = (int)min($maxDelayMs, $baseDelayMs * ($factor ** max(0, $attempt - 1)));
// full jitter
$delay = random_int((int)max(0, $delay / 2), $delay);
usleep(max(0, $delay) * 1000);
}
/**
* 是否需要针对 HTTP 状态码重试
* @param int[] $retryOnStatus
*/
private function shouldRetryHttp(int $status, array $retryOnStatus): bool
{
return in_array($status, $retryOnStatus, true);
}
/**
* 构造 URL(支持 base_uri & query)
*
* @param array<string,scalar|array|null> $query
*/
private function buildUrl(string $url, string $baseUri, array $query): string
{
$isAbsolute = (bool)preg_match('#^https?://#i', $url);
$base = rtrim($baseUri, '/');
$path = $isAbsolute ? $url : ($base !== '' ? $base . '/' . ltrim($url, '/') : $url);
if (!empty($query)) {
$qs = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$path .= (str_contains($path, '?') ? '&' : '?') . $qs;
}
return $path;
}
private function logHttp(string $method, string $url, string $traceId, int $attempt, int $t0, int $status, bool $willRetry): void
{
if (!$this->logger) {
return;
}
$elapsedMs = (int)round((\hrtime(true) - $t0) / 1_000_000);
$level = $willRetry ? 'warning' : 'info';
$context = [
'method' => $method,
'url' => $url,
'status' => $status,
'attempt' => $attempt,
'elapsed_ms' => $elapsedMs,
'trace_id' => $traceId,
'will_retry' => $willRetry,
];
if ($level === 'warning') {
$this->logger->warning('[HTTP] request completed with retryable status', $context);
} else {
$this->logger->info('[HTTP] request completed', $context);
}
}
private function logException(string $method, string $url, string $traceId, int $attempt, int $t0, RestHttpException $e, bool $willRetry): void
{
if (!$this->logger) {
return;
}
$elapsedMs = (int)round((\hrtime(true) - $t0) / 1_000_000);
$context = [
'method' => $method,
'url' => $url,
'attempt' => $attempt,
'elapsed_ms' => $elapsedMs,
'trace_id' => $traceId,
'error_type' => $e->isNetworkError() ? 'network' : 'http',
'status' => $e->getStatusCode(),
'will_retry' => $willRetry,
];
if ($willRetry) {
$this->logger->warning('[HTTP] request failed, will retry: ' . $e->getMessage(), $context);
} else {
$this->logger->error('[HTTP] request failed: ' . $e->getMessage(), $context);
}
}
/**
* 初始化 cURL 连接池(Share)
*/
private function initCurlShare(): void
{
if (self::$curlShare !== null) {
return;
}
$share = \curl_share_init();
if ($share === false) {
// 不可用时静默降级(不抛异常,仍可工作)
return;
}
\curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
\curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);
if (defined('CURL_LOCK_DATA_CONNECT')) {
\curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
}
self::$curlShare = $share;
}
/**
* 合并配置(options 覆盖 clientConfig)
*
* @param array<string,mixed> $base
* @param array<string,mixed> $overrides
* @return array<string,mixed>
*/
private function mergeOptions(array $base, array $overrides): array
{
$merged = $base;
foreach ($overrides as $k => $v) {
// 允许覆盖标量、数组(浅合并)、布尔等
if (is_array($v) && isset($merged[$k]) && is_array($merged[$k])) {
$merged[$k] = array_merge($merged[$k], $v);
} else {
$merged[$k] = $v;
}
}
// 局部超时覆盖
if (isset($overrides['timeout_ms'])) {
$merged['timeout_ms'] = (int)$overrides['timeout_ms'];
}
if (isset($overrides['connect_timeout_ms'])) {
$merged['connect_timeout_ms'] = (int)$overrides['connect_timeout_ms'];
}
return $merged;
}
/**
* 规范化头(不区分大小写,键名规范化)
*
* @param array<string,string> $headers
* @return array<string,string>
*/
private function normalizeHeaders(array $headers): array
{
$out = [];
foreach ($headers as $k => $v) {
$out[self::normalizeHeaderName((string)$k)] = (string)$v;
}
return $out;
}
private static function normalizeHeaderName(string $name): string
{
$name = strtolower(trim($name));
return implode('-', array_map(static fn($p) => ucfirst($p), explode('-', $name)));
}
/**
* @param array<string,string> $headers
* @return array<string,string>
*/
private function ensureHeader(array $headers, string $name, string $value): array
{
if (!$this->hasHeader($headers, $name)) {
$headers[self::normalizeHeaderName($name)] = $value;
}
return $headers;
}
/**
* @param array<string,string> $headers
*/
private function hasHeader(array $headers, string $name): bool
{
$n = self::normalizeHeaderName($name);
return array_key_exists($n, $headers);
}
/**
* @param array<string,string> $headers
* @return array<string,string>
*/
private function removeHeader(array $headers, string $name): array
{
$n = self::normalizeHeaderName($name);
unset($headers[$n]);
return $headers;
}
}
/**
* HTTP 响应对象(简化)
*/
final class RestHttpResponse
{
/**
* @param array<string,string> $headers
*/
public function __construct(
public readonly int $statusCode,
public readonly array $headers,
public readonly string $body
) {
}
/**
* 返回大小写不敏感的 Header 值
*/
public function getHeaderLine(string $name): ?string
{
$n = RestHttpClient::class;
$normalize = (new \ReflectionClass($n))->getMethod('normalizeHeaderName');
$normalize->setAccessible(true);
/** @var callable $call */
$call = $normalize->getClosure(new RestHttpClient());
$key = $call($name);
return $this->headers[$key] ?? null;
}
/**
* 尝试 JSON 解析响应体
* @return array<mixed>|null
*/
public function json(): ?array
{
if ($this->body === '') {
return null;
}
try {
$data = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
return is_array($data) ? $data : null;
} catch (\JsonException) {
return null;
}
}
}
/**
* 统一异常类型
*/
class RestHttpException extends RuntimeException
{
private ?int $statusCode = null;
private string $method = '';
private string $uri = '';
private string $traceId = '';
private bool $networkError = false;
/** @var array<string,string> */
private array $responseHeaders = [];
private string $responseBody = '';
/**
* @param array<string,string> $headers
*/
public static function httpError(
string $message,
string $method,
string $uri,
int $statusCode,
array $headers,
string $body,
string $traceId = '',
?\Throwable $previous = null
): self {
$ex = new self($message, $statusCode, $previous);
$ex->statusCode = $statusCode;
$ex->method = $method;
$ex->uri = $uri;
$ex->traceId = $traceId;
$ex->responseHeaders = $headers;
$ex->responseBody = $body;
$ex->networkError = false;
return $ex;
}
public static function networkError(
string $message,
string $method,
string $uri,
string $traceId = '',
?\Throwable $previous = null
): self {
$ex = new self($message, 0, $previous);
$ex->statusCode = null;
$ex->method = $method;
$ex->uri = $uri;
$ex->traceId = $traceId;
$ex->networkError = true;
return $ex;
}
public function isNetworkError(): bool
{
return $this->networkError;
}
public function getStatusCode(): ?int
{
return $this->statusCode;
}
public function getMethod(): string
{
return $this->method;
}
public function getUri(): string
{
return $this->uri;
}
public function getTraceId(): string
{
return $this->traceId;
}
/**
* @return array<string,string>
*/
public function getResponseHeaders(): array
{
return $this->responseHeaders;
}
public function getResponseBody(): string
{
return $this->responseBody;
}
}
/**
* 签名器接口:返回应附加的头(如 Authorization、X-Signature 等)
*/
interface SignerInterface
{
/**
* @param array<string,string> $headers
* @return array<string,string> 返回要合并到请求中的签名相关头
*/
public function sign(string $method, string $uri, array $headers, ?string $body): array;
}
/**
* 可插拔的 TraceId 生成器
*/
interface TraceIdProviderInterface
{
public function getTraceId(): string;
}
/**
* 默认 UUIDv4 TraceId 生成器
*/
final class UuidV4TraceIdProvider implements TraceIdProviderInterface
{
public function getTraceId(): string
{
$data = random_bytes(16);
// Version 4
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
// Variant RFC 4122
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}
/**
* 示例:HMAC-SHA256 签名器(可选)
*
* 计算规则(仅示例,可按需替换):
* date = gmdate('Ymd\THis\Z')
* payload = method + "\n" + uri + "\n" + sha256(body)
* signature = base64(hmac_sha256(payload, secret))
* headers:
* X-Date: date
* X-Signature: signature
* X-Key-Id: keyId
*/
final class HmacSha256Signer implements SignerInterface
{
public function __construct(
private readonly string $keyId,
private readonly string $secret
) {
if ($this->keyId === '' || $this->secret === '') {
throw new RuntimeException('Invalid HMAC credentials.');
}
}
/**
* @param array<string,string> $headers
* @return array<string,string>
*/
public function sign(string $method, string $uri, array $headers, ?string $body): array
{
$date = gmdate('Ymd\THis\Z');
$content = $body ?? '';
$hash = hash('sha256', $content, true);
$payload = sprintf("%s\n%s\n%s", strtoupper($method), $uri, bin2hex($hash));
$sig = base64_encode(hash_hmac('sha256', $payload, $this->secret, true));
return [
'X-Date' => $date,
'X-Signature' => $sig,
'X-Key-Id' => $this->keyId,
];
}
}
命名空间设计思路
类结构设计 rationale
主要方法功能说明
使用注意事项
使用的PHP特性
设计模式应用
性能优化考虑
示例用法(简要):
基本 GET: $client = new RestHttpClient($logger); $resp = $client->get('/users', ['page' => 1], [], ['base_uri' => 'https://api.example.com']); $data = $resp->json();
POST JSON 并启用签名与自定义超时: $client = new RestHttpClient($logger, new HmacSha256Signer($keyId, $secret), null, [], ['auto_sign' => true]); $resp = $client->post('/orders', [], [ 'base_uri' => 'https://api.vendor.com', 'json' => ['sku' => 'ABC', 'qty' => 2], 'timeout_ms' => 3000, ]);
文件上传(multipart): $file = new CURLFile('/path/to/file.pdf', 'application/pdf', 'contract.pdf'); $resp = $client->post('/upload', [], [ 'base_uri' => 'https://files.example.com', 'multipart' => [ ['name' => 'file', 'contents' => $file], ['name' => 'meta', 'contents' => json_encode(['category' => 'contract'])], ], ]);
<?php
declare(strict_types=1);
namespace App\Domain\Order\Validation;
use App\Domain\Inventory\InventoryServiceInterface;
use App\Domain\Promotion\PromotionServiceInterface;
use App\Domain\Shipping\ShippingServiceInterface;
use App\Domain\User\PermissionServiceInterface;
use App\Domain\Order\DTO\OrderDTO;
use App\Domain\Order\DTO\OrderItemDTO;
use App\Domain\Order\DTO\OrderPromotionDTO;
use App\Domain\Order\DTO\OrderAddressDTO;
use Psr\Log\LoggerInterface;
/**
* 订单校验失败原因枚举(统一错误码)
*/
enum OrderValidationErrorCode: string
{
case CART_EMPTY = 'CART_EMPTY';
case QUANTITY_INVALID = 'QUANTITY_INVALID';
case ITEM_NOT_FOUND = 'ITEM_NOT_FOUND';
case ITEM_INACTIVE = 'ITEM_INACTIVE';
case ITEM_OUT_OF_STOCK = 'ITEM_OUT_OF_STOCK';
case ITEM_PURCHASE_LIMIT_EXCEEDED = 'ITEM_PURCHASE_LIMIT_EXCEEDED';
case USER_PERMISSION_DENIED = 'USER_PERMISSION_DENIED';
case PROMO_INVALID = 'PROMO_INVALID';
case PROMO_EXPIRED = 'PROMO_EXPIRED';
case PROMO_NOT_APPLICABLE = 'PROMO_NOT_APPLICABLE';
case PROMO_CONFLICT = 'PROMO_CONFLICT';
case COUPON_USAGE_LIMIT_REACHED = 'COUPON_USAGE_LIMIT_REACHED';
case MIN_ORDER_AMOUNT_NOT_MET = 'MIN_ORDER_AMOUNT_NOT_MET';
case MAX_ORDER_AMOUNT_EXCEEDED = 'MAX_ORDER_AMOUNT_EXCEEDED';
case ADDRESS_INVALID = 'ADDRESS_INVALID';
case ADDRESS_RESTRICTED_REGION = 'ADDRESS_RESTRICTED_REGION';
case SHIPPING_RULE_VIOLATION = 'SHIPPING_RULE_VIOLATION';
case PRICE_CHANGED = 'PRICE_CHANGED';
case UNKNOWN_ERROR = 'UNKNOWN_ERROR';
}
/**
* 订单校验结果对象
*
* 用于聚合各项校验结果,提供统一结构输出。
*/
final class OrderValidationResult
{
/** @var bool 是否可下单(无错误) */
private bool $isValid = true;
/** @var array<int, array{code:OrderValidationErrorCode,message:string,context:array}> 错误列表 */
private array $errors = [];
/** @var array<int, array{code:OrderValidationErrorCode,message:string,context:array}> 警告列表(不阻断下单) */
private array $warnings = [];
/**
* @var array<int, array{
* skuId:int,
* quantity:int,
* unitPrice:float,
* name?:string
* }> 规格化商品行(可用于后续计算)
*/
private array $normalizedItems = [];
/** @var float 商品小计(不含运费与优惠) */
private float $subtotal = 0.0;
/** @var float 优惠总额(正值表示减少的金额) */
private float $discountTotal = 0.0;
/** @var float 运费(正值) */
private float $shippingFee = 0.0;
/** @var mixed 已应用的促销信息(业务自定义结构) */
private mixed $appliedPromotion = null;
public function addError(OrderValidationErrorCode $code, string $message, array $context = []): void
{
$this->isValid = false;
$this->errors[] = [
'code' => $code,
'message' => $message,
'context' => $context,
];
}
public function addWarning(OrderValidationErrorCode $code, string $message, array $context = []): void
{
$this->warnings[] = [
'code' => $code,
'message' => $message,
'context' => $context,
];
}
/**
* @param array<int, array{skuId:int,quantity:int,unitPrice:float,name?:string}> $items
*/
public function setNormalizedItems(array $items): void
{
$this->normalizedItems = $items;
$this->subtotal = 0.0;
foreach ($items as $item) {
$this->subtotal += $item['unitPrice'] * $item['quantity'];
}
}
public function setSubtotal(float $subtotal): void
{
$this->subtotal = max(0.0, $subtotal);
}
public function setDiscountTotal(float $discountTotal): void
{
$this->discountTotal = max(0.0, $discountTotal);
}
public function setShippingFee(float $shippingFee): void
{
$this->shippingFee = max(0.0, $shippingFee);
}
public function setAppliedPromotion(mixed $appliedPromotion): void
{
$this->appliedPromotion = $appliedPromotion;
}
public function isValid(): bool
{
return $this->isValid;
}
/**
* @return array<int, array{code:OrderValidationErrorCode,message:string,context:array}>
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* @return array<int, array{code:OrderValidationErrorCode,message:string,context:array}>
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* @return array<int, array{skuId:int,quantity:int,unitPrice:float,name?:string}>
*/
public function getNormalizedItems(): array
{
return $this->normalizedItems;
}
public function getSubtotal(): float
{
return $this->subtotal;
}
public function getDiscountTotal(): float
{
return $this->discountTotal;
}
public function getShippingFee(): float
{
return $this->shippingFee;
}
public function getAppliedPromotion(): mixed
{
return $this->appliedPromotion;
}
/**
* 合并另一个校验结果(错误/警告/金额等)
*/
public function merge(self $other): void
{
foreach ($other->getErrors() as $error) {
$this->addError($error['code'], $error['message'], $error['context']);
}
foreach ($other->getWarnings() as $warning) {
$this->addWarning($warning['code'], $warning['message'], $warning['context']);
}
// 按需覆盖或累计金额信息
if ($other->getNormalizedItems() !== []) {
$this->setNormalizedItems($other->getNormalizedItems());
}
// 金额字段采用加总(促销与运费由各自模块计算后累加)
$this->setDiscountTotal($this->getDiscountTotal() + $other->getDiscountTotal());
$this->setShippingFee($this->getShippingFee() + $other->getShippingFee());
// 促销对象以最后一次有效设置为准(可根据业务调整合并策略)
if ($other->getAppliedPromotion() !== null) {
$this->setAppliedPromotion($other->getAppliedPromotion());
}
}
/**
* 便于序列化/日志输出
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'isValid' => $this->isValid(),
'errors' => array_map(
static fn ($e) => ['code' => $e['code']->value, 'message' => $e['message'], 'context' => $e['context']],
$this->errors
),
'warnings' => array_map(
static fn ($w) => ['code' => $w['code']->value, 'message' => $w['message'], 'context' => $w['context']],
$this->warnings
),
'normalizedItems' => $this->normalizedItems,
'subtotal' => $this->subtotal,
'discountTotal' => $this->discountTotal,
'shippingFee' => $this->shippingFee,
'appliedPromotion' => $this->appliedPromotion,
];
}
}
/**
* 订单校验器
*
* 在电商下单业务前进行商品状态、库存、限购、用户权限、优惠与运费规则的全面校验,
* 以减少后续事务回滚与售后纠纷。
*/
final class OrderValidator
{
public function __construct(
private readonly InventoryServiceInterface $inventoryService,
private readonly PromotionServiceInterface $promotionService,
private readonly PermissionServiceInterface $permissionService,
private readonly ShippingServiceInterface $shippingService,
private readonly ?LoggerInterface $logger = null
) {
}
/**
* 校验商品状态、库存与限购、用户购买权限
*
* @param int $userId
* @param array<int, OrderItemDTO> $items 订单商品项(DTO需至少包含 skuId、quantity、expectedUnitPrice)
* @param int|null $warehouseId 仓库/发货地,可选
* @return OrderValidationResult
*/
public function validateItems(int $userId, array $items, ?int $warehouseId = null): OrderValidationResult
{
$result = new OrderValidationResult();
if ($items === []) {
$result.addError(OrderValidationErrorCode::CART_EMPTY, '购物车为空');
return $result;
}
// 归并相同 SKU 的数量,过滤异常数量
$skuQtyMap = [];
$originalUnitPrice = []; // 记录用户侧期望单价用于价格变更提示
foreach ($items as $idx => $item) {
// DTO 形状假定:OrderItemDTO::getSkuId():int, ::getQuantity():int, ::getExpectedUnitPrice():float, ::getName():?string
$skuId = $item->getSkuId();
$quantity = $item->getQuantity();
if ($quantity <= 0) {
$result->addError(
OrderValidationErrorCode::QUANTITY_INVALID,
sprintf('商品数量不合法(SKU:%d)', $skuId),
['skuId' => $skuId, 'quantity' => $quantity, 'index' => $idx]
);
continue;
}
$skuQtyMap[$skuId] = ($skuQtyMap[$skuId] ?? 0) + $quantity;
$originalUnitPrice[$skuId] = $item->getExpectedUnitPrice();
}
if ($result->getErrors() !== []) {
// 若存在非法数量,提前返回(也可继续校验其他项,视业务决定)
return $result;
}
// 用户权限校验(例如:黑名单、资质限制、会员等级限制等)
// 假定返回形状:['allowed' => bool, 'deniedSkus' => array<int, string>]
$permission = $this->permissionService->canPurchase($userId, array_keys($skuQtyMap));
if (!($permission['allowed'] ?? true)) {
foreach ($permission['deniedSkus'] ?? [] as $skuId => $reason) {
$result->addError(
OrderValidationErrorCode::USER_PERMISSION_DENIED,
sprintf('用户无权购买该商品(SKU:%d)', $skuId),
['skuId' => $skuId, 'reason' => $reason]
);
}
}
// 库存与商品状态校验(批量查询以减少调用次数)
// 假定返回:array<int, array{found:bool,active:bool,available:int,purchaseLimit:?int,unitPrice:float,name?:string}>
$availability = $this->inventoryService->inspectItems($skuQtyMap, $warehouseId);
$normalizedItems = [];
foreach ($skuQtyMap as $skuId => $needQty) {
$info = $availability[$skuId] ?? null;
if ($info === null || ($info['found'] ?? false) !== true) {
$result->addError(
OrderValidationErrorCode::ITEM_NOT_FOUND,
sprintf('商品不存在或已下架(SKU:%d)', $skuId),
['skuId' => $skuId]
);
continue;
}
if (($info['active'] ?? false) !== true) {
$result->addError(
OrderValidationErrorCode::ITEM_INACTIVE,
sprintf('商品已禁售或未上架(SKU:%d)', $skuId),
['skuId' => $skuId]
);
continue;
}
$available = (int)($info['available'] ?? 0);
if ($available < $needQty) {
$result->addError(
OrderValidationErrorCode::ITEM_OUT_OF_STOCK,
sprintf('库存不足(SKU:%d,需:%d,余:%d)', $skuId, $needQty, $available),
['skuId' => $skuId, 'need' => $needQty, 'available' => $available]
);
continue;
}
$limit = $info['purchaseLimit'] ?? null;
if (is_int($limit) && $needQty > $limit) {
$result->addError(
OrderValidationErrorCode::ITEM_PURCHASE_LIMIT_EXCEEDED,
sprintf('超过限购数量(SKU:%d,限购:%d,提交:%d)', $skuId, $limit, $needQty),
['skuId' => $skuId, 'limit' => $limit, 'submitted' => $needQty]
);
continue;
}
$systemPrice = (float)($info['unitPrice'] ?? 0.0);
$expectedPrice = (float)($originalUnitPrice[$skuId] ?? $systemPrice);
if ($systemPrice > 0 && abs($systemPrice - $expectedPrice) >= 0.0001) {
// 单价变化提示为警告,不阻断下单(最终以下单时价为准)
$result->addWarning(
OrderValidationErrorCode::PRICE_CHANGED,
sprintf('商品价格有变动(SKU:%d,原:%.2f,现:%.2f)', $skuId, $expectedPrice, $systemPrice),
['skuId' => $skuId, 'expected' => $expectedPrice, 'current' => $systemPrice]
);
}
$normalizedItems[] = [
'skuId' => $skuId,
'quantity' => $needQty,
'unitPrice' => $systemPrice,
'name' => $info['name'] ?? null,
];
}
// 若存在错误,normalizedItems 仅包含通过项;调用方可根据 isValid 决定是否继续
if ($normalizedItems !== []) {
$result->setNormalizedItems($normalizedItems);
}
// 记录日志(可选)
if ($this->logger) {
$this->logger->info('OrderValidator::validateItems', [
'userId' => $userId,
'warehouseId' => $warehouseId,
'result' => $result->toArray(),
]);
}
return $result;
}
/**
* 校验优惠券、满减与促销规则
*
* @param int $userId
* @param array<int, array{skuId:int,quantity:int,unitPrice:float,name?:string}> $normalizedItems 来自 validateItems 的规格化商品项
* @param OrderPromotionDTO|null $promotionDTO 促销/优惠券 DTO(可为空)
* @param float|null $cartSubtotal 商品小计(不含运费),若为空则根据 normalizedItems 计算
* @return OrderValidationResult
*/
public function validatePromotion(
int $userId,
array $normalizedItems,
?OrderPromotionDTO $promotionDTO,
?float $cartSubtotal = null
): OrderValidationResult {
$result = new OrderValidationResult();
if ($normalizedItems === []) {
// 空购物车或前置校验未通过时,直接返回
$result->addError(OrderValidationErrorCode::CART_EMPTY, '无可参与促销的商品项');
return $result;
}
$subtotal = $cartSubtotal ?? array_reduce(
$normalizedItems,
static fn (float $carry, array $row) => $carry + $row['unitPrice'] * $row['quantity'],
0.0
);
$result->setSubtotal($subtotal);
if ($promotionDTO === null) {
// 没有促销则视为通过
return $result;
}
// 假定返回:[
// 'valid'=>bool,
// 'reasonCode'=>string|null,
// 'message'=>string|null,
// 'discountTotal'=>float,
// 'applied'=>mixed,
// 'conflicts'=>array<int, string>,
// 'requirements'=>array{minAmount?:float,maxAmount?:float}
// ]
$validation = $this->promotionService->validate($userId, $normalizedItems, $promotionDTO, $subtotal);
if (!($validation['valid'] ?? false)) {
$mapped = $this->mapPromotionReasonToEnum((string)($validation['reasonCode'] ?? ''));
$result->addError(
$mapped,
(string)($validation['message'] ?? '促销不可用'),
[
'reasonCode' => $validation['reasonCode'] ?? null,
'requirements' => $validation['requirements'] ?? [],
]
);
// 冲突信息以警告形式提示
foreach (($validation['conflicts'] ?? []) as $conflict) {
$result->addWarning(
OrderValidationErrorCode::PROMO_CONFLICT,
'存在促销/优惠冲突:' . (string)$conflict,
['conflict' => $conflict]
);
}
return $result;
}
// 有效促销
$discountTotal = (float)($validation['discountTotal'] ?? 0.0);
$result->setDiscountTotal($discountTotal);
$result->setAppliedPromotion($validation['applied'] ?? null);
// 冲突即使有效也提示
foreach (($validation['conflicts'] ?? []) as $conflict) {
$result->addWarning(
OrderValidationErrorCode::PROMO_CONFLICT,
'存在促销/优惠冲突:' . (string)$conflict,
['conflict' => $conflict]
);
}
if ($this->logger) {
$this->logger->info('OrderValidator::validatePromotion', [
'userId' => $userId,
'subtotal' => $subtotal,
'result' => $result->toArray(),
]);
}
return $result;
}
/**
* 校验收货地址与运费规则
*
* @param int $userId
* @param array<int, array{skuId:int,quantity:int,unitPrice:float,name?:string}> $normalizedItems
* @param OrderAddressDTO $addressDTO
* @return OrderValidationResult
*/
public function validateAddress(
int $userId,
array $normalizedItems,
OrderAddressDTO $addressDTO
): OrderValidationResult {
$result = new OrderValidationResult();
if ($normalizedItems === []) {
$result->addError(OrderValidationErrorCode::CART_EMPTY, '无可计算运费的商品项');
return $result;
}
// 假定返回:[
// 'valid'=>bool,
// 'errors'=>array<int, array{code:string,message:string,context:array}>,
// 'shippingFee'=>float
// ]
$validation = $this->shippingService->validateAddress($userId, $normalizedItems, $addressDTO);
if (!($validation['valid'] ?? false)) {
foreach (($validation['errors'] ?? []) as $err) {
$result->addError(
$this->mapAddressReasonToEnum((string)($err['code'] ?? '')),
(string)($err['message'] ?? '地址不可用'),
(array)($err['context'] ?? [])
);
}
// 若服务未返回错误明细,则补充一个通用错误
if ($validation['errors'] === [] || $validation['errors'] === null) {
$result->addError(OrderValidationErrorCode::ADDRESS_INVALID, '地址不可用或不完整');
}
return $result;
}
$shippingFee = (float)($validation['shippingFee'] ?? 0.0);
$result->setShippingFee($shippingFee);
if ($this->logger) {
$this->logger->info('OrderValidator::validateAddress', [
'userId' => $userId,
'result' => $result->toArray(),
]);
}
return $result;
}
/**
* 全量校验:商品、促销、地址
*
* @param OrderDTO $orderDTO
* @return OrderValidationResult
*/
public function validateAll(OrderDTO $orderDTO): OrderValidationResult
{
$final = new OrderValidationResult();
$userId = $orderDTO->getUserId();
$items = $orderDTO->getItems();
$warehouseId = $orderDTO->getWarehouseId();
$promotionDTO = $orderDTO->getPromotion();
$addressDTO = $orderDTO->getAddress();
// 1) 商品项校验
$itemsResult = $this->validateItems($userId, $items, $warehouseId);
$final->merge($itemsResult);
$normalizedItems = $itemsResult->getNormalizedItems();
if ($normalizedItems === []) {
// 商品校验失败,无需继续
return $final;
}
// 2) 地址与运费校验
$addrResult = $this->validateAddress($userId, $normalizedItems, $addressDTO);
$final->merge($addrResult);
if (!$addrResult->isValid()) {
return $final;
}
// 3) 促销校验(使用规格化商品小计)
$subtotal = $itemsResult->getSubtotal();
$promoResult = $this->validatePromotion($userId, $normalizedItems, $promotionDTO, $subtotal);
$final->merge($promoResult);
// 最终结果已在 merge 中维护 isValid 与金额字段
if ($this->logger) {
$this->logger->info('OrderValidator::validateAll', [
'orderId' => $orderDTO->getClientOrderId() ?? null,
'userId' => $userId,
'result' => $final->toArray(),
]);
}
return $final;
}
/**
* 将促销服务返回的 reasonCode 映射为统一枚举
*/
private function mapPromotionReasonToEnum(string $reasonCode): OrderValidationErrorCode
{
return match ($reasonCode) {
'EXPIRED' => OrderValidationErrorCode::PROMO_EXPIRED,
'NOT_APPLICABLE' => OrderValidationErrorCode::PROMO_NOT_APPLICABLE,
'USAGE_LIMIT' => OrderValidationErrorCode::COUPON_USAGE_LIMIT_REACHED,
'MIN_AMOUNT' => OrderValidationErrorCode::MIN_ORDER_AMOUNT_NOT_MET,
'MAX_AMOUNT' => OrderValidationErrorCode::MAX_ORDER_AMOUNT_EXCEEDED,
'CONFLICT' => OrderValidationErrorCode::PROMO_CONFLICT,
'INVALID' => OrderValidationErrorCode::PROMO_INVALID,
default => OrderValidationErrorCode::PROMO_INVALID,
};
}
/**
* 将地址/运费服务返回的错误码映射为统一枚举
*/
private function mapAddressReasonToEnum(string $reasonCode): OrderValidationErrorCode
{
return match ($reasonCode) {
'RESTRICTED_REGION' => OrderValidationErrorCode::ADDRESS_RESTRICTED_REGION,
'RULE_VIOLATION' => OrderValidationErrorCode::SHIPPING_RULE_VIOLATION,
'INVALID' => OrderValidationErrorCode::ADDRESS_INVALID,
default => OrderValidationErrorCode::ADDRESS_INVALID,
};
}
}
命名空间设计思路
类结构设计 rationale
主要方法功能说明
使用注意事项
使用的PHP特性
设计模式应用
性能优化考虑
用最少输入,生成可直接投入项目的高质量 PHP 类代码,帮助个人与团队快速起步、稳定交付、轻松维护。
从需求描述快速生成服务类、控制器、数据处理类;自动补齐注释与命名空间;减少样板代码,专注核心业务实现。
用自然语言描述功能,一键产出可用类骨架,迅速拼装原型或小型产品;迭代优化命名与结构,缩短从想法到上线的时间。
以生成的标准类作为团队规范样例,统一分层与命名;为新成员提供可复用模板,降低上手成本并提升评审效率。
将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。
把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。
在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。
半价获取高级提示词-优惠即将到期