diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 642eea6..b67cab4 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -24,7 +24,8 @@ func InitializeApp(configConfig *config.Config, allLogger log.AllLogger) (*serve db, cleanup := utils.NewGormDb(configConfig) aiGenerateDocImpl := impl.NewAiGenerateDocImpl(db) aiGenerateTaskImpl := impl.NewAiGenerateTaskImpl(db) - sdkGeneratorBiz := biz.NewSDKGeneratorService(configConfig, aiGenerateDocImpl, aiGenerateTaskImpl) + aiGenerateLogImpl := impl.NewAiGenerateLogImpl(db) + sdkGeneratorBiz := biz.NewSDKGeneratorService(configConfig, aiGenerateDocImpl, aiGenerateTaskImpl, aiGenerateLogImpl) sdkService := service.NewSDKService(sdkGeneratorBiz) pageService := service.NewPageService() appModule := router.NewAppModule(configConfig, sdkService, pageService) diff --git a/internal/biz/generator.go b/internal/biz/generator.go index 755aff0..38f158e 100644 --- a/internal/biz/generator.go +++ b/internal/biz/generator.go @@ -33,14 +33,16 @@ type SDKGeneratorBiz struct { config *config.Config docImpl *impl.AiGenerateDocImpl taskImpl *impl.AiGenerateTaskImpl + logImpl *impl.AiGenerateLogImpl } -func NewSDKGeneratorService(cfg *config.Config, docImpl *impl.AiGenerateDocImpl, taskImpl *impl.AiGenerateTaskImpl) *SDKGeneratorBiz { +func NewSDKGeneratorService(cfg *config.Config, docImpl *impl.AiGenerateDocImpl, taskImpl *impl.AiGenerateTaskImpl, logImpl *impl.AiGenerateLogImpl) *SDKGeneratorBiz { // 创建 OpenAI 客户端配置 return &SDKGeneratorBiz{ config: cfg, docImpl: docImpl, taskImpl: taskImpl, + logImpl: logImpl, } } @@ -184,7 +186,6 @@ func (s *SDKGeneratorBiz) processTask(ctx context.Context, task *entitys.Task) { err = s.creatFile(ctx, task) if err != nil { // 后处理失败不中断流程 - s.failTask(ctx, task, fmt.Sprintf("[creatFile]: %v", err)) return } @@ -337,7 +338,7 @@ func (s *SDKGeneratorBiz) generateCode(ctx context.Context, task *entitys.Task) prompts.WithTimeout(10*time.Minute), prompts.WithModel(task.TasKModel.LlmModel), ) - task.Generate, usage, err = sdkGen.GenerateSDK(ctx, finalPrompt.String(), task.Name, NeedImplement) + task.Generate, usage, err = sdkGen.GenerateSDK(ctx, finalPrompt.String(), task, NeedImplement, s.logImpl) case entitys.DocTypeServerBoilerplate: serverGen := prompts.NewServerGenerator( task.CallLLM.Client, @@ -352,12 +353,33 @@ func (s *SDKGeneratorBiz) generateCode(ctx context.Context, task *entitys.Task) } func (s *SDKGeneratorBiz) valid(ctx context.Context, task *entitys.Task) (usage *entitys.Usage, err error) { - log.Printf("valid(): task.CallLLM=%p, task.CallLLM.Client=%p, task.CallLLM.ApiKey prefix=%s, task.Name=%s", - task.CallLLM, task.CallLLM.Client, prefix(task.CallLLM.ApiKey), task.Name) + // ========== 判断输入大小 ========== + // 估算 token 数量:1 token ≈ 4 字符(中英文混合) + inputSize := len(task.RefinedDoc) + len(task.Generate) + estimatedTokens := inputSize / 4 + log.Printf("valid(): 输入大小: %d 字符, 估算 token: %d", inputSize, estimatedTokens) - validRes, useAge, err := task.CallLLM.Do(ctx, prompts.GetValidatePrompt(task.RefinedDoc, task.Name, task.Generate)) + // ✅ 如果输入太大(超过 10k tokens),直接跳过 + if estimatedTokens > 10000 { + log.Printf("⚠️ 输入过大(%d tokens),跳过验证,继续流程", estimatedTokens) + task.Valid = task.Generate + return &entitys.Usage{StateName: entitys.StatusValid.Desc()}, nil + } + log.Printf("valid(): 步骤1 - 开始检查代码完整性") + + // ========== 步骤1:检查 ========== + req := prompts.GetValidatePrompt(task.RefinedDoc, task.Name, task.Generate) + + validCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + + resp, useAge, err := task.CallLLM.Do(validCtx, req) if err != nil { - log.Printf("valid(): error calling LLM: %v", err) + if errors.Is(err, context.DeadlineExceeded) { + log.Printf("valid(): 检查超时,跳过验证,继续流程") + task.Valid = task.Generate + return &entitys.Usage{StateName: entitys.StatusValid.Desc()}, nil + } return nil, fmt.Errorf("调用大模型失败: %v", err) } @@ -366,18 +388,57 @@ func (s *SDKGeneratorBiz) valid(ctx context.Context, task *entitys.Task) (usage CompletionTokens: useAge.CompletionTokens, TotalTokens: useAge.TotalTokens, } + s.logImpl.Add(ctx, &model.AiGenerateLog{ + TaskID: task.TasKModel.TaskID, + Type: entitys.StatusValid.String(), + RequestContent: pkg.JsonStringIgonErr(req.Messages), + ResponseContent: resp, + }) + cleaned := s.cleanValidationResult(resp) - // ✅ 清理返回结果 - cleaned := s.cleanValidationResult(validRes) - - // ✅ 判断是否为 OK + // 检查通过 if cleaned == "OK" { + log.Printf("✅ 验证通过,无需修复") task.Valid = task.Generate return usage, nil } - // 验证不通过,保存修复后的代码 - task.Valid = validRes + // ========== 步骤2:修复 ========== + log.Printf("⚠️ 验证发现问题,步骤2 - 开始修复:\n%s", cleaned) + + // 保存问题列表 + task.ValidIssues = cleaned + + fixReq := prompts.GetFixByIssuesPrompt(task.RefinedDoc, task.Name, task.Generate, cleaned) + + fixCtx, cancel2 := context.WithTimeout(ctx, 5*time.Minute) + defer cancel2() + + fixRes, fixUsage, err := task.CallLLM.Do(fixCtx, fixReq) + s.logImpl.Add(ctx, &model.AiGenerateLog{ + TaskID: task.TasKModel.TaskID, + Type: entitys.StatusValid.String(), + RequestContent: pkg.JsonStringIgonErr(fixReq), + ResponseContent: fixRes, + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + log.Printf("valid(): 修复超时,使用原代码继续") + task.Valid = task.Generate + return usage, nil + } + log.Printf("valid(): 修复失败: %v,使用原代码继续", err) + task.Valid = task.Generate + return usage, nil + } + + usage.PromptTokens += fixUsage.PromptTokens + usage.CompletionTokens += fixUsage.CompletionTokens + usage.TotalTokens += fixUsage.TotalTokens + + task.Valid = fixRes + log.Printf("✅ 修复完成") + return usage, nil } @@ -414,6 +475,12 @@ func (s *SDKGeneratorBiz) fix(ctx context.Context, task *entitys.Task, errMsg st return fmt.Errorf("修复失败,已达到最大尝试次数: %v", fixCount) } fix, useAge, err := task.CallLLM.Do(ctx, prompts.FixPrompt(task.Valid, errMsg, task.Name, task.RefinedDoc)) + s.logImpl.Add(ctx, &model.AiGenerateLog{ + TaskID: task.TasKModel.TaskID, + Type: entitys.StatusFix.String(), + RequestContent: errMsg, + ResponseContent: fix, + }) if err != nil { return fmt.Errorf("调用大模型失败: %v", err) } diff --git a/internal/data/impl/ai_generate_log.go b/internal/data/impl/ai_generate_log.go new file mode 100644 index 0000000..f0de534 --- /dev/null +++ b/internal/data/impl/ai_generate_log.go @@ -0,0 +1,27 @@ +package impl + +import ( + "sdk-generator/internal/data/model" + "sdk-generator/tmpl/dataTemp" + "sdk-generator/utils" +) + +type AiGenerateLogImpl struct { + dataTemp.DataTemp + db *utils.Db +} + +func NewAiGenerateLogImpl(db *utils.Db) *AiGenerateLogImpl { + return &AiGenerateLogImpl{ + DataTemp: *dataTemp.NewDataTemp(db, new(model.AiGenerateLog)), + db: db, + } +} + +func (m *AiGenerateLogImpl) PrimaryKey() string { + return "id" +} + +func (m *AiGenerateLogImpl) GetTemp() *dataTemp.DataTemp { + return &m.DataTemp +} diff --git a/internal/data/impl/provider_set.go b/internal/data/impl/provider_set.go index 534245b..1bd70b4 100644 --- a/internal/data/impl/provider_set.go +++ b/internal/data/impl/provider_set.go @@ -7,4 +7,5 @@ import ( var ProviderImpl = wire.NewSet( NewAiGenerateDocImpl, NewAiGenerateTaskImpl, + NewAiGenerateLogImpl, ) diff --git a/internal/data/model/ai_generate_log.gen.go b/internal/data/model/ai_generate_log.gen.go new file mode 100644 index 0000000..566ad51 --- /dev/null +++ b/internal/data/model/ai_generate_log.gen.go @@ -0,0 +1,27 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameAiGenerateLog = "ai_generate_log" + +// AiGenerateLog mapped from table +type AiGenerateLog struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` + TaskID string `gorm:"column:task_id;not null" json:"task_id"` + Type string `gorm:"column:type" json:"type"` + RequestContent string `gorm:"column:request_content" json:"request_content"` + ResponseContent string `gorm:"column:response_content" json:"response_content"` + ToolSelect string `gorm:"column:tool_select" json:"tool_select"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` +} + +// TableName AiGenerateLog's table name +func (*AiGenerateLog) TableName() string { + return TableNameAiGenerateLog +} diff --git a/internal/entitys/task.go b/internal/entitys/task.go index 35d5f83..277984d 100644 --- a/internal/entitys/task.go +++ b/internal/entitys/task.go @@ -103,15 +103,16 @@ func (s DocType) String() string { //} type Task struct { - TasKModel *model.AiGenerateTask - RefinedDoc string - Generate string - CallLLM *call.CallLLM - OutputDir string - Valid string - Package string - Name string - Files []extractor.File + TasKModel *model.AiGenerateTask + RefinedDoc string + Generate string + CallLLM *call.CallLLM + OutputDir string + Valid string + ValidIssues string + Package string + Name string + Files []extractor.File } type TaskResponse struct { diff --git a/internal/pkg/call/callLLM.go b/internal/pkg/call/callLLM.go index f59b1c9..19d6636 100644 --- a/internal/pkg/call/callLLM.go +++ b/internal/pkg/call/callLLM.go @@ -6,7 +6,6 @@ import ( "log" "net/http" "net/url" - "strings" "github.com/sashabaranov/go-openai" ) @@ -68,57 +67,57 @@ type loggingRoundTripper struct { func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // Log the URL details for debugging - if req != nil && req.URL != nil { - log.Printf("AuthRoundTripper: request URL before enforcement: scheme=%s host=%s path=%s full=%s", req.URL.Scheme, req.URL.Host, req.URL.Path, req.URL.String()) - } + //if req != nil && req.URL != nil { + // log.Printf("AuthRoundTripper: request URL before enforcement: scheme=%s host=%s path=%s full=%s", req.URL.Scheme, req.URL.Host, req.URL.Path, req.URL.String()) + //} // If base is set and URL has missing scheme/host, resolve against base if l.base != "" && req != nil && req.URL != nil { if req.URL.Scheme == "" || req.URL.Host == "" { if baseURL, err := url.Parse(l.base); err == nil { newURL := baseURL.ResolveReference(req.URL) - log.Printf("AuthRoundTripper: fixing request URL: from=%s to=%s", req.URL.String(), newURL.String()) + //log.Printf("AuthRoundTripper: fixing request URL: from=%s to=%s", req.URL.String(), newURL.String()) req.URL = newURL } } } // Log incoming Authorization header (for debugging) - if v := req.Header.Get("Authorization"); v != "" { - parts := strings.SplitN(v, " ", 2) - if len(parts) == 2 { - tok := parts[1] - if len(tok) > 8 { - log.Printf("AuthRoundTripper: incoming Authorization token prefix: %s", tok[:8]) - } else { - log.Printf("AuthRoundTripper: incoming Authorization token prefix: %s", tok) - } - } - } + //if v := req.Header.Get("Authorization"); v != "" { + // parts := strings.SplitN(v, " ", 2) + // if len(parts) == 2 { + // tok := parts[1] + // if len(tok) > 8 { + // log.Printf("AuthRoundTripper: incoming Authorization token prefix: %s", tok[:8]) + // } else { + // log.Printf("AuthRoundTripper: incoming Authorization token prefix: %s", tok) + // } + // } + //} // If we have an API key stored, ensure the Authorization header is set correctly // This defends against middleware/proxies that may have mutated the header if l.apiKey != "" { expectedAuth := "Bearer " + l.apiKey req.Header.Set("Authorization", expectedAuth) - log.Printf("AuthRoundTripper: enforced Authorization token prefix: %s", prefix(l.apiKey)) + //log.Printf("AuthRoundTripper: enforced Authorization token prefix: %s", prefix(l.apiKey)) } // Log outgoing Authorization header and URL details if req != nil && req.URL != nil { - log.Printf("AuthRoundTripper: request URL after enforcement: scheme=%s host=%s path=%s full=%s", req.URL.Scheme, req.URL.Host, req.URL.Path, req.URL.String()) - } - if v := req.Header.Get("Authorization"); v != "" { - parts := strings.SplitN(v, " ", 2) - if len(parts) == 2 { - tok := parts[1] - if len(tok) > 8 { - log.Printf("AuthRoundTripper: outgoing Authorization token prefix: %s", tok[:8]) - } else { - log.Printf("AuthRoundTripper: outgoing Authorization token prefix: %s", tok) - } - } + //log.Printf("AuthRoundTripper: request URL after enforcement: scheme=%s host=%s path=%s full=%s", req.URL.Scheme, req.URL.Host, req.URL.Path, req.URL.String()) } + //if v := req.Header.Get("Authorization"); v != "" { + // parts := strings.SplitN(v, " ", 2) + // if len(parts) == 2 { + // tok := parts[1] + // if len(tok) > 8 { + // log.Printf("AuthRoundTripper: outgoing Authorization token prefix: %s", tok[:8]) + // } else { + // log.Printf("AuthRoundTripper: outgoing Authorization token prefix: %s", tok) + // } + // } + //} return l.rt.RoundTrip(req) } diff --git a/internal/pkg/func.go b/internal/pkg/func.go index c216798..6904e2f 100644 --- a/internal/pkg/func.go +++ b/internal/pkg/func.go @@ -82,11 +82,6 @@ func JsonStringIgonErr(data interface{}) string { return string(JsonByteIgonErr(data)) } -func JsonByteIgonErr(data interface{}) []byte { - dataByte, _ := json.Marshal(data) - return dataByte -} - func IntersectionGeneric[T comparable](slice1, slice2 []T) []T { m := make(map[T]bool) result := []T{} diff --git a/internal/prompts/sdk_generate.go b/internal/prompts/sdk_generate.go index d8f00e8..452eaeb 100644 --- a/internal/prompts/sdk_generate.go +++ b/internal/prompts/sdk_generate.go @@ -1,4 +1,4 @@ -// sdk_generate.go - 完整优化版 +// sdk_generate.go - 完整优化版(兼容 DeepSeek GA 版本) package prompts import ( @@ -6,7 +6,10 @@ import ( "fmt" "log" "runtime/debug" + "sdk-generator/internal/data/impl" + "sdk-generator/internal/data/model" "sdk-generator/internal/entitys" + "sdk-generator/internal/pkg" "strings" "sync" "time" @@ -20,36 +23,32 @@ import ( type SDKGenerator struct { openaiClient *openai.Client cryptoManager *crypt.CryptoSkillManager - maxIterations int // 最大迭代次数,防止死循环 - timeout time.Duration // 总超时时间 - model string // OpenAI 模型 + maxIterations int + timeout time.Duration + model string } // SDKGeneratorOption 配置选项 type SDKGeneratorOption func(*SDKGenerator) -// WithMaxIterations 设置最大迭代次数 func WithMaxIterations(n int) SDKGeneratorOption { return func(g *SDKGenerator) { g.maxIterations = n } } -// WithTimeout 设置超时时间 func WithTimeout(t time.Duration) SDKGeneratorOption { return func(g *SDKGenerator) { g.timeout = t } } -// WithModel 设置模型 func WithModel(model string) SDKGeneratorOption { return func(g *SDKGenerator) { g.model = model } } -// NewSDKGenerator 创建 SDK 生成器 func NewSDKGenerator(client *openai.Client, opts ...SDKGeneratorOption) *SDKGenerator { g := &SDKGenerator{ openaiClient: client, @@ -58,20 +57,18 @@ func NewSDKGenerator(client *openai.Client, opts ...SDKGeneratorOption) *SDKGene timeout: 10 * time.Minute, model: openai.GPT4, } - for _, opt := range opts { opt(g) } - return g } -// GenerateSDK 生成 SDK 代码 - AI 自主决策流程 -func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName string, needImplement string) (string, *entitys.Usage, error) { +// GenerateSDK 生成 SDK 代码 +func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, task *entitys.Task, needImplement string, logImpl *impl.AiGenerateLogImpl) (string, *entitys.Usage, error) { ctx, cancel := context.WithTimeout(ctx, g.timeout) defer cancel() - systemPrompt := g.buildSystemPrompt(sdkName, needImplement) + systemPrompt := g.buildSystemPrompt(task.Name, needImplement) messages := []openai.ChatCompletionMessage{ { @@ -80,7 +77,7 @@ func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName stri }, { Role: openai.ChatMessageRoleUser, - Content: fmt.Sprintf("请根据以下文档生成完整的 SDK 代码,必须包含所有 6 个文件:\n\n%s", doc), + Content: fmt.Sprintf("请根据以下文档生成完整的 SDK 代码,必须包含所有 6 个文件:\n\n**⚠️ 重要规则**:\n1. 调用工具时,**不要输出任何额外文本**(包括句号、逗号、空格等)\n2. 直接调用工具,等待工具返回结果\n3. 工具返回后,**立即开始生成代码**\n4. 不要重复调用已调用过的工具\n\n%s", doc), }, } @@ -89,7 +86,8 @@ func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName stri var allResults []string calledTools := make(map[string]bool) useAge := &entitys.Usage{} - allFilesGenerated := false + forceTextGeneration := false + guidedToGenerate := false // 是否已经引导过生成代码 for { select { @@ -109,9 +107,17 @@ func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName stri Model: g.model, Messages: messages, Tools: tools, - ToolChoice: "auto", + ToolChoice: nil, Temperature: 0.1, - MaxTokens: 16384, // 增大 token 以生成完整代码 + MaxTokens: 16384, + } + + // 如果已经调用了足够的工具,强制切换到文本生成模式 + if len(calledTools) >= 2 && iteration > 2 { + req.ToolChoice = nil // 兼容所有版本 + req.Tools = nil + forceTextGeneration = true + log.Printf("🛑 强制切换到文本生成模式,已调用工具数: %d", len(calledTools)) } resp, err := g.openaiClient.CreateChatCompletion(ctx, req) @@ -122,74 +128,83 @@ func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName stri choice := resp.Choices[0] msg := choice.Message - // 检查是否包含完成标志 + logImpl.Add(ctx, &model.AiGenerateLog{ + TaskID: task.TasKModel.TaskID, + Type: entitys.StatusGenerateCode.String(), + RequestContent: pkg.JsonStringIgonErr(req.Messages), + ResponseContent: msg.Content, + ToolSelect: pkg.JsonStringIgonErr(msg.ToolCalls), + }) + + // 清理无意义内容 + cleanedContent := g.cleanContent(msg.Content) + if cleanedContent != msg.Content { + log.Printf("🧹 清理了 AI 的无意义输出: %q -> %q", msg.Content, cleanedContent) + msg.Content = cleanedContent + } + + // 检查是否完成 if strings.Contains(msg.Content, "=== SDK 生成完成 ===") { log.Printf("✅ SDK 生成完成") - if len(allResults) > 0 { - return g.mergeResults(msg.Content, allResults), nil, nil - } - return msg.Content, useAge, nil + return g.mergeResults(msg.Content, allResults), useAge, nil } - // 检查是否生成了所有文件 if g.checkAllFilesGenerated(msg.Content) { - allFilesGenerated = true log.Printf("✅ 所有 6 个文件已生成") - if len(allResults) > 0 { - return g.mergeResults(msg.Content, allResults), nil, nil - } - return msg.Content, useAge, nil + return g.mergeResults(msg.Content, allResults), useAge, nil } - // 记录工具调用信息 - if len(msg.ToolCalls) > 0 { - var toolNames []string - for _, tc := range msg.ToolCalls { - toolNames = append(toolNames, tc.Function.Name) - calledTools[tc.Function.Name] = true - } - log.Printf("🔧 AI 调用工具: %v, 迭代次数: %d", toolNames, iteration) - } else { - log.Printf("📝 AI 回复长度: %d 字符", len(msg.Content)) - // 如果 AI 没有调用工具也没有完成,提示它继续 - if len(allResults) > 0 && !allFilesGenerated { - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - Content: fmt.Sprintf("已获取 %d 个加密实现,请现在生成完整的 SDK 代码,包含所有 6 个文件。完成后输出 '=== SDK 生成完成 ==='", len(allResults)), - }) + // 【修复】处理没有工具调用的情况 + if len(msg.ToolCalls) == 0 { + // 如果有内容输出 + if len(msg.Content) > 0 { + messages = append(messages, msg) + // 如果强制生成模式,直接返回 + if forceTextGeneration { + log.Printf("✅ 强制文本生成模式,返回结果") + return g.mergeResults(msg.Content, allResults), useAge, nil + } + // 如果生成了部分代码,引导继续 + if strings.Contains(msg.Content, "// File:") { + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: "请继续生成剩余的文件,完成后输出 '=== SDK 生成完成 ==='", + }) + } continue } - } - - messages = append(messages, msg) - - if len(msg.ToolCalls) == 0 { - if len(allResults) > 0 { - return g.mergeResults(msg.Content, allResults), nil, nil + // 没有内容也没有工具调用 + log.Printf("⚠️ AI 没有输出内容也没有调用工具") + if forceTextGeneration { + // 强制模式下,添加更明确的指令 + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: "请立即生成完整的 SDK 代码,包含所有 6 个文件。直接输出代码,不要调用工具。", + }) } - return msg.Content, useAge, nil - } - - // 检查是否所有工具都已调用过 - allCalled := true - for _, tc := range msg.ToolCalls { - if !calledTools[tc.Function.Name] { - allCalled = false - break - } - } - - // 如果 AI 重复调用已调用的工具,强制终止并引导生成代码 - if allCalled && iteration > 2 { - log.Printf("⚠️ AI 在重复调用已调用的工具,强制引导生成代码") - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - Content: "所有加密工具已调用完成,请现在生成完整的 SDK 代码,包含所有 6 个文件。完成后输出 '=== SDK 生成完成 ==='", - }) continue } - // 并行处理所有工具调用 + // 【修复】处理工具调用时的无意义内容 + if len(msg.ToolCalls) > 0 { + content := strings.TrimSpace(msg.Content) + if g.isMeaninglessContent(content) { + log.Printf("🧹 清除了工具调用时的无意义文本: %q", msg.Content) + msg.Content = "" + } + } + + // 记录工具调用 + var toolNames []string + for _, tc := range msg.ToolCalls { + toolNames = append(toolNames, tc.Function.Name) + calledTools[tc.Function.Name] = true + } + log.Printf("🔧 AI 调用工具: %v, 迭代: %d, 已调用: %v", toolNames, iteration, getCalledToolsList(calledTools)) + + messages = append(messages, msg) + + // 【修复】先执行工具调用,再判断是否完成 toolResults, results, err := g.processToolCalls(ctx, msg.ToolCalls) if err != nil { return "", useAge, err @@ -200,17 +215,74 @@ func (g *SDKGenerator) GenerateSDK(ctx context.Context, doc string, sdkName stri useAge.PromptTokens += resp.Usage.PromptTokens useAge.CompletionTokens += resp.Usage.CompletionTokens useAge.TotalTokens += resp.Usage.TotalTokens - // 添加一个明确的提示,告诉 AI 继续 - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - Content: fmt.Sprintf("✅ 已获取 %d 个加密实现,请现在生成完整的 SDK 代码,包含所有 6 个文件。完成后输出 '=== SDK 生成完成 ==='", len(allResults)), - }) + + // 【修复】工具执行完成后,再引导生成代码(避免遗漏工具结果) + if len(calledTools) >= 2 && !guidedToGenerate { + guidedToGenerate = true + log.Printf("✅ 所有加密工具已调用完成(共 %d 个),引导 AI 生成代码", len(calledTools)) + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: fmt.Sprintf("所有加密工具已调用完成(共 %d 个),请根据工具返回的结果,现在生成完整的 SDK 代码,包含所有 6 个文件。完成后输出 '=== SDK 生成完成 ==='。不要再次调用工具。", len(calledTools)), + }) + forceTextGeneration = true + } else if len(allResults) > 0 { + // 如果已经有工具结果,提示生成 + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: fmt.Sprintf("✅ 已获取 %d 个加密实现,请现在生成完整的 SDK 代码,包含所有 6 个文件。完成后输出 '=== SDK 生成完成 ==='。不要再次调用工具。", len(allResults)), + }) + } } } -// processToolCalls 并行处理工具调用(带去重和 panic 保护) +// cleanContent 清理无意义内容 +func (g *SDKGenerator) cleanContent(content string) string { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return "" + } + if g.isMeaninglessContent(trimmed) { + return "" + } + return content +} + +// isMeaninglessContent 检查内容是否无意义 +func (g *SDKGenerator) isMeaninglessContent(content string) bool { + punctuations := []string{"。", "、", ".", ",", ",", ";", ";", "!", "!", "?", "?"} + for _, p := range punctuations { + if content == p { + return true + } + } + if len([]rune(content)) <= 3 { + for _, r := range content { + isPunct := false + for _, p := range punctuations { + if string(r) == p { + isPunct = true + break + } + } + if !isPunct { + return false + } + } + return true + } + return false +} + +func getCalledToolsList(calledTools map[string]bool) []string { + var list []string + for tool := range calledTools { + list = append(list, tool) + } + return list +} + +// processToolCalls 并行处理工具调用 func (g *SDKGenerator) processToolCalls(ctx context.Context, toolCalls []openai.ToolCall) ([]openai.ChatCompletionMessage, []string, error) { - // 去重 seen := make(map[string]bool) var uniqueCalls []openai.ToolCall for _, tc := range toolCalls { @@ -234,27 +306,23 @@ func (g *SDKGenerator) processToolCalls(ctx context.Context, toolCalls []openai. wg.Add(1) go func(tc openai.ToolCall) { defer wg.Done() - defer func() { if r := recover(); r != nil { errChan <- fmt.Errorf("工具 %s 执行时发生 panic: %v\n堆栈: %s", tc.Function.Name, r, string(debug.Stack())) } }() - select { case <-ctx.Done(): errChan <- ctx.Err() return default: } - result, err := g.cryptoManager.ExecuteTool(ctx, tc) if err != nil { errChan <- fmt.Errorf("执行工具 %s 失败: %v", tc.Function.Name, err) return } - resultChan <- openai.ChatCompletionMessage{ Role: openai.ChatMessageRoleTool, Content: result, @@ -269,14 +337,12 @@ func (g *SDKGenerator) processToolCalls(ctx context.Context, toolCalls []openai. close(resultDataChan) close(errChan) - // 检查错误 for err := range errChan { if err != nil { return nil, nil, err } } - // 收集结果 var toolResults []openai.ChatCompletionMessage var results []string for msg := range resultChan { @@ -299,14 +365,12 @@ func (g *SDKGenerator) checkAllFilesGenerated(content string) bool { "errors.go", "example_test.go", } - foundCount := 0 for _, file := range requiredFiles { if strings.Contains(content, "// File:") && strings.Contains(content, file) { foundCount++ } } - return foundCount >= len(requiredFiles) } @@ -320,23 +384,25 @@ func (g *SDKGenerator) buildSystemPrompt(sdkName, needImplement string) string { return fmt.Sprintf(basePrompt, g.cryptoManager.GetToolDescriptions()) } -// mergeResults 合并加密实现到最终代码 +// mergeResults 合并加密实现 func (g *SDKGenerator) mergeResults(code string, results []string) string { if len(results) == 0 { return code } - + if strings.Contains(code, "SM3Hash") || strings.Contains(code, "GenerateNonce") || + strings.Contains(code, "BuildSignString") || strings.Contains(code, "HMAC") { + log.Printf("📝 代码中已包含加密实现,跳过合并") + return code + } var sb strings.Builder sb.WriteString(code) sb.WriteString("\n\n## 加密实现\n\n") sb.WriteString("以下是从加密工具获取的完整实现:\n\n") - for i, result := range results { sb.WriteString(fmt.Sprintf("### 加密实现 %d\n\n", i+1)) sb.WriteString(result) sb.WriteString("\n\n") } - return sb.String() } @@ -345,7 +411,6 @@ const SdkGeneratePrompt = `你是一个资深的 Go 语言工程师,擅长生 ## 任务 根据用户提供的 API 接口文档,生成一个**完整的** Go SDK 代码工程。 - {{needImplement}} ## ⚠️ 重要:输出格式要求(必须严格遵守) @@ -369,13 +434,14 @@ package {{sdk_name}} ## 重要:你有加密工具可用 %s -## 工具调用策略 -- 只在文档明确提到特定加密算法时才调用工具 -- 工具返回的是完整的实现代码,直接集成到 SDK 的 crypto.go 中 +## 工具调用规则(必须严格遵守) +- 调用工具时,**只调用工具,不输出任何文本**(包括句号、逗号、标点符号) +- 不要在工具调用前后添加任何注释或说明 +- 工具调用完成后,**立即**开始生成代码 - 每个工具**只调用一次**,不要重复调用 ## 工作流程 -1. 分析文档中的加密需求 → 调用对应的工具获取实现 +1. 分析文档中的加密需求 → 调用对应的工具获取实现(只调用需要的工具,通常2-3个) 2. 将工具返回的代码放到 crypto.go 中 3. **然后立即生成所有其他文件**:client.go、types.go、go.mod、errors.go、example_test.go diff --git a/internal/prompts/valid.go b/internal/prompts/valid.go index ef1f25e..9a8477b 100644 --- a/internal/prompts/valid.go +++ b/internal/prompts/valid.go @@ -5,69 +5,147 @@ import ( "github.com/sashabaranov/go-openai" ) -// GetValidatePrompt 验证代码完整性,直接返回修复后的代码 +// GetValidatePrompt 步骤1:只检查,输出缺失列表 func GetValidatePrompt(refineDoc, sdkName, codeContent string) openai.ChatCompletionRequest { return openai.ChatCompletionRequest{ Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleSystem, - Content: "你是代码审查专家。检查生成的 SDK 代码是否完整实现了文档中的所有内容。", + Content: "你是代码审查专家。检查 SDK 代码是否完整实现了文档中的所有内容。只输出检查结论,不要生成代码。", }, { Role: openai.ChatMessageRoleUser, - Content: ValidPrompt(refineDoc, sdkName, codeContent), + Content: ValidateOnlyPrompt(refineDoc, sdkName, codeContent), }, }, - Temperature: 0.1, // 更低,保证确定性输出 - TopP: 0.9, // 核采样,平衡多样性和质量 - MaxTokens: 65536, // 加大到 64k,确保足够 - FrequencyPenalty: 0.0, // 代码生成不需要 - PresencePenalty: 0.0, // 代码生成不需要 - Stop: nil, // 不设置,让模型完整输出 + //ToolChoice: nil, + Temperature: 0.1, + TopP: 0.9, + MaxTokens: 4096, // 稍微大一点,因为缺失列表可能不止几行 + FrequencyPenalty: 0.0, + PresencePenalty: 0.0, + Stop: nil, } } -func ValidPrompt(codeContent, sdkName, refineDoc string) string { +// GetFixByIssuesPrompt 步骤2:根据缺失列表修复代码 +func GetFixByIssuesPrompt(refineDoc, sdkName, codeContent, issues string) openai.ChatCompletionRequest { + return openai.ChatCompletionRequest{ + Messages: []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleSystem, + Content: "你是代码生成专家。根据问题列表修复代码,输出全量代码。", + }, + { + Role: openai.ChatMessageRoleUser, + Content: FixByIssuesPrompt(refineDoc, sdkName, codeContent, issues), + }, + }, + //ToolChoice: nil, + Temperature: 0.1, + TopP: 0.9, + MaxTokens: 65536, + FrequencyPenalty: 0.0, + PresencePenalty: 0.0, + Stop: nil, + } +} - return `你是代码审查专家。检查生成的 SDK 代码是否完整实现了文档中的所有内容。 +// ========== 通用 Prompt ========== -## 精炼文档 +func ValidateOnlyPrompt(refineDoc, sdkName, codeContent string) string { + return `你是代码审查专家。检查 SDK 代码是否完整实现了文档中的所有内容。 + +## 精炼文档(接口定义) ` + refineDoc + ` ## 当前代码 ` + codeContent + ` -## 检查清单 +## 检查项(根据文档内容自动适配) -1. 文档中的所有接口是否都已实现? -2. 每个接口的请求参数是否完整(字段名、类型、必填)? -3. 每个接口的响应字段是否完整? -4. 认证方式是否已实现? -5. 加密方式是否已实现? -6. 签名方式是否已实现? -7. 错误码是否已定义? +请根据文档中的接口定义,逐项检查以下内容: -## 输出规则(严格遵守) +1. **接口/方法完整性** + - 文档中定义的所有 API 接口/方法,代码中是否都有对应的实现? + - 方法名、参数、返回值是否与文档一致? + +2. **数据结构完整性** + - 文档中定义的所有请求/响应结构体,代码中是否都已定义? + - 结构体字段名、类型、必填/可选是否与文档一致? + - 枚举值、常量是否正确定义? + +3. **认证与安全** + - 文档中的认证方式(签名、加密、token等)是否已实现? + - 签名算法、加密算法是否正确? + +4. **错误处理** + - 文档中的错误码是否已定义? + - 错误类型、错误信息是否完整? + +5. **客户端初始化** + - 是否有 NewClient 方法? + - 是否支持配置(超时、重试等)? + +6. **其他文档要求** + - 文档中提到的其他功能(日志、监控、中间件等)是否已实现? + +## ⚠️ 输出规则(必须严格遵守) **情况一:审查通过,没有任何问题** -只输出两个字符:` + "`OK`" + ` +只输出:OK **情况二:存在问题** -输出修复后的**全量代码**,每个文件用以下格式: +输出缺失项列表,按以下格式: + +### 缺失接口 +- InterfaceName: 文档定义了但代码中没有实现 + +### 缺失字段 +- StructName.FieldName: 文档定义了但结构体中缺少 + +### 缺失认证/加密 +- 具体描述缺失的认证或加密逻辑 + +### 其他缺失 +- 其他文档要求但代码中缺失的内容 + +❌ 绝对不要输出修复后的代码! +❌ 绝对不要输出完整的文件内容! +✅ 只输出 OK 或上述格式的缺失列表! +` +} + +func FixByIssuesPrompt(refineDoc, sdkName, codeContent, issues string) string { + return `根据问题列表修复 SDK 代码。 + +## 精炼文档(供参考) +` + refineDoc + ` + +## 当前代码 +` + codeContent + ` + +## 需要修复的问题 +` + issues + ` + +## 修复要求 +1. 根据问题列表,逐项修复代码 +2. 补充缺失的接口、方法、结构体、字段 +3. 补充缺失的认证、加密、签名逻辑 +4. 补充缺失的错误码定义 +5. 保持原有代码风格不变 +6. 不要删除现有代码(除非是重复定义) + +## 输出规则 +输出修复后的**完整代码**,每个文件用以下格式: // File: ` + sdkName + `/文件名.go ` + "```go" + ` -package ` + sdkName + ` +package ` + sdkName + ` // ... 代码内容 ... ` + "```" + ` - - -## 重要提醒 - -1. 如果存在问题,必须输出**所有文件**的完整代码,不能只输出修改的部分 -2. 保持原有代码风格不变 -3. 补全所有遗漏的接口、参数、加密/签名方式 -4. 不要加任何描述性注释 +❌ 不要只输出修改的部分! +✅ 必须输出所有文件的完整代码! ` } diff --git a/internal/test/resp.go b/internal/test/resp.go deleted file mode 100644 index 468eb6c..0000000 --- a/internal/test/resp.go +++ /dev/null @@ -1,3 +0,0 @@ -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/test.md b/test.md index e69de29..6dfacce 100644 --- a/test.md +++ b/test.md @@ -0,0 +1,1307 @@ +// File: intelligence_finance_v1_ga/go.mod +```go +module intelligence_finance_v1_ga + +go 1.21 +``` + +// File: intelligence_finance_v1_ga/errors.go +```go +package intelligence_finance_v1_ga + +import "fmt" + +// APIError 表示 HTTP 层面的错误(非 200 响应)。 +type APIError struct { + // StatusCode HTTP 状态码 + StatusCode int + // Body 响应体原始内容 + Body string +} + +// Error 实现 error 接口。 +func (e *APIError) Error() string { + return fmt.Sprintf("api error: status=%d body=%s", e.StatusCode, e.Body) +} + +// BusinessError 表示业务层面的错误(通用响应结构中 code 非成功值)。 +type BusinessError struct { + // Code 业务错误码 + Code int + // Msg 业务错误描述 + Msg string +} + +// Error 实现 error 接口。 +func (e *BusinessError) Error() string { + return fmt.Sprintf("business error: code=%d msg=%s", e.Code, e.Msg) +} + +// NotificationError 表示通知处理失败。 +type NotificationError struct { + // Reason 失败原因 + Reason string +} + +// Error 实现 error 接口。 +func (e *NotificationError) Error() string { + return fmt.Sprintf("notification error: %s", e.Reason) +} +``` + +// File: intelligence_finance_v1_ga/crypto.go +```go +package intelligence_finance_v1_ga + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "math/big" + "sort" + "strings" + "time" +) + +// GenerateTimestamp 生成秒级时间戳。 +func GenerateTimestamp() string { + return fmt.Sprintf("%d", time.Now().Unix()) +} + +// GenerateTimestampMillis 生成毫秒级时间戳。 +func GenerateTimestampMillis() string { + return fmt.Sprintf("%d", time.Now().UnixMilli()) +} + +// GenerateNonce 生成指定长度的随机字符串(加密安全)。 +func GenerateNonce(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + b[i] = charset[num.Int64()] + } + return string(b), nil +} + +// HmacSHA256Base64 使用 HmacSHA256 算法对 data 进行签名,密钥为 key,结果 Base64 编码。 +// 对应文档中客户应用签名算法:HmacSHA256,密钥为 client-secret, +// 签名数据为 x-bfl-signature-timestamp + x-bfl-signature-nonce。 +func HmacSHA256Base64(key, data string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(data)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// BuildSignString 方式1:字典序排序拼接,排除空值与签名字段本身。 +func BuildSignString(params map[string]string) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if v != "" && k != "sign" && k != "signature" { + keys = append(keys, k) + } + } + sort.Strings(keys) + + var parts []string + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, params[k])) + } + return strings.Join(parts, "&") +} + +// BuildSignStringOrdered 方式2:固定顺序拼接,排除空值。 +func BuildSignStringOrdered(params map[string]string, orderedKeys []string) string { + var parts []string + for _, k := range orderedKeys { + if v, ok := params[k]; ok && v != "" { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + } + } + return strings.Join(parts, "&") +} +``` + +// File: intelligence_finance_v1_ga/types.go +```go +package intelligence_finance_v1_ga + +import "encoding/json" + +// ==================== 通用 ==================== + +// CommonResponse 通用响应结构。 +type CommonResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// ==================== 接口1:订单开票 ==================== + +// InvoiceApplyRequest 提交订单开票申请请求。 +type InvoiceApplyRequest struct { + // CompanyCode 开票的企业主体编码,不传则默认主体开票 + CompanyCode string `json:"companyCode,omitempty"` + // OrderID 订单唯一标识(需保证在贵方系统内唯一) + OrderID string `json:"orderId"` + // InvoiceType 发票类型枚举:1-专用发票;2-普通发票;3-普通发票(电子);4-专用发票(电子);8-数电专票;9-数电普票 + InvoiceType int `json:"invoiceType"` + // Products 货物/服务明细列表,至少一项 + Products []ProductItem `json:"products"` + // Remark 订单备注(非发票备注) + Remark string `json:"remark,omitempty"` + // Purchaser 购方企业名称 + Purchaser string `json:"purchaser"` + // TaxNum 购方纳税人识别号 + TaxNum string `json:"taxnum,omitempty"` + // PurchaserAddress 购方地址 + PurchaserAddress string `json:"purchaserAddress,omitempty"` + // PurchaserTel 购方电话 + PurchaserTel string `json:"purchaserTel,omitempty"` + // BankName 购方开户行名称 + BankName string `json:"bankName,omitempty"` + // BankAccount 购方银行账号 + BankAccount string `json:"bankAccount,omitempty"` + // Phone 收票人手机号(用于接收电票短信) + Phone string `json:"phone,omitempty"` + // Email 收票人邮箱(用于接收电票邮件) + Email string `json:"email,omitempty"` + // ApplyPerson 开票申请人名称 + ApplyPerson string `json:"applyPerson,omitempty"` + // Payee 收款人(发票票面) + Payee string `json:"payee,omitempty"` + // Reviewer 复核人(发票票面) + Reviewer string `json:"reviewer,omitempty"` + // InvoiceRemark 发票备注栏内容 + InvoiceRemark string `json:"invoiceRemark,omitempty"` + // NaturalPerson 购买方自然人标识:Y-是,N-否(默认N),数电票可选传 + NaturalPerson string `json:"naturalPerson,omitempty"` + // AdditionInfo 附加信息(JSON数组字符串) + AdditionInfo string `json:"additionInfo,omitempty"` +} + +// ProductItem 货物/服务明细项。 +type ProductItem struct { + // ProductName 货物或服务名称 + ProductName string `json:"productName"` + // RevenueCode 19位税收分类编码 + RevenueCode string `json:"revenueCode"` + // AmountIncludeTax 单条明细含税总金额(单位:元) + AmountIncludeTax float64 `json:"amountIncludeTax"` + // Specs 规格型号 + Specs string `json:"specs,omitempty"` + // Unit 计量单位(如:台、个、次) + Unit string `json:"unit,omitempty"` + // Quantity 数量 + Quantity float64 `json:"quantity"` + // Discount 折扣金额(无折扣传0) + Discount float64 `json:"discount,omitempty"` + // TaxSign 是否含税:0-不含税;1-含税(默认建议传1) + TaxSign int `json:"taxSign,omitempty"` + // TaxRate 税率(小数形式,如0.13表示13%) + TaxRate float64 `json:"taxRate,omitempty"` +} + +// InvoiceApplyResponse 提交订单开票申请响应。 +type InvoiceApplyResponse struct { + // Status 开票状态:0-未开票;1-开票中;2-部分失败;3-开票成功;4-开票失败;5-部分未开;6-未配置数电账号;7-未配置自动开票配置 + Status int `json:"status"` + // ErrorMsg 错误信息(开票失败时返回原因) + ErrorMsg string `json:"errorMsg,omitempty"` + // DataList 发票数据列表(一张订单可能对应多张发票) + DataList []InvoiceData `json:"dataList"` +} + +// InvoiceData 发票数据。 +type InvoiceData struct { + DeviceCode string `json:"deviceCode"` + Drawer string `json:"drawer"` + Email string `json:"email"` + InvoiceType string `json:"invoiceType"` + IssueType string `json:"issueType"` + ListFlag string `json:"listFlag"` + Mobile string `json:"mobile"` + OriginalInvCode string `json:"originalInvCode,omitempty"` + OriginalInvNo string `json:"originalInvNo,omitempty"` + AdditionInfo string `json:"additionInfo,omitempty"` + Payee string `json:"payee"` + PurchaserAddress string `json:"purchaserAddress"` + PurchaserBankAccount string `json:"purchaserBankAccount"` + PurchaserBankName string `json:"purchaserBankName"` + PurchaserName string `json:"purchaserName"` + PurchaserTaxNo string `json:"purchaserTaxNo"` + PurchaserTel string `json:"purchaserTel"` + NaturalPerson string `json:"naturalPerson,omitempty"` + Remark string `json:"remark"` + Reviewer string `json:"reviewer"` + SellerAddress string `json:"sellerAddress,omitempty"` + SellerBankAccount string `json:"sellerBankAccount"` + SellerBankName string `json:"sellerBankName"` + SellerName string `json:"sellerName"` + CheckCode string `json:"checkCode"` + CipherText string `json:"cipherText"` + DrewDate string `json:"drewDate,omitempty"` + InvoiceCode string `json:"invoiceCode"` + InvoiceNo string `json:"invoiceNo"` + InvoiceStatus string `json:"invoiceStatus"` + LayoutFileURL string `json:"layoutFileUrl,omitempty"` + PDFURL string `json:"pdfUrl,omitempty"` + OFDURL string `json:"ofdUrl,omitempty"` + XMLURL string `json:"xmlUrl,omitempty"` + TotalExcludeTax string `json:"totalExcludeTax"` + TotalIncludeTax string `json:"totalIncludeTax"` + TotalTaxAmount string `json:"totalTaxAmount"` + LevyingType string `json:"levyingType"` + Details []InvoiceDetail `json:"details"` +} + +// InvoiceDetail 发票商品明细。 +type InvoiceDetail struct { + Amount string `json:"amount,omitempty"` + Quantity string `json:"quantity,omitempty"` + DeductionAmount string `json:"deductionAmount,omitempty"` + TaxAmount string `json:"taxAmount,omitempty"` + ItemTitle string `json:"itemTitle"` + TaxCode string `json:"taxCode"` + ItemType string `json:"itemType"` + ItemName string `json:"itemName"` + Specs string `json:"specs,omitempty"` + TaxFreePolicy string `json:"taxFreePolicy"` + PreferentialPolicy string `json:"preferentialPolicy"` + TaxRate string `json:"taxRate"` + TaxSign string `json:"taxSign"` + Unit string `json:"unit,omitempty"` + UnitPrice string `json:"unitPrice,omitempty"` +} + +// ==================== 接口2:开票状态查询 ==================== + +// InvoiceStatusQueryRequest 查询订单开票状态请求。 +type InvoiceStatusQueryRequest struct { + // OrderID 订单唯一标识(需保证在贵方系统内唯一) + OrderID string `json:"orderId"` +} + +// InvoiceStatusQueryResponse 查询订单开票状态响应。 +type InvoiceStatusQueryResponse struct { + // Status 开票状态(0-未开票,3-成功,4-失败,6-未配置数电账号,7-未配置自动开票) + Status int `json:"status"` + // Message 状态描述(如"开票成功") + Message string `json:"message"` + // Data 发票详细列表,结构与回调中的data字段一致 + Data []InvoiceData `json:"data"` +} + +// ==================== 接口3:创建付款单据 ==================== + +// CreatePaymentRequest 创建付款单据请求。 +type CreatePaymentRequest struct { + Code string `json:"code"` + YidaAppType string `json:"yidaAppType,omitempty"` + EmpAccountUserID string `json:"empAccountUserId,omitempty"` + Department *Department `json:"department,omitempty"` + Usage string `json:"usage,omitempty"` + PaymentUserID string `json:"paymentUserId,omitempty"` + Customer *Customer `json:"customer,omitempty"` + PrincipalID string `json:"principalId,omitempty"` + Remark string `json:"remark,omitempty"` + Supplier *Supplier `json:"supplier,omitempty"` + Title string `json:"title,omitempty"` + Project *Project `json:"project,omitempty"` + PaymentUserIDListStr string `json:"paymentUserIdListStr,omitempty"` + NeedPayment bool `json:"needPayment,omitempty"` + PaymentDetailListJSON string `json:"paymentDetailListJsonStr,omitempty"` + PaymentDetailList []PaymentDetail `json:"paymentDetailList,omitempty"` + Company *Company `json:"company,omitempty"` + Amount string `json:"amount,omitempty"` + RecipientAccountInfo *RecipientAccount `json:"recipientAccountInfo,omitempty"` + EnterpriseAccount *EnterpriseAccount `json:"enterpriseAccount,omitempty"` + Category []Category `json:"category,omitempty"` + UserID string `json:"userId"` + OccurDate int64 `json:"occurDate,omitempty"` + Product *Product `json:"product,omitempty"` + YidaFormUUID string `json:"yidaFormUuid,omitempty"` + CanEditPaymentInfo bool `json:"canEditPaymentInfo,omitempty"` + PaymentUserIDList []string `json:"paymentUserIdList,omitempty"` + YidaProcInsID string `json:"yidaProcInsId,omitempty"` + SyncPaymentOrder bool `json:"syncPaymentOrder,omitempty"` +} + +// Department 部门信息。 +type Department struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Customer 客户信息。 +type Customer struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Supplier 供应商信息。 +type Supplier struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Project 项目信息。 +type Project struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Category 收支类别信息。 +type Category struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Product 商品信息。 +type Product struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// Company 企业主体信息。 +type Company struct { + Code string `json:"code,omitempty"` + Name string `json:"name"` +} + +// EnterpriseAccount 企业账号信息。 +type EnterpriseAccount struct { + EnterpriseAccountCode string `json:"enterpriseAccountCode,omitempty"` + AccountCategory string `json:"accountCategory"` + AccountType string `json:"accountType,omitempty"` + CardNo string `json:"cardNo,omitempty"` + AccountName string `json:"accountName,omitempty"` + OfficialNumber string `json:"officialNumber,omitempty"` + OfficialName string `json:"officialName,omitempty"` + Name string `json:"name,omitempty"` + Code string `json:"code,omitempty"` + City string `json:"city,omitempty"` + Province string `json:"province,omitempty"` +} + +// RecipientAccount 收款账户信息。 +type RecipientAccount struct { + AccountCategory string `json:"accountCategory"` + AccountType string `json:"accountType,omitempty"` + CardNo string `json:"cardNo,omitempty"` + AccountName string `json:"accountName,omitempty"` +} + +// PaymentDetail 付款明细。 +type PaymentDetail struct { + Amount string `json:"amount,omitempty"` + InvoiceInfo *InvoiceInfo `json:"invoiceInfo,omitempty"` + ProductCode string `json:"productCode,omitempty"` + ProjectCode string `json:"projectCode,omitempty"` + Remark string `json:"remark,omitempty"` + PrincipalID string `json:"principalId,omitempty"` + Tax string `json:"tax,omitempty"` +} + +// InvoiceInfo 付款明细发票信息。 +type InvoiceInfo struct { + InvoiceNo string `json:"invoiceNo,omitempty"` + InvoiceCode string `json:"invoiceCode,omitempty"` +} + +// CreatePaymentResponse 创建付款单据响应。 +type CreatePaymentResponse struct { + // Code 单据唯一编码 + Code string `json:"code"` +} + +// ==================== 接口4:支付完成通知 ==================== + +// PaymentNotifyData 支付完成通知数据。 +type PaymentNotifyData struct { + Code string `json:"code"` + InstanceID string `json:"instanceId"` + CorpID string `json:"corpId"` + PaymentStatus string `json:"paymentStatus"` + PaymentTime string `json:"paymentTime"` + UserID string `json:"userId"` + FailReason string `json:"failReason,omitempty"` + PayerAccountInfo *PayerAccountInfo `json:"payerAccountInfo,omitempty"` + PayeeAccountInfo *PayeeAccountInfo `json:"payeeAccountInfo,omitempty"` + RelatedRowNumberList []string `json:"relatedRowNumberList,omitempty"` + Source string `json:"source,omitempty"` + Template string `json:"template,omitempty"` + Amount string `json:"amount,omitempty"` +} + +// PayerAccountInfo 付款账户信息。 +type PayerAccountInfo struct { + BankOpenDTO *BankOpenDTO `json:"bankOpenDTO,omitempty"` + EnterpriseAccountCode string `json:"enterpriseAccountCode,omitempty"` + AccountType string `json:"accountType,omitempty"` +} + +// PayeeAccountInfo 收款账户信息。 +type PayeeAccountInfo struct { + BankOpenDTO *BankOpenDTO `json:"bankOpenDTO,omitempty"` +} + +// BankOpenDTO 银行信息。 +type BankOpenDTO struct { + BankCode string `json:"bankCode,omitempty"` + BankName string `json:"bankName,omitempty"` + BankBranchCode string `json:"bankBranchCode,omitempty"` + BankBranchName string `json:"bankBranchName,omitempty"` + AccountName string `json:"accountName,omitempty"` + BankCardNo string `json:"bankCardNo,omitempty"` + Type string `json:"type,omitempty"` +} + +// 支付状态枚举。 +const ( + PaymentStatusSuccess = "SUCCESS" // 支付成功 + PaymentStatusFail = "FAIL" // 支付失败 + PaymentStatusTerminate = "TERMINATE" // 支付取消 + PaymentStatusWaitPay = "WAIT_PAY" // 待支付 + PaymentStatusPaying = "PAYING" // 支付中 + PaymentStatusPartSuccess = "PART_SUCCESS" // 部分支付成功 + PaymentStatusRefund = "REFUND" // 退款 +) + +// 来源枚举。 +const ( + SourceApproval = "approval" // 审批单 + SourceOpenAPI = "openapi" // 开发接口 +) + +// 账户类型枚举。 +const ( + AccountTypeAlipay = "ALIPAY" // 支付宝 + AccountTypeBankCard = "BANKCARD" // 银行卡 + AccountTypeCorpBankCard = "CORP_BANK_CARD" // 对公银行卡 + AccountTypePersonalBankCard = "PERSONAL_BANK_CARD" // 对私银行卡 +) + +// ==================== 接口5:支付状态查询 ==================== + +// PaymentStatusQueryRequest 查询支付状态请求。 +type PaymentStatusQueryRequest struct { + Code string `json:"code"` + UserID string `json:"userId"` +} + +// ==================== 通用通知机制 ==================== + +// Notification 通用通知数据。 +type Notification struct { + BizType string `json:"bizType"` + BizID string `json:"bizId"` + Data string `json:"data,omitempty"` +} + +// 通知响应常量。 +const ( + NotifyResultSuccess = "SUCCESS" // 成功 + NotifyResultFailed = "FAILED" // 失败 +) + +// ==================== 接口6:创建供应商 ==================== + +// CreateSupplierRequest 创建供应商请求。 +type CreateSupplierRequest struct { + InvokeID string `json:"invokeId,omitempty"` + UserID string `json:"userId"` + Data string `json:"data,omitempty"` + BizType string `json:"bizType"` +} + +// SupplierBizData 供应商业务数据。 +type SupplierBizData struct { + CorpID string `json:"corpId"` + Creator string `json:"creator"` + SupplierInfo *SupplierInfo `json:"supplierInfo,omitempty"` +} + +// SupplierInfo 供应商数据。 +type SupplierInfo struct { + Name string `json:"name"` + CorpID string `json:"corpId"` + UserDefineCode string `json:"userDefineCode,omitempty"` + Description string `json:"description,omitempty"` + ContactAddress string `json:"contactAddress,omitempty"` + ContactCompanyTelephone string `json:"contactCompanyTelephone,omitempty"` + ContactEmail string `json:"contactEmail,omitempty"` + ContactName string `json:"contactName,omitempty"` + ContactTelephone string `json:"contactTelephone,omitempty"` + Creator string `json:"creator"` + InvoiceAccount string `json:"invoiceAccount,omitempty"` + InvoiceAddress string `json:"invoiceAddress,omitempty"` + InvoiceBankName string `json:"invoiceBankName,omitempty"` + InvoiceName string `json:"invoiceName,omitempty"` + InvoiceTaxNo string `json:"invoiceTaxNo,omitempty"` + PurchaserTel string `json:"purchaserTel,omitempty"` + BankName string `json:"bankName,omitempty"` + InvoiceTelephone string `json:"invoiceTelephone,omitempty"` + AccountName string `json:"accountName,omitempty"` + AccountType string `json:"accountType,omitempty"` +} + +// CreateSupplierResponse 创建供应商响应。 +type CreateSupplierResponse struct { + InvokeID string `json:"invokeId,omitempty"` + BizType string `json:"bizType"` + Data string `json:"data,omitempty"` +} + +// SupplierResponseData 供应商响应数据。 +type SupplierResponseData struct { + Result string `json:"result,omitempty"` + Success bool `json:"success"` +} + +// SupplierResult 供应商信息(响应)。 +type SupplierResult struct { + SupplierID string `json:"supplierId"` + SupplierName string `json:"supplierName"` + Name string `json:"name"` + CorpID string `json:"corpId"` + UserDefineCode string `json:"userDefineCode,omitempty"` + Description string `json:"description,omitempty"` + ContactAddress string `json:"contactAddress,omitempty"` + ContactCompanyTelephone string `json:"contactCompanyTelephone,omitempty"` + ContactEmail string `json:"contactEmail,omitempty"` + ContactName string `json:"contactName,omitempty"` + ContactTelephone string `json:"contactTelephone,omitempty"` + Creator string `json:"creator"` + InvoiceAccount string `json:"invoiceAccount,omitempty"` + InvoiceAddress string `json:"invoiceAddress,omitempty"` + InvoiceBankName string `json:"invoiceBankName,omitempty"` + InvoiceName string `json:"invoiceName,omitempty"` + InvoiceTaxNo string `json:"invoiceTaxNo,omitempty"` + CreateTime int64 `json:"createTime,omitempty"` + ModifiedTime int64 `json:"modifiedTime,omitempty"` + Code string `json:"code,omitempty"` + ID int64 `json:"id,omitempty"` + Status string `json:"status,omitempty"` +} + +// ==================== 接口7:根据名称查询供应商 ==================== + +// QuerySupplierByNameRequest 根据名称查询供应商请求。 +type QuerySupplierByNameRequest struct { + InvokeID string `json:"invokeId,omitempty"` + UserID string `json:"userId"` + Data string `json:"data,omitempty"` + BizType string `json:"bizType"` +} + +// QueryByName 按名称查询业务数据。 +type QueryByName struct { + CorpID string `json:"corpId"` + Name string `json:"name"` +} + +// QuerySupplierByNameResponse 根据名称查询供应商响应。 +type QuerySupplierByNameResponse struct { + InvokeID string `json:"invokeId,omitempty"` + BizType string `json:"bizType"` + Data string `json:"data,omitempty"` + Result string `json:"result,omitempty"` + Success bool `json:"success"` +} + +// ==================== 接口8:更新供应商 ==================== + +// UpdateSupplierRequest 更新供应商请求。 +type UpdateSupplierRequest struct { + InvokeID string `json:"invokeId,omitempty"` + UserID string `json:"userId"` + Data string `json:"data,omitempty"` + BizType string `json:"bizType"` +} + +// UpdateSupplierBizData 更新供应商业务数据。 +type UpdateSupplierBizData struct { + SupplierID string `json:"supplierId"` + Name string `json:"name"` + CorpID string `json:"corpId"` + UserDefineCode string `json:"userDefineCode,omitempty"` + Description string `json:"description,omitempty"` + ContactAddress string `json:"contactAddress,omitempty"` + ContactCompanyTelephone string `json:"contactCompanyTelephone,omitempty"` + ContactEmail string `json:"contactEmail,omitempty"` + ContactName string `json:"contactName,omitempty"` + ContactTelephone string `json:"contactTelephone,omitempty"` + UserID string `json:"userId"` + InvoiceAccount string `json:"invoiceAccount,omitempty"` + InvoiceAddress string `json:"invoiceAddress,omitempty"` + InvoiceBankName string `json:"invoiceBankName,omitempty"` + InvoiceName string `json:"invoiceName,omitempty"` + InvoiceTaxNo string `json:"invoiceTaxNo,omitempty"` + PurchaserTel string `json:"purchaserTel,omitempty"` + BankName string `json:"bankName,omitempty"` + InvoiceTelephone string `json:"invoiceTelephone,omitempty"` + AccountName string `json:"accountName,omitempty"` + AccountType string `json:"accountType,omitempty"` + BankBranchCode string `json:"bankBranchCode,omitempty"` + BankCode string `json:"bankCode,omitempty"` + BankCity string `json:"bankCity,omitempty"` + BankProvince string `json:"bankProvince,omitempty"` +} + +// UpdateSupplierResponse 更新供应商响应。 +type UpdateSupplierResponse struct { + InvokeID string `json:"invokeId,omitempty"` + BizType string `json:"bizType"` + Data string `json:"data,omitempty"` +} + +// ==================== 接口9:根据用户自定义编码查询供应商 ==================== + +// QuerySupplierByUserDefineCodeRequest 根据用户自定义编码查询供应商请求。 +type QuerySupplierByUserDefineCodeRequest struct { + InvokeID string `json:"invokeId,omitempty"` + UserID string `json:"userId"` + Data string `json:"data,omitempty"` + BizType string `json:"bizType"` +} + +// QueryByUserDefineCode 按用户自定义编码查询业务数据。 +type QueryByUserDefineCode struct { + CorpID string `json:"corpId"` + UserDefineCode string `json:"userDefineCode"` +} + +// QuerySupplierByUserDefineCodeResponse 根据用户自定义编码查询供应商响应。 +type QuerySupplierByUserDefineCodeResponse struct { + InvokeID string `json:"invokeId,omitempty"` + BizType string `json:"bizType"` + Data string `json:"data,omitempty"` +} +``` + +// File: intelligence_finance_v1_ga/client.go +```go +package intelligence_finance_v1_ga + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 供应商业务类型常量。 +const ( + BizTypeCreateSupplier = "create_supplier" + BizTypeQuerySupplierByName = "query_supplier_by_name" + BizTypeUpdateSupplier = "update_supplier" + BizTypeQuerySupplierByUserDefine = "query_supplier_by_userDefineCode" +) + +// Client 业财连接接口客户端。 +type Client struct { + baseURL string + tenantID string + clientID string + clientSecret string + signEnabled bool + httpClient *http.Client + + // 各接口路径(可通过 Option 覆盖) + pathSubmitInvoiceApply string + pathQueryInvoiceStatus string + pathCreatePayment string + pathQueryPaymentStatus string + pathCreateSupplier string + pathQuerySupplierByName string + pathUpdateSupplier string + pathQuerySupplierByUserDefineCode string +} + +// Option 客户端配置项。 +type Option func(*Client) + +// WithHTTPClient 自定义 http.Client。 +func WithHTTPClient(c *http.Client) Option { + return func(cli *Client) { cli.httpClient = c } +} + +// WithSignEnabled 是否启用请求签名(钉钉AI表格场景可关闭,签名由AI表格自动完成)。 +func WithSignEnabled(enabled bool) Option { + return func(cli *Client) { cli.signEnabled = enabled } +} + +// WithPathSubmitInvoiceApply 设置提交订单开票申请接口路径。 +func WithPathSubmitInvoiceApply(path string) Option { + return func(cli *Client) { cli.pathSubmitInvoiceApply = path } +} + +// WithPathQueryInvoiceStatus 设置开票状态查询接口路径。 +func WithPathQueryInvoiceStatus(path string) Option { + return func(cli *Client) { cli.pathQueryInvoiceStatus = path } +} + +// WithPathCreatePayment 设置创建付款单据接口路径。 +func WithPathCreatePayment(path string) Option { + return func(cli *Client) { cli.pathCreatePayment = path } +} + +// WithPathQueryPaymentStatus 设置支付状态查询接口路径。 +func WithPathQueryPaymentStatus(path string) Option { + return func(cli *Client) { cli.pathQueryPaymentStatus = path } +} + +// WithPathCreateSupplier 设置创建供应商接口路径。 +func WithPathCreateSupplier(path string) Option { + return func(cli *Client) { cli.pathCreateSupplier = path } +} + +// WithPathQuerySupplierByName 设置根据名称查询供应商接口路径。 +func WithPathQuerySupplierByName(path string) Option { + return func(cli *Client) { cli.pathQuerySupplierByName = path } +} + +// WithPathUpdateSupplier 设置更新供应商接口路径。 +func WithPathUpdateSupplier(path string) Option { + return func(cli *Client) { cli.pathUpdateSupplier = path } +} + +// WithPathQuerySupplierByUserDefineCode 设置根据用户自定义编码查询供应商接口路径。 +func WithPathQuerySupplierByUserDefineCode(path string) Option { + return func(cli *Client) { cli.pathQuerySupplierByUserDefineCode = path } +} + +// NewClient 创建业财连接接口客户端。 +// +// baseURL 为平台接口根地址;tenantID 为平台分配的租户唯一标识; +// clientID 为平台分配的应用标识(钉钉AI表格固定为 "dd-ai-table"); +// clientSecret 为平台分配的密钥,用于请求签名。 +func NewClient(baseURL, tenantID, clientID, clientSecret string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + tenantID: tenantID, + clientID: clientID, + clientSecret: clientSecret, + signEnabled: true, + httpClient: &http.Client{Timeout: 30 * time.Second}, + + pathSubmitInvoiceApply: "/invoice/apply", + pathQueryInvoiceStatus: "/invoice/status", + pathCreatePayment: "/payment/create", + pathQueryPaymentStatus: "/payment/status", + pathCreateSupplier: "/supplier/create", + pathQuerySupplierByName: "/supplier/queryByName", + pathUpdateSupplier: "/supplier/update", + pathQuerySupplierByUserDefineCode: "/supplier/queryByUserDefineCode", + } + for _, opt := range opts { + opt(c) + } + return c +} + +// doRequest 发送 POST 请求并解析响应。 +func (c *Client) doRequest(ctx context.Context, path string, reqBody, respBody interface{}) error { + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("tenant-id", c.tenantID) + httpReq.Header.Set("client-id", c.clientID) + + if c.signEnabled { + timestamp := GenerateTimestamp() + nonce, err := GenerateNonce(32) + if err != nil { + return fmt.Errorf("generate nonce: %w", err) + } + sign := HmacSHA256Base64(c.clientSecret, timestamp+nonce) + httpReq.Header.Set("x-bfl-signature-timestamp", timestamp) + httpReq.Header.Set("x-bfl-signature-nonce", nonce) + httpReq.Header.Set("x-bfl-signature", sign) + } + + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer httpResp.Body.Close() + + respData, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + return &APIError{StatusCode: httpResp.StatusCode, Body: string(respData)} + } + + if err := json.Unmarshal(respData, respBody); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + return nil +} + +// checkBusinessCode 校验通用响应结构中的业务 code。 +func checkBusinessCode(code int, msg string) error { + if code != 0 && code != 200 { + return &BusinessError{Code: code, Msg: msg} + } + return nil +} + +// SubmitInvoiceApply 提交订单开票申请(接口1)。 +func (c *Client) SubmitInvoiceApply(ctx context.Context, req *InvoiceApplyRequest) (*InvoiceApplyResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *InvoiceApplyResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathSubmitInvoiceApply, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QueryInvoiceStatus 查询订单开票状态(接口2)。 +func (c *Client) QueryInvoiceStatus(ctx context.Context, req *InvoiceStatusQueryRequest) (*InvoiceStatusQueryResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *InvoiceStatusQueryResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQueryInvoiceStatus, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// CreatePayment 创建付款单据(接口3)。 +func (c *Client) CreatePayment(ctx context.Context, req *CreatePaymentRequest) (*CreatePaymentResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *CreatePaymentResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathCreatePayment, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QueryPaymentStatus 查询支付状态(接口5),响应数据与支付通知一致。 +func (c *Client) QueryPaymentStatus(ctx context.Context, req *PaymentStatusQueryRequest) (*PaymentNotifyData, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *PaymentNotifyData `json:"data"` + } + if err := c.doRequest(ctx, c.pathQueryPaymentStatus, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// CreateSupplier 创建供应商(接口6)。 +func (c *Client) CreateSupplier(ctx context.Context, req *CreateSupplierRequest) (*CreateSupplierResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *CreateSupplierResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathCreateSupplier, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QuerySupplierByName 根据名称查询供应商(接口7)。 +func (c *Client) QuerySupplierByName(ctx context.Context, req *QuerySupplierByNameRequest) (*QuerySupplierByNameResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *QuerySupplierByNameResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQuerySupplierByName, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// UpdateSupplier 更新供应商(接口8)。 +func (c *Client) UpdateSupplier(ctx context.Context, req *UpdateSupplierRequest) (*UpdateSupplierResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *UpdateSupplierResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathUpdateSupplier, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QuerySupplierByUserDefineCode 根据用户自定义编码查询供应商(接口9)。 +func (c *Client) QuerySupplierByUserDefineCode(ctx context.Context, req *QuerySupplierByUserDefineCodeRequest) (*QuerySupplierByUserDefineCodeResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *QuerySupplierByUserDefineCodeResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQuerySupplierByUserDefineCode, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// ==================== 通知机制辅助 ==================== + +// ParseNotification 解析通用通知数据。 +func ParseNotification(body []byte) (*Notification, error) { + var n Notification + if err := json.Unmarshal(body, &n); err != nil { + return nil, fmt.Errorf("parse notification: %w", err) + } + return &n, nil +} + +// ParsePaymentNotify 解析支付完成通知数据(接口4)。 +func ParsePaymentNotify(body []byte) (*PaymentNotifyData, error) { + var n PaymentNotifyData + if err := json.Unmarshal(body, &n); err != nil { + return nil, fmt.Errorf("parse payment notify: %w", err) + } + return &n, nil +} + +// VerifyNotificationSignature 校验通知签名。 +// +// 平台使用客户提供的验签密钥对原始请求体进行 HmacSHA256 签名并 Base64 编码, +// 通过请求头 x-bfl-signature 传递。verifyKey 为客户在平台配置的验签密钥。 +func VerifyNotificationSignature(verifyKey string, rawBody []byte, signature string) (bool, error) { + if signature == "" { + return false, &NotificationError{Reason: "empty signature"} + } + expected := HmacSHA256Base64(verifyKey, string(rawBody)) + return hmacEqual(expected, signature), nil +} + +// hmacEqual 常量时间比较两个 Base64 签名是否一致。 +func hmacEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var v byte + for i := 0; i < len(a); i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} +``` + +// File: intelligence_finance_v1_ga/example_test.go +```go +package intelligence_finance_v1_ga + +import ( + "context" + "encoding/json" + "fmt" + "testing" +) + +// ExampleClient_SubmitInvoiceApply 演示提交订单开票申请。 +func ExampleClient_SubmitInvoiceApply() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + req := &InvoiceApplyRequest{ + OrderID: "ORDER-20240101-001", + InvoiceType: 9, // 数电普票 + Purchaser: "某某科技有限公司", + TaxNum: "91330100XXXXXXXXXX", + Email: "finance@example.com", + Phone: "13800000000", + Products: []ProductItem{ + { + ProductName: "软件服务", + RevenueCode: "3040201000000000000", + AmountIncludeTax: 11300, + Quantity: 1, + TaxRate: 0.13, + TaxSign: 1, + }, + }, + } + + resp, err := client.SubmitInvoiceApply(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("status=%d errorMsg=%s\n", resp.Status, resp.ErrorMsg) + // Output: +} + +// ExampleClient_CreatePayment 演示创建付款单据。 +func ExampleClient_CreatePayment() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + req := &CreatePaymentRequest{ + Code: "PAY-20240101-001", + UserID: "04221***", + Title: "供应商货款", + Amount: "11300.00", + Supplier: &Supplier{ + Name: "某某供应商", + }, + PaymentDetailList: []PaymentDetail{ + { + Amount: "11300.00", + Tax: "1300.00", + }, + }, + } + + resp, err := client.CreatePayment(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("code=%s\n", resp.Code) + // Output: +} + +// ExampleClient_CreateSupplier 演示创建供应商。 +func ExampleClient_CreateSupplier() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + bizData := []SupplierBizData{ + { + CorpID: "dingXXXXXX", + Creator: "04221***", + SupplierInfo: &SupplierInfo{ + Name: "某某供应商", + CorpID: "dingXXXXXX", + Creator: "04221***", + ContactName: "张三", + ContactTelephone: "18888888888", + InvoiceName: "某某供应商", + InvoiceTaxNo: "91330100XXXXXXXXXX", + }, + }, + } + dataBytes, _ := json.Marshal(bizData) + + req := &CreateSupplierRequest{ + InvokeID: "REQ-001", + UserID: "04221***", + Data: string(dataBytes), + BizType: BizTypeCreateSupplier, + } + + resp, err := client.CreateSupplier(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("bizType=%s data=%s\n", resp.BizType, resp.Data) + // Output: +} + +// ExampleClient_QuerySupplierByName 演示根据名称查询供应商。 +func ExampleClient_QuerySupplierByName() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + queries := []QueryByName{ + {CorpID: "dingXXXXXX", Name: "某某供应商"}, + } + dataBytes, _ := json.Marshal(queries) + + req := &QuerySupplierByNameRequest{ + InvokeID: "REQ-002", + UserID: "04221***", + Data: string(dataBytes), + BizType: BizTypeQuerySupplierByName, + } + + resp, err := client.QuerySupplierByName(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("success=%v result=%s\n", resp.Success, resp.Result) + // Output: +} + +// ExampleClient_UpdateSupplier 演示更新供应商。 +func ExampleClient_UpdateSupplier() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + bizData := UpdateSupplierBizData{ + SupplierID: "SUP_XXXXX", + Name: "某某供应商", + CorpID: "dingXXXXXX", + UserID: "04221***", + ContactName: "李四", + ContactTelephone: "18888888888", + AccountType: AccountTypeCorpBankCard, + } + dataBytes, _ := json.Marshal(bizData) + + req := &UpdateSupplierRequest{ + InvokeID: "REQ-003", + UserID: "04221***", + Data: string(dataBytes), + BizType: BizTypeUpdateSupplier, + } + + resp, err := client.UpdateSupplier(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("bizType=%s\n", resp.BizType) + // Output: +} + +// ExampleClient_QuerySupplierByUserDefineCode 演示根据用户自定义编码查询供应商。 +func ExampleClient_QuerySupplierByUserDefineCode() { + ctx := context.Background() + client := NewClient( + "https://api.example.com", + "your-tenant-id", + "dd-ai-table", + "your-client-secret", + ) + + bizData := QueryByUserDefineCode{ + CorpID: "dingXXXXXX", + UserDefineCode: "SUP-CODE-001", + } + dataBytes, _ := json.Marshal(bizData) + + req := &QuerySupplierByUserDefineCodeRequest{ + InvokeID: "REQ-004", + UserID: "04221***", + Data: string(dataBytes), + BizType: BizTypeQuerySupplierByUserDefine, + } + + resp, err := client.QuerySupplierByUserDefineCode(ctx, req) + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Printf("bizType=%s data=%s\n", resp.BizType, resp.Data) + // Output: +} + +// TestVerifyNotificationSignature 演示通知验签。 +func TestVerifyNotificationSignature(t *testing.T) { + verifyKey := "customer-verify-secret" + rawBody := []byte(`{"bizType":"payment","bizId":"PAY-001","data":"{}"}`) + sign := HmacSHA256Base64(verifyKey, string(rawBody)) + + ok, err := VerifyNotificationSignature(verifyKey, rawBody, sign) + if err != nil { + t.Fatalf("verify error: %v", err) + } + if !ok { + t.Fatal("signature mismatch") + } +} + +// TestParsePaymentNotify 演示解析支付完成通知。 +func TestParsePaymentNotify(t *testing.T) { + body := []byte(`{ + "code":"PAY-001", + "instanceId":"inst-001", + "corpId":"dingXXXXXX", + "paymentStatus":"SUCCESS", + "paymentTime":"2024-01-01 10:00:00", + "userId":"04221***", + "amount":"11300.00" + }`) + n, err := ParsePaymentNotify(body) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if n.PaymentStatus != PaymentStatusSuccess { + t.Fatalf("unexpected status: %s", n.PaymentStatus) + } +} +``` + +=== SDK 生成完成 === \ No newline at end of file