feat(docker): add Dockerfile and .dockerignore for app containerization

This commit is contained in:
gameloader
2025-11-10 22:14:35 +08:00
parent 4cb9db1b7c
commit 2165fbe5ec
4 changed files with 161 additions and 48 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
# Prevent local build artifacts and secrets from bloating the image
.git
.next
node_modules
.env*
Dockerfile
README.md

63
Dockerfile Normal file
View File

@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ---- Base image with pnpm enabled ----
FROM node:20-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 \
PNPM_HOME=/usr/local/share/pnpm
ENV PATH=${PNPM_HOME}:${PATH}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# ---- Install dependencies (cached layer) ----
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---- Build the Next.js application ----
FROM base AS builder
ARG DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
ENV DATABASE_URL=${DATABASE_URL} \
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
RUN pnpm run prisma:generate
RUN pnpm run build
# ---- Prune devDependencies for the runtime image ----
FROM deps AS prod-deps
RUN pnpm prune --prod
# ---- Production runtime ----
FROM node:20-slim AS runner
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app \
&& chown node:node /app
# Copy only what is required to run `next start`
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/.next ./.next
# Copy public assets only if they exist in the project
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/next.config.js ./next.config.js
# Keep the filesystem owned by the non-root user
USER node
EXPOSE 3000
# `next start` will read any variables from the environment or a mounted .env.local
CMD ["node", "./node_modules/next/dist/bin/next", "start"]

View File

@ -7,38 +7,47 @@ import { getNumericParam } from "@/lib/route-params";
type AssignmentRouteParams = { assignmentId: string | string[] | undefined }; type AssignmentRouteParams = { assignmentId: string | string[] | undefined };
type SubmissionRecord = Awaited<ReturnType<typeof prisma.submission.findMany>>[number];
type ExportableColumn = {
header: string;
getter: (submission: SubmissionRecord) => unknown;
};
const EXPORTABLE_COLUMNS = { const EXPORTABLE_COLUMNS = {
studentId: { header: "学号", getter: (submission: any) => submission.studentId }, studentId: { header: "学号", getter: (submission) => submission.studentId },
studentName: { header: "姓名", getter: (submission: any) => submission.studentName }, studentName: { header: "姓名", getter: (submission) => submission.studentName },
originalFilename: { originalFilename: {
header: "文件名", header: "文件名",
getter: (submission: any) => submission.originalFilename, getter: (submission) => submission.originalFilename,
}, },
fileUrl: { header: "文件地址", getter: (submission: any) => submission.fileUrl }, fileUrl: { header: "文件地址", getter: (submission) => submission.fileUrl },
submittedAt: { submittedAt: {
header: "提交时间", header: "提交时间",
getter: (submission: any) => getter: (submission) =>
submission.submittedAt ? new Date(submission.submittedAt).toISOString() : "", submission.submittedAt ? new Date(submission.submittedAt).toISOString() : "",
}, },
evaluationScore: { evaluationScore: {
header: "得分", header: "得分",
getter: (submission: any) => getter: (submission) =>
typeof submission.evaluationScore === "number" typeof submission.evaluationScore === "number"
? submission.evaluationScore ? submission.evaluationScore
: "", : "",
}, },
evaluationComment: { evaluationComment: {
header: "评价评语", header: "评价评语",
getter: (submission: any) => submission.evaluationComment ?? "", getter: (submission) => submission.evaluationComment ?? "",
}, },
evaluatedAt: { evaluatedAt: {
header: "评价时间", header: "评价时间",
getter: (submission: any) => getter: (submission) =>
submission.evaluatedAt ? new Date(submission.evaluatedAt).toISOString() : "", submission.evaluatedAt ? new Date(submission.evaluatedAt).toISOString() : "",
}, },
} as const; } satisfies Record<string, ExportableColumn>;
const DEFAULT_COLUMNS = ["studentId", "evaluationScore"] as Array<keyof typeof EXPORTABLE_COLUMNS>; type ExportableColumnKey = keyof typeof EXPORTABLE_COLUMNS;
const DEFAULT_COLUMNS: ExportableColumnKey[] = ["studentId", "evaluationScore"];
export async function GET( export async function GET(
request: Request, request: Request,
@ -84,7 +93,7 @@ export async function GET(
return NextResponse.json({ error: "请选择有效的导出列" }, { status: 400 }); return NextResponse.json({ error: "请选择有效的导出列" }, { status: 400 });
} }
const submissions = await prisma.submission.findMany({ const submissions: SubmissionRecord[] = await prisma.submission.findMany({
where: { assignmentId }, where: { assignmentId },
orderBy: { submittedAt: "asc" }, orderBy: { submittedAt: "asc" },
}); });

View File

@ -2,35 +2,62 @@ import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
const { type S3EnvConfig = {
AUTOTEACHER_S3_BUCKET, bucket: string;
AUTOTEACHER_S3_REGION, region?: string;
AUTOTEACHER_S3_ENDPOINT_URL, endpoint?: string;
AUTOTEACHER_S3_ACCESS_KEY_ID, accessKeyId?: string;
AUTOTEACHER_S3_SECRET_ACCESS_KEY, secretAccessKey?: string;
AUTOTEACHER_S3_PUBLIC_BASE_URL, publicBaseUrl?: string;
AUTOTEACHER_S3_USE_SSL, useSsl: boolean;
} = process.env; };
if (!AUTOTEACHER_S3_BUCKET) { let cachedConfig: S3EnvConfig | null = null;
throw new Error("Missing AUTOTEACHER_S3_BUCKET environment variable"); let cachedClient: S3Client | null = null;
}
const useSsl = (AUTOTEACHER_S3_USE_SSL ?? "true").toLowerCase() !== "false"; const resolveEnvConfig = (): S3EnvConfig => {
const bucket = process.env.AUTOTEACHER_S3_BUCKET;
if (!bucket) {
throw new Error("Missing AUTOTEACHER_S3_BUCKET environment variable");
}
const s3Client = new S3Client({ return {
region: AUTOTEACHER_S3_REGION || "us-east-1", bucket,
endpoint: AUTOTEACHER_S3_ENDPOINT_URL, region: process.env.AUTOTEACHER_S3_REGION || "us-east-1",
forcePathStyle: Boolean(AUTOTEACHER_S3_ENDPOINT_URL), endpoint: process.env.AUTOTEACHER_S3_ENDPOINT_URL,
credentials: accessKeyId: process.env.AUTOTEACHER_S3_ACCESS_KEY_ID,
AUTOTEACHER_S3_ACCESS_KEY_ID && AUTOTEACHER_S3_SECRET_ACCESS_KEY secretAccessKey: process.env.AUTOTEACHER_S3_SECRET_ACCESS_KEY,
? { publicBaseUrl: process.env.AUTOTEACHER_S3_PUBLIC_BASE_URL,
accessKeyId: AUTOTEACHER_S3_ACCESS_KEY_ID, useSsl: (process.env.AUTOTEACHER_S3_USE_SSL ?? "true").toLowerCase() !== "false",
secretAccessKey: AUTOTEACHER_S3_SECRET_ACCESS_KEY, };
} };
: undefined,
tls: useSsl, const getConfig = (): S3EnvConfig => {
}); if (!cachedConfig) {
cachedConfig = resolveEnvConfig();
}
return cachedConfig;
};
const getClient = (): S3Client => {
if (!cachedClient) {
const config = getConfig();
cachedClient = new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: Boolean(config.endpoint),
credentials:
config.accessKeyId && config.secretAccessKey
? {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
}
: undefined,
tls: config.useSsl,
});
}
return cachedClient;
};
export type UploadResult = { export type UploadResult = {
key: string; key: string;
@ -38,19 +65,20 @@ export type UploadResult = {
}; };
const buildFileUrl = (key: string): string => { const buildFileUrl = (key: string): string => {
if (AUTOTEACHER_S3_PUBLIC_BASE_URL) { const config = getConfig();
return `${AUTOTEACHER_S3_PUBLIC_BASE_URL.replace(/\/$/, "")}/${key}`; if (config.publicBaseUrl) {
return `${config.publicBaseUrl.replace(/\/$/, "")}/${key}`;
} }
if (AUTOTEACHER_S3_ENDPOINT_URL) { if (config.endpoint) {
return `${AUTOTEACHER_S3_ENDPOINT_URL.replace(/\/$/, "")}/${AUTOTEACHER_S3_BUCKET}/${key}`; return `${config.endpoint.replace(/\/$/, "")}/${config.bucket}/${key}`;
} }
if (AUTOTEACHER_S3_REGION) { if (config.region) {
return `https://${AUTOTEACHER_S3_BUCKET}.s3.${AUTOTEACHER_S3_REGION}.amazonaws.com/${key}`; return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
} }
return `https://${AUTOTEACHER_S3_BUCKET}.s3.amazonaws.com/${key}`; return `https://${config.bucket}.s3.amazonaws.com/${key}`;
}; };
export async function uploadAssignmentFile( export async function uploadAssignmentFile(
@ -70,24 +98,30 @@ export async function uploadAssignmentFile(
contentType = "application/msword"; contentType = "application/msword";
} }
const config = getConfig();
const client = getClient();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: AUTOTEACHER_S3_BUCKET, Bucket: config.bucket,
Key: key, Key: key,
Body: fileBuffer, Body: fileBuffer,
ContentType: contentType, ContentType: contentType,
}); });
await s3Client.send(command); await client.send(command);
return { key, url: buildFileUrl(key) }; return { key, url: buildFileUrl(key) };
} }
export async function downloadAssignmentFile(key: string): Promise<Buffer> { export async function downloadAssignmentFile(key: string): Promise<Buffer> {
const config = getConfig();
const client = getClient();
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: AUTOTEACHER_S3_BUCKET, Bucket: config.bucket,
Key: key, Key: key,
}); });
const response = await s3Client.send(command); const response = await client.send(command);
const body = response.Body; const body = response.Body;
if (!body) { if (!body) {
throw new Error("无法读取存储的作业文件"); throw new Error("无法读取存储的作业文件");