feat(product): 新增充值产品相关数据结构和接口

- 添加 RechargeProduct、ProductItem、RechargeEffect 等实体和值对象
- 实现充值产品相关的数据传输对象 (DTO)
- 定义充值产品仓库接口并提供具体实现
- 增加相关单元测试
This commit is contained in:
李东云
2025-08-18 16:41:37 +08:00
parent a2fc4cecf8
commit 06dc4a2e65
10 changed files with 496 additions and 1 deletions

View File

@@ -0,0 +1,29 @@
<?php
/**
* AbstractDto.php@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Application\Dto;
use Hyperf\Codec\Json;
use Hyperf\Contract\Jsonable;
use Swoole\ArrayObject;
abstract class AbstractDto extends ArrayObject implements Jsonable
{
public function __toString(): string
{
return $this->toJson();
}
public function toJson(): string
{
return Json::encode($this->toArray());
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* RechargeProductsDto.php@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Application\Dto\Product;
use Singularity\HDK\Pay\Application\Dto\AbstractDto;
use Singularity\HDK\Pay\Domain\Product\Aggregate\Entities\ProductItem;
use Singularity\HDK\Pay\Domain\Product\Aggregate\RechargeProduct;
final class RechargeProductsDto extends AbstractDto
{
public function __construct(RechargeProduct $product)
{
parent::__construct(array_filter([
'one_time' => $this->parseProductItem($product->getOneTime()),
'renew' => $this->parseProductItem($product->getRenew()),
'plan' => array_map(
fn(ProductItem $item) => $this->parseProductItem($item),
$product->getPlans(),
),
'package' => array_map(
fn(ProductItem $item) => $this->parseProductItem($item),
$product->getPackages(),
),
]));
}
private function parseProductItem(?ProductItem $product): array
{
if (is_null($product)) {
return [];
}
$effect = $product->effect;
$price = $product->unitPrice;
return [
'id' => $product->id,
'name' => $product->description,
'currency' => $price->getCurrency()->getCode(),
'price' => (float)$price->getAmount(),
'total_points' => $effect->getPointTotal(),
'bonus_rate_pct' => (float)(bcmul(
num1: $effect->getPointTotal() == 0
? '0'
: bcdiv(
(string)$effect->pointBonus,
(string)$effect->getPointTotal(),
3,
),
num2: '100',
scale: 0,
)),
'point' => [
'total' => $effect->getPointTotal(),
'number' => $effect->pointBasic,
'bonus' => $effect->pointBonus,
],
];
}
}

View File

@@ -7,7 +7,10 @@ namespace Singularity\HDK\Pay;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\Framework\Logger\StdoutLogger;
use Singularity\HDK\Pay\Domain\Account\Repository\AccountRepoInterface;
use Singularity\HDK\Pay\Domain\Product\Repository\ExchangeRepoInterface;
use Singularity\HDK\Pay\Domain\Product\Repository\RechargeProductRepoInterface;
use Singularity\HDK\Pay\Infrastructure\Repository\AccountBalanceRepo;
use Singularity\HDK\Pay\Infrastructure\Repository\ProductRepo;
/**
* ConfigProvider.php@HyperfAuth
@@ -27,6 +30,8 @@ class ConfigProvider
// Repository
AccountRepoInterface::class => AccountBalanceRepo::class,
ExchangeRepoInterface::class => ProductRepo::class,
RechargeProductRepoInterface::class => ProductRepo::class,
],
// 合并到 config/autoload/annotations.php 文件
'annotations' => [

View File

@@ -0,0 +1,27 @@
<?php
/**
* RechargeProduct.php@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Domain\Product\Aggregate\Entities;
use Money\Money;
use Singularity\HDK\Pay\Domain\Product\Aggregate\ValueObject\RechargeEffect;
use Singularity\HDK\Pay\Domain\Product\Enum\ProductType;
final class ProductItem
{
public function __construct(
public int $id,
public string $description,
public Money $unitPrice,
public ProductType $productType,
public RechargeEffect $effect,
) {}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* RechargeProduct.php@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Domain\Product\Aggregate;
use Singularity\HDK\Pay\Domain\AggregateRoot;
use Singularity\HDK\Pay\Domain\Product\Aggregate\Entities\ProductItem;
/**
* App\Domain\Product\Aggregate\RechargeProduct@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*
* @property-read ProductItem[] $plans
* @property-read ProductItem[] $packages
* @property-read ProductItem $oneTime
*/
final class RechargeProduct extends AggregateRoot
{
public function __construct(
private readonly ?ProductItem $oneTime,
private readonly ?ProductItem $renew = null,
private readonly array $plans = [],
private readonly array $packages = [],
) {}
/**
* @param array $idList
* @return array{'id': int, 'quantity': int, 'detail': ProductItem}
*/
public function selectItemList(array $idList): array
{
$result = [];
/** @var ProductItem $item */
foreach ([$this->oneTime, $this->renew, ...$this->plans, ...$this->packages] as $item) {
if (in_array($item->id, $idList)) {
$result[] = $item;
}
}
return $result;
}
/**
* @return ProductItem[]
*/
public function getPlans(): array
{
return $this->plans;
}
public function getRenew(): ?ProductItem
{
return $this->renew;
}
/**
* @return ProductItem[]
*/
public function getPackages(): array
{
return $this->packages;
}
public function getOneTime(): ?ProductItem
{
return $this->oneTime;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* RechargeEffect.php@LuxDesign
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Domain\Product\Aggregate\ValueObject;
use Carbon\Carbon;
use Hyperf\Framework\Exception\NotImplementedException;
use Singularity\HDK\Pay\Domain\Account\Enum\PointType;
final class RechargeEffect
{
private float $pointTotal;
public function __construct(
public PointType $pointType,
public float $pointBasic,
public float $pointBonus,
public ?Carbon $expiredAt = null,
public ?string $version = null,
) {
$this->pointTotal = (float)bcadd((string)$pointBasic, (string)$pointBonus, 4);
}
public function getPointTotal(): float
{
return $this->pointTotal;
}
/**
* @template TPoints of array{'total': float, 'basic': float, 'bonus': float}
* @return array{'points'?: TPoints, 'version'?: string, 'expired_at'?: Carbon}
*/
public function toArray(): array
{
return array_filter([
'points' => $this->pointTotal == 0
? null
: [
'total' => $this->pointTotal,
'basic' => $this->pointBasic,
'bonus' => $this->pointBonus,
],
'version' => $this->version,
'expired_at' => $this->expiredAt,
]);
}
/**
* @param PointType $pointType
* @param string $range
* @return Carbon
*/
private static function parseExpiredAt(PointType $pointType, string $range): Carbon
{
if ($pointType !== PointType::EMA) {
throw new NotImplementedException();
}
$now = Carbon::now();
preg_match('/^(\d+)(\w)$/', $range, $matches);
$quantity = $matches[1]; // 数字部分
$unit = $matches[2]; // 单位部分
return match ($unit) {
'Y' => $now->addYears($quantity),
'm' => $now->addMonths($quantity),
'd' => $now->addDays($quantity),
'H' => $now->addHours($quantity),
'i' => $now->addMinutes($quantity),
's' => $now->addSeconds($quantity),
default => $now
};
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* RechargeProductRepoInterface.php@LuxPay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/5
*/
namespace Singularity\HDK\Pay\Domain\Product\Repository;
use Singularity\HDK\Pay\Domain\Product\Aggregate\Entities\ProductItem;
use Singularity\HDK\Pay\Domain\Product\Aggregate\RechargeProduct;
interface RechargeProductRepoInterface
{
/**
* @return RechargeProduct
*/
public function findLuxPointProduct(): RechargeProduct;
/**
* @param string $currentVersion
* @return RechargeProduct
*/
public function findEmaProduct(string $currentVersion): RechargeProduct;
/**
* @param int $id
* @return ProductItem
*/
public function findProductItem(int $id): ProductItem;
/**
* @param int[] $idList
* @return ProductItem[]
*/
public function selectProductList(array $idList): array;
}

View File

@@ -0,0 +1,133 @@
<?php
/**
* ProductRepo.php@Pay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/18
*/
declare(strict_types=1);
namespace Singularity\HDK\Pay\Infrastructure\Repository;
use GuzzleHttp\Exception\GuzzleException;
use Hyperf\Codec\Json;
use Money\Currency;
use Money\Money;
use Singularity\HDK\Pay\Domain\Account\Enum\PointType;
use Singularity\HDK\Pay\Domain\Product\Aggregate\Entities\ProductItem;
use Singularity\HDK\Pay\Domain\Product\Aggregate\RechargeProduct;
use Singularity\HDK\Pay\Domain\Product\Aggregate\ValueObject\RechargeEffect;
use Singularity\HDK\Pay\Domain\Product\Enum\ProductType;
use Singularity\HDK\Pay\Domain\Product\Repository\ExchangeRepoInterface;
use Singularity\HDK\Pay\Domain\Product\Repository\RechargeProductRepoInterface;
final class ProductRepo extends AbstractRepo implements RechargeProductRepoInterface, ExchangeRepoInterface
{
/**
* @inheritDoc
*/
public function findLuxPointProduct(): RechargeProduct
{
$response = $this->requestService->requestGet(
url: "/rpc/v2/products/lux-point",
options: [
'headers' => $this->headerBuilder(),
],
);
$content = $response->getBody()->getContents();
$result = Json::decode($content);
return new RechargeProduct(
oneTime: new ProductItem(
id: $result['one_time']['id'],
description: $result['one_time']['name'],
unitPrice: new Money(
amount: $result['one_time']['price'],
currency: new Currency($result['one_time']['currency']),
),
productType: ProductType::oneTime,
effect: new RechargeEffect(
pointType: PointType::LuxPoint,
pointBasic: $result['one_time']['point']['number'],
pointBonus: $result['one_time']['point']['bonus'],
),
),
packages: array_map(fn(array $item) => new ProductItem(
id: $item['id'],
description: $item['name'],
unitPrice: new Money(
amount: $item['price'],
currency: new Currency($item['currency']),
),
productType: ProductType::pack,
effect: new RechargeEffect(
pointType: PointType::LuxPoint,
pointBasic: $item['point']['number'],
pointBonus: $item['point']['bonus'],
),
), $result['package']),
);
}
/**
* @inheritDoc
*/
public function findEmaProduct(string $currentVersion,
): RechargeProduct {
$response = $this->requestService->requestGet(
url: "/rpc/v2/products/ema",
options: [
'headers' => $this->headerBuilder(),
],
);
$content = $response->getBody()->getContents();
$result = Json::decode($content);
return new RechargeProduct(
oneTime: $result['one_time'],
renew: $result['renew'],
plans: $result['plan'],
);
}
/**
* @inheritDoc
*/
public function findProductItem(int $id): ProductItem
{
// TODO: Implement findProductItem() method.
}
/**
* @inheritDoc
*/
public function selectProductList(array $idList): array
{
// TODO: Implement selectProductList() method.
}
/**
* @param PointType $source
* @param PointType $target
* @return float
* @throws GuzzleException
*/
public function getRate(PointType $source, PointType $target): float
{
$response = $this->requestService->requestGet(
url: "/rpc/v2/products/$target->value/exchange-rate/$source->value",
options: [
'headers' => $this->headerBuilder(),
],
);
$content = $response->getBody()->getContents();
$result = Json::decode($content);
return $result['rate'];
}
}

View File

@@ -21,4 +21,4 @@ it('can query point rate', function () {
$rate = $repo->getRate($from, $to);
expect($rate)->toBeFloat();
})->only();
});

View File

@@ -0,0 +1,26 @@
<?php
/**
* QueryProductsTest.php@Pay
*
* @author 李东云 <Dongyun.Li@LuxCreo.Ai>
* Powered by PhpStorm
* Created on 2025/8/18
*/
use Singularity\HDK\Pay\Domain\Product\Aggregate\Entities\ProductItem;
use Singularity\HDK\Pay\Domain\Product\Aggregate\RechargeProduct;
use Singularity\HDK\Pay\Infrastructure\Repository\ProductRepo;
use function Hyperf\Support\make;
it('should can query LuxPoint products', function () {
$repo = make(ProductRepo::class);
$products = $repo->findLuxPointProduct();
var_dump($products);
expect($products)
->toBeInstanceOf(RechargeProduct::class)
->and($products->getOneTime())->toBeInstanceOf(ProductItem::class)
->and($products->getPackages())->each->toBeInstanceOf(ProductItem::class);
})->only();