package service import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "reflect" "regexp" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) const ( // MaxPurchaseSpecResolutionBodyBytes 是 Client 运行时规格解析请求的硬上限。 MaxPurchaseSpecResolutionBodyBytes = 64 << 10 PurchaseSpecRulesVersion = "purchase_rules_v1" purchaseSpecSchemaVersion = 1 purchaseSpecResolutionAITimeout = 30 * time.Second ) var ( ErrInvalidSpecResolutionBody = errors.New("采购运行时规格解析请求体无效") ErrInvalidSpecResolutionSchema = errors.New("采购运行时规格解析 schema 不支持") ErrInvalidSpecResolutionRequest = errors.New("采购运行时规格解析字段无效") ErrSpecResolutionHashMismatch = errors.New("采购运行时规格解析哈希不一致") ErrTaskNotPurchase = errors.New("任务不是采购任务") ErrTaskVersionConflict = errors.New("任务版本不一致") ErrPDDGoodsMismatch = errors.New("PDD 商品不一致") ErrSpecResolutionUnavailable = errors.New("采购运行时规格解析服务暂不可用") ) // PurchaseSpecCandidate 是 Client 当前页面上一个可购买尺码的逐字快照。 // candidate_id 只在本次请求内有效,AI 只能从这些短编号中选择。 type PurchaseSpecCandidate struct { CandidateID string `json:"candidate_id"` RawText string `json:"raw_text"` Options map[string]string `json:"options"` } // PurchaseSpecResolutionRequest 是 docs/client/04-admin-api-contract.md §7.1 的请求体。 type PurchaseSpecResolutionRequest struct { SchemaVersion int `json:"schema_version"` TaskVersion int64 `json:"task_version"` AttemptID string `json:"attempt_id"` PddGoodsID string `json:"pdd_goods_id"` OriginalOptions map[string]string `json:"original_options"` SelectedColor string `json:"selected_color"` TargetSize string `json:"target_size"` Candidates []PurchaseSpecCandidate `json:"candidates"` CandidateSnapshotHash string `json:"candidate_snapshot_hash"` ObservedAt string `json:"observed_at"` } type PurchaseSpecResolutionMatch struct { CandidateID string `json:"candidate_id"` RawText string `json:"raw_text"` Options map[string]string `json:"options"` } // PurchaseSpecResolutionResponse 只返回请求候选的逐字副本,不返回模型生成的新规格。 type PurchaseSpecResolutionResponse struct { SchemaVersion int `json:"schema_version"` ResolutionID string `json:"resolution_id"` Outcome model.PurchaseSpecResolutionOutcome `json:"outcome"` Source *model.PurchaseSpecResolutionSource `json:"source"` CandidateSnapshotHash string `json:"candidate_snapshot_hash"` Match *PurchaseSpecResolutionMatch `json:"match"` ConfidenceBPS *int `json:"confidence_bps"` Reason string `json:"reason"` ResolvedAt string `json:"resolved_at"` } type purchaseSpecDecision struct { Outcome model.PurchaseSpecResolutionOutcome Source model.PurchaseSpecResolutionSource Candidate *PurchaseSpecCandidate ConfidenceBPS int ConfidenceSet bool Reason string ProviderID string SourceModel string Fingerprint string PromptVersion string } type purchaseSpecGate struct { token chan struct{} refs int } var purchaseSpecGates = struct { sync.Mutex items map[string]*purchaseSpecGate }{items: map[string]*purchaseSpecGate{}} // ResolvePurchaseSpec 保存一次真机候选观察,先跑确定性规则,确实不能唯一判断时才调用 AI。 // 它只写解析审计和幂等响应,不修改任务、PDD 主数据或任何下单安全字段。 func ResolvePurchaseSpec(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy, taskID, clientID, idempotencyKey string, rawBody []byte) (string, error) { return resolvePurchaseSpecWithSnapshotLoader(ctx, db, secrets, policy, taskID, clientID, idempotencyKey, rawBody, LoadActiveAIMatchSnapshot) } type aiSnapshotLoader func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) func resolvePurchaseSpecWithSnapshotLoader(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy, taskID, clientID, idempotencyKey string, rawBody []byte, loadSnapshot aiSnapshotLoader) (string, error) { req, err := parsePurchaseSpecResolutionRequest(taskID, idempotencyKey, rawBody) if err != nil { return "", err } requestHash := repository.HashRequest(rawBody) // 同一 Admin 进程内,同一个业务身份只允许一个协程进入模型调用。 // 锁不包含数据库事务;不同任务仍可以并行解析。 release, err := acquirePurchaseSpecGate(ctx, idempotencyKey) if err != nil { return "", fmt.Errorf("%w: %v", ErrSpecResolutionUnavailable, err) } defer release() resolution, leader, replay, err := beginPurchaseSpecResolution( db, taskID, clientID, idempotencyKey, requestHash, req) if err != nil || replay != "" { return replay, err } if !leader && resolution.Outcome != model.PurchaseSpecResolutionPending { return saveReconstructedPurchaseSpecResponse(db, idempotencyKey, requestHash, resolution, req.Candidates) } // pending 可能来自进程中断后的原请求。因为本进程已取得该业务身份的唯一门禁, // 可以安全接管;最终 UPDATE 仍只允许 pending 完成一次。 decision, terminal := matchPurchaseSpecByRule(req.TargetSize, req.Candidates) if !terminal { decision = matchPurchaseSpecWithRuntimeAI(ctx, db, secrets, policy, req, loadSnapshot) } if purchaseSpecDiagnosticSource(db, req.PddGoodsID) == "shopee_backfill" { decision.Reason = truncateRunes(decision.Reason+";诊断来源:shopee_backfill", 500) } return completePurchaseSpecResolution(db, idempotencyKey, requestHash, resolution, req.Candidates, decision) } // matchPurchaseSpecWithRuntimeAI 给采购运行时 AI 单独设置 30 秒上限。 // Client 会等待最多 60 秒,因此 Admin 有足够时间保存安全结论并返回;如果请求更早取消, // 子上下文也会立即取消,不会让模型调用脱离原 HTTP 请求继续运行。 func matchPurchaseSpecWithRuntimeAI(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy, req PurchaseSpecResolutionRequest, loadSnapshot aiSnapshotLoader) purchaseSpecDecision { aiContext, cancel := context.WithTimeout(ctx, purchaseSpecResolutionAITimeout) defer cancel() return matchPurchaseSpecWithAI(aiContext, db, secrets, policy, req, loadSnapshot) } func parsePurchaseSpecResolutionRequest(taskID, idempotencyKey string, rawBody []byte) (PurchaseSpecResolutionRequest, error) { var req PurchaseSpecResolutionRequest if len(rawBody) == 0 || len(rawBody) > MaxPurchaseSpecResolutionBodyBytes || !utf8.Valid(rawBody) { return req, ErrInvalidSpecResolutionBody } if err := json.Unmarshal(rawBody, &req); err != nil { return req, ErrInvalidSpecResolutionBody } if req.SchemaVersion != purchaseSpecSchemaVersion { return req, ErrInvalidSpecResolutionSchema } if req.TaskVersion <= 0 || !validSpecText(req.AttemptID) || !validSpecText(req.PddGoodsID) || !validSpecText(req.SelectedColor) || !validSpecText(req.TargetSize) || len(req.OriginalOptions) < 1 || len(req.OriginalOptions) > 16 || len(req.Candidates) < 1 || len(req.Candidates) > 100 { return req, ErrInvalidSpecResolutionRequest } for key, value := range req.OriginalOptions { if !validSpecText(key) || !validSpecText(value) { return req, ErrInvalidSpecResolutionRequest } } for index, candidate := range req.Candidates { if candidate.CandidateID != "c"+strconv.Itoa(index+1) || !validSpecText(candidate.RawText) || len(candidate.Options) != 2 || candidate.Options["color"] != req.SelectedColor || candidate.Options["size"] != candidate.RawText { return req, ErrInvalidSpecResolutionRequest } if _, ok := candidate.Options["color"]; !ok { return req, ErrInvalidSpecResolutionRequest } if _, ok := candidate.Options["size"]; !ok { return req, ErrInvalidSpecResolutionRequest } } if _, err := time.Parse(time.RFC3339Nano, req.ObservedAt); err != nil { return req, ErrInvalidSpecResolutionRequest } expectedSnapshot := purchaseSpecCandidateSnapshotHash(req) if req.CandidateSnapshotHash != expectedSnapshot || idempotencyKey != purchaseSpecIdempotencyKey(taskID, req) { return req, ErrSpecResolutionHashMismatch } return req, nil } func validSpecText(value string) bool { if value == "" || utf8.RuneCountInString(value) > 191 { return false } for _, r := range value { if unicode.IsControl(r) { return false } } return true } func purchaseSpecCandidateSnapshotHash(req PurchaseSpecResolutionRequest) string { var material strings.Builder material.WriteString(specFrame("spec-resolution-v1")) material.WriteString(specFrame(req.PddGoodsID)) material.WriteString(specFrame(req.SelectedColor)) material.WriteString(specFrame(strconv.Itoa(len(req.Candidates)))) for _, candidate := range req.Candidates { material.WriteString(specFrame(candidate.CandidateID)) material.WriteString(specFrame(candidate.RawText)) material.WriteString(specFrame(candidate.Options["color"])) material.WriteString(specFrame(candidate.Options["size"])) } return sha256Hex(material.String()) } func purchaseSpecIdempotencyKey(taskID string, req PurchaseSpecResolutionRequest) string { material := specFrame(taskID) + specFrame(req.AttemptID) + specFrame(req.CandidateSnapshotHash) + specFrame("spec-resolution-v1") return "spec-resolution-v1:" + sha256Hex(material) } func specFrame(value string) string { return strconv.Itoa(len([]byte(value))) + ":" + value } func sha256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func acquirePurchaseSpecGate(ctx context.Context, key string) (func(), error) { purchaseSpecGates.Lock() gate := purchaseSpecGates.items[key] if gate == nil { gate = &purchaseSpecGate{token: make(chan struct{}, 1)} purchaseSpecGates.items[key] = gate } gate.refs++ purchaseSpecGates.Unlock() select { case gate.token <- struct{}{}: return func() { <-gate.token purchaseSpecGates.Lock() gate.refs-- if gate.refs == 0 { delete(purchaseSpecGates.items, key) } purchaseSpecGates.Unlock() }, nil case <-ctx.Done(): purchaseSpecGates.Lock() gate.refs-- if gate.refs == 0 { delete(purchaseSpecGates.items, key) } purchaseSpecGates.Unlock() return nil, ctx.Err() } } func beginPurchaseSpecResolution(db *sql.DB, taskID, clientID, idempotencyKey, requestHash string, req PurchaseSpecResolutionRequest) (*model.PurchaseSpecResolution, bool, string, error) { tx, err := db.Begin() if err != nil { return nil, false, "", fmt.Errorf("%w: 开始候选观察事务失败", ErrSpecResolutionUnavailable) } defer tx.Rollback() if body, done, lookupErr := repository.LookupIdempotent(tx, idempotencyKey, requestHash); lookupErr != nil { if errors.Is(lookupErr, repository.ErrIdempotencyConflict) { return nil, false, "", ErrIdempotencyConflict } return nil, false, "", fmt.Errorf("%w: 查询幂等结果失败", ErrSpecResolutionUnavailable) } else if done { return nil, false, body, nil } info, err := repository.GetTaskInfo(tx, taskID) if err != nil { return nil, false, "", fmt.Errorf("%w: 查询任务失败", ErrSpecResolutionUnavailable) } if info == nil { return nil, false, "", ErrTaskNotFound } if info.TaskType != model.TaskPurchase { return nil, false, "", ErrTaskNotPurchase } if info.Version != req.TaskVersion { return nil, false, "", ErrTaskVersionConflict } if info.PddGoodsID != req.PddGoodsID { return nil, false, "", ErrPDDGoodsMismatch } if !taskOptionsEqual(info.PddOptions, req.OriginalOptions) || !targetOptionWasClaimed(req.OriginalOptions, "color", req.SelectedColor) || !targetOptionWasClaimed(req.OriginalOptions, "size", req.TargetSize) { return nil, false, "", ErrInvalidSpecResolutionRequest } claimed, err := repository.HasEverClaimed(tx, taskID, clientID) if err != nil { return nil, false, "", fmt.Errorf("%w: 查询任务领取历史失败", ErrSpecResolutionUnavailable) } if !claimed { return nil, false, "", ErrNeverClaimed } originalJSON, _ := json.Marshal(req.OriginalOptions) candidatesJSON, _ := json.Marshal(req.Candidates) resolution := &model.PurchaseSpecResolution{ ResolutionID: "psr-" + newID(), TaskID: taskID, AttemptID: req.AttemptID, ClientID: clientID, TaskVersion: req.TaskVersion, PddGoodsID: req.PddGoodsID, OriginalOptionsJSON: string(originalJSON), SelectedColor: req.SelectedColor, TargetSize: req.TargetSize, CandidatesJSON: string(candidatesJSON), CandidateSnapshotHash: req.CandidateSnapshotHash, RequestHash: requestHash, ObservedAt: req.ObservedAt, Outcome: model.PurchaseSpecResolutionPending, CreatedAt: model.NowISO(), } if err := repository.InsertPurchaseSpecResolution(tx, *resolution); err != nil { if !errors.Is(err, repository.ErrPurchaseSpecResolutionExists) { return nil, false, "", fmt.Errorf("%w: 保存候选观察失败", ErrSpecResolutionUnavailable) } // MySQL 默认 REPEATABLE READ:本事务前面已经做过一致性读,唯一键等待 // 另一事务提交后,继续在原事务查询仍可能看不到赢家。先回滚再用新快照复查。 if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { return nil, false, "", fmt.Errorf("%w: 回滚重复候选观察失败", ErrSpecResolutionUnavailable) } existing, replayErr := repository.GetPurchaseSpecResolutionForReplay( db, taskID, req.AttemptID, req.CandidateSnapshotHash, requestHash) if errors.Is(replayErr, repository.ErrPurchaseSpecResolutionConflict) { return nil, false, "", ErrIdempotencyConflict } if replayErr != nil || existing == nil { return nil, false, "", fmt.Errorf("%w: 读取已有候选观察失败", ErrSpecResolutionUnavailable) } return existing, false, "", nil } if err := tx.Commit(); err != nil { return nil, false, "", fmt.Errorf("%w: 提交候选观察失败", ErrSpecResolutionUnavailable) } return resolution, true, "", nil } func taskOptionsEqual(raw string, submitted map[string]string) bool { var expected map[string]string return json.Unmarshal([]byte(raw), &expected) == nil && reflect.DeepEqual(expected, submitted) } func targetOptionWasClaimed(options map[string]string, preferredKey, target string) bool { if value, ok := options[preferredKey]; ok { return value == target } for _, value := range options { if value == target { return true } } return false } func purchaseSpecDiagnosticSource(db *sql.DB, goodsID string) string { product, err := repository.GetPddProductByGoodsID(db, goodsID) if err != nil || product == nil || strings.TrimSpace(product.SkusJSON) == "" { return "" } var metadata struct { SpecSource string `json:"spec_source"` } if json.Unmarshal([]byte(product.SkusJSON), &metadata) != nil { return "" } return strings.TrimSpace(metadata.SpecSource) } var runtimeWeightPattern = regexp.MustCompile(`(?i)^\s*(\d+(?:\.\d+)?)\s*(?:[-~~—–至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|kg|kgs|千克|斤)\s*$`) type runtimeWeightRange struct{ fromJin, toJin float64 } func matchPurchaseSpecByRule(target string, candidates []PurchaseSpecCandidate) (purchaseSpecDecision, bool) { matches := make([]PurchaseSpecCandidate, 0, 1) for _, candidate := range candidates { if runtimeSpecsEquivalent(target, candidate.RawText) { matches = append(matches, candidate) } } if len(matches) == 1 { return purchaseSpecDecision{Outcome: model.PurchaseSpecResolutionMatched, Source: model.PurchaseSpecResolutionRule, Candidate: &matches[0], ConfidenceBPS: 10000, ConfidenceSet: true, Reason: "目标尺码与唯一真机候选按确定性规则等价"}, true } if len(matches) > 1 { return purchaseSpecDecision{Outcome: model.PurchaseSpecResolutionUncertain, Source: model.PurchaseSpecResolutionRule, Reason: "存在多个与目标尺码等价的真机候选,不能安全自动选择"}, true } // 两个重量区间同时覆盖目标时,模型也没有可靠信号判断页面上的业务边界, // 直接返回不确定,避免让语言模型替代真实尺码规则猜一个。 if targetWeight, ok := parseRuntimeWeightRange(target); ok { overlaps := 0 for _, candidate := range candidates { if candidateWeight, candidateOK := parseRuntimeWeightRange(candidate.RawText); candidateOK && weightRangesOverlap(targetWeight, candidateWeight) { overlaps++ } } if overlaps > 1 { return purchaseSpecDecision{Outcome: model.PurchaseSpecResolutionUncertain, Source: model.PurchaseSpecResolutionRule, Reason: "目标重量区间同时落入多个真机候选,不能安全自动选择"}, true } } return purchaseSpecDecision{}, false } func runtimeSpecsEquivalent(left, right string) bool { a, okA := parseRuntimeWeightRange(left) b, okB := parseRuntimeWeightRange(right) if okA || okB { return okA && okB && almostEqual(a.fromJin, b.fromJin) && almostEqual(a.toJin, b.toJin) } return normalizeRuntimeSpec(left) == normalizeRuntimeSpec(right) } func normalizeRuntimeSpec(raw string) string { raw = simplifyExplicit(raw) for _, pair := range []struct{ from, to string }{ {"體", "体"}, {"號", "号"}, {"圍", "围"}, {"寬", "宽"}, {"適", "适"}, {"齡", "龄"}, {"歲", "岁"}, {"單", "单"}, {"雙", "双"}, {"釐", "厘"}, {"臺", "台"}, {"兒", "儿"}, {"婦", "妇"}, {"標", "标"}, {"準", "准"}, {"顏", "颜"}, {"議", "议"}, {"鬆", "松"}, {"褲", "裤"}, {"絨", "绒"}, {"寶", "宝"}, {"嬰", "婴"}, } { raw = strings.ReplaceAll(raw, pair.from, pair.to) } var result strings.Builder for _, r := range strings.ToLower(raw) { // 加减号和范围符号可能是尺码语义(例如 M+、50-60kg),不能为了 // “忽略符号”把两个不同规格折叠成同一个。斜杠、括号、冒号等展示 // 分隔符才可以安全忽略。 if strings.ContainsRune("+-~~—–至到", r) { result.WriteRune(r) continue } if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { continue } result.WriteRune(r) } return result.String() } func parseRuntimeWeightRange(raw string) (runtimeWeightRange, bool) { raw = strings.TrimFunc(raw, func(r rune) bool { return (unicode.IsPunct(r) || unicode.IsSymbol(r)) && !strings.ContainsRune("+-~~—–", r) }) match := runtimeWeightPattern.FindStringSubmatch(simplifyExplicit(raw)) if len(match) == 0 { return runtimeWeightRange{}, false } from, err := strconv.ParseFloat(match[1], 64) if err != nil || from <= 0 { return runtimeWeightRange{}, false } to := from if match[2] != "" { to, err = strconv.ParseFloat(match[2], 64) if err != nil || to <= 0 || from > to { return runtimeWeightRange{}, false } } if unit := strings.ToLower(match[3]); unit == "公斤" || unit == "kg" || unit == "kgs" || unit == "千克" { from *= 2 to *= 2 } return runtimeWeightRange{fromJin: from, toJin: to}, true } func weightRangesOverlap(left, right runtimeWeightRange) bool { return left.fromJin <= right.toJin && right.fromJin <= left.toJin } func almostEqual(left, right float64) bool { delta := left - right if delta < 0 { delta = -delta } return delta < 0.000001 } func matchPurchaseSpecWithAI(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy, req PurchaseSpecResolutionRequest, loadSnapshot aiSnapshotLoader) purchaseSpecDecision { snapshot, err := loadSnapshot(ctx, db, secrets, policy) if err != nil { return purchaseSpecDecision{Outcome: model.PurchaseSpecResolutionFailed, Reason: truncateRunes("AI 运行配置不可用:"+safeAIError(err), 500)} } base := purchaseSpecDecision{Source: model.PurchaseSpecResolutionAI, ProviderID: snapshot.Provider.ProviderID, SourceModel: snapshot.Provider.Model, Fingerprint: snapshot.ConfigFingerprint, PromptVersion: AISpecMatchPromptVersion} request := AIModelMatchRequest{ SourceSpec: "已选择颜色:" + truncateRunes(req.SelectedColor, 191) + ";任务目标尺码:" + truncateRunes(req.TargetSize, 191), } for _, candidate := range req.Candidates { request.Candidates = append(request.Candidates, AIModelCandidate{ ID: candidate.CandidateID, Label: candidate.RawText, Options: boundedAIOptions(candidate.Options), }) } selection := selectAIWhitelistedCandidate(ctx, snapshot, request) response := selection.Response base.ConfidenceBPS, base.ConfidenceSet, base.Reason = response.ConfidenceBPS, selection.ConfidenceSet, selection.Reason switch selection.Status { case aiSelectionFailed: base.Outcome = model.PurchaseSpecResolutionFailed return base case aiSelectionUncertain, aiSelectionDimensionsUnresolved, aiSelectionBelowThreshold: base.Outcome = model.PurchaseSpecResolutionUncertain return base case aiSelectionConflict, aiSelectionCandidateRejected: base.Outcome = model.PurchaseSpecResolutionRejected return base } var selected *PurchaseSpecCandidate for index := range req.Candidates { if req.Candidates[index].CandidateID == response.CandidateID { selected = &req.Candidates[index] break } } if selected == nil { base.Outcome, base.Reason = model.PurchaseSpecResolutionRejected, "候选绑定已变化,未返回匹配" return base } base.Outcome, base.Candidate = model.PurchaseSpecResolutionMatched, selected return base } func completePurchaseSpecResolution(db *sql.DB, idempotencyKey, requestHash string, resolution *model.PurchaseSpecResolution, candidates []PurchaseSpecCandidate, result purchaseSpecDecision) (string, error) { tx, err := db.Begin() if err != nil { return "", fmt.Errorf("%w: 开始保存解析结论事务失败", ErrSpecResolutionUnavailable) } defer tx.Rollback() if body, done, lookupErr := repository.LookupIdempotent(tx, idempotencyKey, requestHash); lookupErr != nil { if errors.Is(lookupErr, repository.ErrIdempotencyConflict) { return "", ErrIdempotencyConflict } return "", fmt.Errorf("%w: 查询幂等结果失败", ErrSpecResolutionUnavailable) } else if done { return body, nil } decidedAt := model.NowISO() decision := model.PurchaseSpecResolutionDecision{ ResolutionID: resolution.ResolutionID, Outcome: result.Outcome, DecisionSource: result.Source, ConfidenceBPS: result.ConfidenceBPS, ConfidenceSet: result.ConfidenceSet, Reason: truncateRunes(result.Reason, 500), ProviderID: result.ProviderID, SourceModel: result.SourceModel, ConfigFingerprint: result.Fingerprint, RulesVersion: PurchaseSpecRulesVersion, PromptVersion: result.PromptVersion, DecidedAt: decidedAt, } if result.Candidate != nil { decision.ChosenCandidateID = result.Candidate.CandidateID resolvedJSON, _ := json.Marshal(result.Candidate.Options) decision.ResolvedOptionsJSON = string(resolvedJSON) } if err := repository.CompletePurchaseSpecResolution(tx, decision); err != nil { if errors.Is(err, repository.ErrPurchaseSpecResolutionCompleted) { if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { return "", fmt.Errorf("回滚并发规格解析失败: %w", rollbackErr) } existing, readErr := repository.GetPurchaseSpecResolutionForReplay( db, resolution.TaskID, resolution.AttemptID, resolution.CandidateSnapshotHash, requestHash) if readErr != nil || existing == nil || existing.Outcome == model.PurchaseSpecResolutionPending { return "", fmt.Errorf("%w: 读取并发解析结论失败", ErrSpecResolutionUnavailable) } return saveReconstructedPurchaseSpecResponse(db, idempotencyKey, requestHash, existing, candidates) } return "", fmt.Errorf("%w: 保存解析结论失败", ErrSpecResolutionUnavailable) } response := purchaseSpecResponse(resolution.ResolutionID, resolution.CandidateSnapshotHash, decidedAt, result.Outcome, result.Source, result.Candidate, result.ConfidenceBPS, result.ConfidenceSet, decision.Reason) body, err := json.Marshal(response) if err != nil { return "", fmt.Errorf("序列化规格解析响应失败: %w", err) } if err := repository.SaveIdempotent(tx, idempotencyKey, requestHash, string(body)); err != nil { if errors.Is(err, repository.ErrIdempotencyAlreadySaved) { if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { return "", fmt.Errorf("回滚重复规格解析失败: %w", rollbackErr) } replayed, done, lookupErr := repository.LookupIdempotent(db, idempotencyKey, requestHash) if lookupErr != nil || !done { return "", fmt.Errorf("%w: 读取并发幂等响应失败", ErrSpecResolutionUnavailable) } return replayed, nil } return "", fmt.Errorf("%w: 保存规格解析幂等响应失败", ErrSpecResolutionUnavailable) } if err := repository.TouchClient(tx, resolution.ClientID); err != nil { return "", fmt.Errorf("%w: 刷新客户端活动时间失败", ErrSpecResolutionUnavailable) } if err := tx.Commit(); err != nil { return "", fmt.Errorf("%w: 提交规格解析结论失败", ErrSpecResolutionUnavailable) } return string(body), nil } func saveReconstructedPurchaseSpecResponse(db *sql.DB, idempotencyKey, requestHash string, resolution *model.PurchaseSpecResolution, candidates []PurchaseSpecCandidate) (string, error) { var candidate *PurchaseSpecCandidate for index := range candidates { if candidates[index].CandidateID == resolution.ChosenCandidateID { candidate = &candidates[index] break } } if resolution.Outcome == model.PurchaseSpecResolutionMatched && candidate == nil { return "", fmt.Errorf("%w: 已保存结论的候选编号不在请求快照中", ErrSpecResolutionUnavailable) } response := purchaseSpecResponse(resolution.ResolutionID, resolution.CandidateSnapshotHash, resolution.DecidedAt, resolution.Outcome, resolution.DecisionSource, candidate, resolution.ConfidenceBPS, resolution.ConfidenceSet, resolution.Reason) body, err := json.Marshal(response) if err != nil { return "", err } tx, err := db.Begin() if err != nil { return "", fmt.Errorf("%w: 开始恢复幂等响应事务失败", ErrSpecResolutionUnavailable) } defer tx.Rollback() if replayed, done, lookupErr := repository.LookupIdempotent(tx, idempotencyKey, requestHash); lookupErr != nil { if errors.Is(lookupErr, repository.ErrIdempotencyConflict) { return "", ErrIdempotencyConflict } return "", lookupErr } else if done { return replayed, nil } if err := repository.SaveIdempotent(tx, idempotencyKey, requestHash, string(body)); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return string(body), nil } func purchaseSpecResponse(resolutionID, snapshotHash, resolvedAt string, outcome model.PurchaseSpecResolutionOutcome, source model.PurchaseSpecResolutionSource, candidate *PurchaseSpecCandidate, confidence int, confidenceSet bool, reason string) PurchaseSpecResolutionResponse { response := PurchaseSpecResolutionResponse{SchemaVersion: purchaseSpecSchemaVersion, ResolutionID: resolutionID, Outcome: outcome, CandidateSnapshotHash: snapshotHash, Reason: reason, ResolvedAt: resolvedAt} if source != "" { value := source response.Source = &value } if confidenceSet { value := confidence response.ConfidenceBPS = &value } if candidate != nil { response.Match = &PurchaseSpecResolutionMatch{CandidateID: candidate.CandidateID, RawText: candidate.RawText, Options: candidate.Options} } return response }