Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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(可选,用于密码重置)
Expand Down
20 changes: 15 additions & 5 deletions backend/.env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 邮件(可选,用于密码重置通知)
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
42 changes: 42 additions & 0 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
@@ -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,
}
12 changes: 5 additions & 7 deletions backend/app/api/community_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}


Expand Down
47 changes: 24 additions & 23 deletions backend/app/api/upload.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
import uuid
import tempfile

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
Expand All @@ -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()

Expand All @@ -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,
Expand Down Expand Up @@ -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
Loading
Loading