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