diff --git a/backend/alembic/versions/010_make_community_id_nullable.py b/backend/alembic/versions/010_make_community_id_nullable.py new file mode 100644 index 0000000..b3e0cee --- /dev/null +++ b/backend/alembic/versions/010_make_community_id_nullable.py @@ -0,0 +1,86 @@ +"""将 events / event_templates / contents / campaigns 的 community_id 改为可选(nullable) + +架构重构:从「社区隔离」改为「社区关联」模式 +- 活动、内容、运营活动、生态洞察可独立存在,通过 community_id 属性可选关联到社区 +- 委员会、会议仍保持强社区绑定 + +Revision ID: 010_make_community_id_nullable +Revises: 009_add_ecosystem_community_id +Create Date: 2026-02-23 +""" +import sqlalchemy as sa +from alembic import op + +revision = "010_make_community_id_nullable" +down_revision = "009_add_ecosystem_community_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # events.community_id: NOT NULL → nullable + with op.batch_alter_table("events") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # event_templates.community_id: NOT NULL → nullable + with op.batch_alter_table("event_templates") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # contents.community_id: NOT NULL → nullable + with op.batch_alter_table("contents") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # campaigns.community_id: NOT NULL → nullable + with op.batch_alter_table("campaigns") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # ecosystem_projects.community_id was already added as nullable in 009, + # but model shows nullable=False. No DDL change needed here since SQLite + # already has it nullable; the model fix is done in the ORM layer. + + +def downgrade() -> None: + # Note: downgrade may fail if there are rows with NULL community_id + with op.batch_alter_table("campaigns") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=False, + ) + + with op.batch_alter_table("contents") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=False, + ) + + with op.batch_alter_table("event_templates") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=False, + ) + + with op.batch_alter_table("events") as batch_op: + batch_op.alter_column( + "community_id", + existing_type=sa.Integer(), + nullable=False, + ) diff --git a/backend/app/api/campaigns.py b/backend/app/api/campaigns.py index 5f35d71..e99fd10 100644 --- a/backend/app/api/campaigns.py +++ b/backend/app/api/campaigns.py @@ -4,7 +4,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session, joinedload -from app.core.dependencies import get_current_community, get_current_user +from app.core.dependencies import get_current_user from app.database import get_db from app.models import User from app.models.campaign import Campaign, CampaignActivity, CampaignContact @@ -39,11 +39,10 @@ def list_campaigns( type: str | None = None, status: str | None = None, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - query = db.query(Campaign).filter(Campaign.community_id == community_id) + query = db.query(Campaign) if type: query = query.filter(Campaign.type == type) if status: @@ -54,14 +53,12 @@ def list_campaigns( @router.post("", response_model=CampaignOut, status_code=201) def create_campaign( data: CampaignCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if data.type not in VALID_TYPES: raise HTTPException(400, f"type 必须为 {VALID_TYPES}") campaign = Campaign( - community_id=community_id, owner_id=current_user.id, **data.model_dump(), ) @@ -74,12 +71,11 @@ def create_campaign( @router.get("/{cid}", response_model=CampaignOut) def get_campaign( cid: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -90,12 +86,11 @@ def get_campaign( def update_campaign( cid: int, data: CampaignUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -114,12 +109,11 @@ def update_campaign( @router.get("/{cid}/funnel", response_model=CampaignFunnel) def campaign_funnel( cid: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -149,12 +143,11 @@ def list_contacts( status: str | None = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -170,12 +163,11 @@ def list_contacts( def add_contact( cid: int, data: ContactCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -202,7 +194,6 @@ def update_contact_status( cid: int, contact_id: int, data: ContactStatusUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -229,20 +220,18 @@ def update_contact_status( def import_from_event( cid: int, data: BulkImportFromEvent, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """从活动签到名单批量导入联系人。""" campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") - # 校验活动存在且属于当前社区 + # 校验活动存在 event = db.query(Event).filter( Event.id == data.event_id, - Event.community_id == community_id, ).first() if not event: raise HTTPException(404, "活动不存在") @@ -272,13 +261,12 @@ def import_from_event( def import_from_people( cid: int, data: BulkImportFromPeople, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """从人脉库批量导入指定 person_id 列表。""" campaign = db.query(Campaign).filter( - Campaign.id == cid, Campaign.community_id == community_id + Campaign.id == cid ).first() if not campaign: raise HTTPException(404, "运营活动不存在") @@ -319,7 +307,6 @@ def import_from_people( def list_activities( cid: int, contact_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -345,7 +332,6 @@ def add_activity( cid: int, contact_id: int, data: ActivityCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): diff --git a/backend/app/api/contents.py b/backend/app/api/contents.py index 1a942f7..f0e22d7 100644 --- a/backend/app/api/contents.py +++ b/backend/app/api/contents.py @@ -5,7 +5,7 @@ from sqlalchemy import select as sa_select from sqlalchemy.orm import Session -from app.core.dependencies import check_content_edit_permission, get_current_community, get_current_user +from app.core.dependencies import check_content_edit_permission, get_current_user from app.database import get_db from app.models import Content, User from app.models.content import content_communities @@ -73,12 +73,13 @@ def list_contents( status: str | None = None, source_type: str | None = None, keyword: str | None = None, - community_id: int = Depends(get_current_community), + community_id: int | None = Query(None), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - # 通过 content_communities 关联表过滤(兼容遷移期间的 community_id 列) - query = db.query(Content).filter(_build_community_filter(community_id)) + query = db.query(Content) + if community_id is not None: + query = query.filter(_build_community_filter(community_id)) if status: query = query.filter(Content.status == status) @@ -94,13 +95,14 @@ def list_contents( @router.post("", response_model=ContentOut, status_code=201) def create_content( data: ContentCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if data.source_type not in VALID_SOURCE_TYPES: raise HTTPException(400, f"Invalid source_type, must be one of {VALID_SOURCE_TYPES}") content_html = convert_markdown_to_html(data.content_markdown) if data.content_markdown else "" + # 主社区:优先取 community_ids[0],兼容旧逻辑 + primary_community_id = data.community_ids[0] if data.community_ids else None content = Content( title=data.title, content_markdown=data.content_markdown, @@ -112,7 +114,7 @@ def create_content( cover_image=data.cover_image, status="draft", work_status=data.work_status, - community_id=community_id, + community_id=primary_community_id, created_by_user_id=current_user.id, owner_id=current_user.id, # Creator is the initial owner scheduled_publish_at=data.scheduled_publish_at, @@ -120,9 +122,9 @@ def create_content( db.add(content) db.flush() # Get content ID - # 写入多社区关联(未指定则关联当前社区) - target_community_ids = data.community_ids if data.community_ids else [community_id] - _write_content_communities(db, content.id, target_community_ids, current_user.id) + # 写入多社区关联(仅在提供了 community_ids 时) + if data.community_ids: + _write_content_communities(db, content.id, data.community_ids, current_user.id) # Assign assignees (default to creator if empty) — batch query to avoid N+1 assignee_ids = data.assignee_ids if data.assignee_ids else [current_user.id] @@ -143,14 +145,10 @@ def create_content( @router.get("/{content_id}", response_model=ContentOut) def get_content( content_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -166,14 +164,10 @@ def get_content( def update_content( content_id: int, data: ContentUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -229,14 +223,10 @@ def update_content( @router.delete("/{content_id}", status_code=204) def delete_content( content_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -252,16 +242,12 @@ def delete_content( def update_content_status( content_id: int, data: ContentStatusUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if data.status not in VALID_STATUSES: raise HTTPException(400, f"Invalid status, must be one of {VALID_STATUSES}") - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -285,17 +271,13 @@ def update_content_status( @router.get("/{content_id}/collaborators") def list_collaborators( content_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ List collaborators of a content. """ - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -313,7 +295,6 @@ def list_collaborators( def add_collaborator( content_id: int, user_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -321,10 +302,7 @@ def add_collaborator( Add a collaborator to a content. Only the owner can add collaborators. """ - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -332,14 +310,10 @@ def add_collaborator( if content.owner_id != current_user.id and not current_user.is_superuser: raise HTTPException(403, "Only the content owner can add collaborators") - # Check if user exists and is a member of the community user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(404, "User not found") - if not current_user.is_superuser and user not in content.community.members: - raise HTTPException(400, "User is not a member of this community") - # Check if already a collaborator if user in content.collaborators: raise HTTPException(400, "User is already a collaborator") @@ -354,7 +328,6 @@ def add_collaborator( def remove_collaborator( content_id: int, user_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -362,10 +335,7 @@ def remove_collaborator( Remove a collaborator from a content. Only the owner can remove collaborators. """ - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -388,7 +358,6 @@ def remove_collaborator( def transfer_ownership( content_id: int, new_owner_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -396,10 +365,7 @@ def transfer_ownership( Transfer content ownership to another user. Only the current owner or superuser can transfer ownership. """ - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") @@ -407,14 +373,10 @@ def transfer_ownership( if content.owner_id != current_user.id and not current_user.is_superuser: raise HTTPException(403, "Only the content owner can transfer ownership") - # Check if new owner exists and is a member of the community new_owner = db.query(User).filter(User.id == new_owner_id).first() if not new_owner: raise HTTPException(404, "New owner not found") - if not current_user.is_superuser and new_owner not in content.community.members: - raise HTTPException(400, "New owner is not a member of this community") - content.owner_id = new_owner_id db.commit() db.refresh(content) @@ -435,7 +397,7 @@ def list_calendar_events( start: str = Query(..., description="Start date ISO format (e.g. 2026-02-01)"), end: str = Query(..., description="End date ISO format (e.g. 2026-03-01)"), status: str | None = None, - community_id: int = Depends(get_current_community), + community_id: int | None = Query(None), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -450,7 +412,9 @@ def list_calendar_events( except ValueError: raise HTTPException(400, "Invalid date format. Use ISO format (e.g. 2026-02-01)") from None - query = db.query(Content).filter(_build_community_filter(community_id)) + query = db.query(Content) + if community_id is not None: + query = query.filter(_build_community_filter(community_id)) if status: query = query.filter(Content.status == status) @@ -482,17 +446,13 @@ def list_calendar_events( def update_content_schedule( content_id: int, data: ContentScheduleUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ 更新内容的排期发布时间(用于日历拖拽)。 """ - content = db.query(Content).filter( - Content.id == content_id, - _build_community_filter(community_id), - ).first() + content = db.query(Content).filter(Content.id == content_id).first() if not content: raise HTTPException(404, "Content not found") diff --git a/backend/app/api/ecosystem.py b/backend/app/api/ecosystem.py index 1cda254..55549ff 100644 --- a/backend/app/api/ecosystem.py +++ b/backend/app/api/ecosystem.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from app.config import settings -from app.core.dependencies import get_current_community, get_current_user +from app.core.dependencies import get_current_user from app.database import get_db from app.models import User from app.models.ecosystem import EcosystemContributor, EcosystemProject @@ -26,22 +26,19 @@ @router.get("", response_model=list[ProjectListOut]) def list_projects( - community_id: int = Depends(get_current_community), + community_id: int | None = Query(None), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - return ( - db.query(EcosystemProject) - .filter(EcosystemProject.community_id == community_id) - .order_by(EcosystemProject.created_at.desc()) - .all() - ) + query = db.query(EcosystemProject) + if community_id is not None: + query = query.filter(EcosystemProject.community_id == community_id) + return query.order_by(EcosystemProject.created_at.desc()).all() @router.post("", response_model=ProjectOut, status_code=201) def create_project( data: ProjectCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -49,7 +46,6 @@ def create_project( raise HTTPException(400, f"platform 必须为 {VALID_PLATFORMS}") project = EcosystemProject( added_by_id=current_user.id, - community_id=community_id, **data.model_dump(), ) db.add(project) @@ -61,14 +57,10 @@ def create_project( @router.get("/{pid}", response_model=ProjectOut) def get_project( pid: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - project = db.query(EcosystemProject).filter( - EcosystemProject.id == pid, - EcosystemProject.community_id == community_id, - ).first() + project = db.query(EcosystemProject).filter(EcosystemProject.id == pid).first() if not project: raise HTTPException(404, "项目不存在") return project @@ -78,14 +70,10 @@ def get_project( def update_project( pid: int, data: ProjectUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - project = db.query(EcosystemProject).filter( - EcosystemProject.id == pid, - EcosystemProject.community_id == community_id, - ).first() + project = db.query(EcosystemProject).filter(EcosystemProject.id == pid).first() if not project: raise HTTPException(404, "项目不存在") for key, value in data.model_dump(exclude_unset=True).items(): @@ -100,15 +88,11 @@ def update_project( @router.post("/{pid}/sync", response_model=SyncResult) def trigger_sync( pid: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """手动触发单个项目同步。""" - project = db.query(EcosystemProject).filter( - EcosystemProject.id == pid, - EcosystemProject.community_id == community_id, - ).first() + project = db.query(EcosystemProject).filter(EcosystemProject.id == pid).first() if not project: raise HTTPException(404, "项目不存在") token = getattr(settings, "GITHUB_PAT", None) @@ -125,14 +109,10 @@ def list_contributors( unlinked: bool = False, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - project = db.query(EcosystemProject).filter( - EcosystemProject.id == pid, - EcosystemProject.community_id == community_id, - ).first() + project = db.query(EcosystemProject).filter(EcosystemProject.id == pid).first() if not project: raise HTTPException(404, "项目不存在") query = db.query(EcosystemContributor).filter(EcosystemContributor.project_id == pid) @@ -155,16 +135,11 @@ def list_contributors( def import_contributor_to_people( pid: int, handle: str, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """将贡献者导入人脉库,并关联 person_id。""" - # 校验项目属于当前社区 - project = db.query(EcosystemProject).filter( - EcosystemProject.id == pid, - EcosystemProject.community_id == community_id, - ).first() + project = db.query(EcosystemProject).filter(EcosystemProject.id == pid).first() if not project: raise HTTPException(404, "项目不存在") diff --git a/backend/app/api/event_templates.py b/backend/app/api/event_templates.py index 9772345..964c756 100644 --- a/backend/app/api/event_templates.py +++ b/backend/app/api/event_templates.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app.core.dependencies import get_current_community, get_current_user +from app.core.dependencies import get_current_user from app.database import get_db from app.models import User from app.models.event import ChecklistTemplateItem, EventTemplate @@ -12,15 +12,12 @@ @router.get("", response_model=list[EventTemplateListOut]) def list_templates( - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): return ( db.query(EventTemplate) - .filter( - (EventTemplate.community_id == community_id) | (EventTemplate.is_public == True) # noqa: E712 - ) + .filter(EventTemplate.is_public == True) # noqa: E712 .order_by(EventTemplate.created_at.desc()) .all() ) @@ -29,12 +26,11 @@ def list_templates( @router.post("", response_model=EventTemplateOut, status_code=201) def create_template( data: EventTemplateCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): template = EventTemplate( - community_id=community_id, + community_id=None, name=data.name, event_type=data.event_type, description=data.description, @@ -56,14 +52,13 @@ def create_template( @router.get("/{template_id}", response_model=EventTemplateOut) def get_template( template_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() if not template: raise HTTPException(404, "模板不存在") - if template.community_id != community_id and not template.is_public: + if not template.is_public: raise HTTPException(403, "无权访问此模板") return template @@ -72,14 +67,10 @@ def get_template( def update_template( template_id: int, data: EventTemplateUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - template = db.query(EventTemplate).filter( - EventTemplate.id == template_id, - EventTemplate.community_id == community_id, - ).first() + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() if not template: raise HTTPException(404, "模板不存在") for key, value in data.model_dump(exclude_unset=True).items(): diff --git a/backend/app/api/events.py b/backend/app/api/events.py index ccdca53..f1dbfc9 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from app.core.dependencies import get_current_community, get_current_user +from app.core.dependencies import get_current_user from app.database import get_db from app.models import User from app.models.event import ( @@ -48,13 +48,15 @@ def list_events( status: str | None = None, event_type: str | None = None, + community_id: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - query = db.query(Event).filter(Event.community_id == community_id) + query = db.query(Event) + if community_id is not None: + query = query.filter(Event.community_id == community_id) if status: query = query.filter(Event.status == status) if event_type: @@ -67,7 +69,6 @@ def list_events( @router.post("", response_model=EventOut, status_code=201) def create_event( data: EventCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -75,7 +76,6 @@ def create_event( raise HTTPException(400, f"event_type 必须为 {VALID_EVENT_TYPES}") event = Event( - community_id=community_id, owner_id=current_user.id, **data.model_dump(), ) @@ -102,13 +102,10 @@ def create_event( @router.get("/{event_id}", response_model=EventOut) def get_event( event_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") return event @@ -118,13 +115,10 @@ def get_event( def update_event( event_id: int, data: EventUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") for key, value in data.model_dump(exclude_unset=True).items(): @@ -138,15 +132,12 @@ def update_event( def update_event_status( event_id: int, data: EventStatusUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if data.status not in VALID_EVENT_STATUSES: raise HTTPException(400, f"status 必须为 {VALID_EVENT_STATUSES}") - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") event.status = data.status @@ -160,13 +151,10 @@ def update_event_status( @router.get("/{event_id}/checklist", response_model=list[ChecklistItemOut]) def get_checklist( event_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") return sorted(event.checklist_items, key=lambda x: (x.phase, x.order)) @@ -177,7 +165,6 @@ def update_checklist_item( event_id: int, item_id: int, data: ChecklistItemUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -198,13 +185,10 @@ def update_checklist_item( @router.get("/{event_id}/personnel", response_model=list[EventPersonnelOut]) def list_personnel( event_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") return sorted(event.personnel, key=lambda x: x.order) @@ -214,13 +198,10 @@ def list_personnel( def add_personnel( event_id: int, data: EventPersonnelCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") person = EventPersonnel(event_id=event_id, **data.model_dump()) @@ -235,7 +216,6 @@ def confirm_personnel( event_id: int, pid: int, data: PersonnelConfirmUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -256,14 +236,11 @@ def confirm_personnel( def import_attendees( event_id: int, rows: list[dict], - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """批量导入签到名单。每行需包含 person_id 字段(已确认匹配的 PersonProfile ID)。""" - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") @@ -299,13 +276,10 @@ def import_attendees( @router.get("/{event_id}/feedback", response_model=list[FeedbackOut]) def list_feedback( event_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") return event.feedback_items @@ -315,13 +289,10 @@ def list_feedback( def create_feedback( event_id: int, data: FeedbackCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") feedback = FeedbackItem(event_id=event_id, **data.model_dump()) @@ -336,7 +307,6 @@ def update_feedback( event_id: int, fid: int, data: FeedbackStatusUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -357,7 +327,6 @@ def link_issue( event_id: int, fid: int, data: IssueLinkCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -396,13 +365,10 @@ def _build_task_tree(tasks: list[EventTask]) -> list[EventTask]: @router.get("/{event_id}/tasks", response_model=list[EventTaskOut]) def list_tasks( event_id: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") tasks = db.query(EventTask).filter(EventTask.event_id == event_id).order_by(EventTask.order).all() @@ -413,13 +379,10 @@ def list_tasks( def create_task( event_id: int, data: EventTaskCreate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") task = EventTask(event_id=event_id, **data.model_dump()) @@ -435,7 +398,6 @@ def update_task( event_id: int, tid: int, data: EventTaskUpdate, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -456,7 +418,6 @@ def update_task( def delete_task( event_id: int, tid: int, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -473,13 +434,10 @@ def delete_task( def reorder_tasks( event_id: int, data: TaskReorderRequest, - community_id: int = Depends(get_current_community), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - event = db.query(Event).filter( - Event.id == event_id, Event.community_id == community_id - ).first() + event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") for item in data.tasks: diff --git a/backend/app/models/campaign.py b/backend/app/models/campaign.py index d1e132f..19f7154 100644 --- a/backend/app/models/campaign.py +++ b/backend/app/models/campaign.py @@ -10,7 +10,7 @@ class Campaign(Base): __tablename__ = "campaigns" id = Column(Integer, primary_key=True, index=True) - community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=True, index=True) name = Column(String(300), nullable=False) description = Column(Text, nullable=True) type = Column(String(50), nullable=False) # promotion / care / invitation / survey diff --git a/backend/app/models/content.py b/backend/app/models/content.py index b9eb5d9..30b7bda 100644 --- a/backend/app/models/content.py +++ b/backend/app/models/content.py @@ -64,7 +64,7 @@ class Content(Base): # Work status (工作状态): planning, in_progress, completed work_status = Column(String(50), default="planning", index=True) # Multi-tenancy fields - community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=True, index=True) created_by_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # Ownership field (defaults to creator) owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) diff --git a/backend/app/models/ecosystem.py b/backend/app/models/ecosystem.py index eca5265..252d042 100644 --- a/backend/app/models/ecosystem.py +++ b/backend/app/models/ecosystem.py @@ -10,7 +10,7 @@ class EcosystemProject(Base): __tablename__ = "ecosystem_projects" id = Column(Integer, primary_key=True, index=True) - community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=True, index=True) name = Column(String(200), nullable=False) platform = Column(String(30), nullable=False) # github / gitee / gitcode org_name = Column(String(200), nullable=False) diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 3538a41..eab7489 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -13,7 +13,7 @@ class EventTemplate(Base): id = Column(Integer, primary_key=True) community_id = Column( - Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True + Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=True, index=True ) name = Column(String(200), nullable=False) event_type = Column( @@ -55,7 +55,7 @@ class Event(Base): id = Column(Integer, primary_key=True) community_id = Column( - Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True + Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=True, index=True ) title = Column(String(300), nullable=False) event_type = Column( diff --git a/backend/app/schemas/campaign.py b/backend/app/schemas/campaign.py index c0150af..44f62bb 100644 --- a/backend/app/schemas/campaign.py +++ b/backend/app/schemas/campaign.py @@ -7,6 +7,7 @@ class CampaignCreate(BaseModel): name: str type: str # promotion / care / invitation / survey + community_id: int | None = None description: str | None = None target_count: int | None = None start_date: date | None = None @@ -25,7 +26,7 @@ class CampaignUpdate(BaseModel): class CampaignListOut(BaseModel): id: int - community_id: int + community_id: int | None name: str type: str status: str diff --git a/backend/app/schemas/content.py b/backend/app/schemas/content.py index 51812d1..51477ac 100644 --- a/backend/app/schemas/content.py +++ b/backend/app/schemas/content.py @@ -52,7 +52,7 @@ class ContentOut(BaseModel): cover_image: str | None status: str work_status: str - community_id: int + community_id: int | None created_by_user_id: int | None owner_id: int | None scheduled_publish_at: datetime | None diff --git a/backend/app/schemas/ecosystem.py b/backend/app/schemas/ecosystem.py index 4ca626f..a855fd7 100644 --- a/backend/app/schemas/ecosystem.py +++ b/backend/app/schemas/ecosystem.py @@ -9,6 +9,7 @@ class ProjectCreate(BaseModel): platform: str # github / gitee / gitcode org_name: str repo_name: str | None = None + community_id: int | None = None description: str | None = None tags: list[str] = [] diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 7a8e3a8..ff70e40 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -38,7 +38,7 @@ class EventTemplateUpdate(BaseModel): class EventTemplateOut(BaseModel): id: int - community_id: int + community_id: int | None name: str event_type: str description: str | None @@ -65,6 +65,7 @@ class EventTemplateListOut(BaseModel): class EventCreate(BaseModel): title: str event_type: str = "offline" + community_id: int | None = None template_id: int | None = None planned_at: datetime | None = None duration_minutes: int | None = None @@ -98,7 +99,7 @@ class EventStatusUpdate(BaseModel): class EventOut(BaseModel): id: int - community_id: int + community_id: int | None title: str event_type: str template_id: int | None diff --git a/backend/tests/test_calendar_api.py b/backend/tests/test_calendar_api.py index b68146d..1c7b191 100644 --- a/backend/tests/test_calendar_api.py +++ b/backend/tests/test_calendar_api.py @@ -173,7 +173,7 @@ def test_get_calendar_events_requires_auth( ) assert response.status_code in [401, 403] - def test_get_calendar_events_community_isolation( + def test_get_calendar_events_cross_community( self, client: TestClient, db_session: Session, @@ -181,7 +181,7 @@ def test_get_calendar_events_community_isolation( test_another_community: Community, auth_headers: dict, ): - """社区隔离:只能看到自己社区的数据""" + """跨社区:内容日历展示所有社区的内容(community association 模式)""" now = datetime.utcnow() db_session.add( Content( @@ -203,8 +203,10 @@ def test_get_calendar_events_community_isolation( f"/api/contents/calendar/events?start={start}&end={end}", headers=auth_headers, ) + assert response.status_code == 200 data = response.json() - assert not any(item["title"] == "Other Community Event" for item in data) + # 跨社区内容对所有用户可见 + assert any(item["title"] == "Other Community Event" for item in data) def test_get_calendar_events_response_fields( self, diff --git a/backend/tests/test_campaigns_api.py b/backend/tests/test_campaigns_api.py index 9f1128f..148c998 100644 --- a/backend/tests/test_campaigns_api.py +++ b/backend/tests/test_campaigns_api.py @@ -124,9 +124,10 @@ def test_get_campaign_not_found(self, client: TestClient, auth_headers): resp = client.get("/api/campaigns/99999", headers=auth_headers) assert resp.status_code == 404 - def test_get_campaign_wrong_community(self, client: TestClient, another_user_auth_headers, test_campaign): + def test_get_campaign_cross_community_accessible(self, client: TestClient, another_user_auth_headers, test_campaign): + """运营活动采用 community association 模式,跨社区用户也可访问""" resp = client.get(f"/api/campaigns/{test_campaign.id}", headers=another_user_auth_headers) - assert resp.status_code == 404 + assert resp.status_code == 200 class TestUpdateCampaign: diff --git a/backend/tests/test_contents_api.py b/backend/tests/test_contents_api.py index 371215a..5519a37 100644 --- a/backend/tests/test_contents_api.py +++ b/backend/tests/test_contents_api.py @@ -178,7 +178,7 @@ def test_list_contents_keyword_search( assert data["total"] == 1 assert "Python" in data["items"][0]["title"] - def test_list_contents_community_isolation( + def test_list_contents_cross_community( self, client: TestClient, db_session: Session, @@ -186,7 +186,7 @@ def test_list_contents_community_isolation( test_another_community: Community, auth_headers: dict, ): - """Test contents are isolated by community.""" + """内容采用 community association 模式,所有社区内容均可见(跨社区列表)""" # Create content in test_community content1 = Content( title="My Content", @@ -208,12 +208,14 @@ def test_list_contents_community_isolation( db_session.add_all([content1, content2]) db_session.commit() - # Should only see content from test_community + # 跨社区:两个社区的内容均可见 response = client.get("/api/contents", headers=auth_headers) assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["title"] == "My Content" + assert data["total"] == 2 + titles = {item["title"] for item in data["items"]} + assert "My Content" in titles + assert "Other Content" in titles def test_list_contents_no_auth(self, client: TestClient): """Test listing contents fails without authentication.""" @@ -341,7 +343,7 @@ def test_get_content_from_another_community( test_another_community: Community, auth_headers: dict, ): - """Test cannot access content from another community.""" + """内容采用 community association 模式,可跨社区访问其他社区的内容""" content = Content( title="Other Content", content_markdown="Test", @@ -354,7 +356,7 @@ def test_get_content_from_another_community( db_session.commit() response = client.get(f"/api/contents/{content.id}", headers=auth_headers) - assert response.status_code == 404 # Should not find it + assert response.status_code == 200 # 跨社区内容可访问 class TestUpdateContent: diff --git a/backend/tests/test_ecosystem_api.py b/backend/tests/test_ecosystem_api.py index e33bea8..3964313 100644 --- a/backend/tests/test_ecosystem_api.py +++ b/backend/tests/test_ecosystem_api.py @@ -60,10 +60,11 @@ def test_list_projects_returns_data(self, client: TestClient, auth_headers, test assert len(data) == 1 assert data[0]["name"] == "openGecko" - def test_list_projects_community_isolation(self, client: TestClient, another_user_auth_headers, test_project): + def test_list_projects_cross_community(self, client: TestClient, another_user_auth_headers, test_project): + """生态项目采用 community association 模式,跨社区用户也可看到项目列表""" resp = client.get("/api/ecosystem", headers=another_user_auth_headers) assert resp.status_code == 200 - assert resp.json() == [] + assert len(resp.json()) >= 1 class TestCreateProject: @@ -108,9 +109,10 @@ def test_get_project_not_found(self, client: TestClient, auth_headers): resp = client.get("/api/ecosystem/99999", headers=auth_headers) assert resp.status_code == 404 - def test_get_project_wrong_community(self, client: TestClient, another_user_auth_headers, test_project): + def test_get_project_cross_community_accessible(self, client: TestClient, another_user_auth_headers, test_project): + """生态项目采用 community association 模式,跨社区用户可访问项目详情""" resp = client.get(f"/api/ecosystem/{test_project.id}", headers=another_user_auth_headers) - assert resp.status_code == 404 + assert resp.status_code == 200 class TestUpdateProject: diff --git a/backend/tests/test_event_templates_api.py b/backend/tests/test_event_templates_api.py index a884eb3..b48cfca 100644 --- a/backend/tests/test_event_templates_api.py +++ b/backend/tests/test_event_templates_api.py @@ -33,17 +33,21 @@ def test_list_empty(self, client: TestClient, auth_headers: dict): assert resp.status_code == 200 assert resp.json() == [] - def test_list_returns_community_templates( + def test_list_returns_public_templates_only( self, client: TestClient, auth_headers: dict, db_session: Session, test_community: Community, ): - _create_template(db_session, test_community.id, name="私有模板") + """列表仅返回公开模板,私有模板不出现""" + _create_template(db_session, test_community.id, name="私有模板", is_public=False) + _create_template(db_session, test_community.id, name="公开模板", is_public=True) resp = client.get("/api/event-templates", headers=auth_headers) assert resp.status_code == 200 - assert any(t["name"] == "私有模板" for t in resp.json()) + names = [t["name"] for t in resp.json()] + assert "公开模板" in names + assert "私有模板" not in names def test_list_includes_public_templates( self, @@ -110,7 +114,8 @@ def test_get_existing_template( db_session: Session, test_community: Community, ): - t = _create_template(db_session, test_community.id) + """GET 公开模板返回 200""" + t = _create_template(db_session, test_community.id, is_public=True) resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) assert resp.status_code == 200 assert resp.json()["id"] == t.id diff --git a/backend/tests/test_rbac_collaboration.py b/backend/tests/test_rbac_collaboration.py index ee70a05..d94d70c 100644 --- a/backend/tests/test_rbac_collaboration.py +++ b/backend/tests/test_rbac_collaboration.py @@ -122,7 +122,7 @@ def test_superuser_can_access_all_communities( response = client.get("/api/contents", headers=headers) assert response.status_code == 200 - def test_regular_user_cannot_access_other_communities( + def test_regular_user_can_access_all_contents_cross_community( self, client: TestClient, regular_user: User, @@ -130,14 +130,13 @@ def test_regular_user_cannot_access_other_communities( test_another_community: Community, regular_user_token: str ): - """Regular user should only access their own communities.""" - # Try to access another community + """内容采用 community association 模式,普通用户也可跨社区查看内容列表""" headers = { "Authorization": f"Bearer {regular_user_token}", "X-Community-Id": str(test_another_community.id), } response = client.get("/api/contents", headers=headers) - assert response.status_code == 403 + assert response.status_code == 200 def test_community_admin_can_edit_all_community_content( self, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a982c66..b3eee98 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -40,6 +40,10 @@ 社区治理 + + + 社区总览 + 治理概览 @@ -117,10 +121,6 @@ 社区设置