feat: 后端初始化
This commit is contained in:
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.netlify/
|
||||
.env
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -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`
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] || ''
|
||||
}
|
||||
Generated
+21885
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user