init: me-api 个人简历后台
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.git
|
||||
*.swp
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.md
|
||||
@@ -0,0 +1,10 @@
|
||||
# Backend
|
||||
APP_ID= # Application ID
|
||||
APP_SECRET= # Application secret for JWT signing
|
||||
|
||||
# Database
|
||||
DATABASE_URL= # MySQL connection string (mysql://user:pass@host:port/db)
|
||||
|
||||
# Admin Login
|
||||
ADMIN_USERNAME= # Admin login username
|
||||
ADMIN_PASSWORD= # Admin login password
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# DB migrations
|
||||
db/migrations/*.sql
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/boot.js"]
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import * as cookie from "cookie";
|
||||
import { createRouter, publicQuery, authedQuery } from "./middleware";
|
||||
import { getSessionCookieOptions } from "./lib/cookies";
|
||||
import { signSessionToken } from "./lib/session";
|
||||
import { env } from "./lib/env";
|
||||
import { findUserByUnionId, upsertUser } from "./queries/users";
|
||||
import { Session } from "@contracts/constants";
|
||||
|
||||
export const authRouter = createRouter({
|
||||
login: publicQuery
|
||||
.input(z.object({ username: z.string(), password: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (
|
||||
input.username !== env.adminUsername ||
|
||||
input.password !== env.adminPassword
|
||||
) {
|
||||
throw new Error("Invalid username or password");
|
||||
}
|
||||
|
||||
await upsertUser({
|
||||
unionId: input.username,
|
||||
name: input.username,
|
||||
role: "admin" as const,
|
||||
lastSignInAt: new Date(),
|
||||
});
|
||||
|
||||
const user = await findUserByUnionId(input.username);
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
|
||||
const token = await signSessionToken({ userId: user.id });
|
||||
|
||||
const cookieOpts = getSessionCookieOptions(ctx.req.headers);
|
||||
ctx.resHeaders.append(
|
||||
"set-cookie",
|
||||
cookie.serialize(Session.cookieName, token, {
|
||||
httpOnly: cookieOpts.httpOnly,
|
||||
path: cookieOpts.path,
|
||||
sameSite: cookieOpts.sameSite?.toLowerCase() as "lax" | "none",
|
||||
secure: cookieOpts.secure,
|
||||
maxAge: Session.maxAgeMs / 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
return user;
|
||||
}),
|
||||
|
||||
me: authedQuery.query((opts) => opts.ctx.user),
|
||||
|
||||
logout: authedQuery.mutation(async ({ ctx }) => {
|
||||
const opts = getSessionCookieOptions(ctx.req.headers);
|
||||
ctx.resHeaders.append(
|
||||
"set-cookie",
|
||||
cookie.serialize(Session.cookieName, "", {
|
||||
httpOnly: opts.httpOnly,
|
||||
path: opts.path,
|
||||
sameSite: opts.sameSite?.toLowerCase() as "lax" | "none",
|
||||
secure: opts.secure,
|
||||
maxAge: 0,
|
||||
}),
|
||||
);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import type { HttpBindings } from "@hono/node-server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "./router";
|
||||
import { createContext } from "./context";
|
||||
import { env } from "./lib/env";
|
||||
|
||||
const app = new Hono<{ Bindings: HttpBindings }>();
|
||||
|
||||
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }));
|
||||
app.use("/api/trpc/*", async (c) => {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: c.req.raw,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
});
|
||||
app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404));
|
||||
|
||||
export default app;
|
||||
|
||||
if (env.isProduction) {
|
||||
const { serve } = await import("@hono/node-server");
|
||||
|
||||
const port = parseInt(process.env.PORT || "3000");
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
import type { User } from "@db/schema";
|
||||
import * as cookie from "cookie";
|
||||
import { Session } from "@contracts/constants";
|
||||
import { verifySessionToken } from "./lib/session";
|
||||
import { findUserById } from "./queries/users";
|
||||
|
||||
export type TrpcContext = {
|
||||
req: Request;
|
||||
resHeaders: Headers;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export async function createContext(
|
||||
opts: FetchCreateContextFnOptions,
|
||||
): Promise<TrpcContext> {
|
||||
const ctx: TrpcContext = { req: opts.req, resHeaders: opts.resHeaders };
|
||||
try {
|
||||
const cookies = cookie.parse(opts.req.headers.get("cookie") || "");
|
||||
const token = cookies[Session.cookieName];
|
||||
if (token) {
|
||||
const claim = await verifySessionToken(token);
|
||||
if (claim) {
|
||||
ctx.user = await findUserById(claim.userId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Authentication is optional
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { createRouter, publicQuery, adminQuery } from "./middleware";
|
||||
import { getDb } from "./queries/connection";
|
||||
import { documents } from "@db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
|
||||
export const documentsRouter = createRouter({
|
||||
list: publicQuery.query(async () => {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.published, "published"))
|
||||
.orderBy(desc(documents.createdAt));
|
||||
}),
|
||||
|
||||
listAll: adminQuery.query(async () => {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(documents)
|
||||
.orderBy(desc(documents.createdAt));
|
||||
}),
|
||||
|
||||
getBySlug: publicQuery
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const results = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.slug, input.slug))
|
||||
.limit(1);
|
||||
return results[0] ?? null;
|
||||
}),
|
||||
|
||||
create: adminQuery
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
excerpt: z.string().optional(),
|
||||
content: z.string().min(1),
|
||||
coverImage: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
published: z.enum(["draft", "published"]).default("published"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const result = await db.insert(documents).values({
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
excerpt: input.excerpt ?? "",
|
||||
content: input.content,
|
||||
coverImage: input.coverImage ?? "",
|
||||
tags: input.tags ?? "",
|
||||
published: input.published,
|
||||
});
|
||||
return { id: Number(result[0].insertId) };
|
||||
}),
|
||||
|
||||
update: adminQuery
|
||||
.input(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(255).optional(),
|
||||
excerpt: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
coverImage: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
published: z.enum(["draft", "published"]).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const { id, ...data } = input;
|
||||
await db
|
||||
.update(documents)
|
||||
.set(data)
|
||||
.where(eq(documents.id, id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminQuery
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
await db.delete(documents).where(eq(documents.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
|
||||
function isLocalhost(headers: Headers): boolean {
|
||||
const host = headers.get("host") || "";
|
||||
return host.startsWith("localhost:") || host.startsWith("127.0.0.1:");
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions(headers: Headers): CookieOptions {
|
||||
const localhost = isLocalhost(headers);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: localhost ? "Lax" : "None",
|
||||
secure: !localhost,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import "dotenv/config";
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value && process.env.NODE_ENV === "production") {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
export const env = {
|
||||
jwtSecret: required("JWT_SECRET"),
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
databaseUrl: required("DATABASE_URL"),
|
||||
adminUsername: required("ADMIN_USERNAME"),
|
||||
adminPassword: required("ADMIN_PASSWORD"),
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
interface RequestConfig extends RequestInit {
|
||||
baseUrl?: string;
|
||||
params?: Record<string, string | number>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, opts?: { headers?: Record<string, string> }) {
|
||||
this.baseUrl = baseURL;
|
||||
this.defaultHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
...opts?.headers,
|
||||
};
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
||||
const {
|
||||
method = "GET",
|
||||
params,
|
||||
body,
|
||||
headers,
|
||||
timeout = 30000,
|
||||
...rest
|
||||
} = config;
|
||||
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) =>
|
||||
url.searchParams.append(key, value.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
...rest,
|
||||
method,
|
||||
headers: { ...this.defaultHeaders, ...headers },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response
|
||||
.json()
|
||||
.catch(() => ({}))) as Record<string, string>;
|
||||
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error("Request timeout");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
url: string,
|
||||
params?: RequestConfig["params"],
|
||||
config?: RequestConfig,
|
||||
) {
|
||||
return this.request<T>(url, { ...config, method: "GET", params });
|
||||
}
|
||||
|
||||
post<T>(url: string, body?: any, config?: RequestConfig) {
|
||||
return this.request<T>(url, { ...config, method: "POST", body });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as jose from "jose";
|
||||
import { env } from "./env";
|
||||
|
||||
const JWT_ALG = "HS256";
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export async function signSessionToken(
|
||||
payload: SessionPayload,
|
||||
): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.jwtSecret);
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: JWT_ALG })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1 year")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifySessionToken(
|
||||
token: string,
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const secret = new TextEncoder().encode(env.jwtSecret);
|
||||
const { payload } = await jose.jwtVerify(token, secret, {
|
||||
algorithms: [JWT_ALG],
|
||||
});
|
||||
const userId = payload.userId as number;
|
||||
if (!userId) return null;
|
||||
return { userId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ErrorMessages } from "@contracts/constants";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { TrpcContext } from "./context";
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const createRouter = t.router;
|
||||
export const publicQuery = t.procedure;
|
||||
|
||||
const requireAuth = t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: ErrorMessages.unauthenticated,
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
function requireRole(role: string) {
|
||||
return t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user || ctx.user.role !== role) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: ErrorMessages.insufficientRole,
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
}
|
||||
|
||||
export const authedQuery = t.procedure.use(requireAuth);
|
||||
export const adminQuery = authedQuery.use(requireRole("admin"));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { env } from "../lib/env";
|
||||
import * as schema from "@db/schema";
|
||||
import * as relations from "@db/relations";
|
||||
|
||||
const fullSchema = { ...schema, ...relations };
|
||||
|
||||
let instance: ReturnType<typeof drizzle<typeof fullSchema>>;
|
||||
|
||||
export function getDb() {
|
||||
if (!instance) {
|
||||
instance = drizzle(env.databaseUrl, {
|
||||
mode: "default",
|
||||
schema: fullSchema,
|
||||
});
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "@db/schema";
|
||||
import type { InsertUser } from "@db/schema";
|
||||
import { getDb } from "./connection";
|
||||
|
||||
export async function findUserByUnionId(unionId: string) {
|
||||
const rows = await getDb()
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.unionId, unionId))
|
||||
.limit(1);
|
||||
return rows.at(0);
|
||||
}
|
||||
|
||||
export async function findUserById(id: number) {
|
||||
const rows = await getDb()
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id))
|
||||
.limit(1);
|
||||
return rows.at(0);
|
||||
}
|
||||
|
||||
export async function upsertUser(data: InsertUser) {
|
||||
await getDb()
|
||||
.insert(schema.users)
|
||||
.values(data)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
lastSignInAt: new Date(),
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { authRouter } from "./auth-router";
|
||||
import { documentsRouter } from "./documents-router";
|
||||
import { createRouter, publicQuery } from "./middleware";
|
||||
|
||||
export const appRouter = createRouter({
|
||||
ping: publicQuery.query(() => ({ ok: true, ts: Date.now() })),
|
||||
auth: authRouter,
|
||||
documents: documentsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const Session = {
|
||||
cookieName: "admin_sid",
|
||||
maxAgeMs: 365 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
export const ErrorMessages = {
|
||||
unauthenticated: "Authentication required",
|
||||
insufficientRole: "Insufficient permissions",
|
||||
} as const;
|
||||
|
||||
export const Paths = {
|
||||
login: "/login",
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
type AppError = { tag: "app_error"; status: number; message: string };
|
||||
|
||||
function appError(status: number, message: string): AppError {
|
||||
return { tag: "app_error", status, message };
|
||||
}
|
||||
|
||||
export const Errors = {
|
||||
badRequest: (msg: string) => appError(400, msg),
|
||||
unauthorized: (msg: string) => appError(401, msg),
|
||||
forbidden: (msg: string) => appError(403, msg),
|
||||
notFound: (msg: string) => appError(404, msg),
|
||||
internal: (msg: string) => appError(500, msg),
|
||||
} as const;
|
||||
|
||||
export type { AppError };
|
||||
@@ -0,0 +1,2 @@
|
||||
export type * from "../db/schema";
|
||||
export * from "./errors";
|
||||
@@ -0,0 +1 @@
|
||||
import {} from "./schema";
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
mysqlEnum,
|
||||
serial,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
// bigint,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
unionId: varchar("unionId", { length: 255 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }),
|
||||
email: varchar("email", { length: 320 }),
|
||||
avatar: text("avatar"),
|
||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt")
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date()),
|
||||
lastSignInAt: timestamp("lastSignInAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = typeof users.$inferInsert;
|
||||
|
||||
export const documents = mysqlTable("documents", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
excerpt: text("excerpt"),
|
||||
content: text("content").notNull(),
|
||||
coverImage: text("coverImage"),
|
||||
tags: text("tags"),
|
||||
published: mysqlEnum("published", ["draft", "published"]).default("published").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt")
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
|
||||
export type Document = typeof documents.$inferSelect;
|
||||
export type InsertDocument = typeof documents.$inferInsert;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { getDb } from "../api/queries/connection";
|
||||
// TODO: import tables from "./schema"
|
||||
|
||||
async function seed() {
|
||||
const db = getDb();
|
||||
console.log("Seeding database...");
|
||||
|
||||
// TODO: insert seed data, e.g.
|
||||
// await db.insert(schema.posts).values([
|
||||
// { title: "First post", content: "Hello world" },
|
||||
// ]);
|
||||
|
||||
console.log("Done.");
|
||||
process.exit(0); // close MySQL connection pool
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,19 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
me-api:
|
||||
build: .
|
||||
image: me-api:latest
|
||||
container_name: me-api
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
name: aliyun-app-network
|
||||
@@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required to run drizzle commands");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./db/schema.ts",
|
||||
out: "./db/migrations",
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
url: connectionString,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "me-api",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch api/boot.ts",
|
||||
"build": "tsc -b && esbuild api/boot.ts --platform=node --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/boot.js",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.965.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.965.0",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@trpc/server": "^11.8.1",
|
||||
"cookie": "^1.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.8.3",
|
||||
"jose": "6.1.3",
|
||||
"mysql2": "^3.14.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"superjson": "^2.2.6",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"esbuild": "^0.27.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@contracts/*": ["./contracts/*"],
|
||||
"@db/*": ["./db/*"]
|
||||
}
|
||||
},
|
||||
"include": ["api", "contracts", "db", "drizzle.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user