Checkpoint: v4.0 media service, compose deploy, and verified docs

这个提交包含在:
cryptocommuniums-afk
2026-03-14 21:45:31 +08:00
父节点 27083d5af9
当前提交 d5431aee0e
修改 41 个文件,包含 4056 行新增883 行删除

查看文件

@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic, setupVite } from "./vite";
function isPortAvailable(port: number): Promise<boolean> {
@@ -30,6 +31,7 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
async function startServer() {
const app = express();
const server = createServer(app);
registerMediaProxy(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
@@ -51,7 +53,8 @@ async function startServer() {
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
const strictPort = process.env.STRICT_PORT === "1";
const port = strictPort ? preferredPort : await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);

56
server/_core/mediaProxy.ts 普通文件
查看文件

@@ -0,0 +1,56 @@
import type { Express, RequestHandler } from "express";
import http from "node:http";
import https from "node:https";
function createMediaProxy(targetUrl: string): RequestHandler {
const target = new URL(targetUrl);
const transport = target.protocol === "https:" ? https : http;
return (req, res) => {
const upstreamUrl = new URL(req.originalUrl, target);
const proxyRequest = transport.request(
upstreamUrl,
{
method: req.method,
headers: {
...req.headers,
host: target.host,
connection: "keep-alive",
},
},
(proxyResponse) => {
if (proxyResponse.statusCode) {
res.status(proxyResponse.statusCode);
}
Object.entries(proxyResponse.headers).forEach(([key, value]) => {
if (value !== undefined) {
res.setHeader(key, value);
}
});
proxyResponse.pipe(res);
}
);
proxyRequest.on("error", (error) => {
if (!res.headersSent) {
res.status(502).json({
error: "media_service_unavailable",
message: error.message,
});
} else {
res.end();
}
});
req.pipe(proxyRequest);
};
}
export function registerMediaProxy(app: Express) {
const mediaServiceUrl = process.env.MEDIA_SERVICE_URL;
if (!mediaServiceUrl) {
return;
}
app.use("/media", createMediaProxy(mediaServiceUrl));
}

查看文件

@@ -260,6 +260,37 @@ describe("video.list", () => {
});
});
describe("video.registerExternal input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.registerExternal({
title: "session",
url: "/media/assets/sessions/demo/recording.webm",
fileKey: "media/sessions/demo/recording.webm",
format: "webm",
})
).rejects.toThrow();
});
it("rejects missing url", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.registerExternal({
title: "session",
url: "",
fileKey: "media/sessions/demo/recording.webm",
format: "webm",
})
).rejects.toThrow();
});
});
describe("video.get input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);

查看文件

@@ -258,6 +258,32 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
return { videoId, url };
}),
registerExternal: protectedProcedure
.input(z.object({
title: z.string().min(1).max(256),
url: z.string().min(1),
fileKey: z.string().min(1),
format: z.string().min(1).max(16),
fileSize: z.number().optional(),
duration: z.number().optional(),
exerciseType: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const videoId = await db.createVideo({
userId: ctx.user.id,
title: input.title,
fileKey: input.fileKey,
url: input.url,
format: input.format,
fileSize: input.fileSize ?? null,
duration: input.duration ?? null,
exerciseType: input.exerciseType || "recording",
analysisStatus: "completed",
});
return { videoId, url: input.url };
}),
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserVideos(ctx.user.id);
}),