53863559f4
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
6.1 KiB
Python
209 lines
6.1 KiB
Python
"""复盘记录:多周期截图上传,存储与读取(三所共用)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
|
|
|
JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
|
|
JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
|
|
_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
|
|
_JOURNAL_SLOT_FILE_RE = re.compile(
|
|
r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
|
|
re.I,
|
|
)
|
|
|
|
|
|
def journal_upload_field_name(tf: str) -> str:
|
|
return f"screenshot_{tf}"
|
|
|
|
|
|
def uploaded_screenshot_field_name(tf: str) -> str:
|
|
return f"uploaded_screenshot_{tf}"
|
|
|
|
|
|
def normalize_journal_draft_id(raw: Any) -> Optional[str]:
|
|
s = str(raw or "").strip().lower()
|
|
if _JOURNAL_DRAFT_ID_RE.match(s):
|
|
return s
|
|
return None
|
|
|
|
|
|
def _safe_ext(filename: str) -> str:
|
|
ext = os.path.splitext(str(filename or ""))[1].lower()
|
|
return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png"
|
|
|
|
|
|
def build_journal_slot_filename(
|
|
entry_id: str,
|
|
tf: str,
|
|
ext: str,
|
|
*,
|
|
secure_filename_fn: Callable[[str], str],
|
|
) -> str:
|
|
ext = ext if ext.startswith(".") else f".{ext}"
|
|
ext = _safe_ext(f"x{ext}")
|
|
fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}")
|
|
return fname or ""
|
|
|
|
|
|
def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool:
|
|
fn = os.path.basename(str(filename or "").strip())
|
|
if not fn or fn != str(filename or "").strip():
|
|
return False
|
|
m = _JOURNAL_SLOT_FILE_RE.match(fn)
|
|
if not m:
|
|
return False
|
|
return m.group(1) == entry_id.lower() and m.group(2) == tf
|
|
|
|
|
|
def save_journal_slot_file(
|
|
file,
|
|
entry_id: str,
|
|
tf: str,
|
|
upload_folder: str,
|
|
*,
|
|
secure_filename_fn: Callable[[str], str],
|
|
) -> Optional[Dict[str, str]]:
|
|
if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder:
|
|
return None
|
|
if not file or not getattr(file, "filename", None):
|
|
return None
|
|
ext = _safe_ext(file.filename)
|
|
fname = build_journal_slot_filename(
|
|
entry_id, tf, ext, secure_filename_fn=secure_filename_fn
|
|
)
|
|
if not fname:
|
|
return None
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
path = os.path.join(upload_folder, fname)
|
|
file.save(path)
|
|
return {"tf": tf, "file": fname}
|
|
|
|
|
|
def collect_journal_slot_images(
|
|
form,
|
|
files,
|
|
entry_id: str,
|
|
upload_folder: str,
|
|
*,
|
|
secure_filename_fn: Callable[[str], str],
|
|
) -> List[Dict[str, str]]:
|
|
"""优先使用即时上传 hidden 字段;否则回退到表单 multipart."""
|
|
saved: List[Dict[str, str]] = []
|
|
if not entry_id or not upload_folder:
|
|
return saved
|
|
for tf in JOURNAL_UPLOAD_TFS:
|
|
pre = ""
|
|
if form is not None:
|
|
pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip()
|
|
if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf):
|
|
path = os.path.join(upload_folder, os.path.basename(pre))
|
|
if os.path.isfile(path):
|
|
saved.append({"tf": tf, "file": os.path.basename(pre)})
|
|
continue
|
|
f = files.get(journal_upload_field_name(tf)) if files else None
|
|
item = save_journal_slot_file(
|
|
f,
|
|
entry_id,
|
|
tf,
|
|
upload_folder,
|
|
secure_filename_fn=secure_filename_fn,
|
|
)
|
|
if item:
|
|
saved.append(item)
|
|
return saved
|
|
|
|
|
|
def save_journal_slot_uploads(
|
|
files,
|
|
entry_id: str,
|
|
upload_folder: str,
|
|
*,
|
|
secure_filename_fn: Callable[[str], str],
|
|
) -> List[Dict[str, str]]:
|
|
"""保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]."""
|
|
return collect_journal_slot_images(
|
|
None,
|
|
files,
|
|
entry_id,
|
|
upload_folder,
|
|
secure_filename_fn=secure_filename_fn,
|
|
)
|
|
|
|
|
|
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
|
|
if not items:
|
|
return None
|
|
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def parse_images_json(raw: Any) -> List[Dict[str, str]]:
|
|
if not raw:
|
|
return []
|
|
if isinstance(raw, list):
|
|
data = raw
|
|
else:
|
|
try:
|
|
data = json.loads(str(raw))
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
return []
|
|
if not isinstance(data, list):
|
|
return []
|
|
out: List[Dict[str, str]] = []
|
|
for item in data:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
tf = str(item.get("tf") or "").strip()
|
|
file = str(item.get("file") or "").strip()
|
|
if file:
|
|
out.append({"tf": tf, "file": file})
|
|
return out
|
|
|
|
|
|
def primary_journal_image(
|
|
manual_images: Sequence[Mapping[str, str]],
|
|
*,
|
|
fallback: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
if manual_images:
|
|
return str(manual_images[0].get("file") or "").strip() or None
|
|
return fallback
|
|
|
|
|
|
def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""API 输出:解析 images_json,兼容旧单图 image 字段."""
|
|
images = parse_images_json(item.get("images_json"))
|
|
if not images and item.get("image"):
|
|
images = [{"tf": "", "file": str(item["image"]).strip()}]
|
|
item["images"] = images
|
|
return item
|
|
|
|
|
|
def journal_image_paths(row: Any, upload_folder: str) -> List[str]:
|
|
"""删除 / AI 附图:收集本条复盘所有本地图片路径(去重)."""
|
|
upload_folder = os.path.abspath(upload_folder or "")
|
|
paths: List[str] = []
|
|
seen = set()
|
|
|
|
def _add(name: Optional[str]) -> None:
|
|
if not name:
|
|
return
|
|
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
|
|
if os.path.isfile(p) and p not in seen:
|
|
seen.add(p)
|
|
paths.append(p)
|
|
|
|
try:
|
|
keys = row.keys() if hasattr(row, "keys") else ()
|
|
except Exception:
|
|
keys = ()
|
|
|
|
if "images_json" in keys and row["images_json"]:
|
|
for img in parse_images_json(row["images_json"]):
|
|
_add(img.get("file"))
|
|
if "image" in keys:
|
|
_add(row["image"])
|
|
return paths
|