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 @@
+
+
+
+
+
+
+
+
+ 新增套餐
+
+
+
+
+
+
+
+
+ {{ row.service_provider_text }}
+
+
+ ¥{{ formatMoney(row.price) }}
+
+
+
+
+ {{ row.is_enabled ? "启用" : "停用" }}
+ 默认
+
+
+
+
+
+
+
+ 编辑
+
+ {{ row.is_enabled ? "停用" : "启用" }}
+
+ 设默认
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
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);
-
-
-
-
- 正在准备订单预览
- 请稍候,系统正在汇总服务、商品与费用信息。
-
-
-
- 服务摘要
-
- 鉴定服务
- {{ preview?.service_summary.service_provider_text || '中检鉴定' }}
+
+
+
+
+
+
+ 确认订单
+
+ •••
+
+
-
- 商品摘要
-
- 商品名称
- {{ preview?.product_summary.product_name || '' }}
-
-
- 品类 / 品牌
- {{ productCategoryBrandText }}
-
+
+ 正在准备订单预览
+ 请稍候,系统正在汇总服务、商品与费用信息。
-
- 费用明细
-
- 鉴定服务费
- ¥{{ preview?.fee_detail.service_fee || 0 }}
-
-
- 优惠抵扣
- - ¥{{ preview?.fee_detail.discount_fee || 0 }}
-
-
- 实付金额
- ¥{{ preview?.fee_detail.pay_amount || 0 }}
-
-
-
-
- 寄回地址
-
-
-
- {{ selectedReturnAddress.consignee }}
- {{ selectedReturnAddress.mobile }}
+
+
+
+ 寄回地址
+
+
+ {{ selectedReturnAddress.consignee }} {{ selectedReturnAddress.mobile }}
+ 默认
- 默认地址
-
- {{ selectedReturnAddress.fullAddress }}
- 更换地址
+ {{ selectedReturnAddress.fullAddress }}
+
+ 请添加收货人信息
-
- 暂未选择寄回地址
- 鉴定完成后,平台会按这里的地址回寄商品,请先确认。
- 选择地址
+
+
+
+
+ 商品摘要
+
+
+ {{ brandText === "未填写" ? "AXY" : brandText.slice(0, 2) }}
+
+
+ 服务:{{ serviceProviderText }}
+ 套餐:{{ packageNameText }}
+ 品类:{{ categoryText }}
+ 品牌:{{ brandText }}
+
+
+ {{ productNameText }}
+
+
+
+
+ 费用明细
+ ¥{{ payAmountText }}
+
+
+ 安心验
+ {{ packageNameText || `${serviceProviderText}服务费` }}
+ ¥{{ serviceFeeText }}
+
+
+ 优惠抵扣
+ - ¥{{ discountFeeText }}
-
- 提交前确认
- 提交即表示您已阅读并同意以下协议与说明,请在提交前再次确认。
-
-
- {{ item.title }}
- {{ item.desc }}
+ 提交前请确认
+ 提交即表示您已阅读并同意以下协议与说明,请在提交前再次确认。
+
+
+
+
+
+ {{ item.title }}
+ {{ item.desc }}
-
- 返回
- 立即支付
+
+
+ 总金额
+ ¥{{ payAmountText }}
+ 含税价
+
+
+ {{ submitting ? "提交中..." : "立即支付" }}
+
@@ -269,15 +305,16 @@ onShow(fetchAddresses);
+ 正在加载地址...
-
+
{{ item.consignee }} / {{ item.mobile }}
- {{ item.full_address }}
+ {{ item.full_address }}
{{ selectedReturnAddress.id === item.id ? "已选地址" : "选择" }}
@@ -287,79 +324,456 @@ 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 @@
-
-
-
-
- 当前识别路径
- 当前步骤用于确定商品大类,便于匹配资料模板与服务流程。
-
- {{ productSummary }}
-
- {{ canContinue ? "已完成基础商品信息" : "请选择品类后继续" }}
-
+
+
+
+
+
+ 取消
-
- 商品识别信息
- 品类来源于后台资料配置,品牌由用户按实际商品自行填写。
-
-
- 正在加载商品字典
- 品类数据加载完成后即可继续选择。
-
-
-
- 商品信息加载失败
- {{ loadError }}
-
-
-
- 品类
-
-
- {{ store.product.categoryName || "请选择品类" }}
- 当前已配置 {{ categories.length }} 个品类,来源于后台商品资料配置。
-
-
- 已选品类
- 选择
-
-
-
-
-
- 品牌(选填)
-
-
-
- 不确定品牌时可留空,后续补充说明中仍可描述商品细节。
-
+
+ {{ serviceProviderText }}
+ {{ categoryTitle }}
+ 跳过品牌
-
-
-
-
+
+ 正在加载品牌
+ 请稍候
+
-
-
-
+
+ 品牌加载失败
+ {{ loadError }}
+ 跳过品牌
+
-
+
+
+ 暂无匹配品牌
+ 可跳过品牌后继续确认订单
+ 跳过品牌
+
+
+
+ {{ group.letter }}
+
- {{ pickerMeta.label(item) }}
- {{ isPickerCurrent(item) ? '已选' : '选择' }}
+ {{ item.displayName }}
+ 已选
-
-
- {{ pickerMeta.emptyText }}
-
-
+
-
+
-
- 返回
-
- {{ submitting ? "保存中..." : "下一步" }}
+
+
+ {{ submitting ? "保存中..." : "跳过品牌,继续确认" }}
-
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 @@
-
-
-
-
- 当前无法继续下一步
- {{ loadError }}
-
-
-
-
- 已选服务
- 标准服务
- 实物鉴定
- 由安心验鉴定中心提供标准实物鉴定服务,适合正式结果交付场景。
-
+
+
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);
+});
@@ -342,6 +447,7 @@ onShow(fetchDetail);
订单号 {{ maskOrderNo(detail.order_info.order_no, privacyMode) }}
鉴定单号 {{ maskOrderNo(detail.order_info.appraisal_no, privacyMode) }}
{{ serviceProviderText }}
+ {{ detail.order_info.price_package_name }}
预计 {{ detail.order_info.estimated_finish_time }}
@@ -563,7 +669,7 @@ onShow(fetchDetail);
- 联系客服
+ {{ secondaryActionText }}
{{ primaryActionText }}
diff --git a/user-app/src/pages/order/index.vue b/user-app/src/pages/order/index.vue
index 83cf2f4..7718fde 100644
--- a/user-app/src/pages/order/index.vue
+++ b/user-app/src/pages/order/index.vue
@@ -27,7 +27,7 @@ const heroStats = computed(() => {
stats.completed += 1;
continue;
}
- if (item.order_status === "pending_supplement") {
+ if (["pending_payment", "pending_supplement"].includes(item.order_status)) {
stats.pending += 1;
continue;
}
@@ -146,7 +146,7 @@ onShow(async () => {
{{ item.display_status }}
@@ -155,7 +155,10 @@ onShow(async () => {
平台待安排寄回,请先确认寄回地址。
平台已回寄商品,请留意签收物流。
diff --git a/user-app/src/pages/report/detail.vue b/user-app/src/pages/report/detail.vue
index 8038c2d..f9840b5 100644
--- a/user-app/src/pages/report/detail.vue
+++ b/user-app/src/pages/report/detail.vue
@@ -75,11 +75,6 @@ const productItems = computed(() => {
detail.value.report_header.service_provider_text || serviceProviderText(detail.value.report_header.service_provider),
);
- const appraiserName = textValue(detail.value.appraisal_info?.appraiser_name)
- || textValue(detail.value.appraisal_info?.reviewer_name)
- || textValue(detail.value.report_header.report_entry_admin_name);
- appendProductItem(items, "鉴定师", appraiserName);
-
return items;
});
const publishTime = computed(() => detail.value.report_header.publish_time || "-");
@@ -133,7 +128,7 @@ function appendProductItem(items: ProductDisplayItem[], label: unknown, value: u
const labelText = textValue(label);
const valueText = textValue(value);
const remarkText = textValue(remark);
- if (!labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return;
+ if (labelText === "鉴定师" || !labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return;
items.push({
label: labelText,
value: valueText || "-",
diff --git a/user-app/src/static/appraisal/service-anxinyan-hero.png b/user-app/src/static/appraisal/service-anxinyan-hero.png
new file mode 100644
index 0000000..c3f5157
Binary files /dev/null and b/user-app/src/static/appraisal/service-anxinyan-hero.png differ
diff --git a/user-app/src/static/appraisal/service-step-pay.png b/user-app/src/static/appraisal/service-step-pay.png
new file mode 100644
index 0000000..39f0e6c
Binary files /dev/null and b/user-app/src/static/appraisal/service-step-pay.png differ
diff --git a/user-app/src/stores/appraisal.ts b/user-app/src/stores/appraisal.ts
index b90f599..55dec7a 100644
--- a/user-app/src/stores/appraisal.ts
+++ b/user-app/src/stores/appraisal.ts
@@ -96,6 +96,10 @@ export const useAppraisalStore = defineStore("appraisal", {
draftId: 0,
serviceProvider: "anxinyan",
serviceMode: "physical",
+ pricePackageId: 0,
+ pricePackageName: "",
+ pricePackageCode: "",
+ pricePackagePrice: 0,
currentStep: 1,
product: initialProduct(),
extra: initialExtra(),
@@ -128,6 +132,10 @@ export const useAppraisalStore = defineStore("appraisal", {
draftId: this.draftId,
serviceProvider: this.serviceProvider,
serviceMode: this.serviceMode,
+ pricePackageId: this.pricePackageId,
+ pricePackageName: this.pricePackageName,
+ pricePackageCode: this.pricePackageCode,
+ pricePackagePrice: this.pricePackagePrice,
currentStep: this.currentStep,
product: this.product,
extra: this.extra,
@@ -144,6 +152,18 @@ export const useAppraisalStore = defineStore("appraisal", {
this.serviceProvider = serviceProvider;
this.persist();
},
+ setPricePackage(payload: {
+ id: number;
+ packageName: string;
+ packageCode: string;
+ price: number;
+ }) {
+ this.pricePackageId = payload.id;
+ this.pricePackageName = payload.packageName;
+ this.pricePackageCode = payload.packageCode;
+ this.pricePackagePrice = payload.price;
+ this.persist();
+ },
setDraft(id: number) {
this.draftId = id;
this.persist();
@@ -255,6 +275,10 @@ export const useAppraisalStore = defineStore("appraisal", {
resetForNewFlow() {
this.serviceProvider = "anxinyan";
this.serviceMode = "physical";
+ this.pricePackageId = 0;
+ this.pricePackageName = "";
+ this.pricePackageCode = "";
+ this.pricePackagePrice = 0;
this.draftId = 0;
this.currentStep = 1;
this.product = initialProduct();
diff --git a/user-app/src/utils/appraisal-flow.ts b/user-app/src/utils/appraisal-flow.ts
index cf9bdd5..1670b61 100644
--- a/user-app/src/utils/appraisal-flow.ts
+++ b/user-app/src/utils/appraisal-flow.ts
@@ -9,14 +9,22 @@ export function isMissingDraftError(error: unknown) {
}
export async function rebuildDraftFromStore(store: AppraisalStore) {
- const draft = await appraisalApi.createDraft(store.serviceProvider || "anxinyan");
+ const draft = await appraisalApi.createDraft(store.serviceProvider || "anxinyan", store.pricePackageId || undefined, store.pricePackageCode || undefined);
store.setDraft(draft.draft_id);
+ store.setPricePackage({
+ id: draft.price_package_id,
+ packageName: draft.price_package_name,
+ packageCode: draft.price_package_code,
+ price: draft.price_package_price,
+ });
if (store.product.categoryId) {
await appraisalApi.saveDraft({
draft_id: draft.draft_id,
current_step: 3,
service_provider: store.serviceProvider,
+ price_package_id: store.pricePackageId,
+ price_package_code: store.pricePackageCode,
product_info: {
category_id: store.product.categoryId,
brand_id: store.product.brandId || 0,
diff --git a/user-app/src/utils/auth.ts b/user-app/src/utils/auth.ts
index 991311d..e2c0400 100644
--- a/user-app/src/utils/auth.ts
+++ b/user-app/src/utils/auth.ts
@@ -23,6 +23,14 @@ const PUBLIC_PAGES = new Set([
"/pages/auth/wechat-bind",
]);
+const AUTH_REQUIRED_PAGES = new Set([
+ "/pages/appraisal/product",
+ "/pages/appraisal/confirm",
+ "/pages/appraisal/success",
+ "/pages/appraisal/upload",
+ "/pages/appraisal/extra",
+]);
+
let redirecting = false;
function normalizePath(path: string) {
@@ -146,6 +154,11 @@ export function isPublicPage(urlOrPath: string) {
return PUBLIC_PAGES.has(path);
}
+export function isAuthRequiredPage(urlOrPath: string) {
+ const { path } = splitUrl(urlOrPath);
+ return AUTH_REQUIRED_PAGES.has(path);
+}
+
export function getCurrentPageUrl() {
const pages = getCurrentPages();
const current = pages[pages.length - 1] as
@@ -181,7 +194,7 @@ export function redirectToLogin(targetUrl?: string) {
export function ensureAuthenticatedPageAccess() {
const currentUrl = getCurrentPageUrl();
- if (!currentUrl || isPublicPage(currentUrl) || isLoggedIn()) {
+ if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) {
return;
}
diff --git a/user-app/src/utils/payment.ts b/user-app/src/utils/payment.ts
new file mode 100644
index 0000000..bfddcd0
--- /dev/null
+++ b/user-app/src/utils/payment.ts
@@ -0,0 +1,45 @@
+import { authApi } from "../api/auth";
+import type { PaymentLaunchInfo } from "../api/app";
+
+function miniProgramLoginCode() {
+ return new Promise((resolve, reject) => {
+ uni.login({
+ provider: "weixin",
+ success: (result) => {
+ const code = String(result.code || "");
+ if (!code) {
+ reject(new Error("小程序登录 code 为空"));
+ return;
+ }
+ resolve(code);
+ },
+ fail: (error) => reject(error),
+ });
+ });
+}
+
+export async function prepareMiniProgramPaymentIdentity() {
+ // #ifdef MP-WEIXIN
+ const code = await miniProgramLoginCode();
+ await authApi.bindMiniProgramOpenid(code);
+ // #endif
+}
+
+export function launchOrderPayment(payment: PaymentLaunchInfo) {
+ if (payment.channel === "mini_program") {
+ if (!payment.plugin_url) {
+ throw new Error("小程序支付链接未生成");
+ }
+ uni.navigateTo({ url: payment.plugin_url });
+ return;
+ }
+
+ if (payment.cashier_url) {
+ // #ifdef H5
+ window.location.href = payment.cashier_url;
+ return;
+ // #endif
+ }
+
+ throw new Error("支付链接未生成");
+}