diff --git a/backend/.env.example b/backend/.env.example index 333265a..430d857 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -62,10 +62,20 @@ LOG_LEVEL=info # LOG_FORMAT=json # 生产环境建议启用 JSON 格式日志 # ───────────────────────────────────────────────────────────────────── -# 文件上传 +# 文件存储 # ───────────────────────────────────────────────────────────────────── -# MAX_UPLOAD_SIZE=10485760 # 10MB -# UPLOAD_DIR=/app/uploads +# 开发环境默认使用本地文件系统;Docker 部署时默认使用 MinIO(s3) +# STORAGE_BACKEND=local # local | s3 + +# S3 / MinIO(仅 STORAGE_BACKEND=s3 时需要填写) +# S3_ENDPOINT_URL=http://minio:9000 +# S3_ACCESS_KEY=minioadmin +# S3_SECRET_KEY=minioadmin +# S3_BUCKET=opengecko +# S3_PUBLIC_URL=http://minio:9000/opengecko + +# 上传文件大小上限(字节) +# MAX_UPLOAD_SIZE=52428800 # 50MB # ───────────────────────────────────────────────────────────────────── # SMTP(可选,用于密码重置) diff --git a/backend/.env.prod.example b/backend/.env.prod.example index 5febaf0..a47e051 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -52,12 +52,22 @@ LOG_LEVEL=warning LOG_FORMAT=json # ───────────────────────────────────────────────────────────────────── -# 文件上传 +# 文件存储 [推荐使用 MinIO 对象存储] # ───────────────────────────────────────────────────────────────────── -# 上传文件最大大小(字节),默认 10MB -MAX_UPLOAD_SIZE=10485760 -# 上传文件存储路径 -UPLOAD_DIR=/app/uploads +# 存储后端:local(本地文件系统)或 s3(MinIO / AWS S3 兼容) +STORAGE_BACKEND=s3 + +# S3 / MinIO 配置(仅 STORAGE_BACKEND=s3 时生效) +# Docker Compose 部署时 MinIO 容器名为 minio,内网地址为 http://minio:9000 +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=REPLACE_WITH_MINIO_ACCESS_KEY +S3_SECRET_KEY=REPLACE_WITH_MINIO_SECRET_KEY +S3_BUCKET=opengecko +# Nginx 将 /uploads/ 代理到此 URL;内网用 minio:9000,对外域名请改为公网地址 +S3_PUBLIC_URL=http://minio:9000/opengecko + +# 文件上传最大大小(字节),默认 50MB +MAX_UPLOAD_SIZE=52428800 # ───────────────────────────────────────────────────────────────────── # SMTP 邮件(可选,用于密码重置通知) diff --git "a/backend/alembic/versions/ffbd6edaf13b_\346\264\273\345\212\250\346\250\241\345\235\227_duration_minutes\346\224\271\344\270\272duration_hours_.py" "b/backend/alembic/versions/ffbd6edaf13b_\346\264\273\345\212\250\346\250\241\345\235\227_duration_minutes\346\224\271\344\270\272duration_hours_.py" new file mode 100644 index 0000000..79112e9 --- /dev/null +++ "b/backend/alembic/versions/ffbd6edaf13b_\346\264\273\345\212\250\346\250\241\345\235\227_duration_minutes\346\224\271\344\270\272duration_hours_.py" @@ -0,0 +1,50 @@ +"""活动模块:duration_minutes改为duration_hours,移除draft和cancelled状态 + +Revision ID: ffbd6edaf13b +Revises: 986ddbdad1d7 +Create Date: 2026-02-25 18:03:52.645280 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ffbd6edaf13b' +down_revision: Union[str, None] = '986ddbdad1d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. 数据迁移:将 draft/cancelled 状态更新为 planning + op.execute(sa.text("UPDATE events SET status = 'planning' WHERE status IN ('draft', 'cancelled')")) + + # 2. 先添加 duration_hours 列 + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.add_column(sa.Column('duration_hours', sa.Float(), nullable=True)) + + # 3. 将 duration_minutes 换算为 duration_hours(保留 1 位小数) + op.execute(sa.text( + "UPDATE events SET duration_hours = ROUND(CAST(duration_minutes AS FLOAT) / 60.0, 1) " + "WHERE duration_minutes IS NOT NULL" + )) + + # 4. 移除 duration_minutes 列 + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.drop_column('duration_minutes') + + +def downgrade() -> None: + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.add_column(sa.Column('duration_minutes', sa.INTEGER(), nullable=True)) + + op.execute(sa.text( + "UPDATE events SET duration_minutes = CAST(duration_hours * 60 AS INTEGER) " + "WHERE duration_hours IS NOT NULL" + )) + + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.drop_column('duration_hours') diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..6b5dd4d --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,42 @@ +"""管理员专用 API — 目前仅包含配置 Schema 查询端点。""" +from fastapi import APIRouter, Depends + +from app.config import Settings, settings +from app.core.dependencies import get_current_active_superuser +from app.models.user import User + +router = APIRouter() + +# 包含敏感信息的字段名集合(值显示为 "***" 而不是真实内容) +_SENSITIVE_FIELDS = { + "JWT_SECRET_KEY", + "DEFAULT_ADMIN_PASSWORD", + "SMTP_PASSWORD", + "S3_SECRET_KEY", +} + + +@router.get("/config-schema") +def get_config_schema( + current_user: User = Depends(get_current_active_superuser), +): + """返回所有配置项的 JSON Schema(字段名、类型、描述、默认值)以及当前生效值。 + + - 敏感字段(密钥、密码)的当前值以 `***` 脱敏显示 + - 仅超级管理员可访问 + """ + schema = Settings.model_json_schema() + + # 收集当前生效的配置值(脱敏处理) + current_values: dict = {} + for field_name in Settings.model_fields: + raw = getattr(settings, field_name) + if field_name.upper() in _SENSITIVE_FIELDS: + current_values[field_name] = "***" + else: + current_values[field_name] = raw + + return { + "schema": schema, + "current_values": current_values, + } diff --git a/backend/app/api/community_dashboard.py b/backend/app/api/community_dashboard.py index 28f79b3..85ff56d 100644 --- a/backend/app/api/community_dashboard.py +++ b/backend/app/api/community_dashboard.py @@ -288,11 +288,9 @@ def get_community_dashboard( # 活动事件:按状态区分颜色 # planning → 紫色 #8b5cf6;ongoing → 蓝色 #0095ff;completed → 绿色 #10b981;cancelled → 灰色 #94a3b8 EVENT_COLORS = { - "draft": "#94a3b8", "planning": "#8b5cf6", "ongoing": "#0095ff", "completed": "#10b981", - "cancelled": "#94a3b8", } event_events = ( db.query(Event) @@ -359,15 +357,15 @@ def get_community_dashboard( # 活动事件(跨天支持):如果活动时长超过1天,则创建多天事件 for e in event_events: - event_color = EVENT_COLORS.get(e.status or "draft", "#94a3b8") - if e.planned_at and e.duration_minutes and e.duration_minutes > 1440: - days_count = (e.duration_minutes + 1439) // 1440 # 1440分钟 = 24小时 = 1天 + event_color = EVENT_COLORS.get(e.status or "planning", "#8b5cf6") + if e.planned_at and e.duration_hours and e.duration_hours > 24: + days_count = int(e.duration_hours / 24) + (1 if e.duration_hours % 24 else 0) for day_offset in range(days_count): day_date = e.planned_at + timedelta(days=day_offset) calendar_events.append( CalendarEvent( id=e.id, - type=f"event_{e.status or 'draft'}", + type=f"event_{e.status or 'planning'}", title=f"{e.title}" + (f" (第{day_offset + 1}天)" if day_offset > 0 else ""), date=day_date, color=event_color, @@ -379,7 +377,7 @@ def get_community_dashboard( calendar_events.append( CalendarEvent( id=e.id, - type=f"event_{e.status or 'draft'}", + type=f"event_{e.status or 'planning'}", title=e.title, date=e.planned_at, color=event_color, diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 0953468..20faf0b 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -39,7 +39,7 @@ router = APIRouter() -VALID_EVENT_STATUSES = {"draft", "planning", "ongoing", "completed", "cancelled"} +VALID_EVENT_STATUSES = {"planning", "ongoing", "completed"} VALID_EVENT_TYPES = {"online", "offline", "hybrid"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 8f2bd69..166b317 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -1,5 +1,5 @@ import os -import uuid +import tempfile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy.orm import Session @@ -10,7 +10,8 @@ from app.models.content import Content from app.models.user import User from app.schemas.content import ContentOut -from app.services.converter import convert_docx_to_markdown, convert_markdown_to_html, read_markdown_file +from app.services.converter import convert_docx_to_markdown, convert_markdown_to_html +from app.services.storage import StorageService, get_storage router = APIRouter() @@ -31,33 +32,38 @@ async def upload_file( if ext not in ALLOWED_EXTENSIONS: raise HTTPException(400, f"Unsupported file type: {ext}. Allowed: {ALLOWED_EXTENSIONS}") - # Save uploaded file - save_name = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(settings.UPLOAD_DIR, save_name) file_content = await file.read() - if len(file_content) > settings.MAX_UPLOAD_SIZE: raise HTTPException(400, f"File too large. Max size: {settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB") - with open(save_path, "wb") as f: - f.write(file_content) - - # Convert to markdown title = os.path.splitext(file.filename)[0] if ext == ".docx": - markdown_text, image_paths = convert_docx_to_markdown(save_path) + # python-docx requires a real file path; write to a temp file, parse, then clean up + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(file_content) + tmp_path = tmp.name + try: + markdown_text, _image_paths = convert_docx_to_markdown(tmp_path) + finally: + os.unlink(tmp_path) else: - markdown_text = read_markdown_file(save_path) + # Markdown files can be decoded in-memory without touching the filesystem + markdown_text = file_content.decode("utf-8", errors="replace").strip() content_html = convert_markdown_to_html(markdown_text) if markdown_text else "" + # Persist to configured storage backend (local or S3/MinIO) + storage = get_storage() + key = StorageService.generate_key(ext) + storage.save(file_content, key) + content = Content( title=title, content_markdown=markdown_text, content_html=content_html, source_type="contribution", - source_file=save_name, + source_file=key, status="draft", community_id=None, created_by_user_id=current_user.id, @@ -86,20 +92,15 @@ async def upload_cover_image( if ext not in ALLOWED_IMAGE_EXTENSIONS: raise HTTPException(400, f"不支持的图片格式: {ext}。支持: {ALLOWED_IMAGE_EXTENSIONS}") - covers_dir = os.path.join(settings.UPLOAD_DIR, "covers") - os.makedirs(covers_dir, exist_ok=True) - - save_name = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(covers_dir, save_name) file_content = await file.read() - - if len(file_content) > 10 * 1024 * 1024: # 10MB limit for images + if len(file_content) > 10 * 1024 * 1024: raise HTTPException(400, "图片不能超过 10MB") - with open(save_path, "wb") as f: - f.write(file_content) + storage = get_storage() + key = StorageService.generate_key(ext, "covers") + file_url = storage.save(file_content, key) - content.cover_image = f"/uploads/covers/{save_name}" + content.cover_image = file_url db.commit() db.refresh(content) return content diff --git a/backend/app/config.py b/backend/app/config.py index 422f160..e39d67a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,72 +1,130 @@ import os from pathlib import Path +from pydantic import Field from pydantic_settings import BaseSettings class Settings(BaseSettings): - APP_NAME: str = "openGecko" - DEBUG: bool = False + APP_NAME: str = Field(default="openGecko", description="应用名称") + DEBUG: bool = Field(default=False, description="调试模式(生产环境必须设为 false)") - # CORS - # 生产环境请通过环境变量设置允许的域名列表,多个域名用逗号分隔 - # 示例: CORS_ORIGINS=https://app.example.com,https://admin.example.com - # 开发环境默认允许本地前端调试地址 - CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000" + # ── CORS ────────────────────────────────────────────────────────── + CORS_ORIGINS: str = Field( + default="http://localhost:3000,http://127.0.0.1:3000", + description="允许的跨域来源,多个地址用英文逗号分隔。" + "生产环境示例: https://app.example.com,https://admin.example.com", + ) @property def cors_origins_list(self) -> list[str]: """将逗号分隔的 CORS_ORIGINS 字符串转换为列表""" return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] - # Rate Limiting(速率限制) - # 登录端点限制(防止暴力破解) - RATE_LIMIT_LOGIN: str = "10/minute" - # 默认 API 端点限制 - RATE_LIMIT_DEFAULT: str = "120/minute" - - # Database - DATABASE_URL: str = "sqlite:///./data/opengecko.db" - - # Database Connection Pool (for PostgreSQL/MySQL) - DB_POOL_SIZE: int = 5 - DB_MAX_OVERFLOW: int = 10 - DB_POOL_TIMEOUT: int = 30 - DB_POOL_RECYCLE: int = 3600 - DB_ECHO: bool = False - - # Default admin account (seeded on first run) - DEFAULT_ADMIN_USERNAME: str = "admin" - DEFAULT_ADMIN_PASSWORD: str = "admin123" - DEFAULT_ADMIN_EMAIL: str = "admin@example.com" - - # JWT - JWT_SECRET_KEY: str = "change-me-in-production-please-use-a-strong-secret-key" - JWT_ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days - - # Email / SMTP configuration for password recovery - SMTP_HOST: str = "" - SMTP_PORT: int = 587 - SMTP_USER: str = "" - SMTP_PASSWORD: str = "" - SMTP_FROM_EMAIL: str = "" - SMTP_USE_TLS: bool = True - - # Frontend URL for password reset links - FRONTEND_URL: str = "http://localhost:3000" - - # File storage - UPLOAD_DIR: str = str(Path(__file__).resolve().parent.parent / "uploads") - MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB - - # Timezone - APP_TIMEZONE: str = "Asia/Shanghai" - - # Server - HOST: str = "0.0.0.0" - PORT: int = 8000 - LOG_LEVEL: str = "info" + # ── Rate Limiting ────────────────────────────────────────────────── + RATE_LIMIT_LOGIN: str = Field( + default="10/minute", + description="登录端点速率限制(防止暴力破解)。格式: <次数>/<单位>,单位可为 second/minute/hour", + ) + RATE_LIMIT_DEFAULT: str = Field( + default="120/minute", + description="默认 API 端点速率限制", + ) + + # ── Database ─────────────────────────────────────────────────────── + DATABASE_URL: str = Field( + default="sqlite:///./data/opengecko.db", + description="数据库连接 URL。" + "开发: sqlite:///./data/opengecko.db;" + "生产推荐 PostgreSQL: postgresql://user:pass@host:5432/db", + ) + DB_POOL_SIZE: int = Field(default=5, description="数据库连接池大小(PostgreSQL/MySQL 生效)") + DB_MAX_OVERFLOW: int = Field(default=10, description="连接池最大溢出连接数") + DB_POOL_TIMEOUT: int = Field(default=30, description="获取连接的超时秒数") + DB_POOL_RECYCLE: int = Field(default=3600, description="连接回收时间(秒),防止数据库长连接断开") + DB_ECHO: bool = Field(default=False, description="是否打印所有 SQL 语句(调试用,生产禁用)") + + # ── Default Admin ────────────────────────────────────────────────── + DEFAULT_ADMIN_USERNAME: str = Field(default="admin", description="初始管理员用户名(首次启动时创建)") + DEFAULT_ADMIN_PASSWORD: str = Field( + default="admin123", + description="初始管理员密码(⚠️ 生产环境首次启动后立即修改)", + ) + DEFAULT_ADMIN_EMAIL: str = Field(default="admin@example.com", description="初始管理员邮箱") + + # ── JWT ──────────────────────────────────────────────────────────── + JWT_SECRET_KEY: str = Field( + default="change-me-in-production-please-use-a-strong-secret-key", + description="JWT 签名密钥(⚠️ 生产环境必须替换,建议 `openssl rand -hex 32` 生成)", + ) + JWT_ALGORITHM: str = Field(default="HS256", description="JWT 签名算法") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( + default=60 * 24 * 7, + description="访问令牌有效期(分钟)。默认 7 天 = 10080 分钟", + ) + + # ── SMTP ─────────────────────────────────────────────────────────── + SMTP_HOST: str = Field(default="", description="SMTP 服务器地址(留空则禁用邮件功能)") + SMTP_PORT: int = Field(default=587, description="SMTP 端口(587=STARTTLS,465=SSL)") + SMTP_USER: str = Field(default="", description="SMTP 登录用户名") + SMTP_PASSWORD: str = Field(default="", description="SMTP 登录密码") + SMTP_FROM_EMAIL: str = Field(default="", description="发件人邮箱地址") + SMTP_USE_TLS: bool = Field(default=True, description="是否使用 TLS/STARTTLS 加密连接") + + # ── Frontend ─────────────────────────────────────────────────────── + FRONTEND_URL: str = Field( + default="http://localhost:3000", + description="前端访问地址,用于生成密码重置链接邮件中的跳转 URL", + ) + + # ── File Storage ─────────────────────────────────────────────────── + UPLOAD_DIR: str = Field( + default=str(Path(__file__).resolve().parent.parent / "uploads"), + description="本地文件上传目录(仅 STORAGE_BACKEND=local 时使用)", + ) + MAX_UPLOAD_SIZE: int = Field( + default=50 * 1024 * 1024, + description="单次上传文件大小上限(字节)。默认 50MB = 52428800", + ) + STORAGE_BACKEND: str = Field( + default="local", + description="文件存储后端。" + "local:本地文件系统(开发默认);" + "s3:S3 兼容对象存储(MinIO / AWS S3 / 华为 OBS 等,生产推荐)", + ) + + # ── S3 / MinIO ───────────────────────────────────────────────────── + S3_ENDPOINT_URL: str = Field( + default="http://minio:9000", + description="S3 兼容存储的访问端点。" + "MinIO Docker 内网: http://minio:9000;" + "AWS S3: https://s3.amazonaws.com;" + "华为 OBS: https://obs..myhuaweicloud.com", + ) + S3_ACCESS_KEY: str = Field(default="minioadmin", description="S3/MinIO Access Key(用户名)") + S3_SECRET_KEY: str = Field(default="minioadmin", description="S3/MinIO Secret Key(密码)") + S3_BUCKET: str = Field(default="opengecko", description="S3 Bucket 名称") + S3_PUBLIC_URL: str = Field( + default="http://minio:9000/opengecko", + description="Bucket 的公开访问基础 URL(nginx 将 /uploads/ 代理到此地址)。" + "内网部署保持默认;对外暴露时改为公网域名", + ) + + # ── Timezone ─────────────────────────────────────────────────────── + APP_TIMEZONE: str = Field( + default="Asia/Shanghai", + description="服务端时区,影响 ICS 日历和邮件通知中的时间显示。" + "数据库始终以 UTC 存储,此配置仅控制输出本地化。" + "IANA 时区列表: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", + ) + + # ── Server ───────────────────────────────────────────────────────── + HOST: str = Field(default="0.0.0.0", description="服务监听地址") + PORT: int = Field(default=8000, description="服务监听端口") + LOG_LEVEL: str = Field( + default="info", + description="日志级别。可选: debug / info / warning / error / critical", + ) model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} diff --git a/backend/app/main.py b/backend/app/main.py index 1c52978..68121c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.api import ( + admin, analytics, auth, campaigns, @@ -157,8 +158,10 @@ async def general_exception_handler(request: Request, exc: Exception): ) -# Serve uploaded files -app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") +# Serve uploaded files — only in local storage mode. +# When STORAGE_BACKEND=s3, nginx proxies /uploads/ directly to MinIO. +if settings.STORAGE_BACKEND == "local": + app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") # Register API routers @@ -179,6 +182,7 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(campaigns.router, prefix="/api/campaigns", tags=["Campaigns"]) app.include_router(ecosystem.router, prefix="/api/ecosystem", tags=["Ecosystem"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) @app.get("/api/health") diff --git a/backend/app/models/event.py b/backend/app/models/event.py index ba16100..1d2be65 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -1,4 +1,4 @@ -from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, Text +from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Table, Text from sqlalchemy import Enum as SAEnum from sqlalchemy.orm import relationship @@ -74,11 +74,11 @@ class Event(Base): Integer, ForeignKey("event_templates.id", ondelete="SET NULL"), nullable=True ) status = Column( - SAEnum("draft", "planning", "ongoing", "completed", "cancelled", name="event_status_enum"), - default="draft", + SAEnum("planning", "ongoing", "completed", name="event_status_enum"), + default="planning", ) planned_at = Column(DateTime(timezone=True), nullable=True) - duration_minutes = Column(Integer, nullable=True) + duration_hours = Column(Float, nullable=True) location = Column(String(300), nullable=True) online_url = Column(String(500), nullable=True) description = Column(Text, nullable=True) diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index fc683fc..6520290 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -69,7 +69,7 @@ class EventCreate(BaseModel): community_ids: list[int] = [] template_id: int | None = None planned_at: datetime | None = None - duration_minutes: int | None = None + duration_hours: float | None = None location: str | None = None online_url: str | None = None description: str | None = None @@ -81,7 +81,7 @@ class EventUpdate(BaseModel): event_type: str | None = None community_ids: list[int] | None = None planned_at: datetime | None = None - duration_minutes: int | None = None + duration_hours: float | None = None location: str | None = None online_url: str | None = None description: str | None = None @@ -96,7 +96,7 @@ class EventUpdate(BaseModel): class EventStatusUpdate(BaseModel): - status: str # draft / planning / ongoing / completed / cancelled + status: str # planning / ongoing / completed class CommunitySimple(BaseModel): @@ -116,7 +116,7 @@ class EventOut(BaseModel): template_id: int | None status: str planned_at: datetime | None - duration_minutes: int | None + duration_hours: float | None location: str | None online_url: str | None description: str | None diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..e96ed07 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,92 @@ +"""StorageService abstraction — local filesystem or S3-compatible (MinIO / AWS S3).""" +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from pathlib import Path + + +class StorageService(ABC): + @abstractmethod + def save(self, data: bytes, key: str) -> str: + """Save file data under *key* and return the public URL path (e.g. /uploads/covers/abc.jpg).""" + + @abstractmethod + def delete(self, key: str) -> None: + """Delete the object identified by *key* (relative path, e.g. covers/abc.jpg).""" + + @staticmethod + def generate_key(ext: str, prefix: str = "") -> str: + """Generate a unique storage key. *ext* should include the dot (e.g. '.jpg').""" + name = f"{uuid.uuid4().hex}{ext}" + return f"{prefix}/{name}" if prefix else name + + +class LocalStorage(StorageService): + """Store files on the local filesystem under *upload_dir*.""" + + def __init__(self, upload_dir: str) -> None: + self.upload_dir = Path(upload_dir) + + def save(self, data: bytes, key: str) -> str: + path = self.upload_dir / key + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return f"/uploads/{key}" + + def delete(self, key: str) -> None: + path = self.upload_dir / key + if path.exists(): + path.unlink() + + +class S3Storage(StorageService): + """Store files in an S3-compatible object store (MinIO, AWS S3, Huawei OBS …).""" + + def __init__( + self, + endpoint_url: str, + access_key: str, + secret_key: str, + bucket: str, + public_url: str, + ) -> None: + import boto3 # lazy import — only required when S3 backend is active + + self.bucket = bucket + self.public_url = public_url.rstrip("/") + self.client = boto3.client( + "s3", + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name="us-east-1", + ) + # Ensure the bucket exists + try: + self.client.head_bucket(Bucket=bucket) + except Exception: + self.client.create_bucket(Bucket=bucket) + + def save(self, data: bytes, key: str) -> str: + self.client.put_object(Bucket=self.bucket, Key=key, Body=data) + # Return the same /uploads/ path — nginx proxies this to MinIO + return f"/uploads/{key}" + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.bucket, Key=key) + + +def get_storage() -> StorageService: + """Factory: return the configured StorageService instance.""" + from app.config import settings # deferred to avoid circular imports + + if settings.STORAGE_BACKEND == "s3": + return S3Storage( + endpoint_url=settings.S3_ENDPOINT_URL, + access_key=settings.S3_ACCESS_KEY, + secret_key=settings.S3_SECRET_KEY, + bucket=settings.S3_BUCKET, + public_url=settings.S3_PUBLIC_URL, + ) + return LocalStorage(settings.UPLOAD_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index 822ad84..586470b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,3 +25,4 @@ python-jose[cryptography]==3.3.0 email-validator>=2.0 cryptography>=42.0 apscheduler==3.10.4 +boto3>=1.35.0 diff --git a/backend/scripts/migrate_to_minio.py b/backend/scripts/migrate_to_minio.py new file mode 100644 index 0000000..934d242 --- /dev/null +++ b/backend/scripts/migrate_to_minio.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""将本地 uploads/ 目录中的已有文件批量迁移到 MinIO(或任意 S3 兼容存储)。 + +用法: + # 使用默认值(MinIO 本地 Docker 实例) + python backend/scripts/migrate_to_minio.py + + # 指定参数 + S3_ENDPOINT_URL=http://localhost:9000 \\ + S3_ACCESS_KEY=minioadmin \\ + S3_SECRET_KEY=minioadmin \\ + S3_BUCKET=opengecko \\ + UPLOAD_DIR=./uploads \\ + python backend/scripts/migrate_to_minio.py + +注意: +- 执行前请确保 MinIO 服务已启动(docker compose up minio -d) +- 迁移完成后,将 backend/.env 中的 STORAGE_BACKEND 改为 s3 再重启后端 +""" + +import os +import sys +from pathlib import Path + +try: + import boto3 + from botocore.exceptions import ClientError, NoCredentialsError +except ImportError: + print("错误:缺少依赖 boto3,请先安装:pip install boto3") + sys.exit(1) + +ENDPOINT = os.environ.get("S3_ENDPOINT_URL", "http://localhost:9000") +ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "minioadmin") +SECRET_KEY = os.environ.get("S3_SECRET_KEY", "minioadmin") +BUCKET = os.environ.get("S3_BUCKET", "opengecko") +LOCAL_DIR = Path(os.environ.get("UPLOAD_DIR", "./uploads")) + + +def main() -> None: + if not LOCAL_DIR.exists(): + print(f"错误:本地上传目录不存在:{LOCAL_DIR}") + sys.exit(1) + + print(f"MinIO endpoint : {ENDPOINT}") + print(f"Bucket : {BUCKET}") + print(f"本地目录 : {LOCAL_DIR.resolve()}") + print() + + try: + client = boto3.client( + "s3", + endpoint_url=ENDPOINT, + aws_access_key_id=ACCESS_KEY, + aws_secret_access_key=SECRET_KEY, + region_name="us-east-1", + ) + except NoCredentialsError: + print("错误:S3 凭证无效") + sys.exit(1) + + # 确保 bucket 存在 + try: + client.head_bucket(Bucket=BUCKET) + print(f"Bucket '{BUCKET}' 已存在") + except ClientError: + print(f"Bucket '{BUCKET}' 不存在,正在创建…") + client.create_bucket(Bucket=BUCKET) + print(f"Bucket '{BUCKET}' 创建成功") + + # 遍历本地文件并上传 + files = [p for p in LOCAL_DIR.rglob("*") if p.is_file()] + if not files: + print("本地目录为空,无需迁移") + return + + print(f"共找到 {len(files)} 个文件,开始上传…\n") + ok = 0 + fail = 0 + for file_path in files: + key = str(file_path.relative_to(LOCAL_DIR)) + try: + client.upload_file(str(file_path), BUCKET, key) + print(f" ✓ {key}") + ok += 1 + except Exception as exc: + print(f" ✗ {key} ({exc})") + fail += 1 + + print(f"\n迁移完成:成功 {ok} 个,失败 {fail} 个") + if fail: + print("存在上传失败的文件,请检查错误信息后重试") + sys.exit(1) + else: + print("\n下一步:在 backend/.env 中设置 STORAGE_BACKEND=s3,然后重启后端服务") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_events_api.py b/backend/tests/test_events_api.py index 7486c9d..fffffdf 100644 --- a/backend/tests/test_events_api.py +++ b/backend/tests/test_events_api.py @@ -11,7 +11,7 @@ def _create_event( db_session: Session, community_id: int, title: str = "测试活动", - status: str = "draft", + status: str = "planning", event_type: str = "offline", ) -> Event: event = Event( @@ -64,16 +64,16 @@ def test_delete_event_no_auth( resp = client.delete(f"/api/events/{event.id}") assert resp.status_code == 401 - def test_delete_cancelled_event( + def test_delete_completed_event( self, client: TestClient, auth_headers: dict, db_session: Session, test_community: Community, ): - """已取消状态的活动也可以被删除""" + """已完成状态的活动也可以被删除""" event = _create_event( - db_session, test_community.id, title="已取消活动", status="cancelled" + db_session, test_community.id, title="已完成活动", status="completed" ) resp = client.delete(f"/api/events/{event.id}", headers=auth_headers) assert resp.status_code == 204 diff --git a/docker-compose.yml b/docker-compose.yml index 165ee6c..558d073 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,41 @@ services: + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + ports: + - "9001:9001" # MinIO Console — available at http://localhost:9001 (admin UI) + volumes: + - minio_data:/data + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin} + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + backend: build: ./backend ports: - "8000:8000" volumes: - - ./uploads:/app/uploads - ./data:/app/data env_file: - ./backend/.env environment: TZ: ${APP_TIMEZONE:-Asia/Shanghai} + STORAGE_BACKEND: ${STORAGE_BACKEND:-s3} + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minioadmin} + S3_SECRET_KEY: ${S3_SECRET_KEY:-minioadmin} + S3_BUCKET: ${S3_BUCKET:-opengecko} + S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://minio:9000/opengecko} + depends_on: + minio: + condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] @@ -31,3 +57,6 @@ services: interval: 30s timeout: 5s retries: 3 + +volumes: + minio_data: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 5eda16e..31b89e0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -79,12 +79,15 @@ server { } } - # ── 上传文件代理 ─────────────────────────────────────────────────── + # ── 上传文件(MinIO 对象存储) ────────────────────────────────────── + # /uploads/foo/bar.jpg → minio:9000/opengecko/foo/bar.jpg + # 开发环境不走此 nginx(make dev 直接由 FastAPI StaticFiles 提供服务) location /uploads/ { - proxy_pass http://backend:8000; - proxy_set_header Host $host; + proxy_pass http://minio:9000/opengecko/; + proxy_set_header Host minio; proxy_set_header X-Real-IP $remote_addr; - expires 7d; + proxy_buffering off; + expires 30d; add_header Cache-Control "public"; } diff --git a/frontend/src/api/event.ts b/frontend/src/api/event.ts index d73d772..3ed204a 100644 --- a/frontend/src/api/event.ts +++ b/frontend/src/api/event.ts @@ -26,7 +26,7 @@ export interface EventDetail { template_id: number | null status: string planned_at: string | null - duration_minutes: number | null + duration_hours: number | null location: string | null online_url: string | null description: string | null @@ -49,7 +49,7 @@ export interface EventCreate { community_ids?: number[] template_id?: number | null planned_at?: string | null - duration_minutes?: number | null + duration_hours?: number | null location?: string | null online_url?: string | null description?: string | null diff --git a/frontend/src/views/EventDetail.vue b/frontend/src/views/EventDetail.vue index 30eab88..dcc3191 100644 --- a/frontend/src/views/EventDetail.vue +++ b/frontend/src/views/EventDetail.vue @@ -48,7 +48,7 @@
{{ formatDateTime(event.planned_at) }} - ({{ event.duration_minutes }} 分钟) + ({{ event.duration_hours }} 小时)
@@ -74,20 +74,25 @@ + + + + + + + - - - - + + @@ -423,12 +428,13 @@ const isEditing = ref(false) const saving = ref(false) const editForm = ref({ title: '', + event_type: 'offline' as string, planned_at: null as string | null, - duration_minutes: null as number | null, + duration_hours: null as number | null, location: '', online_url: '', description: '', - status: 'draft' as string, + status: 'planning' as string, community_ids: [] as number[], }) @@ -439,8 +445,8 @@ const personnelForm = ref({ role: '', role_label: '', assignee_type: 'internal', const feedbackForm = ref({ category: 'question', raised_by: '', content: '' }) // ─── Labels & Maps ──────────────────────────────────────────────────────────── -const statusLabel: Record = { draft: '草稿', planning: '策划中', ongoing: '进行中', completed: '已完成', cancelled: '已取消' } -const statusTagMap: Record = { draft: 'info', planning: 'warning', ongoing: 'primary', completed: 'success', cancelled: 'danger' } +const statusLabel: Record = { planning: '策划中', ongoing: '进行中', completed: '已完成' } +const statusTagMap: Record = { planning: 'warning', ongoing: 'primary', completed: 'success' } const typeLabel: Record = { offline: '线下', online: '线上', hybrid: '混合' } const typeTagMap: Record = { offline: '', online: 'success', hybrid: 'warning' } const phaseLabel: Record = { pre: '会前', during: '会中', post: '会后' } @@ -501,12 +507,13 @@ async function loadEvent() { isEditing.value = true editForm.value = { title: '', + event_type: 'offline', planned_at: null, - duration_minutes: null, + duration_hours: null, location: '', online_url: '', description: '', - status: 'draft', + status: 'planning', community_ids: communityStore.currentCommunityId ? [communityStore.currentCommunityId] : [], } } else { @@ -548,8 +555,9 @@ function startEdit() { if (!event.value) return editForm.value = { title: event.value.title, + event_type: event.value.event_type, planned_at: event.value.planned_at, - duration_minutes: event.value.duration_minutes, + duration_hours: event.value.duration_hours, location: event.value.location || '', online_url: event.value.online_url || '', description: event.value.description || '', @@ -580,8 +588,9 @@ async function saveEdit() { const communityIds = editForm.value.community_ids const newEvent = await createEvent({ title: editForm.value.title, + event_type: editForm.value.event_type, planned_at: editForm.value.planned_at || null, - duration_minutes: editForm.value.duration_minutes || null, + duration_hours: editForm.value.duration_hours || null, location: editForm.value.location || null, online_url: editForm.value.online_url || null, description: editForm.value.description || null, @@ -593,8 +602,9 @@ async function saveEdit() { } else { await updateEvent(eventId.value!, { title: editForm.value.title, + event_type: editForm.value.event_type, planned_at: editForm.value.planned_at, - duration_minutes: editForm.value.duration_minutes, + duration_hours: editForm.value.duration_hours, location: editForm.value.location || null, online_url: editForm.value.online_url || null, description: editForm.value.description || null, diff --git a/frontend/src/views/Events.vue b/frontend/src/views/Events.vue index dddcd6a..0426fa8 100644 --- a/frontend/src/views/Events.vue +++ b/frontend/src/views/Events.vue @@ -35,11 +35,9 @@ /> - - @@ -216,19 +214,15 @@ const createForm = ref({ }) const statusLabel: Record = { - draft: '草稿', planning: '策划中', ongoing: '进行中', completed: '已完成', - cancelled: '已取消', } const statusTagMap: Record = { - draft: 'info', planning: 'warning', ongoing: 'primary', completed: 'success', - cancelled: 'danger', } const typeLabel: Record = { diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index c639e9d..5eabf5d 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -3,7 +3,7 @@