Files
server-core/app/api/routers/rbac_pages.py
T

495 lines
23 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.
"""个人主页(OPC 公开名片页)接口。
- GET /pages/{user_id} 公开渲染数据:页面内容 + 身份条 + 接入的公开能力数据
- GET /pages/me 我的个人主页(含审核状态)
- PUT /pages/me 保存个人主页(HTML 上传 OSS / URL 模式强制无能力)
- POST /pages/me/submit 提交审核
- POST /pages/me/publish 上架/下架(仅审核通过可上架)
- GET /admin/pages/review 运营端:待审核列表
- POST /admin/pages/{user_id}/review 运营端:审核通过/拒绝
"""
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import func, select
from ..dependencies import Database, get_current_user, get_db
from ...infrastructure.models import MarketItem, TrainingCertificate
from ...infrastructure.oss import oss, resolve_url
from ...infrastructure.repositories import utcnow_iso
router = APIRouter(prefix="/pages", tags=["pages"])
_MAX_HTML = 2 * 1024 * 1024 # 自写 HTML 上限 2MB
_ALLOWED_CAPS = {"orders", "agents_dev", "agents_use", "certs"}
# ── 开发提示词(供 AI 智能体生成个人主页 HTML 时使用)──────────────────────
_DEV_PROMPT = """# OPC 个人主页 HTML 开发规范
你是一个 OPC 个人主页开发者。请根据用户描述,生成一个**完整的单文件 HTML 页面**。
## 〇、页面定位(最重要)
**这是一个公开展示页面,面向其他用户浏览。**
- 页面的访问者是其他用户,不是主页主人本人。
- **绝对不允许开发任何编辑、修改、保存、提交审核等功能。** 编辑个人主页在平台桌面端的编辑弹窗中完成,不在此页面内。
- 页面只做一件事:**展示**——展示该用户的个人信息、服务、产品、证书、勋章等公开内容。
- 页面内所有数据均为**只读**,只能通过 postMessage 获取平台公开数据,不能修改任何数据。
## 一、硬性约束
1. **单文件**:所有 HTML、CSS、JavaScript 必须内联在一个文件中,不引用外部 CSS/JS 文件(外部图片、字体 CDN 允许)。
2. **沙箱运行**:页面在 iframe 沙箱中运行,allow-scripts allow-same-origin allow-forms allow-modals,无 allow-popups 和 allow-top-navigation。
3. **内部跳转仅限 iframe 内**:所有链接、表单提交、JS 跳转都只在 iframe 内部生效,不会影响外部页面。推荐使用 hash 路由实现多页切换。
4. **大小上限 2MB**。
5. **禁止**window.open、window.parent、window.top、target=_blank、location.href 跳转外部、编辑/保存/提交类功能。
6. **响应式**:页面宽度自适应,最小支持 320px。禁止固定宽度导致横向滚动。
7. **平台已注入固定页眉**:顶部有平台固定的身份条(头像、昵称、信用、徽章)和右上角胶囊按钮(更多+退出),禁止重复绘制页眉或顶部导航栏,你的内容从页眉下方开始。
## 二、平台数据接入(postMessage 桥,只读公开数据)
页面加载后,外部框架会通过 postMessage 主动推送一次身份数据。页面也可主动请求。
### 2.1 接收数据
window.addEventListener("message", function(e) {
if (e.data && e.data.__opcBridge && e.data.type === "opc:page:init") {
var data = e.data.data;
}
});
### 2.2 主动请求数据
window.parent.postMessage({__opcBridge: true, type: "opc:page:get-me"}, "*");
### 2.2.1 获取来访者信息
页面可获取当前浏览者(来访者)的公开信息,用于个性化展示(如欢迎语)。
注意:仅返回昵称、头像、简介,不包含用户ID、手机号等敏感信息;未登录时返回 null。
// 主动请求
window.parent.postMessage({__opcBridge: true, type: "opc:page:get-visitor"}, "*");
// 接收回复
window.addEventListener("message", function(e) {
if (e.data && e.data.__opcBridge && e.data.type === "opc:page:visitor") {
var visitor = e.data.data; // {nickname, avatar, intro} 或 null(未登录)
}
});
// 也可直接从 opc:page:init 的 data.visitor 字段获取
### 2.3 数据结构
{
userId: string,
nickname: string,
avatar: string,
creditScore: number|null,
badges: [{code, name, desc, icon, color}],
pageType: "html"|"embed",
capabilities: [{key, name, desc}],
capabilityData: {
orders?: [{id, serviceId, buyerName, price, status, createdAt}],
certs?: [{id, certType, level, approvedAt}],
agents_dev?: [],
agents_use?: []
}
}
### 2.4 徽章 code 枚举
opc_certified(OPC认证)、opc_member(成员)、credit_gold(黄金信用>=100)、credit_silver(白银>=90)、credit_bronze(青铜>=80)、credit_rising(成长中<80)、cert_holder(持证学员)、talent_on_shelf(人才在架)、creator(内容创作者)
## 三、平台动作(postMessage 调用,由访问者点击触发)
仅限以下展示类动作,禁止任何修改类动作:
### 3.1 打开与该用户的沟通
window.parent.postMessage({__opcBridge:true, type:"opc:action:open-chat"}, "*");
### 3.2 分享个人主页
window.parent.postMessage({__opcBridge:true, type:"opc:action:share"}, "*");
### 3.3 复制主页链接
window.parent.postMessage({__opcBridge:true, type:"opc:action:copy-link"}, "*");
### 3.4 跳转服务/产品页面
window.parent.postMessage({__opcBridge:true, type:"opc:action:navigate-service", payload:{jumpUrl:"地址"}}, "*");
注意:所有跳转必须通过 postMessage 桥接,禁止使用 window.location.href。
## 四、设计风格建议
1. 毛玻璃卡片:background:rgba(255,255,255,0.72); backdrop-filter:blur(20px); border-radius:16px
2. 渐变强调色:linear-gradient(135deg, #667eea, #764ba2)
3. 圆角:卡片16px,按钮12px,标签8px
4. 间距:8px倍数(8/16/24/32
5. 字体:-apple-system, BlinkMacSystemFont, PingFang SC, Segoe UI, sans-serif
6. 动效:过渡0.3s easehover轻微上浮translateY(-2px)
7. 空状态:数据为空时展示友好的空状态文字,不要留空白
## 五、页面结构建议
推荐包含以下区块(按需取舍):
1. Hero区:一句话简介、个人标签(平台页眉已展示头像/昵称/徽章,不要重复)
2. 服务/产品展示:卡片网格展示该用户提供的服务和产品,产品卡片可点击跳转
3. 数据展示:订单数、证书、智能体等(需用户接入对应能力才展示)
4. 作品集:图片/项目展示
5. 联系区:发起沟通按钮(调用opc:action:open-chat)、分享、复制链接按钮
6. 底部:平台数据接入说明或免责声明(可选)
## 六、数据加载原则
- 按需请求:需要什么数据才发起postMessage请求,不需要的不请求
- 不硬编码:所有用户信息、服务、产品、证书必须通过桥接接口动态获取,禁止写死
- 异常处理:接口返回为空或失败时,展示友好的空状态,不要页面空白报错
- 消息过滤:postMessage接收要做类型判断,防止无效消息干扰
## 七、审核要求
- 不得包含违法、违规、欺诈内容
- 接入平台能力后必须真实展示对应数据,不得伪造
- 不得包含恶意脚本、挖矿、弹窗广告
- 不得包含任何编辑、保存、修改、提交类功能
- 不得篡改、隐藏平台页眉
- 违反规则将面临下架、永久关闭个人主页功能权限
---
请根据以上规范,生成一个完整、美观、纯展示的个人主页 HTML。所有 CSS 和 JS 内联,直接输出完整的 HTML 代码。
"""
@router.get("/dev-prompt", summary="个人主页开发提示词")
async def get_dev_prompt(db: Database = Depends(get_db)):
"""返回供 AI 智能体生成个人主页 HTML 的标准开发提示词。
优先读取 system_configs.user_page_dev_prompt,不存在则返回内置默认提示词。
"""
stored = await db.config.get("user_page_dev_prompt")
return {"prompt": stored or _DEV_PROMPT, "isDefault": not bool(stored)}
@router.put("/admin/dev-prompt", summary="运营端:编辑开发提示词")
async def update_dev_prompt(body: dict = Body(...), db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
prompt = str(body.get("prompt") or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="提示词不能为空")
await db.config.set("user_page_dev_prompt", prompt, "个人主页 HTML 开发提示词(供 AI 智能体使用)")
return {"ok": True}
# ── 徽章规则(派生计算,不落库)───────────────────────────────────────────
async def _compute_badges(db: Database, user_id: str) -> list[dict]:
badges: list[dict] = []
certs = await db.certifications.list(user_id=user_id, status="active")
if certs:
badges.append({"code": "opc_certified", "name": "OPC 认证",
"desc": "已完成 OPC 实名认证", "icon": "shield", "color": "#3b6fd4"})
else:
badges.append({"code": "opc_member", "name": "OPC 成员",
"desc": "平台注册成员", "icon": "user", "color": "#8a8f98"})
opc_profile = await db.opc_profiles.get(user_id)
credit = (opc_profile or {}).get("credit_score", 80) or 80
if credit >= 100:
tier = {"code": "credit_gold", "name": "黄金信用", "desc": f"信用分 {credit}(满分)", "icon": "crown", "color": "#d98a1f"}
elif credit >= 90:
tier = {"code": "credit_silver", "name": "白银信用", "desc": f"信用分 {credit}", "icon": "star", "color": "#7a8699"}
elif credit >= 80:
tier = {"code": "credit_bronze", "name": "青铜信用", "desc": f"信用分 {credit}", "icon": "medal", "color": "#b0794f"}
else:
tier = {"code": "credit_rising", "name": "信用成长中", "desc": f"信用分 {credit},持续履约可提升", "icon": "sprout", "color": "#2e9e63"}
badges.append(tier)
cert_count = (await db.session.scalar(
select(func.count()).select_from(TrainingCertificate).where(TrainingCertificate.user_id == user_id)
)) or 0
if cert_count > 0:
badges.append({"code": "cert_holder", "name": "持证学员", "desc": f"已获得 {cert_count} 份结业证书",
"icon": "certificate", "color": "#2e9e63"})
talent = await db.hall_talents.get(user_id)
if talent and talent.get("published"):
badges.append({"code": "talent_on_shelf", "name": "人才在架", "desc": "人才市场公开名片已上架",
"icon": "briefcase", "color": "#8b5cf6"})
item_count = (await db.session.scalar(
select(func.count()).select_from(MarketItem).where(MarketItem.owner == user_id)
)) or 0
if item_count > 0:
badges.append({"code": "creator", "name": "内容创作者", "desc": f"已发布 {item_count} 个应用/技能",
"icon": "code", "color": "#3b6fd4"})
return badges
def _public_profile(user: dict, badges: list[dict], credit: int | None) -> dict:
return {
"nickname": user.get("nickname") or user.get("username") or "OPC 用户",
"avatar": resolve_url(user.get("avatar") or ""),
"creditScore": credit,
"badges": badges,
}
# ── 接入能力的公开数据(用户选择接入即代表公开,无需权限)──────────────────
async def _fetch_capability_data(db: Database, user_id: str, capabilities: list[dict]) -> dict:
"""根据用户声明接入的能力,返回对应的公开数据。"""
data: dict = {}
keys = {c.get("key") for c in capabilities if isinstance(c, dict)}
if "orders" in keys:
try:
all_orders = await db.service_orders.list(opc_id=user_id)
data["orders"] = [
{
"id": o.get("id"), "serviceId": o.get("serviceId"),
"buyerName": o.get("buyerName"), "price": o.get("price"),
"status": o.get("status"), "createdAt": o.get("createdAt"),
}
for o in all_orders if o.get("status") == "completed"
][:50]
except Exception:
data["orders"] = []
if "certs" in keys:
try:
certs = await db.certifications.list(user_id=user_id, status="active")
data["certs"] = [
{"id": c.get("id"), "certType": c.get("cert_type"),
"level": c.get("level"), "approvedAt": c.get("approved_at")}
for c in certs
]
except Exception:
data["certs"] = []
if "agents_dev" in keys:
data["agents_dev"] = []
if "agents_use" in keys:
data["agents_use"] = []
return data
# ── HTML 内容:优先从 OSS 读取,兼容旧数据 html_content ────────────────────
async def _load_html_content(page: dict) -> str:
html_url = page.get("htmlUrl") or ""
if html_url:
try:
key = html_url[len("/oss/"):] if html_url.startswith("/oss/") else html_url
raw = await oss.download(key)
if raw:
return raw.decode("utf-8", errors="replace")
except Exception:
pass
return page.get("htmlContent") or ""
# ── 我的个人主页(字面路径必须先于 /{user_id} 注册)────────────────────────
@router.get("/me", summary="我的个人主页")
async def get_my_page(db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
page = await db.user_pages.get(uid)
return {"page": page, "profile": _public_profile(user, await _compute_badges(db, uid), None)}
@router.put("/me", summary="保存我的个人主页")
async def save_my_page(body: dict = Body(...), db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
page_type = str(body.get("pageType") or "html").strip()
if page_type not in ("html", "embed"):
raise HTTPException(status_code=400, detail="pageType 仅支持 html 或 embed")
patch: dict = {"page_type": page_type, "review_status": "draft"}
if page_type == "html":
html = str(body.get("htmlContent") or "")
if len(html.encode("utf-8")) > _MAX_HTML:
raise HTTPException(status_code=400, detail="HTML 内容超过 2MB 上限")
# HTML 上传到 OSS,数据库存相对路径
key = f"user_pages/{uid}.html"
try:
html_url = await oss.upload(key, html.encode("utf-8"), content_type="text/html; charset=utf-8")
except Exception as e:
raise HTTPException(status_code=500, detail=f"HTML 存储失败: {e}")
patch["html_url"] = html_url
patch["html_content"] = html # 兼容旧字段
patch["embed_url"] = ""
# 能力选择(仅 HTML 模式允许)
caps = body.get("capabilities") or []
if not isinstance(caps, list):
raise HTTPException(status_code=400, detail="capabilities 需为数组")
clean = [{"key": str(c.get("key", "")), "name": str(c.get("name", "")),
"desc": str(c.get("desc", "")), "scope": str(c.get("scope", ""))}
for c in caps if isinstance(c, dict) and c.get("key") in _ALLOWED_CAPS]
patch["capabilities"] = clean
else:
# embed 模式:强制无平台能力接入(第三方网页无法接入我们的能力)
url = str(body.get("embedUrl") or "").strip()
if not url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="embedUrl 需为 http(s) 地址")
patch["embed_url"] = url
patch["html_url"] = ""
patch["html_content"] = ""
patch["capabilities"] = []
# 修改后自动下架(需重新审核)
patch["published"] = False
page = await db.user_pages.upsert(uid, patch)
return {"ok": True, "page": page}
@router.post("/me/submit", summary="提交审核")
async def submit_for_review(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
page = await db.user_pages.get(uid)
if not page:
raise HTTPException(status_code=404, detail="请先创建个人主页")
if page.get("pageType") == "embed" and not page.get("embedUrl"):
raise HTTPException(status_code=400, detail="请先填写第三方网页地址")
if page.get("pageType") == "html" and not (page.get("htmlUrl") or page.get("htmlContent")):
raise HTTPException(status_code=400, detail="请先编写 HTML 内容")
page = await db.user_pages.upsert(uid, {"review_status": "pending", "submitted_at": utcnow_iso()})
# 写审核日志:用户提交
await db.user_pages.add_log(
uid, "submit", uid,
operator_name=user.get("nickname") or user.get("username") or "",
note="用户提交个人主页审核",
)
return {"ok": True, "page": page, "reviewStatus": "pending"}
@router.post("/me/publish", summary="上架/下架个人主页")
async def publish_my_page(body: dict = Body(default={}), db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
uid = user.get("id") or ""
published = bool(body.get("published", True))
page = await db.user_pages.get(uid)
if page is None:
raise HTTPException(status_code=404, detail="请先创建个人主页再上架")
if published and page.get("reviewStatus") != "approved":
raise HTTPException(status_code=400, detail="个人主页需审核通过后才能上架")
page = await db.user_pages.set_published(uid, published)
return {"ok": True, "page": page, "published": published}
# ── 公开渲染数据(人才市场等入口跳转查看)───────────────────────────────
@router.get("/{user_id}", summary="获取用户个人主页(公开渲染数据)")
async def get_user_page(user_id: str, db: Database = Depends(get_db),
viewer: dict = Depends(get_current_user)):
page = await db.user_pages.get(user_id)
user = (await db.users.get_by_id(user_id)) or {}
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
is_self = (viewer.get("id") or "") == user_id
badges = await _compute_badges(db, user_id)
opc_profile = await db.opc_profiles.get(user_id)
credit = (opc_profile or {}).get("credit_score")
# 已上架(或本人自己的页)→ 渲染用户页面
if page and (page.get("published") or is_self):
capabilities = page.get("capabilities") or []
# embed 模式强制无能力
if page.get("pageType") == "embed":
capabilities = []
# 接入的公开能力数据(用户选择接入即代表公开)
capability_data = await _fetch_capability_data(db, user_id, capabilities)
html_content = await _load_html_content(page) if page.get("pageType") == "html" else ""
notice = {
"type": "connected" if capabilities else "unverified",
"capabilities": capabilities,
"text": ("本页面已接入平台数据能力,所展示数据为平台实时公开数据" if capabilities
else "本页面内容由用户自行编写,平台未对其中数据与信息进行核验,请注意核实"),
}
return {
"page": {
"pageType": page.get("pageType"),
"htmlContent": html_content,
"htmlUrl": page.get("htmlUrl"),
"embedUrl": page.get("embedUrl") if page.get("pageType") == "embed" else "",
"capabilities": capabilities,
"capabilityData": capability_data,
"reviewStatus": page.get("reviewStatus"),
"published": page.get("published"),
},
"profile": _public_profile(user, badges, credit),
"notice": notice,
"ownerIsSelf": is_self,
}
# 未设置个人主页 / 他人访问未上架页 → 平台默认名片
talent = await db.hall_talents.get(user_id)
services = await db.hall_services.list(status="published", opc_id=user_id)
notice = {
"type": "unverified", "capabilities": [],
"text": "该用户尚未自定义个人主页,以下内容为平台公开信息,请注意核实",
}
return {
"page": None,
"profile": _public_profile(user, badges, credit),
"notice": notice,
"ownerIsSelf": is_self,
"default": {"talent": talent, "services": services},
}
# ── 运营端:审核 ────────────────────────────────────────────────────────
@router.get("/admin/review/list", summary="运营端:个人主页列表(含HTML内容)")
async def review_list(status: str | None = None,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
pages = await db.user_pages.list_all(status)
result = []
for p in pages:
u = (await db.users.get_by_id(p["userId"])) or {}
# 加载真实 HTML 内容(从 OSS 读取,兼容旧数据 html_content 列)
html_content = await _load_html_content(p) if p.get("pageType") == "html" else ""
result.append({
**p,
"htmlContent": html_content,
"nickname": u.get("nickname") or u.get("username"),
"avatar": resolve_url(u.get("avatar") or ""),
})
return {"pages": result}
@router.post("/admin/{user_id}/review", summary="运营端:审核个人主页")
async def review_page(user_id: str, body: dict = Body(...),
db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
approved = bool(body.get("approved", False))
note = str(body.get("note") or "").strip()
# 驳回必须填写原因(敏感操作)
if not approved and not note:
raise HTTPException(status_code=400, detail="驳回必须填写原因")
patch = {
"review_status": "approved" if approved else "rejected",
"review_note": note,
"reviewed_at": utcnow_iso(),
}
# 拒绝时自动下架
if not approved:
patch["published"] = False
page = await db.user_pages.upsert(user_id, patch)
# 写审核日志
await db.user_pages.add_log(
user_id, "approve" if approved else "reject",
user.get("id") or "",
operator_name=user.get("nickname") or user.get("username") or "",
note=note or (approved and "审核通过") or "",
)
return {"ok": True, "page": page}
@router.get("/admin/{user_id}/logs", summary="运营端:个人主页审核日志")
async def review_logs(user_id: str, db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "operator"):
raise HTTPException(status_code=403, detail="无权限")
logs = await db.user_pages.list_logs(user_id)
return {"logs": logs}