diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index 6a6e722..86ad10c 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -9,10 +9,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from ..dependencies import get_db -from ..schemas.operator import TaskCreateRequest, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse +from ..schemas.operator import TaskCreateRequest, TaskStatusRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest from ...rbac import require_permission, require_roles, write_audit from ...infrastructure.repositories import Database from ...services import compute_client +from ...services import training_admin_bridge as training router = APIRouter(prefix="/admin", tags=["admin-op"]) @@ -209,3 +210,126 @@ async def compute_provision( pat=pat, created=created, ) + + +# ── 培训业务 · 课程(桥接 app.training courses 表)───────────────────────── +@router.get("/courses", summary="课程列表") +async def list_courses( + status: str | None = None, + _u: dict = Depends(require_roles("operator")), +): + return training.list_courses(status=status) + + +@router.post("/courses", summary="创建课程") +async def create_course( + req: CourseCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:course.manage")), +): + course = training.create_course(req.model_dump(exclude_none=True)) + await write_audit(db, action="course.create", resource="course", resource_id=course["id"], + detail=course["title"], user=actor, request=request) + return course + + +@router.post("/courses/{course_id}/status", summary="更新课程状态") +async def set_course_status( + course_id: str, + req: CourseStatusRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:course.manage")), +): + course = training.set_course_status(course_id, req.status) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + await write_audit(db, action="course.status", resource="course", resource_id=course_id, + detail=req.status, user=actor, request=request) + return course + + +# ── 培训业务 · 活动(桥接 app.training events 表)────────────────────────── +@router.get("/activities", summary="活动列表") +async def list_activities( + status: str | None = None, + _u: dict = Depends(require_roles("operator")), +): + return training.list_activities(status=status) + + +@router.post("/activities/{event_id}/status", summary="更新活动状态") +async def set_activity_status( + event_id: str, + req: ActivityStatusRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:activity.manage")), +): + activity = training.set_activity_status(event_id, req.status) + if activity is None: + raise HTTPException(status_code=404, detail="Activity not found") + await write_audit(db, action="activity.status", resource="activity", resource_id=event_id, + detail=req.status, user=actor, request=request) + return activity + + +# ── 培训业务 · 报名(桥接 app.training bookings 表)──────────────────────── +@router.get("/bookings", summary="报名列表") +async def list_bookings( + status: str | None = None, + audit_status: str | None = None, + _u: dict = Depends(require_roles("operator")), +): + return training.list_bookings(status=status, audit_status=audit_status) + + +@router.patch("/bookings/{booking_id}", summary="更新报名(审核/备注)") +async def update_booking( + booking_id: str, + req: BookingUpdateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:booking.manage")), +): + booking = training.update_booking(booking_id, req.model_dump(exclude_none=True)) + if booking is None: + raise HTTPException(status_code=404, detail="Booking not found") + await write_audit(db, action="booking.update", resource="booking", resource_id=booking_id, + detail=str(req.model_dump(exclude_none=True)), user=actor, request=request) + return booking + + +# ── 培训业务 · 测评(桥接 app.training tests 表)────────────────────────── +@router.get("/tests", summary="测评记录列表") +async def list_tests( + _u: dict = Depends(require_roles("operator")), +): + return training.list_tests() + + +@router.post("/tests", summary="创建测评记录") +async def create_test( + req: TestCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:test.manage")), +): + item = training.create_test(req.model_dump(exclude_none=True)) + await write_audit(db, action="test.create", resource="test", resource_id=item["id"], + detail=item["title"], user=actor, request=request) + return item + + +@router.post("/tests/{test_id}/status", summary="更新测评状态") +async def set_test_status( + test_id: str, + req: TestStatusRequest, + _u: dict = Depends(require_roles("operator")), +): + # 测评记录为不可变快照,状态变更仅回读当前记录(后续如需启用/禁用再扩展字段) + item = training.pack_test_if_exists(test_id) + if item is None: + raise HTTPException(status_code=404, detail="Test not found") + return item diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 6189449..8dcc4f5 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -59,6 +59,49 @@ class ConfigUpdateRequest(BaseModel): # ── 算力对接(compute-engine)───────────────────────────────────────────── +# ── 培训业务四件套(课程 / 活动 / 报名 / 测评)→ 桥接 app.training ───────── + +class CourseCreateRequest(BaseModel): + title: str + category: str = "" + level: str = "" + subtitle: str = "" + desc: str = "" + price: float = 0 + status: str = "draft" + start_at: str = "" + end_at: str = "" + venue: str = "" + quota: int = 0 + image: str = "" + host: str = "" + + +class CourseStatusRequest(BaseModel): + status: str # draft/published/ended/offline + + +class ActivityStatusRequest(BaseModel): + status: str # open/invite/pending/done/ended + + +class BookingUpdateRequest(BaseModel): + status: str | None = None + audit_status: str | None = None + note: str | None = None + + +class TestCreateRequest(BaseModel): + title: str + category: str = "" + pass_score: int = 0 + question_count: int = 30 + + +class TestStatusRequest(BaseModel): + status: str # enabled/disabled + + class ComputePingResponse(BaseModel): ok: bool version: str | None = None diff --git a/app/services/training_admin_bridge.py b/app/services/training_admin_bridge.py new file mode 100644 index 0000000..166ba8a --- /dev/null +++ b/app/services/training_admin_bridge.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""运营端 → 培训子应用数据桥接(课程 / 活动 / 报名 / 测评)。 + +培训业务数据存于 app.training(独立 serverdata/data/opc.db);运营端 /admin/* +端点在此聚合读写,避免在平台应用重建一套课程/活动模型。 + +仅作适度封装(list / create / status / patch),状态与原 mock 语义保持一致; +创建统一生成 id(前缀 C- / E- / B- / T-),时间用 UTC ISO。 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.training import db as tdb + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +# ── 课程(courses 表)──────────────────────────────────────────────────── + +def list_courses(status: str | None = None) -> list[dict]: + rows = tdb.list_all("courses", "created_at DESC") + if status: + return [r for r in rows if r.get("status") == status] + return rows + + +def create_course(data: dict) -> dict: + row = dict(data) + row["id"] = tdb.gen_id("C-") + row.setdefault("created_at", _now()) + row.setdefault("status", "draft") + tdb.insert("courses", row) + return tdb.fetch_by_id("courses", row["id"]) + + +def set_course_status(course_id: str, status: str) -> dict | None: + if tdb.fetch_by_id("courses", course_id) is None: + return None + tdb.update_row("courses", course_id, {"status": status}) + return tdb.fetch_by_id("courses", course_id) + + +# ── 活动(events 表)───────────────────────────────────────────────────── + +def list_activities(status: str | None = None) -> list[dict]: + rows = tdb.list_all("events", "start_at DESC") + if status: + return [r for r in rows if r.get("status") == status] + return rows + + +def set_activity_status(event_id: str, status: str) -> dict | None: + if tdb.fetch_by_id("events", event_id) is None: + return None + tdb.update_row("events", event_id, {"status": status}) + return tdb.fetch_by_id("events", event_id) + + +# ── 报名(bookings 表)─────────────────────────────────────────────────── + +def list_bookings(status: str | None = None, audit_status: str | None = None) -> list[dict]: + rows = tdb.list_all("bookings", "created_at DESC") + if status: + rows = [r for r in rows if r.get("status") == status] + if audit_status: + rows = [r for r in rows if r.get("audit_status") == audit_status] + # 映射为运营端 Booking 视图 + return [pack_booking(r) for r in rows] + + +def pack_booking(r: dict) -> dict: + return { + "id": r.get("id"), + "user_id": r.get("username"), + "user_name": r.get("name"), + "target_type": "event", + "target_id": r.get("event_id"), + "target_title": r.get("event_title"), + "status": r.get("status"), + "audit_status": r.get("audit_status"), + "note": r.get("question"), + "created_at": r.get("created_at"), + } + + +def update_booking(booking_id: str, patch: dict) -> dict | None: + if tdb.fetch_by_id("bookings", booking_id) is None: + return None + allowed = {k: v for k, v in patch.items() if k in ("status", "audit_status", "note", "question")} + if "note" in allowed: + allowed["question"] = allowed.pop("note") + if allowed: + tdb.update_row("bookings", booking_id, allowed) + return pack_booking(tdb.fetch_by_id("bookings", booking_id)) + + +# ── 测评(tests 表:OPC 适配度测评记录)───────────────────────────────────── + +def list_tests() -> list[dict]: + rows = tdb.list_all("tests", "created_at DESC") + return [pack_test(r) for r in rows] + + +def pack_test(r: dict) -> dict: + return { + "id": r.get("id"), + "title": r.get("persona") or "OPC 适配度测评", + "category": r.get("type_code"), + "pass_score": 0, + "question_count": 30, + "status": "completed", + "adapt_level": r.get("adapt_level"), + "tracks": r.get("tracks"), + "created_at": r.get("created_at"), + } + + +def pack_test_if_exists(test_id: str) -> dict | None: + row = tdb.fetch_by_id("tests", test_id) + return pack_test(row) if row else None + + +def create_test(data: dict) -> dict: + row = { + "id": tdb.gen_id("T-"), + "created_at": _now(), + "username": "", + "type_code": data.get("category", "opc"), + "persona": data.get("title", "OPC 适配度测评"), + "adapt_level": "", + "tracks": data.get("tracks", ""), + "version": "", + } + tdb.insert("tests", row) + return pack_test(tdb.fetch_by_id("tests", row["id"])) diff --git a/app/training/db.py b/app/training/db.py index 2b5135d..9675e1c 100644 --- a/app/training/db.py +++ b/app/training/db.py @@ -94,6 +94,23 @@ CREATE TABLE IF NOT EXISTS tests ( tracks TEXT, version TEXT ); +CREATE TABLE IF NOT EXISTS courses ( + id TEXT PRIMARY KEY, + category TEXT, + level TEXT, + title TEXT, + subtitle TEXT, + desc TEXT, + price REAL DEFAULT 0, + status TEXT DEFAULT 'draft', + start_at TEXT, + end_at TEXT, + venue TEXT, + quota INTEGER DEFAULT 0, + image TEXT, + host TEXT, + created_at TEXT +); CREATE TABLE IF NOT EXISTS policy_logs ( id TEXT PRIMARY KEY, created_at TEXT, @@ -140,6 +157,12 @@ def now_iso(): return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +SEED_COURSES = [ + {"id": "C-001", "category": "AI应用", "level": "入门", "title": "AI 内容一键产出:从标题到海报", "subtitle": "公益课回放", "desc": "把组标题、写正文、配图、做海报这一条链路用 AI 跑通。", "price": 0, "status": "published", "start_at": "2026-09-04T19:30:00+08:00", "end_at": "", "venue": "视频号直播", "quota": 0, "image": "", "host": "云超服 · OPC 培训", "created_at": "2026-08-24T00:00:00Z"}, + {"id": "C-002", "category": "创业", "level": "进阶", "title": "一人公司财税避坑进阶", "subtitle": "线上标准课", "desc": "个体户 / 一人公司:发票、成本票、申报节点、核定与查账。", "price": 499, "status": "draft", "start_at": "2026-09-20T19:30:00+08:00", "end_at": "", "venue": "视频号直播", "quota": 200, "image": "", "host": "云超服 · OPC 培训", "created_at": "2026-08-24T00:00:00Z"}, +] + + def init_db(): conn = get_conn() conn.executescript(SCHEMA) @@ -158,6 +181,14 @@ def init_db(): "VALUES (:id,:type,:mode,:title,:subtitle,:desc,:location,:host,:image,:link,:start_at,:duration_min,:capacity,:status)", e, ) + # 种子:在线课程(运营端课程管理) + if conn.execute("SELECT COUNT(*) AS c FROM courses").fetchone()["c"] == 0: + for c in SEED_COURSES: + conn.execute( + "INSERT INTO courses (id,category,level,title,subtitle,desc,price,status,start_at,end_at,venue,quota,image,host,created_at) " + "VALUES (:id,:category,:level,:title,:subtitle,:desc,:price,:status,:start_at,:end_at,:venue,:quota,:image,:host,:created_at)", + c, + ) # -- 幂等迁移:为已有 bookings 表补 audit_status 列 -- cols = [r["name"] for r in conn.execute("PRAGMA table_info(bookings)").fetchall()] if "audit_status" not in cols: