This commit is contained in:
renzhiyuan 2026-07-21 15:34:01 +08:00
commit 5e4b42493d
27 changed files with 3975 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.idea
/outputs

71
Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# ============================================================
# 阶段1: 构建
# ============================================================
FROM golang:1.26-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git make
# 复制 go.mod 和 go.sum 并下载依赖(利用 Docker 缓存)
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# 复制源代码
COPY . .
# 构建二进制文件
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o /app/sdk-generator \
./cmd/server
# ============================================================
# 阶段2: 运行
# ============================================================
FROM alpine:latest
# 安装运行时依赖
RUN apk add --no-cache ca-certificates tzdata git
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建非 root 用户
RUN adduser -D -g '' appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /app/sdk-generator /app/sdk-generator
# 复制 Web 静态文件和模板
COPY --chown=appuser:appuser ./web /app/web
# 创建数据目录并设置权限
RUN mkdir -p /app/outputs /app/uploads && \
chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 5002
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# 启动
ENTRYPOINT ["/app/sdk-generator"]
#docker run -d \
# -p 8080:8080 \
# -v /var/www/sdk-generator/outputs:/app/outputs \
# --name sdk-generator \
# sdk-generator

31
go.mod Normal file
View File

@ -0,0 +1,31 @@
module sdk-generator
go 1.26.2
require (
code.gitea.io/sdk/gitea v0.25.1
github.com/gofiber/fiber/v2 v2.52.13
github.com/gofiber/template/html/v2 v2.1.3
github.com/google/uuid v1.6.0
github.com/sashabaranov/go-openai v1.28.0
)
require (
github.com/42wim/httpsig v1.2.4 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)

69
go.sum Normal file
View File

@ -0,0 +1,69 @@
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sashabaranov/go-openai v1.28.0 h1:WS9F9BriSvtHvknPQy2Oi3b+8zkmJdEXcycrWqrSicQ=
github.com/sashabaranov/go-openai v1.28.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

50
internal/call/callLLM.go Normal file
View File

@ -0,0 +1,50 @@
package call
import (
"context"
"fmt"
"github.com/sashabaranov/go-openai"
)
type LlmCallSet struct {
ApiKey string
BaseUrl string
ModelName string
}
type CallLLM struct {
ModelName string
openaiClient *openai.Client
}
func NewCallLLM(LlmCallSet *LlmCallSet) *CallLLM {
clientConfig := openai.DefaultConfig(LlmCallSet.ApiKey)
clientConfig.BaseURL = LlmCallSet.BaseUrl
client := openai.NewClientWithConfig(clientConfig)
return &CallLLM{
openaiClient: client,
ModelName: LlmCallSet.ModelName,
}
}
func (s *CallLLM) Do(ctx context.Context, RequestOption openai.ChatCompletionRequest, modelName ...string) (string, error) {
if modelName == nil {
modelName = append(modelName, s.ModelName)
}
if modelName[0] == "" {
return "", fmt.Errorf("model name is nil")
}
RequestOption.Model = modelName[0]
resp, err := s.openaiClient.CreateChatCompletion(ctx, RequestOption)
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("模型未返回任何内容")
}
content := resp.Choices[0].Message.Content
//content := test.Res
return content, nil
}

54
internal/config/config.go Normal file
View File

@ -0,0 +1,54 @@
package config
import (
"os"
"time"
)
type Config struct {
// 服务配置
Port string
UploadDir string
OutputDir string
// 大模型配置
APIKey string
BaseURL string
Model string
MaxFixAttempts int
Timeout time.Duration
// 限流配置
MaxFileSize int64
MaxTasks int
//gitea配置
GiteaUrl string
GiteaToken string
OrgName string
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "5002"),
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
OutputDir: getEnv("OUTPUT_DIR", "./outputs"),
APIKey: getEnv("DOUBAO_API_KEY", "ark-3f942f9b-7428-4880-b939-026087b99957-062f1"),
BaseURL: getEnv("DOUBAO_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
Model: getEnv("DOUBAO_MODEL", "ep-20260717134040-w9qhc"),
Timeout: 5 * time.Minute,
MaxFileSize: 10 * 1024 * 1024, // 10MB
MaxTasks: 100,
MaxFixAttempts: 3,
GiteaUrl: "https://gitea.cdlsxd.cn",
GiteaToken: "406ebea224965f9be4c25e7ff2614588827fd31f",
OrgName: "ai_sdk",
}
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

View File

@ -0,0 +1,151 @@
package extractor
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type File struct {
Path string
Content string
}
// Extract 从模型响应中提取代码文件
// 使用纯字符串解析,不依赖 goldmark AST
func Extract(response string) ([]File, error) {
var files []File
seen := make(map[string]bool)
// 按 // File: 分割
parts := strings.Split(response, "// File:")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// 第一行是文件名
lines := strings.SplitN(part, "\n", 2)
if len(lines) < 2 {
continue
}
fileName := strings.TrimSpace(lines[0])
code := strings.TrimSpace(lines[1])
if fileName == "" || code == "" {
continue
}
// 清理代码块标记(移除 ```go 或 ```
code = strings.TrimPrefix(code, "```go")
code = strings.TrimPrefix(code, "```")
code = strings.TrimSuffix(code, "```")
code = strings.TrimSpace(code)
// 智能识别文件类型
fileName = smartFileName(fileName, code)
// 规范化路径
fullPath := normalizePath(fileName)
// 去重
if seen[fullPath] {
continue
}
seen[fullPath] = true
files = append(files, File{
Path: fullPath,
Content: code,
})
}
if len(files) == 0 {
return nil, fmt.Errorf("未找到任何代码块,请确保响应格式为:// File: 文件名\n```go\n代码\n```")
}
return files, nil
}
// smartFileName 智能识别文件名
func smartFileName(fileName, code string) string {
fileName = strings.TrimSpace(fileName)
fileName = strings.TrimLeft(fileName, "0123456789.- ")
// 如果文件名是 code.go 或类似通用名,根据内容推断
base := filepath.Base(fileName)
if base == "code.go" || base == "code" || base == "main.go" {
// 根据内容推断
if strings.Contains(code, "package sdk_test") {
return "example_test.go"
}
if strings.Contains(code, "module ") {
return "go.mod"
}
if strings.Contains(code, "package sdk") && strings.Contains(code, "func (c *Client)") {
// 检查是哪个 API 文件
if strings.Contains(code, "CreateKeyOrder") || strings.Contains(code, "OrderKey") {
return "api_key.go"
}
if strings.Contains(code, "BatchOrder") || strings.Contains(code, "BatchQuery") {
return "api_batch.go"
}
if strings.Contains(code, "QueryKey") || strings.Contains(code, "DiscardKey") {
return "api_key.go"
}
}
if strings.Contains(code, "package sdk") && strings.Contains(code, "type Client struct") {
return "client.go"
}
if strings.Contains(code, "package sdk") && (strings.Contains(code, "type APIError") || strings.Contains(code, "CodeSuccess")) {
return "errors.go"
}
if strings.Contains(code, "package sdk") && (strings.Contains(code, "ParseRSAPrivateKey") || strings.Contains(code, "AESEncryptECB")) {
return "crypto.go"
}
if strings.Contains(code, "package sdk") && (strings.Contains(code, "type RequestEnvelope") || strings.Contains(code, "type KeyOrderInfo")) {
return "types.go"
}
}
return fileName
}
func normalizePath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimLeft(path, "0123456789.- ")
// 确保文件扩展名正确
if !strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, ".mod") {
// 如果是 go.mod 或 go.work保持原样
if path != "go.mod" && path != "go.work" {
path = path + ".go"
}
}
// 确保路径以 sdk/ 开头
//if !strings.HasPrefix(path, "sdk/") && !strings.HasPrefix(path, "output/") {
// path = filepath.Join("sdk", path)
//}
return path
}
// WriteFiles 将文件写入本地
func WriteFiles(files []File, outputDir string) error {
for _, f := range files {
fullPath := filepath.Join(outputDir, f.Path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
if err := os.WriteFile(fullPath, []byte(f.Content), 0644); err != nil {
return fmt.Errorf("写入文件失败: %w", fullPath, err)
}
}
return nil
}

View File

@ -0,0 +1,102 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
)
// ConvertResponse 定义接口响应结构
type ConvertResponse struct {
Filename string `json:"filename"`
LlmUsed bool `json:"llm_used"`
Markdown string `json:"markdown"`
Size int `json:"size"`
Success bool `json:"success"`
Title *string `json:"title"`
}
// ConvertRequest 定义请求参数结构
type ConvertRequest struct {
File multipart.File
FileName string
LlmModel string
LlmApiKey string
LlmBaseUrl string
LlmPrompt string
}
const url = "http://121.199.38.107:5001/convert"
// ConvertFile 调用 /convert 接口识别本地文件内容
func ConvertFile(req ConvertRequest) (*ConvertResponse, error) {
defer req.File.Close()
// 创建 multipart 表单
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加文件字段
part, err := writer.CreateFormFile("file", req.FileName)
if err != nil {
return nil, fmt.Errorf("创建文件表单字段失败: %w", err)
}
if _, err := io.Copy(part, req.File); err != nil {
return nil, fmt.Errorf("复制文件内容失败: %w", err)
}
// 添加可选参数(如果提供)
if req.LlmModel != "" {
_ = writer.WriteField("llm_model", req.LlmModel)
}
if req.LlmApiKey != "" {
_ = writer.WriteField("llm_api_key", req.LlmApiKey)
}
if req.LlmBaseUrl != "" {
_ = writer.WriteField("llm_base_url", req.LlmBaseUrl)
}
if req.LlmPrompt != "" {
_ = writer.WriteField("llm_prompt", req.LlmPrompt)
}
// 关闭 multipart writer
if err = writer.Close(); err != nil {
return nil, fmt.Errorf("关闭表单写入器失败: %w", err)
}
// 创建 HTTP 请求
httpReq, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
// 发送请求
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 检查 HTTP 状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("请求返回非200状态码: %d, 响应: %s", resp.StatusCode, string(respBody))
}
// 解析响应 JSON
var result ConvertResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("解析响应JSON失败: %w, 原始响应: %s", err, string(respBody))
}
return &result, nil
}

View File

@ -0,0 +1,16 @@
package handler
import (
"github.com/gofiber/fiber/v2"
)
type PageHandler struct{}
func NewPageHandler() *PageHandler {
return &PageHandler{}
}
// Index 首页
func (h *PageHandler) Index(c *fiber.Ctx) error {
return c.SendFile("./web/templates/index.html")
}

295
internal/handler/sdk.go Normal file
View File

@ -0,0 +1,295 @@
package handler
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"sdk-generator/internal/call"
"sdk-generator/internal/models"
"sdk-generator/internal/service"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type SDKHandler struct {
service *service.SDKGeneratorService
}
func NewSDKHandler(service *service.SDKGeneratorService) *SDKHandler {
return &SDKHandler{service: service}
}
// GenerateSDK 上传文档并生成 SDK
// POST /api/v1/generate
func (h *SDKHandler) GenerateSDK(c *fiber.Ctx) error {
//获取上传文件
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "请上传文件 (field: file)",
})
}
// 检查文件大小
if file.Size > 10*1024*1024 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "文件大小超过 10MB 限制",
})
}
var content []byte
// 检查文件类型
filename := file.Filename
ext := strings.ToLower(filepath.Ext(filename))
src, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "打开文件失败",
})
}
llmSet := call.LlmCallSet{
ApiKey: c.FormValue("llm_api_key", ""),
BaseUrl: c.FormValue("llm_base_url", ""),
ModelName: c.FormValue("llm_model", ""),
}
if llmSet.ModelName == "" || llmSet.ApiKey == "" || llmSet.BaseUrl == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "缺少 llm_model 或 llm_api_key 或 llm_base_url",
})
}
defer src.Close()
switch ext {
case ".md", ".txt", ".markdown":
// 读取文件内容
content, err = io.ReadAll(src)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "读取文件失败",
})
}
break
case ".docx", ".doc", ".pdf":
res, err := ConvertFile(ConvertRequest{
File: src,
FileName: filename,
LlmModel: llmSet.ModelName,
LlmApiKey: llmSet.ApiKey,
LlmBaseUrl: llmSet.BaseUrl,
//LlmPrompt: c.FormValue("llm_prompt", ""),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "读取文件失败" + err.Error(),
})
}
content = []byte(res.Markdown)
break
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "不支持的文件类型"})
}
sdkName := c.FormValue("sdk_name", time.Now().Format(time.RFC3339))
// 调用服务生成
taskID, err := h.service.Generate(c.Context(), content, sdkName, &llmSet)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.Status(fiber.StatusAccepted).JSON(models.GenerateResponse{
TaskID: taskID,
Status: string(models.StatusPending),
Message: "SDK 生成任务已提交,请通过 /api/v1/tasks/:task_id 查询状态",
})
}
// GetTaskStatus 查询任务状态
// GET /api/v1/tasks/:task_id
func (h *SDKHandler) GetTaskStatus(c *fiber.Ctx) error {
taskID := c.Params("task_id")
if taskID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "缺少 task_id",
})
}
task, ok := h.service.GetTask(taskID)
if !ok {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "任务不存在",
})
}
task.StatusName = task.Status.Desc()
task.Percent = task.Status.Percent()
response := models.TaskResponse{
Task: *task,
}
// 如果任务完成,列出生成的文件
if task.Status == models.StatusCompleted {
files, _ := listFiles(task.OutputDir)
response.Files = files
}
return c.JSON(response)
}
// DownloadSDK 下载生成的 SDK
// GET /api/v1/tasks/:task_id/download
func (h *SDKHandler) DownloadSDK(c *fiber.Ctx) error {
taskID := c.Params("task_id")
if taskID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "缺少 task_id",
})
}
task, ok := h.service.GetTask(taskID)
if !ok {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "任务不存在",
})
}
if task.Status != models.StatusCompleted {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": fmt.Sprintf("任务未完成,当前状态: %s", task.Status),
})
}
// 检查输出目录是否存在
if _, err := os.Stat(task.OutputDir); os.IsNotExist(err) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "SDK 文件不存在",
})
}
// 创建 ZIP 文件并流式返回
zipFilename := fmt.Sprintf("sdk_%s.zip", taskID)
c.Set("Content-Type", "application/zip")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", zipFilename))
// 使用 Fiber 的 Response 直接写入
zipWriter := zip.NewWriter(c.Response().BodyWriter())
defer zipWriter.Close()
// 遍历目录添加文件到 ZIP
err := filepath.Walk(task.OutputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// 计算相对路径
relPath, err := filepath.Rel(task.OutputDir, path)
if err != nil {
return err
}
// 创建 ZIP 条目
zipFile, err := zipWriter.Create(relPath)
if err != nil {
return err
}
// 读取文件内容
content, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = zipFile.Write(content)
return err
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("打包 SDK 失败: %v", err),
})
}
return nil
}
// ListTasks 列出所有任务
// GET /api/v1/tasks
func (h *SDKHandler) ListTasks(c *fiber.Ctx) error {
tasks := h.service.ListTasks()
// 分页
page, _ := strconv.Atoi(c.Query("page", "1"))
size, _ := strconv.Atoi(c.Query("size", "20"))
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
total := len(tasks)
start := (page - 1) * size
end := start + size
if start > total {
start = total
}
if end > total {
end = total
}
return c.JSON(models.ListTasksResponse{
Data: tasks[start:end],
Total: total,
Page: page,
Size: size,
})
}
// DeleteTask 删除任务
// DELETE /api/v1/tasks/:task_id
func (h *SDKHandler) DeleteTask(c *fiber.Ctx) error {
taskID := c.Params("task_id")
if taskID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "缺少 task_id",
})
}
if err := h.service.DeleteTask(taskID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"message": "任务已删除",
})
}
// 辅助函数:列出目录下的文件
func listFiles(dir string) ([]string, error) {
var files []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
relPath, _ := filepath.Rel(dir, path)
files = append(files, relPath)
}
return nil
})
return files, err
}

104
internal/models/task.go Normal file
View File

@ -0,0 +1,104 @@
package models
import (
"sdk-generator/internal/call"
"sdk-generator/internal/extractor"
"time"
)
type TaskStatus string
const (
StatusPending TaskStatus = "pending"
StatusProcessing TaskStatus = "processing"
StatusMkdir TaskStatus = "mkdir"
StatusAnaMd TaskStatus = "anaMd"
StatusGenerateCode TaskStatus = "generateCode"
StatusCreatFile TaskStatus = "creatFile"
StatusValid TaskStatus = "valid"
StatusProcess TaskStatus = "process"
StatusFix TaskStatus = "fix"
StatusSendToGit TaskStatus = "sendToGit "
StatusMsgSend TaskStatus = "msgSend"
StatusCompleted TaskStatus = "completed"
StatusFailed TaskStatus = "failed"
)
func (s TaskStatus) Desc() string {
return StatusName[s]
}
func (s TaskStatus) Percent() int {
return StatusPercent[s]
}
var StatusPercent = map[TaskStatus]int{
StatusPending: 0,
StatusProcessing: 1,
StatusMkdir: 10,
StatusAnaMd: 20,
StatusGenerateCode: 60,
StatusCreatFile: 50,
StatusValid: 70,
StatusProcess: 62,
StatusFix: 80,
StatusSendToGit: 85,
StatusMsgSend: 95,
StatusCompleted: 100,
StatusFailed: 100,
}
var StatusName = map[TaskStatus]string{
StatusPending: "准备中",
StatusProcessing: "开始创建",
StatusMkdir: "新建项目文件夹",
StatusAnaMd: "分析文档",
StatusGenerateCode: "生成代码",
StatusCreatFile: "创建代码目录",
StatusValid: "验证功能",
StatusProcess: "编译检测",
StatusFix: "问题修复",
StatusSendToGit: "上传代码仓库",
StatusMsgSend: "发送钉钉消息",
StatusCompleted: "任务完成",
StatusFailed: "任务失败",
}
type Task struct {
ID string `json:"id"`
Status TaskStatus `json:"status"`
StatusName string `json:"status_name"`
Percent int `json:"percent"`
SdkName string `json:"sdk_name"`
OutputDir string `json:"output_dir"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CallLLM *call.CallLLM
SdkPackage string
MarkdownContent string
Refine string
Resp string
Valid string
Files []extractor.File
RepoURL string
}
type GenerateResponse struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
Message string `json:"message"`
}
type TaskResponse struct {
Task
Files []string `json:"files,omitempty"`
}
type ListTasksResponse struct {
Data []*Task `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}

46
internal/msg/dingBot.go Normal file
View File

@ -0,0 +1,46 @@
package msg
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"time"
)
var MesSendUrl = "https://oapi.dingtalk.com/robot/send?access_token=d8367c13499dfb164b1e02a4c024024f6857a892e7406265be08d5b5e06fc590"
func SendDingTalkAlert(ctx context.Context, sdkName, url string) {
client := &http.Client{
Timeout: 10 * time.Second,
}
// ✅ 正确的 link 消息格式
message := map[string]interface{}{
"msgtype": "link",
"link": map[string]interface{}{
"title": "[ai_sdk]🎉 " + sdkName + " 代码生成完成,点此进入仓库",
"text": url + ".git",
"messageUrl": url,
"picUrl": "", // 可选:图片 URL
},
}
jsonData, err := json.Marshal(message)
if err != nil {
log.Printf("JSON序列化失败: %v", err)
return
}
resp, err := client.Post(MesSendUrl, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("发送钉钉告警失败: %v", err)
return
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
log.Printf("钉钉告警发送结果,状态码: %s,内容:%s", resp.Status, data)
return
}

17
internal/msg/msg_test.go Normal file
View File

@ -0,0 +1,17 @@
package msg
import (
"context"
"testing"
)
func TestSendDingTalkAlert(t *testing.T) {
// 测试正常发送
ctx := context.Background()
sdkName := "ymt_v3_generate"
url := "https://gitea.cdlsxd.cn/ai_sdk/ymt_v3_generate-20260721-095556"
SendDingTalkAlert(ctx, sdkName, url)
t.Log("所有测试完成")
}

View File

@ -0,0 +1,56 @@
package postprocess
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func Process(ctx context.Context, sdkPackage string) error {
// 确保 outputDir 存在
if err := os.MkdirAll(sdkPackage, 0755); err != nil {
return fmt.Errorf("创建输出目录失败: %w", err)
}
// 检查是否有 go.mod没有则初始化
goModPath := filepath.Join(sdkPackage, "go.mod")
if _, err := os.Stat(goModPath); os.IsNotExist(err) {
cmd := exec.Command("go", "mod", "init", sdkPackage)
cmd.Dir = sdkPackage
if err := cmd.Run(); err != nil {
// 忽略错误,可能已经有 go.mod 了
}
}
commands := []struct {
name string
args []string
desc string
}{
{"go", []string{"fmt", "./..."}, "格式化代码"},
{"go", []string{"mod", "tidy"}, "整理依赖"},
{"go", []string{"build", "./..."}, "编译检查"},
}
var errors []string
for _, cmdInfo := range commands {
c := exec.Command(cmdInfo.name, cmdInfo.args...)
c.Dir = sdkPackage
var stderr bytes.Buffer
c.Stderr = &stderr
if err := c.Run(); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v\n%s", cmdInfo.desc, err, stderr.String()))
}
}
if len(errors) > 0 {
return fmt.Errorf("\n%s", strings.Join(errors, "\n"))
}
return nil
}

58
internal/prompts/fix.go Normal file
View File

@ -0,0 +1,58 @@
// internal/prompts/fix_prompt.go
package prompts
import "github.com/sashabaranov/go-openai"
func FixPrompt(codeContent, errorMsg, sdkName, refineDoc string) openai.ChatCompletionRequest {
return openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一个资深的 Go 语言工程师。生成的 SDK 代码在编译/格式化时出现错误,请修复。",
},
{
Role: openai.ChatMessageRoleUser,
Content: GetFixPrompt(codeContent, errorMsg, sdkName, refineDoc),
},
},
Temperature: 0.1, // 更低,保证确定性输出
TopP: 0.9, // 核采样,平衡多样性和质量
MaxTokens: 65536, // 加大到 64k确保足够
FrequencyPenalty: 0.0, // 代码生成不需要
PresencePenalty: 0.0, // 代码生成不需要
Stop: nil, // 不设置,让模型完整输出
}
}
// GetFixPrompt 构建修复代码的提示词
func GetFixPrompt(codeContent, errorMsg, refineDoc, sdkName string) string {
return `
## 错误信息
` + errorMsg + `
## 文档供参考
` + refineDoc + `
## 当前代码
` + codeContent + `
## 重要输出格式要求必须严格遵守
每个文件必须以以下格式输出
// File: ` + sdkName + `/文件名.go
` + "```go" + `
package ` + sdkName + `
// ... 代码内容 ...
` + "```" + `
## 修复要求
1. 根据错误信息修复代码
2. 确保代码符合 Go 语法规范
3. 不要改变接口定义和业务逻辑
4. 保持原有代码风格
5. 请直接输出修复后的完整代码不要有任何额外文字
请返回修复后的完整代码`
}

View File

@ -0,0 +1,145 @@
package prompts
import "github.com/sashabaranov/go-openai"
func SDKGeneratorPrompt(prompt string) openai.ChatCompletionRequest {
return openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一个资深的 Go 语言工程师,擅长生成高质量、可直接编译的 SDK 代码。",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0.1, // 更低,保证确定性输出
TopP: 0.9, // 核采样,平衡多样性和质量
MaxTokens: 65536, // 加大到 64k确保足够
FrequencyPenalty: 0.0, // 代码生成不需要
PresencePenalty: 0.0, // 代码生成不需要
Stop: nil, // 不设置,让模型完整输出
}
}
const GenSDKPrompt = `你是一个资深的 Go 语言工程师擅长生成高质量的 SDK 代码
## 任务
根据用户提供的 API 接口文档Markdown 格式生成一个完整的 Go SDK 代码工程
## 重要输出格式要求必须严格遵守
每个文件必须以以下格式输出
// File: {{sdk_name}}/文件名.go
` + "```go" + `
package {{sdk_name}}
// ... 代码内容 ...
` + "```" + `
## 重要导入路径规范必须遵守
生成的 SDK 代码中**示例代码example_test.go必须使用本地导入路径**而不是远程 GitHub 路径
正确示例
` + "```go" + `
package {{sdk_name}}
import (
"context"
"fmt"
"time"
"net/http"
)
` + "```" + `
错误示例禁止使用
` + "```go" + `
import sdk "github.com/bluebrothers/marketing-sdk-go" // 禁止!这会拉取远程包
` + "```" + `
## 文件结构
{{sdk_name}}/
go.mod # 模块名为 generated-sdk
client.go
types.go
crypto.go
errors.go
api_key.go
example_test.go # 使用本地导入路径
## 代码规范
1. 遵循 Effective Go 规范
2. 所有导出类型/方法必须有 godoc 注释
3. context.Context 作为方法的第一个参数
4. 错误处理统一返回 error
## 客户端设计
1. 提供 NewClient 构造函数支持 Option 模式
2. 支持 WithBaseURLWithTimeoutWithHTTPClient 等配置
3. 根据文档中描述的认证方式实现认证逻辑
## 加密/签名实现规范必须遵守
**核心原则使用成熟的第三方库不要自己实现加解密算法**
### 推荐使用的第三方库
**AES 加密**
` + "```go" + `
import "crypto/aes" // Go 标准库
import "crypto/cipher" // Go 标准库
// 或使用更高级的封装
import "github.com/tink-crypto/tink-go/v2/aead"
` + "```" + `
**RSA 加密/签名**
` + "```go" + `
import "crypto/rsa" // Go 标准库
import "crypto/rand" // Go 标准库
` + "```" + `
**HMAC 签名**
` + "```go" + `
import "crypto/hmac"
import "crypto/sha256"
` + "```" + `
**SM2/SM3/SM4国密**
` + "```go" + `
import "github.com/tjfoc/gmsm/sm2"
import "github.com/tjfoc/gmsm/sm3"
import "github.com/tjfoc/gmsm/sm4"
` + "```" + `
**JWT**
` + "```go" + `
import "github.com/golang-jwt/jwt/v5"
` + "```" + `
### 禁止的行为
**不要自己实现**
- 不要自己写 AES 加密函数除非调用标准库
- 不要自己写 RSA 签名函数除非调用标准库
- 不要自己实现 PKCS7 填充使用标准库或成熟库
- 不要自己实现任何加解密算法
**正确做法**
- 调用 Go 标准库 "crypto/*"
- 使用成熟的开源库 gmsmjwt
- 如果文档要求特殊算法先查找是否有现成的 Go 实现
- 只在标准库和成熟库都不支持时才考虑自己实现
## 单元测试规范必须严格遵守
1. 所有单元测试必须写在 example_test.go 文件中
2. 必须使用标准库 testing
3. **每个 API 接口必须对应一个独立的单元测试函数格式必须为**
## 关键要求
1. 生成的代码必须可直接编译运行
2. go的版本为1.21
3. **go.mod module 名使用 {{sdk_name}}**
4. 不要引用任何不存在的远程 GitHub 仓库
## 接口文档
%s`

View File

@ -0,0 +1,70 @@
package prompts
import "github.com/sashabaranov/go-openai"
// BuildRefinePrompt 构建文档精炼的请求
func BuildRefinePrompt(rawDoc string) openai.ChatCompletionRequest {
return openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: `你是一个专业的 API 文档分析专家,擅长从各种格式的文档中提取技术信息。你的输出必须准确、完整,因为后续会基于你的输出生成可运行的代码。`,
},
{
Role: openai.ChatMessageRoleUser,
Content: RefinePrompt(rawDoc),
},
},
Temperature: 0.1,
TopP: 0.9,
MaxTokens: 65536,
FrequencyPenalty: 0.0,
PresencePenalty: 0.0,
}
}
// RefinePrompt 文档精炼提示词模板
func RefinePrompt(rawDoc string) string {
return `分析以下 API 文档为后续代码生成做准备
## 你的任务
1. 判断文档类型"调用文档"生成客户端 SDK还是"对接文档"生成服务端骨架
2. 完整提取所有接口信息路径方法参数响应
3. 完整提取认证/加密/签名细节
4. 去除营销文案流程说明等噪音但保留所有技术信息
## 必须保留的内容
- 所有接口一个不漏
- 所有参数和字段包括类型必填描述
- 所有错误码
- 认证加密签名的完整流程
- 所有示例
## 可以去掉的内容
- 公司介绍平台介绍
- 接入流程步骤
- 联调建议
- 重复内容
## 输出格式
` + "```" + `
文档类型[调用文档/对接文档/混合型]
[接口列表...保持完整]
[认证加密签名...保持完整]
[错误码...保持完整]
` + "```" + `
## 关键原则
1. **完整性优先**不确定是否重要的信息保留
2. **忠于原文**类型字段名示例值保持原样不要修改
3. **不编造**文档中没有的信息不要补充
原始文档
` + rawDoc
}

73
internal/prompts/valid.go Normal file
View File

@ -0,0 +1,73 @@
// internal/prompts/validate_prompt.go
package prompts
import (
"github.com/sashabaranov/go-openai"
)
// GetValidatePrompt 验证代码完整性,直接返回修复后的代码
func GetValidatePrompt(refineDoc, sdkName, codeContent string) openai.ChatCompletionRequest {
return openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是代码审查专家。检查生成的 SDK 代码是否完整实现了文档中的所有内容。",
},
{
Role: openai.ChatMessageRoleUser,
Content: ValidPrompt(refineDoc, sdkName, codeContent),
},
},
Temperature: 0.1, // 更低,保证确定性输出
TopP: 0.9, // 核采样,平衡多样性和质量
MaxTokens: 65536, // 加大到 64k确保足够
FrequencyPenalty: 0.0, // 代码生成不需要
PresencePenalty: 0.0, // 代码生成不需要
Stop: nil, // 不设置,让模型完整输出
}
}
func ValidPrompt(codeContent, sdkName, refineDoc string) string {
return `你是代码审查专家检查生成的 SDK 代码是否完整实现了文档中的所有内容
## 精炼文档
` + refineDoc + `
## 当前代码
` + codeContent + `
## 检查清单
1. 文档中的所有接口是否都已实现
2. 每个接口的请求参数是否完整字段名类型必填
3. 每个接口的响应字段是否完整
4. 认证方式是否已实现
5. 加密方式是否已实现
6. 签名方式是否已实现
7. 错误码是否已定义
## 输出规则严格遵守
**情况一审查通过没有任何问题**
只输出两个字符` + "`OK`" + `
**情况二存在问题**
输出修复后的**全量代码**每个文件用以下格式
// File: ` + sdkName + `/文件名.go
` + "```go" + `
package ` + sdkName + `
// ... 代码内容 ...
` + "```" + `
## 重要提醒
1. 如果存在问题必须输出**所有文件**的完整代码不能只输出修改的部分
2. 保持原有代码风格不变
3. 补全所有遗漏的接口参数加密/签名方式
4. 不要加任何描述性注释
`
}

348
internal/push/gitea.go Normal file
View File

@ -0,0 +1,348 @@
package push
import (
"encoding/base64"
"fmt"
"sync"
"time"
"code.gitea.io/sdk/gitea"
)
var (
// 全局客户端实例(单例)
defaultClient *GiteaClient
once sync.Once
initErr error
)
// GiteaClient Gitea 客户端封装
type GiteaClient struct {
client *gitea.Client
}
// RepoConfig 仓库配置
type RepoConfig struct {
Name string // 仓库名称
Description string // 仓库描述
Private bool // 是否私有false 为公共仓库
AutoInit bool // 是否自动初始化
Gitignores string // .gitignore 模板,如 "Go", "Python"
License string // 许可证,如 "MIT", "Apache-2.0"
Readme string // README 模板,如 "Default"
}
// FileConfig 文件配置
type FileConfig struct {
Path string // 文件路径,如 "main.go"
Content string // 文件内容(原始字符串)
Message string // 提交信息
Branch string // 分支名,为空则使用默认分支
}
// TagConfig Tag 配置
type TagConfig struct {
TagName string // tag 名称,如 "v1.0.0"
Message string // tag 说明
}
// GetClient 获取全局客户端实例(单例模式)
func GetClient() (*GiteaClient, error) {
//if defaultClient == nil {
// once.Do(func() {
// defaultClient, initErr = NewGiteaClient(
// "https://gitea.cdlsxd.cn/api/v1",
// "rzy_lsxd_2026",
// )
// })
// if initErr != nil {
// return nil, initErr
// }
//}
return defaultClient, nil
}
// NewGiteaClient 创建 Gitea 客户端实例
func NewGiteaClient(url, token string) (*GiteaClient, error) {
client, err := gitea.NewClient(url, gitea.SetToken(token))
if err != nil {
return nil, fmt.Errorf("创建 Gitea 客户端失败: %v", err)
}
defaultClient = &GiteaClient{
client: client,
}
return defaultClient, nil
}
// CreateRepo 创建仓库(支持个人和组织)
func (g *GiteaClient) CreateRepo(cfg RepoConfig, orgName ...string) (*gitea.Repository, error) {
if g.client == nil {
return nil, fmt.Errorf("客户端未初始化")
}
opts := gitea.CreateRepoOption{
Name: cfg.Name,
Description: cfg.Description,
Private: cfg.Private,
AutoInit: cfg.AutoInit,
Gitignores: cfg.Gitignores,
License: cfg.License,
//Readme: cfg.Readme,
}
var repo *gitea.Repository
var err error
// 如果传入了组织名,则为组织创建仓库
if len(orgName) > 0 && orgName[0] != "" {
repo, _, err = g.client.CreateOrgRepo(orgName[0], opts)
if err != nil {
return nil, fmt.Errorf("为组织 %s 创建仓库失败: %v", orgName[0], err)
}
} else {
repo, _, err = g.client.CreateRepo(opts)
if err != nil {
return nil, fmt.Errorf("创建仓库失败: %v", err)
}
}
return repo, nil
}
// PushFile 推送单个文件
func (g *GiteaClient) PushFile(owner, repoName string, fileCfg FileConfig) error {
if g.client == nil {
return fmt.Errorf("客户端未初始化")
}
// Base64 编码文件内容
contentBase64 := base64.StdEncoding.EncodeToString([]byte(fileCfg.Content))
opts := gitea.CreateFileOptions{
FileOptions: gitea.FileOptions{
Message: fileCfg.Message,
BranchName: fileCfg.Branch,
},
Content: contentBase64,
}
_, _, err := g.client.CreateFile(owner, repoName, fileCfg.Path, opts)
if err != nil {
return fmt.Errorf("推送文件 %s 失败: %v", fileCfg.Path, err)
}
return nil
}
// PushFiles 批量推送多个文件
func (g *GiteaClient) PushFiles(owner, repoName string, files []FileConfig) error {
if g.client == nil {
return fmt.Errorf("客户端未初始化")
}
for _, file := range files {
if err := g.PushFile(owner, repoName, file); err != nil {
return err
}
}
return nil
}
// CreateTag 创建标签
func (g *GiteaClient) CreateTag(owner, repoName string, tagCfg TagConfig) (*gitea.Tag, error) {
if g.client == nil {
return nil, fmt.Errorf("客户端未初始化")
}
opts := gitea.CreateTagOption{
TagName: tagCfg.TagName,
Message: tagCfg.Message,
}
tag, _, err := g.client.CreateTag(owner, repoName, opts)
if err != nil {
return nil, fmt.Errorf("创建标签 %s 失败: %v", tagCfg.TagName, err)
}
return tag, nil
}
// CreateRepoAndPush 创建仓库并推送代码(完整流程)
func (g *GiteaClient) CreateRepoAndPush(cfg RepoConfig, files []FileConfig, tagCfg *TagConfig, orgName ...string) error {
if g.client == nil {
return fmt.Errorf("客户端未初始化")
}
// 1. 创建仓库
repo, err := g.CreateRepo(cfg, orgName...)
if err != nil {
return err
}
// 等待仓库初始化完成
if cfg.AutoInit {
fmt.Println("⏳ 等待仓库初始化...")
time.Sleep(2 * time.Second)
}
// 2. 推送代码
if len(files) > 0 {
fmt.Println("📤 开始推送文件...")
if err := g.PushFiles(repo.Owner.UserName, repo.Name, files); err != nil {
return err
}
}
// 3. 创建标签(如果需要)
if tagCfg != nil && tagCfg.TagName != "" {
fmt.Println("🏷️ 创建标签...")
if _, err := g.CreateTag(repo.Owner.UserName, repo.Name, *tagCfg); err != nil {
return err
}
}
fmt.Println("🎉 全部操作完成!")
return nil
}
// ==================== 便捷函数(使用全局单例) ====================
// CreateRepoQuick 使用全局客户端快速创建仓库
func CreateRepoQuick(cfg RepoConfig, orgName ...string) (*gitea.Repository, error) {
client, err := GetClient()
if err != nil {
return nil, err
}
return client.CreateRepo(cfg, orgName...)
}
// PushFileQuick 使用全局客户端快速推送文件
func PushFileQuick(owner, repoName string, fileCfg FileConfig) error {
client, err := GetClient()
if err != nil {
return err
}
return client.PushFile(owner, repoName, fileCfg)
}
// PushFilesQuick 使用全局客户端快速批量推送文件
func PushFilesQuick(owner, repoName string, files []FileConfig) error {
client, err := GetClient()
if err != nil {
return err
}
return client.PushFiles(owner, repoName, files)
}
// CreateTagQuick 使用全局客户端快速创建标签
func CreateTagQuick(owner, repoName string, tagCfg TagConfig) (*gitea.Tag, error) {
client, err := GetClient()
if err != nil {
return nil, err
}
return client.CreateTag(owner, repoName, tagCfg)
}
// PushQuick 使用全局客户端快速完整推送
func PushQuick(repoName, description string, files []FileConfig, tagCfg *TagConfig, orgName ...string) error {
client, err := GetClient()
if err != nil {
return err
}
cfg := RepoConfig{
Name: repoName,
Description: description,
Private: false,
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
}
return client.CreateRepoAndPush(cfg, files, tagCfg, orgName...)
}
// ==================== 示例 ====================
// ExampleUsage 使用示例
func ExampleUsage() {
// 准备文件
files := []FileConfig{
{
Path: "main.go",
Content: `package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello, Gitea!")\n}\n`,
Message: "添加主程序",
Branch: "main",
},
{
Path: "README.md",
Content: `# Auto Created Repo\n\nThis repo is created by Gitea API.`,
Message: "添加 README",
Branch: "main",
},
}
tagCfg := &TagConfig{
TagName: "v1.0.0",
Message: "首次发布",
}
// 方式1使用快捷函数自动使用全局单例
err := PushQuick("my-auto-repo", "通过 API 自动创建的公共仓库", files, tagCfg)
if err != nil {
fmt.Printf("❌ 操作失败: %v\n", err)
}
// 方式2手动获取客户端实例更灵活
client, err := GetClient()
if err != nil {
fmt.Printf("获取客户端失败: %v\n", err)
return
}
// 创建私有仓库
cfg := RepoConfig{
Name: "private-repo",
Description: "私有仓库",
Private: true,
AutoInit: true,
}
// 为组织创建仓库
err = client.CreateRepoAndPush(cfg, files, nil, "my-org")
if err != nil {
fmt.Printf("❌ 操作失败: %v\n", err)
}
// 方式3单独使用各个功能
// 只创建仓库
repoCfg := RepoConfig{
Name: "test-repo",
Private: false,
AutoInit: true,
}
repo, err := CreateRepoQuick(repoCfg)
if err != nil {
fmt.Printf("创建仓库失败: %v\n", err)
}
// 只推送文件
err = PushFileQuick(repo.Owner.UserName, repo.Name, FileConfig{
Path: "test.txt",
Content: "Hello World",
Message: "添加测试文件",
Branch: "main",
})
if err != nil {
fmt.Printf("推送文件失败: %v\n", err)
}
// 只创建标签
_, err = CreateTagQuick(repo.Owner.UserName, repo.Name, TagConfig{
TagName: "v0.1.0",
Message: "测试版本",
})
if err != nil {
fmt.Printf("创建标签失败: %v\n", err)
}
}

View File

@ -0,0 +1,409 @@
package service
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sdk-generator/internal/call"
"sdk-generator/internal/config"
"sdk-generator/internal/extractor"
"sdk-generator/internal/models"
"sdk-generator/internal/msg"
"sdk-generator/internal/postprocess"
"sdk-generator/internal/prompts"
"sdk-generator/internal/push"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type SDKGeneratorService struct {
config *config.Config
tasks map[string]*models.Task
tasksMu sync.RWMutex
}
func NewSDKGeneratorService(cfg *config.Config) *SDKGeneratorService {
// 创建 OpenAI 客户端配置
return &SDKGeneratorService{
config: cfg,
tasks: make(map[string]*models.Task),
}
}
// Generate 异步生成 SDK
func (s *SDKGeneratorService) Generate(ctx context.Context, content []byte, sdkName string, llmSet *call.LlmCallSet) (string, error) {
// 限制任务数量
s.tasksMu.RLock()
if len(s.tasks) >= s.config.MaxTasks {
s.tasksMu.RUnlock()
return "", fmt.Errorf("任务队列已满,请稍后重试")
}
s.tasksMu.RUnlock()
taskID := uuid.New().String()
task := &models.Task{
ID: taskID,
Status: models.StatusPending,
SdkName: sdkName,
OutputDir: filepath.Join(s.config.OutputDir, taskID),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CallLLM: call.NewCallLLM(llmSet),
MarkdownContent: string(content),
}
s.tasksMu.Lock()
s.tasks[taskID] = task
s.tasksMu.Unlock()
// 异步处理
go func() {
defer func() {
if r := recover(); r != nil {
s.failTask(task, fmt.Sprintf("panic: %v", r))
}
}()
s.processTask(context.Background(), task, string(content))
}()
return taskID, nil
}
func (s *SDKGeneratorService) processTask(ctx context.Context, task *models.Task, markdownContent string) {
s.processTaskStatus(task, models.StatusProcessing)
//0.创建目录
s.processTaskStatus(task, models.StatusMkdir)
err := s.mkdir(ctx, task)
if err != nil {
s.failTask(task, fmt.Sprintf("[mkdir]: %v", err))
return
}
// 1. 分析文档
s.processTaskStatus(task, models.StatusAnaMd)
err = s.anaMd(ctx, task)
if err != nil {
s.failTask(task, fmt.Sprintf("[anaMd]: %v", err))
return
}
// 2. 生成代码
s.processTaskStatus(task, models.StatusGenerateCode)
err = s.generateCode(ctx, task)
if err != nil {
s.failTask(task, fmt.Sprintf("[generateCode]: %v", err))
return
}
// 4. 验证是否存在缺失
s.processTaskStatus(task, models.StatusValid)
err = s.valid(ctx, task)
if err != nil {
// 后处理失败不中断流程
s.failTask(task, fmt.Sprintf("[valid]: %v", err))
return
}
// 3. 处理文件
s.processTaskStatus(task, models.StatusCreatFile)
err = s.creatFile(ctx, task)
if err != nil {
// 后处理失败不中断流程
s.failTask(task, fmt.Sprintf("[creatFile]: %v", err))
return
}
// 5. 后处理
s.processTaskStatus(task, models.StatusProcess)
err = postprocess.Process(ctx, task.SdkPackage)
if err != nil {
//如果运行报错则多轮重试
s.processTaskStatus(task, models.StatusFix)
err = s.fix(ctx, task, err.Error(), 1)
}
// 6. 上传仓库
s.processTaskStatus(task, models.StatusSendToGit)
err = s.sendToGit(ctx, task)
if err != nil {
s.failTask(task, fmt.Sprintf("[sendToGit]: %v", err))
}
// 6. 发送钉钉消息
s.processTaskStatus(task, models.StatusMsgSend)
msg.SendDingTalkAlert(ctx, task.SdkName, task.RepoURL)
// 5. 完成
now := time.Now()
task.Status = models.StatusCompleted
task.UpdatedAt = now
task.CompletedAt = &now
s.updateTask(task)
}
func (s *SDKGeneratorService) sendToGit(ctx context.Context, task *models.Task) error {
// 1. 获取 Gitea 客户端
giteaClient, err := push.GetClient()
if err != nil || giteaClient == nil {
return fmt.Errorf("[GetClient]: %v", err)
}
// 2. 准备文件列表
var files []push.FileConfig
// 如果有提取的文件列表,推送所有文件
if len(task.Files) > 0 {
for _, file := range task.Files {
files = append(files, push.FileConfig{
Path: file.Path,
Content: file.Content,
Message: fmt.Sprintf("添加文件: %s", file.Path),
Branch: "main",
})
}
} else if task.Resp != "" {
// 如果只有 Resp 内容,作为 main.go 推送
files = append(files, push.FileConfig{
Path: "main.go",
Content: task.Resp,
Message: "Initial commit: SDK code",
Branch: "main",
})
}
//3. 如果有 MarkdownContent添加为 README.md
if task.MarkdownContent != "" {
files = append(files, push.FileConfig{
Path: "README.md",
Content: task.MarkdownContent,
Message: "添加 README 文档",
Branch: "main",
})
}
// 4. 如果没有文件,返回错误
if len(files) == 0 {
return fmt.Errorf("没有可推送的文件内容")
}
// 5. 生成仓库名称(使用时间戳确保唯一性)
repoName := fmt.Sprintf("%s-%s", task.SdkName, task.CreatedAt.Format("20060102-150405"))
// 6. 配置标签(可选)
var tagCfg *push.TagConfig
if task.CompletedAt != nil {
tagCfg = &push.TagConfig{
TagName: fmt.Sprintf("v%s", task.CompletedAt.Format("20060102.150405")),
Message: fmt.Sprintf("SDK 生成完成: %s", task.SdkName),
}
}
// 7. 创建仓库并推送代码
repoCfg := push.RepoConfig{
Name: repoName,
Description: task.SdkName,
Private: false, // 公共仓库
AutoInit: false,
Gitignores: "",
License: "",
Readme: "",
}
err = giteaClient.CreateRepoAndPush(repoCfg, files, tagCfg, s.config.OrgName)
if err != nil {
s.failTask(task, fmt.Sprintf("推送代码到 Gitea 失败: %v", err))
return fmt.Errorf("推送代码到 Gitea 失败: %v", err)
}
task.RepoURL = fmt.Sprintf("%s/%s/%s", s.config.GiteaUrl, s.config.OrgName, repoName)
return nil
}
func (s *SDKGeneratorService) mkdir(ctx context.Context, task *models.Task) (err error) {
if _, err = os.Stat(task.OutputDir); err == nil {
if err = os.RemoveAll(task.OutputDir); err != nil {
return fmt.Errorf("删除旧目录失败: %v", err)
}
}
if err = os.MkdirAll(task.OutputDir, 0755); err != nil {
return fmt.Errorf("创建输出目录失败: %v", err)
}
task.SdkPackage = filepath.Join(task.OutputDir, task.SdkName)
return err
}
func (s *SDKGeneratorService) generateCode(ctx context.Context, task *models.Task) (err error) {
promptSetSdtName := strings.Replace(prompts.GenSDKPrompt, "{{sdk_name}}", task.SdkName, -1)
doc := task.Refine
if len(doc) == 0 {
doc = task.MarkdownContent
}
prompt := fmt.Sprintf(promptSetSdtName, doc)
task.Resp, err = task.CallLLM.Do(ctx, prompts.SDKGeneratorPrompt(prompt))
if err != nil {
s.failTask(task, fmt.Sprintf("调用大模型失败: %v", err))
return
}
return err
}
func (s *SDKGeneratorService) anaMd(ctx context.Context, task *models.Task) (err error) {
task.Refine, err = task.CallLLM.Do(ctx, prompts.BuildRefinePrompt(task.MarkdownContent))
if err != nil {
return fmt.Errorf("调用大模型失败: %v", err)
}
return err
}
func (s *SDKGeneratorService) valid(ctx context.Context, task *models.Task) (err error) {
validRes, err := task.CallLLM.Do(ctx, prompts.GetValidatePrompt(task.Refine, task.SdkName, task.Resp))
if err != nil {
return fmt.Errorf("调用大模型失败: %v", err)
}
if validRes != "OK" {
task.Valid = validRes
}
return err
}
func (s *SDKGeneratorService) fix(ctx context.Context, task *models.Task, errMsg string, fixCount int) (err error) {
log.Printf("代码存在问题,需要修复: %s,当前修复次数:%d", errMsg, fixCount)
if fixCount >= s.config.MaxFixAttempts {
return fmt.Errorf("修复失败,已达到最大尝试次数: %v", fixCount)
}
fix, err := task.CallLLM.Do(ctx, prompts.FixPrompt(task.Resp, errMsg, task.SdkName, task.Refine))
if err != nil {
return fmt.Errorf("调用大模型失败: %v", err)
}
if len(task.Valid) > 0 && task.Valid != "OK" {
task.Valid = fix
} else {
task.Resp = fix
}
if err = s.creatFile(ctx, task); err != nil {
// 后处理失败不中断流程
return fmt.Errorf("创建文件失败: %v", err)
}
if err = postprocess.Process(ctx, task.SdkPackage); err != nil {
err = s.fix(ctx, task, err.Error(), fixCount+1)
}
return err
}
func (s *SDKGeneratorService) creatFile(ctx context.Context, task *models.Task) error {
// 3. 提取代码
extract := task.Resp
if task.Valid != "" {
extract = task.Valid
}
files, err := extractor.Extract(extract)
if err != nil {
return fmt.Errorf("提取代码失败: %v", err)
}
if len(files) == 0 {
return fmt.Errorf("未提取到任何代码文件")
}
files = append(files,
extractor.File{
Path: filepath.Join(task.SdkName, "doc.md"),
Content: task.MarkdownContent,
},
extractor.File{
Path: filepath.Join(task.SdkName, "refine.md"),
Content: task.Refine,
},
extractor.File{
Path: filepath.Join(task.SdkName, "generate.md"),
Content: task.Resp,
},
extractor.File{
Path: filepath.Join(task.SdkName, "valid.md"),
Content: task.Valid,
},
)
// 3. 写入文件
if err = extractor.WriteFiles(files, task.OutputDir); err != nil {
return fmt.Errorf("写入文件失败: %v", err)
}
task.Files = files
return nil
}
func (s *SDKGeneratorService) failTask(task *models.Task, errMsg string) {
task.Status = models.StatusFailed
task.Error = errMsg
task.UpdatedAt = time.Now()
s.updateTask(task)
}
func (s *SDKGeneratorService) processTaskStatus(task *models.Task, process models.TaskStatus) {
task.Status = process
task.UpdatedAt = time.Now()
s.tasksMu.Lock()
defer s.tasksMu.Unlock()
s.tasks[task.ID] = task
}
func (s *SDKGeneratorService) updateTask(task *models.Task) {
s.tasksMu.Lock()
defer s.tasksMu.Unlock()
s.tasks[task.ID] = task
}
// GetTask 获取任务状态
func (s *SDKGeneratorService) GetTask(taskID string) (*models.Task, bool) {
s.tasksMu.RLock()
defer s.tasksMu.RUnlock()
task, ok := s.tasks[taskID]
return task, ok
}
// ListTasks 列出所有任务
func (s *SDKGeneratorService) ListTasks() []*models.Task {
s.tasksMu.RLock()
defer s.tasksMu.RUnlock()
tasks := make([]*models.Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks
}
// DeleteTask 删除任务
func (s *SDKGeneratorService) DeleteTask(taskID string) error {
s.tasksMu.Lock()
defer s.tasksMu.Unlock()
task, ok := s.tasks[taskID]
if !ok {
return fmt.Errorf("任务不存在")
}
// 删除输出目录
if err := os.RemoveAll(task.OutputDir); err != nil {
return fmt.Errorf("删除输出目录失败: %w", err)
}
delete(s.tasks, taskID)
return nil
}
// GetTaskOutputDir 获取任务输出目录
func (s *SDKGeneratorService) GetTaskOutputDir(taskID string) string {
return filepath.Join(s.config.OutputDir, taskID)
}

3
internal/test/md.go Normal file

File diff suppressed because one or more lines are too long

3
internal/test/resp.go Normal file

File diff suppressed because one or more lines are too long

92
main.go Normal file
View File

@ -0,0 +1,92 @@
package main
import (
"log"
"sdk-generator/internal/config"
"sdk-generator/internal/handler"
"sdk-generator/internal/push"
"sdk-generator/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/template/html/v2"
)
func main() {
// 加载配置
cfg := config.Load()
// 创建服务
sdkService := service.NewSDKGeneratorService(cfg)
// 创建 Handler
sdkHandler := handler.NewSDKHandler(sdkService)
pageHandler := handler.NewPageHandler()
// 配置模板引擎
engine := html.New("./web/templates", ".html")
engine.Reload(true) // 开发时热重载
// 创建 Fiber 应用
app := fiber.New(fiber.Config{
AppName: "SDK Generator API",
Views: engine,
Prefork: false,
ServerHeader: "Fiber",
BodyLimit: 10 * 1024 * 1024, // 10MB
})
// 全局中间件
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${method} ${path} ${latency}\n",
}))
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Content-Type,Authorization",
}))
// 健康检查
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"app": "SDK Generator API",
})
})
// 静态文件
app.Static("/static", "./web/static")
// 页面路由
app.Get("/", pageHandler.Index)
// API 路由
api := app.Group("/api/v1")
// 上传文档生成 SDK
api.Post("/generate", sdkHandler.GenerateSDK)
// 查询生成任务状态
api.Get("/tasks/:task_id", sdkHandler.GetTaskStatus)
// 下载生成的 SDK
api.Get("/tasks/:task_id/download", sdkHandler.DownloadSDK)
// 列出所有任务
api.Get("/tasks", sdkHandler.ListTasks)
// 删除任务
api.Delete("/tasks/:task_id", sdkHandler.DeleteTask)
//初始化代码仓库客户端
_, err := push.NewGiteaClient(cfg.GiteaUrl, cfg.GiteaToken)
if err != nil {
log.Fatalf("Failed to initialize Gitea client: %v", err)
}
// 启动服务请访问以下链接获取接口“生成”的接口定义信息https://api.apifox.cn/temp-links/api/488609283?t=cb680e83-e054-41e5-a167-715db384e1d1
if err := app.Listen(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

747
test.md Normal file
View File

@ -0,0 +1,747 @@
# 识别内容(本地文件)
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/convert:
post:
summary: 识别内容(本地文件)
deprecated: false
description: ''
tags:
- any2md
parameters: []
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
example: file://C:\Users\Administrator\Desktop\sucai\bl.png
type: string
format: binary
llm_model:
example: doubao-seed-2-0-mini-260428
type: string
llm_api_key:
example: '******'
type: string
llm_base_url:
example: https://ark.cn-beijing.volces.com/api/v3
type: string
llm_prompt:
example: 这个图片合法吗
type: string
required:
- file
example:
file: file://C:\Users\Administrator\Desktop\sucai\bl.png
llm_model: doubao-seed-2-0-mini-260428
llm_api_key: '******'
llm_base_url: https://ark.cn-beijing.volces.com/api/v3
llm_prompt: 这个图片合法吗
required: true
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
filename:
type: string
llm_used:
type: boolean
markdown:
type: string
size:
type: integer
success:
type: boolean
title:
type: 'null'
required:
- filename
- llm_used
- markdown
- size
- success
- title
example:
filename: 蓝色兄弟营销开放API V3.docx
llm_used: false
markdown: >-
# **蓝色兄弟营销开放API V3**
## 接入流程
1. **注册开发应用账号**:联系平台商务或技术支持,提交接入申请。
2. **获取应用接入信息**:创建应用成功后,可获取应用 ID、应用私钥配置要求、平台公钥及业务加密 Key。
3. **阅读接口文档**:仔细阅读本文档中的接口说明,确认请求加密、签名、响应解密方式。
4. **联调测试**:在正式使用前,完成单发、查询、作废、批量、异常场景验证。
### 环境配置
测试环境地址https://gateway.dev.cdlsxd.cn
正式环境地址https://market.api.86698.cn
### 测试参数
# 客户应用id
app\_id: "xxx"
# 应用客户私钥,用于请求签名
private\_key: "xxx"
# 应用平台公钥,用于平台响应或回调验签
public\_key: "xxx"
# 业务参数加密key
key: "xxxx"
# 活动编号
activity\_no: "xxxx"
# 签名类型
sign\_type: "RSA"
## 概述
### V3 接入说明
V3 版本统一采用 ciphertext 传输业务报文,默认接入方式与现有开放 API 保持一致。
### 业务参数
将业务参数组装为 JSON 明文 plaintext
再使用应用 key 加密,得到 ciphertext
### 签名规则
拼接签名字符串分配给开发者的应用ID + 发送请求的时间 + ciphertext
使用应用私钥生成签名字符串 sign
### 请求模式
V3 统一采用 Header 鉴权模式:
* Header 传Appid、Timestamp、Sign
* Body 传ciphertext
### 响应说明
V3 统一采用严格密文响应模式,业务响应数据通过 ciphertext 返回。
### 时间戳规则
时间格式yyyy-MM-dd HH:mm:ss
请求时间与服务端时间误差不能超过 3 分钟
### SDK
开发者可参考以下 SDK
// Go
https://gitee.com/chengdu\_blue\_brothers/ymt-openapi-go-sdk.git
// Java
https://codeup.aliyun.com/lsxd/marketing/ymt-openapi-java-sdk.git
### 公共 Header 请求参数
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | **示例值** |
| Appid | string | 分配给开发者的应用 ID | 123456 |
| Timestamp | string | 发送请求的时间,格式 yyyy-MM-dd HH:mm:ss |
2026-06-22 15:30:00 |
| Sign | string | 请求签名串 | 详见 SDK 示例 |
| Content-Type | string | 请求数据格式 | application/json |
#### 公共 Header 请求参数示例
{
"Sign": ["签名字符串"],
"Appid": ["应用ID"],
"Timestamp": ["请求时间"],
"Content-Type": ["application/json"]
}
### 公共请求参数
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | **示例值** |
| ciphertext | string | 请求业务参数加密串 | 详见 SDK 示例 |
#### 公共请求参数示例
{
"ciphertext": "加密后的业务报文"
}
### 公共响应参数
| | | |
| --- | --- | --- |
| **字段名称** | **类型** | **描述** |
| code | int32 | 200 成功 |
| message | string | 请求描述 |
| reason | string | 错误原因,错误时返回 |
| data.ciphertext | string | 业务响应加密串 |
### 公共错误码
| | | | |
| --- | --- | --- | --- |
| **code状态码** | **reason** | **错误原因** | **解决** |
| 500 | PANIC/其它 | 系统错误 | 联系平台处理 |
| 400 | INVALID\_PAYLOAD | 请求外壳格式错误 | 请检查请求 JSON 结构 |
| 400 | MISSING\_PARAM | 缺少必要参数 | 请检查
app\_id、timestamp、sign、ciphertext |
| 400 | INVALID\_TIMESTAMP | 时间格式错误 | 请检查时间格式 |
| 400 | DECRYPT\_FAILED | 业务参数解密失败 | 请检查加密方式与密钥 |
| 401 | APP\_NOT\_FOUND | 应用不存在 | 请检查应用 ID |
| 401 | INVALID\_SIGNATURE | 签名错误 | 请检查签名串与私钥 |
| 401 | EXPIRED\_TIMESTAMP | 请求已过期 | 请检查客户端时间 |
| 429 | DUPLICATE\_REQUEST | 重复请求,请稍后重试 | 请避免短时间内重复提交同一业务号 |
### 业务错误码[汇总]
备注:若出现其它未知状态码,请联系平台技术人员。
| | | | |
| --- | --- | --- | --- |
| **code状态码** | **reason** | **错误原因** | **解决** |
| 401 | ACTIVITY\_NOT\_AUTH | 活动未授权 | 请检查活动授权状态 |
| 401 | MERCHANT\_NOT\_EXIST | 客户不存在 | 请检查客户是否存在 |
| 401 | MERCHANT\_NOT\_AUTH | 客户冻结 | 请检查客户授权状态 |
| 401 | MERCHANT\_APP\_INCOMPLETE | 客户应用配置未完善 | 请检查客户应用配置 |
| 401 | MERCHANT\_APP\_NOT\_AUTH | 应用不存在或未授权 | 请检查客户应用授权状态 |
| 403 | ACTIVITY\_EXPIRE | 活动已结束 | 请检查活动是否有效 |
| 403 | ACTIVITY\_OUT\_OF\_STOCK | 活动剩余量不足 | 请检查活动库存 |
| 404 | ACTIVITY\_NOT\_EXIST | 活动不存在 | 请检查活动编号 |
| 404 | MERCHANT\_ORDER\_NOT\_EXIST | 订单不存在 | 请检查交易号或外部业务号 |
| 404 | KEY\_NOT\_EXIST | key码不存在 | 请检查订单信息 |
| 400 | PARAM\_FAIL | 参数错误 | 请检查业务参数 |
| 400 | PARAM\_DECRYPT\_FAIL | 明文参数格式错误 | 请检查密文解密后的业务报文 |
### 获取券码
* 请求方式POST
* Content-Typeapplication/json
* 请求路由:/openapi/v3/key/order
#### 业务请求参数
| | | | | |
| --- | --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |
| out\_biz\_no | string | 外部业务号,幂等 | M | 123456 |
| activity\_no | string | 活动编号 | M | ACT20260622001 |
| account | string | 账号,按活动类型透传 | N | 18666666666 |
| notify\_url | string | 回调通知地址 | N |
https://notify.example.com/openapi |
#### 业务响应参数(解密后)
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | 是否必填 | **描述** |
| out\_biz\_no | string | M | 外部业务号 |
| trade\_no | string | M | 交易号 |
| key | string | N | 卡密 |
| url | string | N | 链接型活动返回短链接key/url 不会同时为空 |
| valid\_begin\_time | string | N | 生效时间 |
| valid\_end\_time | string | N | 失效时间 |
| usable\_num | uint32 | M | 可用次数 |
| usage\_num | uint32 | M | 已使用次数 |
| status | uint32 | M | 状态1 正常2 已核销3 已作废 |
| settlement\_price | float | N | 结算价 |
| account | string | N | 上报账号 |
#### 请求示例
明文业务参数:
{
"out\_biz\_no": "order\_001",
"activity\_no": "ACT20260622001",
"account": "18666666666",
"notify\_url": "https://notify.example.com/openapi"
}
请求体示例:
{
"ciphertext": "加密后的业务报文"
}
#### 响应示例
异常
{
"code": 401,
"message": "Signature verification failed",
"reason": "INVALID\_SIGNATURE"
}
成功
{
"code": 200,
"data": {
"ciphertext": "加密后的响应报文"
},
"message": "成功"
}
解密后示例
{
"out\_biz\_no": "order\_001",
"trade\_no": "7251449503000383488",
"key": "aZKdU9BymzR6qGRzJM",
"url": "",
"valid\_begin\_time": "2026-06-22 15:30:00",
"valid\_end\_time": "2026-12-31 23:59:59",
"usable\_num": 1,
"usage\_num": 0,
"status": 1,
"settlement\_price": 9.9,
"account": "18666666666"
}
### 券码查询
* 请求方式POST
* Content-Typeapplication/json
* 请求路由:/openapi/v3/key/query
#### 业务请求参数
| | | | | |
| --- | --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |
| out\_biz\_no | string | 外部业务号,与 trade\_no 二选一 | N |
order\_001 |
| trade\_no | string | 交易号,与 out\_biz\_no 二选一 | N |
7251449503000383488 |
#### 业务响应参数
与“获取券码”响应参数一致。
#### 响应示例
{
"code": 200,
"data": {
"out\_biz\_no": "order\_001",
"trade\_no": "7251449503000383488",
"key": "aZKdU9BymzR6qGRzJM",
"url": "",
"valid\_begin\_time": "2026-06-22 15:30:00",
"valid\_end\_time": "2026-12-31 23:59:59",
"usable\_num": 1,
"usage\_num": 0,
"status": 1,
"settlement\_price": 9.9,
"account": "18666666666",
"ciphertext": ""
},
"message": "成功"
}
### 券码作废
* 请求方式POST
* Content-Typeapplication/json
* 请求路由:/openapi/v3/key/discard
#### 业务请求参数
| | | | | |
| --- | --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |
| out\_biz\_no | string | 外部业务号,与 trade\_no 二选一 | N |
order\_001 |
| trade\_no | string | 交易号,与 out\_biz\_no 二选一 | N |
7251449503000383488 |
#### 业务响应参数
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | 是否必填 | **描述** |
| out\_biz\_no | string | M | 外部业务号 |
| trade\_no | string | M | 交易号 |
| status | uint32 | M | 3 表示已作废 |
#### 响应示例
{
"code": 200,
"data": {
"out\_biz\_no": "order\_001",
"trade\_no": "7251449503000383488",
"status": 3,
"ciphertext": ""
},
"message": "成功"
}
### 批量发卡
* 请求方式POST
* Content-Typeapplication/json
* 请求路由:/openapi/v3/key/batch\_order
#### 业务请求参数
| | | | | |
| --- | --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |
| out\_biz\_no | string | 外部业务号,幂等 | M | batch\_001 |
| activity\_no | string | 活动编号 | M | ACT20260622001 |
| number | int32 | 发卡数量 | M | 100 |
| notify\_url | string | 回调通知地址 | N |
https://notify.example.com/openapi |
#### 业务响应参数
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | 是否必填 | **描述** |
| out\_biz\_no | string | M | 外部业务号 |
| trade\_no | string | M | 交易号 |
| status | string | M | 任务状态,初始返回 processing |
#### 响应示例
{
"code": 200,
"data": {
"ciphertext": "加密后的响应报文"
},
"message": "成功"
}
解密后示例:
{
"out\_biz\_no": "batch\_001",
"trade\_no": "7251449503000383499",
"status": "processing"
}
### 批量查询
* 请求方式POST
* Content-Typeapplication/json
* 请求路由:/openapi/v3/key/batch\_query
#### 业务请求参数
| | | | | |
| --- | --- | --- | --- | --- |
| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |
| out\_biz\_no | string | 外部业务号,与 trade\_no 二选一 | N |
batch\_001 |
| trade\_no | string | 交易号,与 out\_biz\_no 二选一 | N |
7251449503000383499 |
#### 业务响应参数
| | | | |
| --- | --- | --- | --- |
| **字段名称** | **类型** | 是否必填 | **描述** |
| out\_biz\_no | string | M | 外部业务号 |
| trade\_no | string | M | 交易号 |
| status | string | M | processing / success / failed |
| download\_url | string | N | 批量任务成功后返回下载地址 |
| zip\_password | string | N | 批量任务成功后返回压缩包密码 |
#### 响应示例
{
"code": 200,
"data": {
"ciphertext": "加密后的响应报文"
},
"message": "成功"
}
解密后示例:
{
"out\_biz\_no": "batch\_001",
"trade\_no": "7251449503000383499",
"status": "success",
"download\_url": "https://oss.example.com/openapi\\_7251449503000383499.zip",
"zip\_password": "123456"
}
### 高级能力说明
如客户有特殊签名套件、加密套件适配需求,可通过商户应用 GatewayConfig 做高级配置。
默认接入无需关注该项;如需启用,请联系平台技术人员对接。
### 联调建议
1. 先验证单发、查询、作废三个基础接口。
2. 再验证批量发卡与批量查询。
3. 重点验证响应密文解密结果是否正确。
4. 批量发卡需验证 processing -> success/failed 状态流转。
5. 批量成功后需验证 download\_url、zip\_password 是否可用。
size: 12582
success: true
title: null
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: any2md
x-apifox-status: developing
x-run-in-apifox: https://app.apifox.com/web/project/8591432/apis/api-488221832-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers:
- url: 127.0.0.1:5002
description: generator_api
security: []
```

373
web/static/css/style.css Normal file
View File

@ -0,0 +1,373 @@
/* ===== 全局重置 ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #1a1a2e;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 40px 20px;
}
.container {
max-width: 820px;
width: 100%;
background: #ffffff;
border-radius: 24px;
padding: 40px 48px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.08);
}
/* ===== 头部 ===== */
.header {
text-align: center;
margin-bottom: 32px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: #6b7280;
font-size: 15px;
margin-top: 4px;
}
/* ===== 上传区域 ===== */
.upload-section {
margin-bottom: 24px;
}
.upload-zone {
border: 2px dashed #d1d5db;
border-radius: 16px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.25s ease;
background: #fafbfc;
}
.upload-zone:hover {
border-color: #8b5cf6;
background: #f5f3ff;
}
.upload-zone.dragover {
border-color: #8b5cf6;
background: #ede9fe;
}
.upload-icon {
font-size: 48px;
margin-bottom: 12px;
}
.upload-text {
font-size: 16px;
font-weight: 500;
color: #1f2937;
}
.upload-hint {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
#fileInput {
display: none;
}
.file-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f0fdf4;
border-radius: 12px;
border: 1px solid #86efac;
margin-top: 12px;
}
.file-name {
font-weight: 500;
color: #166534;
flex: 1;
}
.file-size {
color: #6b7280;
font-size: 13px;
}
.btn-remove {
background: none;
border: none;
color: #ef4444;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
}
/* ===== 配置区域 ===== */
.config-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 24px;
margin-bottom: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
.form-group .required {
color: #ef4444;
}
.form-group input,
.form-group select {
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
transition: border 0.2s;
background: #fafbfc;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}
.form-group input:invalid {
border-color: #fca5a5;
}
.form-group small {
font-size: 12px;
color: #9ca3af;
}
/* ===== 操作按钮 ===== */
.action-section {
text-align: center;
margin-bottom: 24px;
}
.btn-generate {
padding: 14px 48px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-generate:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
}
.btn-generate:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-spinner {
display: inline-block;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== 进度区域 ===== */
.progress-section {
margin-bottom: 24px;
padding: 20px 0;
}
.progress-bar {
height: 6px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.4s ease;
}
.progress-status {
text-align: center;
font-size: 14px;
color: #6b7280;
margin-top: 10px;
}
.progress-logs {
margin-top: 12px;
background: #1f2937;
border-radius: 10px;
padding: 12px 16px;
max-height: 180px;
overflow-y: auto;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
color: #d1d5db;
}
.progress-logs .log-line {
padding: 2px 0;
opacity: 0.8;
}
.progress-logs .log-line.info { color: #60a5fa; }
.progress-logs .log-line.success { color: #34d399; }
.progress-logs .log-line.warning { color: #fbbf24; }
.progress-logs .log-line.error { color: #f87171; }
/* ===== 结果区域 ===== */
.result-section {
padding: 20px 0;
border-top: 1px solid #e5e7eb;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.result-icon {
font-size: 24px;
}
.result-title {
font-size: 18px;
font-weight: 600;
}
.result-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.result-files .file-tag {
background: #f3f4f6;
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
color: #374151;
}
.result-actions {
display: flex;
gap: 12px;
}
.result-actions button {
padding: 10px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-download {
background: #6366f1;
color: #fff;
}
.btn-download:hover {
background: #4f46e5;
transform: translateY(-1px);
}
.btn-view {
background: #f3f4f6;
color: #374151;
}
.btn-view:hover {
background: #e5e7eb;
}
.result-error {
margin-top: 12px;
padding: 12px 16px;
background: #fef2f2;
border-radius: 10px;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ===== 响应式 ===== */
@media (max-width: 600px) {
.container {
padding: 24px 16px;
}
.config-section {
grid-template-columns: 1fr;
}
.upload-zone {
padding: 32px 16px;
}
.btn-generate {
width: 100%;
justify-content: center;
}
}
/* 查看代码仓库按钮 */
.btn-view-repo {
background: #10b981;
color: #fff;
}
.btn-view-repo:hover {
background: #059669;
transform: translateY(-1px);
}

496
web/static/js/app.js Normal file
View File

@ -0,0 +1,496 @@
// ============================================================
// 状态管理
// ============================================================
const state = {
file: null,
taskId: null,
status: 'idle',
pollInterval: null,
currentStatusName: '',
repoURL: '',
dotCount: 0,
dotInterval: null,
progressSimInterval: null,
};
// ============================================================
// DOM 引用
// ============================================================
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const removeFile = document.getElementById('removeFile');
const sdkName = document.getElementById('sdkName');
const llmModel = document.getElementById('llmModel');
const apiKey = document.getElementById('apiKey');
const baseUrl = document.getElementById('baseUrl');
const generateBtn = document.getElementById('generateBtn');
const btnText = document.querySelector('.btn-text');
const btnSpinner = document.querySelector('.btn-spinner');
const progressSection = document.getElementById('progressSection');
const progressFill = document.getElementById('progressFill');
const progressStatus = document.getElementById('progressStatus');
const progressLogs = document.getElementById('progressLogs');
const resultSection = document.getElementById('resultSection');
const resultFiles = document.getElementById('resultFiles');
const resultError = document.getElementById('resultError');
const downloadBtn = document.getElementById('downloadBtn');
const viewRepoBtn = document.getElementById('viewRepoBtn');
// ============================================================
// 动态效果函数
// ============================================================
// 1. 进度条闪烁动画
function addProgressGlow() {
const existingStyle = document.getElementById('progressGlowStyle');
if (existingStyle) return;
const style = document.createElement('style');
style.id = 'progressGlowStyle';
style.textContent = `
.progress-fill.glow {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #6366f1);
background-size: 200% 100%;
animation: glowMove 1.5s ease-in-out infinite;
position: relative;
}
.progress-fill.glow::after {
content: '';
position: absolute;
right: -4px;
top: -4px;
width: 14px;
height: 14px;
background: #8b5cf6;
border-radius: 50%;
box-shadow: 0 0 20px 8px rgba(139, 92, 246, 0.6);
animation: pulse 1s ease-in-out infinite;
}
@keyframes glowMove {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.6; }
}
`;
document.head.appendChild(style);
}
// 2. 状态文字动态点点点
function startDotAnimation() {
if (state.dotInterval) {
clearInterval(state.dotInterval);
}
let dotCount = 0;
state.dotInterval = setInterval(() => {
dotCount = (dotCount + 1) % 4;
const dots = '.'.repeat(dotCount);
if (state.status === 'generating' || state.status === 'pending') {
const currentText = progressStatus.textContent.replace(/\.\.\.$/, '').trim();
if (!currentText.includes('✅') && !currentText.includes('❌')) {
progressStatus.textContent = currentText + dots;
}
}
}, 500);
}
function stopDotAnimation() {
if (state.dotInterval) {
clearInterval(state.dotInterval);
state.dotInterval = null;
}
}
// 3. 进度条模拟前进
function startProgressSimulation() {
if (state.progressSimInterval) {
clearInterval(state.progressSimInterval);
}
let lastPercent = parseInt(progressFill.style.width) || 0;
state.progressSimInterval = setInterval(() => {
if (state.status === 'generating' && lastPercent < 95) {
const increment = 0.5 + Math.random() * 1.5;
lastPercent = Math.min(lastPercent + increment, 95);
progressFill.style.width = lastPercent + '%';
}
}, 2000);
}
function stopProgressSimulation() {
if (state.progressSimInterval) {
clearInterval(state.progressSimInterval);
state.progressSimInterval = null;
}
}
// ============================================================
// 日志管理(只让最新的一条闪烁)
// ============================================================
let logCounter = 0;
function addLog(type, message) {
const line = document.createElement('div');
line.className = `log-line ${type}`;
line.textContent = message;
progressLogs.appendChild(line);
progressLogs.scrollTop = progressLogs.scrollHeight;
// 移除之前所有日志的闪烁效果
document.querySelectorAll('.log-line .log-dot-blink').forEach(el => {
el.classList.remove('log-dot-blink');
});
// 为最新日志添加闪烁点(在行首添加)
const dotSpan = document.createElement('span');
dotSpan.className = 'log-dot-blink';
dotSpan.textContent = '● ';
dotSpan.style.cssText = `
color: #60a5fa;
animation: logBlink 1s ease-in-out infinite;
`;
line.prepend(dotSpan);
// 确保动画样式存在
const styleId = 'logBlinkStyle';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@keyframes logBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.2; transform: scale(0.8); }
}
`;
document.head.appendChild(style);
}
}
function clearLogs() {
progressLogs.innerHTML = '';
logCounter = 0;
}
// ============================================================
// 上传区域事件
// ============================================================
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
removeFile.addEventListener('click', () => {
state.file = null;
fileInfo.style.display = 'none';
uploadZone.style.display = 'block';
generateBtn.disabled = true;
fileInput.value = '';
});
// ============================================================
// 文件处理
// ============================================================
function handleFile(file) {
const validTypes = ['.md', '.txt', '.doc', '.docx', '.pdf'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!validTypes.includes(ext)) {
alert('不支持的文件类型,请上传 .md .txt .doc .docx .pdf');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('文件大小超过 50MB 限制');
return;
}
state.file = file;
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
fileInfo.style.display = 'flex';
uploadZone.style.display = 'none';
generateBtn.disabled = false;
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ============================================================
// 生成按钮
// ============================================================
generateBtn.addEventListener('click', generateSDK);
async function generateSDK() {
if (!state.file) return;
const sdkNameVal = sdkName.value.trim();
const modelVal = llmModel.value.trim();
const apiKeyVal = apiKey.value.trim();
const baseUrlVal = baseUrl.value.trim();
if (!sdkNameVal) {
alert('请输入 SDK 名称');
sdkName.focus();
return;
}
if (!modelVal) {
alert('请输入大模型名称');
llmModel.focus();
return;
}
if (!apiKeyVal) {
alert('请输入 API Key');
apiKey.focus();
return;
}
if (!baseUrlVal) {
alert('请输入 Base URL');
baseUrl.focus();
return;
}
// 重置状态
state.status = 'generating';
state.taskId = null;
state.currentStatusName = '';
state.repoURL = '';
resultSection.style.display = 'none';
resultError.style.display = 'none';
resultFiles.innerHTML = '';
progressSection.style.display = 'block';
progressFill.style.width = '0%';
progressStatus.textContent = '准备中...';
clearLogs();
generateBtn.disabled = true;
btnText.textContent = '生成中';
btnSpinner.style.display = 'inline-block';
viewRepoBtn.style.display = 'none';
// 启动动态效果
addProgressGlow();
progressFill.classList.add('glow');
startDotAnimation();
startProgressSimulation();
const formData = new FormData();
formData.append('file', state.file);
formData.append('sdk_name', sdkNameVal);
formData.append('llm_model', modelVal);
formData.append('llm_api_key', apiKeyVal);
formData.append('llm_base_url', baseUrlVal);
try {
const response = await fetch('/api/v1/generate', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '生成失败');
}
state.taskId = data.task_id;
addLog('info', '📤 任务已提交: ' + data.task_id);
pollTaskStatus();
} catch (err) {
showError(err.message);
}
}
// ============================================================
// 轮询任务状态
// ============================================================
function pollTaskStatus() {
if (state.pollInterval) {
clearInterval(state.pollInterval);
}
state.pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/v1/tasks/${state.taskId}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '查询状态失败');
}
updateProgress(data);
if (data.status === 'generateComplete' || data.status === 'completed') {
clearInterval(state.pollInterval);
state.pollInterval = null;
onComplete(data);
} else if (data.status === 'failed') {
clearInterval(state.pollInterval);
state.pollInterval = null;
showError(data.error || '生成失败');
}
} catch (err) {
clearInterval(state.pollInterval);
state.pollInterval = null;
showError(err.message);
}
}, 2000);
}
function updateProgress(data) {
const percent = data.percent || 0;
progressFill.style.width = percent + '%';
const statusName = data.status_name || '';
if (statusName && statusName !== state.currentStatusName) {
state.currentStatusName = statusName;
addLog('info', '🔄 ' + statusName);
}
// ✅ 更新状态文字(带闪烁前缀)
const statusTextMap = {
'pending': '⏳ 等待中',
'generateCode': '🔄 生成代码',
'validateCode': '🔍 验证代码',
'generateComplete': '✅ 完成!',
'completed': '✅ 完成!',
'failed': '❌ 失败',
};
const baseText = statusTextMap[data.status] || (statusName || data.status);
// ✅ 只有进行中的状态才让文字闪烁
const isProcessing = data.status !== 'generateComplete' && data.status !== 'completed' && data.status !== 'failed';
if (isProcessing) {
progressStatus.innerHTML = `<span class="status-blink">●</span> ${baseText}`;
} else {
progressStatus.textContent = baseText;
stopDotAnimation();
}
// ✅ 确保闪烁样式存在
const styleId = 'statusBlinkStyle';
if (!document.getElementById(styleId) && isProcessing) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.status-blink {
color: #60a5fa;
display: inline-block;
animation: statusBlink 1s ease-in-out infinite;
margin-right: 6px;
}
@keyframes statusBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.2; transform: scale(0.8); }
}
`;
document.head.appendChild(style);
}
}
// ============================================================
// 完成处理
// ============================================================
function onComplete(data) {
stopDotAnimation();
stopProgressSimulation();
progressFill.classList.remove('glow');
generateBtn.disabled = false;
btnText.textContent = '生成 SDK';
btnSpinner.style.display = 'none';
resultSection.style.display = 'block';
resultFiles.innerHTML = '';
const viewBtn = document.getElementById('viewBtn');
if (viewBtn) {
viewBtn.style.display = 'none';
}
downloadBtn.onclick = () => {
window.location.href = `/api/v1/tasks/${state.taskId}/download`;
};
const repoURL = data.RepoURL || data.repo_url || '';
if (repoURL) {
viewRepoBtn.style.display = 'inline-block';
viewRepoBtn.onclick = () => {
window.open(repoURL, '_blank');
};
} else {
viewRepoBtn.style.display = 'none';
}
// ✅ 完成状态不带闪烁
progressStatus.textContent = '✅ 生成完成!';
progressFill.style.width = '100%';
addLog('success', '🎉 SDK 生成完成!');
}
// ============================================================
// 错误处理
// ============================================================
function showError(message) {
stopDotAnimation();
stopProgressSimulation();
progressFill.classList.remove('glow');
state.status = 'error';
generateBtn.disabled = false;
btnText.textContent = '生成 SDK';
btnSpinner.style.display = 'none';
resultSection.style.display = 'block';
resultError.style.display = 'block';
resultError.textContent = '❌ ' + message;
viewRepoBtn.style.display = 'none';
// ✅ 错误状态不带闪烁
progressStatus.textContent = '❌ 失败';
progressFill.style.width = '100%';
progressFill.style.background = '#f87171';
if (state.pollInterval) {
clearInterval(state.pollInterval);
state.pollInterval = null;
}
}

94
web/templates/index.html Normal file
View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SDK 生成器</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="container">
<!-- 头部 -->
<header class="header">
<h1>🚀 SDK 生成器</h1>
<p class="subtitle">上传 API 文档,自动生成 Go SDK</p>
</header>
<!-- 上传区域 -->
<section class="upload-section">
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">📄</div>
<p class="upload-text">拖拽文档到此处,或点击上传</p>
<p class="upload-hint">支持 .md .txt .doc .docx .pdf</p>
<input type="file" id="fileInput" accept=".md,.txt,.doc,.docx,.pdf" />
</div>
<div class="file-info" id="fileInfo" style="display:none;">
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
<button class="btn-remove" id="removeFile"></button>
</div>
</section>
<!-- 配置区域 -->
<section class="config-section">
<div class="form-group">
<label for="sdkName">SDK 名称 <span class="required">*</span></label>
<input type="text" id="sdkName" placeholder="例如marketing-sdk" value="my-sdk" required />
<small>将作为 Go 模块名和包名</small>
</div>
<div class="form-group">
<label for="llmModel">大模型名称 <span class="required">*</span></label>
<input type="text" id="llmModel" placeholder="例如doubao-seed-evolving" value="doubao-seed-evolving" required />
<small>请输入完整的大模型标识符</small>
</div>
<div class="form-group">
<label for="apiKey">API Key <span class="required">*</span></label>
<input type="password" id="apiKey" placeholder="请输入 API Key" required />
</div>
<div class="form-group">
<label for="baseUrl">Base URL <span class="required">*</span></label>
<input type="text" id="baseUrl" placeholder="https://ark.cn-beijing.volces.com/api/v3" value="https://ark.cn-beijing.volces.com/api/v3" required />
</div>
</section>
<!-- 操作按钮 -->
<section class="action-section">
<button class="btn-generate" id="generateBtn" disabled>
<span class="btn-text">生成 SDK</span>
<span class="btn-spinner" style="display:none;"></span>
</button>
</section>
<!-- 进度区域 -->
<section class="progress-section" id="progressSection" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%;"></div>
</div>
<div class="progress-status" id="progressStatus">准备中...</div>
<div class="progress-logs" id="progressLogs"></div>
</section>
<!-- 结果区域 -->
<section class="result-section" id="resultSection" style="display:none;">
<div class="result-header">
<span class="result-icon"></span>
<span class="result-title">生成完成!</span>
</div>
<!-- 文件列表隐藏 -->
<div class="result-files" id="resultFiles" style="display:none;"></div>
<div class="result-actions">
<button class="btn-download" id="downloadBtn">📥 下载 SDK</button>
<button class="btn-view-repo" id="viewRepoBtn" style="display:none;">📂 查看代码仓库</button>
</div>
<div class="result-error" id="resultError" style="display:none;"></div>
</section>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>