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
+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;