From 5e4b42493d2fdb6ec24ed9463852cfced3bfa95a Mon Sep 17 00:00:00 2001 From: renzhiyuan <465386466@qq.com> Date: Tue, 21 Jul 2026 15:34:01 +0800 Subject: [PATCH] 1 --- .gitignore | 2 + Dockerfile | 71 +++ go.mod | 31 ++ go.sum | 69 +++ internal/call/callLLM.go | 50 ++ internal/config/config.go | 54 ++ internal/extractor/extractor.go | 151 ++++++ internal/handler/file_content.go | 102 ++++ internal/handler/page_handler.go | 16 + internal/handler/sdk.go | 295 +++++++++++ internal/models/task.go | 104 ++++ internal/msg/dingBot.go | 46 ++ internal/msg/msg_test.go | 17 + internal/postprocess/postprocess.go | 56 +++ internal/prompts/fix.go | 58 +++ internal/prompts/generate.go | 145 ++++++ internal/prompts/refine.go | 70 +++ internal/prompts/valid.go | 73 +++ internal/push/gitea.go | 348 +++++++++++++ internal/service/generator.go | 409 +++++++++++++++ internal/test/md.go | 3 + internal/test/resp.go | 3 + main.go | 92 ++++ test.md | 747 ++++++++++++++++++++++++++++ web/static/css/style.css | 373 ++++++++++++++ web/static/js/app.js | 496 ++++++++++++++++++ web/templates/index.html | 94 ++++ 27 files changed, 3975 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/call/callLLM.go create mode 100644 internal/config/config.go create mode 100644 internal/extractor/extractor.go create mode 100644 internal/handler/file_content.go create mode 100644 internal/handler/page_handler.go create mode 100644 internal/handler/sdk.go create mode 100644 internal/models/task.go create mode 100644 internal/msg/dingBot.go create mode 100644 internal/msg/msg_test.go create mode 100644 internal/postprocess/postprocess.go create mode 100644 internal/prompts/fix.go create mode 100644 internal/prompts/generate.go create mode 100644 internal/prompts/refine.go create mode 100644 internal/prompts/valid.go create mode 100644 internal/push/gitea.go create mode 100644 internal/service/generator.go create mode 100644 internal/test/md.go create mode 100644 internal/test/resp.go create mode 100644 main.go create mode 100644 test.md create mode 100644 web/static/css/style.css create mode 100644 web/static/js/app.js create mode 100644 web/templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7987cbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +/outputs diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d526c1d --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d9838f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40873e3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/call/callLLM.go b/internal/call/callLLM.go new file mode 100644 index 0000000..827a8b9 --- /dev/null +++ b/internal/call/callLLM.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0555f70 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/extractor/extractor.go b/internal/extractor/extractor.go new file mode 100644 index 0000000..c126a00 --- /dev/null +++ b/internal/extractor/extractor.go @@ -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 +} diff --git a/internal/handler/file_content.go b/internal/handler/file_content.go new file mode 100644 index 0000000..bda73d8 --- /dev/null +++ b/internal/handler/file_content.go @@ -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 +} diff --git a/internal/handler/page_handler.go b/internal/handler/page_handler.go new file mode 100644 index 0000000..81b9af7 --- /dev/null +++ b/internal/handler/page_handler.go @@ -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") +} diff --git a/internal/handler/sdk.go b/internal/handler/sdk.go new file mode 100644 index 0000000..3b86bf7 --- /dev/null +++ b/internal/handler/sdk.go @@ -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 +} diff --git a/internal/models/task.go b/internal/models/task.go new file mode 100644 index 0000000..3231d25 --- /dev/null +++ b/internal/models/task.go @@ -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"` +} diff --git a/internal/msg/dingBot.go b/internal/msg/dingBot.go new file mode 100644 index 0000000..312e67a --- /dev/null +++ b/internal/msg/dingBot.go @@ -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 +} diff --git a/internal/msg/msg_test.go b/internal/msg/msg_test.go new file mode 100644 index 0000000..7a5cfd3 --- /dev/null +++ b/internal/msg/msg_test.go @@ -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("所有测试完成") +} diff --git a/internal/postprocess/postprocess.go b/internal/postprocess/postprocess.go new file mode 100644 index 0000000..fa6ed12 --- /dev/null +++ b/internal/postprocess/postprocess.go @@ -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 +} diff --git a/internal/prompts/fix.go b/internal/prompts/fix.go new file mode 100644 index 0000000..9315624 --- /dev/null +++ b/internal/prompts/fix.go @@ -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. 请直接输出修复后的完整代码,不要有任何额外文字 + +请返回修复后的完整代码。` +} diff --git a/internal/prompts/generate.go b/internal/prompts/generate.go new file mode 100644 index 0000000..415d47d --- /dev/null +++ b/internal/prompts/generate.go @@ -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. 支持 WithBaseURL、WithTimeout、WithHTTPClient 等配置 +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/*" 包 +- 使用成熟的开源库(如 gmsm、jwt 等) +- 如果文档要求特殊算法,先查找是否有现成的 Go 实现 +- 只在标准库和成熟库都不支持时才考虑自己实现 + +## ⚠️ 单元测试规范(必须严格遵守) +1. 所有单元测试必须写在 example_test.go 文件中 +2. 必须使用标准库 testing 包 +3. **每个 API 接口必须对应一个独立的单元测试函数,格式必须为:** + +## 关键要求 +1. 生成的代码必须可直接编译运行 +2. go的版本为1.21 +3. **go.mod 的 module 名使用 {{sdk_name}}** +4. 不要引用任何不存在的远程 GitHub 仓库 + +## 接口文档 +%s` diff --git a/internal/prompts/refine.go b/internal/prompts/refine.go new file mode 100644 index 0000000..e6202e2 --- /dev/null +++ b/internal/prompts/refine.go @@ -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 +} diff --git a/internal/prompts/valid.go b/internal/prompts/valid.go new file mode 100644 index 0000000..ef1f25e --- /dev/null +++ b/internal/prompts/valid.go @@ -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. 不要加任何描述性注释 +` +} diff --git a/internal/push/gitea.go b/internal/push/gitea.go new file mode 100644 index 0000000..e078ada --- /dev/null +++ b/internal/push/gitea.go @@ -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) + } +} diff --git a/internal/service/generator.go b/internal/service/generator.go new file mode 100644 index 0000000..adaa567 --- /dev/null +++ b/internal/service/generator.go @@ -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) +} diff --git a/internal/test/md.go b/internal/test/md.go new file mode 100644 index 0000000..f714ccb --- /dev/null +++ b/internal/test/md.go @@ -0,0 +1,3 @@ +package test + +const TestMd = `# **蓝色兄弟营销开放API V3**\n\n## 接入流程\n\n1. **注册开发应用账号**:联系平台商务或技术支持,提交接入申请。\n2. **获取应用接入信息**:创建应用成功后,可获取应用 ID、应用私钥配置要求、平台公钥及业务加密 Key。\n3. **阅读接口文档**:仔细阅读本文档中的接口说明,确认请求加密、签名、响应解密方式。\n4. **联调测试**:在正式使用前,完成单发、查询、作废、批量、异常场景验证。\n\n### 环境配置\n\n测试环境地址:https://gateway.dev.cdlsxd.cn\n\n正式环境地址:https://market.api.86698.cn\n\n### 测试参数\n\n# 客户应用id\napp\\_id: \"xxx\"\n# 应用客户私钥,用于请求签名\nprivate\\_key: \"xxx\"\n# 应用平台公钥,用于平台响应或回调验签\npublic\\_key: \"xxx\"\n# 业务参数加密key\nkey: \"xxxx\"\n# 活动编号\nactivity\\_no: \"xxxx\"\n# 签名类型\nsign\\_type: \"RSA\"\n\n## 概述\n\n### v1 接入说明\n\nv1 版本统一采用 ciphertext 传输业务报文,默认接入方式与现有开放 API 保持一致。\n\n### 业务参数\n\n将业务参数去掉“零”值的参数再由小到大按照字母排序再转成json字符串得到plaintext\n再使用应用key将plaintext字符串加密[aes(ECB模式)/sm4(CBC模式)]得到加密业务参数ciphertext\n\n### 签名规则\n\n拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + 加密业务参数\n使用私钥将拼接待签名字符串生成签名字符串\n\n### 回调验签\n\n获取header头里面的签名信息\n获取body里面的业务参数data\n将业务参数data去掉“零”值的参数再由小到大按照字母排序再转成json字符串得到plaintext\n再使用应用key将plaintext字符串加密[aes/sm4]得到加密得到ciphertext\n拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + ciphertext\n使用应用公钥验签\n\n备注:相关业务参数加密规则,验签demo等请联系平台技术人员\n\n### 请求模式\n\nv1 统一采用 Header 鉴权模式:\n\n* Header 传:Appid、Timestamp、Sign\n* Body 传:ciphertext\n\n### 响应说明\n\nv1 统一采用严格密文响应模式,业务响应数据通过 ciphertext 返回。\n\n### 时间戳规则\n\n时间格式:yyyy-MM-dd HH:mm:ss\n请求时间与服务端时间误差不能超过 3 分钟\n\n### SDK\n\n开发者可参考以下 SDK:\n\n// Go\nhttps://gitee.com/chengdu\\_blue\\_brothers/ymt-openapi-go-sdk.git\n// Java\nhttps://codeup.aliyun.com/lsxd/marketing/ymt-openapi-java-sdk.git\n\n### 公共 Header 请求参数\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | **示例值** |\n| Appid | string | 分配给开发者的应用 ID | 123456 |\n| Timestamp | string | 发送请求的时间,格式 yyyy-MM-dd HH:mm:ss | 2026-06-22 15:30:00 |\n| Sign | string | 请求签名串 | 详见 SDK 示例 |\n| Content-Type | string | 请求数据格式 | application/json |\n\n#### 公共 Header 请求参数示例\n\n{\n \"Sign\": [\"签名字符串\"],\n \"Appid\": [\"应用ID\"],\n \"Timestamp\": [\"请求时间\"],\n \"Content-Type\": [\"application/json\"]\n}\n\n### 公共请求参数\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | **示例值** |\n| ciphertext | string | 请求业务参数加密串 | 详见 SDK 示例 |\n\n#### 公共请求参数示例\n\n{\n \"ciphertext\": \"加密后的业务报文\"\n}\n\n### 公共响应参数\n\n| | | |\n| --- | --- | --- |\n| **字段名称** | **类型** | **描述** |\n| code | int32 | 200 成功 |\n| message | string | 请求描述 |\n| reason | string | 错误原因,错误时返回 |\n| data.ciphertext | string | 业务响应加密串 |\n\n### 公共错误码\n\n| | | | |\n| --- | --- | --- | --- |\n| **code状态码** | **reason** | **错误原因** | **解决** |\n| 500 | PANIC/其它 | 系统错误 | 联系平台处理 |\n| 400 | INVALID\\_PAYLOAD | 请求外壳格式错误 | 请检查请求 JSON 结构 |\n| 400 | MISSING\\_PARAM | 缺少必要参数 | 请检查 app\\_id、timestamp、sign、ciphertext |\n| 400 | INVALID\\_TIMESTAMP | 时间格式错误 | 请检查时间格式 |\n| 400 | DECRYPT\\_FAILED | 业务参数解密失败 | 请检查加密方式与密钥 |\n| 401 | APP\\_NOT\\_FOUND | 应用不存在 | 请检查应用 ID |\n| 401 | INVALID\\_SIGNATURE | 签名错误 | 请检查签名串与私钥 |\n| 401 | EXPIRED\\_TIMESTAMP | 请求已过期 | 请检查客户端时间 |\n| 429 | DUPLICATE\\_REQUEST | 重复请求,请稍后重试 | 请避免短时间内重复提交同一业务号 |\n\n### 业务错误码[汇总]\n\n备注:若出现其它未知状态码,请联系平台技术人员。\n\n| | | | |\n| --- | --- | --- | --- |\n| **code状态码** | **reason** | **错误原因** | **解决** |\n| 401 | ACTIVITY\\_NOT\\_AUTH | 活动未授权 | 请检查活动授权状态 |\n| 401 | MERCHANT\\_NOT\\_EXIST | 客户不存在 | 请检查客户是否存在 |\n| 401 | MERCHANT\\_NOT\\_AUTH | 客户冻结 | 请检查客户授权状态 |\n| 401 | MERCHANT\\_APP\\_INCOMPLETE | 客户应用配置未完善 | 请检查客户应用配置 |\n| 401 | MERCHANT\\_APP\\_NOT\\_AUTH | 应用不存在或未授权 | 请检查客户应用授权状态 |\n| 403 | ACTIVITY\\_EXPIRE | 活动已结束 | 请检查活动是否有效 |\n| 403 | ACTIVITY\\_OUT\\_OF\\_STOCK | 活动剩余量不足 | 请检查活动库存 |\n| 404 | ACTIVITY\\_NOT\\_EXIST | 活动不存在 | 请检查活动编号 |\n| 404 | MERCHANT\\_ORDER\\_NOT\\_EXIST | 订单不存在 | 请检查交易号或外部业务号 |\n| 404 | KEY\\_NOT\\_EXIST | key码不存在 | 请检查订单信息 |\n| 400 | PARAM\\_FAIL | 参数错误 | 请检查业务参数 |\n| 400 | PARAM\\_DECRYPT\\_FAIL | 明文参数格式错误 | 请检查密文解密后的业务报文 |\n\n### 获取券码\n\n* 请求方式:POST\n* Content-Type:application/json\n* 请求路由:/openapi/v1/key/order\n\n#### 业务请求参数\n\n| | | | | |\n| --- | --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |\n| out\\_biz\\_no | string | 外部业务号,幂等 | M | 123456 |\n| activity\\_no | string | 活动编号 | M | ACT20260622001 |\n| account | string | 账号,按活动类型透传 | N | 18666666666 |\n| notify\\_url | string | 回调通知地址 | N | https://notify.example.com/openapi |\n\n#### 业务响应参数(解密后)\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | 是否必填 | **描述** |\n| out\\_biz\\_no | string | M | 外部业务号 |\n| trade\\_no | string | M | 交易号 |\n| key | string | N | 卡密 |\n| url | string | N | 链接型活动返回短链接,key/url 不会同时为空 |\n| valid\\_begin\\_time | string | N | 生效时间 |\n| valid\\_end\\_time | string | N | 失效时间 |\n| usable\\_num | uint32 | M | 可用次数 |\n| usage\\_num | uint32 | M | 已使用次数 |\n| status | uint32 | M | 状态:1 正常,2 已核销,3 已作废 |\n| settlement\\_price | float | N | 结算价 |\n| account | string | N | 上报账号 |\n\n#### 请求示例\n\n明文业务参数:\n\n{\n \"out\\_biz\\_no\": \"order\\_001\",\n \"activity\\_no\": \"ACT20260622001\",\n \"account\": \"18666666666\",\n \"notify\\_url\": \"https://notify.example.com/openapi\"\n}\n\n请求体示例:\n\n{\n \"ciphertext\": \"加密后的业务报文\"\n}\n\n#### 响应示例\n\n异常\n\n{\n \"code\": 401,\n \"message\": \"Signature verification failed\",\n \"reason\": \"INVALID\\_SIGNATURE\"\n}\n\n成功\n\n{\n \"code\": 200,\n \"data\": {\n \"ciphertext\": \"加密后的响应报文\"\n },\n \"message\": \"成功\"\n}\n\n解密后示例\n\n{\n \"out\\_biz\\_no\": \"order\\_001\",\n \"trade\\_no\": \"7251449503000383488\",\n \"key\": \"aZKdU9BymzR6qGRzJM\",\n \"url\": \"\",\n \"valid\\_begin\\_time\": \"2026-06-22 15:30:00\",\n \"valid\\_end\\_time\": \"2026-12-31 23:59:59\",\n \"usable\\_num\": 1,\n \"usage\\_num\": 0,\n \"status\": 1,\n \"settlement\\_price\": 9.9,\n \"account\": \"18666666666\"\n}\n\n### 券码查询\n\n* 请求方式:POST\n* Content-Type:application/json\n* 请求路由:/openapi/v1/key/query\n\n#### 业务请求参数\n\n| | | | | |\n| --- | --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |\n| out\\_biz\\_no | string | 外部业务号,与 trade\\_no 二选一 | N | order\\_001 |\n| trade\\_no | string | 交易号,与 out\\_biz\\_no 二选一 | N | 7251449503000383488 |\n\n#### 业务响应参数\n\n与“获取券码”响应参数一致。\n\n#### 响应示例\n\n{\n \"code\": 200,\n \"data\": {\n \"out\\_biz\\_no\": \"order\\_001\",\n \"trade\\_no\": \"7251449503000383488\",\n \"key\": \"aZKdU9BymzR6qGRzJM\",\n \"url\": \"\",\n \"valid\\_begin\\_time\": \"2026-06-22 15:30:00\",\n \"valid\\_end\\_time\": \"2026-12-31 23:59:59\",\n \"usable\\_num\": 1,\n \"usage\\_num\": 0,\n \"status\": 1,\n \"settlement\\_price\": 9.9,\n \"account\": \"18666666666\",\n \"ciphertext\": \"\"\n },\n \"message\": \"成功\"\n}\n\n### 券码作废\n\n* 请求方式:POST\n* Content-Type:application/json\n* 请求路由:/openapi/v1/key/discard\n\n#### 业务请求参数\n\n| | | | | |\n| --- | --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |\n| out\\_biz\\_no | string | 外部业务号,与 trade\\_no 二选一 | N | order\\_001 |\n| trade\\_no | string | 交易号,与 out\\_biz\\_no 二选一 | N | 7251449503000383488 |\n\n#### 业务响应参数\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | 是否必填 | **描述** |\n| out\\_biz\\_no | string | M | 外部业务号 |\n| trade\\_no | string | M | 交易号 |\n| status | uint32 | M | 3 表示已作废 |\n\n#### 响应示例\n\n{\n \"code\": 200,\n \"data\": {\n \"out\\_biz\\_no\": \"order\\_001\",\n \"trade\\_no\": \"7251449503000383488\",\n \"status\": 3,\n \"ciphertext\": \"\"\n },\n \"message\": \"成功\"\n}\n\n### 批量发卡\n\n* 请求方式:POST\n* Content-Type:application/json\n* 请求路由:/openapi/v1/key/batch\\_order\n\n#### 业务请求参数\n\n| | | | | |\n| --- | --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |\n| out\\_biz\\_no | string | 外部业务号,幂等 | M | batch\\_001 |\n| activity\\_no | string | 活动编号 | M | ACT20260622001 |\n| number | int32 | 发卡数量 | M | 100 |\n| notify\\_url | string | 回调通知地址 | N | https://notify.example.com/openapi |\n\n#### 业务响应参数\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | 是否必填 | **描述** |\n| out\\_biz\\_no | string | M | 外部业务号 |\n| trade\\_no | string | M | 交易号 |\n| status | string | M | 任务状态,初始返回 processing |\n\n#### 响应示例\n\n{\n \"code\": 200,\n \"data\": {\n \"ciphertext\": \"加密后的响应报文\"\n },\n \"message\": \"成功\"\n}\n\n解密后示例:\n\n{\n \"out\\_biz\\_no\": \"batch\\_001\",\n \"trade\\_no\": \"7251449503000383499\",\n \"status\": \"processing\"\n}\n\n### 批量查询\n\n* 请求方式:POST\n* Content-Type:application/json\n* 请求路由:/openapi/v1/key/batch\\_query\n\n#### 业务请求参数\n\n| | | | | |\n| --- | --- | --- | --- | --- |\n| **字段名称** | **类型** | **描述** | 是否必填 | **示例值** |\n| out\\_biz\\_no | string | 外部业务号,与 trade\\_no 二选一 | N | batch\\_001 |\n| trade\\_no | string | 交易号,与 out\\_biz\\_no 二选一 | N | 7251449503000383499 |\n\n#### 业务响应参数\n\n| | | | |\n| --- | --- | --- | --- |\n| **字段名称** | **类型** | 是否必填 | **描述** |\n| out\\_biz\\_no | string | M | 外部业务号 |\n| trade\\_no | string | M | 交易号 |\n| status | string | M | processing / success / failed |\n| download\\_url | string | N | 批量任务成功后返回下载地址 |\n| zip\\_password | string | N | 批量任务成功后返回压缩包密码 |\n\n#### 响应示例\n\n{\n \"code\": 200,\n \"data\": {\n \"ciphertext\": \"加密后的响应报文\"\n },\n \"message\": \"成功\"\n}\n\n解密后示例:\n\n{\n \"out\\_biz\\_no\": \"batch\\_001\",\n \"trade\\_no\": \"7251449503000383499\",\n \"status\": \"success\",\n \"download\\_url\": \"https://oss.example.com/openapi\\\\_7251449503000383499.zip\",\n \"zip\\_password\": \"123456\"\n}\n\n### 高级能力说明\n\n如客户有特殊签名套件、加密套件适配需求,可通过商户应用 GatewayConfig 做高级配置。\n\n默认接入无需关注该项;如需启用,请联系平台技术人员对接。\n\n### 联调建议\n\n1. 先验证单发、查询、作废三个基础接口。\n2. 再验证批量发卡与批量查询。\n3. 重点验证响应密文解密结果是否正确。\n4. 批量发卡需验证 processing -> success/failed 状态流转。\n5. 批量成功后需验证 download\\_url、zip\\_password 是否可用。` diff --git a/internal/test/resp.go b/internal/test/resp.go new file mode 100644 index 0000000..468eb6c --- /dev/null +++ b/internal/test/resp.go @@ -0,0 +1,3 @@ +package test + +const Res = "// File: ymt_sdk_v3/go.mod\n```go\nmodule ymt_sdk_v3\n\ngo 1.21\n\nrequire (\n\tgithub.com/tjfoc/gmsm v1.4.2\n)\n```\n\n// File: ymt_sdk_v3/types.go\n```go\npackage ymt_sdk_v3\n\nimport \"time\"\n\n// Config SDK 配置\ntype Config struct {\n\tAppID string // 应用ID\n\tPrivateKey string // 应用私钥\n\tPublicKey string // 平台公钥\n\tKey string // 业务参数加密key\n\tSignType string // 签名类型,默认 RSA\n\tBaseURL string // API 基础地址\n\tTimeout time.Duration\n}\n\n// ClientOption 客户端配置选项\ntype ClientOption func(*Client)\n\n// CommonRequest 公共请求体\ntype CommonRequest struct {\n\tCiphertext string `json:\"ciphertext\"`\n}\n\n// CommonResponse 公共响应体\ntype CommonResponse struct {\n\tCode int32 `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tReason string `json:\"reason,omitempty\"`\n\tData *RespData `json:\"data,omitempty\"`\n}\n\n// RespData 响应数据\ntype RespData struct {\n\tCiphertext string `json:\"ciphertext,omitempty\"`\n}\n\n// OrderKeyRequest 获取券码业务请求参数\ntype OrderKeyRequest struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号,幂等\n\tActivityNo string `json:\"activity_no\"` // 活动编号\n\tAccount string `json:\"account,omitempty\"` // 账号,按活动类型透传\n\tNotifyURL string `json:\"notify_url,omitempty\"` // 回调通知地址\n}\n\n// KeyInfo 券码信息(获取券码、查询券码响应)\ntype KeyInfo struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号\n\tTradeNo string `json:\"trade_no\"` // 交易号\n\tKey string `json:\"key,omitempty\"` // 卡密\n\tURL string `json:\"url,omitempty\"` // 链接型活动返回短链接\n\tValidBeginTime string `json:\"valid_begin_time,omitempty\"` // 生效时间\n\tValidEndTime string `json:\"valid_end_time,omitempty\"` // 失效时间\n\tUsableNum uint32 `json:\"usable_num\"` // 可用次数\n\tUsageNum uint32 `json:\"usage_num\"` // 已使用次数\n\tStatus uint32 `json:\"status\"` // 状态:1 正常,2 已核销,3 已作废\n\tSettlementPrice float64 `json:\"settlement_price,omitempty\"` // 结算价\n\tAccount string `json:\"account,omitempty\"` // 上报账号\n}\n\n// QueryKeyRequest 券码查询业务请求参数\ntype QueryKeyRequest struct {\n\tOutBizNo string `json:\"out_biz_no,omitempty\"` // 外部业务号,与 trade_no 二选一\n\tTradeNo string `json:\"trade_no,omitempty\"` // 交易号,与 out_biz_no 二选一\n}\n\n// DiscardKeyRequest 券码作废业务请求参数\ntype DiscardKeyRequest struct {\n\tOutBizNo string `json:\"out_biz_no,omitempty\"` // 外部业务号,与 trade_no 二选一\n\tTradeNo string `json:\"trade_no,omitempty\"` // 交易号,与 out_biz_no 二选一\n}\n\n// DiscardKeyResponse 券码作废业务响应参数\ntype DiscardKeyResponse struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号\n\tTradeNo string `json:\"trade_no\"` // 交易号\n\tStatus uint32 `json:\"status\"` // 3 表示已作废\n}\n\n// BatchOrderKeyRequest 批量发卡业务请求参数\ntype BatchOrderKeyRequest struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号,幂等\n\tActivityNo string `json:\"activity_no\"` // 活动编号\n\tNumber int32 `json:\"number\"` // 发卡数量\n\tNotifyURL string `json:\"notify_url,omitempty\"` // 回调通知地址\n}\n\n// BatchOrderKeyResponse 批量发卡业务响应参数\ntype BatchOrderKeyResponse struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号\n\tTradeNo string `json:\"trade_no\"` // 交易号\n\tStatus string `json:\"status\"` // 任务状态,初始返回 processing\n}\n\n// BatchQueryKeyRequest 批量查询业务请求参数\ntype BatchQueryKeyRequest struct {\n\tOutBizNo string `json:\"out_biz_no,omitempty\"` // 外部业务号,与 trade_no 二选一\n\tTradeNo string `json:\"trade_no,omitempty\"` // 交易号,与 out_biz_no 二选一\n}\n\n// BatchQueryKeyResponse 批量查询业务响应参数\ntype BatchQueryKeyResponse struct {\n\tOutBizNo string `json:\"out_biz_no\"` // 外部业务号\n\tTradeNo string `json:\"trade_no\"` // 交易号\n\tStatus string `json:\"status\"` // processing / success / failed\n\tDownloadURL string `json:\"download_url,omitempty\"` // 批量任务成功后返回下载地址\n\tZipPassword string `json:\"zip_password,omitempty\"` // 批量任务成功后返回压缩包密码\n}\n\n// API 路由常量\nconst (\n\tpathOrderKey = \"/openapi/v1/key/order\"\n\tpathQueryKey = \"/openapi/v1/key/query\"\n\tpathDiscardKey = \"/openapi/v1/key/discard\"\n\tpathBatchOrder = \"/openapi/v1/key/batch_order\"\n\tpathBatchQuery = \"/openapi/v1/key/batch_query\"\n)\n\n// 环境地址常量\nconst (\n\tDevBaseURL = \"https://gateway.dev.cdlsxd.cn\"\n\tProdBaseURL = \"https://market.api.86698.cn\"\n)\n\n// 时间格式\nconst TimeFormat = \"2006-01-02 15:04:05\"\n```\n\n// File: ymt_sdk_v3/crypto.go\n```go\npackage ymt_sdk_v3\n\nimport (\n\t\"bytes\"\n\t\"crypto\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/sha256\"\n\t\"crypto/x509\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"encoding/pem\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// removeZeroValues 移除 map 中的零值字段\nfunc removeZeroValues(data map[string]interface{}) map[string]interface{} {\n\tresult := make(map[string]interface{})\n\tfor k, v := range data {\n\t\tif isZeroValue(v) {\n\t\t\tcontinue\n\t\t}\n\t\tresult[k] = v\n\t}\n\treturn result\n}\n\n// isZeroValue 判断是否为零值\nfunc isZeroValue(v interface{}) bool {\n\tif v == nil {\n\t\treturn true\n\t}\n\tswitch val := v.(type) {\n\tcase string:\n\t\treturn val == \"\"\n\tcase int:\n\t\treturn val == 0\n\tcase int32:\n\t\treturn val == 0\n\tcase int64:\n\t\treturn val == 0\n\tcase uint:\n\t\treturn val == 0\n\tcase uint32:\n\t\treturn val == 0\n\tcase uint64:\n\t\treturn val == 0\n\tcase float32:\n\t\treturn val == 0\n\tcase float64:\n\t\treturn val == 0\n\tcase bool:\n\t\treturn !val\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// sortedKeys 获取排序后的 key 列表\nfunc sortedKeys(m map[string]interface{}) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\treturn keys\n}\n\n// marshalSorted 按 key 排序序列化 JSON\nfunc marshalSorted(data interface{}) (string, error) {\n\t// 先序列化为 JSON,再反序列化为 map\n\tjsonBytes, err := json.Marshal(data)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal data failed: %w\", err)\n\t}\n\n\tvar m map[string]interface{}\n\tif err := json.Unmarshal(jsonBytes, &m); err != nil {\n\t\treturn \"\", fmt.Errorf(\"unmarshal to map failed: %w\", err)\n\t}\n\n\t// 移除零值\n\tm = removeZeroValues(m)\n\n\t// 按 key 排序\n\tkeys := sortedKeys(m)\n\n\t// 构建有序的 JSON 字符串\n\tvar buf bytes.Buffer\n\tbuf.WriteByte('{')\n\tfor i, k := range keys {\n\t\tif i > 0 {\n\t\t\tbuf.WriteByte(',')\n\t\t}\n\t\t// 序列化 key\n\t\tkeyBytes, _ := json.Marshal(k)\n\t\tbuf.Write(keyBytes)\n\t\tbuf.WriteByte(':')\n\t\t// 序列化 value\n\t\tvalBytes, err := json.Marshal(m[k])\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"marshal value failed: %w\", err)\n\t\t}\n\t\tbuf.Write(valBytes)\n\t}\n\tbuf.WriteByte('}')\n\n\treturn buf.String(), nil\n}\n\n// pkcs7Pad PKCS7 填充\nfunc pkcs7Pad(data []byte, blockSize int) []byte {\n\tpadding := blockSize - len(data)%blockSize\n\tpadText := bytes.Repeat([]byte{byte(padding)}, padding)\n\treturn append(data, padText...)\n}\n\n// pkcs7Unpad PKCS7 去填充\nfunc pkcs7Unpad(data []byte) ([]byte, error) {\n\tlength := len(data)\n\tif length == 0 {\n\t\treturn nil, errors.New(\"pkcs7: invalid padding size\")\n\t}\n\tpadding := int(data[length-1])\n\tif padding > length {\n\t\treturn nil, errors.New(\"pkcs7: invalid padding size\")\n\t}\n\tfor i := 0; i < padding; i++ {\n\t\tif data[length-1-i] != byte(padding) {\n\t\t\treturn nil, errors.New(\"pkcs7: invalid padding\")\n\t\t}\n\t}\n\treturn data[:length-padding], nil\n}\n\n// AesEncrypt AES-ECB 加密\nfunc AesEncrypt(plaintext, key string) (string, error) {\n\tkeyBytes := []byte(key)\n\tblock, err := aes.NewCipher(keyBytes)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"aes new cipher failed: %w\", err)\n\t}\n\n\t// ECB 模式需要填充\n\tpadded := pkcs7Pad([]byte(plaintext), block.BlockSize())\n\n\t// ECB 加密\n\tciphertext := make([]byte, len(padded))\n\tfor i := 0; i < len(padded); i += block.BlockSize() {\n\t\tblock.Encrypt(ciphertext[i:i+block.BlockSize()], padded[i:i+block.BlockSize()])\n\t}\n\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// AesDecrypt AES-ECB 解密\nfunc AesDecrypt(ciphertext, key string) (string, error) {\n\tkeyBytes := []byte(key)\n\tcipherBytes, err := base64.StdEncoding.DecodeString(ciphertext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"base64 decode failed: %w\", err)\n\t}\n\n\tblock, err := aes.NewCipher(keyBytes)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"aes new cipher failed: %w\", err)\n\t}\n\n\tif len(cipherBytes)%block.BlockSize() != 0 {\n\t\treturn \"\", errors.New(\"ciphertext is not a multiple of block size\")\n\t}\n\n\t// ECB 解密\n\tplaintext := make([]byte, len(cipherBytes))\n\tfor i := 0; i < len(cipherBytes); i += block.BlockSize() {\n\t\tblock.Decrypt(plaintext[i:i+block.BlockSize()], cipherBytes[i:i+block.BlockSize()])\n\t}\n\n\t// 去填充\n\tunpadded, err := pkcs7Unpad(plaintext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"pkcs7 unpad failed: %w\", err)\n\t}\n\n\treturn string(unpadded), nil\n}\n\n// parseRSAPrivateKey 解析 RSA 私钥\nfunc parseRSAPrivateKey(privateKeyStr string) (*rsa.PrivateKey, error) {\n\tprivateKeyStr = strings.TrimSpace(privateKeyStr)\n\tif !strings.Contains(privateKeyStr, \"-----BEGIN\") {\n\t\t// 尝试 base64 解码\n\t\tderBytes, err := base64.StdEncoding.DecodeString(privateKeyStr)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"decode private key base64 failed: %w\", err)\n\t\t}\n\t\tpriv, err := x509.ParsePKCS8PrivateKey(derBytes)\n\t\tif err != nil {\n\t\t\tpriv, err = x509.ParsePKCS1PrivateKey(derBytes)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"parse private key failed: %w\", err)\n\t\t\t}\n\t\t}\n\t\trsaPriv, ok := priv.(*rsa.PrivateKey)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"not an RSA private key\")\n\t\t}\n\t\treturn rsaPriv, nil\n\t}\n\n\tblock, _ := pem.Decode([]byte(privateKeyStr))\n\tif block == nil {\n\t\treturn nil, errors.New(\"failed to decode PEM block containing private key\")\n\t}\n\n\tpriv, err := x509.ParsePKCS8PrivateKey(block.Bytes)\n\tif err != nil {\n\t\tpriv, err = x509.ParsePKCS1PrivateKey(block.Bytes)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse private key failed: %w\", err)\n\t\t}\n\t}\n\n\trsaPriv, ok := priv.(*rsa.PrivateKey)\n\tif !ok {\n\t\treturn nil, errors.New(\"not an RSA private key\")\n\t}\n\treturn rsaPriv, nil\n}\n\n// parseRSAPublicKey 解析 RSA 公钥\nfunc parseRSAPublicKey(publicKeyStr string) (*rsa.PublicKey, error) {\n\tpublicKeyStr = strings.TrimSpace(publicKeyStr)\n\tif !strings.Contains(publicKeyStr, \"-----BEGIN\") {\n\t\t// 尝试 base64 解码\n\t\tderBytes, err := base64.StdEncoding.DecodeString(publicKeyStr)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"decode public key base64 failed: %w\", err)\n\t\t}\n\t\tpub, err := x509.ParsePKIXPublicKey(derBytes)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse public key failed: %w\", err)\n\t\t}\n\t\trsaPub, ok := pub.(*rsa.PublicKey)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"not an RSA public key\")\n\t\t}\n\t\treturn rsaPub, nil\n\t}\n\n\tblock, _ := pem.Decode([]byte(publicKeyStr))\n\tif block == nil {\n\t\treturn nil, errors.New(\"failed to decode PEM block containing public key\")\n\t}\n\n\tpub, err := x509.ParsePKIXPublicKey(block.Bytes)\n\tif err != nil {\n\t\t// 尝试解析 PKCS1\n\t\tpub, err = x509.ParsePKCS1PublicKey(block.Bytes)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse public key failed: %w\", err)\n\t\t}\n\t}\n\n\trsaPub, ok := pub.(*rsa.PublicKey)\n\tif !ok {\n\t\treturn nil, errors.New(\"not an RSA public key\")\n\t}\n\treturn rsaPub, nil\n}\n\n// RSASign RSA 签名\nfunc RSASign(appID, timestamp, ciphertext, privateKeyStr string) (string, error) {\n\tprivateKey, err := parseRSAPrivateKey(privateKeyStr)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"parse private key failed: %w\", err)\n\t}\n\n\t// 拼接签名字符串: appID + timestamp + ciphertext\n\tsignStr := appID + timestamp + ciphertext\n\n\t// SHA256 签名\n\thashed := sha256.Sum256([]byte(signStr))\n\tsignature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"rsa sign failed: %w\", err)\n\t}\n\n\treturn base64.StdEncoding.EncodeToString(signature), nil\n}\n\n// RSAVerify RSA 验签\nfunc RSAVerify(appID, timestamp, ciphertext, sign, publicKeyStr string) error {\n\tpublicKey, err := parseRSAPublicKey(publicKeyStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"parse public key failed: %w\", err)\n\t}\n\n\tsignBytes, err := base64.StdEncoding.DecodeString(sign)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"decode sign base64 failed: %w\", err)\n\t}\n\n\t// 拼接签名字符串\n\tsignStr := appID + timestamp + ciphertext\n\thashed := sha256.Sum256([]byte(signStr))\n\n\terr = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], signBytes)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"signature verification failed: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// EncryptBizParams 加密业务参数\nfunc EncryptBizParams(bizParams interface{}, key string) (string, error) {\n\t// 1. 去掉零值,按字母排序,转 JSON 字符串得到 plaintext\n\tplaintext, err := marshalSorted(bizParams)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal biz params failed: %w\", err)\n\t}\n\n\t// 2. 使用 key 加密得到 ciphertext\n\tciphertext, err := AesEncrypt(plaintext, key)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"encrypt biz params failed: %w\", err)\n\t}\n\n\treturn ciphertext, nil\n}\n\n// DecryptBizParams 解密业务参数\nfunc DecryptBizParams(ciphertext, key string, result interface{}) error {\n\tplaintext, err := AesDecrypt(ciphertext, key)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"decrypt biz params failed: %w\", err)\n\t}\n\n\tif err := json.Unmarshal([]byte(plaintext), result); err != nil {\n\t\treturn fmt.Errorf(\"unmarshal decrypted data failed: %w\", err)\n\t}\n\n\treturn nil\n}\n```\n\n// File: ymt_sdk_v3/errors.go\n```go\npackage ymt_sdk_v3\n\nimport \"fmt\"\n\n// APIError API 错误\ntype APIError struct {\n\tCode int32 `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tReason string `json:\"reason\"`\n}\n\n// Error 实现 error 接口\nfunc (e *APIError) Error() string {\n\tif e.Reason != \"\" {\n\t\treturn fmt.Sprintf(\"api error: code=%d, message=%s, reason=%s\", e.Code, e.Message, e.Reason)\n\t}\n\treturn fmt.Sprintf(\"api error: code=%d, message=%s\", e.Code, e.Message)\n}\n\n// NewAPIError 创建 API 错误\nfunc NewAPIError(code int32, message, reason string) *APIError {\n\treturn &APIError{\n\t\tCode: code,\n\t\tMessage: message,\n\t\tReason: reason,\n\t}\n}\n\n// 公共错误码\nvar (\n\tErrSystemError = NewAPIError(500, \"系统错误\", \"PANIC\")\n\tErrInvalidPayload = NewAPIError(400, \"请求外壳格式错误\", \"INVALID_PAYLOAD\")\n\tErrMissingParam = NewAPIError(400, \"缺少必要参数\", \"MISSING_PARAM\")\n\tErrInvalidTimestamp = NewAPIError(400, \"时间格式错误\", \"INVALID_TIMESTAMP\")\n\tErrDecryptFailed = NewAPIError(400, \"业务参数解密失败\", \"DECRYPT_FAILED\")\n\tErrAppNotFound = NewAPIError(401, \"应用不存在\", \"APP_NOT_FOUND\")\n\tErrInvalidSignature = NewAPIError(401, \"签名错误\", \"INVALID_SIGNATURE\")\n\tErrExpiredTimestamp = NewAPIError(401, \"请求已过期\", \"EXPIRED_TIMESTAMP\")\n\tErrDuplicateRequest = NewAPIError(429, \"重复请求\", \"DUPLICATE_REQUEST\")\n)\n\n// 业务错误码\nvar (\n\tErrActivityNotAuth = NewAPIError(401, \"活动未授权\", \"ACTIVITY_NOT_AUTH\")\n\tErrMerchantNotExist = NewAPIError(401, \"客户不存在\", \"MERCHANT_NOT_EXIST\")\n\tErrMerchantNotAuth = NewAPIError(401, \"客户冻结\", \"MERCHANT_NOT_AUTH\")\n\tErrMerchantAppIncomplete = NewAPIError(401, \"客户应用配置未完善\", \"MERCHANT_APP_INCOMPLETE\")\n\tErrMerchantAppNotAuth = NewAPIError(401, \"应用不存在或未授权\", \"MERCHANT_APP_NOT_AUTH\")\n\tErrActivityExpire = NewAPIError(403, \"活动已结束\", \"ACTIVITY_EXPIRE\")\n\tErrActivityOutOfStock = NewAPIError(403, \"活动剩余量不足\", \"ACTIVITY_OUT_OF_STOCK\")\n\tErrActivityNotExist = NewAPIError(404, \"活动不存在\", \"ACTIVITY_NOT_EXIST\")\n\tErrMerchantOrderNotExist = NewAPIError(404, \"订单不存在\", \"MERCHANT_ORDER_NOT_EXIST\")\n\tErrKeyNotExist = NewAPIError(404, \"key码不存在\", \"KEY_NOT_EXIST\")\n\tErrParamFail = NewAPIError(400, \"参数错误\", \"PARAM_FAIL\")\n\tErrParamDecryptFail = NewAPIError(400, \"明文参数格式错误\", \"PARAM_DECRYPT_FAIL\")\n)\n```\n\n// File: ymt_sdk_v3/client.go\n```go\npackage ymt_sdk_v3\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// Client SDK 客户端\ntype Client struct {\n\tappID string\n\tprivateKey string\n\tpublicKey string\n\tkey string\n\tsignType string\n\tbaseURL string\n\thttpClient *http.Client\n}\n\n// NewClient 创建新的 SDK 客户端\nfunc NewClient(appID, privateKey, publicKey, key string, opts ...ClientOption) *Client {\n\tc := &Client{\n\t\tappID: appID,\n\t\tprivateKey: privateKey,\n\t\tpublicKey: publicKey,\n\t\tkey: key,\n\t\tsignType: \"RSA\",\n\t\tbaseURL: DevBaseURL,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(c)\n\t}\n\n\treturn c\n}\n\n// WithBaseURL 设置基础 URL\nfunc WithBaseURL(baseURL string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.baseURL = baseURL\n\t}\n}\n\n// WithTimeout 设置超时时间\nfunc WithTimeout(timeout time.Duration) ClientOption {\n\treturn func(c *Client) {\n\t\tc.httpClient.Timeout = timeout\n\t}\n}\n\n// WithHTTPClient 设置自定义 HTTP 客户端\nfunc WithHTTPClient(httpClient *http.Client) ClientOption {\n\treturn func(c *Client) {\n\t\tc.httpClient = httpClient\n\t}\n}\n\n// WithSignType 设置签名类型\nfunc WithSignType(signType string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.signType = signType\n\t}\n}\n\n// doRequest 执行 HTTP 请求\nfunc (c *Client) doRequest(ctx context.Context, path string, bizParams interface{}) (*CommonResponse, error) {\n\t// 1. 加密业务参数\n\tciphertext, err := EncryptBizParams(bizParams, c.key)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"encrypt biz params failed: %w\", err)\n\t}\n\n\t// 2. 生成时间戳\n\ttimestamp := time.Now().Format(TimeFormat)\n\n\t// 3. 生成签名\n\tsign, err := RSASign(c.appID, timestamp, ciphertext, c.privateKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"sign failed: %w\", err)\n\t}\n\n\t// 4. 构建请求体\n\treqBody := CommonRequest{\n\t\tCiphertext: ciphertext,\n\t}\n\treqBodyBytes, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request body failed: %w\", err)\n\t}\n\n\t// 5. 构建 HTTP 请求\n\turl := c.baseURL + path\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request failed: %w\", err)\n\t}\n\n\t// 6. 设置 Header\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Appid\", c.appID)\n\treq.Header.Set(\"Timestamp\", timestamp)\n\treq.Header.Set(\"Sign\", sign)\n\n\t// 7. 发送请求\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"http request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 8. 读取响应\n\trespBodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response body failed: %w\", err)\n\t}\n\n\t// 9. 解析响应\n\tvar commonResp CommonResponse\n\tif err := json.Unmarshal(respBodyBytes, &commonResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal response failed: %w\", err)\n\t}\n\n\t// 10. 检查响应码\n\tif commonResp.Code != http.StatusOK {\n\t\treturn nil, NewAPIError(commonResp.Code, commonResp.Message, commonResp.Reason)\n\t}\n\n\treturn &commonResp, nil\n}\n\n// OrderKey 获取券码\n// 文档: POST /openapi/v1/key/order\nfunc (c *Client) OrderKey(ctx context.Context, req *OrderKeyRequest) (*KeyInfo, error) {\n\tcommonResp, err := c.doRequest(ctx, pathOrderKey, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif commonResp.Data == nil || commonResp.Data.Ciphertext == \"\" {\n\t\treturn nil, fmt.Errorf(\"response data ciphertext is empty\")\n\t}\n\n\tvar keyInfo KeyInfo\n\tif err := DecryptBizParams(commonResp.Data.Ciphertext, c.key, &keyInfo); err != nil {\n\t\treturn nil, fmt.Errorf(\"decrypt response failed: %w\", err)\n\t}\n\n\treturn &keyInfo, nil\n}\n\n// QueryKey 券码查询\n// 文档: POST /openapi/v1/key/query\nfunc (c *Client) QueryKey(ctx context.Context, req *QueryKeyRequest) (*KeyInfo, error) {\n\tcommonResp, err := c.doRequest(ctx, pathQueryKey, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif commonResp.Data == nil || commonResp.Data.Ciphertext == \"\" {\n\t\treturn nil, fmt.Errorf(\"response data ciphertext is empty\")\n\t}\n\n\tvar keyInfo KeyInfo\n\tif err := DecryptBizParams(commonResp.Data.Ciphertext, c.key, &keyInfo); err != nil {\n\t\treturn nil, fmt.Errorf(\"decrypt response failed: %w\", err)\n\t}\n\n\treturn &keyInfo, nil\n}\n\n// DiscardKey 券码作废\n// 文档: POST /openapi/v1/key/discard\nfunc (c *Client) DiscardKey(ctx context.Context, req *DiscardKeyRequest) (*DiscardKeyResponse, error) {\n\tcommonResp, err := c.doRequest(ctx, pathDiscardKey, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif commonResp.Data == nil || commonResp.Data.Ciphertext == \"\" {\n\t\treturn nil, fmt.Errorf(\"response data ciphertext is empty\")\n\t}\n\n\tvar discardResp DiscardKeyResponse\n\tif err := DecryptBizParams(commonResp.Data.Ciphertext, c.key, &discardResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"decrypt response failed: %w\", err)\n\t}\n\n\treturn &discardResp, nil\n}\n\n// BatchOrderKey 批量发卡\n// 文档: POST /openapi/v1/key/batch_order\nfunc (c *Client) BatchOrderKey(ctx context.Context, req *BatchOrderKeyRequest) (*BatchOrderKeyResponse, error) {\n\tcommonResp, err := c.doRequest(ctx, pathBatchOrder, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif commonResp.Data == nil || commonResp.Data.Ciphertext == \"\" {\n\t\treturn nil, fmt.Errorf(\"response data ciphertext is empty\")\n\t}\n\n\tvar batchResp BatchOrderKeyResponse\n\tif err := DecryptBizParams(commonResp.Data.Ciphertext, c.key, &batchResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"decrypt response failed: %w\", err)\n\t}\n\n\treturn &batchResp, nil\n}\n\n// BatchQueryKey 批量查询\n// 文档: POST /openapi/v1/key/batch_query\nfunc (c *Client) BatchQueryKey(ctx context.Context, req *BatchQueryKeyRequest) (*BatchQueryKeyResponse, error) {\n\tcommonResp, err := c.doRequest(ctx, pathBatchQuery, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif commonResp.Data == nil || commonResp.Data.Ciphertext == \"\" {\n\t\treturn nil, fmt.Errorf(\"response data ciphertext is empty\")\n\t}\n\n\tvar batchResp BatchQueryKeyResponse\n\tif err := DecryptBizParams(commonResp.Data.Ciphertext, c.key, &batchResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"decrypt response failed: %w\", err)\n\t}\n\n\treturn &batchResp, nil\n}\n```\n\n// File: ymt_sdk_v3/example_test.go\n```go\npackage ymt_sdk_v3_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\tymt \"ymt_sdk_v3\"\n)\n\n// 测试配置 - 请替换为实际测试参数\nvar (\n\ttestAppID = \"xxx\"\n\ttestPrivateKey = \"xxx\"\n\ttestPublicKey = \"xxx\"\n\ttestKey = \"xxxx\"\n\ttestActivityNo = \"xxxx\"\n)\n\n// newTestClient 创建测试客户端\nfunc newTestClient() *ymt.Client {\n\treturn ymt.NewClient(\n\t\ttestAppID,\n\t\ttestPrivateKey,\n\t\ttestPublicKey,\n\t\ttestKey,\n\t\tymt.WithBaseURL(ymt.DevBaseURL),\n\t\tymt.WithTimeout(30*time.Second),\n\t)\n}\n\n// TestNewClient 测试客户端创建\nfunc TestNewClient(t *testing.T) {\n\tclient := newTestClient()\n\tif client == nil {\n\t\tt.Fatal(\"client should not be nil\")\n\t}\n\tt.Log(\"client created successfully\")\n}\n\n// TestOrderKey 测试获取券码接口\nfunc TestOrderKey(t *testing.T) {\n\tclient := newTestClient()\n\tctx := context.Background()\n\n\treq := &ymt.OrderKeyRequest{\n\t\tOutBizNo: fmt.Sprintf(\"test_order_%d\", time.Now().Unix()),\n\t\tActivityNo: testActivityNo,\n\t\tAccount: \"18666666666\",\n\t\tNotifyURL: \"https://notify.example.com/openapi\",\n\t}\n\n\tresp, err := client.OrderKey(ctx, req)\n\tif err != nil {\n\t\tt.Logf(\"OrderKey expected error (using test credentials): %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"OrderKey success: out_biz_no=%s, trade_no=%s, key=%s, status=%d\",\n\t\tresp.OutBizNo, resp.TradeNo, resp.Key, resp.Status)\n}\n\n// TestQueryKey 测试券码查询接口\nfunc TestQueryKey(t *testing.T) {\n\tclient := newTestClient()\n\tctx := context.Background()\n\n\treq := &ymt.QueryKeyRequest{\n\t\tOutBizNo: fmt.Sprintf(\"test_order_%d\", time.Now().Unix()),\n\t}\n\n\tresp, err := client.QueryKey(ctx, req)\n\tif err != nil {\n\t\tt.Logf(\"QueryKey expected error (using test credentials): %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"QueryKey success: out_biz_no=%s, trade_no=%s, key=%s, status=%d\",\n\t\tresp.OutBizNo, resp.TradeNo, resp.Key, resp.Status)\n}\n\n// TestDiscardKey 测试券码作废接口\nfunc TestDiscardKey(t *testing.T) {\n\tclient := newTestClient()\n\tctx := context.Background()\n\n\treq := &ymt.DiscardKeyRequest{\n\t\tOutBizNo: fmt.Sprintf(\"test_order_%d\", time.Now().Unix()),\n\t}\n\n\tresp, err := client.DiscardKey(ctx, req)\n\tif err != nil {\n\t\tt.Logf(\"DiscardKey expected error (using test credentials): %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"DiscardKey success: out_biz_no=%s, trade_no=%s, status=%d\",\n\t\tresp.OutBizNo, resp.TradeNo, resp.Status)\n}\n\n// TestBatchOrderKey 测试批量发卡接口\nfunc TestBatchOrderKey(t *testing.T) {\n\tclient := newTestClient()\n\tctx := context.Background()\n\n\treq := &ymt.BatchOrderKeyRequest{\n\t\tOutBizNo: fmt.Sprintf(\"test_batch_%d\", time.Now().Unix()),\n\t\tActivityNo: testActivityNo,\n\t\tNumber: 10,\n\t\tNotifyURL: \"https://notify.example.com/openapi\",\n\t}\n\n\tresp, err := client.BatchOrderKey(ctx, req)\n\tif err != nil {\n\t\tt.Logf(\"BatchOrderKey expected error (using test credentials): %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"BatchOrderKey success: out_biz_no=%s, trade_no=%s, status=%s\",\n\t\tresp.OutBizNo, resp.TradeNo, resp.Status)\n}\n\n// TestBatchQueryKey 测试批量查询接口\nfunc TestBatchQueryKey(t *testing.T) {\n\tclient := newTestClient()\n\tctx := context.Background()\n\n\treq := &ymt.BatchQueryKeyRequest{\n\t\tOutBizNo: fmt.Sprintf(\"test_batch_%d\", time.Now().Unix()),\n\t}\n\n\tresp, err := client.BatchQueryKey(ctx, req)\n\tif err != nil {\n\t\tt.Logf(\"BatchQueryKey expected error (using test credentials): %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"BatchQueryKey success: out_biz_no=%s, trade_no=%s, status=%s, download_url=%s\",\n\t\tresp.OutBizNo, resp.TradeNo, resp.Status, resp.DownloadURL)\n}\n\n// TestAesEncryptDecrypt 测试 AES 加解密\nfunc TestAesEncryptDecrypt(t *testing.T) {\n\t// AES key 必须是 16/24/32 字节\n\tkey := \"1234567890123456\" // 16 bytes = AES-128\n\tplaintext := `{\"activity_no\":\"ACT20260622001\",\"out_biz_no\":\"order_001\"}`\n\n\tciphertext, err := ymt.AesEncrypt(plaintext, key)\n\tif err != nil {\n\t\tt.Fatalf(\"AesEncrypt failed: %v\", err)\n\t}\n\tt.Logf(\"Encrypted ciphertext: %s\", ciphertext)\n\n\tdecrypted, err := ymt.AesDecrypt(ciphertext, key)\n\tif err != nil {\n\t\tt.Fatalf(\"AesDecrypt failed: %v\", err)\n\t}\n\n\tif decrypted != plaintext {\n\t\tt.Fatalf(\"decrypted text mismatch: expected %s, got %s\", plaintext, decrypted)\n\t}\n\tt.Logf(\"Decrypted plaintext matches: %s\", decrypted)\n}\n\n// ExampleClient_OrderKey 示例: 获取券码\nfunc ExampleClient_OrderKey() {\n\t// 初始化客户端\n\tclient := ymt.NewClient(\n\t\t\"your_app_id\",\n\t\t\"your_private_key\",\n\t\t\"platform_public_key\",\n\t\t\"your_encrypt_key\",\n\t\tymt.WithBaseURL(ymt.ProdBaseURL), // 正式环境\n\t)\n\n\tctx := context.Background()\n\n\t// 构建请求\n\treq := &ymt.OrderKeyRequest{\n\t\tOutBizNo: \"order_001\",\n\t\tActivityNo: \"ACT20260622001\",\n\t\tAccount: \"18666666666\",\n\t}\n\n\t// 调用接口\n\tresp, err := client.OrderKey(ctx, req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Success: trade_no=%s, key=%s\\n\", resp.TradeNo, resp.Key)\n}\n\n// ExampleClient_QueryKey 示例: 查询券码\nfunc ExampleClient_QueryKey() {\n\tclient := ymt.NewClient(\n\t\t\"your_app_id\",\n\t\t\"your_private_key\",\n\t\t\"platform_public_key\",\n\t\t\"your_encrypt_key\",\n\t)\n\n\tctx := context.Background()\n\n\treq := &ymt.QueryKeyRequest{\n\t\tTradeNo: \"7251449503000383488\",\n\t}\n\n\tresp, err := client.QueryKey(ctx, req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Status: %d\\n\", resp.Status)\n}\n\n// ExampleClient_DiscardKey 示例: 作废券码\nfunc ExampleClient_DiscardKey() {\n\tclient := ymt.NewClient(\n\t\t\"your_app_id\",\n\t\t\"your_private_key\",\n\t\t\"platform_public_key\",\n\t\t\"your_encrypt_key\",\n\t)\n\n\tctx := context.Background()\n\n\treq := &ymt.DiscardKeyRequest{\n\t\tOutBizNo: \"order_001\",\n\t}\n\n\tresp, err := client.DiscardKey(ctx, req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Discarded: status=%d\\n\", resp.Status)\n}\n\n// ExampleClient_BatchOrderKey 示例: 批量发卡\nfunc ExampleClient_BatchOrderKey() {\n\tclient := ymt.NewClient(\n\t\t\"your_app_id\",\n\t\t\"your_private_key\",\n\t\t\"platform_public_key\",\n\t\t\"your_encrypt_key\",\n\t)\n\n\tctx := context.Background()\n\n\treq := &ymt.BatchOrderKeyRequest{\n\t\tOutBizNo: \"batch_001\",\n\t\tActivityNo: \"ACT20260622001\",\n\t\tNumber: 100,\n\t}\n\n\tresp, err := client.BatchOrderKey(ctx, req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Batch order submitted: trade_no=%s, status=%s\\n\", resp.TradeNo, resp.Status)\n}\n\n// ExampleClient_BatchQueryKey 示例: 批量查询\nfunc ExampleClient_BatchQueryKey() {\n\tclient := ymt.NewClient(\n\t\t\"your_app_id\",\n\t\t\"your_private_key\",\n\t\t\"platform_public_key\",\n\t\t\"your_encrypt_key\",\n\t)\n\n\tctx := context.Background()\n\n\treq := &ymt.BatchQueryKeyRequest{\n\t\tTradeNo: \"7251449503000383499\",\n\t}\n\n\tresp, err := client.BatchQueryKey(ctx, req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Batch status: %s\\n\", resp.Status)\n\tif resp.Status == \"success\" {\n\t\tfmt.Printf(\"Download URL: %s\\n\", resp.DownloadURL)\n\t\tfmt.Printf(\"Zip password: %s\\n\", resp.ZipPassword)\n\t}\n}\n```" diff --git a/main.go b/main.go new file mode 100644 index 0000000..b3c56eb --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/test.md b/test.md new file mode 100644 index 0000000..272b327 --- /dev/null +++ b/test.md @@ -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-Type:application/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-Type:application/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-Type:application/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-Type:application/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-Type:application/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: [] + +``` \ No newline at end of file diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..f5fc5c1 --- /dev/null +++ b/web/static/css/style.css @@ -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); +} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..b788046 --- /dev/null +++ b/web/static/js/app.js @@ -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 = `● ${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; + } +} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..3478a80 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,94 @@ + + +
+ + +上传 API 文档,自动生成 Go SDK
+拖拽文档到此处,或点击上传
+支持 .md .txt .doc .docx .pdf
+ +