feat: 初始化微信小程序后端骨架

- NestJS + TypeScript + Prisma + PostgreSQL 工程骨架
- 微信登录安全流程:服务端 code2Session 换 openid 后签发 JWT,
  session_key 缓存于 Redis,不信任前端 openid
- 统一响应/异常处理、JWT 全局鉴权(@Public 豁免)、Swagger 文档
- Prisma 全量核心 schema(用户/分类/商品/设计清单/地址/订单/支付/上传/定制任务)+ seed
- 业务模块空壳(商品/分类/设计清单/地址/订单/支付/上传/BullMQ 队列)
- Docker 多阶段镜像 + 本地/生产 docker-compose
- docs:密钥获取指南、COS SDK 移除记录

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:33:50 +08:00
co-authored by Claude Fable 5
commit deaa0c9ce4
73 changed files with 9533 additions and 0 deletions
@@ -0,0 +1,233 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'ON_SALE', 'OFF_SHELF');
-- CreateEnum
CREATE TYPE "DesignListStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'PROCESSING', 'DONE');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'PROCESSING', 'SHIPPED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PaymentProvider" AS ENUM ('WECHAT');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCESS', 'REFUNDED');
-- CreateEnum
CREATE TYPE "CustomizationTaskStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"openid" TEXT NOT NULL,
"unionid" TEXT,
"nickname" TEXT,
"avatar" TEXT,
"phone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"parentId" TEXT,
"sort" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"categoryId" TEXT,
"price" DECIMAL(10,2) NOT NULL,
"images" TEXT[],
"description" TEXT,
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DesignList" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"items" JSONB NOT NULL,
"status" "DesignListStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DesignList_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Address" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"province" TEXT NOT NULL,
"city" TEXT NOT NULL,
"district" TEXT NOT NULL,
"detail" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Address_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"totalAmount" DECIMAL(10,2) NOT NULL,
"addressSnapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT,
"name" TEXT NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"quantity" INTEGER NOT NULL,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"transactionId" TEXT,
"provider" "PaymentProvider" NOT NULL DEFAULT 'WECHAT',
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Upload" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"cosKey" TEXT NOT NULL,
"url" TEXT NOT NULL,
"mimetype" TEXT,
"size" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomizationTask" (
"id" TEXT NOT NULL,
"designListId" TEXT,
"userId" TEXT,
"status" "CustomizationTaskStatus" NOT NULL DEFAULT 'PENDING',
"resultUrl" TEXT,
"queueJobId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomizationTask_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_openid_key" ON "User"("openid");
-- CreateIndex
CREATE INDEX "User_phone_idx" ON "User"("phone");
-- CreateIndex
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
-- CreateIndex
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
-- CreateIndex
CREATE INDEX "Product_status_idx" ON "Product"("status");
-- CreateIndex
CREATE INDEX "DesignList_userId_idx" ON "DesignList"("userId");
-- CreateIndex
CREATE INDEX "Address_userId_idx" ON "Address"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Order_orderNo_key" ON "Order"("orderNo");
-- CreateIndex
CREATE INDEX "Order_userId_idx" ON "Order"("userId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_orderId_key" ON "Payment"("orderId");
-- CreateIndex
CREATE INDEX "Upload_userId_idx" ON "Upload"("userId");
-- CreateIndex
CREATE INDEX "CustomizationTask_designListId_idx" ON "CustomizationTask"("designListId");
-- CreateIndex
CREATE INDEX "CustomizationTask_status_idx" ON "CustomizationTask"("status");
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesignList" ADD CONSTRAINT "DesignList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Address" ADD CONSTRAINT "Address_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Upload" ADD CONSTRAINT "Upload_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomizationTask" ADD CONSTRAINT "CustomizationTask_designListId_fkey" FOREIGN KEY ("designListId") REFERENCES "DesignList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomizationTask" ADD CONSTRAINT "CustomizationTask_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+220
View File
@@ -0,0 +1,220 @@
// 微信小程序后端 — Prisma schema
// 数据库:PostgreSQL
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ── 用户 ──────────────────────────────────────────────
model User {
id String @id @default(cuid())
openid String @unique
unionid String?
nickname String?
avatar String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
addresses Address[]
designLists DesignList[]
uploads Upload[]
customizations CustomizationTask[]
@@index([phone])
}
// ── 商品分类 ──────────────────────────────────────────
model Category {
id String @id @default(cuid())
name String
parentId String?
sort Int @default(0)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
}
// ── 商品 ──────────────────────────────────────────────
model Product {
id String @id @default(cuid())
name String
categoryId String?
price Decimal @db.Decimal(10, 2)
images String[]
description String?
status ProductStatus @default(DRAFT)
category Category? @relation(fields: [categoryId], references: [id])
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([status])
}
enum ProductStatus {
DRAFT
ON_SALE
OFF_SHELF
}
// ── 设计清单 / 定制需求 ───────────────────────────────
model DesignList {
id String @id @default(cuid())
userId String
title String
items Json
status DesignListStatus @default(DRAFT)
user User @relation(fields: [userId], references: [id])
customizations CustomizationTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
enum DesignListStatus {
DRAFT
SUBMITTED
PROCESSING
DONE
}
// ── 收货地址 ──────────────────────────────────────────
model Address {
id String @id @default(cuid())
userId String
name String
phone String
province String
city String
district String
detail String
isDefault Boolean @default(false)
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// ── 订单 ──────────────────────────────────────────────
model Order {
id String @id @default(cuid())
orderNo String @unique
userId String
status OrderStatus @default(PENDING)
totalAmount Decimal @db.Decimal(10, 2)
addressSnapshot Json
user User @relation(fields: [userId], references: [id])
items OrderItem[]
payment Payment?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
enum OrderStatus {
PENDING
PAID
PROCESSING
SHIPPED
COMPLETED
CANCELLED
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String?
name String
price Decimal @db.Decimal(10, 2)
quantity Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id])
@@index([orderId])
}
// ── 支付 ──────────────────────────────────────────────
model Payment {
id String @id @default(cuid())
orderId String @unique
transactionId String?
provider PaymentProvider @default(WECHAT)
status PaymentStatus @default(PENDING)
paidAt DateTime?
order Order @relation(fields: [orderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum PaymentProvider {
WECHAT
}
enum PaymentStatus {
PENDING
SUCCESS
REFUNDED
}
// ── 文件上传 ──────────────────────────────────────────
model Upload {
id String @id @default(cuid())
userId String
cosKey String
url String
mimetype String?
size Int?
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([userId])
}
// ── 异步定制任务 ──────────────────────────────────────
model CustomizationTask {
id String @id @default(cuid())
designListId String?
userId String?
status CustomizationTaskStatus @default(PENDING)
resultUrl String?
queueJobId String?
designList DesignList? @relation(fields: [designListId], references: [id])
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([designListId])
@@index([status])
}
enum CustomizationTaskStatus {
PENDING
RUNNING
SUCCESS
FAILED
}
+46
View File
@@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 分类
const apparel = await prisma.category.upsert({
where: { id: 'cat-apparel' },
update: {},
create: { id: 'cat-apparel', name: '服饰', sort: 1 },
});
const tshirt = await prisma.category.upsert({
where: { id: 'cat-tshirt' },
update: {},
create: { id: 'cat-tshirt', name: 'T恤', parentId: apparel.id, sort: 1 },
});
// 示例商品
await prisma.product.upsert({
where: { id: 'prod-demo-tshirt' },
update: {},
create: {
id: 'prod-demo-tshirt',
name: '示例定制 T恤',
categoryId: tshirt.id,
price: 99.0,
images: [],
description: '用于本地开发联调的示例商品,可在此设计清单中上传自定义图案。',
status: 'ON_SALE',
},
});
// eslint-disable-next-line no-console
console.log('Seed 完成:分类 + 示例商品已写入');
}
main()
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});