¥
立即购买

PHP类代码生成器

4 浏览
1 试用
0 购买
Dec 8, 2025更新

本提示词专为PHP开发场景设计,能够根据用户需求生成结构完整、符合编码规范的PHP类代码。通过精确的角色设定和任务分解,确保生成的代码具备清晰的命名空间、完整的类结构、适当的访问修饰符和规范的注释文档。特别适用于Web开发、API接口开发、业务逻辑封装等场景,帮助开发者快速构建高质量的PHP类文件,提升开发效率和代码可维护性。

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;
    }
}

代码说明

  • 命名空间设计思路

    • 放在 App\Security\Jwt 命名空间下,清晰表达安全与JWT领域的职责边界,便于在更大项目中模块化管理。
    • 类为 final,防止在继承中破坏安全关键路径与不变量。如需扩展,建议通过组合或包装器实现。
  • 类结构设计 rationale

    • 构造函数接收密钥、缓存、发行方、受众、默认TTL、刷新TTL、时钟偏移与黑名单前缀,支持在不同环境下灵活配置。
    • 提供 encode、decode、refresh、revoke 核心方法,满足API无状态认证的通用需求。
    • 内部使用私有方法封装签名、解析、校验、编码/解码细节,保证单一职责与易维护。
    • 使用 r_exp 作为刷新窗口的绝对截止时间,避免无限续期导致会话永久化风险。
  • 主要方法功能说明

    • encode(array $customClaims = [], ?int $ttlSeconds = null): 生成HS256签名的JWT;自动填充 iat、exp、jti;可合并自定义claims;根据配置写入iss与aud;写入r_exp作为刷新窗口上限。
    • decode(string $jwt, bool $checkBlacklist = true): 解码并完整校验令牌,包含签名、exp/nbf/iat、iss/aud、黑名单等;返回claims数组。
    • refresh(string $jwt, ?int $newTtlSeconds = null, array $mergeClaims = [], bool $revokeOld = true): 在刷新窗口内(r_exp)刷新令牌,即使原令牌已过期;默认撤销旧令牌;新令牌继承原有claims并重置iat/exp/jti,同时保持r_exp不变以限制绝对寿命。
    • revoke(string $jwt, ?int $ttlSeconds = null): 将令牌加入黑名单。若未显式TTL,则根据剩余有效期或刷新窗口期计算黑名单TTL,确保撤销在必要期间内有效。
  • 使用注意事项

    • secret 请从安全的配置源注入(环境变量、密钥管理服务),不要硬编码到代码库。
    • leeway 应根据基础设施时间同步情况合理设置,通常 30~120 秒。
    • refreshTtl 决定会话的绝对寿命;设置为 0 可禁用刷新窗口(即不写入 r_exp)。
    • revoke 默认根据令牌剩余时间自动计算黑名单TTL;如有更严格需求,可传入覆盖TTL。
    • scope/权限建议作为自定义claims,例如 scope(空格分隔字符串)或 scopes(数组)。服务侧按约定解析。

技术要点

  • 使用的PHP特性

    • declare(strict_types=1) 强类型约束
    • 类型提示与返回类型、只读意图通过final类体现
    • DateTimeImmutable 管理时间,确保不可变与可测试性
    • JSON_THROW_ON_ERROR 保证JSON读写的健壮性
    • random_bytes 生成强随机JTI
    • hash_equals 常量时间对比,抵御计时侧信道
  • 设计模式应用

    • 配置通过构造函数依赖注入(DI),与 PSR-16 CacheInterface 解耦存储实现
    • 单一职责:签名、解析、校验、黑名单分别封装为私有方法
    • 不变式:alg固定为HS256,禁止算法降级;typ固定为JWT
  • 性能优化考虑

    • Base64URL 与 JSON 使用原生函数,避免额外依赖
    • 黑名单使用 PSR-16 缓存(如 Redis/Memcached),通过TTL避免长期堆积
    • 采用紧凑的 claims 编码,避免冗余字段;默认禁用 JSON 转义提升序列化效率
    • 只在必要时访问缓存(decode 可选择是否校验黑名单,降低频繁无状态校验的缓存压力)

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,
        ];
    }
}

代码说明

  • 命名空间设计思路

    • 采用 App\Http 命名空间,表示基础设施层的 HTTP 能力封装,便于在微服务/接口接入场景中统一复用。
    • SignerInterface、TraceIdProviderInterface 作为可插拔组件放在同一命名空间下,降低依赖复杂度,调用者可自由替换实现。
  • 类结构设计 rationale

    • RestHttpClient 是最终类(final),面向使用者提供 GET/POST/PUT/DELETE 以及通用 request 方法,职责清晰:构建请求、控制超时/重试、日志、追踪、签名、错误处理。
    • 统一异常 RestHttpException 含网络/HTTP错误上下文,控制器层可统一捕获并映射业务错误语义。
    • RestHttpResponse 提供最常用的 status/headers/body,并内置 json() 便捷方法。
    • HmacSha256Signer、UuidV4TraceIdProvider 为可选默认实现,示例化可扩展点。
    • 连接池通过 cURL Share 复用连接/DNS/SSL 会话,提升吞吐与减少握手开销。
  • 主要方法功能说明

    • get/post/put/delete:分别封装 HTTP 动词,支持 query/headers/options。
    • request:通用入口,支持 body 四种提交方式(json/form_params/multipart/raw)、签名、重试策略、超时、重定向、错误抛出策略、trace 注入等。
    • buildBody:构建请求体并设置合适的 Content-Type。
    • executeCurl:底层 cURL 执行,开启 keep-alive、HTTP/2(可回退)、压缩、验证 TLS,收集响应头与体。
    • sleepBackoff:指数退避+抖动,避免雪崩。
    • shouldRetryHttp:定义对特定状态码的重试。
    • logHttp/logException:集中日志,包含 method/url/status/attempt/elapsed_ms/trace_id。
  • 使用注意事项

    • 需要启用 ext-curl 扩展;生产环境请确保 CA 证书正确安装(启用 SSL 验证,已默认开启)。
    • multipart 文件上传请使用 CURLFile 实例(安全、现代),不要使用已废弃的 @file 语法。
    • 默认对 4xx/5xx 抛出 RestHttpException;如需返回响应自行判断,请在 options 中设置 throw_on_http_error=false。
    • POST 默认认为非幂等,不会重试;如确认服务端幂等,设置 options['idempotent']=true。
    • base_uri 配置后,传入相对路径会自动拼接;query 参数支持数组,内部按 RFC3986 编码。
    • 连接池为进程内(请求内)连接复用,FPM/多进程环境下不跨进程共享,属于 PHP 运行模型限制。
    • 如需自定义签名逻辑,实现 SignerInterface 并在构造函数或 options 中开启 sign/auto_sign。

技术要点

  • 使用的PHP特性

    • 严格类型声明、readonly 构造参数属性、联合类型/类型提示、命名参数调用、JSON_THROW_ON_ERROR、hrtime 高精度计时。
    • 安全的随机 UUIDv4 生成(random_bytes)。
  • 设计模式应用

    • 策略模式:SignerInterface、TraceIdProviderInterface 可替换实现。
    • 适配/封装:对 cURL 的细节进行封装,暴露统一语义(超时、重试、连接池、错误)。
  • 性能优化考虑

    • cURL Share 复用 DNS 缓存、SSL 会话与连接,减少 TLS/握手成本。
    • HTTP/2 优先、启用压缩、keep-alive。
    • 指数退避+抖动避免重试风暴。
    • 仅在需要时组装与记录字段,日志包含必要指标便于监控与告警。

示例用法(简要):

  • 基本 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类代码

<?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,
        };
    }
}

代码说明

  • 命名空间设计思路

    • 放置在 App\Domain\Order\Validation 命名空间下,体现领域层中的“订单校验”职责。
    • 引用的服务接口分别位于 Inventory、Promotion、Shipping、User 子域,解耦具体实现,便于替换与单元测试。
    • 使用 PSR-3 LoggerInterface 进行可选日志记录。
  • 类结构设计 rationale

    • OrderValidationErrorCode 使用 PHP 原生枚举统一错误码,便于前后端约定与国际化。
    • OrderValidationResult 作为结果聚合对象,统一承载可下单状态、错误/警告列表、规格化商品行以及金额字段(小计、优惠、运费)。提供 merge 方法用于组装分步校验的结果。
    • OrderValidator 关注校验流程编排:validateItems、validatePromotion、validateAddress 分步校验;validateAll 组合调用,输出最终结果。
  • 主要方法功能说明

    • validateItems:批量校验商品的存在与有效状态、库存充足、限购规则以及用户购买权限;返回规格化的商品行与商品小计,并对价格变动给出警告。
    • validatePromotion:校验优惠券/满减等活动的有效性与适用性,映射原因码至统一枚举;输出优惠总额和已应用的促销信息。
    • validateAddress:校验地址合法性与配送限制、运费规则;输出运费金额。
    • validateAll:按“商品 -> 地址 -> 促销”的顺序整合校验结果,避免不必要的服务调用;合并金额与错误信息,输出最终是否可下单。
  • 使用注意事项

    • 本类依赖的 DTO 与服务接口需在各自子域实现,方法注释中已给出最小字段假定形状。
    • validateItems 返回的 normalizedItems 作为后续促销与运费计算的唯一数据来源,确保价格与数量一致性。
    • 促销与运费可能存在交互(例如运费券),此处示例不做跨模块折扣合并,建议在 PromotionService 层统一处理。
    • 本校验为“前置校验”,并不能替代下单环节的最终一致性与并发控制;实际下单仍需二次确认库存与价格。

技术要点

  • 使用的PHP特性

    • declare(strict_types=1) 强类型约束。
    • PHP 8.1+ 枚举用于错误码定义。
    • 只读构造参数(readonly)用于依赖注入服务,提升不可变性与可维护性。
    • 严格类型提示与返回类型,符合现代 PHP 语法。
  • 设计模式应用

    • Facade/Coordinator:OrderValidator 作为流程协调器,统一封装多个领域服务的校验步骤。
    • Value Object:OrderValidationResult 充当结果值对象,聚合校验状态与金额信息。
    • Strategy/DI:通过接口依赖注入具体的库存、促销、权限与配送策略实现。
  • 性能优化考虑

    • 批量库存与状态查询(inspectItems)避免 N+1 调用。
    • 先进行商品项校验,若失败则短路后续促销与地址校验,减少外部服务压力。
    • 促销校验传入已规格化的商品与小计,避免重复计算。
    • 可选日志记录仅在注入 Logger 时启用,避免无谓的 I/O。

示例详情

解决的问题

用最少输入,生成可直接投入项目的高质量 PHP 类代码,帮助个人与团队快速起步、稳定交付、轻松维护。

  • 快速搭建:只需提供类名、功能与使用场景,即可得到结构完整的类代码与配套说明。
  • 质量内置:命名空间清晰、权限设置合理、注释文档齐全,自动对齐主流编码规范。
  • 安全可靠:默认规避过时语法与常见漏洞,杜绝硬编码敏感信息,减少隐藏风险。
  • 易扩展易协作:类职责边界明确、方法设计清晰,便于团队评审、复用与二次开发。
  • 适用广泛:覆盖 Web、API、业务服务、工具库等主流场景,满足从原型到企业级应用的需求。
  • 促成转化:显著缩短搭建时间、降低返工成本、提升评审通过率与交付信心。

适用用户

PHP后端开发工程师

从需求描述快速生成服务类、控制器、数据处理类;自动补齐注释与命名空间;减少样板代码,专注核心业务实现。

全栈/独立开发者

用自然语言描述功能,一键产出可用类骨架,迅速拼装原型或小型产品;迭代优化命名与结构,缩短从想法到上线的时间。

技术负责人/架构师

以生成的标准类作为团队规范样例,统一分层与命名;为新成员提供可复用模板,降低上手成本并提升评审效率。

特征总结

一键生成结构完整的PHP类,自动规划命名空间与访问修饰,开箱即用。
基于业务描述智能设计属性与方法,贴合场景需求,显著减少返工沟通。
自动附带清晰注释与使用说明,方便团队协作与后续维护交接。
内置安全与性能守则,规避敏感硬编码与过时语法,降低线上风险。
面向Web与接口场景快速产出控制器、服务、工具类,复用性强。
支持多轮迭代优化,按需调整命名与结构,快速试错直至满意。
统一代码风格与命名约定,减少差异实现,让项目更易读更易扩展。
自动给出方法职责与注意要点,新成员接手可迅速理解并开始编码。
适配企业级与开源项目规范,缩短评审周期,加快上线与交付。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥20.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 553 tokens
- 3 个可调节参数
{ 类名称 } { 类功能描述 } { 应用场景 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
使用提示词兑换券,低至 ¥ 9.9
了解兑换券 →
限时半价

不要错过!

半价获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59