feat: update appraisal ordering and payment flows

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

View File

@@ -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

View File

@@ -0,0 +1,87 @@
<?php
namespace app\controller\admin;
use app\support\AppraisalServicePricePackageService;
use support\Request;
class ServicePricePackagesController
{
public function index(Request $request)
{
return api_success($this->service()->adminIndex());
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);
try {
$packageId = $this->service()->save([
'service_provider' => $request->input('service_provider', 'anxinyan'),
'package_name' => $request->input('package_name', ''),
'package_code' => $request->input('package_code', ''),
'price' => $request->input('price', ''),
'description' => $request->input('description', ''),
'is_enabled' => $request->input('is_enabled', true),
'is_default' => $request->input('is_default', false),
'sort_order' => $request->input('sort_order', 0),
], $id);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('服务价格套餐保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $packageId,
], $id > 0 ? '服务价格套餐已更新' : '服务价格套餐已创建');
}
public function updateStatus(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('套餐 ID 不能为空', 422);
}
try {
$this->service()->setEnabled($id, !empty($request->input('is_enabled', true)));
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('服务价格套餐状态更新失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success(['id' => $id], '套餐状态已更新');
}
public function setDefault(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('套餐 ID 不能为空', 422);
}
try {
$this->service()->setDefault($id);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('默认套餐设置失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success(['id' => $id], '默认套餐已更新');
}
private function service(): AppraisalServicePricePackageService
{
return new AppraisalServicePricePackageService();
}
}

View File

@@ -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);