From b8d63cf7607ce8bc09e026a07a9027f161aabed6 Mon Sep 17 00:00:00 2001 From: ggyy <34892002@qq.com> Date: Mon, 13 Jul 2026 10:39:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E4=BD=93=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development-guide.md | 28 ++++++++++++++++- lib/validators/product.ts | 4 +-- modules/catalog/repository.ts | 2 +- modules/catalog/service.ts | 4 +-- modules/catalog/types.ts | 2 +- modules/delivery/service.ts | 6 ++-- modules/order/repository.ts | 2 ++ modules/order/service.ts | 12 +++++-- modules/payment/service.ts | 2 +- pages/admin/orders/@id/+Page.vue | 31 +++++++++++++------ pages/admin/products/+Page.vue | 4 +++ pages/admin/products/ProductForm.vue | 13 +++++++- pages/admin/products/form.ts | 2 +- pages/admin/products/saveProduct.telefunc.ts | 2 +- pages/index/+Page.vue | 6 ++++ pages/product/@slug/+Page.vue | 18 +++++++++-- pages/product/@slug/createOrder.telefunc.ts | 1 + .../migrations/0007_add_express_delivery.sql | 2 ++ prisma/schema.prisma | 3 ++ wrangler.jsonc | 2 +- 20 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/0007_add_express_delivery.sql diff --git a/docs/development-guide.md b/docs/development-guide.md index d00b319..9e9c956 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -75,6 +75,32 @@ bunx prisma migrate diff \ --script > prisma/migrations/000X_描述.sql ``` +> [!WARNING] +> **必须手动核查生成的 SQL**,不要直接使用 `prisma migrate diff` 的输出。常见问题: +> +> **1. 枚举变更加新列 —— 实际不需要任何 DDL** +> +> Prisma 的 `enum` 在 SQLite 中就是普通 `TEXT` 列,数据库层面没有约束。因此: +> - 在 `schema.prisma` 的 enum 中新增值(如 `ProductDeliveryType` 加 `EXPRESS`),**不需要任何 SQL 迁移**,因为 SQLite 不关心列里存的文本内容。 +> - 只有当 enum 对应的列首次创建时,才需要 `CREATE TABLE` / `ALTER TABLE ADD COLUMN`。 +> +> **2. 加列操作不要用全表重建** +> +> `prisma migrate diff` 对 SQLite 经常生成 `CREATE _new_Table → INSERT → DROP TABLE → RENAME` 的全量重建模式。对于只是新增可空列的场景,应该替换为简单的: +> ```sql +> ALTER TABLE "TableName" ADD COLUMN "columnName" TEXT; +> ``` +> +> **3. 正确的决策流程** +> +> | schema 变更类型 | 迁移 SQL | +> | --- | +> | enum 新增值 | 无 SQL(Prisma 层面约束,SQLite 无需 DDL) | +> | 新增可空列 | `ALTER TABLE ... ADD COLUMN ...` | +> | 新增非空列(有默认值) | `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` | +> | 新增表 | `CREATE TABLE ...` | +> | 删除列 / 改列类型 | SQLite 不支持,需全表重建(此时才用 CREATE _new → INSERT → DROP → RENAME 模式) | + #### 步骤2: 同步到本地开发环境 ```bash @@ -306,7 +332,7 @@ try { 1. **不要**假设 `bun dev` 使用的是 `prisma/db.sqlite`;当前它实际使用的是本地 D1 模拟器 2. **不要**使用 `prisma migrate dev`,这会偏离当前 D1 迁移工作流 3. **不要**反复覆盖 `prisma/migrations/0001_init.sql`;初始化迁移和后续增量迁移应分开维护 -4. **不要**信任 Prisma 生成的迁移 SQL,必须手动核查脚本,重点识别并拦截非预期的 **DROP TABLE** 或**全量重建**逻辑 +4. **不要**信任 Prisma 生成的迁移 SQL,必须手动核查。重点规则见上方「步骤1」中的决策流程表:enum 变更无需 DDL、新增列用 `ALTER TABLE`、只有删列/改类型才需要全表重建 5. **不要**使用`node:fs`、`node:path`等Node.js原生模块 --- diff --git a/lib/validators/product.ts b/lib/validators/product.ts index c84a39c..cfbfdb1 100644 --- a/lib/validators/product.ts +++ b/lib/validators/product.ts @@ -26,7 +26,7 @@ export function validateProductInput(input: { } const deliveryType = input.deliveryType || "CARD_AUTO"; - if (!["CARD_AUTO", "FIXED_CARD", "MANUAL"].includes(deliveryType)) { + if (!["CARD_AUTO", "FIXED_CARD", "MANUAL", "EXPRESS"].includes(deliveryType)) { throw badRequestError("发货方式不正确", "PRODUCT_DELIVERY_TYPE_INVALID"); } @@ -37,7 +37,7 @@ export function validateProductInput(input: { return { name, - deliveryType: deliveryType as "CARD_AUTO" | "FIXED_CARD" | "MANUAL", + deliveryType: deliveryType as "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS", fixedDeliveryContent, }; } diff --git a/modules/catalog/repository.ts b/modules/catalog/repository.ts index ac43384..86af79a 100644 --- a/modules/catalog/repository.ts +++ b/modules/catalog/repository.ts @@ -99,7 +99,7 @@ export function upsertProductRecord( description?: string | null; price: number; status: "DRAFT" | "ACTIVE" | "INACTIVE"; - deliveryType: "CARD_AUTO" | "FIXED_CARD" | "MANUAL"; + deliveryType: "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS"; fixedDeliveryContent?: string | null; manualDeliveryHint?: string | null; stockMode: "FINITE" | "UNLIMITED"; diff --git a/modules/catalog/service.ts b/modules/catalog/service.ts index 6a2aa5e..f7f1b77 100644 --- a/modules/catalog/service.ts +++ b/modules/catalog/service.ts @@ -181,7 +181,7 @@ export async function saveProduct(input: { description?: string; price: number; status: "DRAFT" | "ACTIVE" | "INACTIVE"; - deliveryType?: "CARD_AUTO" | "FIXED_CARD" | "MANUAL"; + deliveryType?: "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS"; fixedDeliveryContent?: string; manualDeliveryHint?: string; minBuy: number; @@ -261,7 +261,7 @@ export async function saveProduct(input: { status: input.status, deliveryType, fixedDeliveryContent: deliveryType === "FIXED_CARD" ? fixedDeliveryContent : null, - manualDeliveryHint: deliveryType === "MANUAL" ? input.manualDeliveryHint?.trim() || null : null, + manualDeliveryHint: (deliveryType === "MANUAL" || deliveryType === "EXPRESS") ? input.manualDeliveryHint?.trim() || null : null, stockMode, minBuy, maxBuy, diff --git a/modules/catalog/types.ts b/modules/catalog/types.ts index c2fe6ef..96e34be 100644 --- a/modules/catalog/types.ts +++ b/modules/catalog/types.ts @@ -1,4 +1,4 @@ -export type ProductDeliveryTypeValue = "CARD_AUTO" | "FIXED_CARD" | "MANUAL"; +export type ProductDeliveryTypeValue = "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS"; export interface ProductSummary { id: number; diff --git a/modules/delivery/service.ts b/modules/delivery/service.ts index f5618af..0d482b7 100644 --- a/modules/delivery/service.ts +++ b/modules/delivery/service.ts @@ -31,7 +31,7 @@ export async function deliverOrder(prisma: PrismaClient, orderNo: string) { } try { - if (order.product.deliveryType === "MANUAL") { + if (order.product.deliveryType === "MANUAL" || order.product.deliveryType === "EXPRESS") { logger.info("manual_delivery_waiting", { event: "delivery.manual.waiting", orderNo: order.orderNo, @@ -158,7 +158,7 @@ export async function adminDeliverOrder(prisma: PrismaClient, orderId: number, i throw conflictError("订单已发货,无需重复发货", "ORDER_ALREADY_DELIVERED"); } - if (order.product.deliveryType !== "MANUAL") { + if (order.product.deliveryType !== "MANUAL" && order.product.deliveryType !== "EXPRESS") { return deliverOrder(prisma, order.orderNo); } @@ -170,7 +170,7 @@ export async function adminDeliverOrder(prisma: PrismaClient, orderId: number, i await prisma.orderDelivery.create({ data: { orderId: order.id, - deliveryType: "MANUAL", + deliveryType: order.product.deliveryType === "EXPRESS" ? "EXPRESS" : "MANUAL", contentSnapshot: content, status: "SUCCESS", }, diff --git a/modules/order/repository.ts b/modules/order/repository.ts index a687a47..aae9722 100644 --- a/modules/order/repository.ts +++ b/modules/order/repository.ts @@ -55,6 +55,7 @@ export function createOrderRecord( contactType: "EMAIL" | "QQ" | "TELEGRAM" | "OTHER"; contactValue?: string | null; buyerNote?: string | null; + receiverInfo?: string | null; paymentProvider: PaymentProvider; paymentChannel?: string | null; discountCodeId?: number | null; @@ -75,6 +76,7 @@ export function createOrderRecord( contactType: input.contactType, contactValue: input.contactValue ?? null, buyerNote: input.buyerNote ?? null, + receiverInfo: input.receiverInfo ?? null, paymentProvider: input.paymentProvider, paymentChannel: input.paymentChannel ?? null, discountCodeId: input.discountCodeId ?? null, diff --git a/modules/order/service.ts b/modules/order/service.ts index 14626f8..9d6496a 100644 --- a/modules/order/service.ts +++ b/modules/order/service.ts @@ -1,7 +1,7 @@ import { getContext } from "telefunc"; import type { PaymentProvider } from "../payment/types"; import type { PrismaClient } from "../../generated/prisma/client"; -import { conflictError, notFoundError } from "../../lib/app-error"; +import { badRequestError, conflictError, notFoundError } from "../../lib/app-error"; import { validateOrderInput } from "../../lib/validators/order"; import { getAdminContext, logAdminOperation } from "../auth/service"; import { getAdminProductById } from "../catalog/service"; @@ -47,6 +47,7 @@ export async function createOrder(input: { contactType: "EMAIL" | "QQ" | "TELEGRAM" | "OTHER"; contactValue?: string; buyerNote?: string; + receiverInfo?: string; discountCode?: string; }) { const { prisma } = getOrderContext(); @@ -76,6 +77,10 @@ export async function createOrder(input: { throw conflictError("商品固定发货内容未配置,暂不可购买", "PRODUCT_FIXED_CONTENT_MISSING"); } + if (product.deliveryType === "EXPRESS" && !input.receiverInfo?.trim()) { + throw badRequestError("收货信息不能为空", "RECEIVER_INFO_REQUIRED"); + } + const orderNo = generateOrderNo(); const queryToken = generateQueryToken(); let paymentChannel: string | null = null; @@ -112,6 +117,7 @@ export async function createOrder(input: { contactType: input.contactType, contactValue, buyerNote: input.buyerNote?.trim() || null, + receiverInfo: input.receiverInfo?.trim() || null, paymentProvider: input.paymentProvider, paymentChannel, discountCodeId, @@ -184,6 +190,7 @@ export async function createOrder(input: { contactType: input.contactType, contactValue, buyerNote: input.buyerNote?.trim() || null, + receiverInfo: input.receiverInfo?.trim() || null, paymentProvider: input.paymentProvider, paymentChannel, discountCodeId, @@ -255,7 +262,7 @@ export async function getOrderForQuery( // notify 与 return 几乎同时到达时,return 这次读取可能正好卡在 // “订单已支付但异步发货还没写完”的瞬间。这里做一次短暂重查, // 优先把最终的 DELIVERED 状态和发货内容返回给页面,避免用户手动刷新。 - if (order.product.deliveryType !== "MANUAL" && order.paymentStatus === "PAID" && order.deliveryStatus === "NOT_DELIVERED") { + if (order.product.deliveryType !== "MANUAL" && order.product.deliveryType !== "EXPRESS" && order.paymentStatus === "PAID" && order.deliveryStatus === "NOT_DELIVERED") { for (let index = 0; index < 3; index += 1) { await sleep(150); const refreshed = await findOrderWithProduct(client, orderNo); @@ -457,6 +464,7 @@ export async function getAdminOrderById(id: number, prisma?: PrismaClient) { deliveryStatus: order.deliveryStatus, contactValue: order.contactValue, buyerNote: order.buyerNote, + receiverInfo: order.receiverInfo, createdAt: order.createdAt.toISOString(), paidAt: order.paidAt ? order.paidAt.toISOString() : null, deliveredAt: order.deliveredAt ? order.deliveredAt.toISOString() : null, diff --git a/modules/payment/service.ts b/modules/payment/service.ts index af52e3f..4b0fd90 100644 --- a/modules/payment/service.ts +++ b/modules/payment/service.ts @@ -593,7 +593,7 @@ export async function handlePaymentNotify( select: { deliveryType: true }, }); - if (product?.deliveryType !== "MANUAL") { + if (product?.deliveryType !== "MANUAL" && product?.deliveryType !== "EXPRESS") { try { await deliverOrder(prisma, order.orderNo); message = "already paid; delivery retried"; diff --git a/pages/admin/orders/@id/+Page.vue b/pages/admin/orders/@id/+Page.vue index e8a2af2..3a53a30 100644 --- a/pages/admin/orders/@id/+Page.vue +++ b/pages/admin/orders/@id/+Page.vue @@ -26,20 +26,21 @@
联系方式:{{ order.contactValue || '-' }}
+
收货信息:{{ order.receiverInfo }}
备注:{{ order.buyerNote || '-' }}
创建时间:{{ formatDate(order.createdAt) }}
查询凭证:{{ order.queryToken }}
-
+
- {{ deliveryActionLabel }} - 关闭订单 + {{ deliveryActionLabel }} + 关闭订单 {{ actionMessage }} {{ actionError }}
@@ -80,7 +81,8 @@
{{ formattedPayload }}
+ + @@ -112,6 +114,8 @@ import type { Data } from "./+data"; const { order } = useData(); const actionMessage = ref(""); const actionError = ref(""); +const delivering = ref(false); +const closing = ref(false); const manualDeliveryContent = ref(""); const payloadDialogRef = ref(null); const formattedPayload = ref(""); @@ -119,6 +123,7 @@ const formattedPayload = ref(""); const deliveryActionLabel = computed(() => { if (!order) return "发货"; if (order.productDeliveryType === "MANUAL") return "手动发货"; + if (order.productDeliveryType === "EXPRESS") return "快递发货"; if (order.productDeliveryType === "FIXED_CARD") return "发货固定内容"; return "重新自动发货"; }); @@ -137,28 +142,36 @@ function formatDate(value: string) { } async function handleRedeliver() { - if (!order) return; + if (!order || delivering.value) return; actionMessage.value = ""; actionError.value = ""; + delivering.value = true; try { const result = await onRedeliver({ orderId: order.id, content: manualDeliveryContent.value }); + order.deliveryStatus = "DELIVERED"; actionMessage.value = `${deliveryActionLabel.value}完成,共发出 ${result.items.length} 条内容。`; } catch (error) { actionError.value = normalizeTelefuncError(error, "发货失败"); + } finally { + delivering.value = false; } } async function handleClose() { - if (!order) return; + if (!order || closing.value) return; actionMessage.value = ""; actionError.value = ""; + closing.value = true; try { await onCloseOrder({ orderId: order.id }); - actionMessage.value = "订单已关闭,请刷新查看最新状态。"; + order.status = "CLOSED"; + actionMessage.value = "订单已关闭。"; } catch (error) { actionError.value = normalizeTelefuncError(error, "关闭失败"); + } finally { + closing.value = false; } } diff --git a/pages/admin/products/+Page.vue b/pages/admin/products/+Page.vue index 969f25d..3729e83 100644 --- a/pages/admin/products/+Page.vue +++ b/pages/admin/products/+Page.vue @@ -98,6 +98,8 @@ function getDeliveryTypeLabel(deliveryType: string) { return "固定内容自动发货"; case "MANUAL": return "手动发货"; + case "EXPRESS": + return "快递发货"; default: return deliveryType; } @@ -111,6 +113,8 @@ function getDeliveryTypeTagType(deliveryType: string): "primary" | "success" | " return "success"; case "MANUAL": return "warning"; + case "EXPRESS": + return "warning"; default: return "default"; } diff --git a/pages/admin/products/ProductForm.vue b/pages/admin/products/ProductForm.vue index f8e30c8..3b6bd20 100644 --- a/pages/admin/products/ProductForm.vue +++ b/pages/admin/products/ProductForm.vue @@ -46,7 +46,7 @@
发货方式

发货方式决定库存来源和支付后的发货动作。

-
+
+
+
diff --git a/pages/admin/products/form.ts b/pages/admin/products/form.ts index 299382b..7feed8a 100644 --- a/pages/admin/products/form.ts +++ b/pages/admin/products/form.ts @@ -8,7 +8,7 @@ export interface ProductFormState { description: string; price: number; status: "DRAFT" | "ACTIVE" | "INACTIVE"; - deliveryType: "CARD_AUTO" | "FIXED_CARD" | "MANUAL"; + deliveryType: "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS"; fixedDeliveryContent: string; manualDeliveryHint: string; minBuy: number; diff --git a/pages/admin/products/saveProduct.telefunc.ts b/pages/admin/products/saveProduct.telefunc.ts index a4e5579..e656a70 100644 --- a/pages/admin/products/saveProduct.telefunc.ts +++ b/pages/admin/products/saveProduct.telefunc.ts @@ -11,7 +11,7 @@ export async function onSaveProduct(input: { description?: string; price: number; status: "DRAFT" | "ACTIVE" | "INACTIVE"; - deliveryType?: "CARD_AUTO" | "FIXED_CARD" | "MANUAL"; + deliveryType?: "CARD_AUTO" | "FIXED_CARD" | "MANUAL" | "EXPRESS"; fixedDeliveryContent?: string; manualDeliveryHint?: string; minBuy: number; diff --git a/pages/index/+Page.vue b/pages/index/+Page.vue index e7b5cd1..d33cd92 100644 --- a/pages/index/+Page.vue +++ b/pages/index/+Page.vue @@ -122,6 +122,12 @@ > 人工发货 + + 实体商品 +
¥ {{ formatCents(product.price) }} diff --git a/pages/product/@slug/+Page.vue b/pages/product/@slug/+Page.vue index 3ad1665..257eefd 100644 --- a/pages/product/@slug/+Page.vue +++ b/pages/product/@slug/+Page.vue @@ -54,7 +54,7 @@ :disabled="discountPreview.loading" />