Skip to content

Commit c5d7bd1

Browse files
committed
feat(generator): 优化生成器上传和错误处理
- 改进生成器上传功能,使用原始二进制流读取文件,避免UTF-8编码损坏 - 更新错误处理,增强服务器错误日志,包含堆栈信息 - 修改请求处理逻辑,支持二进制流读取 - 新增生成器端到端集成测试,验证上传和市场交互功能
1 parent 9cf8ef4 commit c5d7bd1

51 files changed

Lines changed: 2407 additions & 89 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

content-forest-backend/src/api/generators.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,22 @@ export function createGeneratorRoutes(service: GeneratorService): Hono {
103103
return c.json({ code: 400, message: `platform must be one of: ${validPlatforms.join(", ")}` }, 400)
104104
}
105105

106-
const zipBuffer = Buffer.from(await file.arrayBuffer())
106+
// 用 file.stream() 读取原始二进制,避免 formData 解析时 UTF-8 编码损坏二进制内容
107+
const chunks: Uint8Array[] = []
108+
const reader = file.stream().getReader()
109+
while (true) {
110+
const { done, value } = await reader.read()
111+
if (done) break
112+
if (value) chunks.push(value)
113+
}
114+
const totalLen = chunks.reduce((s, c) => s + c.length, 0)
115+
const zipBuf = Buffer.allocUnsafe(totalLen)
116+
let pos = 0
117+
for (const chunk of chunks) { zipBuf.set(chunk, pos); pos += chunk.length }
118+
process.stderr.write(`[upload] zip size=${zipBuf.length} first4: ${zipBuf.slice(0,4).toString('hex')}\n`)
119+
if (zipBuf.length === 0) {
120+
return c.json({ code: 400, message: "file is empty" }, 400)
121+
}
107122
const safeJson = (raw: FormDataEntryValue | null): string[] => {
108123
if (!raw) return []
109124
try { return JSON.parse(String(raw)) } catch { return [] }
@@ -120,7 +135,7 @@ export function createGeneratorRoutes(service: GeneratorService): Hono {
120135
price: Number(formData.get("price") ?? 0),
121136
}
122137

123-
const result = await service.upload(userId, dto, zipBuffer)
138+
const result = await service.upload(userId, dto, zipBuf)
124139
return c.json({ code: 0, data: result }, 201)
125140
})
126141

content-forest-backend/src/api/server.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ app.onError((err, c) => {
7272
if (err instanceof InvalidSkillPackageError) {
7373
return c.json({ code: 400, message: err.message }, 400)
7474
}
75-
process.stderr.write(`[Server] unhandled error: ${err.message}\n`)
75+
process.stderr.write(`[Server] unhandled error: ${err.message}\n${err.stack ?? ""}\n`)
7676
return c.json({ code: 500, message: "Internal Server Error" }, 500)
7777
})
7878

@@ -105,7 +105,7 @@ export async function handleRequest(
105105
),
106106
body: ["GET", "HEAD"].includes(request.method ?? "GET")
107107
? undefined
108-
: await readStream(request),
108+
: await readStreamBinary(request),
109109
})
110110

111111
const res = await app.fetch(req)
@@ -120,11 +120,12 @@ export async function handleRequest(
120120
// 内部工具
121121
// ---------------------------------------------------------------------------
122122

123-
async function readStream(stream: IncomingMessage): Promise<string> {
123+
async function readStreamBinary(stream: IncomingMessage): Promise<Buffer> {
124124
return new Promise((resolve, reject) => {
125125
const chunks: Buffer[] = []
126126
stream.on("data", (chunk: Buffer) => chunks.push(chunk))
127-
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
127+
stream.on("end", () => resolve(Buffer.concat(chunks)))
128128
stream.on("error", reject)
129129
})
130130
}
131+

content-forest-backend/src/storage/generator-fs.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414

1515
import fs from "node:fs/promises"
1616
import path from "node:path"
17+
import { createRequire } from "node:module"
1718
import { CF_DATA_ROOT } from "../config.js"
1819

20+
const require = createRequire(import.meta.url)
21+
1922
/** 平台生成器 Skill 存储根目录 */
2023
export const CF_PLATFORM_ROOT =
2124
process.env.CF_PLATFORM_ROOT ??
@@ -147,34 +150,60 @@ export class InvalidSkillPackageError extends Error {
147150
}
148151

149152
/**
150-
* 解压 zip Buffer,返回文件内容 Map<相对路径, Buffer>。
151-
* 校验根目录必须包含 SKILL.md,否则抛 InvalidSkillPackageError。
152-
*
153-
* 依赖:unzipper(需在 package.json 中声明)
153+
* 纯 Node.js zip 解析(无外部依赖,兼容 Node v23)。
154+
* 支持 STORED (0) 和 DEFLATED (8) 压缩方式。
154155
*/
155156
export async function extractAndValidateZip(
156157
zipBuffer: Buffer
157158
): Promise<Map<string, Buffer>> {
158-
const unzipper = await import("unzipper").catch(() => {
159-
throw new Error(
160-
"unzipper is not installed. Run: npm install unzipper @types/unzipper"
161-
)
162-
})
163-
164-
const directory = await unzipper.Open.buffer(zipBuffer)
159+
const { inflateRawSync } = require("node:zlib") as typeof import("node:zlib")
165160
const files = new Map<string, Buffer>()
166161

167-
// 收集所有文件
168-
for (const file of directory.files) {
169-
if (file.type === "Directory") continue
170-
const content = await file.buffer()
171-
// 去除 zip 根目录前缀(如 my-skill/SKILL.md → SKILL.md)
172-
const parts = file.path.split("/")
173-
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0]
174-
if (relPath) files.set(relPath, content)
162+
// 强制拷贝一份独立 Buffer,彻底避免内存池引用问题
163+
const buf = Buffer.allocUnsafe(zipBuffer.length)
164+
zipBuffer.copy(buf)
165+
166+
process.stderr.write(`[zip] parse start, len=${buf.length}, sig=${buf.readUInt32LE(0).toString(16)}\n`)
167+
168+
let offset = 0
169+
while (offset < buf.length - 4) {
170+
const sig = buf.readUInt32LE(offset)
171+
if (sig !== 0x04034b50) break // Local file header signature
172+
173+
const compression = buf.readUInt16LE(offset + 8)
174+
const compSize = buf.readUInt32LE(offset + 18)
175+
const nameLen = buf.readUInt16LE(offset + 26)
176+
const extraLen = buf.readUInt16LE(offset + 28)
177+
const name = buf.slice(offset + 30, offset + 30 + nameLen).toString("utf-8")
178+
const dataOffset = offset + 30 + nameLen + extraLen
179+
const compData = buf.slice(dataOffset, dataOffset + compSize)
180+
181+
process.stderr.write(`[zip] entry name=${name} comp=${compression} compSize=${compSize} dataOffset=${dataOffset} compDataLen=${compData.length}\n`)
182+
183+
if (!name.endsWith("/")) {
184+
let content: Buffer
185+
if (compression === 0) {
186+
content = Buffer.from(compData) // 显式拷贝
187+
} else if (compression === 8) {
188+
content = inflateRawSync(compData)
189+
} else {
190+
throw new InvalidSkillPackageError(`Unsupported compression method ${compression} for: ${name}`)
191+
}
192+
const parts = name.split("/")
193+
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0]!
194+
if (relPath) files.set(relPath, content)
195+
process.stderr.write(`[zip] stored relPath=${relPath} contentLen=${content.length}\n`)
196+
}
197+
198+
offset = dataOffset + compSize
199+
}
200+
201+
process.stderr.write(`[zip] total files: ${files.size} [${[...files.keys()].join(', ')}]\n`)
202+
203+
if (files.size === 0) {
204+
throw new InvalidSkillPackageError("Invalid skill package: zip appears empty or corrupt")
175205
}
176206

177-
// 校验 SKILL.md 存在
178207
const hasSkillMd = [...files.keys()].some(
179208
(p) => p === "SKILL.md" || p.toLowerCase() === "skill.md"
180209
)

content-forest-backend/src/storage/redis-client.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ import { Redis } from "ioredis"
1111
import { REDIS_URL } from "../config.js"
1212

1313
const redis = new Redis(REDIS_URL, {
14-
// 连接失败时自动重试,最多 3 次,避免启动时因 Redis 未就绪崩溃
15-
maxRetriesPerRequest: 3,
16-
enableReadyCheck: true,
14+
maxRetriesPerRequest: 1, // 快速失败,不长时间等待
15+
enableReadyCheck: false, // 不等 ready 再发命令
1716
lazyConnect: false,
17+
retryStrategy: (times: number) => {
18+
if (times > 10) return null // 超过 10 次停止重试
19+
return Math.min(times * 200, 2000) // 200ms 起步,最多 2s
20+
},
21+
reconnectOnError: () => true, // 任何错误都尝试重连
22+
commandTimeout: 5000, // 单个命令 5s 超时
1823
})
1924

2025
redis.on("error", (err: Error) => {

0 commit comments

Comments
 (0)