diff --git a/.gitignore b/.gitignore index ecdeb9d..61216fc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ releases/ # local Codex artifacts .codex-artifacts/ + +# local payment keys +server-api/storage/payment-certs/shouqianba_merchant_* diff --git a/admin-web/src/api/admin.ts b/admin-web/src/api/admin.ts index 46a473c..d608ffd 100644 --- a/admin-web/src/api/admin.ts +++ b/admin-web/src/api/admin.ts @@ -179,6 +179,9 @@ export interface AdminOrderListItem { brand_name: string; service_provider: string; service_provider_text: string; + price_package_name: string; + price_package_code: string; + price_package_price: number; source_channel: string; source_channel_text: string; source_customer_id: string; @@ -196,6 +199,9 @@ export interface AdminOrderDetail { appraisal_no: string; service_provider: string; service_provider_text: string; + price_package_name: string; + price_package_code: string; + price_package_price: number; source_channel: string; source_channel_text: string; source_customer_id: string; @@ -320,6 +326,8 @@ export interface AdminManualOrderMaterialItem { export interface AdminManualOrderCreatePayload { service_provider: string; + price_package_id?: number; + price_package_code?: string; product_info: { category_id: number; brand_id: number; @@ -370,6 +378,7 @@ export interface AdminManualOrderMeta { category_ids: number[]; supported_service_types: string[]; }>; + service_price_packages: AdminServicePriceProviderOption[]; } export interface CatalogOverviewCard { @@ -1404,6 +1413,44 @@ export interface AdminSystemConfigUploadResult { original_name: string; } +export interface AdminServicePricePackage { + id: number; + service_provider: string; + service_provider_text: string; + package_name: string; + package_code: string; + price: number; + description: string; + is_enabled: boolean; + is_default: boolean; + sort_order: number; + sla_hours: number; + created_at: string; + updated_at: string; +} + +export interface AdminServicePriceProviderOption { + service_provider: string; + service_provider_text: string; + price: number; + sla_hours: number; + default_package_id: number; + default_package: AdminServicePricePackage | null; + packages: AdminServicePricePackage[]; +} + +export interface AdminServicePricePayload { + id?: number; + service_provider: string; + package_name: string; + package_code: string; + price: number; + description: string; + is_enabled: boolean; + is_default: boolean; + sort_order: number; +} + export interface AdminPageVisualsConfig { order_background_image_url: string; report_background_image_url: string; @@ -2317,6 +2364,46 @@ export const adminApi = { data: { id: number }; }>; }, + getServicePricePackages() { + return request.get("/api/admin/service-price-packages") as Promise<{ + code: number; + message: string; + data: { + providers: Array<{ + service_provider: string; + service_provider_text: string; + sla_hours: number; + }>; + list: AdminServicePricePackage[]; + }; + }>; + }, + saveServicePricePackage(data: AdminServicePricePayload) { + return request.post("/api/admin/service-price-package/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + updateServicePricePackageStatus(id: number, isEnabled: boolean) { + return request.post("/api/admin/service-price-package/status", { + id, + is_enabled: isEnabled, + }) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + setDefaultServicePricePackage(id: number) { + return request.post("/api/admin/service-price-package/default", { + id, + }) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, getExpressCompanies(params?: { enabled_only?: 0 | 1 }) { return request.get("/api/admin/express-companies", { params }) as Promise<{ code: number; diff --git a/admin-web/src/layouts/AdminLayout.vue b/admin-web/src/layouts/AdminLayout.vue index f968fcc..beae968 100644 --- a/admin-web/src/layouts/AdminLayout.vue +++ b/admin-web/src/layouts/AdminLayout.vue @@ -1,7 +1,7 @@ + + + + diff --git a/admin-web/src/pages/system-config/index.vue b/admin-web/src/pages/system-config/index.vue index e39a979..bd38b97 100644 --- a/admin-web/src/pages/system-config/index.vue +++ b/admin-web/src/pages/system-config/index.vue @@ -86,13 +86,14 @@ function buildH5OAuthRedirectUrl(pageBaseUrl: string) { } function applyDerivedConfigValues(group: AdminSystemConfigGroupItem) { - if (group.group_code !== "h5") return; + if (group.group_code === "h5") { + const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url"); + const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url"); + if (!oauthRedirectUrl) return; - const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url"); - const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url"); - if (!oauthRedirectUrl) return; - - oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || ""); + oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || ""); + return; + } } function handleFieldValueChange( @@ -120,7 +121,7 @@ async function saveGroup(group: AdminSystemConfigGroupItem) { ElMessage.success(`${group.group_name}已保存`); } catch (error) { console.error(error); - ElMessage.error(`${group.group_name}保存失败`); + ElMessage.error(error instanceof Error ? error.message : `${group.group_name}保存失败`); } finally { savingGroupCode.value = ""; } @@ -262,7 +263,7 @@ onMounted(fetchConfigs); v-else-if="item.field_type !== 'textarea'" :model-value="item.value" :type="item.field_type === 'password' ? 'password' : 'text'" - show-password + :show-password="item.field_type === 'password'" :disabled="item.read_only" :placeholder="item.placeholder" @update:model-value="handleFieldValueChange(group, item, $event)" diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts index c497460..1f9e479 100644 --- a/admin-web/src/router/index.ts +++ b/admin-web/src/router/index.ts @@ -113,6 +113,16 @@ const adminChildren = [ permission: "warehouses.manage", }, }, + { + path: "service-prices", + name: "service-prices", + component: () => import("../pages/service-prices/index.vue"), + meta: { + title: "服务价格", + desc: "维护安心验与中检服务的可选价格套餐。", + permission: "service_prices.manage", + }, + }, { path: "express-companies", name: "express-companies", diff --git a/server-api/app/controller/admin/OrdersController.php b/server-api/app/controller/admin/OrdersController.php index 8db1198..b470910 100644 --- a/server-api/app/controller/admin/OrdersController.php +++ b/server-api/app/controller/admin/OrdersController.php @@ -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 diff --git a/server-api/app/controller/admin/ServicePricePackagesController.php b/server-api/app/controller/admin/ServicePricePackagesController.php new file mode 100644 index 0000000..5355bde --- /dev/null +++ b/server-api/app/controller/admin/ServicePricePackagesController.php @@ -0,0 +1,87 @@ +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(); + } +} diff --git a/server-api/app/controller/admin/SystemConfigsController.php b/server-api/app/controller/admin/SystemConfigsController.php index 04d623e..a91ac3d 100644 --- a/server-api/app/controller/admin/SystemConfigsController.php +++ b/server-api/app/controller/admin/SystemConfigsController.php @@ -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); diff --git a/server-api/app/controller/app/AppraisalController.php b/server-api/app/controller/app/AppraisalController.php index 45af524..da98b01 100644 --- a/server-api/app/controller/app/AppraisalController.php +++ b/server-api/app/controller/app/AppraisalController.php @@ -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 diff --git a/server-api/app/controller/app/AuthController.php b/server-api/app/controller/app/AuthController.php index 9e5dc8d..39d5fe0 100644 --- a/server-api/app/controller/app/AuthController.php +++ b/server-api/app/controller/app/AuthController.php @@ -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); diff --git a/server-api/app/controller/app/OrdersController.php b/server-api/app/controller/app/OrdersController.php index 19a72aa..e5bafc4 100644 --- a/server-api/app/controller/app/OrdersController.php +++ b/server-api/app/controller/app/OrdersController.php @@ -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 diff --git a/server-api/app/controller/open/ShouqianbaPaymentController.php b/server-api/app/controller/open/ShouqianbaPaymentController.php new file mode 100644 index 0000000..68b93e0 --- /dev/null +++ b/server-api/app/controller/open/ShouqianbaPaymentController.php @@ -0,0 +1,20 @@ +handleNotification($request->rawBody()); + return json($service->notificationResponse(true)); + } catch (\Throwable $e) { + return json($service->notificationResponse(false)); + } + } +} diff --git a/server-api/app/middleware/AdminAuthMiddleware.php b/server-api/app/middleware/AdminAuthMiddleware.php index a8e257a..649ff53 100644 --- a/server-api/app/middleware/AdminAuthMiddleware.php +++ b/server-api/app/middleware/AdminAuthMiddleware.php @@ -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 => [], diff --git a/server-api/app/middleware/AppAuthMiddleware.php b/server-api/app/middleware/AppAuthMiddleware.php index ea8f028..b5e3b08 100644 --- a/server-api/app/middleware/AppAuthMiddleware.php +++ b/server-api/app/middleware/AppAuthMiddleware.php @@ -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', diff --git a/server-api/app/support/AdminAccessService.php b/server-api/app/support/AdminAccessService.php index d5629f9..a398cbc 100644 --- a/server-api/app/support/AdminAccessService.php +++ b/server-api/app/support/AdminAccessService.php @@ -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, }; } diff --git a/server-api/app/support/AppraisalServicePricePackageService.php b/server-api/app/support/AppraisalServicePricePackageService.php new file mode 100644 index 0000000..38a6460 --- /dev/null +++ b/server-api/app/support/AppraisalServicePricePackageService.php @@ -0,0 +1,450 @@ + [ + '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, + ]); + } + } +} diff --git a/server-api/app/support/EnterpriseOrderService.php b/server-api/app/support/EnterpriseOrderService.php index d0af325..3ee7206 100644 --- a/server-api/app/support/EnterpriseOrderService.php +++ b/server-api/app/support/EnterpriseOrderService.php @@ -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 diff --git a/server-api/app/support/MiniProgramAuthService.php b/server-api/app/support/MiniProgramAuthService.php new file mode 100644 index 0000000..4e6c481 --- /dev/null +++ b/server-api/app/support/MiniProgramAuthService.php @@ -0,0 +1,173 @@ +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')); + } +} diff --git a/server-api/app/support/ShouqianbaClient.php b/server-api/app/support/ShouqianbaClient.php new file mode 100644 index 0000000..c0eb65d --- /dev/null +++ b/server-api/app/support/ShouqianbaClient.php @@ -0,0 +1,285 @@ +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()); + } +} diff --git a/server-api/app/support/ShouqianbaConfigService.php b/server-api/app/support/ShouqianbaConfigService.php new file mode 100644 index 0000000..ff675ea --- /dev/null +++ b/server-api/app/support/ShouqianbaConfigService.php @@ -0,0 +1,175 @@ +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; + } +} diff --git a/server-api/app/support/ShouqianbaPaymentService.php b/server-api/app/support/ShouqianbaPaymentService.php new file mode 100644 index 0000000..c36c9bf --- /dev/null +++ b/server-api/app/support/ShouqianbaPaymentService.php @@ -0,0 +1,613 @@ +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')); + } +} diff --git a/server-api/config/route.php b/server-api/config/route.php index b262c41..306cfce 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -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']); diff --git a/server-api/database/schema.sql b/server-api/database/schema.sql index fa5a890..9854f82 100644 --- a/server-api/database/schema.sql +++ b/server-api/database/schema.sql @@ -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, diff --git a/server-api/tools/db_seed.php b/server-api/tools/db_seed.php index 649e49b..c05dedc 100644 --- a/server-api/tools/db_seed.php +++ b/server-api/tools/db_seed.php @@ -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"; diff --git a/server-api/tools/release_audit.php b/server-api/tools/release_audit.php index 21939ac..7f74fa5 100644 --- a/server-api/tools/release_audit.php +++ b/server-api/tools/release_audit.php @@ -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'); diff --git a/server-api/tools/schema_upgrade_service_price_packages.php b/server-api/tools/schema_upgrade_service_price_packages.php new file mode 100644 index 0000000..e7407b1 --- /dev/null +++ b/server-api/tools/schema_upgrade_service_price_packages.php @@ -0,0 +1,133 @@ +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"; diff --git a/server-api/tools/schema_upgrade_shouqianba_payment.php b/server-api/tools/schema_upgrade_shouqianba_payment.php new file mode 100644 index 0000000..c853d0c --- /dev/null +++ b/server-api/tools/schema_upgrade_shouqianba_payment.php @@ -0,0 +1,115 @@ +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"; diff --git a/server-api/tools/shouqianba_payment_mock_test.php b/server-api/tools/shouqianba_payment_mock_test.php new file mode 100644 index 0000000..2e0184a --- /dev/null +++ b/server-api/tools/shouqianba_payment_mock_test.php @@ -0,0 +1,388 @@ +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); +} diff --git a/server-api/tools/sync_client_configs.php b/server-api/tools/sync_client_configs.php index a515e4d..a1e57fa 100644 --- a/server-api/tools/sync_client_configs.php +++ b/server-api/tools/sync_client_configs.php @@ -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); diff --git a/user-app/src/api/app.ts b/user-app/src/api/app.ts index 9c80243..22ddaab 100644 --- a/user-app/src/api/app.ts +++ b/user-app/src/api/app.ts @@ -130,14 +130,38 @@ export interface MineOverviewData { }; } +export interface PaymentLaunchInfo { + status: string; + channel: "h5" | "mini_program" | string; + check_sn: string; + order_token: string; + cashier_url: string; + order_sn: string; + plugin_url?: string; + plugin_provider?: string; +} + +export interface OrderPaymentStatusData { + order_id: number; + order_no: string; + payment_status: string; + order_status: string; + display_status: string; + payment: PaymentLaunchInfo | null; +} + export interface OrderListItem { order_id: number; order_no: string; appraisal_no: string; + payment_status: string; order_status: string; product_name: string; product_cover: string; service_provider: string; + price_package_name: string; + price_package_code: string; + price_package_price: number; display_status: string; status_desc: string; estimated_finish_time: string; @@ -150,6 +174,10 @@ export interface OrderDetailData { order_no: string; appraisal_no: string; service_provider: string; + price_package_name: string; + price_package_code: string; + price_package_price: number; + payment_status: string; order_status: string; display_status: string; status_desc: string; @@ -239,6 +267,7 @@ export interface OrderDetailData { primary_action: string; secondary_action: string; }; + payment: PaymentLaunchInfo | null; } export interface ShippingDetailData { @@ -650,6 +679,23 @@ export const appApi = { params: { id }, }); }, + retryOrderPayment(orderId: number) { + return request<{ order_id: number; payment: PaymentLaunchInfo }>("/api/app/order/pay/retry", { + method: "POST", + data: { order_id: orderId }, + }); + }, + getOrderPaymentStatus(orderId: number) { + return request("/api/app/order/payment/status", { + params: { id: orderId }, + }); + }, + cancelOrder(orderId: number) { + return request("/api/app/order/cancel", { + method: "POST", + data: { order_id: orderId }, + }); + }, getOrderShippingDetail(orderId: number) { return request("/api/app/order/shipping", { params: { order_id: orderId }, diff --git a/user-app/src/api/appraisal.ts b/user-app/src/api/appraisal.ts index e547363..5163e77 100644 --- a/user-app/src/api/appraisal.ts +++ b/user-app/src/api/appraisal.ts @@ -2,10 +2,12 @@ import { parseUploadResponse, request } from "../utils/request"; import { buildAuthHeaders } from "../utils/auth"; import { resolveApiBaseUrl } from "../utils/env"; import { resolveOrderSourceChannel } from "../utils/order-source"; +import type { PaymentLaunchInfo } from "./app"; export interface CatalogOption { brand_id?: number; brand_name?: string; + brand_en_name?: string; } export interface CategoryOption { @@ -36,6 +38,10 @@ export interface DraftDetail { draft_id: number; service_provider: string; service_mode: string; + price_package_id: number; + price_package_name: string; + price_package_code: string; + price_package_price: number; current_step: number; product_info: Record; extra_info: Record; @@ -48,6 +54,9 @@ export interface PreviewData { service_summary: { service_provider: string; service_provider_text: string; + price_package_id: number; + price_package_name: string; + price_package_code: string; }; product_summary: { product_name: string; @@ -71,23 +80,60 @@ export interface PreviewData { }>; } +export interface AppraisalServicePackage { + id: number; + service_provider: string; + service_provider_text: string; + package_name: string; + package_code: string; + price: number; + description: string; + is_enabled: boolean; + is_default: boolean; + sort_order: number; + sla_hours: number; +} + +export interface AppraisalServiceConfig { + service_provider: string; + service_provider_text: string; + price: number; + sla_hours: number; + default_package_id: number; + default_package: AppraisalServicePackage | null; + packages: AppraisalServicePackage[]; +} + export interface SubmitResult { order_id: number; order_no: string; appraisal_no: string; pay_amount: number; next_status: string; + payment: PaymentLaunchInfo | null; + payment_launch_failed?: boolean; + payment_error?: string; } export const appraisalApi = { - createDraft(serviceProvider: string) { - return request<{ draft_id: number; service_provider: string; service_mode: string }>( + createDraft(serviceProvider: string, pricePackageId?: number, pricePackageCode?: string) { + return request<{ + draft_id: number; + service_provider: string; + service_mode: string; + price_package_id: number; + price_package_name: string; + price_package_code: string; + price_package_price: number; + }>( "/api/app/appraisal/draft/create", { method: "POST", data: { service_provider: serviceProvider, service_mode: "physical", + price_package_id: pricePackageId || undefined, + price_package_code: pricePackageCode || undefined, }, }, ); @@ -111,6 +157,9 @@ export const appraisalApi = { getCategories() { return request<{ list: CategoryOption[] }>("/api/app/catalog/categories"); }, + getServiceConfigs() { + return request<{ list: AppraisalServiceConfig[] }>("/api/app/appraisal/service-configs"); + }, getUploadTemplate(categoryId: number, serviceProvider: string) { return request<{ template_id: number; required_items: UploadItem[]; optional_items: UploadItem[] }>( "/api/app/appraisal/upload-template", diff --git a/user-app/src/api/auth.ts b/user-app/src/api/auth.ts index 91f9dcf..f508547 100644 --- a/user-app/src/api/auth.ts +++ b/user-app/src/api/auth.ts @@ -46,6 +46,11 @@ export interface WechatBindMobileResult extends LoginResult { status: "logged_in"; } +export interface MiniProgramBindResult { + openid: string; + unionid: string; +} + export const authApi = { sendLoginCode(mobile: string) { return request("/api/app/auth/send-code", { @@ -84,6 +89,12 @@ export const authApi = { data: payload, }); }, + bindMiniProgramOpenid(code: string) { + return request("/api/app/auth/mini-program/bind", { + method: "POST", + data: { code }, + }); + }, getMe() { return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); }, diff --git a/user-app/src/manifest.json b/user-app/src/manifest.json index 44ecd85..cd9848b 100644 --- a/user-app/src/manifest.json +++ b/user-app/src/manifest.json @@ -50,11 +50,18 @@ "quickapp" : {}, /* 小程序特有相关 */ "mp-weixin" : { - "appid" : "wx1234567890test", + "appid" : "wx8da234b813b3c9ac", "setting" : { "urlCheck" : false }, - "usingComponents" : true + "usingComponents" : true, + "plugins" : { + "lite-pos-plugin" : { + "version" : "2.4.7", + "provider" : "wx7903bb295ac26ac7", + "export" : "miniprogram_npm/lite-pos-plugin-mate/utils.js" + } + } }, "mp-alipay" : { "usingComponents" : true diff --git a/user-app/src/mocks/app.ts b/user-app/src/mocks/app.ts index 684506e..91a4293 100644 --- a/user-app/src/mocks/app.ts +++ b/user-app/src/mocks/app.ts @@ -81,10 +81,14 @@ export const ordersFallback: OrderListItem[] = [ order_id: 1, order_no: "AXY202604200001", appraisal_no: "AXY-APP-20260420-0001", + payment_status: "paid", order_status: "pending_supplement", product_name: "Louis Vuitton Neverfull MM", product_cover: "", service_provider: "zhongjian", + price_package_name: "中检基础套餐", + price_package_code: "zhongjian_basic", + price_package_price: 199, display_status: "等待您补充资料", status_desc: "还差 2 项必传资料", estimated_finish_time: "2026-04-21 18:00:00", @@ -94,10 +98,14 @@ export const ordersFallback: OrderListItem[] = [ order_id: 2, order_no: "AXY202604190012", appraisal_no: "AXY-APP-20260419-0012", + payment_status: "paid", order_status: "pending_shipping", product_name: "Air Jordan 1 High OG", product_cover: "", service_provider: "anxinyan", + price_package_name: "安心验基础套餐", + price_package_code: "anxinyan_basic", + price_package_price: 99, display_status: "鉴定师处理中", status_desc: "鉴定师正在处理,预计 24 小时内出具报告", estimated_finish_time: "2026-04-20 20:00:00", @@ -107,10 +115,14 @@ export const ordersFallback: OrderListItem[] = [ order_id: 3, order_no: "AXY202604180088", appraisal_no: "AXY-APP-20260418-0088", + payment_status: "paid", order_status: "completed", product_name: "Rolex Datejust 36", product_cover: "", service_provider: "zhongjian", + price_package_name: "中检基础套餐", + price_package_code: "zhongjian_basic", + price_package_price: 199, display_status: "报告已出具", status_desc: "正式报告可查看并验真", estimated_finish_time: "2026-04-18 20:00:00", @@ -124,6 +136,10 @@ export const orderDetailFallback: OrderDetailData = { order_no: "AXY202604200001", appraisal_no: "AXY-APP-20260420-0001", service_provider: "zhongjian", + price_package_name: "中检基础套餐", + price_package_code: "zhongjian_basic", + price_package_price: 199, + payment_status: "paid", order_status: "pending_supplement", display_status: "等待您补充资料", status_desc: "鉴定师需要您补充 2 项资料后继续处理,建议尽快完成。", @@ -228,6 +244,7 @@ export const orderDetailFallback: OrderDetailData = { primary_action: "去补资料", secondary_action: "联系客服", }, + payment: null, }; export const shippingDetailFallback: ShippingDetailData = { @@ -391,7 +408,6 @@ export const reportDetailFallback: ReportDetailData = { { label: "品牌", value: "Rolex" }, { label: "主体颜色", value: "银盘" }, { label: "服务类型", value: "中检鉴定" }, - { label: "鉴定师", value: "张师傅" }, ], }, trace_info: { diff --git a/user-app/src/pages.json b/user-app/src/pages.json index af8c869..4bf093a 100644 --- a/user-app/src/pages.json +++ b/user-app/src/pages.json @@ -1,5 +1,12 @@ { "pages": [ + { + "path": "pages/home/index", + "style": { + "navigationBarTitleText": "安心验", + "navigationStyle": "custom" + } + }, { "path": "pages/auth/login", "style": { @@ -13,29 +20,25 @@ "navigationBarTitleText": "绑定手机号" } }, - { - "path": "pages/home/index", - "style": { - "navigationBarTitleText": "安心验", - "navigationStyle": "custom" - } - }, { "path": "pages/appraisal/service", "style": { - "navigationBarTitleText": "选择鉴定服务" + "navigationBarTitleText": "鉴定服务", + "navigationStyle": "custom" } }, { "path": "pages/appraisal/product", "style": { - "navigationBarTitleText": "选择商品信息" + "navigationBarTitleText": "选择品牌", + "navigationStyle": "custom" } }, { "path": "pages/appraisal/confirm", "style": { - "navigationBarTitleText": "确认订单" + "navigationBarTitleText": "确认订单", + "navigationStyle": "custom" } }, { diff --git a/user-app/src/pages/appraisal/confirm.vue b/user-app/src/pages/appraisal/confirm.vue index 252e293..cdf7d83 100644 --- a/user-app/src/pages/appraisal/confirm.vue +++ b/user-app/src/pages/appraisal/confirm.vue @@ -3,26 +3,37 @@ import { computed, ref } from "vue"; import { onLoad, onShow } from "@dcloudio/uni-app"; import { appraisalApi } from "../../api/appraisal"; import { appApi, type UserAddressItem } from "../../api/app"; -import FlowStepHeader from "../../components/FlowStepHeader.vue"; import { useAppraisalStore } from "../../stores/appraisal"; import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; +import { launchOrderPayment, prepareMiniProgramPaymentIdentity } from "../../utils/payment"; const store = useAppraisalStore(); const preview = computed(() => store.preview); const loading = computed(() => !store.preview); -const submitting = computed(() => false); -const productCategoryBrandText = computed(() => { - const categoryName = preview.value?.product_summary.category_name || ""; - const brandName = preview.value?.product_summary.brand_name || "未填写"; - return categoryName ? `${categoryName} / ${brandName}` : ""; -}); +const submitting = ref(false); const addressSheetVisible = ref(false); const addressesLoading = ref(false); const addressOptions = ref([]); const selectedReturnAddress = computed(() => store.returnAddress); const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id"; +const serviceProviderText = computed(() => preview.value?.service_summary.service_provider_text || "安心验鉴定"); +const packageNameText = computed(() => preview.value?.service_summary.price_package_name || store.pricePackageName || ""); +const categoryText = computed(() => preview.value?.product_summary.category_name || store.product.categoryName || "-"); +const brandText = computed(() => preview.value?.product_summary.brand_name || store.product.brandName || "未填写"); +const productNameText = computed(() => preview.value?.product_summary.product_name || `${categoryText.value} ${brandText.value === "未填写" ? "" : brandText.value}`.trim()); +const serviceFeeText = computed(() => formatMoney(preview.value?.fee_detail.service_fee || 0)); +const discountFeeText = computed(() => formatMoney(preview.value?.fee_detail.discount_fee || 0)); +const payAmountText = computed(() => formatMoney(preview.value?.fee_detail.pay_amount || 0)); +const canSubmit = computed(() => Boolean(store.draftId && store.returnAddress.id && !loading.value && !submitting.value)); + +function formatMoney(value: number | string) { + const amount = Number(value || 0); + if (!Number.isFinite(amount)) return "0"; + return amount % 1 === 0 ? String(amount) : amount.toFixed(2); +} + function applySelectedAddress(item: UserAddressItem) { store.setReturnAddress({ id: item.id, @@ -94,51 +105,12 @@ function chooseAddress(item: UserAddressItem) { showInfoToast("寄回地址已选择"); } -async function goSuccess() { +async function loadPreview() { if (!store.draftId) { showInfoToast("订单信息未准备完成,请返回上一步检查"); return; } - if (!store.returnAddress.id) { - showInfoToast("请先确认寄回地址"); - return; - } - try { - const result = await withLoading("正在创建订单", async () => { - try { - return await appraisalApi.submit(store.draftId, store.returnAddress.id); - } catch (error) { - if (!isMissingDraftError(error)) { - throw error; - } - await rebuildDraftFromStore(store); - return appraisalApi.submit(store.draftId, store.returnAddress.id); - } - }); - const successUrl = `/pages/appraisal/success?order_no=${encodeURIComponent(result.order_no)}&appraisal_no=${encodeURIComponent(result.appraisal_no)}&order_id=${result.order_id}`; - store.resetForNewFlow(); - uni.navigateTo({ - url: successUrl, - }); - } catch (error) { - showErrorToast(error, "创建订单失败,请稍后重试"); - } -} - -function goBack() { - uni.navigateBack(); -} - -function openAgreement(url: string) { - if (!url) return; - uni.navigateTo({ url }); -} - -onLoad(async () => { - store.hydrate(); - await fetchAddresses(); - if (!store.draftId) return; try { let data; try { @@ -156,101 +128,165 @@ onLoad(async () => { } catch (error) { showErrorToast(error, "订单预览加载失败"); } +} + +async function goSuccess() { + if (submitting.value) return; + if (!store.draftId) { + showInfoToast("订单信息未准备完成,请返回上一步检查"); + return; + } + if (!store.returnAddress.id) { + showInfoToast("请先确认寄回地址"); + return; + } + + submitting.value = true; + try { + await prepareMiniProgramPaymentIdentity(); + const result = await withLoading("正在创建订单", async () => { + try { + return await appraisalApi.submit(store.draftId, store.returnAddress.id); + } catch (error) { + if (!isMissingDraftError(error)) { + throw error; + } + + await rebuildDraftFromStore(store); + return appraisalApi.submit(store.draftId, store.returnAddress.id); + } + }); + store.resetForNewFlow(); + if (!result.payment) { + showInfoToast("订单已生成,可稍后在详情页继续支付"); + uni.navigateTo({ url: `/pages/order/detail?id=${result.order_id}` }); + return; + } + if (result.payment?.status === "paid") { + uni.navigateTo({ url: `/pages/order/detail?id=${result.order_id}` }); + return; + } + launchOrderPayment(result.payment); + } catch (error) { + showErrorToast(error, "支付发起失败,请稍后重试"); + } finally { + submitting.value = false; + } +} + +function goBack() { + uni.navigateBack(); +} + +function openAgreement(url: string) { + if (!url) return; + uni.navigateTo({ url }); +} + +onLoad(async () => { + store.hydrate(); + await fetchAddresses(); + await loadPreview(); }); onShow(fetchAddresses); - diff --git a/user-app/src/pages/appraisal/product.vue b/user-app/src/pages/appraisal/product.vue index a4e51f0..f237a43 100644 --- a/user-app/src/pages/appraisal/product.vue +++ b/user-app/src/pages/appraisal/product.vue @@ -1,212 +1,214 @@ - diff --git a/user-app/src/pages/appraisal/service.vue b/user-app/src/pages/appraisal/service.vue index 126f5be..3759b2c 100644 --- a/user-app/src/pages/appraisal/service.vue +++ b/user-app/src/pages/appraisal/service.vue @@ -1,177 +1,1244 @@ + + diff --git a/user-app/src/pages/home/index.vue b/user-app/src/pages/home/index.vue index 28ae76d..e2627a8 100644 --- a/user-app/src/pages/home/index.vue +++ b/user-app/src/pages/home/index.vue @@ -83,13 +83,10 @@ const categoryCards = computed(() => { })); }); -function goService(provider = "anxinyan") { - uni.navigateTo({ url: `/pages/appraisal/service?provider=${provider}` }); -} - -function goCategory(category: string) { - uni.navigateTo({ - url: `/pages/appraisal/service?provider=anxinyan&category=${encodeURIComponent(category)}`, +function goService() { + uni.showToast({ + title: "暂不支持自助下单", + icon: "none", }); } @@ -160,7 +157,7 @@ onShow(fetchHome); - + {{ heroBanner.subtitle }} {{ heroBanner.description }} @@ -170,7 +167,6 @@ onShow(fetchHome); 进度可追踪 - 立即鉴定 @@ -181,7 +177,7 @@ onShow(fetchHome); v-for="card in homeServiceCards" :key="card.service_provider" :class="['home-service-card', `home-service-card--${card.theme}`]" - @click="goService(card.service_provider)" + @click="goService" > {{ card.theme === "blue" ? "CIC" : "" }} @@ -201,7 +197,6 @@ onShow(fetchHome); v-for="item in categoryCards" :key="item.categoryId || item.categoryName" class="home-category-card" - @click="goCategory(item.categoryName)" > {{ item.displayName }} diff --git a/user-app/src/pages/order/detail.vue b/user-app/src/pages/order/detail.vue index a23b893..683afe0 100644 --- a/user-app/src/pages/order/detail.vue +++ b/user-app/src/pages/order/detail.vue @@ -4,6 +4,7 @@ import { onLoad, onShow } from "@dcloudio/uni-app"; import { appApi, type OrderDetailData, type UserAddressItem } from "../../api/app"; import { orderDetailFallback } from "../../mocks/app"; import { resolveErrorMessage, showErrorToast, showInfoToast } from "../../utils/feedback"; +import { launchOrderPayment, prepareMiniProgramPaymentIdentity } from "../../utils/payment"; import { getPrivacyMode, maskOrderNo } from "../../utils/privacy"; const detail = ref(orderDetailFallback); @@ -16,6 +17,8 @@ const addressOptions = ref([]); const loading = ref(false); const pageReady = ref(false); const loadError = ref(""); +const paymentLaunching = ref(false); +const cancelSubmitting = ref(false); const serviceProviderText = computed(() => detail.value.order_info.service_provider === "zhongjian" ? "中检鉴定" : "实物鉴定", @@ -68,8 +71,10 @@ const returnLogisticsStatusText = computed( () => detail.value.return_logistics?.provider_status_text || detail.value.return_logistics?.tracking_status_text || "", ); const canEditReturnAddress = computed(() => detail.value.order_info.can_edit_return_address); +const isPendingPayment = computed(() => detail.value.order_info.order_status === "pending_payment"); const heroTagClass = computed(() => { + if (detail.value.order_info.order_status === "pending_payment") return "tag tag--warning"; if (detail.value.order_info.order_status === "pending_supplement") return "tag tag--warning"; if (detail.value.order_info.order_status === "pending_shipping" || detail.value.order_info.order_status === "pending_submission") { return "tag tag--accent"; @@ -82,6 +87,8 @@ const heroTagClass = computed(() => { const heroTagText = computed(() => { switch (detail.value.order_info.order_status) { + case "pending_payment": + return "待支付"; case "pending_shipping": return shippingSubmitted.value ? "待签收" : "待寄送"; case "pending_submission": @@ -93,6 +100,8 @@ const heroTagText = computed(() => { case "report_published": case "completed": return "已出报告"; + case "cancelled": + return "已取消"; default: return "处理中"; } @@ -100,6 +109,8 @@ const heroTagText = computed(() => { const focusTitle = computed(() => { switch (detail.value.order_info.order_status) { + case "pending_payment": + return "下一步请完成支付"; case "pending_shipping": return shippingSubmitted.value ? "下一步等待鉴定中心签收" : "下一步请先寄送商品"; case "pending_supplement": @@ -108,6 +119,8 @@ const focusTitle = computed(() => { return hasReturnAddress.value ? "下一步等待平台寄回物品" : "下一步请先确认寄回地址"; case "completed": return returnReceived.value ? "本次订单已完成" : hasReturnLogistics.value ? "物品已寄回,请留意签收" : "结果已经可以查看"; + case "cancelled": + return "订单已取消"; default: return "当前订单正在继续处理"; } @@ -119,6 +132,8 @@ const focusDesc = computed(() => { } switch (detail.value.order_info.order_status) { + case "pending_payment": + return "支付成功后,订单会进入待寄送商品环节,平台再开始安排后续鉴定作业。"; case "pending_shipping": return shippingSubmitted.value ? "运单已登记成功,无需再次寄送商品,后续等待鉴定中心签收后继续处理。" @@ -133,6 +148,8 @@ const focusDesc = computed(() => { : hasReturnLogistics.value ? "平台已登记回寄运单,可在本页查看回寄物流进度和最新节点。" : "正式报告已生成,可前往报告页查看结果并完成验真。"; + case "cancelled": + return "该订单已取消,不会继续进入鉴定流程。如仍需服务,请重新发起鉴定。"; default: return detail.value.order_info.status_desc; } @@ -157,6 +174,8 @@ const progressSectionDesc = computed(() => const supportHelpText = computed(() => { switch (detail.value.order_info.order_status) { + case "pending_payment": + return "如果支付页面无法打开或支付后状态未更新,可先刷新本页,也可以联系客服协助核查。"; case "pending_supplement": return "如果暂时无法补图,可先联系客服说明情况,我们会协助判断下一步处理方式。"; case "pending_shipping": @@ -173,6 +192,9 @@ const supportHelpText = computed(() => { const primaryActionText = computed(() => { + if (isPendingPayment.value && paymentLaunching.value) { + return "支付中..."; + } if (detail.value.order_info.order_status === "report_published" && !hasReturnAddress.value) { return "确认寄回地址"; } @@ -183,6 +205,10 @@ const primaryActionText = computed(() => }, ); +const secondaryActionText = computed(() => + isPendingPayment.value ? (cancelSubmitting.value ? "取消中..." : "取消订单") : detail.value.available_actions.secondary_action, +); + async function fetchDetail() { if (!orderId.value) return; loading.value = true; @@ -205,6 +231,24 @@ async function fetchDetail() { } } +async function syncPendingPaymentStatus(silent = true) { + if (!orderId.value || !isPendingPayment.value) return; + try { + const data = await appApi.getOrderPaymentStatus(orderId.value); + if (data.order_status !== detail.value.order_info.order_status || data.payment_status !== detail.value.order_info.payment_status) { + await fetchDetail(); + return; + } + detail.value.payment = data.payment; + detail.value.order_info.display_status = data.display_status; + detail.value.order_info.status_desc = data.order_status === "pending_payment" ? "请完成支付后继续本次鉴定服务" : detail.value.order_info.status_desc; + } catch (error) { + if (!silent) { + showErrorToast(error, "支付状态同步失败"); + } + } +} + async function fetchAddressOptions() { addressesLoading.value = true; try { @@ -217,7 +261,31 @@ async function fetchAddressOptions() { } } -function handlePrimaryAction() { +async function payCurrentOrder() { + if (paymentLaunching.value || !orderId.value) return; + paymentLaunching.value = true; + try { + await prepareMiniProgramPaymentIdentity(); + const data = await appApi.retryOrderPayment(orderId.value); + if (data.payment.status === "paid") { + showInfoToast("订单已支付"); + await fetchDetail(); + return; + } + launchOrderPayment(data.payment); + } catch (error) { + showErrorToast(error, "支付发起失败"); + await syncPendingPaymentStatus(true); + } finally { + paymentLaunching.value = false; + } +} + +async function handlePrimaryAction() { + if (isPendingPayment.value) { + await payCurrentOrder(); + return; + } if (detail.value.order_info.order_status === "report_published" && !hasReturnAddress.value) { openReturnAddressSheet(); return; @@ -249,12 +317,46 @@ function handlePrimaryAction() { }); return; } + if (!action) { + uni.pageScrollTo({ + selector: "#order-progress", + duration: 260, + }); + return; + } uni.showToast({ title: action, icon: "none", }); } +function handleSecondaryAction() { + if (!isPendingPayment.value) { + contactService(); + return; + } + if (cancelSubmitting.value) return; + + uni.showModal({ + title: "取消订单", + content: "确认取消这笔待支付订单吗?取消后不会进入鉴定流程。", + success: async (result) => { + if (!result.confirm) return; + cancelSubmitting.value = true; + try { + await appApi.cancelOrder(detail.value.order_info.order_id); + showInfoToast("订单已取消"); + await fetchDetail(); + } catch (error) { + showErrorToast(error, "订单取消失败"); + await syncPendingPaymentStatus(true); + } finally { + cancelSubmitting.value = false; + } + }, + }); +} + function contactService() { uni.navigateTo({ url: `/pages/support/create?ticket_type=order_issue&order_id=${detail.value.order_info.order_id}&prefill_title=${encodeURIComponent("订单问题咨询")}`, @@ -314,7 +416,10 @@ onLoad((options) => { } }); -onShow(fetchDetail); +onShow(async () => { + await fetchDetail(); + await syncPendingPaymentStatus(true); +});