From 1587432f8a231e6613b4a20bc930fc3b3a4ee9c8 Mon Sep 17 00:00:00 2001 From: Zhenyu Zheng Date: Sun, 22 Feb 2026 23:17:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Phase=204c/4d=20=E2=80=94=20Campaig?= =?UTF-8?q?n=20=E8=BF=90=E8=90=A5=E6=B4=BB=E5=8A=A8=20+=20Ecosystem=20?= =?UTF-8?q?=E7=94=9F=E6=80=81=E6=B4=9E=E5=AF=9F=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 4c: Campaign 运营活动模块(联系人管理、活动导入、状态跟踪) - Phase 4d: Ecosystem 生态洞察模块(GitHub 项目监控、贡献者排行、一键导入人脉) - fix: Ecosystem 模块补充 community_id 多租户隔离(迁移 009) - fix: campaigns.py N+1 查询优化、Event/Person 存在性校验、字段白名单 - fix: event.py Enum 名称冲突修复 - fix: LIKE 通配符注入转义 - fix: EcosystemDetail.vue 定时器内存泄漏 - fix: 无社区时各页面报错和双重 toast 问题 --- .../versions/007_add_campaign_module.py | 67 ++ .../versions/008_add_ecosystem_module.py | 55 ++ .../009_add_ecosystem_community_id.py | 33 + backend/app/api/campaigns.py | 369 ++++++++++++ backend/app/api/ecosystem.py | 201 ++++++ backend/app/main.py | 4 + backend/app/models/__init__.py | 7 + backend/app/models/campaign.py | 60 ++ backend/app/models/ecosystem.py | 50 ++ backend/app/models/event.py | 2 +- backend/app/schemas/campaign.py | 141 +++++ backend/app/schemas/ecosystem.py | 77 +++ backend/app/services/ecosystem/__init__.py | 0 .../app/services/ecosystem/github_crawler.py | 91 +++ frontend/src/App.vue | 8 +- frontend/src/api/campaign.ts | 148 +++++ frontend/src/api/ecosystem.ts | 85 +++ frontend/src/router/index.ts | 22 +- frontend/src/views/CampaignDetail.vue | 570 ++++++++++++++++++ frontend/src/views/Campaigns.vue | 273 ++++++++- frontend/src/views/EcosystemDetail.vue | 404 +++++++++++++ frontend/src/views/EcosystemList.vue | 264 ++++++++ frontend/src/views/Events.vue | 17 +- 23 files changed, 2914 insertions(+), 34 deletions(-) create mode 100644 backend/alembic/versions/007_add_campaign_module.py create mode 100644 backend/alembic/versions/008_add_ecosystem_module.py create mode 100644 backend/alembic/versions/009_add_ecosystem_community_id.py create mode 100644 backend/app/api/campaigns.py create mode 100644 backend/app/api/ecosystem.py create mode 100644 backend/app/models/campaign.py create mode 100644 backend/app/models/ecosystem.py create mode 100644 backend/app/schemas/campaign.py create mode 100644 backend/app/schemas/ecosystem.py create mode 100644 backend/app/services/ecosystem/__init__.py create mode 100644 backend/app/services/ecosystem/github_crawler.py create mode 100644 frontend/src/api/campaign.ts create mode 100644 frontend/src/api/ecosystem.ts create mode 100644 frontend/src/views/CampaignDetail.vue create mode 100644 frontend/src/views/EcosystemDetail.vue create mode 100644 frontend/src/views/EcosystemList.vue 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..e373467 --- /dev/null +++ b/backend/alembic/versions/009_add_ecosystem_community_id.py @@ -0,0 +1,33 @@ +"""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, + sa.ForeignKey("communities.id", ondelete="CASCADE"), + nullable=True, # 已有数据兼容;新记录由 API 层强制填写 + ) + ) + batch_op.create_index("ix_ecosystem_projects_community_id", ["community_id"]) + + +def downgrade() -> None: + with op.batch_alter_table("ecosystem_projects") as batch_op: + 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..d7af613 --- /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 = {status: count for status, count in 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..17fd8eb --- /dev/null +++ b/backend/app/api/ecosystem.py @@ -0,0 +1,201 @@ +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.config import settings +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 ( + ContributorOut, + 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..3c229e3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,7 @@ from app.models.audit import AuditLog +from app.models.campaign import Campaign, CampaignActivity, CampaignContact from app.models.channel import ChannelConfig +from app.models.ecosystem import EcosystemContributor, EcosystemProject from app.models.committee import Committee, CommitteeMember from app.models.community import Community from app.models.content import Content @@ -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..cdb970a --- /dev/null +++ b/backend/app/models/campaign.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy import Enum as SAEnum +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..2d0d535 --- /dev/null +++ b/backend/app/models/ecosystem.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, 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..36050f2 --- /dev/null +++ b/backend/app/schemas/campaign.py @@ -0,0 +1,141 @@ +from datetime import date, datetime +from typing import Optional + +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..2690c84 --- /dev/null +++ b/backend/app/schemas/ecosystem.py @@ -0,0 +1,77 @@ +from datetime import datetime +from typing import Optional + +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..5265fc5 --- /dev/null +++ b/backend/app/services/ecosystem/github_crawler.py @@ -0,0 +1,91 @@ +"""GitHub 生态项目贡献者采集服务。 + +通过 GitHub REST API 获取指定仓库的贡献者列表, +写入 EcosystemContributor 表并更新 EcosystemProject.last_synced_at。 +""" + +import logging +from datetime import datetime +from typing import Optional + +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: Optional[str]) -> 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: Optional[str] = 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: Optional[str] = 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/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 @@ 活动管理 - +