Files
cmautobuy/admin/service/purchase_spec_resolution.go
T

690 lines
28 KiB
Go

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
}