#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import os import posixpath import secrets import time import database from app_config import get_config from app_logger import get_logger from app_security import is_safe_path, sanitize_filename from flask import current_app, jsonify, request, url_for from routes.admin_api import admin_api_bp from routes.decorators import admin_required logger = get_logger("app") config = get_config() def _get_upload_dir(): rel_dir = getattr(config, "ANNOUNCEMENT_IMAGE_DIR", "static/announcements") if not is_safe_path(current_app.root_path, rel_dir): rel_dir = "static/announcements" abs_dir = os.path.join(current_app.root_path, rel_dir) os.makedirs(abs_dir, exist_ok=True) return abs_dir, rel_dir def _get_file_size(file_storage): try: file_storage.stream.seek(0, os.SEEK_END) size = file_storage.stream.tell() file_storage.stream.seek(0) return size except Exception: return None # ==================== 公告管理API(管理员) ==================== @admin_api_bp.route("/announcements/upload_image", methods=["POST"]) @admin_required def admin_upload_announcement_image(): """上传公告图片(返回可访问URL)""" file = request.files.get("file") if not file or not file.filename: return jsonify({"error": "请选择图片"}), 400 filename = sanitize_filename(file.filename) ext = os.path.splitext(filename)[1].lower() allowed_exts = getattr(config, "ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS", {".png", ".jpg", ".jpeg"}) if not ext or ext not in allowed_exts: return jsonify({"error": "不支持的图片格式"}), 400 if file.mimetype and not str(file.mimetype).startswith("image/"): return jsonify({"error": "文件类型无效"}), 400 size = _get_file_size(file) max_size = int(getattr(config, "MAX_ANNOUNCEMENT_IMAGE_SIZE", 5 * 1024 * 1024)) if size is not None and size > max_size: max_mb = max_size // 1024 // 1024 return jsonify({"error": f"图片大小不能超过{max_mb}MB"}), 400 abs_dir, rel_dir = _get_upload_dir() token = secrets.token_hex(6) name = f"announcement_{int(time.time())}_{token}{ext}" save_path = os.path.join(abs_dir, name) file.save(save_path) static_root = os.path.join(current_app.root_path, "static") rel_to_static = os.path.relpath(abs_dir, static_root) if rel_to_static.startswith(".."): rel_to_static = "announcements" url_path = posixpath.join(rel_to_static.replace(os.sep, "/"), name) return jsonify({"success": True, "url": url_for("serve_static", filename=url_path)}) @admin_api_bp.route("/announcements", methods=["GET"]) @admin_required def admin_get_announcements(): """获取公告列表""" try: limit = int(request.args.get("limit", 50)) offset = int(request.args.get("offset", 0)) except (TypeError, ValueError): limit, offset = 50, 0 limit = max(1, min(200, limit)) offset = max(0, offset) return jsonify(database.get_announcements(limit=limit, offset=offset)) @admin_api_bp.route("/announcements", methods=["POST"]) @admin_required def admin_create_announcement(): """创建公告(默认启用并替换旧公告)""" data = request.json or {} title = (data.get("title") or "").strip() content = (data.get("content") or "").strip() image_url = (data.get("image_url") or "").strip() is_active = bool(data.get("is_active", True)) if image_url and len(image_url) > 1000: return jsonify({"error": "图片地址过长"}), 400 announcement_id = database.create_announcement(title, content, image_url=image_url, is_active=is_active) if not announcement_id: return jsonify({"error": "标题和内容不能为空"}), 400 return jsonify({"success": True, "id": announcement_id}) @admin_api_bp.route("/announcements//activate", methods=["POST"]) @admin_required def admin_activate_announcement(announcement_id): """启用公告(会自动停用其他公告)""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.set_announcement_active(announcement_id, True) return jsonify({"success": ok}) @admin_api_bp.route("/announcements//deactivate", methods=["POST"]) @admin_required def admin_deactivate_announcement(announcement_id): """停用公告""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.set_announcement_active(announcement_id, False) return jsonify({"success": ok}) @admin_api_bp.route("/announcements/", methods=["DELETE"]) @admin_required def admin_delete_announcement(announcement_id): """删除公告""" if not database.get_announcement_by_id(announcement_id): return jsonify({"error": "公告不存在"}), 404 ok = database.delete_announcement(announcement_id) return jsonify({"success": ok})