feat(个人主页): 审核流程+OSS存储+能力数据公开+URL模式强制无能力
- 迁移0053: html_url/review_status/review_note/submitted_at/reviewed_at
- HTML保存时上传OSS,数据库存html_url;URL模式只存embed_url
- URL(embed)模式强制capabilities为空(第三方网页无法接入平台能力)
- HTML模式选择能力后警告:必须真实接入,否则下架+永久关闭功能
- 上架流程: 保存草稿→提交审核→审核通过→上架;修改已上架内容自动下架
- GET /pages/{user_id} 根据capabilities返回公开的能力数据(订单/证书)
- postMessage桥注入capabilityData给HTML
- 运营端审核接口: /pages/admin/review/list + /pages/admin/{user_id}/review
- OSS新增download方法
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""user_pages: html_url + 审核字段
|
||||
|
||||
Revision ID: 0053
|
||||
Revises: 0052
|
||||
Create Date: 2026-09-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0053"
|
||||
down_revision = "0052"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("user_pages", sa.Column("html_url", sa.String(length=512), nullable=True))
|
||||
op.add_column("user_pages", sa.Column("review_status", sa.String(length=32), nullable=False, server_default="draft"))
|
||||
op.add_column("user_pages", sa.Column("review_note", sa.Text(), nullable=True))
|
||||
op.add_column("user_pages", sa.Column("submitted_at", sa.String(length=64), nullable=True))
|
||||
op.add_column("user_pages", sa.Column("reviewed_at", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("user_pages", "reviewed_at")
|
||||
op.drop_column("user_pages", "submitted_at")
|
||||
op.drop_column("user_pages", "review_note")
|
||||
op.drop_column("user_pages", "review_status")
|
||||
op.drop_column("user_pages", "html_url")
|
||||
+183
-72
@@ -1,11 +1,12 @@
|
||||
"""个人主页(OPC 公开名片页)接口。
|
||||
|
||||
- GET /pages/{user_id} 公开渲染数据:页面内容 + 身份条(昵称/头像/信用/徽章)
|
||||
+ 能力接入说明(capabilities 非空 → connected 接入说明;空 → unverified 提醒访客核实)。
|
||||
未设置个人主页或他人访问未上架页 → 返回 page=null + default(人才档案 + 已上架服务)。
|
||||
- GET /pages/me 我的个人主页(含发布状态)
|
||||
- PUT /pages/me 保存个人主页(html 源码 或 embed 第三方网页 + 能力声明)
|
||||
- POST /pages/me/publish 上架/下架
|
||||
- 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
|
||||
|
||||
@@ -14,31 +15,26 @@ 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"}
|
||||
|
||||
|
||||
# ── 徽章规则(派生计算,不落库)───────────────────────────────────────────
|
||||
async def _compute_badges(db: Database, user_id: str) -> list[dict]:
|
||||
"""根据平台既有数据派生用户徽章集合(规则可扩展)。"""
|
||||
badges: list[dict] = []
|
||||
|
||||
# 1) OPC 认证徽章:存在 active 认证记录
|
||||
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",
|
||||
})
|
||||
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",
|
||||
})
|
||||
badges.append({"code": "opc_member", "name": "OPC 成员",
|
||||
"desc": "平台注册成员", "icon": "user", "color": "#8a8f98"})
|
||||
|
||||
# 2) 信用等级徽章(credit_score 分段)
|
||||
opc_profile = await db.opc_profiles.get(user_id)
|
||||
credit = (opc_profile or {}).get("credit_score", 80) or 80
|
||||
if credit >= 100:
|
||||
@@ -51,39 +47,28 @@ async def _compute_badges(db: Database, user_id: str) -> list[dict]:
|
||||
tier = {"code": "credit_rising", "name": "信用成长中", "desc": f"信用分 {credit},持续履约可提升", "icon": "sprout", "color": "#2e9e63"}
|
||||
badges.append(tier)
|
||||
|
||||
# 3) 持证徽章:结业证书
|
||||
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",
|
||||
})
|
||||
badges.append({"code": "cert_holder", "name": "持证学员", "desc": f"已获得 {cert_count} 份结业证书",
|
||||
"icon": "certificate", "color": "#2e9e63"})
|
||||
|
||||
# 4) 人才名片徽章:人才档案在架
|
||||
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",
|
||||
})
|
||||
badges.append({"code": "talent_on_shelf", "name": "人才在架", "desc": "人才市场公开名片已上架",
|
||||
"icon": "briefcase", "color": "#8b5cf6"})
|
||||
|
||||
# 5) 创作者徽章:发布过市场应用/技能/插件
|
||||
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",
|
||||
})
|
||||
|
||||
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:
|
||||
from ...infrastructure.oss import resolve_url
|
||||
return {
|
||||
"nickname": user.get("nickname") or user.get("username") or "OPC 用户",
|
||||
"avatar": resolve_url(user.get("avatar") or ""),
|
||||
@@ -92,6 +77,59 @@ def _public_profile(user: dict, badges: list[dict], credit: int | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── 接入能力的公开数据(用户选择接入即代表公开,无需权限)──────────────────
|
||||
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)):
|
||||
@@ -104,24 +142,80 @@ async def get_my_page(db: Database = Depends(get_db), user: dict = Depends(get_c
|
||||
async def save_my_page(body: dict = Body(...), db: Database = Depends(get_db),
|
||||
user: dict = Depends(get_current_user)):
|
||||
uid = user.get("id") or ""
|
||||
patch = _normalize_page_input(body)
|
||||
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()})
|
||||
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.set_published(uid, published)
|
||||
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="获取用户个人主页(公开渲染数据;未设置时返回默认名片数据)")
|
||||
@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)
|
||||
@@ -133,21 +227,30 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
|
||||
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
|
||||
"text": ("本页面已接入平台数据能力,所展示数据为平台实时公开数据" if capabilities
|
||||
else "本页面内容由用户自行编写,平台未对其中数据与信息进行核验,请注意核实"),
|
||||
}
|
||||
return {
|
||||
"page": {
|
||||
"pageType": page.get("pageType"),
|
||||
"htmlContent": page.get("htmlContent") if page.get("pageType") == "html" else "",
|
||||
"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),
|
||||
@@ -155,12 +258,11 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
|
||||
"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": [],
|
||||
"type": "unverified", "capabilities": [],
|
||||
"text": "该用户尚未自定义个人主页,以下内容为平台公开信息,请注意核实",
|
||||
}
|
||||
return {
|
||||
@@ -168,34 +270,43 @@ async def get_user_page(user_id: str, db: Database = Depends(get_db),
|
||||
"profile": _public_profile(user, badges, credit),
|
||||
"notice": notice,
|
||||
"ownerIsSelf": is_self,
|
||||
"default": {
|
||||
"talent": talent,
|
||||
"services": services,
|
||||
},
|
||||
"default": {"talent": talent, "services": services},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_page_input(body: dict) -> dict:
|
||||
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}
|
||||
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 上限")
|
||||
patch["html_content"] = html
|
||||
else:
|
||||
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
|
||||
if "capabilities" in body:
|
||||
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")]
|
||||
patch["capabilities"] = clean
|
||||
return patch
|
||||
# ── 运营端:审核 ────────────────────────────────────────────────────────
|
||||
@router.get("/admin/review/list", summary="运营端:待审核个人主页列表")
|
||||
async def review_list(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_pending()
|
||||
result = []
|
||||
for p in pages:
|
||||
u = (await db.users.get_by_id(p["userId"])) or {}
|
||||
result.append({
|
||||
**p,
|
||||
"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 "")
|
||||
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)
|
||||
return {"ok": True, "page": page}
|
||||
|
||||
@@ -26,6 +26,12 @@ from .models import (
|
||||
from .repositories import new_id, utcnow_iso
|
||||
|
||||
|
||||
def _resolve_media_urls(*urls):
|
||||
"""统一把对象路径/裸 CDN 链补全为可访问直链(空值原样返回)。"""
|
||||
from .oss import resolve_url
|
||||
return [resolve_url(u) for u in urls]
|
||||
|
||||
|
||||
def _loads(s: str, default):
|
||||
try:
|
||||
v = json.loads(s or "")
|
||||
@@ -138,10 +144,14 @@ class OpcServiceRepository:
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(s: OpcService) -> dict:
|
||||
gallery = _loads(s.gallery_json, [])
|
||||
resolved = _resolve_media_urls(s.cover, *gallery)
|
||||
cover = resolved[0] if resolved else ""
|
||||
gallery = resolved[1:] if len(resolved) > 1 else []
|
||||
return {
|
||||
"id": s.id, "opcId": s.opc_id, "opcName": s.opc_name, "title": s.title,
|
||||
"category": s.category, "description": s.description, "cover": s.cover,
|
||||
"gallery": _loads(s.gallery_json, []), "price": s.price,
|
||||
"category": s.category, "description": s.description, "cover": cover,
|
||||
"gallery": gallery, "price": s.price,
|
||||
"deliveryDays": s.delivery_days, "tags": s.tags, "status": s.status,
|
||||
"rating": s.rating, "orderCount": s.order_count,
|
||||
"createdAt": s.created_at, "updatedAt": s.updated_at,
|
||||
@@ -232,9 +242,10 @@ class TalentProfileRepository:
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(t: TalentProfile) -> dict:
|
||||
avatar = _resolve_media_urls(t.avatar)[0]
|
||||
return {
|
||||
"userId": t.user_id, "talentType": t.talent_type,
|
||||
"displayName": t.display_name, "avatar": t.avatar,
|
||||
"displayName": t.display_name, "avatar": avatar,
|
||||
"headline": t.headline, "bio": t.bio,
|
||||
"fields": _loads(t.fields_json, []), "skills": _loads(t.skills_json, []),
|
||||
"workYears": t.work_years, "education": t.education, "region": t.region,
|
||||
@@ -297,10 +308,14 @@ class CommunityPostRepository:
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(p: CommunityPost) -> dict:
|
||||
images = _loads(p.images_json, [])
|
||||
resolved = _resolve_media_urls(p.avatar, *images)
|
||||
avatar = resolved[0] if resolved else ""
|
||||
images = resolved[1:] if len(resolved) > 1 else []
|
||||
return {
|
||||
"id": p.id, "authorId": p.author_id, "authorName": p.author_name,
|
||||
"avatar": p.avatar, "topic": p.topic, "title": p.title, "content": p.content,
|
||||
"images": _loads(p.images_json, []), "likeCount": p.like_count,
|
||||
"avatar": avatar, "topic": p.topic, "title": p.title, "content": p.content,
|
||||
"images": images, "likeCount": p.like_count,
|
||||
"commentCount": p.comment_count, "status": p.status, "pinned": bool(p.pinned),
|
||||
"createdAt": p.created_at, "updatedAt": p.updated_at,
|
||||
}
|
||||
@@ -612,8 +627,13 @@ class UserPageRepository:
|
||||
"userId": p.user_id,
|
||||
"pageType": p.page_type,
|
||||
"htmlContent": p.html_content,
|
||||
"htmlUrl": p.html_url,
|
||||
"embedUrl": p.embed_url,
|
||||
"capabilities": json.loads(p.capabilities_json or "[]"),
|
||||
"reviewStatus": p.review_status or "draft",
|
||||
"reviewNote": p.review_note or "",
|
||||
"submittedAt": p.submitted_at or "",
|
||||
"reviewedAt": p.reviewed_at or "",
|
||||
"published": bool(p.published),
|
||||
"createdAt": p.created_at,
|
||||
"updatedAt": p.updated_at,
|
||||
@@ -633,10 +653,20 @@ class UserPageRepository:
|
||||
row.page_type = patch["page_type"]
|
||||
if "html_content" in patch:
|
||||
row.html_content = patch["html_content"] or ""
|
||||
if "html_url" in patch:
|
||||
row.html_url = patch["html_url"] or ""
|
||||
if "embed_url" in patch:
|
||||
row.embed_url = patch["embed_url"] or ""
|
||||
if "capabilities" in patch:
|
||||
row.capabilities_json = json.dumps(patch["capabilities"] or [], ensure_ascii=False)
|
||||
if "review_status" in patch:
|
||||
row.review_status = patch["review_status"] or "draft"
|
||||
if "review_note" in patch:
|
||||
row.review_note = patch["review_note"] or ""
|
||||
if "submitted_at" in patch:
|
||||
row.submitted_at = patch["submitted_at"] or ""
|
||||
if "reviewed_at" in patch:
|
||||
row.reviewed_at = patch["reviewed_at"] or ""
|
||||
if "published" in patch:
|
||||
row.published = bool(patch["published"])
|
||||
row.updated_at = now
|
||||
@@ -645,3 +675,8 @@ class UserPageRepository:
|
||||
|
||||
async def set_published(self, user_id: str, published: bool) -> dict | None:
|
||||
return await self.upsert(user_id, {"published": published})
|
||||
|
||||
async def list_pending(self) -> list[dict]:
|
||||
stmt = select(UserPage).where(UserPage.review_status == "pending").order_by(UserPage.submitted_at.asc())
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [self._to_dict(r) for r in rows]
|
||||
|
||||
@@ -1796,17 +1796,24 @@ class UserPage(Base):
|
||||
"""个人主页(OPC 公开名片页):每位用户可自写 HTML 或嵌入第三方网页。
|
||||
|
||||
- page_type: html 自写 HTML(沙箱渲染)| embed 第三方网页地址(iframe)
|
||||
- capabilities_json: 接入的平台能力声明 [{key,name,desc,scope}],接入后
|
||||
底部展示接入说明;未接入则提醒访客核实数据。
|
||||
- published: 是否上架对外展示(人才市场等入口仅展示已发布页)。
|
||||
- html_url: HTML 模式下,HTML 文件在 OSS 的相对路径(/oss/...);数据库不存 HTML 源码
|
||||
- embed_url: embed 模式下的第三方网页 https 地址
|
||||
- capabilities_json: 接入的平台能力声明 [{key,name,desc,scope}];embed 模式强制为空
|
||||
- review_status: draft 草稿 | pending 待审核 | approved 已通过 | rejected 已拒绝
|
||||
- published: 是否上架对外展示(仅 approved 状态可上架)
|
||||
"""
|
||||
__tablename__ = "user_pages"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
page_type: Mapped[str] = mapped_column(String, default="html") # html | embed
|
||||
html_content: Mapped[str] = mapped_column(Text, default="") # 自写 HTML 源码
|
||||
html_content: Mapped[str] = mapped_column(Text, default="") # 兼容旧数据,新数据存 OSS
|
||||
html_url: Mapped[str] = mapped_column(String, default="") # OSS 相对路径 /oss/...
|
||||
embed_url: Mapped[str] = mapped_column(String, default="") # 第三方网页 https 地址
|
||||
capabilities_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
review_status: Mapped[str] = mapped_column(String, default="draft") # draft|pending|approved|rejected
|
||||
review_note: Mapped[str] = mapped_column(Text, default="")
|
||||
submitted_at: Mapped[str] = mapped_column(String, default="")
|
||||
reviewed_at: Mapped[str] = mapped_column(String, default="")
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[str] = mapped_column(String, default="")
|
||||
updated_at: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
@@ -96,6 +96,19 @@ class OSS:
|
||||
return self.cdn_url(key)
|
||||
return await self.presigned_get(key, expires)
|
||||
|
||||
async def download(self, key: str) -> bytes | None:
|
||||
"""直接读取对象内容(字节)。OSS 模式走 get_object,本地模式读文件。"""
|
||||
key = self.clean_key(key)
|
||||
if self.enabled:
|
||||
client = await self._get_client()
|
||||
resp = await client.get_object(Bucket=self.bucket, Key=key)
|
||||
async with resp["Body"] as body:
|
||||
return await body.read()
|
||||
dest = self.local_dir / key
|
||||
if dest.exists():
|
||||
return dest.read_bytes()
|
||||
return None
|
||||
|
||||
# ---- CDN URL 鉴权(阿里云「鉴权方式A」)----
|
||||
|
||||
# 「永久有效」实现:鉴权时间戳取 32 位最大值(2038-01),叠加控制台有效期内长期可访问
|
||||
|
||||
Reference in New Issue
Block a user