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

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ releases/
# local Codex artifacts
.codex-artifacts/
# local payment keys
server-api/storage/payment-certs/shouqianba_merchant_*

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { House, Tickets, CollectionTag, DocumentChecked, DataAnalysis, Bell, ChatLineRound, User, Lock, Setting, OfficeBuilding, Connection, Box, Van } from "@element-plus/icons-vue";
import { House, Tickets, CollectionTag, DocumentChecked, DataAnalysis, Bell, ChatLineRound, User, Lock, Setting, OfficeBuilding, Connection, Box, Van, Money } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { adminApi } from "../api/admin";
import { clearAdminSession, getAdminInfo, hasPermission } from "../utils/auth";
@@ -28,6 +28,7 @@ const menus = [
{ index: "users", label: "用户管理", icon: User, permission: "users.manage" },
{ index: "customers", label: "客户管理", icon: Connection, permission: "customers.manage" },
{ index: "warehouses", label: "仓库中心", icon: OfficeBuilding, permission: "warehouses.manage" },
{ index: "service-prices", label: "服务价格", icon: Money, permission: "service_prices.manage" },
{ index: "express-companies", label: "快递公司", icon: Van, permission: "warehouses.manage" },
{ index: "materials", label: "物料管理", icon: Box, permission: "materials.manage" },
{ index: "access", label: "权限中心", icon: Lock, permission: "access.manage" },

View File

@@ -10,6 +10,7 @@ import {
type AdminOrderDetail,
type AdminOrderListItem,
type AdminOrderWarehouseOption,
type AdminServicePricePackage,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
import { recognizeReturnAddress } from "../../utils/address-recognition";
@@ -36,7 +37,7 @@ const defaultExpressCompany = ref("");
const manualDialogVisible = ref(false);
const manualSubmitting = ref(false);
const manualMetaLoading = ref(false);
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [], service_price_packages: [] });
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref("");
@@ -150,11 +151,16 @@ const expressCompanySelectOptions = computed(() => {
...expressCompanyOptions.value,
];
});
const manualServicePackages = computed<AdminServicePricePackage[]>(() =>
manualMeta.value.service_price_packages.find((item) => item.service_provider === manualForm.value.service_provider)?.packages.filter((item) => item.is_enabled) || [],
);
let returnRecognitionTimer: ReturnType<typeof setTimeout> | undefined;
function createManualOrderForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
price_package_id: 0,
price_package_code: "",
product_info: {
category_id: 0,
brand_id: 0,
@@ -214,6 +220,7 @@ async function ensureManualMeta() {
try {
const response = await adminApi.getManualOrderMeta();
manualMeta.value = response.data;
applyManualDefaultPackage(true);
} catch (error) {
console.error(error);
ElMessage.error("补录订单选项加载失败");
@@ -222,11 +229,24 @@ async function ensureManualMeta() {
}
}
function applyManualDefaultPackage(force = false) {
const current = manualServicePackages.value.find((item) => item.id === manualForm.value.price_package_id);
if (current && !force) {
manualForm.value.price_package_code = current.package_code;
return;
}
const target = manualServicePackages.value.find((item) => item.is_default) || manualServicePackages.value[0];
manualForm.value.price_package_id = target?.id || 0;
manualForm.value.price_package_code = target?.package_code || "";
}
async function openManualDialog() {
manualForm.value = createManualOrderForm();
manualAddressRecognitionText.value = "";
manualDialogVisible.value = true;
await ensureManualMeta();
applyManualDefaultPackage(true);
}
async function ensureExpressCompanyOptions() {
@@ -260,6 +280,10 @@ function applyRecognizedManualAddress() {
function validateManualForm() {
const form = manualForm.value;
if (!form.price_package_id) {
ElMessage.warning("请选择价格套餐");
return false;
}
if (!form.product_info.category_id) {
ElMessage.warning("请选择品类");
return false;
@@ -474,6 +498,10 @@ watch(returnTrackingNo, () => {
}
});
watch(() => manualForm.value.service_provider, () => {
applyManualDefaultPackage(true);
});
onBeforeUnmount(() => {
if (returnRecognitionTimer) {
clearTimeout(returnRecognitionTimer);
@@ -523,6 +551,9 @@ onMounted(fetchOrders);
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="180" />
<el-table-column prop="product_name" label="商品名称" min-width="220" />
<el-table-column prop="service_provider_text" label="服务类型" min-width="120" />
<el-table-column prop="price_package_name" label="价格套餐" min-width="150">
<template #default="{ row }">{{ row.price_package_name || "-" }}</template>
</el-table-column>
<el-table-column label="下单渠道" min-width="150">
<template #default="{ row }">
<span>{{ row.source_channel_text }}</span>
@@ -615,6 +646,10 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">服务类型</div>
<div class="order-detail-item__value">{{ detail.order_info.service_provider_text }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">价格套餐</div>
<div class="order-detail-item__value">{{ detail.order_info.price_package_name || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">下单渠道</div>
<div class="order-detail-item__value">{{ detail.order_info.source_channel_text || "-" }}</div>
@@ -966,6 +1001,21 @@ onMounted(fetchOrders);
<el-option label="中检鉴定" value="zhongjian" />
</el-select>
</el-form-item>
<el-form-item label="价格套餐">
<el-select
v-model="manualForm.price_package_id"
style="width: 100%"
:disabled="manualServicePackages.length === 0"
@change="applyManualDefaultPackage(false)"
>
<el-option
v-for="item in manualServicePackages"
:key="item.id"
:label="`${item.package_name} / ¥${item.price}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="品类">
<el-select v-model="manualForm.product_info.category_id" filterable style="width: 100%">
<el-option v-for="item in manualMeta.categories" :key="item.id" :label="item.name" :value="item.id" />

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { Edit, Plus, Star } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminServicePricePackage, type AdminServicePricePayload } from "../../api/admin";
const loading = ref(false);
const submitting = ref(false);
const dialogVisible = ref(false);
const activeProvider = ref("anxinyan");
const providers = ref<Array<{ service_provider: string; service_provider_text: string; sla_hours: number }>>([]);
const packages = ref<AdminServicePricePackage[]>([]);
const form = reactive<AdminServicePricePayload>({
service_provider: "anxinyan",
package_name: "",
package_code: "",
price: 0,
description: "",
is_enabled: true,
is_default: false,
sort_order: 0,
});
const filteredPackages = computed(() => packages.value.filter((item) => item.service_provider === activeProvider.value));
const activeProviderText = computed(() => providerText(activeProvider.value));
function providerText(serviceProvider: string) {
return providers.value.find((item) => item.service_provider === serviceProvider)?.service_provider_text || serviceProvider;
}
function formatMoney(value: number) {
const amount = Number(value || 0);
return Number.isInteger(amount) ? amount.toFixed(0) : amount.toFixed(2);
}
function defaultPackageCode(serviceProvider: string) {
return serviceProvider === "zhongjian" ? "zhongjian_" : "anxinyan_";
}
async function fetchPackages() {
loading.value = true;
try {
const response = await adminApi.getServicePricePackages();
providers.value = response.data.providers;
packages.value = response.data.list;
if (!providers.value.some((item) => item.service_provider === activeProvider.value)) {
activeProvider.value = providers.value[0]?.service_provider || "anxinyan";
}
} catch (error) {
console.error(error);
ElMessage.error("服务价格加载失败");
} finally {
loading.value = false;
}
}
function resetForm(serviceProvider = activeProvider.value) {
form.id = undefined;
form.service_provider = serviceProvider;
form.package_name = "";
form.package_code = defaultPackageCode(serviceProvider);
form.price = 0;
form.description = "";
form.is_enabled = true;
form.is_default = filteredPackages.value.length === 0;
form.sort_order = filteredPackages.value.length + 1;
}
function openDialog(row?: AdminServicePricePackage) {
if (row) {
form.id = row.id;
form.service_provider = row.service_provider;
form.package_name = row.package_name;
form.package_code = row.package_code;
form.price = row.price;
form.description = row.description;
form.is_enabled = row.is_enabled;
form.is_default = row.is_default;
form.sort_order = row.sort_order;
} else {
resetForm(activeProvider.value);
}
dialogVisible.value = true;
}
async function submit() {
submitting.value = true;
try {
await adminApi.saveServicePricePackage({ ...form });
ElMessage.success(form.id ? "套餐已更新" : "套餐已创建");
dialogVisible.value = false;
await fetchPackages();
} catch (error) {
console.error(error);
ElMessage.error("套餐保存失败");
} finally {
submitting.value = false;
}
}
async function updateStatus(row: AdminServicePricePackage, isEnabled: boolean) {
try {
await adminApi.updateServicePricePackageStatus(row.id, isEnabled);
ElMessage.success(isEnabled ? "套餐已启用" : "套餐已停用");
await fetchPackages();
} catch (error) {
console.error(error);
ElMessage.error("套餐状态更新失败");
await fetchPackages();
}
}
async function setDefault(row: AdminServicePricePackage) {
try {
await ElMessageBox.confirm(`确认将「${row.package_name}」设为${row.service_provider_text}默认套餐?`, "设置默认套餐", {
type: "warning",
confirmButtonText: "确认",
cancelButtonText: "取消",
});
await adminApi.setDefaultServicePricePackage(row.id);
ElMessage.success("默认套餐已更新");
await fetchPackages();
} catch (error) {
if (error !== "cancel") {
console.error(error);
ElMessage.error("默认套餐设置失败");
}
}
}
onMounted(fetchPackages);
</script>
<template>
<div v-loading="loading" class="service-prices-page">
<el-card class="panel-card" shadow="never">
<div class="service-prices-toolbar">
<el-tabs v-model="activeProvider" class="service-prices-tabs">
<el-tab-pane v-for="provider in providers" :key="provider.service_provider" :label="provider.service_provider_text" :name="provider.service_provider" />
</el-tabs>
<el-button type="primary" :icon="Plus" @click="openDialog()">新增套餐</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="filteredPackages" stripe>
<el-table-column prop="package_name" label="套餐名称" min-width="180" />
<el-table-column prop="package_code" label="套餐编码" min-width="160" />
<el-table-column label="服务方" min-width="120">
<template #default="{ row }">{{ row.service_provider_text }}</template>
</el-table-column>
<el-table-column label="价格" min-width="120">
<template #default="{ row }">¥{{ formatMoney(row.price) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="160">
<template #default="{ row }">
<el-space>
<el-tag :type="row.is_enabled ? 'success' : 'info'">{{ row.is_enabled ? "启用" : "停用" }}</el-tag>
<el-tag v-if="row.is_default" type="warning">默认</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" min-width="90" />
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="250">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openDialog(row)">编辑</el-button>
<el-button link :type="row.is_enabled ? 'warning' : 'success'" @click="updateStatus(row, !row.is_enabled)">
{{ row.is_enabled ? "停用" : "启用" }}
</el-button>
<el-button link type="primary" :icon="Star" :disabled="row.is_default || !row.is_enabled" @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`${activeProviderText}暂无价格套餐`" />
</template>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务价格套餐' : '新增服务价格套餐'" width="640px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="服务方">
<el-select v-model="form.service_provider" :disabled="Boolean(form.id)" style="width: 100%">
<el-option v-for="provider in providers" :key="provider.service_provider" :label="provider.service_provider_text" :value="provider.service_provider" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序">
<el-input v-model.number="form.sort_order" type="number" placeholder="越小越靠前" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐名称">
<el-input v-model="form.package_name" maxlength="128" placeholder="例如 安心验基础套餐" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐编码">
<el-input v-model="form.package_code" maxlength="64" placeholder="例如 anxinyan_basic" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="价格">
<el-input-number v-model="form.price" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-switch v-model="form.is_enabled" inline-prompt active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写适用范围或套餐说明" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="默认套餐">
<el-switch v-model="form.is_default" :disabled="!form.is_enabled" inline-prompt active-text="默认" inactive-text="普通" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.service-prices-page {
min-width: 0;
}
.service-prices-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.service-prices-tabs {
flex: 1;
min-width: 0;
}
.service-prices-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
</style>

View File

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

View File

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

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

View File

@@ -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([
[
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'created',
'node_text' => '下单成功',
'node_desc' => '订单已生成并完成支付',
'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,
],
]);
$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)$serviceConfig['price'],
'next_status' => 'pending_shipping',
'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)$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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<?php
namespace app\controller\open;
use app\support\ShouqianbaPaymentService;
use support\Request;
class ShouqianbaPaymentController
{
public function notify(Request $request)
{
$service = new ShouqianbaPaymentService();
try {
$service->handleNotification($request->rawBody());
return json($service->notificationResponse(true));
} catch (\Throwable $e) {
return json($service->notificationResponse(false));
}
}
}

View File

@@ -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 => [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function column_exists(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare("SHOW COLUMNS FROM {$table} LIKE ?");
$stmt->execute([$column]);
return (bool)$stmt->fetch();
}
function add_column_if_missing(PDO $pdo, string $table, string $column, string $definition): void
{
if (column_exists($pdo, $table, $column)) {
return;
}
$pdo->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$definition}");
echo "ADD_COLUMN {$table}.{$column}\n";
}
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS appraisal_service_price_packages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
package_name VARCHAR(128) NOT NULL DEFAULT '',
package_code VARCHAR(64) NOT NULL DEFAULT '',
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
description VARCHAR(500) NOT NULL DEFAULT '',
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_appraisal_service_price_packages_code (service_provider, package_code),
KEY idx_appraisal_service_price_packages_provider (service_provider),
KEY idx_appraisal_service_price_packages_enabled (is_enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定服务价格套餐'
SQL);
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_id', 'BIGINT UNSIGNED NULL DEFAULT NULL AFTER service_provider');
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_name', "VARCHAR(128) NOT NULL DEFAULT '' AFTER price_package_id");
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_code', "VARCHAR(64) NOT NULL DEFAULT '' AFTER price_package_name");
add_column_if_missing($pdo, 'appraisal_drafts', 'price_package_price', 'DECIMAL(10,2) NULL DEFAULT NULL AFTER price_package_code');
add_column_if_missing($pdo, 'orders', 'price_package_id', 'BIGINT UNSIGNED NULL DEFAULT NULL AFTER service_provider');
add_column_if_missing($pdo, 'orders', 'price_package_name', "VARCHAR(128) NOT NULL DEFAULT '' AFTER price_package_id");
add_column_if_missing($pdo, 'orders', 'price_package_code', "VARCHAR(64) NOT NULL DEFAULT '' AFTER price_package_name");
add_column_if_missing($pdo, 'orders', 'price_package_price', 'DECIMAL(10,2) NULL DEFAULT NULL AFTER price_package_code');
$indexRows = $pdo->query("SHOW INDEX FROM orders WHERE Key_name = 'idx_orders_price_package_id'")->fetchAll();
if (!$indexRows) {
$pdo->exec('ALTER TABLE orders ADD KEY idx_orders_price_package_id (price_package_id)');
echo "ADD_INDEX orders.idx_orders_price_package_id\n";
}
$now = date('Y-m-d H:i:s');
$seedStmt = $pdo->prepare(
'INSERT INTO appraisal_service_price_packages (service_provider, package_name, package_code, price, description, is_enabled, is_default, sort_order, created_at, updated_at)
SELECT ?, ?, ?, ?, ?, 1, 1, 1, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM appraisal_service_price_packages WHERE service_provider = ? AND package_code = ?
)'
);
$seedStmt->execute(['anxinyan', '安心验基础套餐', 'anxinyan_basic', 99.00, '默认服务价格套餐', $now, $now, 'anxinyan', 'anxinyan_basic']);
$seedStmt->execute(['zhongjian', '中检基础套餐', 'zhongjian_basic', 199.00, '默认服务价格套餐', $now, $now, 'zhongjian', 'zhongjian_basic']);
foreach (['anxinyan', 'zhongjian'] as $serviceProvider) {
$candidateStmt = $pdo->prepare('SELECT id FROM appraisal_service_price_packages WHERE service_provider = ? AND is_enabled = 1 ORDER BY is_default DESC, sort_order ASC, id ASC LIMIT 1');
$candidateStmt->execute([$serviceProvider]);
$candidateId = (int)$candidateStmt->fetchColumn();
if ($candidateId > 0) {
$clearStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 0, updated_at = ? WHERE service_provider = ? AND id <> ? AND is_default = 1');
$clearStmt->execute([$now, $serviceProvider, $candidateId]);
$defaultStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 1, updated_at = ? WHERE id = ? AND is_default <> 1');
$defaultStmt->execute([$now, $candidateId]);
echo "NORMALIZE_DEFAULT_PACKAGE {$serviceProvider}:{$candidateId}\n";
} else {
$clearStmt = $pdo->prepare('UPDATE appraisal_service_price_packages SET is_default = 0, updated_at = ? WHERE service_provider = ? AND is_default = 1');
$clearStmt->execute([$now, $serviceProvider]);
}
}
$permissionStmt = $pdo->prepare(
'INSERT INTO admin_permissions (name, code, module, action, created_at, updated_at)
SELECT ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM admin_permissions WHERE code = ?)'
);
$permissionStmt->execute(['管理服务价格', 'service_prices.manage', 'service_prices', 'manage', $now, $now, 'service_prices.manage']);
$permissionIdStmt = $pdo->prepare('SELECT id FROM admin_permissions WHERE code = ?');
$permissionIdStmt->execute(['service_prices.manage']);
$permissionId = (int)$permissionIdStmt->fetchColumn();
$roleIdStmt = $pdo->prepare('SELECT id FROM admin_roles WHERE code = ? LIMIT 1');
$roleIdStmt->execute(['super_admin']);
$roleId = (int)$roleIdStmt->fetchColumn();
if ($permissionId > 0 && $roleId > 0) {
$rolePermissionStmt = $pdo->prepare(
'INSERT INTO admin_role_permissions (role_id, permission_id, created_at)
SELECT ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM admin_role_permissions WHERE role_id = ? AND permission_id = ?)'
);
$rolePermissionStmt->execute([$roleId, $permissionId, $now, $roleId, $permissionId]);
}
$pdo->exec("DELETE FROM system_configs WHERE config_group = 'appraisal_service'");
echo "SCHEMA_UPGRADE_SERVICE_PRICE_PACKAGES_OK\n";

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function hasTable(PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
$stmt->execute([$table]);
return (int)$stmt->fetchColumn() > 0;
}
function hasSystemConfig(PDO $pdo, string $group, string $key): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM system_configs WHERE config_group = ? AND config_key = ?');
$stmt->execute([$group, $key]);
return (int)$stmt->fetchColumn() > 0;
}
function hasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
$stmt->execute([$table, $column]);
return (int)$stmt->fetchColumn() > 0;
}
$now = date('Y-m-d H:i:s');
if (hasTable($pdo, 'orders') && !hasColumn($pdo, 'orders', 'cancelled_at')) {
$pdo->exec('ALTER TABLE orders ADD COLUMN cancelled_at DATETIME NULL DEFAULT NULL AFTER paid_at');
echo "ADD_COLUMN orders.cancelled_at\n";
}
if (!hasTable($pdo, 'shouqianba_payments')) {
$pdo->exec(<<<'SQL'
CREATE TABLE shouqianba_payments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
order_no VARCHAR(64) NOT NULL,
check_sn VARCHAR(32) NOT NULL,
order_sn VARCHAR(32) NOT NULL DEFAULT '',
order_token VARCHAR(64) NOT NULL DEFAULT '',
cashier_url VARCHAR(500) NOT NULL DEFAULT '',
order_image_url VARCHAR(500) NOT NULL DEFAULT '',
order_landing_url VARCHAR(500) NOT NULL DEFAULT '',
scene VARCHAR(8) NOT NULL DEFAULT '',
source_channel VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'pending',
amount INT UNSIGNED NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT '156',
request_json LONGTEXT NULL,
response_json LONGTEXT NULL,
notify_json LONGTEXT NULL,
paid_at DATETIME NULL DEFAULT NULL,
cancelled_at DATETIME NULL DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_shouqianba_payments_check_sn (check_sn),
KEY idx_shouqianba_payments_order_id (order_id),
KEY idx_shouqianba_payments_order_sn (order_sn),
KEY idx_shouqianba_payments_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收钱吧支付流水'
SQL);
echo "CREATE_TABLE shouqianba_payments\n";
}
$configs = [
['payment', 'enabled', 'disabled'],
['payment', 'api_domain', ''],
['payment', 'appid', ''],
['payment', 'brand_code', ''],
['payment', 'store_sn', ''],
['payment', 'store_name', ''],
['payment', 'workstation_sn', '0'],
['payment', 'industry_code', '0'],
['payment', 'order_expire_minutes', '1440'],
['payment', 'merchant_private_key', ''],
['payment', 'shouqianba_public_key', ''],
['payment', 'notify_url', ''],
['payment', 'mini_program_plugin_version', ''],
];
$stmt = $pdo->prepare('INSERT INTO system_configs (config_group, config_key, config_value, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)');
foreach ($configs as [$group, $key, $value]) {
if (hasSystemConfig($pdo, $group, $key)) {
continue;
}
$stmt->execute([$group, $key, $value, '后台系统配置', $now, $now]);
echo "ADD_CONFIG {$group}.{$key}\n";
}
echo "SCHEMA_UPGRADE_OK\n";

View File

@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
use app\support\ShouqianbaClient;
use app\support\ShouqianbaConfigService;
use app\support\ShouqianbaPaymentService;
use support\think\Db;
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
class MockShouqianbaClient extends ShouqianbaClient
{
public array $queryData = [];
public int $voidCalls = 0;
public function purchase(array $body): array
{
return [
'request' => ['body' => $body],
'response' => ['response' => ['body' => ['biz_response' => ['data' => [
'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8),
'order_token' => 'mock_token_' . $body['check_sn'],
'cashier_url' => 'https://cashier.mock/' . $body['check_sn'],
]]]]],
'data' => [
'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8),
'order_token' => 'mock_token_' . $body['check_sn'],
'cashier_url' => 'https://cashier.mock/' . $body['check_sn'],
],
];
}
public function query(array $body): array
{
return [
'request' => ['body' => $body],
'response' => ['response' => ['body' => ['biz_response' => ['data' => $this->queryData]]]],
'data' => $this->queryData,
];
}
public function void(array $body): array
{
$this->voidCalls++;
return [
'request' => ['body' => $body],
'response' => ['response' => ['body' => ['biz_response' => ['data' => [
'order_status' => '0',
'check_sn' => (string)($body['original_check_sn'] ?? ''),
]]]]],
'data' => [
'order_status' => '0',
'check_sn' => (string)($body['original_check_sn'] ?? ''),
],
];
}
public function decodeNotification(string $rawBody): array
{
$data = json_decode($rawBody, true);
if (!is_array($data)) {
throw new RuntimeException('mock notification json invalid');
}
return $data;
}
}
function assertTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function ensureConfig(string $group, string $key, string $value): void
{
$now = date('Y-m-d H:i:s');
$exists = Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->find();
$payload = [
'config_group' => $group,
'config_key' => $key,
'config_value' => $value,
'remark' => '收钱吧支付 mock 测试配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', (int)$exists['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
function captureConfigs(array $keys): array
{
$snapshot = [];
foreach ($keys as $key) {
[$group, $configKey] = explode('.', $key, 2);
$row = Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $configKey)
->find();
$snapshot[$key] = $row ?: null;
}
return $snapshot;
}
function restoreConfigs(array $snapshot): void
{
foreach ($snapshot as $key => $row) {
[$group, $configKey] = explode('.', $key, 2);
if ($row) {
Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $configKey)
->update([
'config_value' => (string)($row['config_value'] ?? ''),
'remark' => (string)($row['remark'] ?? '后台系统配置'),
'updated_at' => date('Y-m-d H:i:s'),
]);
continue;
}
Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $configKey)
->delete();
}
}
function cleanupMockData(): void
{
$orderIds = Db::name('orders')->whereLike('order_no', 'SQBMOCK%')->column('id');
if ($orderIds) {
Db::name('shouqianba_payments')->whereIn('order_id', $orderIds)->delete();
Db::name('message_logs')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
Db::name('user_messages')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
Db::name('appraisal_tasks')->whereIn('order_id', $orderIds)->delete();
Db::name('order_timelines')->whereIn('order_id', $orderIds)->delete();
Db::name('order_shipping_targets')->whereIn('order_id', $orderIds)->delete();
Db::name('order_return_addresses')->whereIn('order_id', $orderIds)->delete();
$uploadItemIds = Db::name('order_upload_items')->whereIn('order_id', $orderIds)->column('id');
if ($uploadItemIds) {
Db::name('order_upload_files')->whereIn('order_upload_item_id', $uploadItemIds)->delete();
}
Db::name('order_upload_items')->whereIn('order_id', $orderIds)->delete();
Db::name('order_extras')->whereIn('order_id', $orderIds)->delete();
Db::name('order_products')->whereIn('order_id', $orderIds)->delete();
Db::name('orders')->whereIn('id', $orderIds)->delete();
}
$userIds = Db::name('users')->whereLike('mobile', '1399919%')->column('id');
if ($userIds) {
Db::name('user_auths')->whereIn('user_id', $userIds)->delete();
Db::name('user_addresses')->whereIn('user_id', $userIds)->delete();
Db::name('users')->whereIn('id', $userIds)->delete();
}
}
function createMockUser(): int
{
$now = date('Y-m-d H:i:s');
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '收钱吧支付测试用户',
'avatar' => '',
'mobile' => '13999190001',
'password' => '',
'status' => 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('user_addresses')->insert([
'user_id' => $userId,
'consignee' => '测试用户',
'mobile' => '13999190001',
'province' => '广东省',
'city' => '深圳市',
'district' => '南山区',
'detail_address' => '收钱吧测试地址',
'is_default' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return $userId;
}
function createMockOrder(int $userId, string $suffix): int
{
$now = date('Y-m-d H:i:s');
$orderId = (int)Db::name('orders')->insertGetId([
'order_no' => 'SQBMOCK' . $suffix,
'appraisal_no' => 'SQB-MOCK-' . $suffix,
'user_id' => $userId,
'service_mode' => 'physical',
'service_provider' => 'anxinyan',
'payment_status' => 'unpaid',
'order_status' => 'pending_payment',
'display_status' => '待支付',
'estimated_finish_time' => date('Y-m-d H:i:s', strtotime('+48 hours')),
'source_channel' => 'h5',
'source_customer_id' => '',
'pay_amount' => 9.99,
'paid_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_products')->insert([
'order_id' => $orderId,
'category_id' => null,
'category_name' => '测试品类',
'brand_id' => null,
'brand_name' => '测试品牌',
'color' => '',
'size_spec' => '',
'serial_no' => '',
'product_name' => '收钱吧支付测试商品',
'product_cover' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_extras')->insert([
'order_id' => $orderId,
'purchase_channel' => '',
'purchase_price' => 0,
'purchase_date' => null,
'usage_status' => '',
'condition_desc' => '',
'has_accessories' => 0,
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'remark' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'created',
'node_text' => '订单已生成',
'node_desc' => '订单资料已保存,等待用户完成支付。',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
return $orderId;
}
function latestPayment(int $orderId): array
{
$payment = Db::name('shouqianba_payments')->where('order_id', $orderId)->order('id', 'desc')->find();
assertTrue((bool)$payment, 'payment row missing');
return $payment;
}
$configKeys = [
'payment.enabled',
'payment.api_domain',
'payment.appid',
'payment.brand_code',
'payment.store_sn',
'payment.store_name',
'payment.workstation_sn',
'payment.industry_code',
'payment.order_expire_minutes',
'payment.merchant_private_key',
'payment.shouqianba_public_key',
'payment.notify_url',
'payment.mini_program_plugin_version',
'h5.page_base_url',
];
$snapshot = captureConfigs($configKeys);
$client = new MockShouqianbaClient(new ShouqianbaConfigService());
$service = new ShouqianbaPaymentService(null, $client);
try {
cleanupMockData();
ensureConfig('payment', 'enabled', 'enabled');
ensureConfig('payment', 'api_domain', 'https://mock.shouqianba.test');
ensureConfig('payment', 'appid', 'sqb_mock_appid');
ensureConfig('payment', 'brand_code', 'mock_brand');
ensureConfig('payment', 'store_sn', 'mock_store');
ensureConfig('payment', 'store_name', 'mock store');
ensureConfig('payment', 'workstation_sn', '0');
ensureConfig('payment', 'industry_code', '0');
ensureConfig('payment', 'order_expire_minutes', '1440');
ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----");
ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----");
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
$userId = createMockUser();
$notifyOrderId = createMockOrder($userId, 'NOTIFY');
$launch = $service->createOrReusePayment($notifyOrderId);
assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment');
assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
$payment = latestPayment($notifyOrderId);
$notifyPayload = [
'check_sn' => $payment['check_sn'],
'order_status' => '4',
'amount' => (int)$payment['amount'],
'order_sn' => 'SQBMOCKPAID',
];
$service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE));
$service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE));
$paidOrder = Db::name('orders')->where('id', $notifyOrderId)->find();
assertTrue(($paidOrder['payment_status'] ?? '') === 'paid', 'notify should mark order paid');
assertTrue(($paidOrder['order_status'] ?? '') === 'pending_shipping', 'notify should move order to pending_shipping');
assertTrue((int)Db::name('appraisal_tasks')->where('order_id', $notifyOrderId)->count() === 1, 'notify should be idempotent for appraisal task');
assertTrue((int)Db::name('order_timelines')->where('order_id', $notifyOrderId)->where('node_code', 'payment_paid')->count() === 1, 'notify should be idempotent for paid timeline');
$queryOrderId = createMockOrder($userId, 'QUERY');
$service->createOrReusePayment($queryOrderId);
$queryPayment = latestPayment($queryOrderId);
$client->queryData = [
'check_sn' => $queryPayment['check_sn'],
'order_status' => '4',
'amount' => (int)$queryPayment['amount'],
'order_sn' => 'SQBMOCKQUERYPAID',
];
$sync = $service->syncOrderPaymentStatus($queryOrderId, $userId);
assertTrue(($sync['payment_status'] ?? '') === 'paid', 'query sync should mark paid');
$cancelOrderId = createMockOrder($userId, 'CANCEL');
$service->createOrReusePayment($cancelOrderId);
$cancelPayment = latestPayment($cancelOrderId);
$client->queryData = [
'check_sn' => $cancelPayment['check_sn'],
'order_status' => '1',
'amount' => (int)$cancelPayment['amount'],
'order_sn' => 'SQBMOCKCANCELPENDING',
];
$cancel = $service->cancelPendingOrder($cancelOrderId, $userId);
assertTrue(($cancel['order_status'] ?? '') === 'cancelled', 'cancel should mark order cancelled');
assertTrue($client->voidCalls === 1, 'cancel should call remote void once');
$paidCancelOrderId = createMockOrder($userId, 'PAIDCANCEL');
$service->createOrReusePayment($paidCancelOrderId);
$paidCancelPayment = latestPayment($paidCancelOrderId);
$client->queryData = [
'check_sn' => $paidCancelPayment['check_sn'],
'order_status' => '4',
'amount' => (int)$paidCancelPayment['amount'],
'order_sn' => 'SQBMOCKPAIDCANCEL',
];
$thrown = false;
try {
$service->cancelPendingOrder($paidCancelOrderId, $userId);
} catch (RuntimeException $e) {
$thrown = str_contains($e->getMessage(), '已支付');
}
$paidCancelOrder = Db::name('orders')->where('id', $paidCancelOrderId)->find();
assertTrue($thrown, 'cancel paid remote order should be rejected');
assertTrue(($paidCancelOrder['payment_status'] ?? '') === 'paid', 'cancel paid remote order should sync paid state');
echo "SHOUQIANBA_PAYMENT_MOCK_TEST_OK\n";
} catch (Throwable $e) {
fwrite(STDERR, "SHOUQIANBA_PAYMENT_MOCK_TEST_FAIL: " . $e->getMessage() . "\n");
exit(1);
} finally {
cleanupMockData();
restoreConfigs($snapshot);
}

View File

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

View File

@@ -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<OrderPaymentStatusData>("/api/app/order/payment/status", {
params: { id: orderId },
});
},
cancelOrder(orderId: number) {
return request<OrderPaymentStatusData>("/api/app/order/cancel", {
method: "POST",
data: { order_id: orderId },
});
},
getOrderShippingDetail(orderId: number) {
return request<ShippingDetailData>("/api/app/order/shipping", {
params: { order_id: orderId },

View File

@@ -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<string, any>;
extra_info: Record<string, any>;
@@ -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",

View File

@@ -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<SendLoginCodeResult>("/api/app/auth/send-code", {
@@ -84,6 +89,12 @@ export const authApi = {
data: payload,
});
},
bindMiniProgramOpenid(code: string) {
return request<MiniProgramBindResult>("/api/app/auth/mini-program/bind", {
method: "POST",
data: { code },
});
},
getMe() {
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
},

View File

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

View File

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

View File

@@ -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"
}
},
{

View File

@@ -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<UserAddressItem[]>([]);
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);
</script>
<template>
<view class="app-page app-page--tight">
<FlowStepHeader
:step="3"
:total="3"
title="确认订单并支付"
desc="提交前请再次确认服务、商品信息与寄回地址,提交后会生成正式鉴定单并进入履约流程。"
:chips="['确认服务', '确认商品', '提交后生成正式鉴定单']"
/>
<view v-if="loading" class="notice-card">
<view class="notice-card__title">正在准备订单预览</view>
<view class="notice-card__desc">请稍候系统正在汇总服务商品与费用信息</view>
<view class="confirm-page">
<view class="confirm-nav">
<view class="confirm-nav__home" @click="goBack">
<view class="confirm-nav__home-roof"></view>
<view class="confirm-nav__home-body"></view>
</view>
<view class="section summary-card">
<view class="summary-card__title">服务摘要</view>
<view class="summary-card__row">
<text class="summary-card__label">鉴定服务</text>
<text class="summary-card__value">{{ preview?.service_summary.service_provider_text || '中检鉴定' }}</text>
<view class="confirm-nav__title">确认订单</view>
<view class="confirm-nav__capsule">
<text class="confirm-nav__dots"></text>
<view class="confirm-nav__divider"></view>
<view class="confirm-nav__circle"></view>
</view>
</view>
<view class="section summary-card">
<view class="summary-card__title">商品摘要</view>
<view class="summary-card__row">
<text class="summary-card__label">商品名称</text>
<text class="summary-card__value">{{ preview?.product_summary.product_name || '' }}</text>
<view v-if="loading" class="confirm-state">
<view class="confirm-state__title">正在准备订单预览</view>
<view class="confirm-state__desc">请稍候系统正在汇总服务商品与费用信息</view>
</view>
<view class="summary-card__row">
<text class="summary-card__label">品类 / 品牌</text>
<text class="summary-card__value">{{ productCategoryBrandText }}</text>
<view class="confirm-card confirm-address" @click="openAddressSheet">
<view class="confirm-address__icon"></view>
<view class="confirm-address__body">
<view class="confirm-card__title">寄回地址</view>
<template v-if="selectedReturnAddress.id">
<view class="confirm-address__person">
{{ selectedReturnAddress.consignee }} {{ selectedReturnAddress.mobile }}
<text v-if="selectedReturnAddress.isDefault" class="confirm-address__tag">默认</text>
</view>
<view class="confirm-address__detail">{{ selectedReturnAddress.fullAddress }}</view>
</template>
<view v-else class="confirm-card__muted">请添加收货人信息</view>
</view>
<view class="confirm-address__edit"></view>
</view>
<view class="confirm-card">
<view class="confirm-card__title">商品摘要</view>
<view class="product-summary">
<view class="product-summary__thumb">
<view class="product-summary__mark">{{ brandText === "未填写" ? "AXY" : brandText.slice(0, 2) }}</view>
</view>
<view class="product-summary__info">
<view class="product-summary__row">服务{{ serviceProviderText }}</view>
<view v-if="packageNameText" class="product-summary__row">套餐{{ packageNameText }}</view>
<view class="product-summary__row">品类{{ categoryText }}</view>
<view class="product-summary__row">品牌{{ brandText }}</view>
</view>
</view>
<view class="confirm-card__muted confirm-card__muted--top">{{ productNameText }}</view>
</view>
<view class="confirm-card">
<view class="fee-head">
<view class="confirm-card__title">费用明细</view>
<view class="fee-head__amount">{{ payAmountText }}</view>
</view>
<view class="fee-line">
<view class="fee-line__service-logo">安心验</view>
<view class="fee-line__name">{{ packageNameText || `${serviceProviderText}服务费` }}</view>
<view class="fee-line__amount">{{ serviceFeeText }}</view>
</view>
<view v-if="Number(preview?.fee_detail.discount_fee || 0) > 0" class="fee-line fee-line--plain">
<view class="fee-line__name">优惠抵扣</view>
<view class="fee-line__amount">- {{ discountFeeText }}</view>
</view>
</view>
<view class="section summary-card">
<view class="summary-card__title">费用明细</view>
<view class="summary-card__row">
<text class="summary-card__label">鉴定服务费</text>
<text class="summary-card__value">¥{{ preview?.fee_detail.service_fee || 0 }}</text>
</view>
<view class="summary-card__row">
<text class="summary-card__label">优惠抵扣</text>
<text class="summary-card__value">- ¥{{ preview?.fee_detail.discount_fee || 0 }}</text>
</view>
<view class="summary-card__row">
<text class="summary-card__label">实付金额</text>
<text class="summary-card__value">¥{{ preview?.fee_detail.pay_amount || 0 }}</text>
</view>
</view>
<view class="confirm-section-title">提交前请确认</view>
<view class="confirm-section-desc">提交即表示您已阅读并同意以下协议与说明请在提交前再次确认</view>
<view class="section summary-card">
<view class="summary-card__title">寄回地址</view>
<view v-if="selectedReturnAddress.id" class="address-preview-card">
<view class="address-preview-card__top">
<view>
<view class="address-preview-card__name">{{ selectedReturnAddress.consignee }}</view>
<view class="address-preview-card__mobile">{{ selectedReturnAddress.mobile }}</view>
</view>
<text v-if="selectedReturnAddress.isDefault" class="certificate-meta-chip">默认地址</text>
</view>
<view class="address-preview-card__address">{{ selectedReturnAddress.fullAddress }}</view>
<view class="address-preview-card__action" @click="openAddressSheet">更换地址</view>
</view>
<view v-else class="address-preview-card address-preview-card--empty">
<view class="address-preview-card__name">暂未选择寄回地址</view>
<view class="section__desc">鉴定完成后平台会按这里的地址回寄商品请先确认</view>
<view class="address-preview-card__action" @click="openAddressSheet">选择地址</view>
</view>
</view>
<view class="section agreement-card">
<view class="section__title">提交前确认</view>
<view class="section__desc">提交即表示您已阅读并同意以下协议与说明请在提交前再次确认</view>
<view v-if="preview?.agreements?.length" class="agreement-card__list">
<view v-if="preview?.agreements?.length" class="agreement-list">
<view
v-for="item in preview.agreements"
:key="item.code"
class="agreement-card__item"
class="agreement-item"
@click="openAgreement(item.target_url)"
>
<view class="agreement-card__item-title">{{ item.title }}</view>
<view class="agreement-card__item-desc">{{ item.desc }}</view>
<view class="agreement-item__icon"></view>
<view class="agreement-item__body">
<view class="agreement-item__title">{{ item.title }}</view>
<view class="agreement-item__desc">{{ item.desc }}</view>
</view>
</view>
</view>
<view class="fixed-action-bar">
<view class="btn btn--secondary" @click="goBack">返回</view>
<view :class="['btn', 'btn--primary', loading ? 'btn--disabled' : '']" @click="goSuccess">立即支付</view>
<view class="confirm-bottom">
<view class="confirm-bottom__total">
<view class="confirm-bottom__label">总金额</view>
<view class="confirm-bottom__price">{{ payAmountText }}</view>
<view class="confirm-bottom__tax">含税价</view>
</view>
<view :class="['confirm-bottom__submit', !canSubmit ? 'confirm-bottom__submit--disabled' : '']" @click="goSuccess">
{{ submitting ? "提交中..." : "立即支付" }}
</view>
</view>
<view v-if="addressSheetVisible" class="picker-sheet">
@@ -269,15 +305,16 @@ onShow(fetchAddresses);
</view>
<scroll-view scroll-y class="picker-sheet__list">
<view v-if="addressesLoading" class="picker-sheet__empty">正在加载地址...</view>
<view
v-for="item in addressOptions"
:key="item.id"
:class="['picker-sheet__option', selectedReturnAddress.id === item.id ? 'picker-sheet__option--selected' : '']"
@click="chooseAddress(item)"
>
<view style="flex:1">
<view class="picker-sheet__option-main">
<view class="picker-sheet__option-title">{{ item.consignee }} / {{ item.mobile }}</view>
<view class="selector-card__meta" style="margin-top: 10rpx;">{{ item.full_address }}</view>
<view class="picker-sheet__option-desc">{{ item.full_address }}</view>
</view>
<view class="picker-sheet__option-action">{{ selectedReturnAddress.id === item.id ? "已选地址" : "选择" }}</view>
</view>
@@ -287,79 +324,456 @@ onShow(fetchAddresses);
</view>
</template>
<style scoped>
.address-preview-card {
margin-top: 16rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: #ffffff;
border: 1px solid var(--card-border);
<style scoped lang="scss">
.confirm-page {
min-height: 100vh;
padding: 28rpx 30rpx calc(148rpx + env(safe-area-inset-bottom));
background: #f2f2f4;
color: #252527;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
.address-preview-card--empty {
background: rgba(255, 255, 255, 0.76);
.confirm-page,
.confirm-page view,
.confirm-page text {
box-sizing: border-box;
}
.address-preview-card__top {
.confirm-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
align-items: flex-start;
min-height: 72rpx;
margin-bottom: 28rpx;
}
.address-preview-card__name {
color: var(--color-heading);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
.confirm-nav__home {
position: relative;
width: 52rpx;
height: 52rpx;
}
.address-preview-card__mobile {
.confirm-nav__home-roof {
position: absolute;
left: 10rpx;
top: 4rpx;
width: 32rpx;
height: 32rpx;
border-left: 5rpx solid #252527;
border-top: 5rpx solid #252527;
transform: rotate(45deg);
}
.confirm-nav__home-body {
position: absolute;
left: 12rpx;
bottom: 6rpx;
width: 30rpx;
height: 28rpx;
border: 5rpx solid #252527;
border-top: 0;
border-radius: 4rpx;
background: #ffffff;
}
.confirm-nav__home-body::after {
content: "";
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 18rpx;
height: 12rpx;
border-radius: 12rpx 12rpx 12rpx 4rpx;
background: #edbd00;
}
.confirm-nav__title {
color: #252527;
font-size: 38rpx;
font-weight: 800;
line-height: 1.2;
}
.confirm-nav__capsule {
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 64rpx;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.confirm-nav__dots {
color: #111111;
font-size: 36rpx;
font-weight: 800;
line-height: 1;
}
.confirm-nav__divider {
width: 1px;
height: 36rpx;
margin: 0 18rpx;
background: rgba(0, 0, 0, 0.3);
}
.confirm-nav__circle {
width: 34rpx;
height: 34rpx;
border: 7rpx solid #111111;
border-radius: 50%;
}
.confirm-state,
.confirm-card,
.agreement-item {
border-radius: 16rpx;
background: #ffffff;
}
.confirm-state {
padding: 28rpx 30rpx;
margin-bottom: 22rpx;
}
.confirm-state__title {
color: #252527;
font-size: 30rpx;
font-weight: 800;
line-height: 1.5;
}
.confirm-state__desc {
margin-top: 8rpx;
color: var(--color-muted);
font-size: var(--font-size-xs);
line-height: 1.6;
color: #9a9a9d;
font-size: 25rpx;
line-height: 1.7;
}
.address-preview-card__address {
margin-top: 14rpx;
color: var(--color-body);
font-size: var(--font-size-sm);
line-height: 1.8;
.confirm-card {
margin-top: 22rpx;
padding: 30rpx;
}
.address-preview-card__action {
margin-top: 16rpx;
color: var(--color-accent);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
.confirm-card__title {
color: #252527;
font-size: 32rpx;
font-weight: 800;
line-height: 1.5;
}
.agreement-card__list {
display: grid;
gap: 14rpx;
.confirm-card__muted {
color: #a0a0a4;
font-size: 26rpx;
line-height: 1.65;
}
.confirm-card__muted--top {
margin-top: 18rpx;
}
.agreement-card__item {
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--card-border);
.confirm-address {
display: flex;
align-items: center;
gap: 22rpx;
}
.agreement-card__item-title {
color: var(--color-heading);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
.confirm-address__icon {
position: relative;
flex-shrink: 0;
width: 48rpx;
height: 42rpx;
border: 4rpx solid #252527;
border-radius: 8rpx;
}
.confirm-address__icon::before {
content: "";
position: absolute;
left: 7rpx;
top: -14rpx;
width: 30rpx;
height: 24rpx;
border: 4rpx solid #252527;
border-bottom: 0;
border-radius: 12rpx 12rpx 0 0;
background: #ffffff;
}
.confirm-address__body {
flex: 1;
min-width: 0;
}
.confirm-address__person {
margin-top: 8rpx;
color: #252527;
font-size: 27rpx;
font-weight: 700;
line-height: 1.5;
}
.confirm-address__tag {
display: inline-flex;
align-items: center;
min-height: 34rpx;
margin-left: 12rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.12);
color: #c89b00;
font-size: 21rpx;
line-height: 34rpx;
}
.confirm-address__detail {
margin-top: 8rpx;
color: #7a7a7d;
font-size: 25rpx;
line-height: 1.65;
}
.confirm-address__edit {
position: relative;
flex-shrink: 0;
width: 50rpx;
height: 50rpx;
border: 3rpx dashed #8d8d91;
}
.confirm-address__edit::before {
content: "";
position: absolute;
left: 12rpx;
top: 22rpx;
width: 24rpx;
height: 4rpx;
border-radius: 999rpx;
background: #5d5d61;
transform: rotate(-35deg);
}
.product-summary {
display: flex;
align-items: center;
gap: 28rpx;
margin-top: 20rpx;
}
.product-summary__thumb {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 104rpx;
height: 104rpx;
border: 2rpx dashed #8d8d91;
background: #f7f7f8;
}
.product-summary__mark {
max-width: 86rpx;
color: #252527;
font-size: 20rpx;
font-weight: 900;
line-height: 1.15;
text-align: center;
word-break: break-all;
}
.product-summary__info {
flex: 1;
min-width: 0;
}
.product-summary__row {
color: #3d3d40;
font-size: 28rpx;
line-height: 1.8;
word-break: break-all;
}
.fee-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.fee-head__amount {
color: #e01b24;
font-size: 32rpx;
font-weight: 800;
line-height: 1.4;
}
.fee-line {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 80rpx;
margin-top: 24rpx;
padding: 0 18rpx;
border-radius: 10rpx;
background: #f5f5f6;
}
.fee-line--plain {
background: #ffffff;
border-top: 1px solid #f0f0f2;
}
.fee-line__service-logo {
flex-shrink: 0;
color: #252527;
font-size: 24rpx;
font-weight: 900;
}
.fee-line__name {
flex: 1;
min-width: 0;
color: #3d3d40;
font-size: 28rpx;
line-height: 1.5;
}
.fee-line__amount {
flex-shrink: 0;
color: #3d3d40;
font-size: 28rpx;
line-height: 1.5;
}
.confirm-section-title {
margin-top: 34rpx;
color: #252527;
font-size: 34rpx;
font-weight: 800;
line-height: 1.5;
}
.confirm-section-desc {
margin-top: 10rpx;
color: #a0a0a4;
font-size: 26rpx;
line-height: 1.75;
}
.agreement-list {
display: grid;
gap: 18rpx;
margin-top: 20rpx;
}
.agreement-item {
display: flex;
align-items: center;
gap: 22rpx;
min-height: 104rpx;
padding: 20rpx 24rpx;
}
.agreement-item__icon {
position: relative;
flex-shrink: 0;
width: 52rpx;
height: 52rpx;
border: 3rpx dashed #8d8d91;
}
.agreement-item__icon::before {
content: "";
position: absolute;
left: 15rpx;
top: 10rpx;
width: 22rpx;
height: 30rpx;
border: 4rpx solid #252527;
border-top: 0;
border-left: 0;
transform: rotate(45deg);
}
.agreement-item__body {
flex: 1;
min-width: 0;
}
.agreement-item__title {
color: #252527;
font-size: 30rpx;
font-weight: 800;
line-height: 1.5;
}
.agreement-item__desc {
margin-top: 4rpx;
color: #a0a0a4;
font-size: 25rpx;
line-height: 1.6;
}
.agreement-card__item-desc {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
.confirm-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28rpx;
padding: 18rpx 30rpx calc(18rpx + env(safe-area-inset-bottom));
border-top: 1px solid #e9e9eb;
background: rgba(255, 255, 255, 0.96);
}
.confirm-bottom__total {
flex-shrink: 0;
min-width: 220rpx;
}
.confirm-bottom__label {
display: inline-block;
color: #3d3d40;
font-size: 25rpx;
line-height: 1.4;
}
.confirm-bottom__price {
display: inline-block;
margin-left: 8rpx;
color: #edbd00;
font-size: 42rpx;
font-weight: 800;
line-height: 1.2;
}
.confirm-bottom__tax {
margin-top: 2rpx;
color: #a0a0a4;
font-size: 24rpx;
line-height: 1.4;
}
.confirm-bottom__submit {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 84rpx;
border-radius: 999rpx;
background: #edbd00;
color: #ffffff;
font-size: 30rpx;
font-weight: 800;
}
.confirm-bottom__submit--disabled {
opacity: 0.58;
}
.picker-sheet {
@@ -394,32 +808,25 @@ onShow(fetchAddresses);
}
.picker-sheet__title {
color: var(--color-heading);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: #252527;
font-size: 34rpx;
font-weight: 800;
}
.picker-sheet__desc {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
.picker-sheet__desc,
.picker-sheet__option-desc,
.picker-sheet__empty {
color: #9a9a9d;
font-size: 24rpx;
line-height: 1.7;
}
.picker-sheet__close {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 104rpx;
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.12);
color: var(--color-accent);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1;
white-space: nowrap;
.picker-sheet__close,
.picker-sheet__create,
.picker-sheet__option-action {
color: #edbd00;
font-size: 26rpx;
font-weight: 700;
}
.picker-sheet__list {
@@ -432,46 +839,36 @@ onShow(fetchAddresses);
margin-bottom: 8rpx;
}
.picker-sheet__create {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 144rpx;
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(237, 189, 0, 0.16) 0%, rgba(237, 189, 0, 0.08) 100%);
color: var(--color-accent);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1;
}
.picker-sheet__option {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
padding: 24rpx 6rpx;
border-bottom: 1px solid var(--card-border);
border-bottom: 1px solid #eeeeef;
}
.picker-sheet__option--selected {
padding: 24rpx 18rpx;
border-radius: 22rpx;
background: #ffffff;
border-radius: 16rpx;
background: rgba(237, 189, 0, 0.08);
border: 1px solid rgba(237, 189, 0, 0.22);
box-shadow: 0 10rpx 24rpx rgba(237, 189, 0, 0.08);
}
.picker-sheet__option-main {
flex: 1;
min-width: 0;
}
.picker-sheet__option-title {
color: var(--color-heading);
font-size: var(--font-size-sm);
line-height: 1.7;
color: #252527;
font-size: 28rpx;
font-weight: 700;
line-height: 1.6;
}
.picker-sheet__option-action {
color: var(--color-accent);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
.picker-sheet__empty {
padding: 48rpx 0 24rpx;
text-align: center;
}
</style>

View File

@@ -1,212 +1,214 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { appraisalApi, type CategoryOption } from "../../api/appraisal";
import FlowStepHeader from "../../components/FlowStepHeader.vue";
import { appraisalApi, type CatalogOption, type CategoryOption } from "../../api/appraisal";
import { useAppraisalStore } from "../../stores/appraisal";
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
type PickerType = "category";
type CategoryPickerItem = {
type ProductPageOptions = Record<string, string | undefined>;
type BrandOption = {
id: number;
name: string;
code: string;
desc: string;
enName: string;
displayName: string;
searchText: string;
group: string;
};
const store = useAppraisalStore();
const categories = ref<CategoryPickerItem[]>([]);
const pageLoading = ref(false);
const submitting = ref(false);
const loadError = ref("");
const activePicker = ref<PickerType | "">("");
const pickerKeyword = ref("");
const keyword = ref("");
const brands = ref<BrandOption[]>([]);
const canContinue = computed(() => Boolean(store.product.categoryId));
const brandNameForSave = computed(() => store.product.brandName.trim());
const selectedBrandId = computed(() => store.product.brandId || 0);
const categoryTitle = computed(() => store.product.categoryName || "已选品类");
const serviceProviderText = computed(() => store.serviceProvider === "zhongjian" ? "中检鉴定" : "安心验鉴定");
const hasCategory = computed(() => Boolean(store.product.categoryId));
const productSummary = computed(() => [
store.product.categoryName || "未选品类",
brandNameForSave.value || "未填品牌",
].join(" / "));
const filteredBrands = computed(() => {
const normalized = normalizeSearchText(keyword.value);
if (!normalized) return brands.value;
return brands.value.filter((item) => item.searchText.includes(normalized));
});
const pickerMeta = computed(() => {
const groupedBrands = computed(() => {
const groups: Array<{ letter: string; list: BrandOption[] }> = [];
for (const item of filteredBrands.value) {
const last = groups[groups.length - 1];
if (last?.letter === item.group) {
last.list.push(item);
continue;
}
groups.push({ letter: item.group, list: [item] });
}
return groups;
});
function normalizeSearchText(value = "") {
return value.replace(/[\s\u200B-\u200D\uFEFF]+/g, "").toLowerCase();
}
function firstGroupLetter(item: CatalogOption) {
const source = (item.brand_en_name || item.brand_name || "").trim();
const first = source.charAt(0).toUpperCase();
return /^[A-Z]$/.test(first) ? first : "#";
}
function formatBrandName(item: CatalogOption) {
const name = String(item.brand_name || "").trim();
const enName = String(item.brand_en_name || "").trim();
if (enName && name && normalizeSearchText(enName) !== normalizeSearchText(name)) {
return `${enName}/${name}`;
}
return enName || name || "未命名品牌";
}
function mapBrand(item: CatalogOption): BrandOption {
const name = String(item.brand_name || "").trim();
const enName = String(item.brand_en_name || "").trim();
const displayName = formatBrandName(item);
return {
title: "选择品类",
desc: "品类将持续扩展,建议统一通过搜索选择器完成选择,后续更便于拓展更多鉴定业务。",
options: categories.value,
label: (item: CategoryPickerItem) => item.name || "",
emptyText: "暂无可用品类",
searchPlaceholder: "搜索品类名称",
id: Number(item.brand_id || 0),
name,
enName,
displayName,
searchText: normalizeSearchText(`${name}${enName}${displayName}`),
group: firstGroupLetter(item),
};
});
const filteredPickerOptions = computed(() => {
const keyword = pickerKeyword.value.trim().toLowerCase();
if (!keyword) {
return pickerMeta.value.options;
}
const options = pickerMeta.value.options;
return options.filter((item) =>
pickerMeta.value.label(item).toLowerCase().includes(keyword),
);
});
function pickerOptionKey(item: CategoryPickerItem) {
return String(item.id);
}
function selectCategory(item: CategoryPickerItem) {
store.setProduct({
categoryId: item.id,
categoryName: item.name,
brandId: 0,
});
}
async function loadCategories() {
async function ensureCategoryNameFromCatalog(categoryId: number) {
if (!categoryId || store.product.categoryName) return;
const data = await appraisalApi.getCategories();
categories.value = data.list.map((item: CategoryOption) => ({
id: item.category_id,
name: item.category_name,
code: item.category_code,
desc: "来自后台品类配置,支持后续继续扩展。",
}));
const match = data.list.find((item: CategoryOption) => Number(item.category_id) === categoryId);
if (match) {
store.setProduct({ categoryName: match.category_name });
}
}
function openPicker(type: PickerType) {
activePicker.value = type;
pickerKeyword.value = "";
async function loadDraftAndBrands(options: ProductPageOptions = {}) {
const queryDraftId = Number(options.draft_id || 0);
const queryCategoryId = Number(options.category_id || 0);
if (queryDraftId && queryDraftId !== store.draftId) {
store.setDraft(queryDraftId);
}
function closePicker() {
activePicker.value = "";
pickerKeyword.value = "";
}
function confirmPicker(item: CategoryPickerItem) {
selectCategory(item);
closePicker();
}
function isPickerCurrent(item: CategoryPickerItem) {
return Number(item.id) === Number(store.product.categoryId || 0);
}
function setBrandName(event: unknown) {
const value = typeof event === "string"
? event
: (event as { detail?: { value?: string } })?.detail?.value || "";
if (queryCategoryId && queryCategoryId !== store.product.categoryId) {
store.setProduct({
categoryId: queryCategoryId,
brandId: 0,
brandName: value,
brandName: "",
});
}
async function goNext() {
if (!store.draftId) {
throw new Error("订单草稿不存在,请返回服务介绍页重新发起鉴定");
}
const draft = await appraisalApi.getDraft(store.draftId);
store.setServiceProvider(draft.service_provider || store.serviceProvider);
if (draft.product_info?.category_id) {
const categoryId = Number(draft.product_info.category_id || 0);
store.setProduct({
categoryId,
brandId: Number(draft.product_info.brand_id || 0),
brandName: draft.product_info.brand_name || "",
});
}
await ensureCategoryNameFromCatalog(store.product.categoryId);
if (!store.product.categoryId) {
throw new Error("请先选择鉴定品类");
}
const data = await appraisalApi.getBrands(store.product.categoryId);
brands.value = data.list
.map(mapBrand)
.filter((item) => item.id > 0 || item.displayName)
.sort((a, b) => {
if (a.group !== b.group) return a.group.localeCompare(b.group);
return a.displayName.localeCompare(b.displayName);
});
loadError.value = "";
}
async function saveProductAndGoConfirm(payload: { brandId: number; brandName: string }) {
if (submitting.value) return;
if (!canContinue.value) {
if (!hasCategory.value) {
showInfoToast("请先选择品类");
return;
}
store.setProduct({
brandId: 0,
brandName: brandNameForSave.value,
});
submitting.value = true;
try {
await withLoading("正在保存商品信息", async () => {
await withLoading("正在保存品牌", async () => {
store.setProduct({
brandId: payload.brandId,
brandName: payload.brandName,
});
await appraisalApi.saveDraft({
draft_id: store.draftId,
current_step: 3,
service_provider: store.serviceProvider,
product_info: {
category_id: store.product.categoryId,
brand_id: 0,
brand_name: store.product.brandName,
brand_id: payload.brandId,
brand_name: payload.brandName,
},
});
});
loadError.value = "";
store.setCurrentStep(3);
store.setPreview(null);
uni.navigateTo({ url: "/pages/appraisal/confirm" });
} catch (error) {
loadError.value = "品信息保存失败,请稍后重试或联系管理员检查服务。";
showErrorToast(error, "品信息保存失败");
loadError.value = "品信息保存失败,请稍后重试。";
showErrorToast(error, "品信息保存失败");
} finally {
submitting.value = false;
}
}
function selectBrand(item: BrandOption) {
void saveProductAndGoConfirm({
brandId: item.id,
brandName: item.name || item.enName || item.displayName,
});
}
function skipBrand() {
void saveProductAndGoConfirm({
brandId: 0,
brandName: "",
});
}
function goBack() {
uni.navigateBack();
}
onLoad(async () => {
onLoad(async (options: ProductPageOptions = {}) => {
store.hydrate();
pageLoading.value = true;
try {
await loadCategories();
if (!categories.value.length) {
loadError.value = "后台暂未启用品类,请先在商品资料中心配置并启用品类。";
store.setProduct({
categoryId: 0,
categoryName: "",
brandId: 0,
brandName: "",
});
return;
}
if (!store.draftId) {
const draft = await appraisalApi.createDraft(store.serviceProvider);
store.setDraft(draft.draft_id);
}
const draft = await appraisalApi.getDraft(store.draftId);
if (draft.product_info?.category_id) {
const categoryId = Number(draft.product_info.category_id);
const categoryName = categories.value.find((item) => item.id === categoryId)?.name || "";
store.setProduct({
categoryId,
categoryName,
brandId: Number(draft.product_info.brand_id || 0),
brandName: draft.product_info.brand_name || "",
});
} else if (store.product.categoryId) {
const currentCategory = categories.value.find((item) => item.id === store.product.categoryId);
if (!currentCategory) {
store.setProduct({
categoryId: 0,
categoryName: "",
brandId: 0,
brandName: "",
});
} else if (store.product.categoryName !== currentCategory.name) {
store.setProduct({ categoryName: currentCategory.name });
}
}
loadError.value = "";
await loadDraftAndBrands(options);
} catch (error) {
if (isMissingDraftError(error)) {
try {
await rebuildDraftFromStore(store);
loadError.value = "";
showInfoToast("草稿已自动恢复,可继续填写商品信息。");
await loadDraftAndBrands(options);
showInfoToast("草稿已自动恢复,可继续选择品牌。");
} catch (recoverError) {
store.resetForNewFlow();
loadError.value = "商品草稿已失效,请重新选择鉴定服务。";
showErrorToast(recoverError, "商品信息加载失败");
loadError.value = "商品草稿已失效,请重新发起鉴定。";
showErrorToast(recoverError, "品牌加载失败");
}
} else {
loadError.value = "商品字典加载失败,请稍后重试或联系管理员检查接口。";
showErrorToast(error, "商品信息加载失败");
loadError.value = error instanceof Error ? error.message : "品牌加载失败,请稍后重试。";
showErrorToast(error, "品牌加载失败");
}
} finally {
pageLoading.value = false;
@@ -215,297 +217,289 @@ onLoad(async () => {
</script>
<template>
<view class="app-page app-page--tight">
<FlowStepHeader
:step="2"
:total="3"
title="选择商品信息"
desc="只需确认本次送检商品的品类,品牌可按实际情况选填,其他细节可在后续补充说明中填写。"
:chips="['选择品类', '品牌选填']"
/>
<view class="section form-panel">
<view class="form-panel__title">当前识别路径</view>
<view class="form-panel__desc">当前步骤用于确定商品大类便于匹配资料模板与服务流程</view>
<view class="selection-summary-card">
<view class="selection-summary-card__title">{{ productSummary }}</view>
<view class="selection-summary-card__desc">
{{ canContinue ? "已完成基础商品信息" : "请选择品类后继续" }}
</view>
</view>
</view>
<view class="section form-panel">
<view class="form-panel__title">商品识别信息</view>
<view class="form-panel__desc">品类来源于后台资料配置品牌由用户按实际商品自行填写</view>
<view v-if="pageLoading" class="notice-card">
<view class="notice-card__title">正在加载商品字典</view>
<view class="notice-card__desc">品类数据加载完成后即可继续选择</view>
</view>
<view v-if="loadError" class="notice-card">
<view class="notice-card__title">商品信息加载失败</view>
<view class="notice-card__desc">{{ loadError }}</view>
</view>
<view class="form-group">
<view class="form-group__label">品类</view>
<view :class="['selector-card', store.product.categoryId ? 'selector-card--selected' : '']" @click="openPicker('category')">
<view class="selector-card__main">
<view class="selector-card__value">{{ store.product.categoryName || "请选择品类" }}</view>
<view class="selector-card__meta">当前已配置 {{ categories.length }} 个品类来源于后台商品资料配置</view>
</view>
<view class="selector-card__side">
<view v-if="store.product.categoryId" class="selector-card__badge">已选品类</view>
<view class="selector-card__action">选择</view>
</view>
</view>
</view>
<view class="form-group">
<view class="form-group__label">品牌选填</view>
<view class="field-box">
<view class="brand-page">
<view class="brand-search">
<view class="brand-search__box">
<view class="brand-search__icon"></view>
<input
:value="store.product.brandName"
class="field-input"
maxlength="128"
placeholder="请输入品牌名称,可不填"
@input="setBrandName"
v-model="keyword"
class="brand-search__input"
confirm-type="search"
placeholder="请输入您要搜索的品牌名称"
/>
</view>
<view class="form-group__hint">不确定品牌时可留空后续补充说明中仍可描述商品细节</view>
</view>
<view class="brand-search__cancel" @click="goBack">取消</view>
</view>
<view v-if="activePicker" class="picker-sheet">
<view class="picker-sheet__mask" @click="closePicker"></view>
<view class="picker-sheet__panel">
<view class="picker-sheet__header">
<view>
<view class="picker-sheet__title">{{ pickerMeta.title }}</view>
<view class="picker-sheet__desc">{{ pickerMeta.desc }}</view>
</view>
<view class="picker-sheet__close" @click="closePicker">关闭</view>
<view class="brand-summary">
<view class="brand-summary__service">{{ serviceProviderText }}</view>
<view class="brand-summary__category">{{ categoryTitle }}</view>
<view class="brand-summary__skip" @click="skipBrand">跳过品牌</view>
</view>
<view class="field-box">
<input v-model="pickerKeyword" class="field-input" :placeholder="pickerMeta.searchPlaceholder" />
<view v-if="pageLoading" class="brand-state">
<view class="brand-state__title">正在加载品牌</view>
<view class="brand-state__desc">请稍候</view>
</view>
<scroll-view scroll-y class="picker-sheet__list">
<view v-else-if="loadError" class="brand-state">
<view class="brand-state__title">品牌加载失败</view>
<view class="brand-state__desc">{{ loadError }}</view>
<view class="brand-state__button" @click="skipBrand">跳过品牌</view>
</view>
<scroll-view v-else scroll-y class="brand-list">
<view v-if="groupedBrands.length === 0" class="brand-state brand-state--embedded">
<view class="brand-state__title">暂无匹配品牌</view>
<view class="brand-state__desc">可跳过品牌后继续确认订单</view>
<view class="brand-state__button" @click="skipBrand">跳过品牌</view>
</view>
<view v-for="group in groupedBrands" :key="group.letter" class="brand-group">
<view class="brand-group__letter">{{ group.letter }}</view>
<view class="brand-group__items">
<view
v-for="item in filteredPickerOptions"
:key="pickerOptionKey(item)"
:class="['picker-sheet__option', isPickerCurrent(item) ? 'picker-sheet__option--selected' : '']"
@click="confirmPicker(item)"
v-for="item in group.list"
:key="`${group.letter}-${item.id}-${item.displayName}`"
:class="['brand-item', selectedBrandId === item.id ? 'brand-item--selected' : '']"
@click="selectBrand(item)"
>
<view class="picker-sheet__option-title">{{ pickerMeta.label(item) }}</view>
<view class="picker-sheet__option-action">{{ isPickerCurrent(item) ? '已选' : '选择' }}</view>
<text class="brand-item__name">{{ item.displayName }}</text>
<text v-if="selectedBrandId === item.id" class="brand-item__selected">已选</text>
</view>
</view>
<view v-if="filteredPickerOptions.length === 0" class="picker-sheet__empty">
{{ pickerMeta.emptyText }}
</view>
</scroll-view>
</view>
</view>
<view class="fixed-action-bar">
<view class="btn btn--secondary" @click="goBack">返回</view>
<view :class="['btn', 'btn--primary', (!canContinue || submitting) ? 'btn--disabled' : '']" @click="goNext">
{{ submitting ? "保存中..." : "下一步" }}
<view class="brand-bottom">
<view :class="['brand-bottom__button', submitting ? 'brand-bottom__button--disabled' : '']" @click="skipBrand">
{{ submitting ? "保存中..." : "跳过品牌继续确认" }}
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.selection-summary-card {
margin-top: 20rpx;
padding: 24rpx 26rpx;
border-radius: 26rpx;
<style scoped lang="scss">
.brand-page {
min-height: 100vh;
padding-bottom: calc(132rpx + env(safe-area-inset-bottom));
background: #f2f2f4;
color: #252527;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
.brand-page,
.brand-page view,
.brand-page text,
.brand-page input {
box-sizing: border-box;
}
.brand-search {
display: flex;
align-items: center;
gap: 22rpx;
padding: 28rpx 30rpx 22rpx;
background: #f2f2f4;
}
.brand-search__box {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 72rpx;
padding: 0 24rpx;
border: 2rpx solid #a9a9ad;
border-radius: 999rpx;
background: #ffffff;
border: 1px solid rgba(237, 189, 0, 0.18);
}
.selection-summary-card__title {
color: var(--color-heading);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.7;
}
.selection-summary-card__desc {
margin-top: 10rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
}
.selector-card {
.brand-search__icon {
position: relative;
flex-shrink: 0;
width: 34rpx;
height: 34rpx;
margin-right: 14rpx;
border: 5rpx solid #b7b7bb;
border-radius: 50%;
}
.brand-search__icon::after {
content: "";
position: absolute;
right: -10rpx;
bottom: -8rpx;
width: 18rpx;
height: 5rpx;
border-radius: 999rpx;
background: #b7b7bb;
transform: rotate(45deg);
}
.brand-search__input {
flex: 1;
min-width: 0;
height: 68rpx;
color: #252527;
font-size: 28rpx;
line-height: 68rpx;
}
.brand-search__cancel {
flex-shrink: 0;
color: #252527;
font-size: 30rpx;
line-height: 72rpx;
}
.brand-summary {
display: flex;
align-items: center;
gap: 14rpx;
padding: 0 30rpx 20rpx;
background: #f2f2f4;
}
.brand-summary__service,
.brand-summary__category {
min-height: 44rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: #ffffff;
color: #6a6a6d;
font-size: 23rpx;
line-height: 44rpx;
}
.brand-summary__category {
color: #8a6d00;
}
.brand-summary__skip {
margin-left: auto;
color: #edbd00;
font-size: 25rpx;
font-weight: 700;
line-height: 44rpx;
}
.brand-list {
height: calc(100vh - 252rpx);
background: #ffffff;
}
.brand-group__letter {
height: 72rpx;
padding: 0 30rpx;
background: #f2f2f4;
color: #6a6a6d;
font-size: 32rpx;
line-height: 72rpx;
}
.brand-group__items {
background: #ffffff;
}
.brand-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-top: 12rpx;
padding: 24rpx 24rpx;
border-radius: var(--radius-lg);
border: 1px solid var(--input-border);
background: #ffffff;
min-height: 82rpx;
padding: 0 30rpx;
border-bottom: 1px solid #f0f0f2;
}
.selector-card--disabled {
opacity: 0.56;
.brand-item--selected {
background: rgba(237, 189, 0, 0.08);
}
.selector-card--selected {
border-color: rgba(237, 189, 0, 0.36);
background:
radial-gradient(circle at top right, rgba(237, 189, 0, 0.18), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 238, 223, 0.98) 100%);
box-shadow:
0 14rpx 30rpx rgba(237, 189, 0, 0.1),
0 0 0 2rpx rgba(237, 189, 0, 0.08) inset;
}
.selector-card__badge {
position: static;
min-height: 38rpx;
padding: 0 16rpx;
border-radius: var(--radius-pill);
background: linear-gradient(135deg, rgba(237, 189, 0, 0.16) 0%, rgba(237, 189, 0, 0.22) 100%);
color: var(--color-brand-gold-deep);
font-size: 22rpx;
font-weight: var(--font-weight-semibold);
line-height: 38rpx;
border: 1px solid rgba(237, 189, 0, 0.18);
}
.selector-card__main {
.brand-item__name {
flex: 1;
min-width: 0;
color: #a0a0a4;
font-size: 28rpx;
line-height: 1.45;
}
.selector-card__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
.brand-item--selected .brand-item__name {
color: #252527;
font-weight: 700;
}
.brand-item__selected {
flex-shrink: 0;
color: #edbd00;
font-size: 24rpx;
font-weight: 700;
}
.selector-card__value {
color: var(--color-heading);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
.brand-state {
margin: 40rpx 30rpx;
padding: 46rpx 32rpx;
border-radius: 16rpx;
background: #ffffff;
text-align: center;
}
.brand-state--embedded {
margin-top: 68rpx;
}
.brand-state__title {
color: #252527;
font-size: 30rpx;
font-weight: 800;
line-height: 1.5;
}
.brand-state__desc {
margin-top: 10rpx;
color: #9a9a9d;
font-size: 26rpx;
line-height: 1.6;
}
.selector-card__meta {
margin-top: 8rpx;
color: var(--color-muted);
font-size: var(--font-size-xs);
line-height: 1.6;
.brand-state__button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 220rpx;
height: 68rpx;
margin-top: 26rpx;
border-radius: 999rpx;
background: #edbd00;
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
}
.selector-card__action {
min-width: 96rpx;
color: var(--color-accent);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-align: right;
}
.picker-sheet {
.brand-bottom {
position: fixed;
inset: 0;
z-index: 160;
}
.picker-sheet__mask {
position: absolute;
inset: 0;
background: rgba(20, 20, 18, 0.38);
}
.picker-sheet__panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
border-radius: 36rpx 36rpx 0 0;
background: #ffffff;
box-shadow: 0 -12rpx 36rpx rgba(0, 0, 0, 0.08);
z-index: 80;
padding: 18rpx 30rpx calc(18rpx + env(safe-area-inset-bottom));
border-top: 1px solid #e9e9eb;
background: rgba(255, 255, 255, 0.96);
}
.picker-sheet__header {
display: flex;
justify-content: space-between;
gap: 20rpx;
align-items: flex-start;
margin-bottom: 20rpx;
}
.picker-sheet__title {
color: var(--color-heading);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.picker-sheet__desc {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
}
.picker-sheet__close {
color: var(--color-accent);
font-size: var(--font-size-xs);
line-height: 1.8;
}
.picker-sheet__list {
max-height: 58vh;
margin-top: 18rpx;
}
.picker-sheet__option {
.brand-bottom__button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 24rpx 6rpx;
border-bottom: 1px solid var(--card-border);
justify-content: center;
height: 82rpx;
border-radius: 999rpx;
background: #252527;
color: #ffffff;
font-size: 28rpx;
font-weight: 800;
}
.picker-sheet__option--selected {
border-radius: 20rpx;
padding: 24rpx 18rpx;
background: #ffffff;
border: 1px solid rgba(237, 189, 0, 0.22);
box-shadow: 0 10rpx 24rpx rgba(237, 189, 0, 0.08);
}
.picker-sheet__option-title {
color: var(--color-heading);
font-size: var(--font-size-sm);
line-height: 1.7;
}
.picker-sheet__option-action {
color: var(--color-accent);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.picker-sheet__empty {
padding: 48rpx 0 24rpx;
color: var(--color-muted);
font-size: var(--font-size-sm);
text-align: center;
.brand-bottom__button--disabled {
opacity: 0.58;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -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);
</view>
</view>
<view class="home-platform" @click="goService()">
<view class="home-platform">
<view class="home-platform__content">
<view class="home-platform__title">{{ heroBanner.subtitle }}</view>
<view class="home-platform__desc">{{ heroBanner.description }}</view>
@@ -170,7 +167,6 @@ onShow(fetchHome);
<text class="home-platform__chip">进度可追踪</text>
</view>
</view>
<view class="home-platform__button">立即鉴定</view>
</view>
</view>
@@ -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"
>
<view class="home-service-card__watermark">{{ card.theme === "blue" ? "CIC" : "" }}</view>
<view class="home-service-card__title">
@@ -201,7 +197,6 @@ onShow(fetchHome);
v-for="item in categoryCards"
:key="item.categoryId || item.categoryName"
class="home-category-card"
@click="goCategory(item.categoryName)"
>
<view class="home-category-card__title">{{ item.displayName }}</view>
<image
@@ -426,8 +421,7 @@ onShow(fetchHome);
background-size: 34rpx 34rpx;
}
.home-platform__content,
.home-platform__button {
.home-platform__content {
position: relative;
z-index: 1;
}
@@ -466,20 +460,6 @@ onShow(fetchHome);
line-height: 32rpx;
}
.home-platform__button {
flex-shrink: 0;
min-width: 132rpx;
height: 58rpx;
margin-left: 14rpx;
border-radius: 30rpx;
background: #e8b700;
color: #fff;
font-size: 25rpx;
font-weight: 700;
line-height: 58rpx;
text-align: center;
}
.home-section {
width: 100vw;
max-width: 100vw;
@@ -719,14 +699,5 @@ onShow(fetchHome);
white-space: normal;
}
.home-platform {
align-items: flex-start;
flex-direction: column;
}
.home-platform__button {
margin-top: 22rpx;
margin-left: 0;
}
}
</style>

View File

@@ -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<OrderDetailData>(orderDetailFallback);
@@ -16,6 +17,8 @@ const addressOptions = ref<UserAddressItem[]>([]);
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);
});
</script>
<template>
@@ -342,6 +447,7 @@ onShow(fetchDetail);
<text class="meta-chip">订单号 {{ maskOrderNo(detail.order_info.order_no, privacyMode) }}</text>
<text class="meta-chip">鉴定单号 {{ maskOrderNo(detail.order_info.appraisal_no, privacyMode) }}</text>
<text class="meta-chip">{{ serviceProviderText }}</text>
<text v-if="detail.order_info.price_package_name" class="meta-chip">{{ detail.order_info.price_package_name }}</text>
<text v-if="detail.order_info.estimated_finish_time" class="meta-chip">预计 {{ detail.order_info.estimated_finish_time }}</text>
</view>
@@ -563,7 +669,7 @@ onShow(fetchDetail);
</view>
<view class="fixed-action-bar">
<view class="btn btn--secondary" @click="contactService">联系客服</view>
<view class="btn btn--secondary" @click="handleSecondaryAction">{{ secondaryActionText }}</view>
<view class="btn btn--primary" @click="handlePrimaryAction">{{ primaryActionText }}</view>
</view>

View File

@@ -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 () => {
</view>
<text
class="order-card__status"
:class="item.order_status === 'pending_supplement' ? 'order-card__status--warning' : ['report_published', 'completed'].includes(item.order_status) ? 'order-card__status--success' : 'order-card__status--info'"
:class="['pending_payment', 'pending_supplement'].includes(item.order_status) ? 'order-card__status--warning' : ['report_published', 'completed'].includes(item.order_status) ? 'order-card__status--success' : 'order-card__status--info'"
>
{{ item.display_status }}
</text>
@@ -155,7 +155,10 @@ onShow(async () => {
<view v-if="item.order_status === 'report_published'" class="order-card__desc">平台待安排寄回请先确认寄回地址</view>
<view v-if="item.order_status === 'completed' && item.display_status === '物品已寄回'" class="order-card__desc">平台已回寄商品请留意签收物流</view>
<view class="order-card__footer">
<view class="order-card__provider">{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}</view>
<view class="order-card__provider">
{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}
<text v-if="item.price_package_name"> / {{ item.price_package_name }}</text>
</view>
<view class="order-card__action">{{ item.primary_action }}</view>
</view>
</view>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
import { authApi } from "../api/auth";
import type { PaymentLaunchInfo } from "../api/app";
function miniProgramLoginCode() {
return new Promise<string>((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("支付链接未生成");
}