diff --git a/backend/alembic/versions/007_add_campaign_module.py b/backend/alembic/versions/007_add_campaign_module.py new file mode 100644 index 0000000..a3174b5 --- /dev/null +++ b/backend/alembic/versions/007_add_campaign_module.py @@ -0,0 +1,67 @@ +"""新增 Campaign 运营活动模块 + +Revision ID: 007_add_campaign_module +Revises: 006_link_committee_member_to_person +Create Date: 2026-02-22 +""" +import sqlalchemy as sa +from alembic import op + +revision = "007_add_campaign_module" +down_revision = "006_link_committee_member_to_person" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "campaigns", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("community_id", sa.Integer, sa.ForeignKey("communities.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(300), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("type", sa.String(50), nullable=False), + sa.Column("status", sa.String(50), nullable=False, server_default="draft"), + sa.Column("target_count", sa.Integer, nullable=True), + sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("start_date", sa.Date, nullable=True), + sa.Column("end_date", sa.Date, nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_campaigns_community_id", "campaigns", ["community_id"]) + + op.create_table( + "campaign_contacts", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("campaign_id", sa.Integer, sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.String(50), nullable=False, server_default="pending"), + sa.Column("channel", sa.String(50), nullable=True), + sa.Column("added_by", sa.String(50), nullable=False, server_default="manual"), + sa.Column("last_contacted_at", sa.DateTime, nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("assigned_to_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.UniqueConstraint("campaign_id", "person_id", name="uq_campaign_contact"), + ) + op.create_index("ix_campaign_contacts_campaign_id", "campaign_contacts", ["campaign_id"]) + op.create_index("ix_campaign_contacts_person_id", "campaign_contacts", ["person_id"]) + + op.create_table( + "campaign_activities", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("campaign_id", sa.Integer, sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("action", sa.String(50), nullable=False), + sa.Column("content", sa.Text, nullable=True), + sa.Column("outcome", sa.String(300), nullable=True), + sa.Column("operator_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_campaign_activities_campaign_id", "campaign_activities", ["campaign_id"]) + + +def downgrade() -> None: + op.drop_table("campaign_activities") + op.drop_table("campaign_contacts") + op.drop_table("campaigns") diff --git a/backend/alembic/versions/008_add_ecosystem_module.py b/backend/alembic/versions/008_add_ecosystem_module.py new file mode 100644 index 0000000..5425079 --- /dev/null +++ b/backend/alembic/versions/008_add_ecosystem_module.py @@ -0,0 +1,55 @@ +"""新增 Ecosystem 生态洞察模块 + +Revision ID: 008_add_ecosystem_module +Revises: 007_add_campaign_module +Create Date: 2026-02-22 +""" +import sqlalchemy as sa +from alembic import op + +revision = "008_add_ecosystem_module" +down_revision = "007_add_campaign_module" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ecosystem_projects", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("platform", sa.String(30), nullable=False), + sa.Column("org_name", sa.String(200), nullable=False), + sa.Column("repo_name", sa.String(200), nullable=True), + sa.Column("description", sa.Text, nullable=True), + sa.Column("tags", sa.JSON, nullable=True), + sa.Column("is_active", sa.Boolean, server_default="1"), + sa.Column("last_synced_at", sa.DateTime, nullable=True), + sa.Column("added_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + + op.create_table( + "ecosystem_contributors", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("project_id", sa.Integer, sa.ForeignKey("ecosystem_projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("github_handle", sa.String(100), nullable=False), + sa.Column("display_name", sa.String(200), nullable=True), + sa.Column("avatar_url", sa.String(500), nullable=True), + sa.Column("role", sa.String(50), nullable=True), + sa.Column("commit_count_90d", sa.Integer, nullable=True), + sa.Column("pr_count_90d", sa.Integer, nullable=True), + sa.Column("star_count", sa.Integer, nullable=True), + sa.Column("followers", sa.Integer, nullable=True), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True), + sa.Column("last_synced_at", sa.DateTime, server_default=sa.func.now()), + sa.UniqueConstraint("project_id", "github_handle", name="uq_eco_contributor"), + ) + op.create_index("ix_ecosystem_contributors_project_id", "ecosystem_contributors", ["project_id"]) + op.create_index("ix_ecosystem_contributors_github_handle", "ecosystem_contributors", ["github_handle"]) + op.create_index("ix_ecosystem_contributors_person_id", "ecosystem_contributors", ["person_id"]) + + +def downgrade() -> None: + op.drop_table("ecosystem_contributors") + op.drop_table("ecosystem_projects") diff --git a/backend/alembic/versions/009_add_ecosystem_community_id.py b/backend/alembic/versions/009_add_ecosystem_community_id.py new file mode 100644 index 0000000..f399266 --- /dev/null +++ b/backend/alembic/versions/009_add_ecosystem_community_id.py @@ -0,0 +1,40 @@ +"""Ecosystem projects 增加 community_id 多租户字段 + +Revision ID: 009_add_ecosystem_community_id +Revises: 008_add_ecosystem_module +Create Date: 2026-02-22 +""" +import sqlalchemy as sa +from alembic import op + +revision = "009_add_ecosystem_community_id" +down_revision = "008_add_ecosystem_module" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # SQLite 不支持 ADD COLUMN NOT NULL without default,用 batch 模式 + with op.batch_alter_table("ecosystem_projects") as batch_op: + batch_op.add_column( + sa.Column( + "community_id", + sa.Integer, + nullable=True, # 已有数据兼容;新记录由 API 层强制填写 + ) + ) + batch_op.create_index("ix_ecosystem_projects_community_id", ["community_id"]) + batch_op.create_foreign_key( + "fk_ecosystem_projects_community_id", + "communities", + ["community_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + with op.batch_alter_table("ecosystem_projects") as batch_op: + batch_op.drop_constraint("fk_ecosystem_projects_community_id", type_="foreignkey") + batch_op.drop_index("ix_ecosystem_projects_community_id") + batch_op.drop_column("community_id") diff --git a/backend/app/api/campaigns.py b/backend/app/api/campaigns.py new file mode 100644 index 0000000..5f35d71 --- /dev/null +++ b/backend/app/api/campaigns.py @@ -0,0 +1,369 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session, joinedload + +from app.core.dependencies import get_current_community, get_current_user +from app.database import get_db +from app.models import User +from app.models.campaign import Campaign, CampaignActivity, CampaignContact +from app.models.event import Event, EventAttendee +from app.models.people import PersonProfile +from app.schemas.campaign import ( + ActivityCreate, + ActivityOut, + BulkImportFromEvent, + BulkImportFromPeople, + CampaignCreate, + CampaignFunnel, + CampaignListOut, + CampaignOut, + CampaignUpdate, + ContactCreate, + ContactOut, + ContactStatusUpdate, + PaginatedContacts, +) + +router = APIRouter() + +VALID_TYPES = {"promotion", "care", "invitation", "survey"} +VALID_STATUSES = {"draft", "active", "completed", "archived"} +VALID_CONTACT_STATUSES = {"pending", "contacted", "responded", "converted", "declined"} + + +# ─── Campaign CRUD ───────────────────────────────────────────────────────────── + +@router.get("", response_model=list[CampaignListOut]) +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) + if type: + query = query.filter(Campaign.type == type) + if status: + query = query.filter(Campaign.status == status) + return query.order_by(Campaign.created_at.desc()).all() + + +@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(), + ) + db.add(campaign) + db.commit() + db.refresh(campaign) + return 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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + return campaign + + +@router.patch("/{cid}", response_model=CampaignOut) +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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + update_data = data.model_dump(exclude_unset=True) + if "status" in update_data and update_data["status"] not in VALID_STATUSES: + raise HTTPException(400, f"status 必须为 {VALID_STATUSES}") + for key, value in update_data.items(): + setattr(campaign, key, value) + db.commit() + db.refresh(campaign) + return campaign + + +# ─── Funnel Stats ────────────────────────────────────────────────────────────── + +@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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + rows = ( + db.query(CampaignContact.status, func.count()) + .filter(CampaignContact.campaign_id == cid) + .group_by(CampaignContact.status) + .all() + ) + counts = dict(rows) + total = sum(counts.values()) + return CampaignFunnel( + pending=counts.get("pending", 0), + contacted=counts.get("contacted", 0), + responded=counts.get("responded", 0), + converted=counts.get("converted", 0), + declined=counts.get("declined", 0), + total=total, + ) + + +# ─── Contacts ───────────────────────────────────────────────────────────────── + +@router.get("/{cid}/contacts", response_model=PaginatedContacts) +def list_contacts( + cid: int, + 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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + query = db.query(CampaignContact).filter(CampaignContact.campaign_id == cid) + if status: + query = query.filter(CampaignContact.status == status) + total = query.count() + items = query.options(joinedload(CampaignContact.person)).offset((page - 1) * page_size).limit(page_size).all() + return PaginatedContacts(items=items, total=total, page=page, page_size=page_size) + + +@router.post("/{cid}/contacts", response_model=ContactOut, status_code=201) +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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + # 检查是否已存在 + existing = db.query(CampaignContact).filter( + CampaignContact.campaign_id == cid, + CampaignContact.person_id == data.person_id, + ).first() + if existing: + raise HTTPException(400, "该联系人已在此运营活动中") + contact = CampaignContact( + campaign_id=cid, + added_by="manual", + **data.model_dump(), + ) + db.add(contact) + db.commit() + db.refresh(contact) + return contact + + +@router.patch("/{cid}/contacts/{contact_id}/status", response_model=ContactOut) +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), +): + if data.status not in VALID_CONTACT_STATUSES: + raise HTTPException(400, f"status 必须为 {VALID_CONTACT_STATUSES}") + contact = db.query(CampaignContact).filter( + CampaignContact.id == contact_id, + CampaignContact.campaign_id == cid, + ).first() + if not contact: + raise HTTPException(404, "联系人记录不存在") + _allowed = {"status", "channel", "notes", "assigned_to_id"} + for key, value in data.model_dump(exclude_unset=True).items(): + if key in _allowed: + setattr(contact, key, value) + db.commit() + db.refresh(contact) + return contact + + +# ─── Bulk Import ────────────────────────────────────────────────────────────── + +@router.post("/{cid}/contacts/import-event", status_code=200) +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 + ).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, "活动不存在") + attendees = db.query(EventAttendee).filter(EventAttendee.event_id == data.event_id).all() + created = skipped = 0 + for att in attendees: + existing = db.query(CampaignContact).filter( + CampaignContact.campaign_id == cid, + CampaignContact.person_id == att.person_id, + ).first() + if existing: + skipped += 1 + continue + db.add(CampaignContact( + campaign_id=cid, + person_id=att.person_id, + channel=data.channel, + assigned_to_id=data.assigned_to_id, + added_by="event_import", + )) + created += 1 + db.commit() + return {"created": created, "skipped": skipped} + + +@router.post("/{cid}/contacts/import-people", status_code=200) +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 + ).first() + if not campaign: + raise HTTPException(404, "运营活动不存在") + # 校验所有 person_id 真实存在 + if data.person_ids: + existing_ids = { + row[0] for row in db.query(PersonProfile.id).filter( + PersonProfile.id.in_(data.person_ids) + ).all() + } + invalid_ids = set(data.person_ids) - existing_ids + if invalid_ids: + raise HTTPException(400, f"以下人脉档案不存在: {sorted(invalid_ids)}") + created = skipped = 0 + for pid in data.person_ids: + existing = db.query(CampaignContact).filter( + CampaignContact.campaign_id == cid, + CampaignContact.person_id == pid, + ).first() + if existing: + skipped += 1 + continue + db.add(CampaignContact( + campaign_id=cid, + person_id=pid, + channel=data.channel, + assigned_to_id=data.assigned_to_id, + added_by="manual", + )) + created += 1 + db.commit() + return {"created": created, "skipped": skipped} + + +# ─── Activities ─────────────────────────────────────────────────────────────── + +@router.get("/{cid}/contacts/{contact_id}/activities", response_model=list[ActivityOut]) +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), +): + contact = db.query(CampaignContact).filter( + CampaignContact.id == contact_id, + CampaignContact.campaign_id == cid, + ).first() + if not contact: + raise HTTPException(404, "联系人记录不存在") + return ( + db.query(CampaignActivity) + .filter( + CampaignActivity.campaign_id == cid, + CampaignActivity.person_id == contact.person_id, + ) + .order_by(CampaignActivity.created_at.desc()) + .all() + ) + + +@router.post("/{cid}/contacts/{contact_id}/activities", response_model=ActivityOut, status_code=201) +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), +): + contact = db.query(CampaignContact).filter( + CampaignContact.id == contact_id, + CampaignContact.campaign_id == cid, + ).first() + if not contact: + raise HTTPException(404, "联系人记录不存在") + activity = CampaignActivity( + campaign_id=cid, + person_id=contact.person_id, + operator_id=current_user.id, + **data.model_dump(), + ) + db.add(activity) + # 更新联系人最近跟进时间 + contact.last_contacted_at = datetime.utcnow() + db.commit() + db.refresh(activity) + return activity diff --git a/backend/app/api/ecosystem.py b/backend/app/api/ecosystem.py new file mode 100644 index 0000000..1cda254 --- /dev/null +++ b/backend/app/api/ecosystem.py @@ -0,0 +1,200 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.config import settings +from app.core.dependencies import get_current_community, get_current_user +from app.database import get_db +from app.models import User +from app.models.ecosystem import EcosystemContributor, EcosystemProject +from app.models.people import PersonProfile +from app.schemas.ecosystem import ( + PaginatedContributors, + ProjectCreate, + ProjectListOut, + ProjectOut, + ProjectUpdate, + SyncResult, +) +from app.services.ecosystem.github_crawler import sync_project + +router = APIRouter() + +VALID_PLATFORMS = {"github", "gitee", "gitcode"} + + +# ─── Project CRUD ───────────────────────────────────────────────────────────── + +@router.get("", response_model=list[ProjectListOut]) +def list_projects( + community_id: int = Depends(get_current_community), + 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() + ) + + +@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), +): + if data.platform not in VALID_PLATFORMS: + 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) + db.commit() + db.refresh(project) + return 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() + if not project: + raise HTTPException(404, "项目不存在") + return project + + +@router.patch("/{pid}", response_model=ProjectOut) +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() + if not project: + raise HTTPException(404, "项目不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(project, key, value) + db.commit() + db.refresh(project) + return project + + +# ─── Sync ───────────────────────────────────────────────────────────────────── + +@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() + if not project: + raise HTTPException(404, "项目不存在") + token = getattr(settings, "GITHUB_PAT", None) + result = sync_project(db, project, token) + return result + + +# ─── Contributors ───────────────────────────────────────────────────────────── + +@router.get("/{pid}/contributors", response_model=PaginatedContributors) +def list_contributors( + pid: int, + q: str | None = None, + 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() + if not project: + raise HTTPException(404, "项目不存在") + query = db.query(EcosystemContributor).filter(EcosystemContributor.project_id == pid) + if q: + # 转义 LIKE 通配符,防止用户输入 % / _ 造成意外匹配 + q_escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + query = query.filter( + EcosystemContributor.github_handle.ilike(f"%{q_escaped}%") + | EcosystemContributor.display_name.ilike(f"%{q_escaped}%") + ) + if unlinked: + query = query.filter(EcosystemContributor.person_id == None) # noqa: E711 + query = query.order_by(EcosystemContributor.commit_count_90d.desc().nullslast()) + total = query.count() + items = query.offset((page - 1) * page_size).limit(page_size).all() + return PaginatedContributors(items=items, total=total, page=page, page_size=page_size) + + +@router.post("/{pid}/contributors/{handle}/import-person", status_code=200) +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() + if not project: + raise HTTPException(404, "项目不存在") + + contributor = db.query(EcosystemContributor).filter( + EcosystemContributor.project_id == pid, + EcosystemContributor.github_handle == handle, + ).first() + if not contributor: + raise HTTPException(404, "贡献者不存在") + + # 尝试在人脉库查找同名 GitHub 账号 + existing = db.query(PersonProfile).filter( + PersonProfile.github_handle == handle + ).first() + + if existing: + contributor.person_id = existing.id + db.commit() + return {"action": "linked", "person_id": existing.id} + + # 新建人脉档案 + person = PersonProfile( + display_name=contributor.display_name or handle, + github_handle=handle, + avatar_url=contributor.avatar_url, + source="ecosystem_import", + created_by_id=current_user.id, + ) + db.add(person) + db.flush() + contributor.person_id = person.id + db.commit() + return {"action": "created", "person_id": person.id} diff --git a/backend/app/main.py b/backend/app/main.py index 05dae85..1c52978 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,12 +14,14 @@ from app.api import ( analytics, auth, + campaigns, channels, committees, communities, community_dashboard, contents, dashboard, + ecosystem, event_templates, events, meetings, @@ -175,6 +177,8 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(people.router, prefix="/api/people", tags=["People"]) app.include_router(events.router, prefix="/api/events", tags=["Events"]) 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.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ce5d551..d8c2054 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,8 +1,10 @@ from app.models.audit import AuditLog +from app.models.campaign import Campaign, CampaignActivity, CampaignContact from app.models.channel import ChannelConfig from app.models.committee import Committee, CommitteeMember from app.models.community import Community from app.models.content import Content +from app.models.ecosystem import EcosystemContributor, EcosystemProject from app.models.event import ( ChecklistItem, ChecklistTemplateItem, @@ -48,4 +50,9 @@ "EventTask", "FeedbackItem", "IssueLink", + "Campaign", + "CampaignContact", + "CampaignActivity", + "EcosystemProject", + "EcosystemContributor", ] diff --git a/backend/app/models/campaign.py b/backend/app/models/campaign.py new file mode 100644 index 0000000..d1e132f --- /dev/null +++ b/backend/app/models/campaign.py @@ -0,0 +1,59 @@ +from datetime import datetime + +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.database import Base + + +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) + name = Column(String(300), nullable=False) + description = Column(Text, nullable=True) + type = Column(String(50), nullable=False) # promotion / care / invitation / survey + status = Column(String(50), nullable=False, default="draft") # draft / active / completed / archived + target_count = Column(Integer, nullable=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + contacts = relationship("CampaignContact", back_populates="campaign", cascade="all, delete-orphan") + activities = relationship("CampaignActivity", back_populates="campaign", cascade="all, delete-orphan") + + +class CampaignContact(Base): + __tablename__ = "campaign_contacts" + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + status = Column(String(50), nullable=False, default="pending") # pending/contacted/responded/converted/declined + channel = Column(String(50), nullable=True) # email/wechat/phone/in_person/other + added_by = Column(String(50), nullable=False, default="manual") # manual/event_import/ecosystem_import/csv_import + last_contacted_at = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + assigned_to_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + campaign = relationship("Campaign", back_populates="contacts") + person = relationship("PersonProfile") + + +class CampaignActivity(Base): + __tablename__ = "campaign_activities" + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + action = Column(String(50), nullable=False) # sent_email/made_call/sent_wechat/in_person_meeting/got_reply/note + content = Column(Text, nullable=True) + outcome = Column(String(300), nullable=True) + operator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + campaign = relationship("Campaign", back_populates="activities") + person = relationship("PersonProfile") diff --git a/backend/app/models/ecosystem.py b/backend/app/models/ecosystem.py new file mode 100644 index 0000000..eca5265 --- /dev/null +++ b/backend/app/models/ecosystem.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.database import Base + + +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) + name = Column(String(200), nullable=False) + platform = Column(String(30), nullable=False) # github / gitee / gitcode + org_name = Column(String(200), nullable=False) + repo_name = Column(String(200), nullable=True) + description = Column(Text, nullable=True) + tags = Column(JSON, default=list) + is_active = Column(Boolean, default=True) + last_synced_at = Column(DateTime, nullable=True) + added_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + contributors = relationship( + "EcosystemContributor", + back_populates="project", + cascade="all, delete-orphan", + ) + + +class EcosystemContributor(Base): + __tablename__ = "ecosystem_contributors" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("ecosystem_projects.id", ondelete="CASCADE"), nullable=False, index=True) + github_handle = Column(String(100), nullable=False, index=True) + display_name = Column(String(200), nullable=True) + avatar_url = Column(String(500), nullable=True) + role = Column(String(50), nullable=True) + commit_count_90d = Column(Integer, nullable=True) + pr_count_90d = Column(Integer, nullable=True) + star_count = Column(Integer, nullable=True) + followers = Column(Integer, nullable=True) + # 关联到人脉库 + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + last_synced_at = Column(DateTime, default=datetime.utcnow) + + project = relationship("EcosystemProject", back_populates="contributors") + person = relationship("PersonProfile") diff --git a/backend/app/models/event.py b/backend/app/models/event.py index d1ec3aa..3538a41 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -59,7 +59,7 @@ class Event(Base): ) title = Column(String(300), nullable=False) event_type = Column( - SAEnum("online", "offline", "hybrid", name="event_type_enum2"), + SAEnum("online", "offline", "hybrid", name="event_type_enum"), nullable=False, default="offline", ) diff --git a/backend/app/schemas/campaign.py b/backend/app/schemas/campaign.py new file mode 100644 index 0000000..c0150af --- /dev/null +++ b/backend/app/schemas/campaign.py @@ -0,0 +1,139 @@ +from datetime import date, datetime + +from pydantic import BaseModel + +# ─── Campaign ───────────────────────────────────────────────────────────────── + +class CampaignCreate(BaseModel): + name: str + type: str # promotion / care / invitation / survey + description: str | None = None + target_count: int | None = None + start_date: date | None = None + end_date: date | None = None + + +class CampaignUpdate(BaseModel): + name: str | None = None + type: str | None = None + description: str | None = None + status: str | None = None # draft / active / completed / archived + target_count: int | None = None + start_date: date | None = None + end_date: date | None = None + + +class CampaignListOut(BaseModel): + id: int + community_id: int + name: str + type: str + status: str + target_count: int | None + start_date: date | None + end_date: date | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class CampaignOut(CampaignListOut): + description: str | None + owner_id: int | None + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Campaign Contact ────────────────────────────────────────────────────────── + +class ContactCreate(BaseModel): + person_id: int + channel: str | None = None + notes: str | None = None + assigned_to_id: int | None = None + + +class ContactStatusUpdate(BaseModel): + status: str # pending/contacted/responded/converted/declined + channel: str | None = None + notes: str | None = None + assigned_to_id: int | None = None + + +class PersonSnapshot(BaseModel): + id: int + display_name: str + company: str | None + email: str | None + github_handle: str | None + + model_config = {"from_attributes": True} + + +class ContactOut(BaseModel): + id: int + campaign_id: int + person_id: int + status: str + channel: str | None + added_by: str + last_contacted_at: datetime | None + notes: str | None + assigned_to_id: int | None + person: PersonSnapshot | None = None + + model_config = {"from_attributes": True} + + +class PaginatedContacts(BaseModel): + items: list[ContactOut] + total: int + page: int + page_size: int + + +# ─── Campaign Activity ───────────────────────────────────────────────────────── + +class ActivityCreate(BaseModel): + action: str # sent_email/made_call/sent_wechat/in_person_meeting/got_reply/note + content: str | None = None + outcome: str | None = None + + +class ActivityOut(BaseModel): + id: int + campaign_id: int + person_id: int + action: str + content: str | None + outcome: str | None + operator_id: int | None + created_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Funnel / Stats ─────────────────────────────────────────────────────────── + +class CampaignFunnel(BaseModel): + pending: int = 0 + contacted: int = 0 + responded: int = 0 + converted: int = 0 + declined: int = 0 + total: int = 0 + + +# ─── Bulk Import ────────────────────────────────────────────────────────────── + +class BulkImportFromEvent(BaseModel): + event_id: int + channel: str | None = None + assigned_to_id: int | None = None + + +class BulkImportFromPeople(BaseModel): + person_ids: list[int] + channel: str | None = None + assigned_to_id: int | None = None diff --git a/backend/app/schemas/ecosystem.py b/backend/app/schemas/ecosystem.py new file mode 100644 index 0000000..4ca626f --- /dev/null +++ b/backend/app/schemas/ecosystem.py @@ -0,0 +1,75 @@ +from datetime import datetime + +from pydantic import BaseModel + +# ─── EcosystemProject ───────────────────────────────────────────────────────── + +class ProjectCreate(BaseModel): + name: str + platform: str # github / gitee / gitcode + org_name: str + repo_name: str | None = None + description: str | None = None + tags: list[str] = [] + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + tags: list[str] | None = None + is_active: bool | None = None + + +class ProjectListOut(BaseModel): + id: int + name: str + platform: str + org_name: str + repo_name: str | None + is_active: bool + last_synced_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class ProjectOut(ProjectListOut): + description: str | None + tags: list[str] + added_by_id: int | None + + model_config = {"from_attributes": True} + + +# ─── EcosystemContributor ───────────────────────────────────────────────────── + +class ContributorOut(BaseModel): + id: int + project_id: int + github_handle: str + display_name: str | None + avatar_url: str | None + role: str | None + commit_count_90d: int | None + pr_count_90d: int | None + star_count: int | None + followers: int | None + person_id: int | None + last_synced_at: datetime + + model_config = {"from_attributes": True} + + +class PaginatedContributors(BaseModel): + items: list[ContributorOut] + total: int + page: int + page_size: int + + +# ─── Sync Result ────────────────────────────────────────────────────────────── + +class SyncResult(BaseModel): + created: int + updated: int + errors: int diff --git a/backend/app/services/ecosystem/__init__.py b/backend/app/services/ecosystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ecosystem/github_crawler.py b/backend/app/services/ecosystem/github_crawler.py new file mode 100644 index 0000000..adcae21 --- /dev/null +++ b/backend/app/services/ecosystem/github_crawler.py @@ -0,0 +1,90 @@ +"""GitHub 生态项目贡献者采集服务。 + +通过 GitHub REST API 获取指定仓库的贡献者列表, +写入 EcosystemContributor 表并更新 EcosystemProject.last_synced_at。 +""" + +import logging +from datetime import datetime + +import httpx +from sqlalchemy.orm import Session + +from app.models.ecosystem import EcosystemContributor, EcosystemProject + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" + + +def _build_headers(token: str | None) -> dict: + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def sync_project(db: Session, project: EcosystemProject, token: str | None = None) -> dict: + """同步单个项目的贡献者数据。 + + 返回 {"created": int, "updated": int, "errors": int}。 + """ + if project.platform != "github": + logger.info("跳过非 GitHub 项目: %s", project.name) + return {"created": 0, "updated": 0, "errors": 0} + + headers = _build_headers(token) + url = f"{GITHUB_API}/repos/{project.org_name}/{project.repo_name}/contributors" + + created = updated = errors = 0 + try: + with httpx.Client(timeout=30) as client: + resp = client.get(url, headers=headers, params={"per_page": 100, "anon": "false"}) + if resp.status_code != 200: + logger.warning("GitHub API %s → %s", url, resp.status_code) + return {"created": 0, "updated": 0, "errors": 1} + + existing_map = {c.github_handle: c for c in project.contributors} + + for item in resp.json(): + handle = item.get("login") + if not handle: + continue + if handle in existing_map: + existing_map[handle].commit_count_90d = item.get("contributions") + existing_map[handle].display_name = item.get("login") + existing_map[handle].avatar_url = item.get("avatar_url") + existing_map[handle].last_synced_at = datetime.utcnow() + updated += 1 + else: + db.add(EcosystemContributor( + project_id=project.id, + github_handle=handle, + display_name=item.get("login"), + avatar_url=item.get("avatar_url"), + commit_count_90d=item.get("contributions"), + last_synced_at=datetime.utcnow(), + )) + created += 1 + + except Exception as exc: + logger.error("同步项目 %s 失败: %s", project.name, exc) + errors += 1 + return {"created": created, "updated": updated, "errors": errors} + + project.last_synced_at = datetime.utcnow() + db.commit() + logger.info("项目 %s 同步完成 — created=%d updated=%d", project.name, created, updated) + return {"created": created, "updated": updated, "errors": errors} + + +def sync_all_projects(db: Session, token: str | None = None) -> dict: + """同步所有活跃项目(APScheduler 定时调用)。""" + projects = db.query(EcosystemProject).filter(EcosystemProject.is_active == True).all() # noqa: E712 + total_created = total_updated = total_errors = 0 + for project in projects: + result = sync_project(db, project, token) + total_created += result["created"] + total_updated += result["updated"] + total_errors += result["errors"] + return {"created": total_created, "updated": total_updated, "errors": total_errors} diff --git a/backend/tests/test_campaigns_api.py b/backend/tests/test_campaigns_api.py new file mode 100644 index 0000000..9f1128f --- /dev/null +++ b/backend/tests/test_campaigns_api.py @@ -0,0 +1,334 @@ +"""Campaign 运营活动 API 测试""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.campaign import Campaign, CampaignContact +from app.models.people import PersonProfile + + +# ─── Fixtures ───────────────────────────────────────────────────────────────── + +@pytest.fixture +def test_person(db_session: Session, test_community): + person = PersonProfile( + display_name="测试联系人", + github_handle="test-contact", + source="manual", + ) + db_session.add(person) + db_session.commit() + db_session.refresh(person) + return person + + +@pytest.fixture +def test_campaign(db_session: Session, test_community, test_user): + campaign = Campaign( + community_id=test_community.id, + owner_id=test_user.id, + name="测试运营活动", + type="promotion", + description="测试描述", + status="draft", + ) + db_session.add(campaign) + db_session.commit() + db_session.refresh(campaign) + return campaign + + +@pytest.fixture +def test_contact(db_session: Session, test_campaign, test_person): + contact = CampaignContact( + campaign_id=test_campaign.id, + person_id=test_person.id, + status="pending", + added_by="manual", + ) + db_session.add(contact) + db_session.commit() + db_session.refresh(contact) + return contact + + +# ─── Campaign CRUD ──────────────────────────────────────────────────────────── + +class TestListCampaigns: + def test_list_campaigns_empty(self, client: TestClient, auth_headers): + resp = client.get("/api/campaigns", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_campaigns_returns_data(self, client: TestClient, auth_headers, test_campaign): + resp = client.get("/api/campaigns", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "测试运营活动" + + def test_list_campaigns_filter_by_type(self, client: TestClient, auth_headers, test_campaign): + resp = client.get("/api/campaigns?type=promotion", headers=auth_headers) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + resp2 = client.get("/api/campaigns?type=care", headers=auth_headers) + assert resp2.status_code == 200 + assert resp2.json() == [] + + def test_list_campaigns_filter_by_status(self, client: TestClient, auth_headers, test_campaign): + resp = client.get("/api/campaigns?status=draft", headers=auth_headers) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + resp2 = client.get("/api/campaigns?status=active", headers=auth_headers) + assert resp2.status_code == 200 + assert resp2.json() == [] + + +class TestCreateCampaign: + def test_create_campaign_success(self, client: TestClient, auth_headers): + resp = client.post("/api/campaigns", headers=auth_headers, json={ + "name": "新活动", + "type": "care", + "description": "描述", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "新活动" + assert data["type"] == "care" + assert data["status"] == "draft" + + def test_create_campaign_invalid_type(self, client: TestClient, auth_headers): + resp = client.post("/api/campaigns", headers=auth_headers, json={ + "name": "活动", + "type": "invalid_type", + }) + assert resp.status_code == 400 + + def test_create_campaign_minimal(self, client: TestClient, auth_headers): + resp = client.post("/api/campaigns", headers=auth_headers, json={ + "name": "最简活动", + "type": "survey", + }) + assert resp.status_code == 201 + + +class TestGetCampaign: + def test_get_campaign_success(self, client: TestClient, auth_headers, test_campaign): + resp = client.get(f"/api/campaigns/{test_campaign.id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["name"] == "测试运营活动" + + 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): + resp = client.get(f"/api/campaigns/{test_campaign.id}", headers=another_user_auth_headers) + assert resp.status_code == 404 + + +class TestUpdateCampaign: + def test_update_campaign_name(self, client: TestClient, auth_headers, test_campaign): + resp = client.patch(f"/api/campaigns/{test_campaign.id}", headers=auth_headers, json={ + "name": "新名称", + }) + assert resp.status_code == 200 + assert resp.json()["name"] == "新名称" + + def test_update_campaign_status(self, client: TestClient, auth_headers, test_campaign): + resp = client.patch(f"/api/campaigns/{test_campaign.id}", headers=auth_headers, json={ + "status": "active", + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "active" + + def test_update_campaign_invalid_status(self, client: TestClient, auth_headers, test_campaign): + resp = client.patch(f"/api/campaigns/{test_campaign.id}", headers=auth_headers, json={ + "status": "invalid", + }) + assert resp.status_code == 400 + + def test_update_campaign_not_found(self, client: TestClient, auth_headers): + resp = client.patch("/api/campaigns/99999", headers=auth_headers, json={"name": "x"}) + assert resp.status_code == 404 + + +class TestCampaignFunnel: + def test_funnel_empty(self, client: TestClient, auth_headers, test_campaign): + resp = client.get(f"/api/campaigns/{test_campaign.id}/funnel", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["pending"] == 0 + + def test_funnel_with_contacts(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.get(f"/api/campaigns/{test_campaign.id}/funnel", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["pending"] == 1 + + def test_funnel_not_found(self, client: TestClient, auth_headers): + resp = client.get("/api/campaigns/99999/funnel", headers=auth_headers) + assert resp.status_code == 404 + + +# ─── Contacts ───────────────────────────────────────────────────────────────── + +class TestListContacts: + def test_list_contacts_empty(self, client: TestClient, auth_headers, test_campaign): + resp = client.get(f"/api/campaigns/{test_campaign.id}/contacts", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + def test_list_contacts_with_data(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.get(f"/api/campaigns/{test_campaign.id}/contacts", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + + def test_list_contacts_filter_by_status(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.get(f"/api/campaigns/{test_campaign.id}/contacts?status=pending", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + resp2 = client.get(f"/api/campaigns/{test_campaign.id}/contacts?status=converted", headers=auth_headers) + assert resp2.status_code == 200 + assert resp2.json()["total"] == 0 + + def test_list_contacts_campaign_not_found(self, client: TestClient, auth_headers): + resp = client.get("/api/campaigns/99999/contacts", headers=auth_headers) + assert resp.status_code == 404 + + +class TestAddContact: + def test_add_contact_success(self, client: TestClient, auth_headers, test_campaign, test_person): + resp = client.post(f"/api/campaigns/{test_campaign.id}/contacts", headers=auth_headers, json={ + "person_id": test_person.id, + "channel": "email", + "notes": "测试备注", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["person_id"] == test_person.id + assert data["status"] == "pending" + + def test_add_contact_duplicate(self, client: TestClient, auth_headers, test_campaign, test_contact, test_person): + resp = client.post(f"/api/campaigns/{test_campaign.id}/contacts", headers=auth_headers, json={ + "person_id": test_person.id, + }) + assert resp.status_code == 400 + + def test_add_contact_campaign_not_found(self, client: TestClient, auth_headers, test_person): + resp = client.post("/api/campaigns/99999/contacts", headers=auth_headers, json={ + "person_id": test_person.id, + }) + assert resp.status_code == 404 + + +class TestUpdateContactStatus: + def test_update_status_success(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.patch( + f"/api/campaigns/{test_campaign.id}/contacts/{test_contact.id}/status", + headers=auth_headers, + json={"status": "contacted", "channel": "wechat"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "contacted" + + def test_update_status_invalid(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.patch( + f"/api/campaigns/{test_campaign.id}/contacts/{test_contact.id}/status", + headers=auth_headers, + json={"status": "invalid_status"}, + ) + assert resp.status_code == 400 + + def test_update_status_not_found(self, client: TestClient, auth_headers, test_campaign): + resp = client.patch( + f"/api/campaigns/{test_campaign.id}/contacts/99999/status", + headers=auth_headers, + json={"status": "contacted"}, + ) + assert resp.status_code == 404 + + +# ─── Bulk Import ────────────────────────────────────────────────────────────── + +class TestImportFromPeople: + def test_import_from_people_success(self, client: TestClient, auth_headers, test_campaign, test_person): + resp = client.post( + f"/api/campaigns/{test_campaign.id}/contacts/import-people", + headers=auth_headers, + json={"person_ids": [test_person.id]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] == 1 + assert data["skipped"] == 0 + + def test_import_from_people_skips_duplicate(self, client: TestClient, auth_headers, test_campaign, test_contact, test_person): + resp = client.post( + f"/api/campaigns/{test_campaign.id}/contacts/import-people", + headers=auth_headers, + json={"person_ids": [test_person.id]}, + ) + assert resp.status_code == 200 + assert resp.json()["skipped"] == 1 + + def test_import_from_people_invalid_ids(self, client: TestClient, auth_headers, test_campaign): + resp = client.post( + f"/api/campaigns/{test_campaign.id}/contacts/import-people", + headers=auth_headers, + json={"person_ids": [99999]}, + ) + assert resp.status_code == 400 + + def test_import_from_people_campaign_not_found(self, client: TestClient, auth_headers, test_person): + resp = client.post( + "/api/campaigns/99999/contacts/import-people", + headers=auth_headers, + json={"person_ids": [test_person.id]}, + ) + assert resp.status_code == 404 + + +# ─── Activities ─────────────────────────────────────────────────────────────── + +class TestActivities: + def test_list_activities_empty(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.get( + f"/api/campaigns/{test_campaign.id}/contacts/{test_contact.id}/activities", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_activities_contact_not_found(self, client: TestClient, auth_headers, test_campaign): + resp = client.get( + f"/api/campaigns/{test_campaign.id}/contacts/99999/activities", + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_add_activity_success(self, client: TestClient, auth_headers, test_campaign, test_contact): + resp = client.post( + f"/api/campaigns/{test_campaign.id}/contacts/{test_contact.id}/activities", + headers=auth_headers, + json={"action": "email_sent", "content": "发送邀请邮件", "outcome": "已读"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["action"] == "email_sent" + + def test_add_activity_contact_not_found(self, client: TestClient, auth_headers, test_campaign): + resp = client.post( + f"/api/campaigns/{test_campaign.id}/contacts/99999/activities", + headers=auth_headers, + json={"action": "call"}, + ) + assert resp.status_code == 404 diff --git a/backend/tests/test_ecosystem_api.py b/backend/tests/test_ecosystem_api.py new file mode 100644 index 0000000..e33bea8 --- /dev/null +++ b/backend/tests/test_ecosystem_api.py @@ -0,0 +1,229 @@ +"""Ecosystem 生态洞察 API 测试""" +from unittest import mock + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.ecosystem import EcosystemContributor, EcosystemProject +from app.models.people import PersonProfile + + +# ─── Fixtures ───────────────────────────────────────────────────────────────── + +@pytest.fixture +def test_project(db_session: Session, test_community, test_user): + project = EcosystemProject( + community_id=test_community.id, + added_by_id=test_user.id, + name="openGecko", + platform="github", + org_name="opensourceways", + repo_name="openGecko", + description="测试项目", + tags=["python", "fastapi"], + is_active=True, + ) + db_session.add(project) + db_session.commit() + db_session.refresh(project) + return project + + +@pytest.fixture +def test_contributor(db_session: Session, test_project): + contributor = EcosystemContributor( + project_id=test_project.id, + github_handle="octocat", + display_name="Octocat", + avatar_url="https://github.com/octocat.png", + commit_count_90d=42, + ) + db_session.add(contributor) + db_session.commit() + db_session.refresh(contributor) + return contributor + + +# ─── Project CRUD ───────────────────────────────────────────────────────────── + +class TestListProjects: + def test_list_projects_empty(self, client: TestClient, auth_headers): + resp = client.get("/api/ecosystem", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_projects_returns_data(self, client: TestClient, auth_headers, test_project): + resp = client.get("/api/ecosystem", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "openGecko" + + def test_list_projects_community_isolation(self, client: TestClient, another_user_auth_headers, test_project): + resp = client.get("/api/ecosystem", headers=another_user_auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + +class TestCreateProject: + def test_create_project_success(self, client: TestClient, auth_headers): + resp = client.post("/api/ecosystem", headers=auth_headers, json={ + "name": "新项目", + "platform": "github", + "org_name": "testorg", + "repo_name": "testrepo", + "description": "描述", + "tags": ["python"], + }) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "新项目" + assert data["platform"] == "github" + + def test_create_project_invalid_platform(self, client: TestClient, auth_headers): + resp = client.post("/api/ecosystem", headers=auth_headers, json={ + "name": "项目", + "platform": "invalid", + "org_name": "org", + }) + assert resp.status_code == 400 + + def test_create_project_gitee(self, client: TestClient, auth_headers): + resp = client.post("/api/ecosystem", headers=auth_headers, json={ + "name": "Gitee项目", + "platform": "gitee", + "org_name": "testorg", + }) + assert resp.status_code == 201 + + +class TestGetProject: + def test_get_project_success(self, client: TestClient, auth_headers, test_project): + resp = client.get(f"/api/ecosystem/{test_project.id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["name"] == "openGecko" + + 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): + resp = client.get(f"/api/ecosystem/{test_project.id}", headers=another_user_auth_headers) + assert resp.status_code == 404 + + +class TestUpdateProject: + def test_update_project_success(self, client: TestClient, auth_headers, test_project): + resp = client.patch(f"/api/ecosystem/{test_project.id}", headers=auth_headers, json={ + "description": "新描述", + "is_active": False, + }) + assert resp.status_code == 200 + data = resp.json() + assert data["description"] == "新描述" + assert data["is_active"] is False + + def test_update_project_not_found(self, client: TestClient, auth_headers): + resp = client.patch("/api/ecosystem/99999", headers=auth_headers, json={"description": "x"}) + assert resp.status_code == 404 + + +# ─── Sync ───────────────────────────────────────────────────────────────────── + +class TestSyncProject: + def test_sync_project_success(self, client: TestClient, auth_headers, test_project): + mock_result = {"created": 5, "updated": 2, "errors": 0} + with mock.patch("app.api.ecosystem.sync_project", return_value=mock_result): + resp = client.post(f"/api/ecosystem/{test_project.id}/sync", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] == 5 + assert data["updated"] == 2 + + def test_sync_project_not_found(self, client: TestClient, auth_headers): + resp = client.post("/api/ecosystem/99999/sync", headers=auth_headers) + assert resp.status_code == 404 + + +# ─── Contributors ───────────────────────────────────────────────────────────── + +class TestListContributors: + def test_list_contributors_empty(self, client: TestClient, auth_headers, test_project): + resp = client.get(f"/api/ecosystem/{test_project.id}/contributors", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + def test_list_contributors_with_data(self, client: TestClient, auth_headers, test_project, test_contributor): + resp = client.get(f"/api/ecosystem/{test_project.id}/contributors", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["github_handle"] == "octocat" + + def test_list_contributors_search(self, client: TestClient, auth_headers, test_project, test_contributor): + resp = client.get(f"/api/ecosystem/{test_project.id}/contributors?q=octo", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + resp2 = client.get(f"/api/ecosystem/{test_project.id}/contributors?q=nomatch", headers=auth_headers) + assert resp2.status_code == 200 + assert resp2.json()["total"] == 0 + + def test_list_contributors_unlinked_filter(self, client: TestClient, auth_headers, test_project, test_contributor): + resp = client.get(f"/api/ecosystem/{test_project.id}/contributors?unlinked=true", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + def test_list_contributors_project_not_found(self, client: TestClient, auth_headers): + resp = client.get("/api/ecosystem/99999/contributors", headers=auth_headers) + assert resp.status_code == 404 + + +# ─── Import Contributor to People ───────────────────────────────────────────── + +class TestImportContributorToPeople: + def test_import_creates_new_person(self, client: TestClient, auth_headers, test_project, test_contributor): + resp = client.post( + f"/api/ecosystem/{test_project.id}/contributors/octocat/import-person", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "created" + assert "person_id" in data + + def test_import_links_existing_person(self, client: TestClient, auth_headers, test_project, test_contributor, db_session: Session): + # 先创建已有人脉档案 + existing_person = PersonProfile( + display_name="Octocat", + github_handle="octocat", + source="manual", + ) + db_session.add(existing_person) + db_session.commit() + + resp = client.post( + f"/api/ecosystem/{test_project.id}/contributors/octocat/import-person", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "linked" + assert data["person_id"] == existing_person.id + + def test_import_contributor_not_found(self, client: TestClient, auth_headers, test_project): + resp = client.post( + f"/api/ecosystem/{test_project.id}/contributors/no-such-user/import-person", + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_import_project_not_found(self, client: TestClient, auth_headers): + resp = client.post( + "/api/ecosystem/99999/contributors/someuser/import-person", + headers=auth_headers, + ) + assert resp.status_code == 404 diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 5d1229f..a16503f 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -619,3 +619,160 @@ def fake_img_element(fn): assert isinstance(images, list) finally: os.unlink(tmp_path) + + +# ────────────────────────────────────────────────────────────────────────────── +# GitHub Crawler Service Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestGithubCrawlerService: + """测试 github_crawler sync_project / sync_all_projects""" + + def _make_project(self, platform="github", org="testorg", repo="testrepo", pid=1): + p = MagicMock() + p.id = pid + p.name = "Test Project" + p.platform = platform + p.org_name = org + p.repo_name = repo + p.contributors = [] + p.last_synced_at = None + return p + + def test_skip_non_github_project(self): + from app.services.ecosystem.github_crawler import sync_project + db = MagicMock() + project = self._make_project(platform="gitee") + result = sync_project(db, project, token=None) + assert result == {"created": 0, "updated": 0, "errors": 0} + + def test_sync_project_api_error(self): + from app.services.ecosystem.github_crawler import sync_project + db = MagicMock() + project = self._make_project() + + mock_response = MagicMock() + mock_response.status_code = 404 + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = mock_response + + with patch("app.services.ecosystem.github_crawler.httpx.Client", return_value=mock_client): + result = sync_project(db, project, token=None) + + assert result["errors"] == 1 + + def test_sync_project_creates_new_contributor(self): + from app.services.ecosystem.github_crawler import sync_project + from app.models.ecosystem import EcosystemContributor + db = MagicMock() + project = self._make_project() + project.contributors = [] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"login": "octocat", "avatar_url": "https://github.com/octocat.png", "contributions": 10}, + ] + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = mock_response + + with patch("app.services.ecosystem.github_crawler.httpx.Client", return_value=mock_client): + result = sync_project(db, project, token="test-token") + + assert result["created"] == 1 + assert result["updated"] == 0 + assert result["errors"] == 0 + db.add.assert_called_once() + db.commit.assert_called_once() + + def test_sync_project_updates_existing_contributor(self): + from app.services.ecosystem.github_crawler import sync_project + db = MagicMock() + project = self._make_project() + + existing = MagicMock() + existing.github_handle = "octocat" + existing.commit_count_90d = 5 + project.contributors = [existing] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"login": "octocat", "avatar_url": "new-avatar", "contributions": 15}, + ] + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = mock_response + + with patch("app.services.ecosystem.github_crawler.httpx.Client", return_value=mock_client): + result = sync_project(db, project) + + assert result["updated"] == 1 + assert result["created"] == 0 + assert existing.commit_count_90d == 15 + + def test_sync_project_skips_item_without_login(self): + from app.services.ecosystem.github_crawler import sync_project + db = MagicMock() + project = self._make_project() + project.contributors = [] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"contributions": 5}] # no login + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.get.return_value = mock_response + + with patch("app.services.ecosystem.github_crawler.httpx.Client", return_value=mock_client): + result = sync_project(db, project) + + assert result["created"] == 0 + assert result["errors"] == 0 + + def test_sync_project_exception_handling(self): + from app.services.ecosystem.github_crawler import sync_project + db = MagicMock() + project = self._make_project() + + with patch("app.services.ecosystem.github_crawler.httpx.Client", side_effect=Exception("network error")): + result = sync_project(db, project, token=None) + + assert result["errors"] == 1 + + def test_sync_all_projects_empty(self): + from app.services.ecosystem.github_crawler import sync_all_projects + db = MagicMock() + db.query.return_value.filter.return_value.all.return_value = [] + result = sync_all_projects(db) + assert result == {"created": 0, "updated": 0, "errors": 0} + + def test_sync_all_projects_aggregates(self): + from app.services.ecosystem.github_crawler import sync_all_projects + db = MagicMock() + p1 = self._make_project(pid=1) + p2 = self._make_project(pid=2) + db.query.return_value.filter.return_value.all.return_value = [p1, p2] + + with patch("app.services.ecosystem.github_crawler.sync_project") as mock_sync: + mock_sync.side_effect = [ + {"created": 3, "updated": 1, "errors": 0}, + {"created": 2, "updated": 0, "errors": 1}, + ] + result = sync_all_projects(db, token="tok") + + assert result["created"] == 5 + assert result["updated"] == 1 + assert result["errors"] == 1 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d325df0..a982c66 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -84,7 +84,7 @@ 活动管理 - +