# coding=utf-8 """菜品模板仓储:SQLite 本地缓存读写,变更同步到 MySQL 总服务器后拉取刷新。""" import datetime import json import os import shutil import numpy as np from sqlalchemy import func from dao.database import get_backend, init_local_engine, init_server_engine, local_session_scope from dao.feature_codec import blob_to_feature from dao.base_registry import resolve_local_base from dao.template_sync import TemplateSync from models.App_settings import AppSetting from models.Dish_categories import DishCategory from models.Global_templates import GlobalTemplate SETTING_THRESHOLD = 'similarity_threshold' SETTING_JSON_MIGRATED = 'json_templates_migrated' class TemplateRepository: def __init__(self, json_path=None, source_base_id=None, base_code=None): self.json_path = json_path init_local_engine() init_server_engine() self.base_info = resolve_local_base() # 显式传入优先;否则用注册解析结果 self.source_base_id = source_base_id if source_base_id is not None else self.base_info.get('id') self.base_code = base_code or self.base_info.get('base_code') self.sync = TemplateSync(source_base_id=self.source_base_id) print("[INFO] check json template migration…") self._maybe_migrate_json() print("[INFO] initial template sync with mysql…") self.sync.initial_sync() print("[INFO] start background template sync thread") self.sync.start_background_sync(on_sync=self._on_background_sync) self._background_reload_callback = None def get_base_info(self): return dict(self.base_info or {}) def refresh_base_info(self): self.base_info = resolve_local_base(force_refresh=True) self.source_base_id = self.base_info.get('id') self.base_code = self.base_info.get('base_code') self.sync.source_base_id = self.source_base_id return self.base_info def set_reload_callback(self, callback): self._background_reload_callback = callback def _on_background_sync(self): if self._background_reload_callback: self._background_reload_callback() def get_threshold(self, default=0.7): with local_session_scope() as session: row = session.get(AppSetting, SETTING_THRESHOLD) if row and row.value: try: return float(row.value) except ValueError: pass return default def set_threshold(self, threshold): self.sync.push_set_threshold(threshold) def _is_json_migrated(self, session): row = session.get(AppSetting, SETTING_JSON_MIGRATED) return row is not None and row.value == '1' def _mark_json_migrated(self, session): row = session.get(AppSetting, SETTING_JSON_MIGRATED) if row: row.value = '1' else: session.add(AppSetting(key=SETTING_JSON_MIGRATED, value='1')) def _maybe_migrate_json(self): if not self.json_path or not os.path.exists(self.json_path): return with local_session_scope() as session: if self._is_json_migrated(session): return active_count = session.query(func.count(GlobalTemplate.id)).filter( GlobalTemplate.is_active == 1 ).scalar() or 0 if active_count > 0: self._mark_json_migrated(session) return try: with open(self.json_path, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as exc: print(f"[WARN] read JSON templates failed, skip migration: {exc}") return threshold = data.get('threshold', 0.7) library = data.get('template_library', {}) batch = [] for category_name, templates in library.items(): if not templates: continue for item in templates: feature = item.get('feature') if feature is None: continue batch.append({ 'category_name': category_name, 'image_url': item.get('path'), 'feature': feature, 'thumbnail_url': item.get('thumbnail'), }) # 阈值先写总服务器(有则),并批量上传模板;最终以 MySQL 拉取结果为准 self.sync.push_set_threshold(threshold) migrated = self.sync.push_insert_batch(batch) with local_session_scope() as session: self._mark_json_migrated(session) backup_path = self.json_path + '.bak' try: shutil.move(self.json_path, backup_path) print(f"[OK] JSON templates migrated: {migrated}, backup: {backup_path}") except Exception as exc: print(f"[WARN] JSON migration backup failed: {exc}") def load_library(self): """从 SQLite 本地缓存加载(识别只读本地库)。""" threshold = self.get_threshold() library = {} with local_session_scope() as session: rows = ( session.query(GlobalTemplate, DishCategory) .join(DishCategory, GlobalTemplate.category_id == DishCategory.id) .filter(GlobalTemplate.is_active == 1, DishCategory.is_active == 1) .order_by(DishCategory.name, GlobalTemplate.id) .all() ) for template, category in rows: feature = blob_to_feature(template.feature_blob) if feature is None: continue add_time = template.create_time.strftime('%Y-%m-%d %H:%M:%S') if template.create_time else '' library.setdefault(category.name, []).append({ 'id': template.id, 'category_id': category.id, 'path': template.image_url or '', 'feature': feature.tolist(), 'add_time': add_time, 'thumbnail': template.thumbnail_url or '', }) return threshold, library def _find_template_record(self, category_name, image_path): with local_session_scope() as session: row = ( session.query(GlobalTemplate, DishCategory) .join(DishCategory, GlobalTemplate.category_id == DishCategory.id) .filter( DishCategory.name == category_name, GlobalTemplate.image_url == image_path, GlobalTemplate.is_active == 1, ) .order_by(GlobalTemplate.id.desc()) .first() ) if not row: return None template, category = row feature = blob_to_feature(template.feature_blob) if feature is None: return None return { 'id': template.id, 'category_id': category.id, 'path': template.image_url or '', 'feature': feature.tolist(), 'add_time': template.create_time.strftime('%Y-%m-%d %H:%M:%S') if template.create_time else '', 'thumbnail': template.thumbnail_url or '', } def insert_template(self, category_name, image_path, feature, thumbnail_path, add_time=None): self.sync.push_insert(category_name, image_path, feature, thumbnail_path) record = self._find_template_record(category_name, image_path) if record: return record return { 'id': None, 'category_id': None, 'path': image_path, 'feature': np.asarray(feature, dtype=np.float32).tolist(), 'add_time': add_time or datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'thumbnail': thumbnail_path or '', } def insert_templates_batch(self, items): """批量写入;内部只同步一次,避免逐条 pull 冲掉本地。""" return self.sync.push_insert_batch(items) def delete_template_by_id(self, template_id): paths = None with local_session_scope() as session: template = session.get(GlobalTemplate, template_id) if template and template.is_active == 1: paths = { 'path': template.image_url, 'thumbnail': template.thumbnail_url, } if paths: self.sync.push_delete_template(template_id) return paths def delete_category(self, category_name): removed = [] with local_session_scope() as session: category = ( session.query(DishCategory) .filter(DishCategory.name == category_name, DishCategory.is_active == 1) .first() ) if category: templates = ( session.query(GlobalTemplate) .filter(GlobalTemplate.category_id == category.id, GlobalTemplate.is_active == 1) .all() ) removed = [{'path': t.image_url, 'thumbnail': t.thumbnail_url} for t in templates] self.sync.push_delete_category(category_name) return removed def clear_all(self): self.sync.push_clear_all() def backend_name(self): return get_backend() def refresh_from_server(self): return self.sync.pull_from_server() def sync_now(self, full=True): return self.sync.sync_now(full=full) def get_sync_status(self): return self.sync.get_sync_status() def list_upload_queue(self, statuses=('pending', 'failed'), limit=100): return self.sync.list_upload_queue(statuses=statuses, limit=limit) def retry_failed_uploads(self): return self.sync.retry_failed_uploads()