init: me-api 个人简历后台

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Superuser
2026-05-18 20:10:56 +08:00
commit 40d3a66055
27 changed files with 730 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
.git
*.swp
*.log
.env
.env.local
.env.*.local
*.md
+10
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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"]
+66
View File
@@ -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
View File
@@ -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}/`);
});
}
+31
View File
@@ -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;
}
+93
View File
@@ -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 };
}),
});
+17
View File
@@ -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,
};
}
+17
View File
@@ -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"),
};
+77
View File
@@ -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 });
}
}
+36
View File
@@ -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;
}
}
+42
View File
@@ -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"));
+18
View File
@@ -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;
}
+35
View File
@@ -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,
},
});
}
+11
View File
@@ -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;
+13
View File
@@ -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;
+15
View File
@@ -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 };
+2
View File
@@ -0,0 +1,2 @@
export type * from "../db/schema";
export * from "./errors";
View File
+1
View File
@@ -0,0 +1 @@
import {} from "./schema";
+46
View File
@@ -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
View File
@@ -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();
+19
View File
@@ -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
+16
View File
@@ -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,
},
});
+40
View File
@@ -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"
}
}
+22
View File
@@ -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"]
}