678 lines
27 KiB
Go
678 lines
27 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
|
|
)
|
|
|
|
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 = matchPurchaseSpecWithAI(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)
|
|
}
|
|
|
|
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
|
|
}
|