Files
server-core/app/api/routers/rbac_hall.py
T
Pine d851b54755 refactor: 角色组织模型重构 - 阶段二完成
1. 运行迁移脚本:同步 2 条园区管理员 + 1 条企业管理员到 organization_members
2. 新增 get_user_organizations / get_user_primary_org 辅助函数
3. 改造各路由优先从 organization_members 查询组织:
   - rbac_org.py: /org/me 返回所有绑定组织
   - rbac_portals.py: referrals 和 carrier_park 优先用 organization_members
   - rbac_hall.py: 发布岗位优先用 organization_members 获取企业
4. users.org_id 保留作回退,不删除字段,风险可控
2026-09-03 09:18:28 +08:00

1032 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""接口层 · 大厅(任务/人才/岗位/服务)+ 社区(线上/实体载体)。
设计:design/云超服平台/大厅与社区升级设计方案.md §4。
- 公共读:游客可访问(optional_current_user),登录后附 can_accept / 已报名标记。
- 写接口:按 capabilities 叠加制角色判定(opc_member 基础能力 + enterprise/carrier/operator 叠加)。
- 审核类写接口(任务/岗位/服务上下架)走 operator 能力;admin 端管理页在 rbac_admin 扩展。
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from ..dependencies import Database, get_current_user, get_db, get_user_primary_org, optional_current_user
from ..schemas.opc import TaskClaimRequest
from ...infrastructure.models import ParkTenant
from ...infrastructure.repositories import utcnow_iso
from ...rbac import write_audit
router = APIRouter(prefix="/hall", tags=["hall"])
def require_cap(*caps: str):
"""按叠加制能力集合判定(capabilitiesopc_member/enterprise/carrier/operator)。"""
from fastapi import Depends as _Depends
def dep(user: dict = _Depends(get_current_user)) -> dict:
if not (set(caps) & set(user.get("capabilities") or [])):
raise HTTPException(status_code=403, detail="无权限执行该操作")
return user
return dep
def _publish_status(user: dict) -> str:
"""operator 直接发布,其余角色进审核(pending → 运营端通过后 published)。"""
return "published" if "operator" in (user.get("capabilities") or []) else "pending"
# ===========================================================================
# 大厅 Hub
# ===========================================================================
@router.get("/overview", summary="大厅总览(四厅统计)")
async def hall_overview(db: Database = Depends(get_db)):
tasks = [t for t in await db.tasks.list_published() if t.get("status") == "published" and t.get("c_visible")]
return {
"tasks": len(tasks),
"grab": sum(1 for t in tasks if t.get("mode") == "grab"),
"bid": sum(1 for t in tasks if t.get("mode") == "bid"),
"register": sum(1 for t in tasks if t.get("mode") == "register"),
"talents": len(await db.hall_talents.list()),
"jobs": len(await db.hall_jobs.list(status="published")),
"services": len(await db.hall_services.list(status="published")),
}
# ===========================================================================
# 任务大厅
# ===========================================================================
@router.get("/tasks", summary="任务大厅列表(公开;可按模式/分类/关键字筛选)")
async def hall_tasks(
mode: str = "",
category: str = "",
q: str = "",
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...services.task_service import TaskService
items = [t for t in await db.tasks.list_published()
if t.get("status") == "published" and t.get("c_visible")]
if mode:
items = [t for t in items if t.get("mode") == mode]
if category:
items = [t for t in items if t.get("category") == category]
if q:
kw = q.lower()
items = [t for t in items
if kw in (t.get("title") or "").lower() or kw in (t.get("tags") or "").lower()
or kw in (t.get("publisher_name") or "").lower()]
items.sort(key=lambda t: -(t.get("display_priority") or 0))
if user:
svc = TaskService(db)
for it in items:
acc = await svc.can_accept(it, user)
it["can_accept"] = acc["ok"]
it["accept_reasons"] = acc["reasons"]
return {"items": items}
# 字面路径必须在参数化路径之前注册,否则 /tasks/mine 会被 /tasks/{task_id} 抢先匹配
@router.get("/tasks/mine", summary="我发布的任务(含接单/投标数)")
async def hall_my_tasks(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = [t for t in await db.tasks.list(status="") if t.get("publisher_id") == user["id"]]
out = []
for t in items:
claims = await db.task_claims.list_by_task(t["id"])
bids = await db.bids.list_for_task(t["id"])
out.append({**t,
"claimCount": sum(1 for c in claims if c.get("status") in ("claimed", "doing", "assigned")),
"registerCount": sum(1 for c in claims if c.get("claim_source") == "register" and c.get("status") == "registered"),
"bidCount": sum(1 for b in bids if b.get("status") == "submitted")})
return {"items": out}
@router.get("/tasks/{task_id}", summary="任务详情(公开;登录附资格与我的接单/投标/报名状态)")
async def hall_task_detail(
task_id: str,
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...services.task_service import TaskService
item = await db.tasks.get(task_id)
if item is None or item.get("status") not in ("published", "claimed", "doing") \
or not item.get("c_visible"):
raise HTTPException(status_code=404, detail="任务不存在")
if user:
svc = TaskService(db)
acc = await svc.can_accept(item, user)
item["can_accept"] = acc["ok"]
item["accept_reasons"] = acc["reasons"]
claims = await db.task_claims.list_by_task(task_id)
mine = [c for c in claims if c.get("claimer_user_id") == user["id"]]
item["myClaim"] = mine[0] if mine else None
item["myBid"] = next(
(b for b in await db.bids.list_for_task(task_id) if b.get("opc_id") == user["id"]), None)
if item.get("mode") == "register":
item["registerCount"] = sum(
1 for c in claims if c.get("claim_source") == "register" and c.get("status") == "registered")
return item
@router.post("/tasks", summary="发布任务(登录用户;operator 直接发布,其余进审核)")
async def hall_publish_task(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写任务标题")
status = _publish_status(user)
caps = user.get("capabilities") or []
task = await db.tasks.create({
"title": title,
"category": str(body.get("category", "")).strip(),
"sub_category": str(body.get("subCategory", "") or body.get("sub_category", "")).strip(),
"description": str(body.get("description", "")),
"tags": str(body.get("tags", "")),
"mode": str(body.get("mode", "grab")).strip() or "grab",
"budget_min": int(body.get("budgetMin") or body.get("budget_min") or 0),
"budget_max": int(body.get("budgetMax") or body.get("budget_max") or 0),
"deadline": str(body.get("deadline", "")),
"delivery_days": int(body.get("deliveryDays") or body.get("delivery_days") or 0),
"headcount": int(body.get("headcount") or 0),
"register_quota": int(body.get("registerQuota") or body.get("register_quota") or 0),
"bid_quota": int(body.get("bidQuota") or body.get("bid_quota") or 0),
"win_quota": int(body.get("winQuota") or body.get("win_quota") or 0),
"eligibility": json.dumps(body.get("eligibility") or {}, ensure_ascii=False),
"status": status,
"published_at": utcnow_iso() if status == "published" else "",
"publisher_id": user.get("id"),
"publisher_name": user.get("nickname") or user.get("username", ""),
"publisher_role": "enterprise" if "enterprise" in caps else ("carrier" if "carrier" in caps else ("operator" if "operator" in caps else "opc")),
"c_visible": True,
})
await write_audit(db, action="hall.task.publish", resource="task", resource_id=task["id"],
detail=title, user=user, request=request)
# 任务发布即建任务群(fire-and-forgetIM 服务不可用不阻断)
try:
import asyncio
from ...im import client as im_client
asyncio.create_task(im_client.sync_task_group(task["id"], title))
except Exception: # noqa: BLE001
pass
return {"ok": True, "id": task["id"], "status": status, "item": task}
@router.post("/tasks/{task_id}/register", summary="报名任务(register 模式;先到先得,达上限关闭)")
async def hall_register_task(
task_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_cap("opc_member")),
):
from ...services.task_service import TaskService
claim = await TaskService(db).register(task_id, actor)
await write_audit(db, action="hall.task.register", resource="task", resource_id=task_id,
detail=actor.get("username"), user=actor, request=request)
return {"ok": True, "claim": claim}
@router.get("/tasks/{task_id}/claims", summary="任务报名/接单列表(发包方或运营端可见)")
async def hall_task_claims(
task_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
caps = user.get("capabilities") or []
is_publisher = task.get("publisher_id") == user.get("id")
if not (is_publisher or "operator" in caps or "carrier" in caps or "enterprise" in caps):
raise HTTPException(status_code=403, detail="仅发包方或运营端可查看")
claims = await db.task_claims.list_by_task(task_id)
bids = await db.bids.list_for_task(task_id)
return {"claims": claims, "bids": bids}
# ===========================================================================
# 人才大厅
# ===========================================================================
@router.get("/talents", summary="人才大厅列表(OPC + 求职者)")
async def hall_talents(
type: str = "",
region: str = "",
field: str = "",
q: str = "",
db: Database = Depends(get_db),
):
items = await db.hall_talents.list(talent_type=type, region=region, q=q, field=field)
return {"items": items}
@router.get("/talents/{user_id}", summary="人才详情")
async def hall_talent_detail(user_id: str, db: Database = Depends(get_db)):
profile = await db.hall_talents.get(user_id)
if profile is None or not profile.get("published"):
raise HTTPException(status_code=404, detail="人才档案不存在")
full = await db.users.get_by_id(user_id) or {}
out = dict(profile)
# 隐私:contact_ok=False 不出联系方式;OPC 附信用分与认证状态
out["creditScore"] = (full.get("opc_credit_score") if profile.get("contactOk") else None)
out["opcCertified"] = bool(full.get("opc_certified") or full.get("opc_cert"))
out["services"] = await db.hall_services.list(status="published", opc_id=user_id)
return out
@router.put("/talents/me", summary="维护我的人才档案(OPC 与求职者共用)")
async def hall_upsert_talent(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
patch = {
"talentType": str(body.get("talentType", "opc")).strip() or "opc",
"displayName": str(body.get("displayName", "")).strip() or user.get("nickname") or user.get("username", ""),
"avatar": str(body.get("avatar", "") or user.get("avatar", "")),
"headline": str(body.get("headline", "")).strip(),
"bio": str(body.get("bio", "")),
"fields": [str(x) for x in (body.get("fields") or []) if str(x).strip()],
"skills": [str(x) for x in (body.get("skills") or []) if str(x).strip()],
"workYears": int(body.get("workYears") or 0),
"education": str(body.get("education", "")),
"region": str(body.get("region", "")),
"contactOk": bool(body.get("contactOk", True)),
}
profile = await db.hall_talents.upsert(user["id"], patch)
await write_audit(db, action="hall.talent.upsert", resource="talent_profile",
resource_id=user["id"], user=user, request=request)
return {"ok": True, "profile": profile}
# ===========================================================================
# 岗位大厅
# ===========================================================================
@router.get("/jobs", summary="岗位大厅列表(publishedenterprise 可见自己全部)")
async def hall_jobs(
category: str = "",
work_type: str = "",
q: str = "",
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
if user and "enterprise" in (user.get("capabilities") or []):
mine = await db.hall_jobs.list(status="", publisher_id=user["id"])
pub = await db.hall_jobs.list(status="published", category=category, work_type=work_type, q=q)
seen = {i["id"] for i in pub}
return {"items": pub + [i for i in mine if i["id"] not in seen]}
return {"items": await db.hall_jobs.list(status="published", category=category,
work_type=work_type, q=q)}
# 字面路径必须在参数化路径之前注册
@router.get("/jobs/mine", summary="我发布的岗位(含投递数)")
async def hall_my_jobs(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = await db.hall_jobs.list(status="", publisher_id=user["id"])
out = []
for j in items:
apps = await db.hall_job_apps.list_for_job(j["id"])
out.append({**j, "applicationCount": len(apps),
"newApplicationCount": sum(1 for a in apps if a.get("status") == "submitted")})
return {"items": out}
@router.get("/jobs/{job_id}", summary="岗位详情")
async def hall_job_detail(job_id: str, db: Database = Depends(get_db)):
item = await db.hall_jobs.get(job_id)
if item is None or item.get("status") not in ("published", "closed"):
raise HTTPException(status_code=404, detail="岗位不存在")
return item
@router.post("/jobs", summary="发布岗位(enterprise/carrier/operator")
async def hall_publish_job(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("enterprise", "carrier", "operator")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写岗位名称")
status = _publish_status(user)
# 优先从 organization_members 获取企业组织,回退 user.org_id
ent_org = await get_user_primary_org(db, user, org_type="enterprise")
company_id = ent_org["org_id"] if ent_org else user.get("org_id")
job = await db.hall_jobs.create(
company_id=company_id or None,
company_name=str(body.get("companyName", "")).strip() or user.get("company", ""),
title=title,
category=str(body.get("category", "")).strip(),
description=str(body.get("description", "")),
requirement=str(body.get("requirement", "")),
tags=str(body.get("tags", "")),
salary_min=int(body.get("salaryMin") or 0),
salary_max=int(body.get("salaryMax") or 0),
location=str(body.get("location", "")),
work_type=str(body.get("workType", "fulltime")) or "fulltime",
headcount=int(body.get("headcount") or 1),
contact=str(body.get("contact", "")),
publisher_id=user.get("id"),
status=status,
published_at=utcnow_iso() if status == "published" else "",
)
await write_audit(db, action="hall.job.publish", resource="job", resource_id=job["id"],
detail=title, user=user, request=request)
return {"ok": True, "id": job["id"], "status": status}
@router.post("/jobs/{job_id}/apply", summary="投递简历(opc_member 及以上)")
async def hall_apply_job(
job_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
job = await db.hall_jobs.get(job_id)
if job is None or job.get("status") != "published":
raise HTTPException(status_code=404, detail="岗位不存在或未发布")
existing = [a for a in await db.hall_job_apps.list_for_job(job_id) if a.get("userId") == user["id"]]
if existing:
raise HTTPException(status_code=400, detail="你已投递该岗位")
app_ = await db.hall_job_apps.create(
job_id, user["id"], user.get("nickname") or user.get("username", ""),
contact=str(body.get("contact", "")), note=str(body.get("note", "")))
await write_audit(db, action="hall.job.apply", resource="job", resource_id=job_id,
detail=user.get("username"), user=user, request=request)
return {"ok": True, "id": app_["id"]}
@router.get("/jobs/{job_id}/applications", summary="岗位投递列表(发布者/企业/运营端)")
async def hall_job_applications(
job_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("enterprise", "operator", "carrier")),
):
job = await db.hall_jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
if job.get("publisherId") != user.get("id") and "operator" not in (user.get("capabilities") or []):
raise HTTPException(status_code=403, detail="仅岗位发布方或运营端可查看")
return {"items": await db.hall_job_apps.list_for_job(job_id)}
# ===========================================================================
# 服务大厅(OPC 个人服务)
# ===========================================================================
@router.get("/services", summary="服务大厅列表(published;可按 OPC 过滤)")
async def hall_services(
category: str = "",
q: str = "",
opc_id: str = "",
db: Database = Depends(get_db),
):
return {"items": await db.hall_services.list(status="published", category=category,
q=q, opc_id=opc_id)}
@router.get("/services/mine", summary="我的服务(发布者视角,含审核状态)")
async def hall_my_services(
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
return {"items": await db.hall_services.list(status="", opc_id=user["id"])}
@router.get("/services/{service_id}", summary="服务详情(含作品集与 OPC 档案)")
async def hall_service_detail(service_id: str, db: Database = Depends(get_db)):
item = await db.hall_services.get(service_id)
if item is None or item.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
if item.get("opcId"):
item["talent"] = await db.hall_talents.get(item["opcId"])
return item
@router.post("/services", summary="发布服务(opc_member;分类限服务类;operator 直接上架)")
async def hall_publish_service(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写服务名称")
status = _publish_status(user)
svc = await db.hall_services.create(
opc_id=user["id"],
opc_name=user.get("nickname") or user.get("username", ""),
title=title,
category=str(body.get("category", "")).strip(),
description=str(body.get("description", "")),
cover=str(body.get("cover", "")),
gallery=[str(x) for x in (body.get("gallery") or []) if str(x).strip()],
price=int(body.get("price") or 0),
delivery_days=int(body.get("deliveryDays") or 0),
tags=str(body.get("tags", "")),
status=status,
)
await write_audit(db, action="hall.service.publish", resource="opc_service",
resource_id=svc["id"], detail=title, user=user, request=request)
return {"ok": True, "id": svc["id"], "status": status}
@router.put("/services/{service_id}", summary="编辑/上下架我的服务(发布者或运营端)")
async def hall_update_service(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None:
raise HTTPException(status_code=404, detail="服务不存在")
caps = user.get("capabilities") or []
if svc.get("opcId") != user.get("id") and "operator" not in caps:
raise HTTPException(status_code=403, detail="仅服务发布者或运营端可编辑")
allowed = ("title", "category", "description", "cover", "price", "delivery_days", "tags", "status")
patch = {}
for k in allowed:
camel = "".join(p.capitalize() for p in k.split("_"))
if k in body:
patch[k] = body[k]
elif camel in body:
patch[k] = body[camel]
if "status" in patch and patch["status"] == "published" and "operator" not in caps:
patch["status"] = "pending" # 编辑后回审核
updated = await db.hall_services.update(service_id, patch)
await write_audit(db, action="hall.service.update", resource="opc_service",
resource_id=service_id, user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/services/{service_id}/inquiry", summary="服务咨询/下单意向")
async def hall_service_inquiry(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None or svc.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
inq = await db.hall_inquiries.create(
service_id, user["id"], user.get("nickname") or user.get("username", ""),
contact=str(body.get("contact", "")), note=str(body.get("note", "")))
await write_audit(db, action="hall.service.inquiry", resource="opc_service",
resource_id=service_id, user=user, request=request)
return {"ok": True, "id": inq["id"]}
# ===========================================================================
# 社区(线上帖子 + 实体载体)
# ===========================================================================
community_router = APIRouter(prefix="/community", tags=["community"])
@community_router.get("/overview", summary="社区 Hub(最新帖子 + 载体推荐)")
async def community_overview(db: Database = Depends(get_db)):
posts = (await db.community_posts.list(status="published"))[:5]
tenants = (await db.session.execute(
select(ParkTenant).where(ParkTenant.status == "active").limit(5))).scalars().all()
carriers = [{"id": t.id, "name": t.name, "city": t.city, "cover": t.cover, "introHtml": t.intro_html[:120]}
for t in tenants]
return {"posts": posts, "carriers": carriers,
"topics": ["交流", "求助", "资源", "经验", "公告"]}
@community_router.get("/posts", summary="帖子流(published;按话题/关键字筛选)")
async def community_posts(topic: str = "", q: str = "", db: Database = Depends(get_db)):
return {"items": await db.community_posts.list(status="published", topic=topic, q=q)}
@community_router.get("/posts/mine", summary="我的帖子(含审核状态)")
async def community_my_posts(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
return {"items": await db.community_posts.list(status="", author_id=user["id"])}
@community_router.get("/posts/{post_id}", summary="帖子详情(正文 + 评论树)")
async def community_post_detail(post_id: str, db: Database = Depends(get_db)):
post = await db.community_posts.get(post_id)
if post is None or post.get("status") != "published":
raise HTTPException(status_code=404, detail="帖子不存在")
comments = await db.community_comments.list_for_post(post_id)
return {"post": post, "comments": comments}
@community_router.post("/posts", summary="发帖(登录用户;admin 可后台治理)")
async def community_create_post(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
title = str(body.get("title", "")).strip()
content = str(body.get("content", "")).strip()
if not title or not content:
raise HTTPException(status_code=400, detail="请填写标题与正文")
post = await db.community_posts.create(
author_id=user["id"],
author_name=user.get("nickname") or user.get("username", ""),
avatar=user.get("avatar", ""),
topic=str(body.get("topic", "交流")).strip() or "交流",
title=title, content=content,
images=[str(x) for x in (body.get("images") or []) if str(x).strip()],
status="published",
)
await write_audit(db, action="community.post.create", resource="community_post",
resource_id=post["id"], user=user, request=request)
return {"ok": True, "id": post["id"], "item": post}
@community_router.post("/posts/{post_id}/like", summary="点赞/取消点赞帖子")
async def community_like_post(
post_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
post = await db.community_posts.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="帖子不存在")
if await db.community_likes.has("post", post_id, user["id"]):
await db.community_likes.remove("post", post_id, user["id"])
await db.community_posts.inc_likes(post_id, -1)
return {"ok": True, "liked": False}
await db.community_likes.add("post", post_id, user["id"])
await db.community_posts.inc_likes(post_id, 1)
return {"ok": True, "liked": True}
@community_router.post("/posts/{post_id}/comments", summary="评论帖子(支持楼中楼)")
async def community_comment_post(
post_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
post = await db.community_posts.get(post_id)
if post is None or post.get("status") != "published":
raise HTTPException(status_code=404, detail="帖子不存在")
content = str(body.get("content", "")).strip()
if not content:
raise HTTPException(status_code=400, detail="请填写评论内容")
cmt = await db.community_comments.create(
post_id, user["id"], user.get("nickname") or user.get("username", ""),
content, parent_id=str(body.get("parentId", "")))
await db.community_posts.inc_comments(post_id, 1)
await write_audit(db, action="community.comment", resource="community_post",
resource_id=post_id, user=user, request=request)
return {"ok": True, "comment": cmt}
@community_router.get("/carriers", summary="实体社区载体列表(云南省内创业载体,公开)")
async def community_carriers(region: str = "", q: str = "", db: Database = Depends(get_db)):
from sqlalchemy import select
from ...infrastructure.models import ParkCompany, ParkTenant
rows = (await db.session.execute(
select(ParkTenant).where(ParkTenant.status == "active"))).scalars().all()
items = []
for t in rows:
if region and (t.city or "") != region:
continue
if q and q.lower() not in (t.name or "").lower():
continue
companies = (await db.session.execute(
select(ParkCompany).where(ParkCompany.tenant_id == t.id))).scalars().all()
items.append({
"id": t.id, "name": t.name, "city": t.city, "cover": t.cover,
"address": t.address, "contact": t.contact, "tags": t.tags,
"areaM2": t.area_m2, "stats": t.stats_json,
"companyCount": len(companies),
})
return {"items": items}
@community_router.get("/carriers/{carrier_id}", summary="载体详情(实体社区)")
async def community_carrier_detail(carrier_id: str, db: Database = Depends(get_db)):
from sqlalchemy import select
from ...infrastructure.models import ParkCompany, ParkTenant
t = await db.session.get(ParkTenant, carrier_id)
if t is None or t.status != "active":
raise HTTPException(status_code=404, detail="载体不存在")
companies = (await db.session.execute(
select(ParkCompany).where(ParkCompany.tenant_id == t.id)
.order_by(ParkCompany.created_at.desc()))).scalars().all()
return {
"id": t.id, "name": t.name, "city": t.city, "cover": t.cover,
"lng": t.lng, "lat": t.lat, "address": t.address, "contact": t.contact,
"tags": t.tags, "areaM2": t.area_m2,
"introHtml": t.intro_html, "policyHtml": t.policy_html, "admissionHtml": t.admission_html,
"services": json.loads(t.services_json or "[]"),
"stats": t.stats_json,
"companies": [{"id": c.id, "name": c.name, "industry": c.industry, "bio": c.bio}
for c in companies[:60]],
"companyCount": len(companies),
"followerCount": await db.community_follows.count("carrier", t.id),
}
@community_router.post("/carriers/{carrier_id}/follow", summary="关注/取消关注载体")
async def community_follow_carrier(
carrier_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
t = await db.session.get(ParkTenant, carrier_id)
if t is None:
raise HTTPException(status_code=404, detail="载体不存在")
res = await db.community_follows.toggle(user["id"], "carrier", carrier_id)
return {"ok": True, **res}
@community_router.post("/carriers/{carrier_id}/join", summary="申请入驻载体(复用入驻审核流)")
async def community_join_carrier(
carrier_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
from ...infrastructure.models import ParkTenant
t = await db.session.get(ParkTenant, carrier_id)
if t is None or t.status != "active":
raise HTTPException(status_code=404, detail="载体不存在")
adm = await db.park_admissions.create(
tenant_id=t.id, tenant_name=t.name, user_id=user["id"],
username=user.get("username", ""),
applicant_name=str(body.get("name", "")).strip() or user.get("nickname", ""),
contact_phone=str(body.get("phone", "")).strip(),
form_json=json.dumps(body.get("form") or {}, ensure_ascii=False),
source="community")
await write_audit(db, action="community.carrier.join", resource="park_admission",
resource_id=adm["id"], user=user, request=request)
return {"ok": True, "id": adm["id"], "status": adm["status"]}
# ===========================================================================
# 服务订单(预约/下单 → 接单 → 交付 → 完成,0036)
# ===========================================================================
async def _send_user_sms(db, user_id: str, scene: str, params: dict[str, str]) -> None:
"""向指定用户手机号发送场景短信;无手机号/发送失败均不影响主业务。"""
if not user_id:
return
try:
u = await db.users.get_by_id(user_id)
except Exception:
u = None
phone = str((u or {}).get("phone") or "").strip()
if not phone:
return
try:
from ...services import sms
cfg = await sms.load_sms_config(db)
await sms.send_template(cfg, scene, phone, params)
except Exception as exc: # 短信失败不阻断业务
print(f"[sms] {scene} -> {phone} 发送失败(已忽略): {exc}")
@router.post("/services/{service_id}/order", summary="预约/下单服务(buyer")
async def hall_order_service(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None or svc.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
if svc.get("opcId") == user.get("id"):
raise HTTPException(status_code=400, detail="不能下单自己的服务")
contact = str(body.get("contact", "")).strip()
if not contact:
raise HTTPException(status_code=400, detail="请填写联系方式")
order = await db.service_orders.create(
service_id=service_id, opc_id=svc.get("opcId"), buyer_id=user["id"],
buyer_name=user.get("nickname") or user.get("username", ""),
contact=contact, schedule_at=str(body.get("scheduleAt", "")).strip(),
note=str(body.get("note", "")), price=int(body.get("price") or 0))
await write_audit(db, action="hall.service.order", resource="service_order",
resource_id=order["id"], user=user, request=request)
# 服务预约短信 → 服务方(appointment 场景,模板变量见 system_configs
await _send_user_sms(db, svc.get("opcId"), "appointment", {
"service_name": svc.get("title") or "",
"schedule_at": str(body.get("scheduleAt", "") or ""),
"contact": contact,
})
return {"ok": True, "id": order["id"], "status": order["status"], "item": order}
@router.get("/orders/bought", summary="我下单的服务订单(buyer")
async def hall_orders_bought(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
return {"items": await db.service_orders.list(buyer_id=user["id"])}
@router.get("/orders/selling", summary="我收到的服务订单(服务方)")
async def hall_orders_selling(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
return {"items": await db.service_orders.list(opc_id=user["id"])}
@router.post("/orders/{order_id}/{action}", summary="订单流转(accept/reject/start/deliver/complete/cancel")
async def hall_order_transition(
order_id: str,
action: str,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
order = await db.service_orders.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="订单不存在")
is_seller = order.get("opcId") == user.get("id")
is_buyer = order.get("buyerId") == user.get("id")
is_operator = "operator" in (user.get("capabilities") or [])
if not (is_seller or is_buyer or is_operator):
raise HTTPException(status_code=403, detail="无权操作该订单")
# 权限约束:accept/reject/start/deliver 仅服务方(或运营);cancel 买卖双方皆可;complete 仅买方
seller_only = {"accept", "reject", "start", "deliver"}
buyer_only = {"complete"}
if action in seller_only and not (is_seller or is_operator):
raise HTTPException(status_code=403, detail="仅服务方可操作")
if action in buyer_only and not (is_buyer or is_operator):
raise HTTPException(status_code=403, detail="仅买方可确认完成")
if action == "cancel" and not (is_seller or is_buyer or is_operator):
raise HTTPException(status_code=403, detail="无权取消")
try:
updated = await db.service_orders.transition(order_id, action)
except KeyError:
raise HTTPException(status_code=404, detail="订单不存在")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await write_audit(db, action=f"hall.order.{action}", resource="service_order",
resource_id=order_id, user=user, request=request)
# 订单状态变动短信 → 交易对手方(order_status 场景)
status_map = {
"accept": "已接单", "reject": "已拒绝", "start": "进行中",
"deliver": "已交付", "complete": "已完成", "cancel": "已取消",
}
target = order.get("buyerId") if is_seller else order.get("opcId")
if action in ("complete", "cancel"):
target = order.get("opcId") if is_buyer else order.get("buyerId")
await _send_user_sms(db, target, "order_status", {
"order_no": order.get("id") or "",
"status": status_map.get(action, action),
})
return {"ok": True, "item": updated}
# ===========================================================================
# 发包方任务操作(定标 / 指派 / 报名选定 / 取消,0036)
# ===========================================================================
def _ensure_task_owner(task: dict, user: dict) -> None:
caps = user.get("capabilities") or []
if task.get("publisher_id") != user.get("id") and "operator" not in caps:
raise HTTPException(status_code=403, detail="仅发包方或运营端可操作")
@router.post("/tasks/{task_id}/win", summary="评标定标(发包方/运营端;bid → win,任务 → in_progress")
async def hall_task_win(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).win_bid(task_id, str(body.get("bidId", "")))
await write_audit(db, action="hall.task.win", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/assign", summary="指派接单人(发包方/运营端;assign 模式)")
async def hall_task_assign(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).assign(task_id, str(body.get("userId", "")), user)
await write_audit(db, action="hall.task.assign", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/select", summary="报名选定(发包方/运营端;register 模式)")
async def hall_task_select(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).register_select(task_id, str(body.get("claimId", "")), user)
await write_audit(db, action="hall.task.select", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/cancel", summary="取消任务(发包方/运营端;未接单可取消)")
async def hall_task_cancel(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
if task.get("status") != "published":
raise HTTPException(status_code=400, detail="任务已被接单,不可取消")
updated = await db.tasks.set_status(task_id, "cancelled")
await write_audit(db, action="hall.task.cancel", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
# ── 任务统一合并到大厅:OPC 接单侧操作(原 /opc/tasks/*2026-09 增量合并) ──
@router.post("/tasks/{task_id}/grab", summary="抢单(grab 模式,先到先得)")
async def hall_task_grab(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).grab(task_id, user)
await write_audit(db, action="hall.task.grab", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/grab-by-code", summary="扫码/按短码接单")
async def hall_task_grab_by_code(
req: TaskClaimRequest,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).claim(
(req.task_code or "").strip(), user, source="scan",
)
await write_audit(db, action="hall.task.claim", resource="task",
resource_id=updated["id"], user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/bid", summary="投标(bid 模式,报价+方案)")
async def hall_task_bid(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
bid = await TaskService(db).bid(task_id, user,
int(body.get("quote") or 0),
str(body.get("plan") or ""))
await write_audit(db, action="hall.task.bid", resource="bid", resource_id=bid["id"],
user=user, request=request)
return {"ok": True, "item": bid}
@router.post("/tasks/{task_id}/doing", summary="开始做单")
async def hall_task_doing(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).start_doing(task_id, user)
await write_audit(db, action="hall.task.doing", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/complete", summary="完成做单")
async def hall_task_complete(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).complete(task_id, user)
await write_audit(db, action="hall.task.complete", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/deliver", summary="交付成果")
async def hall_task_deliver(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).deliver(task_id, user)
await write_audit(db, action="hall.task.deliver", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/recommend", summary="推荐候选人(发包方/运营端)")
async def hall_task_recommend(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
records = await TaskService(db).recommend(task_id, body.get("candidates") or [], user)
await write_audit(db, action="hall.task.recommend", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "items": records}
# ===========================================================================
# 站内会话(点对点私信,0041
# ===========================================================================
dm_router = APIRouter(prefix="/dm", tags=["dm"])
@dm_router.get("/conversations", summary="我的会话列表(按对方聚合,含未读数)")
async def dm_conversations(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
convs = await db.dm.conversations(user["id"])
# 补对方展示信息
out = []
for c in convs:
peer = await db.users.get_by_id(c["peerId"]) or {}
talent = await db.hall_talents.get(c["peerId"])
out.append({**c, "peerName": peer.get("nickname") or peer.get("username", ""),
"peerAvatar": peer.get("avatar", ""),
"peerHeadline": (talent or {}).get("headline", "")})
return {"items": out}
@dm_router.get("/messages", summary="与某人的消息记录(进入会话即标记已读)")
async def dm_messages(peer: str, db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = await db.dm.thread(user["id"], peer)
await db.dm.mark_read(user["id"], peer)
peer = await db.users.get_by_id(peer) or {}
return {"items": items, "peerName": peer.get("nickname") or peer.get("username", ""),
"peerAvatar": peer.get("avatar", "")}
@dm_router.post("/messages", summary="发送私信")
async def dm_send(body: dict, request: Request, db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
peer = str(body.get("peerId", "")).strip()
content = str(body.get("content", "")).strip()
if not peer or not content:
raise HTTPException(status_code=400, detail="请填写接收人与内容")
if peer == user.get("id"):
raise HTTPException(status_code=400, detail="不能给自己发消息")
target = await db.users.get_by_id(peer)
if target is None:
raise HTTPException(status_code=404, detail="接收人不存在")
msg = await db.dm.send(user["id"], peer, content)
await write_audit(db, action="dm.send", resource="dm_message", resource_id=msg["id"],
detail=f"to={peer}", user=user, request=request)
return {"ok": True, "message": msg}
# ===========================================================================
# 我发布的(发布者视角,0041
# ===========================================================================
# 注:/tasks/mine 和 /jobs/mine 已移至参数化路由之前注册,避免被 {task_id}/{job_id} 抢先匹配