feat: 后端初始化

This commit is contained in:
2026-07-28 10:54:47 +08:00
parent 88f5371ed8
commit dfe0d6bd95
24 changed files with 22326 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+9
View File
@@ -0,0 +1,9 @@
# Pooled Neon connection string for Netlify Functions runtime.
DATABASE_URL="postgresql://USER:PASSWORD@HOST-pooler.REGION.aws.neon.tech/DB?sslmode=require"
# Direct Neon connection string for Prisma CLI commands such as migrate.
DIRECT_URL="postgresql://USER:PASSWORD@HOST.REGION.aws.neon.tech/DB?sslmode=require"
# Optional app settings.
JWT_SECRET="replace-me"
UPLOAD_TOKEN="replace-me"
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.netlify/
.env
dist/
coverage/
*.log
+57
View File
@@ -0,0 +1,57 @@
# supplied-service
Netlify Functions backend for the supplied mall H5 project.
## Stack
- Netlify Functions
- TypeScript
- Prisma ORM
- Neon Postgres
- `@prisma/adapter-neon` + `@neondatabase/serverless`
## Directory
```text
netlify/
functions/ # API entrypoints
utils/ # shared function utilities
types/ # shared API types
prisma/
schema.prisma # Prisma models
```
## Environment
Copy `.env.example` to `.env` and set:
- `DATABASE_URL`: pooled Neon connection string for Netlify Functions runtime
- `DIRECT_URL`: direct Neon connection string for Prisma CLI and migrations
- `JWT_SECRET`: auth signing secret
- `UPLOAD_TOKEN`: upload guard token
## Scripts
```bash
npm run dev
npm run build
npm run typecheck
npm run prisma:generate
npm run prisma:migrate
npm run prisma:studio
```
## Function Routes
Netlify exposes functions under `/.netlify/functions/<name>`.
Current placeholders:
- `login`
- `product-list`
- `product-detail`
- `cart-add`
- `order-create`
- `order-pay`
- `user-info`
- `upload`
+14
View File
@@ -0,0 +1,14 @@
[build]
command = "npm run build"
functions = "netlify/functions"
[functions]
node_bundler = "esbuild"
external_node_modules = ["@prisma/client", "@prisma/adapter-neon", "@neondatabase/serverless", "ws"]
[[headers]]
for = "/.netlify/functions/*"
[headers.values]
Access-Control-Allow-Origin = "*"
Access-Control-Allow-Headers = "Content-Type, Authorization"
Access-Control-Allow-Methods = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return ok({ message: 'cart-add endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return ok({ message: 'login endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return ok({ message: 'order-create endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return ok({ message: 'order-pay endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['GET'])
if (methodResponse) return methodResponse
return ok({ message: 'product-detail endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['GET'])
if (methodResponse) return methodResponse
return ok({ message: 'product-list endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return ok({ message: 'upload endpoint placeholder' })
})
@@ -0,0 +1,11 @@
import type { Context } from '@netlify/functions'
import { guardMethod } from '../utils/method.js'
import { ok } from '../utils/http.js'
import { withErrorBoundary } from '../utils/handler.js'
export default withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['GET'])
if (methodResponse) return methodResponse
return ok({ message: 'user-info endpoint placeholder' })
})
+14
View File
@@ -0,0 +1,14 @@
export type ApiSuccess<T> = {
success: true
data: T
}
export type ApiFailure = {
success: false
error: {
code: string
message: string
}
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure
+15
View File
@@ -0,0 +1,15 @@
import type { Context } from '@netlify/functions'
import { fail } from './http.js'
export type FunctionHandler = (request: Request, context: Context) => Promise<Response> | Response
export const withErrorBoundary = (handler: FunctionHandler): FunctionHandler => {
return async (request, context) => {
try {
return await handler(request, context)
} catch (error) {
console.error(error)
return fail('Internal server error', 500, 'INTERNAL_SERVER_ERROR')
}
}
}
+36
View File
@@ -0,0 +1,36 @@
export type JsonBody = Record<string, unknown> | unknown[]
export const json = (body: JsonBody, init: ResponseInit = {}) =>
Response.json(body, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers || {})
}
})
export const ok = (data: unknown = null) => json({ success: true, data })
export const fail = (message: string, status = 400, code = 'BAD_REQUEST') =>
json({ success: false, error: { code, message } }, { status })
export const methodNotAllowed = (allowedMethods: string[]) =>
json(
{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' } },
{
status: 405,
headers: {
Allow: allowedMethods.join(', ')
}
}
)
export const handleOptions = () =>
new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
}
})
+13
View File
@@ -0,0 +1,13 @@
import { handleOptions, methodNotAllowed } from './http.js'
export const guardMethod = (request: Request, allowedMethods: string[]) => {
if (request.method === 'OPTIONS') {
return handleOptions()
}
if (!allowedMethods.includes(request.method)) {
return methodNotAllowed(allowedMethods)
}
return null
}
+24
View File
@@ -0,0 +1,24 @@
import { neonConfig } from '@neondatabase/serverless'
import { PrismaNeon } from '@prisma/adapter-neon'
import { PrismaClient } from '@prisma/client'
import ws from 'ws'
neonConfig.webSocketConstructor = ws
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL is required')
}
const globalForPrisma = globalThis as typeof globalThis & {
prisma?: PrismaClient
}
const adapter = new PrismaNeon({ connectionString })
export const prisma = globalForPrisma.prisma || new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+16
View File
@@ -0,0 +1,16 @@
export const readJson = async <T = unknown>(request: Request): Promise<T> => {
const contentType = request.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
return {} as T
}
return request.json() as Promise<T>
}
export const getBearerToken = (request: Request) => {
const authorization = request.headers.get('authorization') || ''
const match = authorization.match(/^Bearer\s+(.+)$/i)
return match?.[1] || ''
}
+21885
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "supplied-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "netlify dev",
"build": "prisma generate",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@neondatabase/serverless": "^1.0.1",
"@netlify/functions": "^4.2.5",
"@prisma/adapter-neon": "^6.16.0",
"@prisma/client": "^6.16.0",
"dotenv": "^17.2.3",
"ws": "^8.18.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"netlify-cli": "^23.13.3",
"prisma": "^6.16.0",
"typescript": "^5.9.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: env('DIRECT_URL')
}
})
+96
View File
@@ -0,0 +1,96 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
mobile String @unique
nickname String?
avatarUrl String?
points Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carts CartItem[]
orders Order[]
}
model Product {
id String @id @default(cuid())
name String
description String?
detail String?
price Decimal @db.Decimal(10, 2)
imageUrl String?
category String?
tag String?
stock Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carts CartItem[]
orderItems OrderItem[]
}
model CartItem {
id String @id @default(cuid())
userId String
productId String
skuText String?
quantity Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId, skuText])
}
model Order {
id String @id @default(cuid())
orderNo String @unique
userId String
status OrderStatus @default(PENDING_PAYMENT)
totalAmount Decimal @db.Decimal(10, 2)
payType PayType?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items OrderItem[]
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
name String
imageUrl String?
skuText 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])
}
enum OrderStatus {
PENDING_PAYMENT
PAID
SHIPPED
COMPLETED
CANCELLED
}
enum PayType {
POINTS
WECHAT
ALIPAY
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["netlify/**/*.ts", "prisma.config.ts"]
}