feat: update appraisal ordering and payment flows
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalServicePricePackageService;
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\MessageDispatcher;
|
||||
@@ -37,6 +38,9 @@ class OrdersController
|
||||
'o.estimated_finish_time',
|
||||
'o.source_channel',
|
||||
'o.source_customer_id',
|
||||
'o.price_package_name',
|
||||
'o.price_package_code',
|
||||
'o.price_package_price',
|
||||
'o.pay_amount',
|
||||
'o.created_at',
|
||||
'p.product_name',
|
||||
@@ -130,6 +134,9 @@ class OrdersController
|
||||
'brand_name' => $item['brand_name'] ?: '',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'price_package_name' => (string)($item['price_package_name'] ?? ''),
|
||||
'price_package_code' => (string)($item['price_package_code'] ?? ''),
|
||||
'price_package_price' => (float)($item['price_package_price'] ?? 0),
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($item['source_channel'] ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($item['source_channel'] ?? '')),
|
||||
'source_customer_id' => (string)($item['source_customer_id'] ?? ''),
|
||||
@@ -312,6 +319,9 @@ class OrdersController
|
||||
'appraisal_no' => $order['appraisal_no'],
|
||||
'service_provider' => $order['service_provider'],
|
||||
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'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),
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($order['source_channel'] ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
|
||||
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
|
||||
@@ -925,6 +935,8 @@ class OrdersController
|
||||
public function createManualOrder(Request $request)
|
||||
{
|
||||
$serviceProvider = $this->normalizeServiceProvider((string)$request->input('service_provider', 'anxinyan'));
|
||||
$pricePackageId = (int)$request->input('price_package_id', 0);
|
||||
$pricePackageCode = trim((string)$request->input('price_package_code', ''));
|
||||
$productInput = $this->requestArray($request, 'product_info');
|
||||
$extraInput = $this->requestArray($request, 'extra_info');
|
||||
$returnAddressInput = $this->requestArray($request, 'return_address');
|
||||
@@ -971,10 +983,14 @@ class OrdersController
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$serviceConfig = $this->serviceConfig($serviceProvider);
|
||||
try {
|
||||
$servicePackage = $this->pricePackageSnapshot($serviceProvider, $pricePackageId, $pricePackageCode);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
$orderNo = $this->generateOrderNo();
|
||||
$appraisalNo = $this->generateAppraisalNo();
|
||||
$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'])));
|
||||
$operatorId = (int)$request->header('x-admin-id', 0) ?: null;
|
||||
|
||||
Db::startTrans();
|
||||
@@ -1001,7 +1017,11 @@ class OrdersController
|
||||
'estimated_finish_time' => $estimated,
|
||||
'source_channel' => self::MANUAL_ENTRY_SOURCE,
|
||||
'source_customer_id' => '',
|
||||
'pay_amount' => (float)$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' => (float)$servicePackage['pay_amount'],
|
||||
'paid_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
@@ -1107,6 +1127,8 @@ class OrdersController
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'user_id' => (int)$user['id'],
|
||||
'price_package_name' => $servicePackage['price_package_name'],
|
||||
'pay_amount' => (float)$servicePackage['pay_amount'],
|
||||
'next_status' => 'pending_shipping',
|
||||
], '补录订单已创建');
|
||||
}
|
||||
@@ -1152,6 +1174,7 @@ class OrdersController
|
||||
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
|
||||
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||
], $brands),
|
||||
'service_price_packages' => (new AppraisalServicePricePackageService())->serviceOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1240,14 +1263,9 @@ class OrdersController
|
||||
return in_array($serviceProvider, ['anxinyan', 'zhongjian'], true) ? $serviceProvider : '';
|
||||
}
|
||||
|
||||
private function serviceConfig(string $serviceProvider): array
|
||||
private function pricePackageSnapshot(string $serviceProvider, int $packageId = 0, string $packageCode = ''): array
|
||||
{
|
||||
$configs = [
|
||||
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
|
||||
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
|
||||
];
|
||||
|
||||
return $configs[$serviceProvider] ?? $configs['anxinyan'];
|
||||
return (new AppraisalServicePricePackageService())->snapshotForOrder($serviceProvider, $packageId, $packageCode);
|
||||
}
|
||||
|
||||
private function generateOrderNo(): string
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalServicePricePackageService;
|
||||
use support\Request;
|
||||
|
||||
class ServicePricePackagesController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return api_success($this->service()->adminIndex());
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
|
||||
try {
|
||||
$packageId = $this->service()->save([
|
||||
'service_provider' => $request->input('service_provider', 'anxinyan'),
|
||||
'package_name' => $request->input('package_name', ''),
|
||||
'package_code' => $request->input('package_code', ''),
|
||||
'price' => $request->input('price', ''),
|
||||
'description' => $request->input('description', ''),
|
||||
'is_enabled' => $request->input('is_enabled', true),
|
||||
'is_default' => $request->input('is_default', false),
|
||||
'sort_order' => $request->input('sort_order', 0),
|
||||
], $id);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('服务价格套餐保存失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $packageId,
|
||||
], $id > 0 ? '服务价格套餐已更新' : '服务价格套餐已创建');
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('套餐 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service()->setEnabled($id, !empty($request->input('is_enabled', true)));
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('服务价格套餐状态更新失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success(['id' => $id], '套餐状态已更新');
|
||||
}
|
||||
|
||||
public function setDefault(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
if ($id <= 0) {
|
||||
return api_error('套餐 ID 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service()->setDefault($id);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('默认套餐设置失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success(['id' => $id], '默认套餐已更新');
|
||||
}
|
||||
|
||||
private function service(): AppraisalServicePricePackageService
|
||||
{
|
||||
return new AppraisalServicePricePackageService();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use support\think\Db;
|
||||
class SystemConfigsController
|
||||
{
|
||||
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
|
||||
private const SHOUQIANBA_NOTIFY_PATH = '/api/open/shouqianba/payment/notify';
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -55,6 +56,8 @@ class SystemConfigsController
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$this->bootstrapDefaults();
|
||||
|
||||
$items = $request->input('items', []);
|
||||
if (!is_array($items) || !$items) {
|
||||
return api_error('配置项不能为空', 422);
|
||||
@@ -423,17 +426,31 @@ class SystemConfigsController
|
||||
],
|
||||
],
|
||||
'payment' => [
|
||||
'group_name' => '支付与商户平台',
|
||||
'group_desc' => '配置微信支付商户号、API 密钥、证书序列号等上线必要参数。',
|
||||
'group_name' => '收钱吧支付',
|
||||
'group_desc' => '配置收钱吧轻 POS 远程收款和小程序收银插件参数,用于用户端 H5 与微信小程序下单付款。',
|
||||
'items' => [
|
||||
['config_key' => 'mch_id', 'title' => '商户号 MchID', 'field_type' => 'text', 'placeholder' => '请输入商户号', 'remark' => '微信支付商户平台分配的商户号', 'is_secret' => false],
|
||||
['config_key' => 'api_v3_key', 'title' => 'APIv3 Key', 'field_type' => 'password', 'placeholder' => '请输入 APIv3 Key', 'remark' => '用于微信支付接口验签与解密', 'is_secret' => true],
|
||||
['config_key' => 'merchant_serial_no', 'title' => '商户证书序列号', 'field_type' => 'text', 'placeholder' => '请输入商户证书序列号', 'remark' => '与商户 API 证书匹配', 'is_secret' => false],
|
||||
['config_key' => 'apiclient_key_path', 'title' => 'apiclient_key.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_key.pem', 'remark' => '上传微信支付商户私钥文件,系统将保存到后端非公开目录', 'is_secret' => true],
|
||||
['config_key' => 'apiclient_cert_path', 'title' => 'apiclient_cert.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_cert.pem', 'remark' => '上传微信支付商户证书文件,系统将保存到后端非公开目录', 'is_secret' => false],
|
||||
['config_key' => 'merchant_private_key', 'title' => '商户私钥', 'field_type' => 'textarea', 'placeholder' => '请输入商户私钥内容', 'remark' => '用于支付签名,请妥善保管', 'is_secret' => true],
|
||||
['config_key' => 'platform_certificate_serial', 'title' => '平台证书序列号', 'field_type' => 'text', 'placeholder' => '请输入微信支付平台证书序列号', 'remark' => '用于平台证书校验', 'is_secret' => false],
|
||||
['config_key' => 'notify_url', 'title' => '支付回调地址', 'field_type' => 'text', 'placeholder' => '请输入支付回调通知地址', 'remark' => '支付成功后用于回调业务系统', 'is_secret' => false],
|
||||
[
|
||||
'config_key' => 'enabled',
|
||||
'title' => '收钱吧支付开关',
|
||||
'field_type' => 'select',
|
||||
'placeholder' => '请选择是否启用',
|
||||
'remark' => '参数未齐时请先保持停用保存草稿;启用后会校验全部必填参数并正式接管用户端支付。',
|
||||
'is_secret' => false,
|
||||
'default_value' => 'disabled',
|
||||
'options' => [
|
||||
['label' => '停用', 'value' => 'disabled'],
|
||||
['label' => '启用', 'value' => 'enabled'],
|
||||
],
|
||||
],
|
||||
['config_key' => 'api_domain', 'title' => 'API 域名', 'field_type' => 'text', 'placeholder' => '例如 https://xxx.shouqianba.com', 'remark' => '收钱吧提供的接口域名,不需要填写末尾斜杠。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'appid', 'title' => '收钱吧 AppID', 'field_type' => 'text', 'placeholder' => '请输入收钱吧 AppID', 'remark' => '请求头 head.appid 使用的应用编号。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'brand_code', 'title' => '品牌编号 brand_code', 'field_type' => 'text', 'placeholder' => '请输入收钱吧分配的品牌编号', 'remark' => '收钱吧系统对接前分配并提供。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'store_sn', 'title' => '门店编号 store_sn', 'field_type' => 'text', 'placeholder' => '请输入门店编号', 'remark' => '商户内部使用的门店编号。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'order_expire_minutes', 'title' => '订单有效期(分钟)', 'field_type' => 'text', 'placeholder' => '默认 1440', 'remark' => '收钱吧订单有效时间,允许 1-43200 分钟。', 'is_secret' => false, 'default_value' => '1440', 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'merchant_private_key', 'title' => '商户 RSA 私钥', 'field_type' => 'textarea', 'placeholder' => '可填 PEM 内容或服务器可读取的 PEM 文件路径', 'remark' => '我们自己生成的私钥,用于请求签名和通知响应签名,请勿提供给收钱吧。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'shouqianba_public_key', 'title' => '收钱吧 RSA 公钥', 'field_type' => 'textarea', 'placeholder' => '请输入收钱吧提供的 RSA 公钥 PEM 内容', 'remark' => '这是收钱吧回传给我们的公钥,不是我们生成并提交给收钱吧的商户公钥。用于验签接口返回和支付通知。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'notify_url', 'title' => '支付通知地址', 'field_type' => 'text', 'placeholder' => '由 API 公开域名自动生成', 'remark' => '由后端 API 公开域名自动拼接生成,仅展示无需手动填写。', 'is_secret' => false, 'read_only' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'mini_program_plugin_version', 'title' => '小程序插件版本号', 'field_type' => 'text', 'placeholder' => '例如 2.3.xx', 'remark' => '构建微信小程序前同步到 manifest,用于声明收钱吧轻 POS 插件。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
],
|
||||
],
|
||||
'sms' => [
|
||||
@@ -477,19 +494,13 @@ class SystemConfigsController
|
||||
|
||||
private function uploadableConfigMap(): array
|
||||
{
|
||||
return [
|
||||
'payment.apiclient_key_path' => [
|
||||
'filename' => 'apiclient_key.pem',
|
||||
],
|
||||
'payment.apiclient_cert_path' => [
|
||||
'filename' => 'apiclient_cert.pem',
|
||||
],
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
private function validateConfigValues(array $configValueMap): void
|
||||
{
|
||||
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
|
||||
$this->validatePaymentConfig($configValueMap);
|
||||
if ($driver === 'local') {
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
return;
|
||||
@@ -544,6 +555,89 @@ class SystemConfigsController
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
}
|
||||
|
||||
private function validatePaymentConfig(array $configValueMap): void
|
||||
{
|
||||
$enabled = (string)($configValueMap['payment.enabled'] ?? 'disabled');
|
||||
if (!in_array($enabled, ['enabled', 'disabled'], true)) {
|
||||
throw new \RuntimeException('收钱吧支付开关配置无效');
|
||||
}
|
||||
if ($enabled !== 'enabled') {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'payment.api_domain' => '收钱吧 API 域名',
|
||||
'payment.appid' => '收钱吧 AppID',
|
||||
'payment.brand_code' => '品牌编号',
|
||||
'payment.store_sn' => '门店编号',
|
||||
'payment.order_expire_minutes' => '订单有效分钟数',
|
||||
'payment.merchant_private_key' => '商户 RSA 私钥',
|
||||
'payment.shouqianba_public_key' => '收钱吧 RSA 公钥',
|
||||
'payment.notify_url' => '支付通知地址',
|
||||
];
|
||||
|
||||
foreach ($required as $key => $label) {
|
||||
if (trim((string)($configValueMap[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('当前已启用收钱吧支付,请先填写 %s;如需先保存草稿,请先将收钱吧支付开关设为停用', $label));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['payment.api_domain' => '收钱吧 API 域名', 'payment.notify_url' => '支付通知地址'] as $key => $label) {
|
||||
if (!preg_match('/^https?:\/\//i', trim((string)$configValueMap[$key]))) {
|
||||
throw new \RuntimeException(sprintf('%s需以 http:// 或 https:// 开头', $label));
|
||||
}
|
||||
}
|
||||
|
||||
$expireMinutes = trim((string)($configValueMap['payment.order_expire_minutes'] ?? ''));
|
||||
if (!ctype_digit($expireMinutes) || (int)$expireMinutes < 1 || (int)$expireMinutes > 43200) {
|
||||
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
|
||||
}
|
||||
|
||||
if (!$this->isPemContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
|
||||
throw new \RuntimeException('商户 RSA 私钥需填写 PEM 内容,或填写服务器可读取的 PEM 文件路径');
|
||||
}
|
||||
if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) {
|
||||
throw new \RuntimeException('收钱吧 RSA 公钥需填写 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径');
|
||||
}
|
||||
}
|
||||
|
||||
private function isPublicKeyContentOrReadablePath(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($this->isPemContentOrReadablePath($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->looksLikeBase64KeyBody($value);
|
||||
}
|
||||
|
||||
private function isPemContentOrReadablePath(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
if (str_contains($value, '-----BEGIN')) {
|
||||
return true;
|
||||
}
|
||||
if (!is_file($value) || !is_readable($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($value);
|
||||
return is_string($content) && str_contains($content, '-----BEGIN');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function validateKuaidi100Config(array $configValueMap): void
|
||||
{
|
||||
$enabled = (string)($configValueMap['kuaidi100.enabled'] ?? 'disabled');
|
||||
@@ -574,6 +668,7 @@ class SystemConfigsController
|
||||
private function applyDerivedConfigValues(array &$configValueMap): void
|
||||
{
|
||||
$configValueMap['h5.oauth_redirect_url'] = $this->buildH5OAuthRedirectUrl((string)($configValueMap['h5.page_base_url'] ?? ''));
|
||||
$configValueMap['payment.notify_url'] = $this->buildShouqianbaNotifyUrl((string)($configValueMap['file_storage.public_base_url'] ?? ''));
|
||||
}
|
||||
|
||||
private function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
|
||||
@@ -586,6 +681,38 @@ class SystemConfigsController
|
||||
return $baseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
|
||||
}
|
||||
|
||||
private function buildShouqianbaNotifyUrl(string $publicBaseUrl): string
|
||||
{
|
||||
$baseUrl = $this->normalizePublicApiBaseUrl($publicBaseUrl);
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $baseUrl . self::SHOUQIANBA_NOTIFY_PATH;
|
||||
}
|
||||
|
||||
private function normalizePublicApiBaseUrl(string $publicBaseUrl): string
|
||||
{
|
||||
$baseUrl = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ''));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = trim($publicBaseUrl);
|
||||
}
|
||||
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 normalizeH5PageBaseUrl(string $value): string
|
||||
{
|
||||
$baseUrl = trim($value);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\ContentService;
|
||||
use app\support\AppraisalServicePricePackageService;
|
||||
use app\support\FileStorageService;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use app\support\WarehouseService;
|
||||
use app\support\ShouqianbaPaymentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
use function str_starts_with;
|
||||
@@ -52,16 +52,35 @@ class AppraisalController
|
||||
]);
|
||||
}
|
||||
|
||||
public function serviceConfigs(Request $request)
|
||||
{
|
||||
return api_success([
|
||||
'list' => (new AppraisalServicePricePackageService())->serviceOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function createDraft(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$serviceProvider = (string)$request->input('service_provider', 'anxinyan');
|
||||
$serviceMode = (string)$request->input('service_mode', 'physical');
|
||||
$pricePackageId = (int)$request->input('price_package_id', 0);
|
||||
$pricePackageCode = trim((string)$request->input('price_package_code', ''));
|
||||
|
||||
try {
|
||||
$package = $this->pricePackageSnapshot($serviceProvider, $pricePackageId, $pricePackageCode);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
|
||||
$draftId = Db::name('appraisal_drafts')->insertGetId([
|
||||
'user_id' => $userId,
|
||||
'service_mode' => $serviceMode,
|
||||
'service_provider' => $serviceProvider,
|
||||
'price_package_id' => $package['price_package_id'],
|
||||
'price_package_name' => $package['price_package_name'],
|
||||
'price_package_code' => $package['price_package_code'],
|
||||
'price_package_price' => $package['price_package_price'],
|
||||
'current_step' => 1,
|
||||
'status' => 'draft',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
@@ -72,6 +91,10 @@ class AppraisalController
|
||||
'draft_id' => (int)$draftId,
|
||||
'service_provider' => $serviceProvider,
|
||||
'service_mode' => $serviceMode,
|
||||
'price_package_id' => $package['price_package_id'],
|
||||
'price_package_name' => $package['price_package_name'],
|
||||
'price_package_code' => $package['price_package_code'],
|
||||
'price_package_price' => $package['price_package_price'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,6 +135,10 @@ class AppraisalController
|
||||
'draft_id' => (int)$draft['id'],
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'service_mode' => $draft['service_mode'],
|
||||
'price_package_id' => (int)($draft['price_package_id'] ?? 0),
|
||||
'price_package_name' => (string)($draft['price_package_name'] ?? ''),
|
||||
'price_package_code' => (string)($draft['price_package_code'] ?? ''),
|
||||
'price_package_price' => (float)($draft['price_package_price'] ?? 0),
|
||||
'current_step' => (int)$draft['current_step'],
|
||||
'product_info' => $product ?: new \stdClass(),
|
||||
'extra_info' => $extra ?: new \stdClass(),
|
||||
@@ -133,11 +160,30 @@ class AppraisalController
|
||||
$productInfo = (array)$request->input('product_info', []);
|
||||
$extraInfo = (array)$request->input('extra_info', []);
|
||||
$uploadInfo = (array)$request->input('upload_info', []);
|
||||
$serviceProvider = (string)$request->input('service_provider', $draft['service_provider']);
|
||||
$hasPackageInput = $request->input('price_package_id', null) !== null
|
||||
|| trim((string)$request->input('price_package_code', '')) !== '';
|
||||
$pricePackageId = (int)$request->input('price_package_id', $draft['price_package_id'] ?? 0);
|
||||
$pricePackageCode = trim((string)$request->input('price_package_code', $draft['price_package_code'] ?? ''));
|
||||
if ($serviceProvider !== (string)$draft['service_provider'] && !$hasPackageInput) {
|
||||
$pricePackageId = 0;
|
||||
$pricePackageCode = '';
|
||||
}
|
||||
|
||||
try {
|
||||
$package = $this->pricePackageSnapshot($serviceProvider, $pricePackageId, $pricePackageCode);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
|
||||
Db::name('appraisal_drafts')
|
||||
->where('id', $draftId)
|
||||
->update([
|
||||
'service_provider' => $request->input('service_provider', $draft['service_provider']),
|
||||
'service_provider' => $serviceProvider,
|
||||
'price_package_id' => $package['price_package_id'],
|
||||
'price_package_name' => $package['price_package_name'],
|
||||
'price_package_code' => $package['price_package_code'],
|
||||
'price_package_price' => $package['price_package_price'],
|
||||
'current_step' => $currentStep,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
@@ -303,13 +349,24 @@ class AppraisalController
|
||||
return api_error('预览数据不存在', 404);
|
||||
}
|
||||
|
||||
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
|
||||
try {
|
||||
$servicePackage = $this->pricePackageSnapshot(
|
||||
(string)$draft['service_provider'],
|
||||
(int)($draft['price_package_id'] ?? 0),
|
||||
(string)($draft['price_package_code'] ?? '')
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
$policyConfig = (new ContentService())->getPolicyConfig();
|
||||
|
||||
return api_success([
|
||||
'service_summary' => [
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'service_provider_text' => $draft['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'service_provider_text' => $servicePackage['service_provider_text'],
|
||||
'price_package_id' => $servicePackage['price_package_id'],
|
||||
'price_package_name' => $servicePackage['price_package_name'],
|
||||
'price_package_code' => $servicePackage['price_package_code'],
|
||||
],
|
||||
'product_summary' => [
|
||||
'product_name' => $this->resolveProductName($product),
|
||||
@@ -321,9 +378,9 @@ class AppraisalController
|
||||
'uploaded_count' => $this->countUploadedDraftItems($draftId),
|
||||
],
|
||||
'fee_detail' => [
|
||||
'service_fee' => (float)$serviceConfig['price'],
|
||||
'service_fee' => (float)$servicePackage['price_package_price'],
|
||||
'discount_fee' => 0,
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
'pay_amount' => (float)$servicePackage['price_package_price'],
|
||||
],
|
||||
'agreements' => $policyConfig['appraisal_agreements'],
|
||||
]);
|
||||
@@ -343,6 +400,9 @@ class AppraisalController
|
||||
if ($sourceChannel !== 'enterprise_push') {
|
||||
$sourceCustomerId = '';
|
||||
}
|
||||
if (!in_array($sourceChannel, ['mini_program', 'h5'], true)) {
|
||||
return api_error('当前下单渠道暂不支持在线支付', 422);
|
||||
}
|
||||
|
||||
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find();
|
||||
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
|
||||
@@ -352,13 +412,21 @@ class AppraisalController
|
||||
return api_error('提交数据不完整', 422);
|
||||
}
|
||||
|
||||
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
|
||||
try {
|
||||
$servicePackage = $this->pricePackageSnapshot(
|
||||
(string)$draft['service_provider'],
|
||||
(int)($draft['price_package_id'] ?? 0),
|
||||
(string)($draft['price_package_code'] ?? '')
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
$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'])));
|
||||
$productName = $this->resolveProductName($product);
|
||||
$warehouseService = new WarehouseService();
|
||||
$paymentService = new ShouqianbaPaymentService();
|
||||
$defaultAddress = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('is_default', 1)
|
||||
@@ -383,6 +451,17 @@ class AppraisalController
|
||||
return api_error('请先添加并确认寄回地址', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$paymentService->assertReadyForSource($sourceChannel, $userId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('支付配置检查失败,请稍后重试', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$orderId = 0;
|
||||
Db::startTrans();
|
||||
try {
|
||||
$orderId = Db::name('orders')->insertGetId([
|
||||
@@ -391,14 +470,18 @@ class AppraisalController
|
||||
'user_id' => $userId,
|
||||
'service_mode' => $draft['service_mode'],
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'payment_status' => 'paid',
|
||||
'order_status' => 'pending_shipping',
|
||||
'display_status' => '待寄送商品',
|
||||
'payment_status' => 'unpaid',
|
||||
'order_status' => 'pending_payment',
|
||||
'display_status' => '待支付',
|
||||
'estimated_finish_time' => $estimated,
|
||||
'source_channel' => $sourceChannel,
|
||||
'source_customer_id' => $sourceCustomerId,
|
||||
'pay_amount' => $serviceConfig['price'],
|
||||
'paid_at' => $now,
|
||||
'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' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -447,37 +530,15 @@ class AppraisalController
|
||||
]);
|
||||
}
|
||||
|
||||
$shippingTarget = $warehouseService->bindOrderTarget(
|
||||
$orderId,
|
||||
(string)$draft['service_provider'],
|
||||
!empty($product['category_id']) ? (int)$product['category_id'] : null,
|
||||
$defaultAddress ?: null
|
||||
);
|
||||
|
||||
Db::name('order_timelines')->insertAll([
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'created',
|
||||
'node_text' => '下单成功',
|
||||
'node_desc' => '订单已生成并完成支付',
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
[
|
||||
'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,
|
||||
],
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'created',
|
||||
'node_text' => '订单已生成',
|
||||
'node_desc' => '订单资料已保存,等待用户完成支付。',
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$draftUploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray();
|
||||
@@ -514,38 +575,11 @@ class AppraisalController
|
||||
}
|
||||
}
|
||||
|
||||
Db::name('appraisal_tasks')->insert([
|
||||
'order_id' => $orderId,
|
||||
'task_stage' => 'first_review',
|
||||
'service_provider' => $draft['service_provider'],
|
||||
'status' => 'pending',
|
||||
'assignee_id' => null,
|
||||
'assignee_name' => '未分配',
|
||||
'started_at' => null,
|
||||
'submitted_at' => null,
|
||||
'sla_deadline' => $estimated,
|
||||
'is_overtime' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('appraisal_drafts')->where('id', $draftId)->update([
|
||||
'status' => 'submitted',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('order_created', [
|
||||
'user_id' => $userId,
|
||||
'biz_type' => 'order',
|
||||
'biz_id' => (int)$orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'product_name' => $productName,
|
||||
'pay_amount' => (string)$serviceConfig['price'],
|
||||
'fallback_title' => '订单提交成功',
|
||||
'fallback_content' => '您的鉴定订单已提交成功,可前往订单中心查看进度。',
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
@@ -554,12 +588,28 @@ class AppraisalController
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$payment = $paymentService->createOrReusePayment((int)$orderId);
|
||||
} catch (\Throwable $e) {
|
||||
return api_success([
|
||||
'order_id' => (int)$orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'pay_amount' => (float)$servicePackage['pay_amount'],
|
||||
'next_status' => 'pending_payment',
|
||||
'payment' => null,
|
||||
'payment_launch_failed' => true,
|
||||
'payment_error' => $e->getMessage(),
|
||||
], '订单已生成,请稍后在订单详情中继续支付');
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => (int)$orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
'next_status' => 'pending_shipping',
|
||||
'pay_amount' => (float)$servicePackage['pay_amount'],
|
||||
'next_status' => 'pending_payment',
|
||||
'payment' => $payment,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -607,16 +657,9 @@ class AppraisalController
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
|
||||
private function serviceConfig(string $serviceProvider): array
|
||||
private function pricePackageSnapshot(string $serviceProvider, int $packageId = 0, 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, $packageId, $packageCode);
|
||||
}
|
||||
|
||||
private function draftUploadItems(int $draftId, Request $request): array
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\AppAuthService;
|
||||
use app\support\MiniProgramAuthService;
|
||||
use support\Request;
|
||||
|
||||
class AuthController
|
||||
@@ -119,6 +120,26 @@ class AuthController
|
||||
}
|
||||
}
|
||||
|
||||
public function miniProgramBind(Request $request)
|
||||
{
|
||||
$userId = app_user_id($request);
|
||||
$code = trim((string)$request->input('code', ''));
|
||||
if ($code === '') {
|
||||
return api_error('小程序登录 code 不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = (new MiniProgramAuthService())->bindOpenid($userId, $code);
|
||||
return api_success($payload, '小程序身份已绑定');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('小程序身份绑定失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
$userInfo = (new AppAuthService())->current($request);
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\model\OrderSupplementTaskItem;
|
||||
use app\model\OrderTimeline;
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use app\support\ShouqianbaPaymentService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
@@ -26,6 +27,10 @@ class OrdersController
|
||||
'o.order_no',
|
||||
'o.appraisal_no',
|
||||
'o.service_provider',
|
||||
'o.price_package_name',
|
||||
'o.price_package_code',
|
||||
'o.price_package_price',
|
||||
'o.payment_status',
|
||||
'o.order_status',
|
||||
'o.display_status',
|
||||
'o.estimated_finish_time',
|
||||
@@ -62,10 +67,14 @@ class OrdersController
|
||||
'order_id' => (int)$item['id'],
|
||||
'order_no' => $item['order_no'],
|
||||
'appraisal_no' => $item['appraisal_no'],
|
||||
'payment_status' => $item['payment_status'],
|
||||
'order_status' => $item['order_status'],
|
||||
'product_name' => $item['product_name'] ?: '待补充商品名称',
|
||||
'product_cover' => $item['product_cover'] ?: '',
|
||||
'service_provider' => $item['service_provider'],
|
||||
'price_package_name' => (string)($item['price_package_name'] ?? ''),
|
||||
'price_package_code' => (string)($item['price_package_code'] ?? ''),
|
||||
'price_package_price' => (float)($item['price_package_price'] ?? 0),
|
||||
'display_status' => $this->displayStatus(
|
||||
$item['order_status'],
|
||||
$item['display_status'],
|
||||
@@ -123,6 +132,10 @@ class OrdersController
|
||||
'occurred_at' => $item->occurred_at,
|
||||
])
|
||||
->toArray();
|
||||
$payment = Db::name('shouqianba_payments')
|
||||
->where('order_id', $id)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$supplement = OrderSupplementTask::where('order_id', $id)
|
||||
->where('status', 'pending')
|
||||
@@ -221,9 +234,13 @@ class OrdersController
|
||||
'order_no' => $order->order_no,
|
||||
'appraisal_no' => $order->appraisal_no,
|
||||
'service_provider' => $order->service_provider,
|
||||
'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),
|
||||
'source_channel' => $this->normalizeOrderSourceChannel((string)($order->source_channel ?? '')),
|
||||
'source_channel_text' => $this->sourceChannelText((string)($order->source_channel ?? '')),
|
||||
'source_customer_id' => (string)($order->source_customer_id ?? ''),
|
||||
'payment_status' => $order->payment_status,
|
||||
'order_status' => $order->order_status,
|
||||
'display_status' => $this->displayStatus(
|
||||
$order->order_status,
|
||||
@@ -242,12 +259,12 @@ class OrdersController
|
||||
'can_edit_return_address' => empty($returnLogistics['tracking_no']),
|
||||
],
|
||||
'product_info' => [
|
||||
'product_name' => $product?->product_name ?: '',
|
||||
'category_name' => $product?->category_name ?: '',
|
||||
'brand_name' => $product?->brand_name ?: '',
|
||||
'color' => $product?->color ?: '',
|
||||
'size_spec' => $product?->size_spec ?: '',
|
||||
'serial_no' => $product?->serial_no ?: '',
|
||||
'product_name' => $product ? ($product->product_name ?: '') : '',
|
||||
'category_name' => $product ? ($product->category_name ?: '') : '',
|
||||
'brand_name' => $product ? ($product->brand_name ?: '') : '',
|
||||
'color' => $product ? ($product->color ?: '') : '',
|
||||
'size_spec' => $product ? ($product->size_spec ?: '') : '',
|
||||
'serial_no' => $product ? ($product->serial_no ?: '') : '',
|
||||
],
|
||||
'extra_info' => [
|
||||
'purchase_channel' => $extra['purchase_channel'] ?? '',
|
||||
@@ -287,11 +304,85 @@ class OrdersController
|
||||
] : null,
|
||||
'available_actions' => [
|
||||
'primary_action' => $this->primaryAction($order->order_status),
|
||||
'secondary_action' => '联系客服',
|
||||
'secondary_action' => $order->order_status === 'pending_payment' ? '取消订单' : '联系客服',
|
||||
],
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
public function retryPayment(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', $request->input('id', 0));
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单参数不能为空', 422);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find();
|
||||
if (!$order) {
|
||||
return api_error('订单不存在', 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$payment = (new ShouqianbaPaymentService())->createOrReusePayment($orderId);
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'payment' => $payment,
|
||||
]);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('支付发起失败,请稍后重试', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function paymentStatus(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', $request->input('id', 0));
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单参数不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return api_success((new ShouqianbaPaymentService())->syncOrderPaymentStatus($orderId, $userId));
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('支付状态同步失败,请稍后重试', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function cancel(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', $request->input('id', 0));
|
||||
$userId = app_user_id($request);
|
||||
if ($orderId <= 0) {
|
||||
return api_error('订单参数不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return api_success((new ShouqianbaPaymentService())->cancelPendingOrder($orderId, $userId), '订单已取消');
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('订单取消失败,请稍后重试', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveReturnAddress(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
@@ -376,33 +467,46 @@ class OrdersController
|
||||
|
||||
private function primaryAction(string $status, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||
{
|
||||
return match ($status) {
|
||||
if ($status === 'completed') {
|
||||
return ($returnTrackingNo !== '' && $returnTrackingStatus !== 'received') ? '查看物流' : '查看报告';
|
||||
}
|
||||
|
||||
$map = [
|
||||
'pending_payment' => '去支付',
|
||||
'pending_submission' => '去上传',
|
||||
'pending_shipping' => '查看寄送',
|
||||
'pending_supplement' => '去补资料',
|
||||
'report_published' => '查看报告',
|
||||
'completed' => ($returnTrackingNo !== '' && $returnTrackingStatus !== 'received') ? '查看物流' : '查看报告',
|
||||
default => '查看进度',
|
||||
};
|
||||
'cancelled' => '',
|
||||
];
|
||||
|
||||
return $map[$status] ?? '查看进度';
|
||||
}
|
||||
|
||||
private function statusDescription(string $status, string $trackingNo = '', string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||
{
|
||||
return match ($status) {
|
||||
if ($status === 'pending_shipping') {
|
||||
return $trackingNo !== '' ? '运单已提交,等待鉴定中心签收' : '请尽快将商品寄送至鉴定中心';
|
||||
}
|
||||
if ($status === 'completed') {
|
||||
if ($returnTrackingStatus === 'received') {
|
||||
return '回寄商品已签收,本次订单已完成';
|
||||
}
|
||||
return $returnTrackingNo !== '' ? '鉴定物品已寄回,请留意签收与物流信息' : '正式报告已生成,可立即查看并验真';
|
||||
}
|
||||
|
||||
$map = [
|
||||
'pending_payment' => '请完成支付后继续本次鉴定服务',
|
||||
'cancelled' => '订单已取消,如需鉴定请重新下单',
|
||||
'pending_submission' => '请补充必要资料后继续进入鉴定流程',
|
||||
'pending_shipping' => $trackingNo !== '' ? '运单已提交,等待鉴定中心签收' : '请尽快将商品寄送至鉴定中心',
|
||||
'received' => '商品已由鉴定中心签收,等待鉴定师开始处理',
|
||||
'in_first_review' => '鉴定师正在处理,后续节点会持续同步',
|
||||
'in_final_review' => '鉴定师正在处理,预计 24 小时内出具报告',
|
||||
'pending_supplement' => '鉴定师需要您补充资料后继续处理',
|
||||
'report_published' => '正式报告已生成,待平台安排回寄商品',
|
||||
'completed' => $returnTrackingStatus === 'received'
|
||||
? '回寄商品已签收,本次订单已完成'
|
||||
: ($returnTrackingNo !== '' ? '鉴定物品已寄回,请留意签收与物流信息' : '正式报告已生成,可立即查看并验真'),
|
||||
default => '当前无需操作,请耐心等待',
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$status] ?? '当前无需操作,请耐心等待';
|
||||
}
|
||||
|
||||
private function displayStatus(
|
||||
@@ -410,13 +514,21 @@ class OrdersController
|
||||
string $displayStatus,
|
||||
string $trackingNo = '',
|
||||
string $returnTrackingNo = '',
|
||||
string $returnTrackingStatus = '',
|
||||
string $returnTrackingStatus = ''
|
||||
): string
|
||||
{
|
||||
if ($status === 'pending_shipping' && $trackingNo !== '') {
|
||||
return '已提交运单';
|
||||
}
|
||||
|
||||
if ($status === 'pending_payment') {
|
||||
return '待支付';
|
||||
}
|
||||
|
||||
if ($status === 'cancelled') {
|
||||
return '已取消';
|
||||
}
|
||||
|
||||
if ($status === 'report_published') {
|
||||
return '待寄回';
|
||||
}
|
||||
@@ -435,30 +547,29 @@ class OrdersController
|
||||
|
||||
private function usageStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
$map = [
|
||||
'new' => '全新未使用',
|
||||
'light_use' => '轻微使用痕迹',
|
||||
'used' => '长期使用',
|
||||
default => $status,
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$status] ?? $status;
|
||||
}
|
||||
|
||||
private function materialStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
$map = [
|
||||
'uploaded' => '已上传',
|
||||
'optional' => '选填未上传',
|
||||
'pending' => '待上传',
|
||||
default => $status,
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$status] ?? $status;
|
||||
}
|
||||
|
||||
private function materialSourceTypeText(string $sourceType): string
|
||||
{
|
||||
return match ($sourceType) {
|
||||
'supplement' => '补充资料',
|
||||
default => '下单资料',
|
||||
};
|
||||
return $sourceType === 'supplement' ? '补充资料' : '下单资料';
|
||||
}
|
||||
|
||||
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
||||
@@ -485,13 +596,14 @@ class OrdersController
|
||||
|
||||
private function sourceChannelText(string $sourceChannel): string
|
||||
{
|
||||
return match ($this->normalizeOrderSourceChannel($sourceChannel)) {
|
||||
$map = [
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
'manual_entry' => '后台补录订单',
|
||||
default => '未知渠道',
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$this->normalizeOrderSourceChannel($sourceChannel)] ?? '未知渠道';
|
||||
}
|
||||
|
||||
private function decodeJsonArray(mixed $value): array
|
||||
@@ -535,20 +647,22 @@ class OrdersController
|
||||
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
||||
{
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
return match ($status) {
|
||||
$map = [
|
||||
'submitted' => '已登记回寄运单',
|
||||
'in_transit' => '回寄途中',
|
||||
'received' => '用户已签收',
|
||||
default => $status === '' ? '待回寄' : $status,
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$status] ?? ($status === '' ? '待回寄' : $status);
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
$map = [
|
||||
'submitted' => '已提交运单',
|
||||
'in_transit' => '运输中',
|
||||
'received' => '已签收',
|
||||
default => $status === '' ? '待提交' : $status,
|
||||
};
|
||||
];
|
||||
|
||||
return $map[$status] ?? ($status === '' ? '待提交' : $status);
|
||||
}
|
||||
|
||||
private function assetUrlService(): PublicAssetUrlService
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\open;
|
||||
|
||||
use app\support\ShouqianbaPaymentService;
|
||||
use support\Request;
|
||||
|
||||
class ShouqianbaPaymentController
|
||||
{
|
||||
public function notify(Request $request)
|
||||
{
|
||||
$service = new ShouqianbaPaymentService();
|
||||
try {
|
||||
$service->handleNotification($request->rawBody());
|
||||
return json($service->notificationResponse(true));
|
||||
} catch (\Throwable $e) {
|
||||
return json($service->notificationResponse(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
str_starts_with($path, '/api/admin/access/') => ['access.manage'],
|
||||
str_starts_with($path, '/api/admin/content/') => ['system.manage'],
|
||||
str_starts_with($path, '/api/admin/system-configs') => ['system.manage'],
|
||||
str_starts_with($path, '/api/admin/service-price-package') => ['service_prices.manage'],
|
||||
str_starts_with($path, '/api/admin/auth/me'),
|
||||
str_starts_with($path, '/api/admin/auth/logout') => [],
|
||||
default => [],
|
||||
|
||||
@@ -42,6 +42,7 @@ class AppAuthMiddleware implements MiddlewareInterface
|
||||
return in_array($path, [
|
||||
'/api/app/home/index',
|
||||
'/api/app/content/page-visuals',
|
||||
'/api/app/appraisal/service-configs',
|
||||
'/api/app/catalog/brands',
|
||||
'/api/app/help-center',
|
||||
'/api/app/help-article/detail',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
450
server-api/app/support/AppraisalServicePricePackageService.php
Normal file
450
server-api/app/support/AppraisalServicePricePackageService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
173
server-api/app/support/MiniProgramAuthService.php
Normal file
173
server-api/app/support/MiniProgramAuthService.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
285
server-api/app/support/ShouqianbaClient.php
Normal file
285
server-api/app/support/ShouqianbaClient.php
Normal 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());
|
||||
}
|
||||
}
|
||||
175
server-api/app/support/ShouqianbaConfigService.php
Normal file
175
server-api/app/support/ShouqianbaConfigService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
613
server-api/app/support/ShouqianbaPaymentService.php
Normal file
613
server-api/app/support/ShouqianbaPaymentService.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use app\controller\admin\MaterialsController as AdminMaterialsController;
|
||||
use app\controller\admin\AccessController as AdminAccessController;
|
||||
use app\controller\admin\ContentsController as AdminContentsController;
|
||||
use app\controller\admin\SystemConfigsController as AdminSystemConfigsController;
|
||||
use app\controller\admin\ServicePricePackagesController as AdminServicePricePackagesController;
|
||||
use app\controller\admin\AuthController as AdminAuthController;
|
||||
use app\controller\admin\CustomersController as AdminCustomersController;
|
||||
use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController;
|
||||
@@ -50,6 +51,7 @@ use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesCont
|
||||
use app\controller\admin\FileUploadController as AdminFileUploadController;
|
||||
use app\controller\open\OrdersController as OpenOrdersController;
|
||||
use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller;
|
||||
use app\controller\open\ShouqianbaPaymentController as OpenShouqianbaPaymentController;
|
||||
|
||||
Route::get('/', [app\controller\IndexController::class, 'json']);
|
||||
Route::get('/T/{token}', [AppMaterialTagRedirectController::class, 'redirect']);
|
||||
@@ -105,6 +107,15 @@ Route::options('/api/app/order/shipping/save', function () {
|
||||
Route::options('/api/app/order/return-address/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/pay/retry', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/payment/status', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/order/cancel', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/app/address/save', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
@@ -129,6 +140,9 @@ Route::options('/api/admin/{path:.+}', function () {
|
||||
Route::options('/api/open/v1/{path:.+}', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::options('/api/open/shouqianba/payment/notify', function () {
|
||||
return response('', 204);
|
||||
});
|
||||
Route::get('/api/app/home/index', [HomeController::class, 'index']);
|
||||
Route::get('/api/app/content/page-visuals', [HomeController::class, 'pageVisuals']);
|
||||
Route::get('/api/app/catalog/categories', [CatalogController::class, 'categories']);
|
||||
@@ -138,12 +152,16 @@ Route::get('/api/app/appraisal/draft', [AppraisalController::class, 'draftDetail
|
||||
Route::post('/api/app/appraisal/draft/save', [AppraisalController::class, 'saveDraft']);
|
||||
Route::post('/api/app/appraisal/file/upload', [AppraisalController::class, 'uploadFile']);
|
||||
Route::post('/api/app/appraisal/file/delete', [AppraisalController::class, 'deleteFile']);
|
||||
Route::get('/api/app/appraisal/service-configs', [AppraisalController::class, 'serviceConfigs']);
|
||||
Route::get('/api/app/appraisal/upload-template', [AppraisalController::class, 'uploadTemplate']);
|
||||
Route::post('/api/app/appraisal/preview', [AppraisalController::class, 'preview']);
|
||||
Route::post('/api/app/appraisal/submit', [AppraisalController::class, 'submit']);
|
||||
Route::get('/api/app/orders', [OrdersController::class, 'index']);
|
||||
Route::get('/api/app/order/detail', [OrdersController::class, 'detail']);
|
||||
Route::post('/api/app/order/return-address/save', [OrdersController::class, 'saveReturnAddress']);
|
||||
Route::post('/api/app/order/pay/retry', [OrdersController::class, 'retryPayment']);
|
||||
Route::get('/api/app/order/payment/status', [OrdersController::class, 'paymentStatus']);
|
||||
Route::post('/api/app/order/cancel', [OrdersController::class, 'cancel']);
|
||||
Route::get('/api/app/reports', [ReportsController::class, 'index']);
|
||||
Route::get('/api/app/report/detail', [ReportsController::class, 'detail']);
|
||||
Route::post('/api/app/report/anti-counterfeit/verify', [ReportsController::class, 'antiCounterfeitVerify']);
|
||||
@@ -158,6 +176,7 @@ Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByP
|
||||
Route::get('/api/app/auth/wechat/config', [AppAuthController::class, 'wechatConfig']);
|
||||
Route::post('/api/app/auth/wechat/exchange', [AppAuthController::class, 'wechatExchange']);
|
||||
Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']);
|
||||
Route::post('/api/app/auth/mini-program/bind', [AppAuthController::class, 'miniProgramBind']);
|
||||
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
||||
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
|
||||
Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']);
|
||||
@@ -194,6 +213,7 @@ Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
|
||||
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
|
||||
Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']);
|
||||
Route::post('/api/open/kuaidi100/callback', [OpenKuaidi100Controller::class, 'callback']);
|
||||
Route::post('/api/open/shouqianba/payment/notify', [OpenShouqianbaPaymentController::class, 'notify']);
|
||||
|
||||
Route::get('/api/admin/ping', function () {
|
||||
return api_success(['pong' => true]);
|
||||
@@ -314,3 +334,7 @@ Route::post('/api/admin/content/help/article/delete', [AdminContentsController::
|
||||
Route::get('/api/admin/system-configs', [AdminSystemConfigsController::class, 'index']);
|
||||
Route::post('/api/admin/system-configs/upload-file', [AdminSystemConfigsController::class, 'uploadFile']);
|
||||
Route::post('/api/admin/system-configs/save', [AdminSystemConfigsController::class, 'save']);
|
||||
Route::get('/api/admin/service-price-packages', [AdminServicePricePackagesController::class, 'index']);
|
||||
Route::post('/api/admin/service-price-package/save', [AdminServicePricePackagesController::class, 'save']);
|
||||
Route::post('/api/admin/service-price-package/status', [AdminServicePricePackagesController::class, 'updateStatus']);
|
||||
Route::post('/api/admin/service-price-package/default', [AdminServicePricePackagesController::class, 'setDefault']);
|
||||
|
||||
@@ -23,6 +23,7 @@ DROP TABLE IF EXISTS material_batch_download_logs;
|
||||
DROP TABLE IF EXISTS material_tag_codes;
|
||||
DROP TABLE IF EXISTS material_batches;
|
||||
DROP TABLE IF EXISTS system_configs;
|
||||
DROP TABLE IF EXISTS appraisal_service_price_packages;
|
||||
DROP TABLE IF EXISTS service_packages;
|
||||
DROP TABLE IF EXISTS admin_users;
|
||||
DROP TABLE IF EXISTS user_messages;
|
||||
@@ -57,6 +58,7 @@ DROP TABLE IF EXISTS order_assignments;
|
||||
DROP TABLE IF EXISTS order_timelines;
|
||||
DROP TABLE IF EXISTS order_upload_files;
|
||||
DROP TABLE IF EXISTS order_upload_items;
|
||||
DROP TABLE IF EXISTS shouqianba_payments;
|
||||
DROP TABLE IF EXISTS order_return_addresses;
|
||||
DROP TABLE IF EXISTS order_shipping_targets;
|
||||
DROP TABLE IF EXISTS order_extras;
|
||||
@@ -440,11 +442,33 @@ CREATE TABLE appraisal_template_key_points (
|
||||
KEY idx_appraisal_template_key_points_template_id (template_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定模板关键点';
|
||||
|
||||
CREATE TABLE appraisal_service_price_packages (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
package_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
package_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
description VARCHAR(500) NOT NULL DEFAULT '',
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_appraisal_service_price_packages_code (service_provider, package_code),
|
||||
KEY idx_appraisal_service_price_packages_provider (service_provider),
|
||||
KEY idx_appraisal_service_price_packages_enabled (is_enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定服务价格套餐';
|
||||
|
||||
CREATE TABLE appraisal_drafts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
service_mode VARCHAR(32) NOT NULL DEFAULT 'physical',
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
price_package_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||
price_package_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
price_package_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
price_package_price DECIMAL(10,2) NULL DEFAULT NULL,
|
||||
current_step INT NOT NULL DEFAULT 1,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -521,6 +545,10 @@ CREATE TABLE orders (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
service_mode VARCHAR(32) NOT NULL DEFAULT 'physical',
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
price_package_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||
price_package_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
price_package_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
price_package_price DECIMAL(10,2) NULL DEFAULT NULL,
|
||||
payment_status VARCHAR(32) NOT NULL DEFAULT 'unpaid',
|
||||
order_status VARCHAR(32) NOT NULL DEFAULT 'pending_payment',
|
||||
display_status VARCHAR(64) NOT NULL DEFAULT '待支付',
|
||||
@@ -539,10 +567,40 @@ CREATE TABLE orders (
|
||||
KEY idx_orders_user_id (user_id),
|
||||
KEY idx_orders_order_status (order_status),
|
||||
KEY idx_orders_service_provider (service_provider),
|
||||
KEY idx_orders_price_package_id (price_package_id),
|
||||
KEY idx_orders_source_channel (source_channel),
|
||||
KEY idx_orders_source_customer_id (source_customer_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单主表';
|
||||
|
||||
CREATE TABLE shouqianba_payments (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
check_sn VARCHAR(32) NOT NULL,
|
||||
order_sn VARCHAR(32) NOT NULL DEFAULT '',
|
||||
order_token VARCHAR(64) NOT NULL DEFAULT '',
|
||||
cashier_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
order_image_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
order_landing_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
scene VARCHAR(8) NOT NULL DEFAULT '',
|
||||
source_channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
amount INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT '156',
|
||||
request_json LONGTEXT NULL,
|
||||
response_json LONGTEXT NULL,
|
||||
notify_json LONGTEXT NULL,
|
||||
paid_at DATETIME NULL DEFAULT NULL,
|
||||
cancelled_at DATETIME NULL DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shouqianba_payments_check_sn (check_sn),
|
||||
KEY idx_shouqianba_payments_order_id (order_id),
|
||||
KEY idx_shouqianba_payments_order_sn (order_sn),
|
||||
KEY idx_shouqianba_payments_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收钱吧支付流水';
|
||||
|
||||
CREATE TABLE order_products (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -30,11 +30,12 @@ $tables = [
|
||||
'express_companies',
|
||||
'shipping_warehouses',
|
||||
'order_shipping_targets',
|
||||
'shouqianba_payments',
|
||||
'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches',
|
||||
'order_transfer_flow_logs', 'order_transfer_flows', 'internal_transfer_tags', 'internal_transfer_tag_batches',
|
||||
'enterprise_webhook_deliveries', 'enterprise_order_events', 'enterprise_customer_order_refs', 'enterprise_api_nonces', 'enterprise_customer_apps', 'enterprise_customers',
|
||||
'user_api_tokens', 'sms_code_logs',
|
||||
'admin_api_tokens', 'admin_role_permissions', 'admin_permissions', 'admin_role_relations', 'admin_roles', 'operation_logs', 'system_configs', 'admin_users',
|
||||
'admin_api_tokens', 'admin_role_permissions', 'admin_permissions', 'admin_role_relations', 'admin_roles', 'operation_logs', 'system_configs', 'appraisal_service_price_packages', 'admin_users',
|
||||
'ticket_messages', 'tickets',
|
||||
'user_messages', 'message_logs', 'message_rules', 'message_templates',
|
||||
'upload_template_items', 'upload_templates',
|
||||
@@ -130,10 +131,14 @@ INSERT INTO upload_template_items (id, template_id, item_code, item_name, is_req
|
||||
(33, 6, 'strap_buckle', '表带 / 表扣细节图', 1, '请拍摄表带材质、表扣刻字和连接处细节。', '', 2, 4, 1, '{$now}', '{$now}'),
|
||||
(34, 6, 'purchase_voucher', '购买凭证', 0, '如有保卡、发票或购买记录,可一并上传。', '', 2, 5, 1, '{$now}', '{$now}');
|
||||
|
||||
INSERT INTO orders (id, order_no, appraisal_no, user_id, service_mode, service_provider, payment_status, order_status, display_status, estimated_finish_time, source_channel, source_customer_id, pay_amount, paid_at, created_at, updated_at) VALUES
|
||||
(1, 'AXY202604200001', 'AXY-APP-20260420-0001', 1, 'physical', 'zhongjian', 'paid', 'pending_supplement', '等待您补充资料', '2026-04-21 18:00:00', 'mini_program', '', 199.00, '2026-04-20 09:12:00', '2026-04-20 09:12:00', '{$now}'),
|
||||
(2, 'AXY202604190012', 'AXY-APP-20260419-0012', 1, 'physical', 'anxinyan', 'paid', 'in_first_review', '鉴定师处理中', '2026-04-20 20:00:00', 'h5', '', 99.00, '2026-04-19 13:02:00', '2026-04-19 13:02:00', '{$now}'),
|
||||
(3, 'AXY202604180088', 'AXY-APP-20260418-0088', 1, 'physical', 'zhongjian', 'paid', 'completed', '报告已出具', '2026-04-18 20:00:00', 'enterprise_push', 'ENT-DEMO-001', 199.00, '2026-04-18 08:18:00', '2026-04-18 08:18:00', '{$now}');
|
||||
INSERT INTO appraisal_service_price_packages (id, service_provider, package_name, package_code, price, description, is_enabled, is_default, sort_order, created_at, updated_at) VALUES
|
||||
(1, 'anxinyan', '安心验基础套餐', 'anxinyan_basic', 99.00, '默认服务价格套餐', 1, 1, 1, '{$now}', '{$now}'),
|
||||
(2, 'zhongjian', '中检基础套餐', 'zhongjian_basic', 199.00, '默认服务价格套餐', 1, 1, 1, '{$now}', '{$now}');
|
||||
|
||||
INSERT INTO orders (id, order_no, appraisal_no, user_id, service_mode, service_provider, price_package_id, price_package_name, price_package_code, price_package_price, payment_status, order_status, display_status, estimated_finish_time, source_channel, source_customer_id, pay_amount, paid_at, created_at, updated_at) VALUES
|
||||
(1, 'AXY202604200001', 'AXY-APP-20260420-0001', 1, 'physical', 'zhongjian', 2, '中检基础套餐', 'zhongjian_basic', 199.00, 'paid', 'pending_supplement', '等待您补充资料', '2026-04-21 18:00:00', 'mini_program', '', 199.00, '2026-04-20 09:12:00', '2026-04-20 09:12:00', '{$now}'),
|
||||
(2, 'AXY202604190012', 'AXY-APP-20260419-0012', 1, 'physical', 'anxinyan', 1, '安心验基础套餐', 'anxinyan_basic', 99.00, 'paid', 'in_first_review', '鉴定师处理中', '2026-04-20 20:00:00', 'h5', '', 99.00, '2026-04-19 13:02:00', '2026-04-19 13:02:00', '{$now}'),
|
||||
(3, 'AXY202604180088', 'AXY-APP-20260418-0088', 1, 'physical', 'zhongjian', 2, '中检基础套餐', 'zhongjian_basic', 199.00, 'paid', 'completed', '报告已出具', '2026-04-18 20:00:00', 'enterprise_push', 'ENT-DEMO-001', 199.00, '2026-04-18 08:18:00', '2026-04-18 08:18:00', '{$now}');
|
||||
|
||||
INSERT INTO order_products (id, order_id, category_id, category_name, brand_id, brand_name, color, size_spec, serial_no, product_name, product_cover, created_at, updated_at) VALUES
|
||||
(1, 1, 1, '奢侈品箱包', 1, 'Louis Vuitton', '老花', 'MM', '', 'Louis Vuitton 奢侈品箱包', '', '{$now}', '{$now}'),
|
||||
@@ -294,7 +299,8 @@ INSERT INTO admin_permissions (id, name, code, module, action, created_at, updat
|
||||
(11, '管理仓库', 'warehouses.manage', 'warehouses', 'manage', '{$now}', '{$now}'),
|
||||
(12, '管理物料', 'materials.manage', 'materials', 'manage', '{$now}', '{$now}'),
|
||||
(13, '管理权限', 'access.manage', 'access', 'manage', '{$now}', '{$now}'),
|
||||
(14, '管理系统配置', 'system.manage', 'system_config', 'manage', '{$now}', '{$now}');
|
||||
(14, '管理系统配置', 'system.manage', 'system_config', 'manage', '{$now}', '{$now}'),
|
||||
(15, '管理服务价格', 'service_prices.manage', 'service_prices', 'manage', '{$now}', '{$now}');
|
||||
|
||||
INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALUES
|
||||
(1, 1, 1, '{$now}'),
|
||||
@@ -310,7 +316,8 @@ INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALU
|
||||
(11, 1, 11, '{$now}'),
|
||||
(12, 1, 12, '{$now}'),
|
||||
(13, 1, 13, '{$now}'),
|
||||
(14, 1, 14, '{$now}');
|
||||
(14, 1, 14, '{$now}'),
|
||||
(15, 1, 15, '{$now}');
|
||||
|
||||
INSERT INTO system_configs (id, config_group, config_key, config_value, remark, created_at, updated_at) VALUES
|
||||
(1, 'mini_program', 'app_id', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
@@ -320,19 +327,26 @@ INSERT INTO system_configs (id, config_group, config_key, config_value, remark,
|
||||
(5, 'h5', 'app_secret', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(6, 'h5', 'oauth_redirect_url', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(7, 'h5', 'page_base_url', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(8, 'payment', 'mch_id', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(9, 'payment', 'api_v3_key', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(10, 'payment', 'merchant_serial_no', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(11, 'payment', 'merchant_private_key', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(12, 'payment', 'platform_certificate_serial', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(13, 'payment', 'notify_url', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(14, 'sms', 'access_key_id', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(15, 'sms', 'access_key_secret', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(16, 'sms', 'sign_name', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(17, 'sms', 'login_template_code', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(18, 'sms', 'region_id', 'cn-hangzhou', '后台系统配置', '{$now}', '{$now}'),
|
||||
(19, 'sms', 'endpoint', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(20, 'user_settings', 'user_1', '{\"notify_order\":true,\"notify_report\":true,\"notify_supplement\":true,\"notify_ticket\":true,\"marketing_notify\":false,\"privacy_mode\":false}', '用户端设置偏好', '{$now}', '{$now}');
|
||||
(8, 'payment', 'enabled', 'disabled', '后台系统配置', '{$now}', '{$now}'),
|
||||
(9, 'payment', 'api_domain', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(10, 'payment', 'appid', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(11, 'payment', 'brand_code', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(12, 'payment', 'store_sn', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(13, 'payment', 'store_name', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(14, 'payment', 'workstation_sn', '0', '后台系统配置', '{$now}', '{$now}'),
|
||||
(15, 'payment', 'industry_code', '0', '后台系统配置', '{$now}', '{$now}'),
|
||||
(16, 'payment', 'order_expire_minutes', '1440', '后台系统配置', '{$now}', '{$now}'),
|
||||
(17, 'payment', 'merchant_private_key', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(18, 'payment', 'shouqianba_public_key', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(19, 'payment', 'notify_url', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(20, 'payment', 'mini_program_plugin_version', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(21, 'sms', 'access_key_id', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(22, 'sms', 'access_key_secret', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(23, 'sms', 'sign_name', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(24, 'sms', 'login_template_code', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(25, 'sms', 'region_id', 'cn-hangzhou', '后台系统配置', '{$now}', '{$now}'),
|
||||
(26, 'sms', 'endpoint', '', '后台系统配置', '{$now}', '{$now}'),
|
||||
(27, 'user_settings', 'user_1', '{\"notify_order\":true,\"notify_report\":true,\"notify_supplement\":true,\"notify_ticket\":true,\"marketing_notify\":false,\"privacy_mode\":false}', '用户端设置偏好', '{$now}', '{$now}');
|
||||
");
|
||||
|
||||
echo "SEED_OK\n";
|
||||
|
||||
@@ -83,6 +83,19 @@ function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
|
||||
return $baseUrl === '' ? '' : $baseUrl . '/#/pages/auth/login';
|
||||
}
|
||||
|
||||
function buildShouqianbaNotifyUrl(array $env): string
|
||||
{
|
||||
$baseUrl = trim((string)($env['PUBLIC_FILE_BASE_URL'] ?? ''));
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '/api/open/shouqianba/payment/notify';
|
||||
}
|
||||
|
||||
function checkClientProductionApiBase(array &$issues, string $label, string $envPath): void
|
||||
{
|
||||
$env = @parse_ini_file($envPath);
|
||||
@@ -130,6 +143,7 @@ foreach ($configRows as $row) {
|
||||
$configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? '');
|
||||
}
|
||||
$configMap['h5.oauth_redirect_url'] = buildH5OAuthRedirectUrl((string)($configMap['h5.page_base_url'] ?? ''));
|
||||
$configMap['payment.notify_url'] = buildShouqianbaNotifyUrl($env);
|
||||
|
||||
$requiredConfigKeys = [
|
||||
'mini_program.app_id',
|
||||
@@ -143,18 +157,34 @@ $requiredConfigKeys = [
|
||||
'sms.access_key_secret',
|
||||
'sms.sign_name',
|
||||
'sms.login_template_code',
|
||||
'payment.mch_id',
|
||||
'payment.api_v3_key',
|
||||
'payment.merchant_serial_no',
|
||||
'payment.enabled',
|
||||
'payment.api_domain',
|
||||
'payment.appid',
|
||||
'payment.brand_code',
|
||||
'payment.store_sn',
|
||||
'payment.merchant_private_key',
|
||||
'payment.platform_certificate_serial',
|
||||
'payment.shouqianba_public_key',
|
||||
'payment.notify_url',
|
||||
'payment.mini_program_plugin_version',
|
||||
];
|
||||
|
||||
foreach ($requiredConfigKeys as $key) {
|
||||
check(($configMap[$key] ?? '') !== '', $issues, 'FAIL', "系统配置缺失: {$key}", "后台系统配置中 {$key} 仍为空。");
|
||||
}
|
||||
|
||||
check(($configMap['payment.enabled'] ?? '') === 'enabled', $issues, 'FAIL', '收钱吧支付未启用', '后台系统配置 payment.enabled 必须为 enabled。');
|
||||
foreach (['payment.api_domain' => '收钱吧 API 域名', 'payment.notify_url' => '收钱吧通知地址'] as $key => $label) {
|
||||
if (($configMap[$key] ?? '') !== '') {
|
||||
check(
|
||||
preg_match('/^https?:\/\//i', (string)$configMap[$key]) === 1,
|
||||
$issues,
|
||||
'FAIL',
|
||||
"{$label} 格式不正确",
|
||||
"后台系统配置 {$key} 需以 http:// 或 https:// 开头。"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (($configMap['h5.page_base_url'] ?? '') !== '' && isPlaceholderApiBase((string)$configMap['h5.page_base_url'])) {
|
||||
add_issue($issues, 'FAIL', 'H5 页面根地址未配置正式域名', '后台系统配置 h5.page_base_url 仍为本地或占位地址,扫码公开链接将无法用于正式环境。');
|
||||
}
|
||||
@@ -162,8 +192,7 @@ if (($configMap['h5.page_base_url'] ?? '') !== '' && isPlaceholderApiBase((strin
|
||||
$demoValues = [
|
||||
'mini_program.app_id' => 'wx1234567890test',
|
||||
'h5.app_id' => 'h5_app_demo',
|
||||
'payment.mch_id' => '1900000109',
|
||||
'payment.api_v3_key' => 'demo_api_v3_key_1234567890',
|
||||
'payment.api_domain' => 'https://example.com',
|
||||
];
|
||||
|
||||
foreach ($demoValues as $key => $value) {
|
||||
@@ -211,6 +240,27 @@ if (!is_array($manifest)) {
|
||||
'manifest.json 中 mp-weixin.appid 与后台系统配置 mini_program.app_id 不一致,请先执行配置同步。'
|
||||
);
|
||||
}
|
||||
$plugin = $manifest['mp-weixin']['plugins']['lite-pos-plugin'] ?? null;
|
||||
if (!is_array($plugin)) {
|
||||
add_issue($issues, 'FAIL', '小程序未声明收钱吧插件', 'manifest.json 中缺少 mp-weixin.plugins.lite-pos-plugin,请先执行配置同步。');
|
||||
} else {
|
||||
check(
|
||||
($plugin['provider'] ?? '') === 'wx7903bb295ac26ac7',
|
||||
$issues,
|
||||
'FAIL',
|
||||
'小程序收钱吧插件 provider 不正确',
|
||||
'manifest.json 中 lite-pos-plugin.provider 必须为 wx7903bb295ac26ac7。'
|
||||
);
|
||||
if (($configMap['payment.mini_program_plugin_version'] ?? '') !== '') {
|
||||
check(
|
||||
($plugin['version'] ?? '') === $configMap['payment.mini_program_plugin_version'],
|
||||
$issues,
|
||||
'FAIL',
|
||||
'小程序收钱吧插件版本未同步后台配置',
|
||||
'manifest.json 中 lite-pos-plugin.version 与后台 payment.mini_program_plugin_version 不一致,请先执行配置同步。'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkClientProductionApiBase($issues, 'admin-web', $projectRoot . '/admin-web/.env.production');
|
||||
|
||||
133
server-api/tools/schema_upgrade_service_price_packages.php
Normal file
133
server-api/tools/schema_upgrade_service_price_packages.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||
$_ENV['DB_PORT'] ?? '3306',
|
||||
$_ENV['DB_DATABASE'] ?? '',
|
||||
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$_ENV['DB_USERNAME'] ?? '',
|
||||
$_ENV['DB_PASSWORD'] ?? '',
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
|
||||
function column_exists(PDO $pdo, string $table, string $column): bool
|
||||
{
|
||||
$stmt = $pdo->prepare("SHOW COLUMNS FROM {$table} LIKE ?");
|
||||
$stmt->execute([$column]);
|
||||
return (bool)$stmt->fetch();
|
||||
}
|
||||
|
||||
function add_column_if_missing(PDO $pdo, string $table, string $column, string $definition): void
|
||||
{
|
||||
if (column_exists($pdo, $table, $column)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$definition}");
|
||||
echo "ADD_COLUMN {$table}.{$column}\n";
|
||||
}
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS appraisal_service_price_packages (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||
package_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
package_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
description VARCHAR(500) NOT NULL DEFAULT '',
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_appraisal_service_price_packages_code (service_provider, package_code),
|
||||
KEY idx_appraisal_service_price_packages_provider (service_provider),
|
||||
KEY idx_appraisal_service_price_packages_enabled (is_enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定服务价格套餐'
|
||||
SQL);
|
||||
|
||||
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_id', 'BIGINT UNSIGNED NULL DEFAULT NULL AFTER service_provider');
|
||||
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_name', "VARCHAR(128) NOT NULL DEFAULT '' AFTER price_package_id");
|
||||
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_code', "VARCHAR(64) NOT NULL DEFAULT '' AFTER price_package_name");
|
||||
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_price', 'DECIMAL(10,2) NULL DEFAULT NULL AFTER price_package_code');
|
||||
|
||||
add_column_if_missing($pdo, 'orders', 'price_package_id', 'BIGINT UNSIGNED NULL DEFAULT NULL AFTER service_provider');
|
||||
add_column_if_missing($pdo, 'orders', 'price_package_name', "VARCHAR(128) NOT NULL DEFAULT '' AFTER price_package_id");
|
||||
add_column_if_missing($pdo, 'orders', 'price_package_code', "VARCHAR(64) NOT NULL DEFAULT '' AFTER price_package_name");
|
||||
add_column_if_missing($pdo, 'orders', 'price_package_price', 'DECIMAL(10,2) NULL DEFAULT NULL AFTER price_package_code');
|
||||
|
||||
$indexRows = $pdo->query("SHOW INDEX FROM orders WHERE Key_name = 'idx_orders_price_package_id'")->fetchAll();
|
||||
if (!$indexRows) {
|
||||
$pdo->exec('ALTER TABLE orders ADD KEY idx_orders_price_package_id (price_package_id)');
|
||||
echo "ADD_INDEX orders.idx_orders_price_package_id\n";
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$seedStmt = $pdo->prepare(
|
||||
'INSERT INTO appraisal_service_price_packages (service_provider, package_name, package_code, price, description, is_enabled, is_default, sort_order, created_at, updated_at)
|
||||
SELECT ?, ?, ?, ?, ?, 1, 1, 1, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM appraisal_service_price_packages WHERE service_provider = ? AND package_code = ?
|
||||
)'
|
||||
);
|
||||
$seedStmt->execute(['anxinyan', '安心验基础套餐', 'anxinyan_basic', 99.00, '默认服务价格套餐', $now, $now, 'anxinyan', 'anxinyan_basic']);
|
||||
$seedStmt->execute(['zhongjian', '中检基础套餐', 'zhongjian_basic', 199.00, '默认服务价格套餐', $now, $now, 'zhongjian', 'zhongjian_basic']);
|
||||
|
||||
foreach (['anxinyan', 'zhongjian'] as $serviceProvider) {
|
||||
$candidateStmt = $pdo->prepare('SELECT id FROM appraisal_service_price_packages WHERE service_provider = ? AND is_enabled = 1 ORDER BY is_default DESC, sort_order ASC, id ASC LIMIT 1');
|
||||
$candidateStmt->execute([$serviceProvider]);
|
||||
$candidateId = (int)$candidateStmt->fetchColumn();
|
||||
if ($candidateId > 0) {
|
||||
$clearStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 0, updated_at = ? WHERE service_provider = ? AND id <> ? AND is_default = 1');
|
||||
$clearStmt->execute([$now, $serviceProvider, $candidateId]);
|
||||
|
||||
$defaultStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 1, updated_at = ? WHERE id = ? AND is_default <> 1');
|
||||
$defaultStmt->execute([$now, $candidateId]);
|
||||
echo "NORMALIZE_DEFAULT_PACKAGE {$serviceProvider}:{$candidateId}\n";
|
||||
} else {
|
||||
$clearStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 0, updated_at = ? WHERE service_provider = ? AND is_default = 1');
|
||||
$clearStmt->execute([$now, $serviceProvider]);
|
||||
}
|
||||
}
|
||||
|
||||
$permissionStmt = $pdo->prepare(
|
||||
'INSERT INTO admin_permissions (name, code, module, action, created_at, updated_at)
|
||||
SELECT ?, ?, ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM admin_permissions WHERE code = ?)'
|
||||
);
|
||||
$permissionStmt->execute(['管理服务价格', 'service_prices.manage', 'service_prices', 'manage', $now, $now, 'service_prices.manage']);
|
||||
$permissionIdStmt = $pdo->prepare('SELECT id FROM admin_permissions WHERE code = ?');
|
||||
$permissionIdStmt->execute(['service_prices.manage']);
|
||||
$permissionId = (int)$permissionIdStmt->fetchColumn();
|
||||
|
||||
$roleIdStmt = $pdo->prepare('SELECT id FROM admin_roles WHERE code = ? LIMIT 1');
|
||||
$roleIdStmt->execute(['super_admin']);
|
||||
$roleId = (int)$roleIdStmt->fetchColumn();
|
||||
if ($permissionId > 0 && $roleId > 0) {
|
||||
$rolePermissionStmt = $pdo->prepare(
|
||||
'INSERT INTO admin_role_permissions (role_id, permission_id, created_at)
|
||||
SELECT ?, ?, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM admin_role_permissions WHERE role_id = ? AND permission_id = ?)'
|
||||
);
|
||||
$rolePermissionStmt->execute([$roleId, $permissionId, $now, $roleId, $permissionId]);
|
||||
}
|
||||
|
||||
$pdo->exec("DELETE FROM system_configs WHERE config_group = 'appraisal_service'");
|
||||
|
||||
echo "SCHEMA_UPGRADE_SERVICE_PRICE_PACKAGES_OK\n";
|
||||
115
server-api/tools/schema_upgrade_shouqianba_payment.php
Normal file
115
server-api/tools/schema_upgrade_shouqianba_payment.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||
$_ENV['DB_PORT'] ?? '3306',
|
||||
$_ENV['DB_DATABASE'] ?? '',
|
||||
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$_ENV['DB_USERNAME'] ?? '',
|
||||
$_ENV['DB_PASSWORD'] ?? '',
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
|
||||
function hasTable(PDO $pdo, string $table): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
|
||||
$stmt->execute([$table]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function hasSystemConfig(PDO $pdo, string $group, string $key): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM system_configs WHERE config_group = ? AND config_key = ?');
|
||||
$stmt->execute([$group, $key]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function hasColumn(PDO $pdo, string $table, string $column): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
|
||||
$stmt->execute([$table, $column]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if (hasTable($pdo, 'orders') && !hasColumn($pdo, 'orders', 'cancelled_at')) {
|
||||
$pdo->exec('ALTER TABLE orders ADD COLUMN cancelled_at DATETIME NULL DEFAULT NULL AFTER paid_at');
|
||||
echo "ADD_COLUMN orders.cancelled_at\n";
|
||||
}
|
||||
|
||||
if (!hasTable($pdo, 'shouqianba_payments')) {
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE shouqianba_payments (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
check_sn VARCHAR(32) NOT NULL,
|
||||
order_sn VARCHAR(32) NOT NULL DEFAULT '',
|
||||
order_token VARCHAR(64) NOT NULL DEFAULT '',
|
||||
cashier_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
order_image_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
order_landing_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
scene VARCHAR(8) NOT NULL DEFAULT '',
|
||||
source_channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
amount INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT '156',
|
||||
request_json LONGTEXT NULL,
|
||||
response_json LONGTEXT NULL,
|
||||
notify_json LONGTEXT NULL,
|
||||
paid_at DATETIME NULL DEFAULT NULL,
|
||||
cancelled_at DATETIME NULL DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shouqianba_payments_check_sn (check_sn),
|
||||
KEY idx_shouqianba_payments_order_id (order_id),
|
||||
KEY idx_shouqianba_payments_order_sn (order_sn),
|
||||
KEY idx_shouqianba_payments_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收钱吧支付流水'
|
||||
SQL);
|
||||
echo "CREATE_TABLE shouqianba_payments\n";
|
||||
}
|
||||
|
||||
$configs = [
|
||||
['payment', 'enabled', 'disabled'],
|
||||
['payment', 'api_domain', ''],
|
||||
['payment', 'appid', ''],
|
||||
['payment', 'brand_code', ''],
|
||||
['payment', 'store_sn', ''],
|
||||
['payment', 'store_name', ''],
|
||||
['payment', 'workstation_sn', '0'],
|
||||
['payment', 'industry_code', '0'],
|
||||
['payment', 'order_expire_minutes', '1440'],
|
||||
['payment', 'merchant_private_key', ''],
|
||||
['payment', 'shouqianba_public_key', ''],
|
||||
['payment', 'notify_url', ''],
|
||||
['payment', 'mini_program_plugin_version', ''],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO system_configs (config_group, config_key, config_value, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
foreach ($configs as [$group, $key, $value]) {
|
||||
if (hasSystemConfig($pdo, $group, $key)) {
|
||||
continue;
|
||||
}
|
||||
$stmt->execute([$group, $key, $value, '后台系统配置', $now, $now]);
|
||||
echo "ADD_CONFIG {$group}.{$key}\n";
|
||||
}
|
||||
|
||||
echo "SCHEMA_UPGRADE_OK\n";
|
||||
388
server-api/tools/shouqianba_payment_mock_test.php
Normal file
388
server-api/tools/shouqianba_payment_mock_test.php
Normal file
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->safeLoad();
|
||||
|
||||
use app\support\ShouqianbaClient;
|
||||
use app\support\ShouqianbaConfigService;
|
||||
use app\support\ShouqianbaPaymentService;
|
||||
use support\think\Db;
|
||||
|
||||
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
|
||||
|
||||
class MockShouqianbaClient extends ShouqianbaClient
|
||||
{
|
||||
public array $queryData = [];
|
||||
public int $voidCalls = 0;
|
||||
|
||||
public function purchase(array $body): array
|
||||
{
|
||||
return [
|
||||
'request' => ['body' => $body],
|
||||
'response' => ['response' => ['body' => ['biz_response' => ['data' => [
|
||||
'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8),
|
||||
'order_token' => 'mock_token_' . $body['check_sn'],
|
||||
'cashier_url' => 'https://cashier.mock/' . $body['check_sn'],
|
||||
]]]]],
|
||||
'data' => [
|
||||
'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8),
|
||||
'order_token' => 'mock_token_' . $body['check_sn'],
|
||||
'cashier_url' => 'https://cashier.mock/' . $body['check_sn'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $body): array
|
||||
{
|
||||
return [
|
||||
'request' => ['body' => $body],
|
||||
'response' => ['response' => ['body' => ['biz_response' => ['data' => $this->queryData]]]],
|
||||
'data' => $this->queryData,
|
||||
];
|
||||
}
|
||||
|
||||
public function void(array $body): array
|
||||
{
|
||||
$this->voidCalls++;
|
||||
|
||||
return [
|
||||
'request' => ['body' => $body],
|
||||
'response' => ['response' => ['body' => ['biz_response' => ['data' => [
|
||||
'order_status' => '0',
|
||||
'check_sn' => (string)($body['original_check_sn'] ?? ''),
|
||||
]]]]],
|
||||
'data' => [
|
||||
'order_status' => '0',
|
||||
'check_sn' => (string)($body['original_check_sn'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function decodeNotification(string $rawBody): array
|
||||
{
|
||||
$data = json_decode($rawBody, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('mock notification json invalid');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConfig(string $group, string $key, string $value): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = Db::name('system_configs')
|
||||
->where('config_group', $group)
|
||||
->where('config_key', $key)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'config_group' => $group,
|
||||
'config_key' => $key,
|
||||
'config_value' => $value,
|
||||
'remark' => '收钱吧支付 mock 测试配置',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
Db::name('system_configs')->where('id', (int)$exists['id'])->update($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('system_configs')->insert($payload);
|
||||
}
|
||||
|
||||
function captureConfigs(array $keys): array
|
||||
{
|
||||
$snapshot = [];
|
||||
foreach ($keys as $key) {
|
||||
[$group, $configKey] = explode('.', $key, 2);
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $group)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
$snapshot[$key] = $row ?: null;
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
function restoreConfigs(array $snapshot): void
|
||||
{
|
||||
foreach ($snapshot as $key => $row) {
|
||||
[$group, $configKey] = explode('.', $key, 2);
|
||||
if ($row) {
|
||||
Db::name('system_configs')
|
||||
->where('config_group', $group)
|
||||
->where('config_key', $configKey)
|
||||
->update([
|
||||
'config_value' => (string)($row['config_value'] ?? ''),
|
||||
'remark' => (string)($row['remark'] ?? '后台系统配置'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('system_configs')
|
||||
->where('config_group', $group)
|
||||
->where('config_key', $configKey)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupMockData(): void
|
||||
{
|
||||
$orderIds = Db::name('orders')->whereLike('order_no', 'SQBMOCK%')->column('id');
|
||||
if ($orderIds) {
|
||||
Db::name('shouqianba_payments')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('message_logs')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
|
||||
Db::name('user_messages')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
|
||||
Db::name('appraisal_tasks')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('order_timelines')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('order_shipping_targets')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('order_return_addresses')->whereIn('order_id', $orderIds)->delete();
|
||||
$uploadItemIds = Db::name('order_upload_items')->whereIn('order_id', $orderIds)->column('id');
|
||||
if ($uploadItemIds) {
|
||||
Db::name('order_upload_files')->whereIn('order_upload_item_id', $uploadItemIds)->delete();
|
||||
}
|
||||
Db::name('order_upload_items')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('order_extras')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('order_products')->whereIn('order_id', $orderIds)->delete();
|
||||
Db::name('orders')->whereIn('id', $orderIds)->delete();
|
||||
}
|
||||
|
||||
$userIds = Db::name('users')->whereLike('mobile', '1399919%')->column('id');
|
||||
if ($userIds) {
|
||||
Db::name('user_auths')->whereIn('user_id', $userIds)->delete();
|
||||
Db::name('user_addresses')->whereIn('user_id', $userIds)->delete();
|
||||
Db::name('users')->whereIn('id', $userIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
function createMockUser(): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => '收钱吧支付测试用户',
|
||||
'avatar' => '',
|
||||
'mobile' => '13999190001',
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('user_addresses')->insert([
|
||||
'user_id' => $userId,
|
||||
'consignee' => '测试用户',
|
||||
'mobile' => '13999190001',
|
||||
'province' => '广东省',
|
||||
'city' => '深圳市',
|
||||
'district' => '南山区',
|
||||
'detail_address' => '收钱吧测试地址',
|
||||
'is_default' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
function createMockOrder(int $userId, string $suffix): int
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$orderId = (int)Db::name('orders')->insertGetId([
|
||||
'order_no' => 'SQBMOCK' . $suffix,
|
||||
'appraisal_no' => 'SQB-MOCK-' . $suffix,
|
||||
'user_id' => $userId,
|
||||
'service_mode' => 'physical',
|
||||
'service_provider' => 'anxinyan',
|
||||
'payment_status' => 'unpaid',
|
||||
'order_status' => 'pending_payment',
|
||||
'display_status' => '待支付',
|
||||
'estimated_finish_time' => date('Y-m-d H:i:s', strtotime('+48 hours')),
|
||||
'source_channel' => 'h5',
|
||||
'source_customer_id' => '',
|
||||
'pay_amount' => 9.99,
|
||||
'paid_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_products')->insert([
|
||||
'order_id' => $orderId,
|
||||
'category_id' => null,
|
||||
'category_name' => '测试品类',
|
||||
'brand_id' => null,
|
||||
'brand_name' => '测试品牌',
|
||||
'color' => '',
|
||||
'size_spec' => '',
|
||||
'serial_no' => '',
|
||||
'product_name' => '收钱吧支付测试商品',
|
||||
'product_cover' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_extras')->insert([
|
||||
'order_id' => $orderId,
|
||||
'purchase_channel' => '',
|
||||
'purchase_price' => 0,
|
||||
'purchase_date' => null,
|
||||
'usage_status' => '',
|
||||
'condition_desc' => '',
|
||||
'has_accessories' => 0,
|
||||
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'remark' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insert([
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'created',
|
||||
'node_text' => '订单已生成',
|
||||
'node_desc' => '订单资料已保存,等待用户完成支付。',
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
return $orderId;
|
||||
}
|
||||
|
||||
function latestPayment(int $orderId): array
|
||||
{
|
||||
$payment = Db::name('shouqianba_payments')->where('order_id', $orderId)->order('id', 'desc')->find();
|
||||
assertTrue((bool)$payment, 'payment row missing');
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
$configKeys = [
|
||||
'payment.enabled',
|
||||
'payment.api_domain',
|
||||
'payment.appid',
|
||||
'payment.brand_code',
|
||||
'payment.store_sn',
|
||||
'payment.store_name',
|
||||
'payment.workstation_sn',
|
||||
'payment.industry_code',
|
||||
'payment.order_expire_minutes',
|
||||
'payment.merchant_private_key',
|
||||
'payment.shouqianba_public_key',
|
||||
'payment.notify_url',
|
||||
'payment.mini_program_plugin_version',
|
||||
'h5.page_base_url',
|
||||
];
|
||||
|
||||
$snapshot = captureConfigs($configKeys);
|
||||
$client = new MockShouqianbaClient(new ShouqianbaConfigService());
|
||||
$service = new ShouqianbaPaymentService(null, $client);
|
||||
|
||||
try {
|
||||
cleanupMockData();
|
||||
|
||||
ensureConfig('payment', 'enabled', 'enabled');
|
||||
ensureConfig('payment', 'api_domain', 'https://mock.shouqianba.test');
|
||||
ensureConfig('payment', 'appid', 'sqb_mock_appid');
|
||||
ensureConfig('payment', 'brand_code', 'mock_brand');
|
||||
ensureConfig('payment', 'store_sn', 'mock_store');
|
||||
ensureConfig('payment', 'store_name', 'mock store');
|
||||
ensureConfig('payment', 'workstation_sn', '0');
|
||||
ensureConfig('payment', 'industry_code', '0');
|
||||
ensureConfig('payment', 'order_expire_minutes', '1440');
|
||||
ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----");
|
||||
ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----");
|
||||
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
|
||||
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
|
||||
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
|
||||
|
||||
$userId = createMockUser();
|
||||
|
||||
$notifyOrderId = createMockOrder($userId, 'NOTIFY');
|
||||
$launch = $service->createOrReusePayment($notifyOrderId);
|
||||
assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment');
|
||||
assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
|
||||
$payment = latestPayment($notifyOrderId);
|
||||
$notifyPayload = [
|
||||
'check_sn' => $payment['check_sn'],
|
||||
'order_status' => '4',
|
||||
'amount' => (int)$payment['amount'],
|
||||
'order_sn' => 'SQBMOCKPAID',
|
||||
];
|
||||
$service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE));
|
||||
$service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE));
|
||||
$paidOrder = Db::name('orders')->where('id', $notifyOrderId)->find();
|
||||
assertTrue(($paidOrder['payment_status'] ?? '') === 'paid', 'notify should mark order paid');
|
||||
assertTrue(($paidOrder['order_status'] ?? '') === 'pending_shipping', 'notify should move order to pending_shipping');
|
||||
assertTrue((int)Db::name('appraisal_tasks')->where('order_id', $notifyOrderId)->count() === 1, 'notify should be idempotent for appraisal task');
|
||||
assertTrue((int)Db::name('order_timelines')->where('order_id', $notifyOrderId)->where('node_code', 'payment_paid')->count() === 1, 'notify should be idempotent for paid timeline');
|
||||
|
||||
$queryOrderId = createMockOrder($userId, 'QUERY');
|
||||
$service->createOrReusePayment($queryOrderId);
|
||||
$queryPayment = latestPayment($queryOrderId);
|
||||
$client->queryData = [
|
||||
'check_sn' => $queryPayment['check_sn'],
|
||||
'order_status' => '4',
|
||||
'amount' => (int)$queryPayment['amount'],
|
||||
'order_sn' => 'SQBMOCKQUERYPAID',
|
||||
];
|
||||
$sync = $service->syncOrderPaymentStatus($queryOrderId, $userId);
|
||||
assertTrue(($sync['payment_status'] ?? '') === 'paid', 'query sync should mark paid');
|
||||
|
||||
$cancelOrderId = createMockOrder($userId, 'CANCEL');
|
||||
$service->createOrReusePayment($cancelOrderId);
|
||||
$cancelPayment = latestPayment($cancelOrderId);
|
||||
$client->queryData = [
|
||||
'check_sn' => $cancelPayment['check_sn'],
|
||||
'order_status' => '1',
|
||||
'amount' => (int)$cancelPayment['amount'],
|
||||
'order_sn' => 'SQBMOCKCANCELPENDING',
|
||||
];
|
||||
$cancel = $service->cancelPendingOrder($cancelOrderId, $userId);
|
||||
assertTrue(($cancel['order_status'] ?? '') === 'cancelled', 'cancel should mark order cancelled');
|
||||
assertTrue($client->voidCalls === 1, 'cancel should call remote void once');
|
||||
|
||||
$paidCancelOrderId = createMockOrder($userId, 'PAIDCANCEL');
|
||||
$service->createOrReusePayment($paidCancelOrderId);
|
||||
$paidCancelPayment = latestPayment($paidCancelOrderId);
|
||||
$client->queryData = [
|
||||
'check_sn' => $paidCancelPayment['check_sn'],
|
||||
'order_status' => '4',
|
||||
'amount' => (int)$paidCancelPayment['amount'],
|
||||
'order_sn' => 'SQBMOCKPAIDCANCEL',
|
||||
];
|
||||
$thrown = false;
|
||||
try {
|
||||
$service->cancelPendingOrder($paidCancelOrderId, $userId);
|
||||
} catch (RuntimeException $e) {
|
||||
$thrown = str_contains($e->getMessage(), '已支付');
|
||||
}
|
||||
$paidCancelOrder = Db::name('orders')->where('id', $paidCancelOrderId)->find();
|
||||
assertTrue($thrown, 'cancel paid remote order should be rejected');
|
||||
assertTrue(($paidCancelOrder['payment_status'] ?? '') === 'paid', 'cancel paid remote order should sync paid state');
|
||||
|
||||
echo "SHOUQIANBA_PAYMENT_MOCK_TEST_OK\n";
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "SHOUQIANBA_PAYMENT_MOCK_TEST_FAIL: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
} finally {
|
||||
cleanupMockData();
|
||||
restoreConfigs($snapshot);
|
||||
}
|
||||
@@ -9,6 +9,74 @@ $dotenv->safeLoad();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$manifestPath = $projectRoot . '/user-app/src/manifest.json';
|
||||
$litePosPluginProvider = 'wx7903bb295ac26ac7';
|
||||
$litePosPluginExport = 'miniprogram_npm/lite-pos-plugin-mate/utils.js';
|
||||
|
||||
function replaceMpWeixinPlugin(string $content, string $version, string $provider, string $exportPath): string
|
||||
{
|
||||
if (!preg_match('/"mp-weixin"\s*:\s*\{/', $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
throw new RuntimeException('未在 manifest.json 中找到 mp-weixin 配置块。');
|
||||
}
|
||||
|
||||
$blockStart = $matches[0][1] + strlen($matches[0][0]) - 1;
|
||||
$length = strlen($content);
|
||||
$depth = 0;
|
||||
$inString = false;
|
||||
$escaped = false;
|
||||
$blockEnd = -1;
|
||||
for ($i = $blockStart; $i < $length; $i++) {
|
||||
$char = $content[$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 === '{') {
|
||||
$depth++;
|
||||
continue;
|
||||
}
|
||||
if ($char === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$blockEnd = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($blockEnd < 0) {
|
||||
throw new RuntimeException('manifest.json 中 mp-weixin 配置块不完整。');
|
||||
}
|
||||
|
||||
$block = substr($content, $blockStart, $blockEnd - $blockStart + 1);
|
||||
$block = preg_replace('/,\s*"plugins"\s*:\s*\{\s*"lite-pos-plugin"\s*:\s*\{.*?\}\s*\}/s', '', $block, 1);
|
||||
if (!is_string($block)) {
|
||||
throw new RuntimeException('清理 manifest.json 小程序插件配置失败。');
|
||||
}
|
||||
|
||||
$pluginBlock = sprintf(
|
||||
",\n \"plugins\" : {\n \"lite-pos-plugin\" : {\n \"version\" : \"%s\",\n \"provider\" : \"%s\",\n \"export\" : \"%s\"\n }\n }",
|
||||
addcslashes($version, "\\\""),
|
||||
addcslashes($provider, "\\\""),
|
||||
addcslashes($exportPath, "\\\"")
|
||||
);
|
||||
$updatedBlock = rtrim(substr($block, 0, -1)) . $pluginBlock . "\n }";
|
||||
|
||||
return substr($content, 0, $blockStart) . $updatedBlock . substr($content, $blockEnd + 1);
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
@@ -39,6 +107,10 @@ try {
|
||||
if ($miniProgramAppId === '') {
|
||||
throw new RuntimeException('后台系统配置 mini_program.app_id 为空,无法同步到 user-app manifest。');
|
||||
}
|
||||
$litePosPluginVersion = trim($configMap['payment.mini_program_plugin_version'] ?? '');
|
||||
if ($litePosPluginVersion === '') {
|
||||
throw new RuntimeException('后台系统配置 payment.mini_program_plugin_version 为空,无法同步收钱吧小程序插件。');
|
||||
}
|
||||
|
||||
$manifestContent = @file_get_contents($manifestPath);
|
||||
if ($manifestContent === false) {
|
||||
@@ -60,6 +132,7 @@ try {
|
||||
if (!is_string($updatedContent) || $updatedContent === '') {
|
||||
throw new RuntimeException('同步 manifest.json 失败。');
|
||||
}
|
||||
$updatedContent = replaceMpWeixinPlugin($updatedContent, $litePosPluginVersion, $litePosPluginProvider, $litePosPluginExport);
|
||||
|
||||
if (@file_put_contents($manifestPath, $updatedContent) === false) {
|
||||
throw new RuntimeException('写入 user-app/src/manifest.json 失败。');
|
||||
@@ -67,6 +140,7 @@ try {
|
||||
|
||||
echo "SYNC_CLIENT_CONFIG_OK\n";
|
||||
echo "mini_program.app_id => {$miniProgramAppId}\n";
|
||||
echo "payment.mini_program_plugin_version => {$litePosPluginVersion}\n";
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "SYNC_CLIENT_CONFIG_FAIL: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
|
||||
Reference in New Issue
Block a user