feat: 实体商品

This commit is contained in:
ggyy
2026-07-13 10:39:38 +08:00
parent 812108f963
commit b8d63cf760
20 changed files with 118 additions and 28 deletions

View File

@@ -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 新增值 | 无 SQLPrisma 层面约束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原生模块
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,20 +26,21 @@
</div>
<div class="space-y-1 text-sm">
<div>联系方式{{ order.contactValue || '-' }}</div>
<div v-if="order.receiverInfo">收货信息{{ order.receiverInfo }}</div>
<div>备注{{ order.buyerNote || '-' }}</div>
<div>创建时间{{ formatDate(order.createdAt) }}</div>
<div>查询凭证<code>{{ order.queryToken }}</code></div>
</div>
</div>
<div v-if="order.productDeliveryType === 'MANUAL' && order.paymentStatus === 'PAID' && order.deliveryStatus !== 'DELIVERED'" class="pt-2">
<div v-if="(order.productDeliveryType === 'MANUAL' || order.productDeliveryType === 'EXPRESS') && order.paymentStatus === 'PAID' && order.deliveryStatus !== 'DELIVERED'" class="pt-2">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">手动发货内容</span>
<textarea v-model="manualDeliveryContent" class="textarea textarea-bordered w-full" rows="4" placeholder="填写本次订单要发给买家的内容"></textarea>
<span class="label-text font-medium">{{ order.productDeliveryType === 'EXPRESS' ? '快递发货内容' : '手动发货内容' }}</span>
<textarea v-model="manualDeliveryContent" class="textarea textarea-bordered w-full" rows="4" :placeholder="order.productDeliveryType === 'EXPRESS' ? '例如:快递单号、快递公司等物流信息' : '例如:账号密码、激活码等买家需要的内容'"></textarea>
</label>
</div>
<div class="flex flex-wrap items-center gap-3 pt-2">
<AppButton size="sm" variant="primary" :disabled="order.deliveryStatus === 'DELIVERED' || order.paymentStatus !== 'PAID'" @click="handleRedeliver">{{ deliveryActionLabel }}</AppButton>
<AppButton size="sm" variant="outline" :disabled="order.status === 'CLOSED'" @click="handleClose">关闭订单</AppButton>
<AppButton size="sm" variant="primary" :loading="delivering" :disabled="order.deliveryStatus === 'DELIVERED' || order.paymentStatus !== 'PAID'" @click="handleRedeliver">{{ deliveryActionLabel }}</AppButton>
<AppButton size="sm" variant="outline" :loading="closing" :disabled="order.status === 'CLOSED'" @click="handleClose">关闭订单</AppButton>
<span v-if="actionMessage" class="text-sm text-success">{{ actionMessage }}</span>
<span v-if="actionError" class="text-sm text-error">{{ actionError }}</span>
</div>
@@ -80,7 +81,8 @@
<pre class="bg-base-200 rounded-box p-3 text-xs overflow-x-auto whitespace-pre-wrap break-all">{{ formattedPayload }}</pre>
<div class="modal-action">
<form method="dialog"><button class="btn btn-sm">关闭</button></form>
</div></div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>关闭</button></form>
</dialog>
</div>
@@ -112,6 +114,8 @@ import type { Data } from "./+data";
const { order } = useData<Data>();
const actionMessage = ref("");
const actionError = ref("");
const delivering = ref(false);
const closing = ref(false);
const manualDeliveryContent = ref("");
const payloadDialogRef = ref<HTMLDialogElement | null>(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;
}
}
</script>

View File

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

View File

@@ -46,7 +46,7 @@
<div class="text-sm font-medium">发货方式</div>
<p class="text-xs text-base-content/60">发货方式决定库存来源和支付后的发货动作</p>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-4">
<label class="rounded-box border border-base-300 p-3" :class="form.deliveryType === 'CARD_AUTO' ? 'border-primary bg-primary/5' : ''">
<div class="flex items-center justify-between gap-2">
<span class="font-medium">自动发货卡密</span>
@@ -68,6 +68,13 @@
</div>
<p class="mt-1 text-xs text-base-content/60">支付后等待管理员在订单详情填写发货内容</p>
</label>
<label class="rounded-box border border-base-300 p-3" :class="form.deliveryType === 'EXPRESS' ? 'border-primary bg-primary/5' : ''">
<div class="flex items-center justify-between gap-2">
<span class="font-medium">快递发货</span>
<input v-model="form.deliveryType" type="radio" class="radio radio-primary radio-sm" value="EXPRESS" />
</div>
<p class="mt-1 text-xs text-base-content/60">买家下单时填写收货信息支付后管理员安排快递发货</p>
</label>
</div>
<label v-if="form.deliveryType === 'FIXED_CARD'" class="flex flex-col gap-1.5">
<span class="label-text font-medium">固定发货内容</span>
@@ -77,6 +84,10 @@
<span class="label-text font-medium">手动发货说明可选</span>
<textarea v-model="form.manualDeliveryHint" class="textarea textarea-bordered w-full" rows="3" placeholder="例如:请留下账号信息,管理员将在 24 小时内处理"></textarea>
</label>
<label v-if="form.deliveryType === 'EXPRESS'" class="flex flex-col gap-1.5">
<span class="label-text font-medium">快递发货说明可选</span>
<textarea v-model="form.manualDeliveryHint" class="textarea textarea-bordered w-full" rows="3" placeholder="例如:请填写收货地址,管理员将在 48 小时内安排发货"></textarea>
</label>
</div>
<div class="grid gap-4 md:grid-cols-4">

View File

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

View File

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

View File

@@ -122,6 +122,12 @@
>
人工发货
</span>
<span
v-else-if="product.deliveryType === 'EXPRESS'"
class="text-xs font-semibold px-2 py-0.5 rounded bg-violet-500/10 text-violet-600"
>
实体商品
</span>
<div class="flex items-baseline gap-0.5">
<span class="text-[11px] font-bold text-red-500/60">¥</span>
<span class="text-xl font-bold text-red-500 leading-none">{{ formatCents(product.price) }}</span>

View File

@@ -54,7 +54,7 @@
:disabled="discountPreview.loading"
/>
<button
class="btn btn-outline btn-sm"
class="btn btn-outline"
:disabled="!form.discountCode.trim() || discountPreview.loading"
@click="handlePreviewDiscount"
>
@@ -65,6 +65,12 @@
<p v-if="discountPreview.error" class="-mt-2 text-xs text-error">{{ discountPreview.error }}</p>
<p v-if="discountPreview.valid" class="-mt-2 text-xs text-orange-400">折扣码有效优惠 {{ formatCents(discountPreview.discount) }}</p>
<label v-if="product.deliveryType === 'EXPRESS'" class="flex flex-col gap-1.5">
<span class="label-text font-medium">收货信息 <span class="text-error">*</span></span>
<textarea v-model="form.receiverInfo" class="textarea textarea-bordered w-full" rows="3" placeholder="请填写收货信息,例如:
张三13812341234广东省深圳市xxx"></textarea>
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">备注</span>
<textarea v-model="form.buyerNote" class="textarea textarea-bordered w-full" rows="3" placeholder="可以留下QQ号、微信等联系方式"></textarea>
@@ -128,6 +134,7 @@
</p>
<p v-else-if="product.deliveryType === 'FIXED_CARD'" class="text-sm text-success">自动发货库存充足</p>
<p v-else-if="product.deliveryType === 'MANUAL'" class="text-sm text-success">{{ product.manualDeliveryHint || '支付后客服将尽快为您处理订单请耐心等待' }}</p>
<p v-else-if="product.deliveryType === 'EXPRESS'" class="text-sm text-success">{{ product.manualDeliveryHint || '请填写收货信息支付后管理员将安排快递发货' }}</p>
<AppButton variant="primary" :loading="submitting" :disabled="(!isFreeOrder && !paymentMethods.length) || (product.deliveryType === 'CARD_AUTO' && product.availableStock === 0)" @click="handleCreateOrder">
{{ product.deliveryType === 'CARD_AUTO' && product.availableStock === 0 ? '已售罄' : isFreeOrder ? '免费获取' : '提交订单' }}
@@ -177,13 +184,14 @@ const form = reactive({
quantity: product?.minBuy ?? 1,
contactValue: "",
buyerNote: "",
receiverInfo: "",
discountCode: "",
paymentProvider: paymentMethods[0]?.provider ?? "",
paymentChannel: getDefaultPaymentChannel(paymentMethods[0]?.provider ?? ""),
});
function getDeliveryTypeLabel(type: string) {
return ({ CARD_AUTO: "自动发货", FIXED_CARD: "自动发货", MANUAL: "人工发货" } as Record<string, string>)[type] || type;
return ({ CARD_AUTO: "自动发货", FIXED_CARD: "自动发货", MANUAL: "人工发货", EXPRESS: "快递发货" } as Record<string, string>)[type] || type;
}
let mobile = false;
@@ -260,6 +268,11 @@ async function handleCreateOrder() {
return;
}
if (product.deliveryType === 'EXPRESS' && !form.receiverInfo.trim()) {
errorMessage.value = "收货信息不能为空";
return;
}
submitting.value = true;
errorMessage.value = "";
@@ -275,6 +288,7 @@ async function handleCreateOrder() {
contactType: "EMAIL",
contactValue: contactEmail,
buyerNote: form.buyerNote,
receiverInfo: form.receiverInfo,
discountCode: form.discountCode.trim() || undefined,
});

View File

@@ -10,6 +10,7 @@ export async function onCreateOrder(input: {
contactType: "EMAIL";
contactValue: string;
buyerNote?: string;
receiverInfo?: string;
discountCode?: string;
}) {
try {

View File

@@ -0,0 +1,2 @@
-- AlterTable: Add receiver info field for express delivery
ALTER TABLE "Order" ADD COLUMN "receiverInfo" TEXT;

View File

@@ -28,6 +28,7 @@ enum ProductDeliveryType {
CARD_AUTO
FIXED_CARD
MANUAL
EXPRESS
}
enum ProductStockMode {
@@ -93,6 +94,7 @@ enum DeliveryRecordType {
CARD
FIXED_CARD
MANUAL
EXPRESS
}
enum DeliveryRecordStatus {
@@ -233,6 +235,7 @@ model Order {
contactType ContactType @default(EMAIL)
contactValue String?
buyerNote String?
receiverInfo String?
paymentProvider String
paymentChannel String?
paymentOrderNo String?

View File

@@ -13,7 +13,7 @@
"binding": "DB",
"database_name": "edgekey-db",
"migrations_dir": "prisma/migrations",
// "database_id": "24390dbc-b9c6-4ae6-8c7f-507fb2eb36f2", // 执行 wrangler d1 命令必须但是与cf一键部署冲突所以注释
"database_id": "24390dbc-b9c6-4ae6-8c7f-507fb2eb36f2", // 执行 wrangler d1 命令必须但是与cf一键部署冲突所以注释
}
],
"triggers": {