package m2mcore import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" ) // PostgresRepository — реализация Repository через jackc/pgx. // Использует таблицы m2m_core.deals и m2m_core.deal_events // (см. migrations/m2m-core/001__deals.sql и 002__stages.sql). type PostgresRepository struct { pool *pgxpool.Pool } // NewPostgresRepository подключается по DSN (postgres://user:pass@host:port/db). func NewPostgresRepository(ctx context.Context, dsn string) (*PostgresRepository, error) { if dsn == "" { return nil, errors.New("m2mcore: PostgresRepository: пустой DSN") } cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("pgxpool.ParseConfig: %w", err) } cfg.MaxConns = 10 cfg.MaxConnLifetime = 30 * time.Minute pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("pgxpool.NewWithConfig: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("pool.Ping: %w", err) } return &PostgresRepository{pool: pool}, nil } // Close закрывает пул. func (r *PostgresRepository) Close() { if r.pool != nil { r.pool.Close() } } // Create вставляет сделку идемпотентно по guid. Если запись с таким // guid уже есть, возвращает её (без модификации). func (r *PostgresRepository) Create(ctx context.Context, deal *Deal) (*Deal, error) { reqXML, _ := marshalXMLIfPresent(deal.Request) respXML := responseBytes(deal) decisionXML, _ := marshalXMLIfPresent(deal.Decision) stages, err := json.Marshal(deal.Stages) if err != nil { return nil, err } const q = ` INSERT INTO m2m_core.deals (id, guid, state, investor_id, signed_claim, request_xml, response_xml, decision_xml, stages, created_at, updated_at) VALUES ($1, $2, $3, NULLIF($4, '')::uuid, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (guid) DO NOTHING RETURNING id` var id string err = r.pool.QueryRow(ctx, q, deal.ID, string(deal.GUID), string(deal.State), deal.InvestorID, deal.SignedClaim, reqXML, respXML, decisionXML, stages, deal.CreatedAt, deal.UpdatedAt, ).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { // уже существовала — вернём существующую return r.GetByGUID(ctx, deal.GUID) } if err != nil { return nil, fmt.Errorf("PostgresRepository.Create: %w", err) } return deal, nil } // GetByGUID находит сделку по M2M GUID. func (r *PostgresRepository) GetByGUID(ctx context.Context, guid m2m.UUID) (*Deal, error) { return r.scanOne(ctx, `WHERE guid = $1`, string(guid)) } // GetByID находит сделку по внутреннему UUID. func (r *PostgresRepository) GetByID(ctx context.Context, id string) (*Deal, error) { return r.scanOne(ctx, `WHERE id = $1`, id) } // Update сохраняет полное состояние сделки (для простоты — без diff). func (r *PostgresRepository) Update(ctx context.Context, deal *Deal) error { reqXML, _ := marshalXMLIfPresent(deal.Request) respXML := responseBytes(deal) decisionXML, _ := marshalXMLIfPresent(deal.Decision) stages, err := json.Marshal(deal.Stages) if err != nil { return err } const q = ` UPDATE m2m_core.deals SET state = $2, request_xml = $3, response_xml = $4, decision_xml = $5, stages = $6, updated_at = $7 WHERE id = $1` tag, err := r.pool.Exec(ctx, q, deal.ID, string(deal.State), reqXML, respXML, decisionXML, stages, deal.UpdatedAt, ) if err != nil { return fmt.Errorf("PostgresRepository.Update: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // List возвращает сделки по фильтру. func (r *PostgresRepository) List(ctx context.Context, f Filter) ([]*Deal, error) { args := []any{} where := "" add := func(cond string, val any) { args = append(args, val) if where == "" { where = " WHERE " } else { where += " AND " } where += fmt.Sprintf(cond, len(args)) } if f.State != nil { add(`state = $%d`, string(*f.State)) } if f.InvestorID != "" { add(`investor_id = $%d::uuid`, f.InvestorID) } if f.CreatedFrom != nil { add(`created_at >= $%d`, *f.CreatedFrom) } if f.CreatedTo != nil { add(`created_at <= $%d`, *f.CreatedTo) } limit := f.Limit if limit <= 0 { limit = 50 } offset := f.Offset q := dealsSelectSQL() + where + fmt.Sprintf(" ORDER BY created_at DESC LIMIT %d OFFSET %d", limit, offset) rows, err := r.pool.Query(ctx, q, args...) if err != nil { return nil, fmt.Errorf("PostgresRepository.List: %w", err) } defer rows.Close() out := make([]*Deal, 0) for rows.Next() { d, err := scanRow(rows) if err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // AppendEvent добавляет аудит-событие сделки. func (r *PostgresRepository) AppendEvent(ctx context.Context, dealID string, ev Event) error { payload, _ := json.Marshal(ev.Payload) const q = ` INSERT INTO m2m_core.deal_events (deal_id, type, payload, actor, created_at) VALUES ($1::uuid, $2, $3, $4, $5)` _, err := r.pool.Exec(ctx, q, dealID, ev.Type, payload, ev.Actor, ev.CreatedAt) if err != nil { return fmt.Errorf("PostgresRepository.AppendEvent: %w", err) } return nil } // scanOne возвращает одну запись по where-условию. func (r *PostgresRepository) scanOne(ctx context.Context, where string, args ...any) (*Deal, error) { q := dealsSelectSQL() + " " + where row := r.pool.QueryRow(ctx, q, args...) d, err := scanRow(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return d, err } // rowScanner объединяет pgx.Row и pgx.Rows для общего scanRow. type rowScanner interface { Scan(dest ...any) error } // scanRow читает одну строку deals в *Deal. func scanRow(r rowScanner) (*Deal, error) { var ( id, guid, state string investorID *string signedClaim []byte reqXML, respXML, decisionX []byte stages []byte createdAt, updatedAt time.Time ) if err := r.Scan(&id, &guid, &state, &investorID, &signedClaim, &reqXML, &respXML, &decisionX, &stages, &createdAt, &updatedAt); err != nil { return nil, err } d := &Deal{ ID: id, GUID: m2m.UUID(guid), State: State(state), SignedClaim: signedClaim, CreatedAt: createdAt, UpdatedAt: updatedAt, } if investorID != nil { d.InvestorID = *investorID } if len(reqXML) > 0 { var v m2m.M2MTransferRequest if err := nsdxml.Unmarshal(reqXML, &v); err == nil { d.Request = &v } } if len(respXML) > 0 { // Сохраняем точные байты для дословного показа/пересылки в ТП НРД. d.RawResponse = respXML var v m2m.M2MTransferResponse if err := nsdxml.Unmarshal(respXML, &v); err == nil { d.Response = &v } } if len(decisionX) > 0 { var v m2m.M2MTransferDecision if err := nsdxml.Unmarshal(decisionX, &v); err == nil { d.Decision = &v } } if len(stages) > 0 { _ = json.Unmarshal(stages, &d.Stages) } return d, nil } // dealsSelectSQL — SELECT с фиксированным порядком колонок. func dealsSelectSQL() string { return `SELECT id, guid, state, investor_id, signed_claim, request_xml, response_xml, decision_xml, stages, created_at, updated_at FROM m2m_core.deals` } // responseBytes возвращает байты ответа МОСТ для записи в response_xml: // точные байты от НРД (RawResponse), если они есть, иначе пере-сериализация // разобранной структуры. Точные байты предпочтительны — их можно дословно // переслать в техподдержку НРД. func responseBytes(deal *Deal) []byte { if len(deal.RawResponse) > 0 { return deal.RawResponse } b, _ := marshalXMLIfPresent(deal.Response) return b } // marshalXMLIfPresent сериализует *T в windows-1251 XML (или возвращает nil). func marshalXMLIfPresent(v any) ([]byte, error) { if v == nil { return nil, nil } // Через reflect не используем — у nsdxml.Marshal вход — interface{}. // Здесь же поступает *m2m.M2M... — проверим nil-ness через type-switch. switch x := v.(type) { case *m2m.M2MTransferRequest: if x == nil { return nil, nil } return nsdxml.Marshal(x) case *m2m.M2MTransferResponse: if x == nil { return nil, nil } return nsdxml.Marshal(x) case *m2m.M2MTransferDecision: if x == nil { return nil, nil } return nsdxml.Marshal(x) } return nil, nil } // _ assertions var ( _ Repository = (*PostgresRepository)(nil) )