feat: update appraisal ordering and payment flows

This commit is contained in:
wushumin
2026-06-03 18:14:40 +08:00
parent 0838db5aba
commit 6383ec5a2a
50 changed files with 6143 additions and 988 deletions

View File

@@ -31,6 +31,7 @@ class AdminAccessService
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
['name' => '管理系统配置', 'code' => 'system.manage', 'module' => 'system_config', 'action' => 'manage'],
['name' => '管理服务价格', 'code' => 'service_prices.manage', 'module' => 'service_prices', 'action' => 'manage'],
];
}
@@ -51,6 +52,7 @@ class AdminAccessService
'materials' => '物料管理',
'access' => '权限中心',
'system_config' => '系统配置',
'service_prices' => '服务价格',
default => $module,
};
}

View File

@@ -0,0 +1,450 @@
<?php
namespace app\support;
use support\think\Db;
class AppraisalServicePricePackageService
{
private const TABLE = 'appraisal_service_price_packages';
private const PROVIDERS = [
'anxinyan' => [
'text' => '安心验鉴定',
'sla_hours' => 48,
'default_package_name' => '安心验基础套餐',
'default_package_code' => 'anxinyan_basic',
'default_price' => 99.00,
],
'zhongjian' => [
'text' => '中检鉴定',
'sla_hours' => 72,
'default_package_name' => '中检基础套餐',
'default_package_code' => 'zhongjian_basic',
'default_price' => 199.00,
],
];
public function adminIndex(): array
{
return [
'providers' => $this->providerOptions(),
'list' => $this->list(false),
];
}
public function serviceOptions(): array
{
$packages = $this->list(true);
$grouped = [];
foreach ($packages as $package) {
$grouped[$package['service_provider']][] = $package;
}
return array_map(function (string $serviceProvider) use ($grouped) {
$provider = self::PROVIDERS[$serviceProvider];
$items = $grouped[$serviceProvider] ?? [];
$defaultPackage = $this->defaultFromList($items);
return [
'service_provider' => $serviceProvider,
'service_provider_text' => $provider['text'],
'price' => $defaultPackage ? (float)$defaultPackage['price'] : (float)$provider['default_price'],
'sla_hours' => (int)$provider['sla_hours'],
'default_package_id' => $defaultPackage ? (int)$defaultPackage['id'] : 0,
'default_package' => $defaultPackage,
'packages' => $items,
];
}, array_keys(self::PROVIDERS));
}
public function list(bool $enabledOnly = false): array
{
$query = Db::name(self::TABLE);
if ($enabledOnly) {
$query->where('is_enabled', 1);
}
$rows = $query
->order('service_provider', 'asc')
->order('sort_order', 'asc')
->order('id', 'asc')
->select()
->toArray();
return array_map(fn (array $row) => $this->formatPackage($row), $rows);
}
public function enabledPackages(string $serviceProvider): array
{
$serviceProvider = $this->normalizeServiceProvider($serviceProvider);
$rows = Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('is_enabled', 1)
->order('sort_order', 'asc')
->order('id', 'asc')
->select()
->toArray();
return array_map(fn (array $row) => $this->formatPackage($row), $rows);
}
public function save(array $payload, int $id = 0): int
{
$serviceProvider = $this->normalizeServiceProvider((string)($payload['service_provider'] ?? ''));
$packageName = $this->limitText(trim((string)($payload['package_name'] ?? '')), 128);
$packageCode = $this->normalizePackageCode((string)($payload['package_code'] ?? ''));
$price = $this->normalizePrice($payload['price'] ?? null);
$description = $this->limitText(trim((string)($payload['description'] ?? '')), 500);
$isEnabled = !empty($payload['is_enabled']) ? 1 : 0;
$isDefault = !empty($payload['is_default']) ? 1 : 0;
$sortOrder = (int)($payload['sort_order'] ?? 0);
if ($packageName === '') {
throw new \RuntimeException('套餐名称不能为空');
}
if ($packageCode === '') {
throw new \RuntimeException('套餐编码不能为空,只能使用字母、数字、下划线或短横线');
}
if ($isDefault && !$isEnabled) {
throw new \RuntimeException('默认套餐必须保持启用');
}
$exists = null;
if ($id > 0) {
$exists = Db::name(self::TABLE)->where('id', $id)->find();
if (!$exists) {
throw new \RuntimeException('价格套餐不存在');
}
if ((string)$exists['service_provider'] !== $serviceProvider) {
throw new \RuntimeException('已创建套餐不支持切换服务方');
}
}
$duplicate = Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('package_code', $packageCode)
->when($id > 0, fn ($query) => $query->where('id', '<>', $id))
->find();
if ($duplicate) {
throw new \RuntimeException('同一服务方下套餐编码不能重复');
}
if (!$isEnabled && $exists && (int)$exists['is_enabled'] === 1) {
$enabledCount = (int)Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('is_enabled', 1)
->where('id', '<>', $id)
->count();
if ($enabledCount === 0) {
throw new \RuntimeException('每个服务方至少需要保留一个启用套餐');
}
}
$now = date('Y-m-d H:i:s');
$data = [
'service_provider' => $serviceProvider,
'package_name' => $packageName,
'package_code' => $packageCode,
'price' => $price,
'description' => $description,
'is_enabled' => $isEnabled,
'is_default' => $isDefault,
'sort_order' => $sortOrder,
'updated_at' => $now,
];
Db::startTrans();
try {
if ($id > 0) {
Db::name(self::TABLE)->where('id', $id)->update($data);
$packageId = $id;
} else {
$data['created_at'] = $now;
$packageId = (int)Db::name(self::TABLE)->insertGetId($data);
}
if ($isDefault) {
$this->clearOtherDefaults($serviceProvider, $packageId);
}
$this->normalizeProviderDefault($serviceProvider);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $packageId;
}
public function setEnabled(int $id, bool $enabled): void
{
$package = Db::name(self::TABLE)->where('id', $id)->find();
if (!$package) {
throw new \RuntimeException('价格套餐不存在');
}
$serviceProvider = (string)$package['service_provider'];
if (!$enabled) {
$enabledCount = (int)Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('is_enabled', 1)
->where('id', '<>', $id)
->count();
if ($enabledCount === 0) {
throw new \RuntimeException('每个服务方至少需要保留一个启用套餐');
}
}
Db::startTrans();
try {
Db::name(self::TABLE)->where('id', $id)->update([
'is_enabled' => $enabled ? 1 : 0,
'is_default' => $enabled ? (int)$package['is_default'] : 0,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->normalizeProviderDefault($serviceProvider);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function setDefault(int $id): void
{
$package = Db::name(self::TABLE)->where('id', $id)->find();
if (!$package) {
throw new \RuntimeException('价格套餐不存在');
}
if ((int)$package['is_enabled'] !== 1) {
throw new \RuntimeException('停用套餐不能设为默认套餐');
}
Db::startTrans();
try {
Db::name(self::TABLE)
->where('service_provider', (string)$package['service_provider'])
->update([
'is_default' => 0,
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::name(self::TABLE)->where('id', $id)->update([
'is_default' => 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function resolveForOrder(string $serviceProvider, int $packageId = 0, string $packageCode = ''): array
{
$serviceProvider = $this->normalizeServiceProvider($serviceProvider);
$packageCode = trim($packageCode);
$query = Db::name(self::TABLE)->where('service_provider', $serviceProvider);
if ($packageId > 0) {
$query->where('id', $packageId);
} elseif ($packageCode !== '') {
$query->where('package_code', $packageCode);
} else {
$query->where('is_enabled', 1)
->order('is_default', 'desc')
->order('sort_order', 'asc')
->order('id', 'asc');
}
$package = $query->find();
if (!$package) {
throw new \RuntimeException('当前服务暂无可用价格套餐');
}
if ((int)$package['is_enabled'] !== 1) {
throw new \RuntimeException('所选价格套餐已停用,请重新选择');
}
return $this->formatPackage($package);
}
public function snapshotForOrder(string $serviceProvider, int $packageId = 0, string $packageCode = ''): array
{
$package = $this->resolveForOrder($serviceProvider, $packageId, $packageCode);
return [
'price_package_id' => (int)$package['id'],
'price_package_name' => (string)$package['package_name'],
'price_package_code' => (string)$package['package_code'],
'price_package_price' => (float)$package['price'],
'pay_amount' => (float)$package['price'],
'sla_hours' => (int)$package['sla_hours'],
'service_provider_text' => (string)$package['service_provider_text'],
];
}
public function providerOptions(): array
{
return array_map(fn (string $serviceProvider) => [
'service_provider' => $serviceProvider,
'service_provider_text' => self::PROVIDERS[$serviceProvider]['text'],
'sla_hours' => (int)self::PROVIDERS[$serviceProvider]['sla_hours'],
], array_keys(self::PROVIDERS));
}
public function serviceProviderText(string $serviceProvider): string
{
$serviceProvider = isset(self::PROVIDERS[$serviceProvider]) ? $serviceProvider : 'anxinyan';
return self::PROVIDERS[$serviceProvider]['text'];
}
public function defaultSeeds(): array
{
return array_map(function (string $serviceProvider) {
$provider = self::PROVIDERS[$serviceProvider];
return [
'service_provider' => $serviceProvider,
'package_name' => $provider['default_package_name'],
'package_code' => $provider['default_package_code'],
'price' => (float)$provider['default_price'],
'description' => '默认服务价格套餐',
'is_enabled' => 1,
'is_default' => 1,
'sort_order' => 1,
];
}, array_keys(self::PROVIDERS));
}
private function formatPackage(array $row): array
{
$serviceProvider = isset(self::PROVIDERS[(string)($row['service_provider'] ?? '')])
? (string)$row['service_provider']
: 'anxinyan';
$provider = self::PROVIDERS[$serviceProvider];
return [
'id' => (int)($row['id'] ?? 0),
'service_provider' => $serviceProvider,
'service_provider_text' => $provider['text'],
'package_name' => (string)($row['package_name'] ?? ''),
'package_code' => (string)($row['package_code'] ?? ''),
'price' => (float)($row['price'] ?? 0),
'description' => (string)($row['description'] ?? ''),
'is_enabled' => (bool)($row['is_enabled'] ?? false),
'is_default' => (bool)($row['is_default'] ?? false),
'sort_order' => (int)($row['sort_order'] ?? 0),
'sla_hours' => (int)$provider['sla_hours'],
'created_at' => (string)($row['created_at'] ?? ''),
'updated_at' => (string)($row['updated_at'] ?? ''),
];
}
private function defaultFromList(array $packages): ?array
{
foreach ($packages as $package) {
if (!empty($package['is_default'])) {
return $package;
}
}
return $packages[0] ?? null;
}
private function normalizeServiceProvider(string $serviceProvider): string
{
$serviceProvider = trim($serviceProvider);
if (!isset(self::PROVIDERS[$serviceProvider])) {
throw new \RuntimeException('服务类型不正确');
}
return $serviceProvider;
}
private function normalizePackageCode(string $packageCode): string
{
$packageCode = strtolower(trim($packageCode));
if (preg_match('/^[a-z0-9_-]{2,64}$/', $packageCode) !== 1) {
return '';
}
return $packageCode;
}
private function normalizePrice(mixed $value): float
{
$value = trim((string)$value);
if ($value === '' || preg_match('/^\d+(\.\d{1,2})?$/', $value) !== 1) {
throw new \RuntimeException('套餐价格需填写大于 0 的金额,最多保留 2 位小数');
}
$price = round((float)$value, 2);
if ($price <= 0 || $price > 999999.99) {
throw new \RuntimeException('套餐价格需大于 0 且不超过 999999.99');
}
return $price;
}
private function limitText(string $value, int $maxLength): string
{
if (function_exists('mb_substr')) {
return mb_substr($value, 0, $maxLength, 'UTF-8');
}
return substr($value, 0, $maxLength);
}
private function clearOtherDefaults(string $serviceProvider, int $packageId): void
{
Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('id', '<>', $packageId)
->update([
'is_default' => 0,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function normalizeProviderDefault(string $serviceProvider): void
{
$candidate = Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('is_enabled', 1)
->order('is_default', 'desc')
->order('sort_order', 'asc')
->order('id', 'asc')
->find();
if (!$candidate) {
Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('is_default', 1)
->update([
'is_default' => 0,
'updated_at' => date('Y-m-d H:i:s'),
]);
return;
}
$now = date('Y-m-d H:i:s');
$candidateId = (int)$candidate['id'];
Db::name(self::TABLE)
->where('service_provider', $serviceProvider)
->where('id', '<>', $candidateId)
->where('is_default', 1)
->update([
'is_default' => 0,
'updated_at' => $now,
]);
if ((int)$candidate['is_default'] !== 1) {
Db::name(self::TABLE)->where('id', $candidateId)->update([
'is_default' => 1,
'updated_at' => $now,
]);
}
}
}

View File

@@ -34,7 +34,12 @@ class EnterpriseOrderService
throw new \InvalidArgumentException('service_provider 无效');
}
$serviceConfig = $this->serviceConfig($serviceProvider);
$pricePackageCode = trim((string)($payload['price_package_code'] ?? ''));
try {
$servicePackage = $this->pricePackageSnapshot($serviceProvider, $pricePackageCode);
} catch (\RuntimeException $e) {
throw new \InvalidArgumentException($e->getMessage());
}
$product = $this->normalizeProduct((array)($payload['product_info'] ?? []));
$returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? []));
@@ -43,7 +48,7 @@ class EnterpriseOrderService
$now = date('Y-m-d H:i:s');
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$servicePackage['sla_hours'])));
$userId = (new EnterpriseCustomerService())->ensureVirtualUser($customer);
$productName = $this->resolveProductName($product);
@@ -61,7 +66,11 @@ class EnterpriseOrderService
'estimated_finish_time' => $estimated,
'source_channel' => 'enterprise_push',
'source_customer_id' => $customer['customer_code'],
'pay_amount' => $serviceConfig['price'],
'price_package_id' => $servicePackage['price_package_id'],
'price_package_name' => $servicePackage['price_package_name'],
'price_package_code' => $servicePackage['price_package_code'],
'price_package_price' => $servicePackage['price_package_price'],
'pay_amount' => $servicePackage['pay_amount'],
'paid_at' => $now,
'created_at' => $now,
'updated_at' => $now,
@@ -130,7 +139,8 @@ class EnterpriseOrderService
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'order_created', [
'product_name' => $productName,
'pay_amount' => (float)$serviceConfig['price'],
'price_package_name' => $servicePackage['price_package_name'],
'pay_amount' => (float)$servicePackage['pay_amount'],
]);
$ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find();
@@ -182,6 +192,9 @@ class EnterpriseOrderService
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
'payment_status' => (string)$order['payment_status'],
'price_package_name' => (string)($order['price_package_name'] ?? ''),
'price_package_code' => (string)($order['price_package_code'] ?? ''),
'price_package_price' => (float)($order['price_package_price'] ?? 0),
'pay_amount' => (float)$order['pay_amount'],
'estimated_finish_time' => (string)($order['estimated_finish_time'] ?? ''),
'created_at' => (string)$order['created_at'],
@@ -317,16 +330,9 @@ class EnterpriseOrderService
return trim(($product['brand_name'] ?? '') . ' ' . ($product['category_name'] ?? ''));
}
private function serviceConfig(string $serviceProvider): array
private function pricePackageSnapshot(string $serviceProvider, string $packageCode = ''): array
{
$configs = [
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
];
if (isset($configs[$serviceProvider])) {
return $configs[$serviceProvider];
}
return $configs['anxinyan'];
return (new AppraisalServicePricePackageService())->snapshotForOrder($serviceProvider, 0, $packageCode);
}
private function insertMaterials(int $orderId, array $materials, string $now): void

View File

@@ -0,0 +1,173 @@
<?php
namespace app\support;
use support\think\Db;
class MiniProgramAuthService
{
public const AUTH_TYPE = 'wechat_mini_program';
public function bindOpenid(int $userId, string $code): array
{
$code = trim($code);
if ($userId <= 0) {
throw new \RuntimeException('用户登录状态无效');
}
if ($code === '') {
throw new \RuntimeException('小程序登录 code 不能为空');
}
$identity = $this->fetchOpenidByCode($code);
$openid = (string)$identity['openid'];
$unionid = (string)($identity['unionid'] ?? '');
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$existing = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_key', $openid)
->lock(true)
->find();
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
if ($unionid !== '') {
$unionAuth = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_union_id', $unionid)
->lock(true)
->find();
if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
if (!$existing && $unionAuth) {
$existing = $unionAuth;
}
}
$payload = [
'user_id' => $userId,
'auth_type' => self::AUTH_TYPE,
'auth_key' => $openid,
'auth_open_id' => $openid,
'auth_union_id' => $unionid,
'auth_extra' => json_encode([
'session_key_present' => ((string)($identity['session_key'] ?? '')) !== '',
'bound_at' => $now,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'openid' => $openid,
'unionid' => $unionid,
];
}
public function openidForUser(int $userId): string
{
if ($userId <= 0) {
return '';
}
return (string)Db::name('user_auths')
->where('user_id', $userId)
->where('auth_type', self::AUTH_TYPE)
->order('id', 'desc')
->value('auth_open_id');
}
private function fetchOpenidByCode(string $code): array
{
if (str_starts_with($code, 'mock_mp_')) {
return [
'openid' => 'mock_mp_openid_' . substr($code, 8),
'unionid' => '',
'session_key' => 'mock_session_key',
];
}
$appId = $this->systemConfig('mini_program', 'app_id');
$appSecret = $this->systemConfig('mini_program', 'app_secret');
if ($appId === '' || $appSecret === '') {
throw new \RuntimeException('小程序 AppID 或 AppSecret 未配置');
}
$url = 'https://api.weixin.qq.com/sns/jscode2session?' . http_build_query([
'appid' => $appId,
'secret' => $appSecret,
'js_code' => $code,
'grant_type' => 'authorization_code',
]);
$payload = $this->wechatApiGet($url);
$openid = trim((string)($payload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信小程序登录返回缺少 openid');
}
return [
'openid' => $openid,
'unionid' => trim((string)($payload['unionid'] ?? '')),
'session_key' => (string)($payload['session_key'] ?? ''),
];
}
private function wechatApiGet(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('微信小程序登录换取 openid 失败:' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException('微信小程序登录接口 HTTP 状态异常:' . $httpStatus);
}
$payload = json_decode((string)$response, true);
if (!is_array($payload)) {
throw new \RuntimeException('微信小程序登录接口返回格式异常');
}
$errcode = (int)($payload['errcode'] ?? 0);
if ($errcode !== 0) {
throw new \RuntimeException((string)($payload['errmsg'] ?? '微信小程序登录接口返回错误'));
}
return $payload;
}
private function systemConfig(string $group, string $key): string
{
return trim((string)Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->value('config_value'));
}
}

View File

@@ -0,0 +1,285 @@
<?php
namespace app\support;
class ShouqianbaClient
{
private const VERSION = '1.0.0';
private const SIGN_TYPE = 'SHA256';
private ?ShouqianbaConfigService $configService;
public function __construct(?ShouqianbaConfigService $configService = null)
{
$this->configService = $configService ?: new ShouqianbaConfigService();
}
public function purchase(array $body): array
{
return $this->post('/api/lite-pos/v1/sales/purchase', $body);
}
public function query(array $body): array
{
return $this->post('/api/lite-pos/v1/sales/query', $body);
}
public function void(array $body): array
{
return $this->post('/api/lite-pos/v1/sales/void', $body);
}
public function decodeNotification(string $rawBody): array
{
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
throw new \RuntimeException('收钱吧通知请求体格式错误');
}
if (!$this->verifyEnvelopeRaw($rawBody, $payload, 'request') && !$this->verifyEnvelope($payload, 'request')) {
throw new \RuntimeException('收钱吧通知验签失败');
}
$body = $payload['request']['body'] ?? null;
if (!is_array($body)) {
throw new \RuntimeException('收钱吧通知业务参数为空');
}
return $body;
}
public function signedResponse(string $resultCode = '200', string $bizResultCode = '200'): array
{
$config = $this->configService->assertReady();
$response = [
'head' => [
'version' => self::VERSION,
'sign_type' => self::SIGN_TYPE,
'appid' => $config['appid'],
'response_time' => $this->isoTime(),
],
'body' => [
'result_code' => $resultCode,
'biz_response' => [
'result_code' => $bizResultCode,
],
],
];
return [
'response' => $response,
'signature' => $this->sign($response, $config['merchant_private_key']),
];
}
private function post(string $path, array $body): array
{
$config = $this->configService->assertReady(true);
$request = [
'head' => [
'version' => self::VERSION,
'sign_type' => self::SIGN_TYPE,
'appid' => $config['appid'],
'request_time' => $this->isoTime(),
],
'body' => $body,
];
$payload = [
'request' => $request,
'signature' => $this->sign($request, $config['merchant_private_key']),
];
$rawResponse = $this->postJson($config['api_domain'] . $path, $payload);
$decoded = json_decode($rawResponse, true);
if (!is_array($decoded)) {
throw new \RuntimeException('收钱吧接口返回格式异常');
}
if (!$this->verifyEnvelopeRaw($rawResponse, $decoded, 'response') && !$this->verifyEnvelope($decoded, 'response')) {
throw new \RuntimeException('收钱吧接口返回验签失败');
}
$responseBody = $decoded['response']['body'] ?? [];
if (!is_array($responseBody)) {
throw new \RuntimeException('收钱吧接口返回业务体异常');
}
if ((string)($responseBody['result_code'] ?? '') !== '200') {
throw new \RuntimeException($responseBody['error_message'] ?? '收钱吧接口通信失败');
}
$bizResponse = $responseBody['biz_response'] ?? [];
if (!is_array($bizResponse) || (string)($bizResponse['result_code'] ?? '') !== '200') {
throw new \RuntimeException($bizResponse['error_message'] ?? '收钱吧接口业务处理失败');
}
$data = $bizResponse['data'] ?? [];
return [
'request' => $request,
'response' => $decoded,
'data' => is_array($data) ? $data : [],
];
}
private function sign(array $payload, string $privateKey): string
{
$content = $this->encodeJson($payload);
$key = openssl_pkey_get_private($privateKey);
if ($key === false) {
throw new \RuntimeException('收钱吧商户 RSA 私钥不可用');
}
$signature = '';
$ok = openssl_sign($content, $signature, $key, OPENSSL_ALGO_SHA256);
if (!$ok) {
throw new \RuntimeException('收钱吧请求签名失败');
}
return base64_encode($signature);
}
private function verifyEnvelope(array $payload, string $field): bool
{
$content = $payload[$field] ?? null;
$signature = (string)($payload['signature'] ?? '');
if (!is_array($content) || $signature === '') {
return false;
}
return $this->verifyContent($this->encodeJson($content), $signature);
}
private function verifyEnvelopeRaw(string $rawPayload, array $payload, string $field): bool
{
$signature = (string)($payload['signature'] ?? '');
if ($signature === '') {
return false;
}
$content = $this->extractJsonFieldRaw($rawPayload, $field);
if ($content === '') {
return false;
}
return $this->verifyContent($content, $signature);
}
private function verifyContent(string $content, string $signature): bool
{
$config = $this->configService->assertReady(true);
$publicKey = openssl_pkey_get_public($config['shouqianba_public_key']);
if ($publicKey === false) {
throw new \RuntimeException('收钱吧 RSA 公钥不可用');
}
$result = openssl_verify(
$content,
base64_decode($signature, true) ?: '',
$publicKey,
OPENSSL_ALGO_SHA256
);
return $result === 1;
}
private function extractJsonFieldRaw(string $json, string $field): string
{
if (!preg_match('/"' . preg_quote($field, '/') . '"\s*:/', $json, $matches, PREG_OFFSET_CAPTURE)) {
return '';
}
$offset = $matches[0][1] + strlen($matches[0][0]);
$length = strlen($json);
while ($offset < $length && ctype_space($json[$offset])) {
$offset++;
}
if ($offset >= $length || !in_array($json[$offset], ['{', '['], true)) {
return '';
}
$start = $offset;
$depth = 0;
$inString = false;
$escaped = false;
for ($i = $offset; $i < $length; $i++) {
$char = $json[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === '"') {
$inString = false;
}
continue;
}
if ($char === '"') {
$inString = true;
continue;
}
if ($char === '{' || $char === '[') {
$depth++;
continue;
}
if ($char === '}' || $char === ']') {
$depth--;
if ($depth === 0) {
return substr($json, $start, $i - $start + 1);
}
}
}
return '';
}
private function postJson(string $url, array $payload): string
{
$body = $this->encodeJson($payload);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 12,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('收钱吧请求失败:' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException('收钱吧请求 HTTP 状态异常:' . $httpStatus);
}
return is_string($response) ? $response : '';
}
private function encodeJson(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
throw new \RuntimeException('收钱吧 JSON 编码失败');
}
return $json;
}
private function isoTime(?int $timestamp = null): string
{
return date('c', $timestamp ?? time());
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace app\support;
use support\think\Db;
class ShouqianbaConfigService
{
private const GROUP = 'payment';
private const SHOUQIANBA_NOTIFY_PATH = '/api/open/shouqianba/payment/notify';
public const MINI_PROGRAM_PLUGIN_PROVIDER = 'wx7903bb295ac26ac7';
public function getConfig(): array
{
$rows = Db::name('system_configs')
->where('config_group', self::GROUP)
->column('config_value', 'config_key');
$expireMinutes = trim((string)($rows['order_expire_minutes'] ?? '1440'));
if ($expireMinutes === '' || !ctype_digit($expireMinutes)) {
$expireMinutes = '1440';
}
$workstationSn = trim((string)($rows['workstation_sn'] ?? '0'));
if ($workstationSn === '') {
$workstationSn = '0';
}
$industryCode = trim((string)($rows['industry_code'] ?? '0'));
if ($industryCode === '') {
$industryCode = '0';
}
$notifyUrl = trim((string)($rows['notify_url'] ?? ''));
if ($notifyUrl === '') {
$notifyUrl = $this->defaultNotifyUrl();
}
return [
'enabled' => (string)($rows['enabled'] ?? 'disabled') === 'enabled',
'api_domain' => rtrim(trim((string)($rows['api_domain'] ?? '')), '/'),
'appid' => trim((string)($rows['appid'] ?? '')),
'brand_code' => trim((string)($rows['brand_code'] ?? '')),
'store_sn' => trim((string)($rows['store_sn'] ?? '')),
'store_name' => trim((string)($rows['store_name'] ?? '')),
'workstation_sn' => $workstationSn,
'industry_code' => $industryCode,
'order_expire_minutes' => max(1, min(43200, (int)$expireMinutes)),
'merchant_private_key' => $this->normalizeKey((string)($rows['merchant_private_key'] ?? '')),
'shouqianba_public_key' => $this->normalizeKey((string)($rows['shouqianba_public_key'] ?? ''), 'PUBLIC KEY'),
'notify_url' => $notifyUrl,
'mini_program_plugin_version' => trim((string)($rows['mini_program_plugin_version'] ?? '')),
];
}
public function assertReady(bool $requirePublicKey = false): array
{
$config = $this->getConfig();
if (!$config['enabled']) {
throw new \RuntimeException('收钱吧支付未启用,请先在后台系统配置中启用。');
}
$required = [
'api_domain' => '收钱吧 API 域名',
'appid' => '收钱吧 AppID',
'brand_code' => '收钱吧品牌编号',
'store_sn' => '门店编号',
'workstation_sn' => '收银机编号',
'industry_code' => '行业代码',
'merchant_private_key' => '商户 RSA 私钥',
'notify_url' => '支付通知地址',
];
if ($requirePublicKey) {
$required['shouqianba_public_key'] = '收钱吧 RSA 公钥';
}
foreach ($required as $key => $label) {
if (trim((string)($config[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('收钱吧支付配置未完成,请先填写%s。', $label));
}
}
return $config;
}
public function h5OrderDetailUrl(int $orderId): string
{
$baseUrl = $this->h5PageBaseUrl();
if ($baseUrl === '') {
return '';
}
return $baseUrl . '/#/pages/order/detail?id=' . $orderId;
}
public function miniProgramCallbackPath(int $orderId): string
{
return '/pages/order/detail?id=' . $orderId;
}
private function h5PageBaseUrl(): string
{
$value = (string)Db::name('system_configs')
->where('config_group', 'h5')
->where('config_key', 'page_base_url')
->value('config_value');
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function defaultNotifyUrl(): string
{
$baseUrl = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ''));
if ($baseUrl === '') {
$baseUrl = trim((string)Db::name('system_configs')
->where('config_group', 'file_storage')
->where('config_key', 'public_base_url')
->value('config_value'));
}
if ($baseUrl === '') {
return '';
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/') . self::SHOUQIANBA_NOTIFY_PATH;
}
private function normalizeKey(string $value, string $pemLabel = ''): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (str_contains($value, '-----BEGIN')) {
return $value;
}
if (is_file($value)) {
$content = file_get_contents($value);
return is_string($content) ? $this->normalizeKey($content, $pemLabel) : '';
}
if ($pemLabel !== '' && $this->looksLikeBase64KeyBody($value)) {
$body = preg_replace('/\s+/', '', $value) ?: '';
return sprintf(
"-----BEGIN %s-----\n%s\n-----END %s-----",
$pemLabel,
rtrim(chunk_split($body, 64, "\n")),
$pemLabel
);
}
return $value;
}
private function looksLikeBase64KeyBody(string $value): bool
{
$body = preg_replace('/\s+/', '', trim($value));
if (!is_string($body) || strlen($body) < 64) {
return false;
}
return preg_match('/^[A-Za-z0-9+\/=]+$/', $body) === 1 && base64_decode($body, true) !== false;
}
}

View File

@@ -0,0 +1,613 @@
<?php
namespace app\support;
use support\think\Db;
class ShouqianbaPaymentService
{
private ?ShouqianbaConfigService $configService;
private ?ShouqianbaClient $client;
private ?MiniProgramAuthService $miniProgramAuthService;
public function __construct(
?ShouqianbaConfigService $configService = null,
?ShouqianbaClient $client = null,
?MiniProgramAuthService $miniProgramAuthService = null
) {
$this->configService = $configService ?: new ShouqianbaConfigService();
$this->client = $client ?: new ShouqianbaClient($this->configService);
$this->miniProgramAuthService = $miniProgramAuthService ?: new MiniProgramAuthService();
}
public function assertReadyForSource(string $sourceChannel, int $userId): void
{
$this->configService->assertReady(true);
if ($sourceChannel === 'h5' && $this->configService->h5OrderDetailUrl(1) === '') {
throw new \RuntimeException('H5 页面根地址未配置,无法生成支付回跳地址');
}
if ($sourceChannel === 'mini_program' && $this->miniProgramAuthService->openidForUser($userId) === '') {
throw new \RuntimeException('小程序 openid 未绑定,请先完成小程序登录授权');
}
}
public function createOrReusePayment(int $orderId): array
{
$order = $this->findOrder($orderId);
if ((string)$order['payment_status'] === 'paid') {
$payment = $this->latestPayment($orderId);
return [
'status' => 'paid',
'channel' => (string)$order['source_channel'],
'check_sn' => (string)($payment['check_sn'] ?? ''),
'order_token' => (string)($payment['order_token'] ?? ''),
'cashier_url' => (string)($payment['cashier_url'] ?? ''),
'order_sn' => (string)($payment['order_sn'] ?? ''),
'order_status' => (string)$order['order_status'],
];
}
if ((string)$order['order_status'] !== 'pending_payment') {
throw new \RuntimeException('当前订单状态不可发起支付');
}
$this->assertReadyForSource((string)$order['source_channel'], (int)$order['user_id']);
$latest = $this->latestPayment($orderId);
if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') {
return $this->buildPaymentLaunchPayload($latest, $order);
}
return $this->createPayment($order);
}
public function syncOrderPaymentStatus(int $orderId, ?int $userId = null): array
{
$order = $this->findOrder($orderId, $userId);
$payment = $this->latestPayment($orderId);
if (!$payment) {
return $this->orderPaymentPayload($order, null);
}
if ((string)$order['payment_status'] === 'paid') {
return $this->orderPaymentPayload($order, $payment);
}
if (!in_array((string)$payment['status'], ['pending', 'created', 'failed'], true)) {
return $this->orderPaymentPayload($order, $payment);
}
$data = $this->queryRemotePayment($payment);
$this->applyRemotePaymentData($payment, $data, 'query');
return $this->orderPaymentPayload($this->findOrder($orderId, $userId), $this->latestPayment($orderId));
}
public function cancelPendingOrder(int $orderId, int $userId): array
{
$order = $this->findOrder($orderId, $userId);
if ((string)$order['payment_status'] === 'paid') {
throw new \RuntimeException('订单已支付,不能取消');
}
if ((string)$order['order_status'] !== 'pending_payment') {
throw new \RuntimeException('当前订单状态不可取消');
}
$payment = $this->latestPayment($orderId);
if ($payment && in_array((string)$payment['status'], ['pending', 'created'], true)) {
$data = $this->queryRemotePayment($payment);
if ($this->isRemotePaid($data)) {
$this->applyRemotePaymentData($payment, $data, 'query');
throw new \RuntimeException('订单已支付,不能取消');
}
if (!in_array((string)($data['order_status'] ?? ''), ['0', '6', '7'], true)) {
$this->voidRemotePayment($payment);
}
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'cancelled',
'display_status' => '已取消',
'cancelled_at' => $now,
'updated_at' => $now,
]);
if ($payment) {
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => 'cancelled',
'cancelled_at' => $now,
'updated_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'cancelled')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'cancelled',
'node_text' => '订单已取消',
'node_desc' => '用户已取消待支付订单。',
'operator_type' => 'user',
'operator_id' => $userId,
'occurred_at' => $now,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->orderPaymentPayload($this->findOrder($orderId, $userId), $this->latestPayment($orderId));
}
public function handleNotification(string $rawBody): array
{
$data = $this->client->decodeNotification($rawBody);
$payment = $this->findPaymentByRemoteData($data);
if (!$payment) {
throw new \RuntimeException('收钱吧通知对应的支付流水不存在');
}
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'notify_json' => $this->encodeJson($data),
'updated_at' => date('Y-m-d H:i:s'),
]);
$payment = $this->latestPayment((int)$payment['order_id']) ?: $payment;
$this->applyRemotePaymentData($payment, $data, 'notify');
return [
'order_id' => (int)$payment['order_id'],
'check_sn' => (string)$payment['check_sn'],
'status' => (string)($this->latestPayment((int)$payment['order_id'])['status'] ?? ''),
];
}
public function notificationResponse(bool $ok = true): array
{
return $this->client->signedResponse($ok ? '200' : '500', $ok ? '200' : '500');
}
private function createPayment(array $order): array
{
$sourceChannel = (string)$order['source_channel'];
$config = $this->configService->assertReady(true);
$amount = $this->amountCents((float)$order['pay_amount']);
if ($amount <= 0) {
throw new \RuntimeException('订单支付金额必须大于 0');
}
$checkSn = $this->generateCheckSn((string)$order['order_no']);
$product = Db::name('order_products')->where('order_id', (int)$order['id'])->find() ?: [];
$subject = $this->truncateText('安心验鉴定', 64);
$description = $this->truncateText((string)($product['product_name'] ?? $order['appraisal_no']), 255);
$scene = $sourceChannel === 'h5' ? '2' : '5';
$body = [
'request_id' => $this->generateRequestId('P'),
'brand_code' => $config['brand_code'],
'store_sn' => $config['store_sn'],
'workstation_sn' => $config['workstation_sn'],
'check_sn' => $checkSn,
'sales_sn' => (string)$order['order_no'],
'scene' => $scene,
'sales_time' => date('c'),
'expire_time' => (string)$config['order_expire_minutes'],
'amount' => (string)$amount,
'currency' => '156',
'subject' => $subject,
'description' => $description,
'operator' => 'system',
'industry_code' => $config['industry_code'],
'pos_info' => 'anxinyan',
'notify_url' => $config['notify_url'],
'reflect' => (string)$order['order_no'],
];
if ($config['store_name'] !== '') {
$body['store_name'] = $config['store_name'];
}
if ($sourceChannel === 'h5') {
$returnUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
if ($returnUrl !== '') {
$body['return_url'] = $returnUrl;
$body['back_url'] = $returnUrl;
}
}
$remote = $this->client->purchase($body);
$data = $remote['data'];
$now = date('Y-m-d H:i:s');
$paymentId = (int)Db::name('shouqianba_payments')->insertGetId([
'order_id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'check_sn' => $checkSn,
'order_sn' => (string)($data['order_sn'] ?? ''),
'order_token' => (string)($data['order_token'] ?? ''),
'cashier_url' => (string)($data['cashier_url'] ?? ''),
'order_image_url' => (string)($data['order_image_url'] ?? ''),
'order_landing_url' => (string)($data['order_landing_url'] ?? ''),
'scene' => $scene,
'source_channel' => $sourceChannel,
'status' => 'pending',
'amount' => $amount,
'currency' => '156',
'request_json' => $this->encodeJson($remote['request']),
'response_json' => $this->encodeJson($remote['response']),
'notify_json' => null,
'paid_at' => null,
'cancelled_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$payment = Db::name('shouqianba_payments')->where('id', $paymentId)->find();
if (!$payment) {
throw new \RuntimeException('收钱吧支付流水创建失败');
}
return $this->buildPaymentLaunchPayload($payment, $order);
}
private function buildPaymentLaunchPayload(array $payment, array $order): array
{
$sourceChannel = (string)$order['source_channel'];
$payload = [
'status' => (string)$payment['status'],
'channel' => $sourceChannel,
'check_sn' => (string)$payment['check_sn'],
'order_token' => (string)$payment['order_token'],
'cashier_url' => (string)$payment['cashier_url'],
'order_sn' => (string)$payment['order_sn'],
];
if ($sourceChannel === 'mini_program') {
$appId = $this->systemConfig('mini_program', 'app_id');
$openid = $this->miniProgramAuthService->openidForUser((int)$order['user_id']);
if ($appId === '' || $openid === '') {
throw new \RuntimeException('小程序支付参数未准备完成');
}
$query = http_build_query([
'token' => (string)$payment['order_token'],
'appid' => $appId,
'openid' => $openid,
'callback_url' => $this->configService->miniProgramCallbackPath((int)$order['id']),
], '', '&', PHP_QUERY_RFC3986);
$payload['plugin_url'] = 'plugin://lite-pos-plugin/cashierV2?' . $query;
$payload['plugin_provider'] = ShouqianbaConfigService::MINI_PROGRAM_PLUGIN_PROVIDER;
}
return $payload;
}
private function queryRemotePayment(array $payment): array
{
$config = $this->configService->assertReady(true);
$body = [
'brand_code' => $config['brand_code'],
'store_sn' => $config['store_sn'],
'workstation_sn' => $config['workstation_sn'],
'check_sn' => (string)$payment['check_sn'],
];
if ((string)$payment['order_sn'] !== '') {
$body['order_sn'] = (string)$payment['order_sn'];
}
$remote = $this->client->query($body);
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'response_json' => $this->encodeJson($remote['response']),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $remote['data'];
}
private function voidRemotePayment(array $payment): void
{
$config = $this->configService->assertReady(true);
$body = [
'request_id' => $this->generateRequestId('V'),
'brand_code' => $config['brand_code'],
'original_store_sn' => $config['store_sn'],
'original_workstation_sn' => $config['workstation_sn'],
'original_check_sn' => (string)$payment['check_sn'],
'reflect' => (string)$payment['order_no'],
];
if ((string)$payment['order_sn'] !== '') {
$body['original_order_sn'] = (string)$payment['order_sn'];
}
$remote = $this->client->void($body);
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'response_json' => $this->encodeJson($remote['response']),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function applyRemotePaymentData(array $payment, array $data, string $source): void
{
if (isset($data['amount']) && (int)$data['amount'] !== (int)$payment['amount']) {
throw new \RuntimeException('收钱吧支付金额与本地订单金额不一致');
}
if ($this->isRemotePaid($data)) {
$this->markOrderPaid($payment, $data, $source);
return;
}
$remoteStatus = (string)($data['order_status'] ?? '');
$statusMap = [
'0' => 'cancelled',
'6' => 'failed',
'7' => 'terminated',
];
$status = $statusMap[$remoteStatus] ?? 'pending';
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => $status,
'order_sn' => (string)($data['order_sn'] ?? $payment['order_sn']),
'updated_at' => date('Y-m-d H:i:s'),
]);
if ($status === 'cancelled') {
$this->markPendingOrderCancelled($payment, '收钱吧已取消该支付订单。');
}
}
private function markOrderPaid(array $payment, array $data, string $source): void
{
$orderId = (int)$payment['order_id'];
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$order = Db::name('orders')->where('id', $orderId)->lock(true)->find();
if (!$order) {
throw new \RuntimeException('支付对应订单不存在');
}
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => 'paid',
'order_sn' => (string)($data['order_sn'] ?? $payment['order_sn']),
'paid_at' => $payment['paid_at'] ?: $now,
'updated_at' => $now,
]);
if ((string)$order['payment_status'] !== 'paid') {
Db::name('orders')->where('id', $orderId)->update([
'payment_status' => 'paid',
'order_status' => 'pending_shipping',
'display_status' => '待寄送商品',
'paid_at' => $now,
'updated_at' => $now,
]);
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
$defaultAddress = Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->where('is_default', 1)
->find();
$shippingTarget = (new WarehouseService())->bindOrderTarget(
$orderId,
(string)$order['service_provider'],
!empty($product['category_id']) ? (int)$product['category_id'] : null,
$defaultAddress ?: null
);
if (!Db::name('appraisal_tasks')->where('order_id', $orderId)->find()) {
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => (string)$order['service_provider'],
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => $order['estimated_finish_time'],
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'payment_paid')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'payment_paid',
'node_text' => '支付成功',
'node_desc' => $source === 'notify' ? '已收到收钱吧支付成功通知。' : '已同步确认收钱吧支付成功。',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'pending_shipping')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'pending_shipping',
'node_text' => '待寄送商品',
'node_desc' => sprintf('请尽快将商品寄送至%s以免影响处理时效', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
(new MessageDispatcher())->sendInboxEvent('order_created', [
'user_id' => (int)$order['user_id'],
'biz_type' => 'order',
'biz_id' => $orderId,
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'product_name' => (string)($product['product_name'] ?? ''),
'pay_amount' => (string)$order['pay_amount'],
'fallback_title' => '订单支付成功',
'fallback_content' => '您的鉴定订单已支付成功,可前往订单中心查看进度。',
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
private function orderPaymentPayload(array $order, ?array $payment): array
{
return [
'order_id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'payment_status' => (string)$order['payment_status'],
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
'payment' => $payment ? [
'status' => (string)$payment['status'],
'channel' => (string)$payment['source_channel'],
'check_sn' => (string)$payment['check_sn'],
'order_sn' => (string)$payment['order_sn'],
'order_token' => (string)$payment['order_token'],
'cashier_url' => (string)$payment['cashier_url'],
] : null,
];
}
private function markPendingOrderCancelled(array $payment, string $nodeDesc): void
{
$orderId = (int)$payment['order_id'];
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$order = Db::name('orders')->where('id', $orderId)->lock(true)->find();
if (!$order || (string)$order['payment_status'] === 'paid' || (string)$order['order_status'] !== 'pending_payment') {
Db::commit();
return;
}
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'cancelled',
'display_status' => '已取消',
'cancelled_at' => $now,
'updated_at' => $now,
]);
if (!$this->timelineExists($orderId, 'cancelled')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'cancelled',
'node_text' => '订单已取消',
'node_desc' => $nodeDesc,
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
private function findOrder(int $orderId, ?int $userId = null): array
{
$query = Db::name('orders')->where('id', $orderId);
if ($userId !== null) {
$query->where('user_id', $userId);
}
$order = $query->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
return $order;
}
private function latestPayment(int $orderId): ?array
{
$payment = Db::name('shouqianba_payments')->where('order_id', $orderId)->order('id', 'desc')->find();
return $payment ?: null;
}
private function findPaymentByRemoteData(array $data): ?array
{
$checkSn = trim((string)($data['check_sn'] ?? ''));
if ($checkSn !== '') {
$payment = Db::name('shouqianba_payments')->where('check_sn', $checkSn)->find();
if ($payment) {
return $payment;
}
}
$orderSn = trim((string)($data['order_sn'] ?? ''));
if ($orderSn !== '') {
$payment = Db::name('shouqianba_payments')->where('order_sn', $orderSn)->order('id', 'desc')->find();
if ($payment) {
return $payment;
}
}
return null;
}
private function isRemotePaid(array $data): bool
{
return in_array((string)($data['order_status'] ?? ''), ['4', '5'], true);
}
private function timelineExists(int $orderId, string $nodeCode): bool
{
return (bool)Db::name('order_timelines')
->where('order_id', $orderId)
->where('node_code', $nodeCode)
->find();
}
private function amountCents(float $amount): int
{
return (int)round($amount * 100);
}
private function generateCheckSn(string $orderNo): string
{
return substr($orderNo . 'P' . date('His') . random_int(10, 99), 0, 32);
}
private function generateRequestId(string $prefix): string
{
return $prefix . date('YmdHis') . bin2hex(random_bytes(6));
}
private function truncateText(string $value, int $maxLength): string
{
if (mb_strlen($value, 'UTF-8') <= $maxLength) {
return $value;
}
return mb_substr($value, 0, $maxLength, 'UTF-8');
}
private function encodeJson(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
throw new \RuntimeException('收钱吧支付数据编码失败');
}
return $json;
}
private function systemConfig(string $group, string $key): string
{
return trim((string)Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->value('config_value'));
}
}