Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -0,0 +1,485 @@
|
||||
// Package ledger ist die Datenbank des Accountants (accountant.db, Spec
|
||||
// 5.3): registrierte Payees (meldende Sultan-Server), ihre Einladungen und
|
||||
// Einstellungen. der jeweils letzte Bericht je Payee und die Historie (Zeiträume je Ort).
|
||||
//
|
||||
// Bewusst getrennt von sultan.db: andere Lebensdauer (Historie über Jahre)
|
||||
// und anderes Vertrauen (Berichte sind Eingaben fremder Server). Migrationen
|
||||
// und Zeitformat kommen aus pkg/store; Fehler sind die von pkg/store
|
||||
// (ErrNotFound, ErrExists, ErrInvalid, ErrTokenUsed, ErrTokenExpired).
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// migrations: nur anhängen, nie ändern (siehe store.OpenDB).
|
||||
var migrations = []string{
|
||||
`CREATE TABLE payees (
|
||||
id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT NOT NULL,
|
||||
enrolled_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE invites (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
used_at DATETIME
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
// v2: letzter Bericht je Payee.
|
||||
`CREATE TABLE reports (
|
||||
payee_id TEXT PRIMARY KEY REFERENCES payees(id) ON DELETE CASCADE,
|
||||
received_at DATETIME NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);`,
|
||||
// v3: Historie – Zeiträume je Ort (Payee, Mandant, Knoten, Protokoll,
|
||||
// Router). Ohne Fremdschlüssel: bleibt, wenn ein Payee gelöscht wird.
|
||||
`CREATE TABLE route_spans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
payee_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
node TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
hosts TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
backend_fqdn TEXT NOT NULL,
|
||||
workload TEXT NOT NULL,
|
||||
compose_dir TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
ended_at DATETIME
|
||||
);
|
||||
CREATE INDEX route_spans_payee_open ON route_spans(payee_id, ended_at);
|
||||
CREATE INDEX route_spans_tenant ON route_spans(tenant);`,
|
||||
// v4: Kunden und die Zuordnung von Mandanten (payee_id '' = alle Payees).
|
||||
`CREATE TABLE customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
cycle TEXT NOT NULL DEFAULT '',
|
||||
flat_rate INTEGER,
|
||||
auto INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE customer_map (
|
||||
payee_id TEXT NOT NULL DEFAULT '',
|
||||
tenant TEXT NOT NULL,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (payee_id, tenant)
|
||||
);`,
|
||||
// v5: Produktkatalog (Klasse/Preis je Monat), Kundenpreise, Zuordnung je
|
||||
// Posten (Mandant, Protokoll, Router).
|
||||
`CREATE TABLE products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE product_versions (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
price INTEGER,
|
||||
PRIMARY KEY (product_id, valid_from)
|
||||
);
|
||||
CREATE TABLE customer_prices (
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
valid_from TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
PRIMARY KEY (product_id, customer_id, valid_from)
|
||||
);
|
||||
CREATE TABLE assignments (
|
||||
tenant TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
router TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL DEFAULT '',
|
||||
class TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (tenant, protocol, router)
|
||||
);`,
|
||||
}
|
||||
|
||||
// Ledger ist der Zugriff auf accountant.db; parallel nutzbar.
|
||||
type Ledger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Payee ist ein registrierter, meldender Sultan-Server.
|
||||
type Payee struct {
|
||||
ID string
|
||||
Hostname string
|
||||
PublicKey string // auth.EncodePublicKey
|
||||
IPAddress string // beim Enrollment
|
||||
EnrolledAt time.Time
|
||||
LastReport time.Time // Nullzeit = noch kein Bericht
|
||||
}
|
||||
|
||||
// Open öffnet (bzw. erstellt) die Datenbank unter path.
|
||||
func Open(ctx context.Context, path string) (*Ledger, error) {
|
||||
db, err := store.OpenDB(ctx, path, migrations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Ledger{db: db, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbank.
|
||||
func (l *Ledger) Close() error { return l.db.Close() }
|
||||
|
||||
// SetClock ersetzt die Uhr (Tests mit festen Zeitpunkten); nicht parallel
|
||||
// zu anderen Aufrufen verwenden.
|
||||
func (l *Ledger) SetClock(now func() time.Time) { l.now = now }
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", store.ErrInvalid, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// AddInvite hinterlegt eine einmalige Einladung für den Payee payeeID
|
||||
// (gespeichert wird nur der Hash von token). Ein bereits registrierter Payee
|
||||
// muss erst gelöscht werden (ErrExists).
|
||||
func (l *Ledger) AddInvite(ctx context.Context, token, payeeID string, expiresAt time.Time) error {
|
||||
switch {
|
||||
case token == "":
|
||||
return invalid("Token fehlt")
|
||||
case !policy.ValidID(payeeID):
|
||||
return invalid("Payee-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", payeeID)
|
||||
case !expiresAt.After(l.now()):
|
||||
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Payee %s: %w (erst löschen)", payeeID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO invites (token_hash, payee_id, expires_at, used) VALUES (?, ?, ?, 0)`,
|
||||
hashToken(token), payeeID, store.FormatTime(expiresAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Enroll löst token ein und legt den Payee an; seine ID bestimmt die
|
||||
// Einladung (p.ID wird ersetzt), EnrolledAt setzt der Ledger. Schlägt das
|
||||
// Anlegen fehl, bleibt die Einladung unverbraucht.
|
||||
func (l *Ledger) Enroll(ctx context.Context, token string, p Payee) (Payee, error) {
|
||||
if strings.TrimSpace(p.Hostname) == "" || p.PublicKey == "" {
|
||||
return Payee{}, invalid("Hostname und Schlüssel sind Pflicht")
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
hash := hashToken(token)
|
||||
var payeeID string
|
||||
var expiresAt time.Time
|
||||
var used bool
|
||||
err = tx.QueryRowContext(ctx, `SELECT payee_id, expires_at, used FROM invites WHERE token_hash = ?`, hash).
|
||||
Scan(&payeeID, store.ScanTime(&expiresAt), &used)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return Payee{}, fmt.Errorf("Einladung: %w", store.ErrNotFound)
|
||||
case err != nil:
|
||||
return Payee{}, err
|
||||
case used:
|
||||
return Payee{}, store.ErrTokenUsed
|
||||
case !l.now().Before(expiresAt):
|
||||
return Payee{}, store.ErrTokenExpired
|
||||
}
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ? OR public_key = ?`, payeeID, p.PublicKey); err != nil {
|
||||
return Payee{}, err
|
||||
} else if exists {
|
||||
return Payee{}, fmt.Errorf("Payee %s oder Schlüssel: %w", payeeID, store.ErrExists)
|
||||
}
|
||||
|
||||
now := l.now().Round(0).UTC()
|
||||
p.ID, p.EnrolledAt = payeeID, now
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE invites SET used = 1, used_at = ? WHERE token_hash = ?`,
|
||||
store.FormatTime(now), hash); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payees (id, hostname, public_key, ip_address, enrolled_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Hostname, p.PublicKey, p.IPAddress, store.FormatTime(now)); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Payee{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
const payeeSelect = `SELECT p.id, p.hostname, p.public_key, p.ip_address, p.enrolled_at, r.received_at
|
||||
FROM payees p LEFT JOIN reports r ON r.payee_id = p.id`
|
||||
|
||||
func scanPayee(row interface{ Scan(...any) error }) (Payee, error) {
|
||||
var p Payee
|
||||
err := row.Scan(&p.ID, &p.Hostname, &p.PublicKey, &p.IPAddress, store.ScanTime(&p.EnrolledAt), store.ScanTime(&p.LastReport))
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ListPayees liefert alle Payees, sortiert nach ID.
|
||||
func (l *Ledger) ListPayees(ctx context.Context) ([]Payee, error) {
|
||||
rows, err := l.db.QueryContext(ctx, payeeSelect+` ORDER BY p.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Payee
|
||||
for rows.Next() {
|
||||
p, err := scanPayee(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetPayeeByPublicKey sucht den Payee zu einem Schlüssel (Prüfung signierter
|
||||
// Berichte).
|
||||
func (l *Ledger) GetPayeeByPublicKey(ctx context.Context, publicKey string) (Payee, error) {
|
||||
p, err := scanPayee(l.db.QueryRowContext(ctx, payeeSelect+` WHERE p.public_key = ?`, publicKey))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Payee{}, fmt.Errorf("Payee: %w", store.ErrNotFound)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// DeletePayee entfernt einen Payee (z. B. vor einer neuen Einladung nach
|
||||
// Schlüsselverlust).
|
||||
func (l *Ledger) DeletePayee(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM payees WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Payee %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RouteRecord ist eine Route aus einem Bericht, wie sie in die Historie eingeht.
|
||||
type RouteRecord struct {
|
||||
Tenant, Node, Protocol, Router string
|
||||
Hosts []string
|
||||
Backend, BackendFQDN string
|
||||
Workload, ComposeDir, Product string
|
||||
}
|
||||
|
||||
func (r RouteRecord) key() string {
|
||||
return r.Tenant + "\x00" + r.Node + "\x00" + r.Protocol + "\x00" + r.Router
|
||||
}
|
||||
|
||||
// RouteSpan ist ein Zeitraum an einem Ort.
|
||||
type RouteSpan struct {
|
||||
RouteRecord
|
||||
ID int64
|
||||
Payee string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Ended time.Time // Nullzeit = noch offen
|
||||
}
|
||||
|
||||
// SaveReport ersetzt den letzten Bericht des Payees und führt die Historie
|
||||
// fort (Zeitpunkt: jetzt): neue Orte öffnen einen Zeitraum, bestehende
|
||||
// übernehmen geänderte Angaben (first_seen bleibt), fehlende enden. Ein
|
||||
// Bericht ist vollständig – was fehlt, gibt es beim Payee nicht mehr.
|
||||
func (l *Ledger) SaveReport(ctx context.Context, payeeID string, body []byte, routes []RouteRecord) error {
|
||||
now := store.FormatTime(l.now())
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
|
||||
return err
|
||||
} else if !exists {
|
||||
return fmt.Errorf("Payee %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO reports (payee_id, received_at, body) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id) DO UPDATE SET received_at = excluded.received_at, body = excluded.body`,
|
||||
payeeID, now, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
open := map[string]int64{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, tenant, node, protocol, router FROM route_spans
|
||||
WHERE payee_id = ? AND ended_at IS NULL`, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r RouteRecord
|
||||
if err := rows.Scan(&id, &r.Tenant, &r.Node, &r.Protocol, &r.Router); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
open[r.key()] = id
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
hosts := normalizeHosts(r.Hosts)
|
||||
if id, ok := open[r.key()]; ok {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET hosts = ?, backend = ?, backend_fqdn = ?,
|
||||
workload = ?, compose_dir = ?, product = ?, last_seen = ? WHERE id = ?`,
|
||||
hosts, r.Backend, r.BackendFQDN, r.Workload, r.ComposeDir, r.Product, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(open, r.key())
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO route_spans (payee_id, tenant, node, protocol, router, hosts,
|
||||
backend, backend_fqdn, workload, compose_dir, product, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
payeeID, r.Tenant, r.Node, r.Protocol, r.Router, hosts, r.Backend, r.BackendFQDN,
|
||||
r.Workload, r.ComposeDir, r.Product, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range open {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET ended_at = ? WHERE id = ?`, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// normalizeHosts speichert Hosts klein, sortiert, ohne Doppelte (JSON).
|
||||
func normalizeHosts(hosts []string) string {
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
out = append(out, strings.ToLower(strings.TrimSpace(h)))
|
||||
}
|
||||
slices.Sort(out)
|
||||
raw, _ := json.Marshal(slices.Compact(out))
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// HistoryFilter schränkt RouteHistory ein; leere Felder filtern nicht.
|
||||
type HistoryFilter struct {
|
||||
Host string // Teiltreffer, ohne Groß-/Kleinschreibung
|
||||
Tenant string
|
||||
Payee string
|
||||
OpenOnly bool
|
||||
}
|
||||
|
||||
// RouteHistory liefert Zeiträume, sortiert nach Beginn, Payee, Knoten, Router.
|
||||
func (l *Ledger) RouteHistory(ctx context.Context, f HistoryFilter) ([]RouteSpan, error) {
|
||||
q := `SELECT id, payee_id, tenant, node, protocol, router, hosts, backend, backend_fqdn, workload,
|
||||
compose_dir, product, first_seen, last_seen, ended_at FROM route_spans WHERE 1=1`
|
||||
var args []any
|
||||
if f.Host != "" {
|
||||
like := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(f.Host))
|
||||
q += ` AND hosts LIKE ? ESCAPE '\'`
|
||||
args = append(args, "%"+like+"%")
|
||||
}
|
||||
if f.Tenant != "" {
|
||||
q += ` AND tenant = ?`
|
||||
args = append(args, f.Tenant)
|
||||
}
|
||||
if f.Payee != "" {
|
||||
q += ` AND payee_id = ?`
|
||||
args = append(args, f.Payee)
|
||||
}
|
||||
if f.OpenOnly {
|
||||
q += ` AND ended_at IS NULL`
|
||||
}
|
||||
q += ` ORDER BY first_seen, payee_id, node, router, id`
|
||||
rows, err := l.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RouteSpan
|
||||
for rows.Next() {
|
||||
var sp RouteSpan
|
||||
var hosts string
|
||||
if err := rows.Scan(&sp.ID, &sp.Payee, &sp.Tenant, &sp.Node, &sp.Protocol, &sp.Router, &hosts, &sp.Backend,
|
||||
&sp.BackendFQDN, &sp.Workload, &sp.ComposeDir, &sp.Product, store.ScanTime(&sp.FirstSeen),
|
||||
store.ScanTime(&sp.LastSeen), store.ScanTime(&sp.Ended)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(hosts), &sp.Hosts); err != nil {
|
||||
return nil, fmt.Errorf("Historie %d: Hosts unlesbar: %w", sp.ID, err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LatestReport liefert den letzten Bericht des Payees und seinen Eingang.
|
||||
func (l *Ledger) LatestReport(ctx context.Context, payeeID string) ([]byte, time.Time, error) {
|
||||
var body string
|
||||
var at time.Time
|
||||
err := l.db.QueryRowContext(ctx, `SELECT body, received_at FROM reports WHERE payee_id = ?`, payeeID).
|
||||
Scan(&body, store.ScanTime(&at))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, time.Time{}, fmt.Errorf("Bericht von %s: %w", payeeID, store.ErrNotFound)
|
||||
}
|
||||
return []byte(body), at, err
|
||||
}
|
||||
|
||||
// Setting liefert eine Einstellung ("" wenn nicht gesetzt).
|
||||
func (l *Ledger) Setting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := l.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting setzt eine Einstellung.
|
||||
func (l *Ledger) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := l.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func rowExists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) {
|
||||
var one int
|
||||
err := tx.QueryRowContext(ctx, query, args...).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
Reference in new issue
Block a user