feat(instance): add exchange account ledger tab with SSE sync
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""账户流水 SQLite 缓存."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS
|
||||
|
||||
|
||||
def ensure_account_ledger_tables(conn) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS account_ledger_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT NOT NULL,
|
||||
ccy TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
balance_after REAL,
|
||||
kind TEXT,
|
||||
raw_type TEXT,
|
||||
symbol TEXT,
|
||||
ref_id TEXT NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
synced_at REAL,
|
||||
UNIQUE(account, ref_id, ccy, ts_ms)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts "
|
||||
"ON account_ledger_entries(account, ts_ms DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS account_ledger_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def meta_get(conn, key: str, default: str = "") -> str:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM account_ledger_meta WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return default
|
||||
try:
|
||||
return str(row[0] if not hasattr(row, "keys") else row["value"])
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def meta_set(conn, key: str, value: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
|
||||
def upsert_entries(conn, rows: list[dict[str, Any]]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
now = time.time()
|
||||
n = 0
|
||||
for r in rows:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_ledger_entries(
|
||||
account, ccy, amount, balance_after, kind, raw_type,
|
||||
symbol, ref_id, ts_ms, note, synced_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET
|
||||
amount=excluded.amount,
|
||||
balance_after=excluded.balance_after,
|
||||
kind=excluded.kind,
|
||||
raw_type=excluded.raw_type,
|
||||
symbol=excluded.symbol,
|
||||
note=excluded.note,
|
||||
synced_at=excluded.synced_at
|
||||
""",
|
||||
(
|
||||
r["account"],
|
||||
r["ccy"],
|
||||
float(r["amount"]),
|
||||
r.get("balance_after"),
|
||||
r.get("kind") or "other",
|
||||
r.get("raw_type") or "",
|
||||
r.get("symbol") or "",
|
||||
r["ref_id"],
|
||||
int(r["ts_ms"]),
|
||||
r.get("note") or "",
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
except Exception:
|
||||
continue
|
||||
conn.commit()
|
||||
return n
|
||||
|
||||
|
||||
def query_entries(
|
||||
conn,
|
||||
*,
|
||||
account: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
page: int = 1,
|
||||
page_size: int = PAGE_SIZE,
|
||||
currencies: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
acc = (account or "").strip().lower()
|
||||
if acc not in VALID_ACCOUNTS:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0}
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(50, int(page_size or PAGE_SIZE)))
|
||||
start_ms = int(start_ms)
|
||||
end_ms = int(end_ms)
|
||||
params: list[Any] = [acc, start_ms, end_ms]
|
||||
ccy_sql = ""
|
||||
if currencies:
|
||||
ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()]
|
||||
if ccy_list:
|
||||
placeholders = ",".join("?" for _ in ccy_list)
|
||||
ccy_sql = f" AND ccy IN ({placeholders})"
|
||||
params.extend(ccy_list)
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM account_ledger_entries "
|
||||
f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}",
|
||||
params,
|
||||
).fetchone()[0]
|
||||
total = int(total or 0)
|
||||
pages = (total + page_size - 1) // page_size if total else 0
|
||||
if pages and page > pages:
|
||||
page = pages
|
||||
offset = (page - 1) * page_size
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT account, ccy, amount, balance_after, kind, raw_type, symbol,
|
||||
ref_id, ts_ms, note
|
||||
FROM account_ledger_entries
|
||||
WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}
|
||||
ORDER BY ts_ms DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
if hasattr(r, "keys"):
|
||||
d = {k: r[k] for k in r.keys()}
|
||||
else:
|
||||
d = {
|
||||
"account": r[0],
|
||||
"ccy": r[1],
|
||||
"amount": r[2],
|
||||
"balance_after": r[3],
|
||||
"kind": r[4],
|
||||
"raw_type": r[5],
|
||||
"symbol": r[6],
|
||||
"ref_id": r[7],
|
||||
"ts_ms": r[8],
|
||||
"note": r[9],
|
||||
}
|
||||
from lib.account_ledger.account_ledger_normalize import kind_label_zh
|
||||
|
||||
d["kind_label"] = kind_label_zh(d.get("kind") or "")
|
||||
items.append(d)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def prune_older_than(conn, min_ts_ms: int) -> None:
|
||||
conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),))
|
||||
conn.commit()
|
||||
Reference in New Issue
Block a user