5328835dbe
submit_for_review(request: Request) 未导入 Request,注解求值失败被 FastAPI 当作必填 query 参数(loc: query.request)→ 422。补上导入后 request 正确注入,submit 走正常审核流程
578 lines
28 KiB
Python
578 lines
28 KiB
Python
"""个人主页(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, Request
|
||
from sqlalchemy import func, select
|
||
|
||
from ..dependencies import Database, get_current_user, get_db, optional_current_user
|
||
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 ease,hover轻微上浮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(request: Request, 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(),
|
||
"ip": request.client.host if request.client else ""})
|
||
# 写审核日志:用户提交
|
||
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,
|
||
"social": {
|
||
"viewCount": await db.user_page_social.count_views(user_id),
|
||
"likeCount": await db.user_page_social.count_likes(user_id),
|
||
"greetCount": await db.user_page_social.count_greets(user_id),
|
||
"likedByMe": await db.user_page_social.liked_by(user_id, viewer.get("id") or ""),
|
||
},
|
||
}
|
||
|
||
# 未设置个人主页 / 他人访问未上架页 → 平台默认名片
|
||
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},
|
||
"social": {
|
||
"viewCount": await db.user_page_social.count_views(user_id),
|
||
"likeCount": await db.user_page_social.count_likes(user_id),
|
||
"greetCount": await db.user_page_social.count_greets(user_id),
|
||
"likedByMe": await db.user_page_social.liked_by(user_id, viewer.get("id") or ""),
|
||
},
|
||
}
|
||
|
||
|
||
# ── 运营端:审核 ────────────────────────────────────────────────────────
|
||
@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}
|
||
|
||
|
||
# ── 个人主页社交互动:流量 / 点赞 / 打招呼 ──────────────────────────────────
|
||
@router.post("/{user_id}/view", summary="记录一次主页访问(流量+1,当日同访客去重)")
|
||
async def record_page_view(user_id: str, db: Database = Depends(get_db),
|
||
viewer: dict = Depends(optional_current_user)):
|
||
"""公开访问主页时上报流量。匿名访问以 visitor_id="" 记录(不去重)。"""
|
||
target = (await db.users.get_by_id(user_id)) or {}
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
visitor_id = (viewer or {}).get("id") or ""
|
||
count = await db.user_page_social.add_view(user_id, visitor_id)
|
||
return {"ok": True, "viewCount": count}
|
||
|
||
|
||
@router.post("/{user_id}/like", summary="点赞/取消点赞个人主页(一人一赞)")
|
||
async def toggle_page_like(user_id: str, db: Database = Depends(get_db),
|
||
liker: dict = Depends(get_current_user)):
|
||
target = (await db.users.get_by_id(user_id)) or {}
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
try:
|
||
result = await db.user_page_social.toggle_like(user_id, liker.get("id") or "")
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
return {"ok": True, **result}
|
||
|
||
|
||
@router.post("/{user_id}/greet", summary="对个人主页打招呼(say hi)")
|
||
async def greet_user_page(user_id: str, body: dict = Body(default={}),
|
||
db: Database = Depends(get_db),
|
||
greeter: dict = Depends(get_current_user)):
|
||
target = (await db.users.get_by_id(user_id)) or {}
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
content = str(body.get("content") or "").strip()
|
||
try:
|
||
result = await db.user_page_social.add_greet(user_id, greeter.get("id") or "", content)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
return {"ok": True, **result}
|
||
|
||
|
||
@router.get("/me/social/stats", summary="我的主页互动数据(本人查看:流量/点赞/打招呼)")
|
||
async def my_page_social_stats(db: Database = Depends(get_db),
|
||
user: dict = Depends(get_current_user)):
|
||
"""本人查看自己主页的流量、点赞、打招呼数据。"""
|
||
uid = user.get("id") or ""
|
||
stats = await db.user_page_social.stats(uid)
|
||
greets = await db.user_page_social.recent_greets(uid)
|
||
likers = await db.user_page_social.recent_likers(uid)
|
||
# 补充打招呼/点赞访客的昵称与头像(供「我的沟通」展示访客并一键发起沟通)
|
||
async def _decorate(items: list[dict]) -> list[dict]:
|
||
out = []
|
||
for it in items:
|
||
pid = it.get("greeterId") or it.get("likerId") or ""
|
||
u = (await db.users.get_by_id(pid)) if pid else None
|
||
out.append({
|
||
**it,
|
||
"nickname": (u or {}).get("nickname") or (u or {}).get("username") or "匿名访客",
|
||
"avatar": resolve_url((u or {}).get("avatar") or ""),
|
||
})
|
||
return out
|
||
return {
|
||
"stats": stats,
|
||
"trend": await db.user_page_social.view_trend(uid, 7),
|
||
"recentViews": await db.user_page_social.recent_views(uid),
|
||
"recentLikers": await _decorate(likers),
|
||
"recentGreets": await _decorate(greets),
|
||
}
|