- Add api_key field to MonitoredServer model
- Add migration to create api_key column with unique index
- Add /admin/servers/{id}/api-key endpoint for key generation
- Add /admin/servers/{id}/api-key DELETE endpoint for revocation
- Add /server/heartbeat-v2 endpoint with X-API-Key header authentication
- Add TelemetryPayload model with extended fields (load_avg, uptime_seconds)
- Add basic unit tests for API key functionality
263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""HarborForge API — Agent/人类协同任务管理平台"""
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
app = FastAPI(
|
|
title="HarborForge API",
|
|
description="Agent/人类协同任务管理平台 API",
|
|
version="0.3.0"
|
|
)
|
|
|
|
# CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Health & version (kept at top level)
|
|
@app.get("/health", tags=["System"])
|
|
def health_check():
|
|
return {"status": "healthy"}
|
|
|
|
@app.get("/version", tags=["System"])
|
|
def version():
|
|
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
|
|
|
|
# Register routers
|
|
from app.api.routers.auth import router as auth_router
|
|
from app.api.routers.tasks import router as tasks_router
|
|
from app.api.routers.projects import router as projects_router
|
|
from app.api.routers.users import router as users_router
|
|
from app.api.routers.comments import router as comments_router
|
|
from app.api.routers.webhooks import router as webhooks_router
|
|
from app.api.routers.misc import router as misc_router
|
|
from app.api.routers.monitor import router as monitor_router
|
|
from app.api.routers.milestones import router as milestones_router
|
|
from app.api.routers.roles import router as roles_router
|
|
from app.api.routers.proposes import router as proposes_router
|
|
from app.api.routers.milestone_actions import router as milestone_actions_router
|
|
|
|
app.include_router(auth_router)
|
|
app.include_router(tasks_router)
|
|
app.include_router(projects_router)
|
|
app.include_router(users_router)
|
|
app.include_router(comments_router)
|
|
app.include_router(webhooks_router)
|
|
app.include_router(misc_router)
|
|
app.include_router(monitor_router)
|
|
app.include_router(milestones_router)
|
|
app.include_router(roles_router)
|
|
app.include_router(proposes_router)
|
|
app.include_router(milestone_actions_router)
|
|
|
|
|
|
# Auto schema migration for lightweight deployments
|
|
def _migrate_schema():
|
|
from sqlalchemy import text
|
|
from app.core.config import SessionLocal
|
|
|
|
def _has_table(db, table_name: str) -> bool:
|
|
return db.execute(text("SHOW TABLES LIKE :table_name"), {"table_name": table_name}).fetchone() is not None
|
|
|
|
def _has_column(db, table_name: str, column_name: str) -> bool:
|
|
return db.execute(
|
|
text(f"SHOW COLUMNS FROM {table_name} LIKE :column_name"),
|
|
{"column_name": column_name},
|
|
).fetchone() is not None
|
|
|
|
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
|
rows = db.execute(text(
|
|
"""
|
|
SELECT CONSTRAINT_NAME
|
|
FROM information_schema.KEY_COLUMN_USAGE
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = :table_name
|
|
AND REFERENCED_TABLE_NAME = :referenced_table
|
|
AND CONSTRAINT_NAME <> 'PRIMARY'
|
|
"""
|
|
), {"table_name": table_name, "referenced_table": referenced_table}).fetchall()
|
|
for (constraint_name,) in rows:
|
|
db.execute(text(f"ALTER TABLE {table_name} DROP FOREIGN KEY `{constraint_name}`"))
|
|
|
|
def _ensure_fk(db, table_name: str, column_name: str, referenced_table: str, referenced_column: str, constraint_name: str):
|
|
exists = db.execute(text(
|
|
"""
|
|
SELECT 1
|
|
FROM information_schema.KEY_COLUMN_USAGE
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = :table_name
|
|
AND COLUMN_NAME = :column_name
|
|
AND REFERENCED_TABLE_NAME = :referenced_table
|
|
AND REFERENCED_COLUMN_NAME = :referenced_column
|
|
LIMIT 1
|
|
"""
|
|
), {
|
|
"table_name": table_name,
|
|
"column_name": column_name,
|
|
"referenced_table": referenced_table,
|
|
"referenced_column": referenced_column,
|
|
}).fetchone()
|
|
if not exists:
|
|
db.execute(text(
|
|
f"ALTER TABLE {table_name} ADD CONSTRAINT `{constraint_name}` FOREIGN KEY ({column_name}) REFERENCES {referenced_table}({referenced_column})"
|
|
))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# projects.project_code
|
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
|
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
|
|
|
# projects.owner_name
|
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
|
|
|
|
# projects.sub_projects / related_projects
|
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
|
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
|
|
|
|
# tasks extra fields
|
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_type'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(32) DEFAULT 'issue'"))
|
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_subtype'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN task_subtype VARCHAR(64) NULL"))
|
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'tags'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN tags VARCHAR(500) NULL"))
|
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'resolution_summary'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN resolution_summary TEXT NULL"))
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN positions TEXT NULL"))
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN pending_matters TEXT NULL"))
|
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'created_by_id'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
|
|
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
|
|
|
|
# milestones creator field
|
|
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
|
|
if not result.fetchone():
|
|
db.execute(text("ALTER TABLE milestones ADD COLUMN created_by_id INTEGER NULL"))
|
|
_ensure_fk(db, "milestones", "created_by_id", "users", "id", "fk_milestones_created_by_id")
|
|
|
|
# comments: issue_id -> task_id
|
|
if _has_table(db, "comments"):
|
|
_drop_fk_constraints(db, "comments", "issues")
|
|
if _has_column(db, "comments", "issue_id") and not _has_column(db, "comments", "task_id"):
|
|
db.execute(text("ALTER TABLE comments CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
|
|
if _has_column(db, "comments", "task_id"):
|
|
_ensure_fk(db, "comments", "task_id", "tasks", "id", "fk_comments_task_id")
|
|
|
|
# work_logs: issue_id -> task_id
|
|
if _has_table(db, "work_logs"):
|
|
_drop_fk_constraints(db, "work_logs", "issues")
|
|
if _has_column(db, "work_logs", "issue_id") and not _has_column(db, "work_logs", "task_id"):
|
|
db.execute(text("ALTER TABLE work_logs CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
|
|
if _has_column(db, "work_logs", "task_id"):
|
|
_ensure_fk(db, "work_logs", "task_id", "tasks", "id", "fk_work_logs_task_id")
|
|
|
|
# Drop issues table if it exists (no longer used anywhere)
|
|
if _has_table(db, "issues"):
|
|
db.execute(text("DROP TABLE issues"))
|
|
|
|
# --- Milestone status enum migration (old -> new) ---
|
|
if _has_table(db, "milestones"):
|
|
# Alter enum column to accept new values
|
|
db.execute(text(
|
|
"ALTER TABLE milestones MODIFY COLUMN status "
|
|
"ENUM('open','pending','deferred','progressing','freeze','undergoing','completed','closed') "
|
|
"DEFAULT 'open'"
|
|
))
|
|
# Migrate old values
|
|
db.execute(text("UPDATE milestones SET status='open' WHERE status='pending'"))
|
|
db.execute(text("UPDATE milestones SET status='closed' WHERE status='deferred'"))
|
|
db.execute(text("UPDATE milestones SET status='undergoing' WHERE status='progressing'"))
|
|
# Shrink enum to new-only values
|
|
db.execute(text(
|
|
"ALTER TABLE milestones MODIFY COLUMN status "
|
|
"ENUM('open','freeze','undergoing','completed','closed') "
|
|
"DEFAULT 'open'"
|
|
))
|
|
# Add started_at if missing
|
|
if not _has_column(db, "milestones", "started_at"):
|
|
db.execute(text("ALTER TABLE milestones ADD COLUMN started_at DATETIME NULL"))
|
|
|
|
# --- P7.1: Migrate task_type='task' to 'issue' ---
|
|
if _has_table(db, "tasks") and _has_column(db, "tasks", "task_type"):
|
|
db.execute(text("UPDATE tasks SET task_type='issue' WHERE task_type='task'"))
|
|
|
|
# --- Task status enum migration (old -> new) ---
|
|
if _has_table(db, "tasks"):
|
|
# Widen enum first
|
|
db.execute(text(
|
|
"ALTER TABLE tasks MODIFY COLUMN status "
|
|
"ENUM('open','pending','progressing','undergoing','completed','closed') "
|
|
"DEFAULT 'open'"
|
|
))
|
|
# Migrate old values
|
|
db.execute(text("UPDATE tasks SET status='undergoing' WHERE status='progressing'"))
|
|
# Shrink enum to new-only values
|
|
db.execute(text(
|
|
"ALTER TABLE tasks MODIFY COLUMN status "
|
|
"ENUM('open','pending','undergoing','completed','closed') "
|
|
"DEFAULT 'open'"
|
|
))
|
|
|
|
# --- monitored_servers.api_key for heartbeat v2 ---
|
|
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
|
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
|
db.execute(text("CREATE UNIQUE INDEX idx_monitored_servers_api_key ON monitored_servers (api_key)"))
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
print(f"Migration warning: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
# Run database migration on startup
|
|
@app.on_event("startup")
|
|
def startup():
|
|
from app.core.config import Base, engine, SessionLocal
|
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, propose
|
|
Base.metadata.create_all(bind=engine)
|
|
_migrate_schema()
|
|
|
|
# Initialize from AbstractWizard (admin user, default project, etc.)
|
|
from app.init_wizard import run_init
|
|
db = SessionLocal()
|
|
try:
|
|
run_init(db)
|
|
finally:
|
|
db.close()
|
|
|
|
# Start lightweight monitor polling thread (every 10 minutes)
|
|
import threading, time
|
|
from app.services.monitoring import refresh_provider_usage_once
|
|
|
|
def _monitor_poll_loop():
|
|
while True:
|
|
db2 = SessionLocal()
|
|
try:
|
|
refresh_provider_usage_once(db2)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
db2.close()
|
|
time.sleep(600)
|
|
|
|
t = threading.Thread(target=_monitor_poll_loop, daemon=True)
|
|
t.start()
|