refactor(fam-core): 重构第二阶段 - img_proxy 收尾 + 稳定性小修 + 工程质量

img_proxy 收尾: 提交此前未提交的图片代理蓝图(app.py 注册),改为复用 oracle_sync
已解析好的 base_url/token/timeout,不再独立读一份配置(避免配置改动时两处不同步)。

稳定性/正确性: chat_handler 问答失败时不再把 Oracle 内部 HTTP 状态码等细节透传给
客户端,改为通用错误信息(详细原因仍记服务端日志); 删除 logger.py 里旧任务架构
遗留的死函数 log_task; 更新 config.yaml.example 到当前 v2 架构(原文件还是重构前
的 scheduler/dispatcher/video_server 旧结构,当前代码完全不读这些字段)。

工程质量: 新增 fam-core/tests(9 个单元测试,覆盖 _format_events 上下文格式化和
config_loader 的 ${ENV_VAR} 解析)。

部署时发现并修复一个和这次改动无关的运维问题: NAS .env 文件缺 export 关键字,
plain source 只在当前 shell 生效不会被子进程(gunicorn)继承,导致 Oracle-Sync
token 校验失败;用 set -a/set +a 强制导出重启,非代码改动。

已部署 NAS 并验证:/health、/api/status、/api/proxy/frame、/api/proxy/avatar
全部通过;浏览器实测事件时间轴、人物管理页正常渲染。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-22 00:50:49 +08:00
parent 61db82cb9b
commit e56d362d73
8 changed files with 168 additions and 38 deletions

View File

@@ -19,6 +19,7 @@ from .logger import setup_logger
from .oracle_sync import get_sync
from .chat_handler.chat_handler import chat_bp
from .member_manager.member_manager import member_bp
from .img_proxy import img_bp
logger = setup_logger('fam-core.app')
@@ -27,6 +28,7 @@ app = Flask(__name__)
# 注册蓝图
app.register_blueprint(chat_bp)
app.register_blueprint(member_bp)
app.register_blueprint(img_bp)
# 健康检查
@app.route('/health', methods=['GET'])

View File

@@ -98,7 +98,7 @@ def chat_ask():
answer = _call_edge_qa(prompt)
except Exception as e:
logger.error(f"问答编排调用失败: {e}")
return jsonify({"error": f"AI 调用失败: {e}"}), 503
return jsonify({"error": "AI 服务暂时不可用,请稍后重试"}), 503
chat_id = db_layer.insert_chat_history(
user_question=question,

View File

@@ -0,0 +1,61 @@
"""
Img-Proxy - NAS 端图片代理(图源在 Oracle计算全部在 Oracle
浏览器不直连 Oracle避免公网暴露 5000 端口与 token 外泄),
而是访问 NAS fam-core 的 /api/proxy/*,由 NAS 出网到 Oracle 拉取 jpeg 回传。
Oracle 侧已有磁盘缓存 / VLM 人物定位裁剪NAS 端仅透传,不做图像计算。
"""
from flask import Blueprint, Response, request
from .oracle_sync import get_sync
from .logger import setup_logger
logger = setup_logger('fam-core.img_proxy')
img_bp = Blueprint('img_proxy', __name__)
def _fetch(rel: str, params: dict):
import requests
sync = get_sync() # 复用 oracle_sync 已解析好的 base_url/token/timeout避免两处配置各读一份
params = dict(params)
if sync.token:
params['token'] = sync.token
try:
resp = requests.get(f"{sync.base_url}{rel}", params=params,
timeout=(10, sync.timeout))
if resp.status_code == 200 and resp.content:
return resp.content
logger.warning(f"Oracle {rel} 返回 {resp.status_code}: {resp.text[:120]}")
return None
except Exception as e:
logger.error(f"Oracle {rel} 请求失败: {e}")
return None
@img_bp.route('/api/proxy/frame', methods=['GET'])
def proxy_frame():
"""事件缩略帧: video_id + ts -> jpeg"""
data = _fetch('/api/oracle/frame', {
'video_id': request.args.get('video_id', type=int) or 0,
'ts': request.args.get('ts', ''),
'w': request.args.get('w', 400, type=int),
})
if data is None:
return '', 404
return Response(data, mimetype='image/jpeg',
headers={'Cache-Control': 'public, max-age=86400'})
@img_bp.route('/api/proxy/avatar', methods=['GET'])
def proxy_avatar():
"""人物头像: label/canonical -> jpeg"""
data = _fetch('/api/oracle/avatar', {
'label': request.args.get('label', ''),
'w': request.args.get('w', 160, type=int),
})
if data is None:
return '', 404
return Response(data, mimetype='image/jpeg',
headers={'Cache-Control': 'public, max-age=86400'})

View File

@@ -1,10 +1,9 @@
"""
日志工具 - 统一格式,带 task_id 作为 trace_id
日志工具 - 统一格式stdout + 文件双写)
"""
import logging
import os
import sys
from datetime import datetime
# 日志目录fam-core/logs/(相对 src 的上一级),失败则退化为仅 stdout
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
@@ -33,12 +32,3 @@ def setup_logger(name='fam-core', level=logging.INFO):
except OSError:
pass # 目录不可写时退化为仅 stdout
return logger
def log_task(logger, task_id, stage, message, level=logging.INFO, duration_ms=None):
"""带 task_id 的结构化日志"""
parts = [f"[task_id={task_id}]", stage]
if duration_ms is not None:
parts.append(f"done in {duration_ms}ms")
parts.append(message)
logger.log(level, ' '.join(parts))