| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- <?php
- declare(strict_types=1);
- namespace app\miniapi\service;
- use app\miniapi\model\MiniCheckout;
- use app\miniapi\model\MiniBalanceLedger;
- use app\miniapi\http\Fields;
- use app\miniapi\http\Input;
- use app\miniapi\exception\ApiException;
- use plugin\saiadmin\app\model\water\WaterEnterprise;
- use plugin\saiadmin\app\service\WaterPurchaseReceipt;
- use plugin\saiadmin\app\model\water\WaterSku;
- /** Checkout intents become water receipts and rights only after a successful balance payment. */
- final class CheckoutService
- {
- public function __construct(private readonly Access $access)
- {
- }
- public function quote(int $enterprise, array $input): array
- {
- return $this->buildQuote($enterprise, $input, false);
- }
- private function buildQuote(int $enterprise, array $input, bool $lock): array
- {
- $this->access->enterprise($enterprise, true);
- Fields::only($input, ['items']);
- $items = $input['items'] ?? null;
- if (!is_array($items) || !array_is_list($items) || count($items) < 1 || count($items) > 20) {
- throw new ApiException(42201, '请选择1至20种套餐', 422);
- }
- $result = [];
- $seen = [];
- $total = 0;
- $parsed = [];
- foreach ($items as $item) {
- if (!is_array($item)) {
- throw new ApiException(42201, '套餐明细格式不正确', 422);
- }
- Fields::only($item, ['package_id', 'quantity']);
- $id = Fields::id($item, 'package_id');
- $quantity = Input::integer($item['quantity'] ?? null, 'quantity', 1, 100);
- if (isset($seen[$id])) {
- throw new ApiException(42201, '同一套餐请合并数量', 422);
- }
- $seen[$id] = true;
- $parsed[] = ['package_id' => $id, 'quantity' => $quantity];
- }
- $catalog = new Catalog();
- $locked = $lock ? $catalog->lockedDetails(array_keys($seen)) : [];
- foreach ($parsed as $item) {
- $id = $item['package_id'];
- $quantity = $item['quantity'];
- $package = $lock ? $locked[$id] : $catalog->detail($id);
- $amount = $package['price_cents'] * $quantity;
- $total += $amount;
- $result[] = [
- 'package_id' => $id,
- 'name' => $package['name'],
- 'sku_id' => $package['sku_id'],
- 'quantity' => $quantity,
- 'buckets' => $package['buckets'] * $quantity,
- 'unit_price_cents' => $package['price_cents'],
- 'amount_cents' => $amount,
- ];
- }
- usort($result, fn ($a, $b) => $a['package_id'] <=> $b['package_id']);
- return [
- 'items' => $result,
- 'quote_revision' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR)),
- 'subtotal_cents' => $total,
- 'discount_cents' => 0,
- 'amount_cents' => $total,
- 'payment_methods' => ['balance', 'wechat'],
- 'capabilities' => [
- 'payment' => false,
- 'balance' => true,
- 'first_purchase_discount' => false,
- 'invoice_issuance' => false,
- ],
- 'balance_cents' => BalanceService::cents($enterprise),
- 'agreement_url' => config('mini_api.purchase_agreement_url', '') ?: null,
- ];
- }
- public function create(int $enterprise, array $input): array
- {
- $this->access->enterprise($enterprise, true);
- Fields::only($input, [
- 'items',
- 'expected_amount_cents',
- 'quote_revision',
- 'agreement_accepted',
- 'invoice',
- 'request_key',
- ]);
- if (($input['agreement_accepted'] ?? false) !== true) {
- throw new ApiException(42201, '请确认购买与配送服务协议', 422);
- }
- $expected = Input::integer($input['expected_amount_cents'] ?? null, 'expected_amount_cents', 0, PHP_INT_MAX);
- $invoice = $input['invoice'] ?? null;
- if ($invoice !== null) {
- if (!is_array($invoice)) {
- throw new ApiException(42201, '发票资料格式不正确', 422);
- }
- Fields::only($invoice, ['title', 'tax_number', 'email']);
- $invoice = [
- 'title' => Fields::text($invoice, 'title', 120),
- 'tax_number' => Fields::text($invoice, 'tax_number', 30),
- 'email' => Fields::text($invoice, 'email', 120),
- ];
- if (!filter_var($invoice['email'], FILTER_VALIDATE_EMAIL)) {
- throw new ApiException(42201, '发票邮箱格式不正确', 422);
- }
- }
- $key = Fields::text($input, 'request_key', 80);
- $revision = isset($input['quote_revision']) ? Fields::text($input, 'quote_revision', 64) : null;
- return Commands::run($this->access->actor, $key, 'checkout:' . $enterprise, $input, function () use ($enterprise, $input, $expected, $invoice, $revision) {
- $enterpriseRow = $this->access->enterprise($enterprise, true);
- $quote = $this->buildQuote($enterprise, ['items' => $input['items'] ?? null], true);
- if ($quote['amount_cents'] !== $expected || ($revision !== null && !hash_equals($quote['quote_revision'], $revision))) {
- throw new ApiException(40904, '套餐内容或价格已变化,请重新确认', 409);
- }
- $checkout = MiniCheckout::query()->create([
- 'user_id' => $this->access->actor->userId,
- 'enterprise_id' => $enterprise,
- 'code' => 'MP' . date('YmdHis') . bin2hex(random_bytes(6)),
- 'status' => 'pending',
- 'items' => $quote['items'],
- 'address_snapshot' => [
- 'name' => $enterpriseRow->name,
- 'address' => $enterpriseRow->address,
- 'region' => $enterpriseRow->region,
- 'contact' => $enterpriseRow->contact,
- 'phone' => $enterpriseRow->phone,
- ],
- 'invoice' => $invoice,
- 'amount_cents' => $quote['amount_cents'],
- 'created_at' => date('Y-m-d H:i:s'),
- 'expires_at' => date('Y-m-d H:i:s', time() + 1800),
- ]);
- return self::present($checkout);
- }, fn () => $this->access->enterprise($enterprise, true));
- }
- public static function present($checkout): array
- {
- return [
- 'id' => (int) $checkout->id,
- 'code' => $checkout->code,
- 'enterprise_id' => (int) $checkout->enterprise_id,
- 'status' => $checkout->status === 'pending' && $checkout->expires_at <= date('Y-m-d H:i:s') ? 'expired' : $checkout->status,
- 'items' => $checkout->items,
- 'amount_cents' => (int) $checkout->amount_cents,
- 'created_at' => $checkout->created_at,
- 'expires_at' => $checkout->expires_at,
- 'payment_enabled' => true,
- 'payment_method' => $checkout->payment_method,
- 'paid_at' => $checkout->paid_at,
- 'balance_cents' => BalanceService::cents((int) $checkout->enterprise_id),
- ];
- }
- public function detail(int $enterprise, int $id): array
- {
- $this->access->enterprise($enterprise);
- $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->find($id);
- if (!$checkout) {
- throw new ApiException(40401, '订单不存在', 404);
- }
- $address = $checkout->address_snapshot;
- $address['phone'] = WaterPresenter::phone($address['phone']);
- return self::present($checkout) + ['address' => $address];
- }
- public function cancel(int $enterprise, int $id, array $input): array
- {
- $this->access->enterprise($enterprise, true);
- Fields::only($input, ['request_key']);
- return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'checkout.cancel:' . $enterprise . ':' . $id, $input, function () use ($enterprise, $id) {
- $this->access->enterprise($enterprise, true);
- $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->where('id', $id)->lockForUpdate()->first();
- if (!$checkout) {
- throw new ApiException(40401, '订单不存在', 404);
- }
- if (!in_array($checkout->status, ['pending', 'cancelled'], true)) {
- throw new ApiException(40902, '当前订单不能取消', 409);
- }
- $checkout->status = 'cancelled';
- $checkout->save();
- return self::present($checkout);
- }, fn () => $this->access->enterprise($enterprise, true));
- }
- public function pay(int $enterprise, int $id, array $input): array
- {
- $this->access->enterprise($enterprise, true);
- Fields::only($input, ['request_key', 'payment_method']);
- $key = Fields::text($input, 'request_key', 80);
- $method = Fields::choice($input, 'payment_method', ['balance', 'wechat']);
- if ($method === 'wechat') {
- throw new ApiException(50303, '微信支付接口已预留,请先使用企业余额', 503);
- }
- return Commands::run($this->access->actor, $key, 'checkout.pay:' . $enterprise . ':' . $id, $input, function () use ($enterprise, $id, $method, $key) {
- $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->where('id', $id)->lockForUpdate()->first();
- if (!$checkout) {
- throw new ApiException(40401, '订单不存在', 404);
- }
- if ($checkout->status !== 'pending') {
- throw new ApiException(40902, '当前订单不能支付', 409);
- }
- if ($checkout->expires_at <= date('Y-m-d H:i:s')) {
- throw new ApiException(40906, '订单已超期,请重新创建订单', 409);
- }
- $items = $this->snapshotItems($checkout);
- // Receipt writers consistently lock SKU(s), then enterprise, then accounts/balance.
- $skuIds = array_unique(array_column($items, 'sku_id'));
- sort($skuIds, SORT_NUMERIC);
- foreach ($skuIds as $skuId) {
- $sku = WaterSku::query()->where('id', $skuId)->lockForUpdate()->first();
- if (!$sku || (int) $sku->status !== 1) {
- throw new ApiException(40902, '订单中的商品规格已停用或不存在,请联系平台', 409);
- }
- }
- $enterpriseRow = WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->first();
- if (!$enterpriseRow || (int) $enterpriseRow->status !== 1) {
- throw new ApiException(40301, '企业已停用或不存在', 403);
- }
- $balance = BalanceService::locked($enterprise);
- $amount = (int) $checkout->amount_cents;
- $before = (int) $balance->balance_cents;
- if ($before < $amount) {
- throw new ApiException(40905, '企业余额不足', 409, [
- 'balance_cents' => $before,
- 'required_cents' => $amount,
- ]);
- }
- $receipts = new WaterPurchaseReceipt();
- foreach ($items as $item) {
- for ($index = 1; $index <= $item['quantity']; $index++) {
- $receipts->issue(
- $enterprise,
- [
- 'id' => $item['package_id'],
- 'name' => $item['name'],
- 'sku_id' => $item['sku_id'],
- 'buckets' => intdiv($item['buckets'], $item['quantity']),
- 'price_cents' => $item['unit_price_cents'],
- ],
- $checkout->code . '-' . $item['package_id'] . '-' . $index,
- 0,
- true,
- $id
- );
- }
- }
- $after = $before - $amount;
- $now = date('Y-m-d H:i:s');
- $balance->balance_cents = $after;
- $balance->updated_at = $now;
- $balance->save();
- MiniBalanceLedger::query()->insert([
- 'enterprise_id' => $enterprise,
- 'checkout_id' => $id,
- 'user_id' => $this->access->actor->userId,
- 'request_key' => $key,
- 'direction' => -1,
- 'amount_cents' => $amount,
- 'before_balance' => $before,
- 'after_balance' => $after,
- 'source' => 'checkout_balance',
- 'created_at' => $now,
- ]);
- $checkout->status = 'paid';
- $checkout->payment_method = $method;
- $checkout->paid_at = $now;
- $checkout->save();
- return self::present($checkout) + ['balance_cents' => $after];
- }, fn () => $this->access->enterprise($enterprise, true));
- }
- /** Check persisted server snapshots before any financial write. */
- private function snapshotItems(MiniCheckout $checkout): array
- {
- $items = $checkout->items;
- if (!is_array($items) || !array_is_list($items) || count($items) < 1 || count($items) > 20) {
- throw new ApiException(40902, '订单快照不完整,请联系平台', 409);
- }
- $total = 0;
- $seen = [];
- foreach ($items as $item) {
- if (!is_array($item)) {
- throw new ApiException(40902, '订单快照不完整,请联系平台', 409);
- }
- foreach (['package_id', 'sku_id', 'quantity', 'buckets', 'unit_price_cents', 'amount_cents'] as $field) {
- if (!isset($item[$field]) || !is_int($item[$field]) || $item[$field] <= 0) {
- throw new ApiException(40902, '订单快照数据无效,请联系平台', 409);
- }
- }
- if ($item['quantity'] > 100 || $item['buckets'] % $item['quantity'] !== 0
- || $item['amount_cents'] !== $item['quantity'] * $item['unit_price_cents']
- || !is_string($item['name'] ?? null) || trim($item['name']) === ''
- || isset($seen[$item['package_id']])) {
- throw new ApiException(40902, '订单快照数据无效,请联系平台', 409);
- }
- $seen[$item['package_id']] = true;
- $total += $item['amount_cents'];
- }
- if ($total !== (int) $checkout->amount_cents) {
- throw new ApiException(40902, '订单快照与应付金额不一致,请联系平台', 409);
- }
- return $items;
- }
- }
|