chore: sync docs, gitea wiki assets, templates
Some checks failed
CI / notice (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
卡若
2026-04-14 18:44:44 +08:00
parent bbb5a80e50
commit ea3a589f0b
43 changed files with 2289 additions and 6 deletions

View File

@@ -0,0 +1,29 @@
---
name: Bug 反馈
about: 缺陷与异常行为
title: "[Bug] "
labels: bug
---
## 复现步骤
1.
2.
## 期望行为
## 实际行为
## 环境
- 分支 / commit
- 开发 / 生产:
- 浏览器(若相关):
## 日志或截图(脱敏)
```
粘贴关键日志或接口返回
```

View File

@@ -0,0 +1,27 @@
---
name: 功能建议
about: 新功能或改进
title: "[Feature] "
labels: enhancement
---
## 用户故事
作为 … 我希望 … 以便 …
## 验收标准
- [ ]
- [ ]
## 影响范围
- [ ] 直播 / 推流
- [ ] 支付 / 订单
- [ ] 管理后台
- [ ] 其它:
## 参考文档
- Wiki
- 玩值开发文档路径:

View File

@@ -0,0 +1,19 @@
---
name: 任务报备
about: 运维、迁移、文档等非功能任务
title: "[Task] "
labels: task
---
## 背景
## 要做的事
- [ ]
- [ ]
## 完成定义DoD
## 风险 / 回滚

View File

@@ -0,0 +1,15 @@
## 变更说明
## 关联工单
Closes #
## 自检
- [ ] `pnpm lint`(或说明未跑原因)
- [ ]`.env` / 密钥入库
- [ ] 已更新「玩值开发文档」或 Gitea 百科(若行为或接口有变)
## 截图 / 演示(可选)

17
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,17 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
notice:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Placeholder
run: |
echo "Gitea Actions 已接入。若 Runner 已安装 Node/pnpm可改为corepack enable && pnpm install && pnpm lint && pnpm build"
ls -la

126
app/admin/journey/page.tsx Normal file
View File

@@ -0,0 +1,126 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
type Row = {
id: string
eventName: unknown
source: unknown
sessionId: unknown
properties: unknown
occurredAt: unknown
receivedAt: unknown
}
export default function AdminJourneyPage() {
const [userId, setUserId] = useState("")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [loading, setLoading] = useState(false)
const [rows, setRows] = useState<Row[]>([])
const [error, setError] = useState<string | null>(null)
const load = async () => {
setError(null)
if (!userId.trim()) {
setError("请填写用户 MongoDB ObjectId")
return
}
setLoading(true)
try {
const q = new URLSearchParams({ userId: userId.trim(), limit: "200" })
if (from.trim()) q.set("from", from.trim())
if (to.trim()) q.set("to", to.trim())
const res = await fetch(`/api/admin/journey-events?${q}`)
const json = await res.json()
if (!json.success) {
setError(json.message || "加载失败")
setRows([])
return
}
setRows(json.data || [])
} catch {
setError("网络错误")
setRows([])
} finally {
setLoading(false)
}
}
return (
<div className="p-6 space-y-6 max-w-6xl">
<div>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">WM-O1-KR1-03 · ID 线</p>
</div>
<Card className="border border-gray-200">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 items-end">
<div className="min-w-[220px] flex-1">
<label className="text-xs text-gray-500 block mb-1"> ID</label>
<Input value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="users._id" />
</div>
<div className="min-w-[160px]">
<label className="text-xs text-gray-500 block mb-1"></label>
<Input type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="min-w-[160px]">
<label className="text-xs text-gray-500 block mb-1"></label>
<Input type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<Button onClick={load} disabled={loading}>
{loading ? "查询中…" : "查询"}
</Button>
</CardContent>
</Card>
{error && <p className="text-sm text-red-600">{error}</p>}
<Card className="border border-gray-200">
<CardHeader>
<CardTitle className="text-base">线 </CardTitle>
</CardHeader>
<CardContent>
{rows.length === 0 && !loading ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left border-collapse">
<thead>
<tr className="border-b border-gray-200 text-gray-600">
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-gray-100 align-top">
<td className="py-2 pr-4 whitespace-nowrap text-gray-800">{String(r.occurredAt ?? "")}</td>
<td className="py-2 pr-4 font-mono text-xs">{String(r.eventName ?? "")}</td>
<td className="py-2 pr-4">{String(r.source ?? "")}</td>
<td className="py-2 pr-4 font-mono text-xs max-w-[140px] truncate" title={String(r.sessionId ?? "")}>
{String(r.sessionId ?? "")}
</td>
<td className="py-2 text-xs font-mono break-all max-w-md">
{JSON.stringify(r.properties ?? {})}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -25,6 +25,9 @@ import {
LayoutDashboard,
Trophy,
Monitor,
History,
PieChart,
CircleDollarSign,
} from "lucide-react"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
@@ -107,6 +110,9 @@ const menuGroups: { title: string; items: { id: string; label: string; icon: typ
title: "用户与创作者",
items: [
{ id: "users", label: "用户管理", icon: Users, path: "/admin/users" },
{ id: "journey", label: "用户旅程", icon: History, path: "/admin/journey" },
{ id: "rfm-rules", label: "RFM规则", icon: PieChart, path: "/admin/rfm-rules" },
{ id: "user-value", label: "用户估值", icon: CircleDollarSign, path: "/admin/user-value" },
{ id: "streamers", label: "主播管理", icon: Radio, path: "/admin/streamers" },
{ id: "guilds", label: "公会管理", icon: Building2, path: "/admin/guilds" },
{ id: "live", label: "直播管理", icon: Gamepad2, path: "/admin/live" },

View File

@@ -0,0 +1,227 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const DEFAULT_DIMENSIONS = [
{
code: "R" as const,
label: "近因(观看/活跃)",
metricKey: "watch_minutes_30d",
thresholds: [0, 30, 120, 600],
binLabels: ["D", "C", "B", "A"],
},
{
code: "F" as const,
label: "频次(下单/打赏次数)",
metricKey: "order_count_90d",
thresholds: [0, 1, 3, 10],
binLabels: ["D", "C", "B", "A"],
},
{
code: "M" as const,
label: "金额(消费额)",
metricKey: "gmv_cents_90d",
thresholds: [0, 10000, 50000, 200000],
binLabels: ["D", "C", "B", "A"],
},
]
type RuleRow = Record<string, unknown> & { id?: string; _id?: { toString(): string } }
export default function AdminRfmRulesPage() {
const [list, setList] = useState<RuleRow[]>([])
const [loading, setLoading] = useState(true)
const [msg, setMsg] = useState<string | null>(null)
const [editId, setEditId] = useState<string | null>(null)
const [editJson, setEditJson] = useState("")
const rowId = (r: RuleRow) => String(r.id ?? r._id?.toString() ?? "")
const refresh = async () => {
setLoading(true)
setMsg(null)
try {
const res = await fetch("/api/admin/rfm-rules")
const json = await res.json()
if (json.success) setList(json.data || [])
else setMsg(json.message || "加载失败")
} catch {
setMsg("网络错误")
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
}, [])
const seedDefault = async () => {
setMsg(null)
try {
const res = await fetch("/api/admin/rfm-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "default",
status: "draft",
dimensions: DEFAULT_DIMENSIONS,
}),
})
const json = await res.json()
if (!json.success) {
setMsg(json.message || "创建失败")
return
}
setMsg(`已创建草稿版本 v${json.data?.version ?? ""}`)
await refresh()
} catch {
setMsg("网络错误")
}
}
const saveDraft = async () => {
if (!editId) return
setMsg(null)
let dimensions: unknown
try {
dimensions = JSON.parse(editJson)
} catch {
setMsg("JSON 格式错误")
return
}
try {
const res = await fetch(`/api/admin/rfm-rules/${editId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions }),
})
const json = await res.json()
if (!json.success) {
setMsg(json.message || "保存失败")
return
}
setMsg("已保存草稿维度")
setEditId(null)
setEditJson("")
await refresh()
} catch {
setMsg("网络错误")
}
}
const activate = async (id: string) => {
setMsg(null)
try {
const res = await fetch(`/api/admin/rfm-rules/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "activate" }),
})
const json = await res.json()
if (!json.success) {
setMsg(json.message || "激活失败")
return
}
setMsg("已激活该版本(同名将其它 active 归档)")
setEditId(null)
setEditJson("")
await refresh()
} catch {
setMsg("网络错误")
}
}
return (
<div className="p-6 space-y-6 max-w-5xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">RFM </h1>
<p className="text-sm text-gray-500 mt-1">
WM-O1-KR2-02稿 JSON status=active RBAC
</p>
</div>
<Button variant="outline" onClick={seedDefault}>
稿
</Button>
</div>
{msg && <p className="text-sm text-gray-700">{msg}</p>}
{editId && (
<Card className="border border-blue-200 bg-blue-50/30">
<CardHeader>
<CardTitle className="text-base">稿 {editId}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<textarea
className="w-full min-h-[240px] font-mono text-xs border border-gray-200 rounded-lg p-3 bg-white"
value={editJson}
onChange={(e) => setEditJson(e.target.value)}
spellCheck={false}
/>
<div className="flex gap-2">
<Button onClick={saveDraft}></Button>
<Button variant="outline" onClick={() => { setEditId(null); setEditJson("") }}>
</Button>
</div>
</CardContent>
</Card>
)}
<Card className="border border-gray-200">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-gray-500"></p>
) : list.length === 0 ? (
<p className="text-sm text-gray-500">稿</p>
) : (
<ul className="space-y-4 text-sm">
{list.map((r) => {
const id = rowId(r)
const status = String(r.status ?? "")
return (
<li key={id} className="border border-gray-100 rounded-lg p-4 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-gray-900">
{String(r.name ?? "")} · v{r.version as number} · {status}
</div>
<div className="flex flex-wrap gap-2">
{status === "draft" && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setEditId(id)
setEditJson(JSON.stringify(r.dimensions, null, 2))
}}
>
JSON
</Button>
<Button size="sm" onClick={() => void activate(id)}>
</Button>
</>
)}
</div>
</div>
<pre className="text-xs overflow-x-auto bg-gray-50 p-2 rounded max-h-48 overflow-y-auto">
{JSON.stringify(r.dimensions, null, 2)}
</pre>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function AdminUserValuePage() {
const [userId, setUserId] = useState("")
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Record<string, unknown> | null>(null)
const [err, setErr] = useState<string | null>(null)
const load = async () => {
setErr(null)
setData(null)
if (!userId.trim()) {
setErr("请填写用户 ID")
return
}
setLoading(true)
try {
const res = await fetch(`/api/admin/user-value-summary?userId=${encodeURIComponent(userId.trim())}`)
const json = await res.json()
if (!json.success) {
setErr(json.message || "失败")
return
}
setData(json.data)
} catch {
setErr("网络错误")
} finally {
setLoading(false)
}
}
return (
<div className="p-6 space-y-6 max-w-3xl">
<div>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">WM-O1-KR3-02 MVP · 90 </p>
</div>
<Card className="border border-gray-200">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[200px]">
<label className="text-xs text-gray-500 block mb-1"> MongoDB ID</label>
<Input value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="users._id" />
</div>
<Button onClick={load} disabled={loading}>
{loading ? "查询中…" : "汇总"}
</Button>
</CardContent>
</Card>
{err && <p className="text-sm text-red-600">{err}</p>}
{data && (
<Card className="border border-gray-200">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<pre className="text-xs bg-gray-50 p-4 rounded-lg overflow-x-auto">{JSON.stringify(data, null, 2)}</pre>
<p className="text-xs text-gray-500 mt-3">
JSON CSV `live.room.stay.heartbeat` ×30s
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server"
import { getCollection } from "@/lib/db/mongo"
import { ObjectId } from "mongodb"
/**
* 管理端:按用户 ID + 时间查询旅程时间线WM-O1-KR1-03
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const userIdStr = searchParams.get("userId")?.trim()
const fromStr = searchParams.get("from")?.trim()
const toStr = searchParams.get("to")?.trim()
const limitRaw = searchParams.get("limit")
const limit = Math.min(500, Math.max(1, Number(limitRaw) || 200))
if (!userIdStr) {
return NextResponse.json({ success: false, message: "缺少 userId" }, { status: 400 })
}
let userId: ObjectId
try {
userId = new ObjectId(userIdStr)
} catch {
return NextResponse.json({ success: false, message: "userId 非法" }, { status: 400 })
}
const filter: Record<string, unknown> = { userId }
if (fromStr || toStr) {
filter.occurredAt = {}
if (fromStr) {
const from = new Date(fromStr)
if (!Number.isNaN(from.getTime())) (filter.occurredAt as Record<string, Date>).$gte = from
}
if (toStr) {
const to = new Date(toStr)
if (!Number.isNaN(to.getTime())) (filter.occurredAt as Record<string, Date>).$lte = to
}
if (Object.keys(filter.occurredAt as object).length === 0) delete filter.occurredAt
}
const coll = await getCollection("userJourneyEvents")
const rows = await coll
.find(filter)
.sort({ occurredAt: -1 })
.limit(limit)
.toArray()
const data = rows.map((doc) => {
const d = doc as Record<string, unknown> & { _id: ObjectId }
return {
id: d._id.toString(),
eventName: d.eventName,
source: d.source,
sessionId: d.sessionId,
properties: d.properties,
occurredAt: d.occurredAt instanceof Date ? d.occurredAt.toISOString() : d.occurredAt,
receivedAt: d.receivedAt instanceof Date ? d.receivedAt.toISOString() : d.receivedAt,
}
})
return NextResponse.json({ success: true, data })
} catch (e) {
console.error("GET /api/admin/journey-events error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server"
import { getCollection } from "@/lib/db/mongo"
import { ObjectId } from "mongodb"
type RfmDimension = {
code: "R" | "F" | "M"
label: string
metricKey: string
thresholds: number[]
binLabels?: string[]
}
function isDimensions(v: unknown): v is RfmDimension[] {
return Array.isArray(v) && v.length > 0
}
/**
* WM-O1-KR2-02单条规则更新草稿维度与激活版本
*/
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
if (!ObjectId.isValid(id)) {
return NextResponse.json({ success: false, message: "非法 id" }, { status: 400 })
}
const body = (await req.json()) as { dimensions?: unknown }
if (!isDimensions(body.dimensions)) {
return NextResponse.json({ success: false, message: "dimensions 须为非空数组" }, { status: 400 })
}
const coll = await getCollection("rfmRuleSets")
const oid = new ObjectId(id)
const doc = await coll.findOne({ _id: oid })
if (!doc) {
return NextResponse.json({ success: false, message: "不存在" }, { status: 404 })
}
if ((doc as { status?: string }).status !== "draft") {
return NextResponse.json({ success: false, message: "仅草稿可编辑维度" }, { status: 400 })
}
const now = new Date()
await coll.updateOne(
{ _id: oid },
{ $set: { dimensions: body.dimensions, updatedAt: now } },
)
return NextResponse.json({ success: true })
} catch (e) {
console.error("PUT /api/admin/rfm-rules/[id] error:", e)
return NextResponse.json({ success: false, message: "更新失败" }, { status: 500 })
}
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
if (!ObjectId.isValid(id)) {
return NextResponse.json({ success: false, message: "非法 id" }, { status: 400 })
}
const body = (await req.json()) as { action?: string }
if (body.action !== "activate") {
return NextResponse.json({ success: false, message: "仅支持 action: activate" }, { status: 400 })
}
const coll = await getCollection("rfmRuleSets")
const oid = new ObjectId(id)
const doc = await coll.findOne({ _id: oid })
if (!doc) {
return NextResponse.json({ success: false, message: "不存在" }, { status: 404 })
}
const name = String((doc as { name?: string }).name ?? "")
const now = new Date()
await coll.updateMany({ name, status: "active" }, { $set: { status: "archived", updatedAt: now } })
await coll.updateOne({ _id: oid }, { $set: { status: "active", updatedAt: now } })
return NextResponse.json({ success: true })
} catch (e) {
console.error("PATCH /api/admin/rfm-rules/[id] error:", e)
return NextResponse.json({ success: false, message: "激活失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server"
import { getCollection } from "@/lib/db/mongo"
type RfmDimension = {
code: "R" | "F" | "M"
label: string
/** 与数仓/订单域对齐的指标键,跑批时读取 */
metricKey: string
/** 分档边界(升序),与 bins 标签一一对应或 n 档 n-1 阈值 */
thresholds: number[]
binLabels?: string[]
}
/**
* RFM 规则集WM-O1-KR2-01 表结构 + 最小读写 API编辑页见 WM-O1-KR2-02
*/
export async function GET() {
try {
const coll = await getCollection("rfmRuleSets")
const list = await coll.find({}).sort({ version: -1 }).limit(50).toArray()
const data = list.map((doc) => {
const d = doc as Record<string, unknown> & { _id: { toString(): string } }
return { ...d, id: d._id.toString() }
})
return NextResponse.json({ success: true, data })
} catch (e) {
console.error("GET /api/admin/rfm-rules error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
name?: string
dimensions?: RfmDimension[]
status?: "draft" | "active" | "archived"
}
const name = typeof body.name === "string" ? body.name.trim() : ""
if (!name) {
return NextResponse.json({ success: false, message: "缺少 name" }, { status: 400 })
}
const dimensions = Array.isArray(body.dimensions) ? body.dimensions : []
if (dimensions.length === 0) {
return NextResponse.json({ success: false, message: "dimensions 不能为空" }, { status: 400 })
}
const coll = await getCollection("rfmRuleSets")
const last = await coll.find({ name }).sort({ version: -1 }).limit(1).toArray()
const nextVersion = last.length ? Number((last[0] as { version?: number }).version ?? 0) + 1 : 1
const status = body.status === "active" || body.status === "archived" ? body.status : "draft"
const now = new Date()
if (status === "active") {
await coll.updateMany({ name, status: "active" }, { $set: { status: "archived", updatedAt: now } })
}
const doc = {
name,
version: nextVersion,
status,
dimensions,
createdAt: now,
updatedAt: now,
}
const r = await coll.insertOne(doc)
return NextResponse.json({
success: true,
data: {
id: r.insertedId.toString(),
name: doc.name,
version: doc.version,
status: doc.status,
dimensions: doc.dimensions,
createdAt: doc.createdAt.toISOString(),
},
})
} catch (e) {
console.error("POST /api/admin/rfm-rules error:", e)
return NextResponse.json({ success: false, message: "创建失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server"
import { getCollection } from "@/lib/db/mongo"
import { ObjectId } from "mongodb"
/**
* 单用户消费/打赏/观看粗汇总WM-O1-KR3-02 看板 MVP口径后续与 RFM 对齐)
*/
export async function GET(req: NextRequest) {
try {
const userIdStr = new URL(req.url).searchParams.get("userId")?.trim()
if (!userIdStr || !ObjectId.isValid(userIdStr)) {
return NextResponse.json({ success: false, message: "缺少或非法 userId" }, { status: 400 })
}
const uid = new ObjectId(userIdStr)
const giftsColl = await getCollection("liveGifts")
const giftAgg = await giftsColl
.aggregate<{ totalCoins: number; count: number }>([
{ $match: { userId: userIdStr } },
{ $group: { _id: null, totalCoins: { $sum: "$amountCoins" }, count: { $sum: 1 } } },
])
.toArray()
const txColl = await getCollection("transactions")
const txAgg = await txColl
.aggregate<{ _id: string | null; total: number; count: number }>([
{ $match: { userId: userIdStr } },
{ $group: { _id: "$type", total: { $sum: { $toDouble: { $ifNull: ["$amount", 0] } } }, count: { $sum: 1 } } },
])
.toArray()
const matchUser = {
$or: [{ userId: userIdStr }, { userId: uid }],
}
const poColl = await getCollection("productOrders")
const poAgg = await poColl
.aggregate<{ total: number; count: number }>([
{ $match: { ...matchUser, status: { $nin: ["cancelled", "canceled", "refunded"] } } },
{
$group: {
_id: null,
total: {
$sum: {
$ifNull: ["$finalAmount", { $ifNull: ["$totalAmount", 0] }],
},
},
count: { $sum: 1 },
},
},
])
.toArray()
const pcColl = await getCollection("pointCardOrders")
const pcAgg = await pcColl
.aggregate<{ total: number; count: number }>([
{ $match: { ...matchUser, status: { $nin: ["cancelled", "canceled", "refunded"] } } },
{ $group: { _id: null, total: { $sum: { $ifNull: ["$totalAmount", 0] } }, count: { $sum: 1 } } },
])
.toArray()
const journeyColl = await getCollection("userJourneyEvents")
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
const hbCount = await journeyColl.countDocuments({
userId: uid,
eventName: "live.room.stay.heartbeat",
occurredAt: { $gte: since },
})
const giftRow = giftAgg[0]
const poRow = poAgg[0]
const pcRow = pcAgg[0]
return NextResponse.json({
success: true,
data: {
userId: userIdStr,
windowDays: 90,
liveGifts: {
count: giftRow?.count ?? 0,
totalCoins: giftRow?.totalCoins ?? 0,
},
productOrders: {
count: poRow?.count ?? 0,
totalAmountYuan: poRow?.total ?? 0,
},
pointCardOrders: {
count: pcRow?.count ?? 0,
totalAmountYuan: pcRow?.total ?? 0,
},
transactionsByType: txAgg.map((t) => ({
type: t._id ?? "unknown",
count: t.count,
totalAmount: t.total,
})),
/** 每条心跳约 30s折算为「等效观看分钟」近似值 */
watchMinutesEstimate90d: Math.round(hbCount * 0.5),
heartbeatCount90d: hbCount,
},
})
} catch (e) {
console.error("GET /api/admin/user-value-summary error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server"
import { getCollection } from "@/lib/db/mongo"
import { ObjectId } from "mongodb"
type JourneySource = "app" | "h5" | "admin"
type IncomingEvent = {
eventName?: string
properties?: Record<string, unknown>
sessionId?: string
clientEventId?: string
occurredAt?: string
source?: JourneySource
}
function parseUserId(req: NextRequest): ObjectId | null {
const raw = req.cookies.get("wanzhi_user_id")?.value
if (!raw) return null
try {
return new ObjectId(raw)
} catch {
return null
}
}
/**
* 用户旅程埋点批量上报WM-O1-KR1-02
* 去重Mongo 唯一索引 sessionId + clientEventId冲突时跳过该条。
*/
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
events?: IncomingEvent[]
anonymousId?: string
}
const events = body?.events
if (!Array.isArray(events) || events.length === 0) {
return NextResponse.json({ success: false, message: "events 必须为非空数组" }, { status: 400 })
}
if (events.length > 200) {
return NextResponse.json({ success: false, message: "单次最多 200 条" }, { status: 400 })
}
const userId = parseUserId(req)
const anonymousId =
typeof body.anonymousId === "string" && body.anonymousId.trim().length > 0
? body.anonymousId.trim().slice(0, 128)
: undefined
if (!userId && !anonymousId) {
return NextResponse.json(
{ success: false, message: "未登录时需传 anonymousIdH5 匿名会话)" },
{ status: 400 },
)
}
const coll = await getCollection("userJourneyEvents")
const now = new Date()
let inserted = 0
let deduped = 0
let invalid = 0
for (const ev of events) {
const eventName = typeof ev.eventName === "string" ? ev.eventName.trim() : ""
const sessionId = typeof ev.sessionId === "string" ? ev.sessionId.trim() : ""
const clientEventId = typeof ev.clientEventId === "string" ? ev.clientEventId.trim() : ""
const source = ev.source
if (!eventName || !sessionId || !clientEventId) {
invalid++
continue
}
if (source !== "app" && source !== "h5" && source !== "admin") {
invalid++
continue
}
let occurredAt = now
if (typeof ev.occurredAt === "string" && ev.occurredAt) {
const d = new Date(ev.occurredAt)
if (!Number.isNaN(d.getTime())) occurredAt = d
}
const doc = {
userId: userId ?? null,
/** 登录用户也写入匿名 ID便于未登录→登录后旅程拼接可为空 */
...(anonymousId ? { anonymousId } : {}),
sessionId,
clientEventId,
eventName,
properties: ev.properties && typeof ev.properties === "object" ? ev.properties : {},
source,
occurredAt,
receivedAt: now,
}
try {
const r = await coll.insertOne(doc)
if (r.insertedId) inserted++
} catch (e: unknown) {
const code = typeof e === "object" && e && "code" in e ? (e as { code: number }).code : 0
if (code === 11000) deduped++
else throw e
}
}
return NextResponse.json({
success: true,
data: { inserted, deduped, invalid, total: events.length },
})
} catch (e) {
console.error("POST /api/analytics/journey-events error:", e)
return NextResponse.json({ success: false, message: "上报失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
const WINDOW_MS = 7 * 24 * 60 * 60 * 1000
type AggRow = {
viewerKey: string
displayLabel: string
viewerType: "user" | "anonymous"
lastTouchAt: string
maxWatchMarkSec: number
likeCount: number
commentCount: number
shareCount: number
giftCount: number
/** 0100观看里程碑 + 互动加权,便于主播一眼对比 */
engagePercent: number
recentTouches: { eventName: string; at: string; detail?: string }[]
}
function maskViewerKey(key: string, type: "user" | "anonymous"): string {
if (type === "anonymous") return `访客·${key.slice(-6)}`
if (key.length >= 8) return `用户·…${key.slice(-6)}`
return key
}
function computeEngagePercent(a: {
maxWatchMarkSec: number
likeCount: number
commentCount: number
shareCount: number
giftCount: number
}): number {
let base = 0
if (a.maxWatchMarkSec >= 30) base = 50
else if (a.maxWatchMarkSec >= 20) base = 35
const bonus = Math.min(50, a.likeCount * 8 + a.commentCount * 15 + a.shareCount * 12 + a.giftCount * 20)
return Math.min(100, Math.round(base + bonus))
}
/**
* 主播端:查看与本直播间相关的观众旅程触达(来自 userJourneyEvents
* 需登录且为已审核主播本人;?streamerId 可省略(默认当前登录用户的主播档案)
*/
export async function GET(req: NextRequest) {
try {
const uidStr = req.cookies.get("wanzhi_user_id")?.value
if (!uidStr || !ObjectId.isValid(uidStr)) {
return NextResponse.json({ success: false, message: "未登录" }, { status: 401 })
}
const streamersColl = await getCollection("streamers")
const me = await streamersColl.findOne({ userId: uidStr, status: "approved" })
if (!me) {
return NextResponse.json({ success: false, message: "非主播或未审核" }, { status: 403 })
}
const mySid = (me._id as ObjectId).toString()
const qSid = new URL(req.url).searchParams.get("streamerId")?.trim()
const streamerId = qSid && ObjectId.isValid(qSid) ? qSid : mySid
if (streamerId !== mySid) {
return NextResponse.json({ success: false, message: "仅可查看本人主播数据" }, { status: 403 })
}
const since = new Date(Date.now() - WINDOW_MS)
const evColl = await getCollection("userJourneyEvents")
const rows = await evColl
.find({
"properties.streamerId": streamerId,
occurredAt: { $gte: since },
})
.sort({ occurredAt: -1 })
.limit(4000)
.toArray()
const map = new Map<
string,
{
viewerType: "user" | "anonymous"
lastTouchAt: Date
maxWatchMarkSec: number
likeCount: number
commentCount: number
shareCount: number
giftCount: number
recent: { eventName: string; at: Date; detail?: string }[]
}
>()
for (const doc of rows) {
const d = doc as Record<string, unknown> & {
userId?: ObjectId | null
anonymousId?: string
eventName?: string
occurredAt?: Date
properties?: Record<string, unknown>
}
const viewerKey = d.userId ? (d.userId as ObjectId).toString() : String(d.anonymousId || "")
if (!viewerKey) continue
const viewerType: "user" | "anonymous" = d.userId ? "user" : "anonymous"
let row = map.get(viewerKey)
if (!row) {
row = {
viewerType,
lastTouchAt: d.occurredAt instanceof Date ? d.occurredAt : new Date(0),
maxWatchMarkSec: 0,
likeCount: 0,
commentCount: 0,
shareCount: 0,
giftCount: 0,
recent: [],
}
map.set(viewerKey, row)
}
const at = d.occurredAt instanceof Date ? d.occurredAt : new Date()
if (at > row.lastTouchAt) row.lastTouchAt = at
const en = String(d.eventName ?? "")
if (en === "live.audience.watch_milestone") {
const sec = Number((d.properties as { secondMark?: number })?.secondMark ?? 0)
if (sec > row.maxWatchMarkSec) row.maxWatchMarkSec = sec
} else if (en === "live.audience.like") row.likeCount += 1
else if (en === "live.audience.comment") row.commentCount += 1
else if (en === "live.audience.share") row.shareCount += 1
else if (en === "live.gift.send") row.giftCount += 1
if (row.recent.length < 8) {
let detail: string | undefined
if (en === "live.audience.watch_milestone")
detail = `${(d.properties as { secondMark?: number })?.secondMark ?? 0}s`
if (en === "live.audience.share") detail = String((d.properties as { platform?: string })?.platform ?? "")
row.recent.push({ eventName: en, at, detail })
}
}
const data: AggRow[] = [...map.entries()].map(([viewerKey, v]) => ({
viewerKey,
displayLabel: maskViewerKey(viewerKey, v.viewerType),
viewerType: v.viewerType,
lastTouchAt: v.lastTouchAt.toISOString(),
maxWatchMarkSec: v.maxWatchMarkSec,
likeCount: v.likeCount,
commentCount: v.commentCount,
shareCount: v.shareCount,
giftCount: v.giftCount,
engagePercent: computeEngagePercent(v),
recentTouches: v.recent.map((t) => ({
eventName: t.eventName,
at: t.at.toISOString(),
detail: t.detail,
})),
}))
data.sort((a, b) => (a.lastTouchAt < b.lastTouchAt ? 1 : -1))
return NextResponse.json({
success: true,
data: {
streamerId,
windowDays: 7,
viewers: data.slice(0, 200),
totalViewers: data.length,
},
})
} catch (e) {
console.error("GET /api/streamer/audience-insights error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}

View File

@@ -1,6 +1,7 @@
"use client"
import { use, useState, useRef, useEffect, useMemo, useCallback } from "react"
import { trackJourney } from "@/lib/analytics/journey-track"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
@@ -73,7 +74,7 @@ function mapDtoToRow(c: LiveCommentDTO): ChatRow {
export default function LiveRoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { syncFromDatabase } = useAppContext()
const { syncFromDatabase, user, isLoggedIn } = useAppContext()
const { streamer, loading } = useStreamer(id)
const [liveCtx, setLiveCtx] = useState<LiveContextData | null>(null)
const [ctxLoading, setCtxLoading] = useState(true)
@@ -88,6 +89,12 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
const [floatingHearts, setFloatingHearts] = useState<{ id: number; x: number }[]>([])
const [serverChat, setServerChat] = useState<ChatRow[]>([])
const enterPostedRef = useRef(false)
const [enterPostedOk, setEnterPostedOk] = useState(false)
const journeyEnterTrackedRef = useRef(false)
const heartbeatSeqRef = useRef(0)
const bindLiveRoomTriedRef = useRef(false)
const watchMilestone20Ref = useRef(false)
const watchMilestone30Ref = useRef(false)
useEffect(() => {
let cancelled = false
@@ -120,11 +127,96 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
void postLiveEnter(streamer.id).then((ok) => {
if (ok) {
enterPostedRef.current = true
setEnterPostedOk(true)
void refreshComments()
}
})
}, [streamer?.id, refreshComments])
/** 进房旅程埋点:等直播上下文加载完再发一条(未登录时 API 会静默失败) */
useEffect(() => {
if (!enterPostedOk || !streamer?.id || journeyEnterTrackedRef.current || ctxLoading) return
journeyEnterTrackedRef.current = true
const q = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
const entrySource = q?.get("channel") || q?.get("source") || "direct"
trackJourney(
"live.room.enter",
{
liveRoomId: liveCtx?.room?.id ?? "",
streamerId: streamer.id,
entrySource,
},
"app",
)
}, [enterPostedOk, streamer?.id, liveCtx, ctxLoading])
/** 进房后最简捆绑已登录且无其它有效绑定时POST /api/streamer/bind沿用现有 30 天策略) */
useEffect(() => {
if (!enterPostedOk || !streamer?.id || bindLiveRoomTriedRef.current) return
if (!isLoggedIn || !user?.id) return
bindLiveRoomTriedRef.current = true
void fetch("/api/streamer/bind", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ userId: user.id, streamerId: streamer.id }),
}).catch(() => undefined)
}, [enterPostedOk, streamer?.id, isLoggedIn, user?.id])
/** 观看里程碑 20s / 30s主播侧 audience-insights 汇总) */
useEffect(() => {
watchMilestone20Ref.current = false
watchMilestone30Ref.current = false
if (!streamer?.id) return
const sid = streamer.id
const roomId = liveCtx?.room?.id ?? ""
const t20 = window.setTimeout(() => {
if (watchMilestone20Ref.current) return
watchMilestone20Ref.current = true
trackJourney(
"live.audience.watch_milestone",
{ streamerId: sid, liveRoomId: roomId, secondMark: 20, surface: "live_room" },
"app",
)
}, 20_000)
const t30 = window.setTimeout(() => {
if (watchMilestone30Ref.current) return
watchMilestone30Ref.current = true
trackJourney(
"live.audience.watch_milestone",
{ streamerId: sid, liveRoomId: roomId, secondMark: 30, surface: "live_room" },
"app",
)
}, 30_000)
return () => {
window.clearTimeout(t20)
window.clearTimeout(t30)
}
}, [id, streamer?.id, liveCtx?.room?.id])
/** 停留心跳(字典 live.room.stay.heartbeat约 30s 一条 */
useEffect(() => {
if (!streamer?.id || ctxLoading || !liveCtx?.room?.id) return
const roomId = liveCtx.room.id
const sid = streamer.id
const intervalMs = 30_000
const tick = () => {
heartbeatSeqRef.current += 1
trackJourney(
"live.room.stay.heartbeat",
{
liveRoomId: roomId,
streamerId: sid,
elapsedMs: intervalMs,
seq: heartbeatSeqRef.current,
},
"app",
)
}
const timer = setInterval(tick, intervalMs)
return () => clearInterval(timer)
}, [streamer?.id, liveCtx?.room?.id, ctxLoading])
const shareUrl = useMemo(() => {
if (typeof window === "undefined") return `https://wanzhi.gg/live/${id}?streamerId=${id}&channel=share`
return `${window.location.origin}/live/${id}?streamerId=${id}&channel=share`
@@ -208,6 +300,11 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
const handleLike = () => {
setLikeCount((prev) => prev + 1)
setIsLiked(true)
trackJourney(
"live.audience.like",
{ streamerId: id, liveRoomId: liveCtx?.room?.id ?? "", surface: "live_room" },
"app",
)
// 添加飘心动画
const newHeart = { id: Date.now(), x: Math.random() * 60 - 30 }
setFloatingHearts((prev) => [...prev, newHeart])
@@ -228,7 +325,8 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
})
return
}
const r = await postLiveComment(id, { content: comment.trim() })
const text = comment.trim()
const r = await postLiveComment(id, { content: text })
if (!r.ok) {
toast({
title: "发送失败",
@@ -239,6 +337,11 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
}
setComment("")
toast({ title: "评论成功", description: "你的评论已发送" })
trackJourney(
"live.audience.comment",
{ streamerId: id, liveRoomId: liveCtx?.room?.id ?? "", surface: "live_room", length: text.length },
"app",
)
void refreshComments()
}
@@ -256,11 +359,26 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
}
setShowGiftPanel(false)
toast({ title: "送礼成功", description: `已送出 ${gift.icon} ${gift.name}` })
trackJourney(
"live.gift.send",
{
liveRoomId: liveCtx?.room?.id ?? "",
streamerId: id,
giftId: gift.id,
amount: gift.price,
},
"app",
)
await syncFromDatabase()
void refreshComments()
}
const handleShare = (platform: string) => {
trackJourney(
"live.audience.share",
{ streamerId: id, liveRoomId: liveCtx?.room?.id ?? "", platform, surface: "live_room" },
"app",
)
toast({
title: "分享成功",
description: `已分享到${platform}`,
@@ -641,6 +759,11 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
className="h-7 text-xs bg-transparent"
onClick={() => {
navigator.clipboard.writeText(shareUrl)
trackJourney(
"live.audience.share",
{ streamerId: id, liveRoomId: liveCtx?.room?.id ?? "", platform: "copy_link", surface: "live_room" },
"app",
)
toast({ title: "链接已复制" })
}}
>

View File

@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button"
import { useAppContext } from "@/components/providers/app-provider"
import { useStreamers } from "@/hooks/use-streamers"
import { getStreamerAvatarUrl, getStreamerCoverUrl } from "@/lib/streamer-avatar"
import { trackJourney } from "@/lib/analytics/journey-track"
const GIFTS = [
{ id: 1, name: "小心心", price: 1, icon: "💕" },
@@ -95,6 +96,38 @@ export default function TikTokStyleLivePage() {
const currentStreamer = displayList[currentIndex]
useEffect(() => {
if (!currentStreamer?.id) return
trackJourney(
"streamer.profile.view",
{ streamerId: currentStreamer.id, sourceTab: "live_feed", cardIndex: currentIndex },
"app",
)
}, [currentStreamer?.id, currentIndex])
useEffect(() => {
if (!currentStreamer?.id) return
const sid = currentStreamer.id
const t20 = window.setTimeout(() => {
trackJourney(
"live.audience.watch_milestone",
{ streamerId: sid, liveRoomId: "", secondMark: 20, surface: "live_feed" },
"app",
)
}, 20_000)
const t30 = window.setTimeout(() => {
trackJourney(
"live.audience.watch_milestone",
{ streamerId: sid, liveRoomId: "", secondMark: 30, surface: "live_feed" },
"app",
)
}, 30_000)
return () => {
window.clearTimeout(t20)
window.clearTimeout(t30)
}
}, [currentStreamer?.id])
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY
}
@@ -113,6 +146,13 @@ export default function TikTokStyleLivePage() {
}
const handleLike = () => {
if (currentStreamer?.id) {
trackJourney(
"live.audience.like",
{ streamerId: currentStreamer.id, liveRoomId: "", surface: "live_feed" },
"app",
)
}
setLocalStreamers((prev) => prev.map((s, i) => (i === currentIndex ? { ...s, likes: s.likes + 1 } : s)))
const newHeart = { id: Date.now(), x: Math.random() * 40 - 20 }
setFloatingHearts((prev) => [...prev, newHeart])
@@ -131,11 +171,19 @@ export default function TikTokStyleLivePage() {
const handleSendComment = () => {
if (!comment.trim()) return
const text = comment.trim()
if (currentStreamer?.id) {
trackJourney(
"live.audience.comment",
{ streamerId: currentStreamer.id, liveRoomId: "", surface: "live_feed", length: text.length },
"app",
)
}
const newComment = {
id: commentIdRef.current++,
user: "我",
avatar: "/user-avatar.jpg",
text: comment,
text,
time: "刚刚",
}
setComments((prev) => [newComment, ...prev])
@@ -149,6 +197,13 @@ export default function TikTokStyleLivePage() {
}
const handleShare = () => {
if (currentStreamer?.id) {
trackJourney(
"live.audience.share",
{ streamerId: currentStreamer.id, liveRoomId: "", platform: "native", surface: "live_feed" },
"app",
)
}
setLocalStreamers((prev) => prev.map((s, i) => (i === currentIndex ? { ...s, shares: s.shares + 1 } : s)))
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useRef } from "react"
import { trackJourney } from "@/lib/analytics/journey-track"
import { ArrowLeft, Check, ShoppingCart, Zap, Flame } from "lucide-react"
import Image from "next/image"
import { useRouter } from "next/navigation"
@@ -187,9 +188,24 @@ export default function GamePointCardClient({ gameId }: { gameId: string }) {
const router = useRouter()
const { pay } = useAppContext()
const [selectedOption, setSelectedOption] = useState<number | null>(null)
const viewTracked = useRef(false)
const game = gameData[gameId]
useEffect(() => {
if (!game || viewTracked.current) return
viewTracked.current = true
const fromLive = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("fromLiveRoomId") : null
trackJourney(
"mall.product.view",
{
productId: `pointcard:${gameId}`,
...(fromLive ? { fromLiveRoomId: fromLive } : {}),
},
"app",
)
}, [gameId, game])
if (!game) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">

View File

@@ -0,0 +1,117 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAppContext } from "@/components/providers/app-provider"
import { fetchAudienceInsights, type AudienceViewerRow } from "@/lib/api/streamer-studio"
export default function StreamerAudiencePage() {
const router = useRouter()
const { authChecked, isLoggedIn } = useAppContext()
const [loading, setLoading] = useState(true)
const [rows, setRows] = useState<AudienceViewerRow[]>([])
const [meta, setMeta] = useState<{ windowDays: number; totalViewers: number; streamerId: string } | null>(null)
const [err, setErr] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setErr(null)
const r = await fetchAudienceInsights()
if (!r.ok) {
setErr(r.message)
setRows([])
setMeta(null)
} else {
setRows(r.data.viewers)
setMeta({
streamerId: r.data.streamerId,
windowDays: r.data.windowDays,
totalViewers: r.data.totalViewers,
})
}
setLoading(false)
}, [])
useEffect(() => {
if (!authChecked) return
if (!isLoggedIn) {
router.replace("/login")
return
}
void load()
}, [authChecked, isLoggedIn, load, router])
return (
<div className="min-h-screen bg-background text-foreground p-4 pb-24 max-w-3xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="icon" asChild>
<Link href="/streamer/studio">
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-lg font-bold flex items-center gap-2">
<Users className="h-5 w-5" />
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{meta?.windowDays ?? "—"} · /
</p>
</div>
</div>
{loading && <p className="text-sm text-muted-foreground"></p>}
{err && (
<p className="text-sm text-destructive mb-4">
{err}
{err.includes("非主播") ? "(需已审核主播账号)" : ""}
</p>
)}
{!loading && !err && meta && (
<p className="text-xs text-muted-foreground mb-3">
<span className="font-semibold text-foreground">{meta.totalViewers}</span> 0100
便
</p>
)}
{!loading && rows.length === 0 && !err && (
<p className="text-sm text-muted-foreground"> `/live/[你的主播ID]` 20s+ </p>
)}
<div className="space-y-3">
{rows.map((v) => (
<div key={v.viewerKey} className="rounded-xl border border-border bg-card p-3 text-sm space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{v.displayLabel}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/15 text-primary font-bold">
{v.engagePercent}%
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-muted-foreground">
<span> {v.maxWatchMarkSec}s+</span>
<span> {v.likeCount}</span>
<span> {v.commentCount}</span>
<span> {v.shareCount}</span>
<span className="col-span-2"> {v.giftCount}</span>
<span className="col-span-2 text-right"> {new Date(v.lastTouchAt).toLocaleString()}</span>
</div>
{v.recentTouches?.length ? (
<div className="text-[10px] text-muted-foreground border-t border-border pt-2 space-y-0.5">
{v.recentTouches.map((t, i) => (
<div key={`${t.at}-${i}`}>
{t.eventName}
{t.detail ? ` · ${t.detail}` : ""} · {new Date(t.at).toLocaleTimeString()}
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
)
}

View File

@@ -359,6 +359,17 @@ export default function StreamerStudioPage() {
</Button>
</div>
<div className="rounded-xl border border-border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Link href="/streamer/audience">
<Button size="sm" variant="secondary" className="gap-1">
<ExternalLink size={14} />
</Button>
</Link>
</div>
</div>
<div className="rounded-xl border border-border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>

View File

@@ -0,0 +1,39 @@
/**
* 客户端上报用户旅程埋点WM-O1-KR1-02
* 需在浏览器侧生成稳定的 sessionId 与每条 clientEventId建议 UUID
*/
export type JourneySource = "app" | "h5" | "admin"
export type JourneyEventPayload = {
eventName: string
properties?: Record<string, unknown>
sessionId: string
clientEventId: string
occurredAt?: string
source: JourneySource
}
export async function postJourneyEvents(
events: JourneyEventPayload[],
options?: { anonymousId?: string },
): Promise<{ inserted: number; deduped: number; invalid: number }> {
const res = await fetch("/api/analytics/journey-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
events,
...(options?.anonymousId ? { anonymousId: options.anonymousId } : {}),
}),
})
const json = (await res.json()) as {
success?: boolean
message?: string
data?: { inserted: number; deduped: number; invalid: number }
}
if (!res.ok || !json.success) {
throw new Error(json.message || "journey ingest failed")
}
return json.data ?? { inserted: 0, deduped: 0, invalid: 0 }
}

View File

@@ -0,0 +1,43 @@
/** 浏览器会话内稳定的旅程 sessionIdWM-O1-KR1-02 */
const SESSION_KEY = "wz_journey_sid"
/** 跨会话匿名设备 ID未登录也可上报旅程 */
const ANON_KEY = "wz_anon_id"
export function getOrCreateJourneySessionId(): string {
if (typeof window === "undefined") return "ssr"
try {
let sid = sessionStorage.getItem(SESSION_KEY)
if (!sid) {
sid =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `wz_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
sessionStorage.setItem(SESSION_KEY, sid)
}
return sid
} catch {
return `wz_fallback_${Date.now()}`
}
}
export function newJourneyClientEventId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID()
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
}
export function getOrCreateAnonymousId(): string {
if (typeof window === "undefined") return "ssr"
try {
let id = localStorage.getItem(ANON_KEY)
if (!id) {
id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `anon_${crypto.randomUUID()}`
: `anon_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
localStorage.setItem(ANON_KEY, id)
}
return id.slice(0, 128)
} catch {
return `anon_fallback_${Date.now()}`
}
}

View File

@@ -0,0 +1,29 @@
import { postJourneyEvents, type JourneyEventPayload } from "./journey-client"
import { getOrCreateAnonymousId, getOrCreateJourneySessionId, newJourneyClientEventId } from "./journey-session"
/**
* 批量旅程打点(同一 session每条独立 clientEventId。失败静默不打断业务。
*/
export function trackJourneyEvents(
events: Omit<JourneyEventPayload, "sessionId" | "clientEventId">[],
options?: { anonymousId?: string },
): void {
if (typeof window === "undefined" || events.length === 0) return
const sessionId = getOrCreateJourneySessionId()
const body: JourneyEventPayload[] = events.map((e) => ({
...e,
sessionId,
clientEventId: newJourneyClientEventId(),
}))
const anonymousId = options?.anonymousId ?? getOrCreateAnonymousId()
void postJourneyEvents(body, { ...options, anonymousId }).catch(() => undefined)
}
export function trackJourney(
eventName: string,
properties: Record<string, unknown>,
source: JourneyEventPayload["source"] = "app",
options?: { anonymousId?: string },
): void {
trackJourneyEvents([{ eventName, properties, source }], options)
}

View File

@@ -79,3 +79,33 @@ export async function postStreamerLiveAction(
}
return { ok: true, data: json.data }
}
export type AudienceViewerRow = {
viewerKey: string
displayLabel: string
viewerType: "user" | "anonymous"
lastTouchAt: string
maxWatchMarkSec: number
likeCount: number
commentCount: number
shareCount: number
giftCount: number
engagePercent: number
recentTouches: { eventName: string; at: string; detail?: string }[]
}
export async function fetchAudienceInsights(): Promise<
| { ok: true; data: { streamerId: string; windowDays: number; viewers: AudienceViewerRow[]; totalViewers: number } }
| { ok: false; status: number; message: string }
> {
const res = await fetch("/api/streamer/audience-insights", { credentials: "include", cache: "no-store" })
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
data?: { streamerId: string; windowDays: number; viewers: AudienceViewerRow[]; totalViewers: number }
message?: string
}
if (!res.ok || !json.success || !json.data) {
return { ok: false, status: res.status, message: json.message ?? "无法加载观众触达" }
}
return { ok: true, data: json.data }
}

View File

@@ -79,6 +79,12 @@ export const COLLECTIONS = {
// 代练(代练通等对接)
levelingProducts: "levelingProducts",
levelingOrders: "levelingOrders",
/** 用户旅程埋点WM-O1-KR1与 event_logs 命名对齐见数据架构) */
userJourneyEvents: "userJourneyEvents",
/** RFM 规则版本WM-O1-KR2-01 */
rfmRuleSets: "rfmRuleSets",
/** RFM 跑批结果快照WM-O1-KR3-01 */
rfmUserScores: "rfmUserScores",
} as const
export type CollectionName = keyof typeof COLLECTIONS
@@ -314,4 +320,19 @@ export const INDEX_SPECS: Record<string, IndexDescription[]> = {
{ key: { status: 1 }, name: "status_1" },
{ key: { createdAt: -1 }, name: "createdAt_-1" },
],
userJourneyEvents: [
{ key: { userId: 1, occurredAt: -1 }, name: "userId_occurredAt_-1", sparse: true },
{ key: { anonymousId: 1, occurredAt: -1 }, name: "anonymousId_occurredAt_-1", sparse: true },
{ key: { sessionId: 1, clientEventId: 1 }, name: "sessionId_clientEventId_1", unique: true },
{ key: { eventName: 1, occurredAt: -1 }, name: "eventName_occurredAt_-1" },
{ key: { "properties.streamerId": 1, occurredAt: -1 }, name: "props_streamerId_occurredAt_-1", sparse: true },
],
rfmRuleSets: [
{ key: { status: 1, version: -1 }, name: "status_version_-1" },
{ key: { name: 1, version: -1 }, name: "name_version_-1" },
],
rfmUserScores: [
{ key: { userId: 1, ruleSetVersion: 1 }, name: "userId_ruleSetVersion_1" },
{ key: { computedAt: -1 }, name: "computedAt_-1" },
],
}

View File

@@ -0,0 +1,126 @@
# OKR 任务拆解与排期(产研第 70 场对齐)
> **依据**`产研团队 第70场 20260413聊ai.txt`(中台/存客宝/用户旅程与锁客/直播腾讯或犀牛/商城分销/玩值与兑换/公会主播/CP 与音视频/模块化 APP 等)+ 王明政、许永平两套 OKR。
> **周期**2026-04-142026-05-31共 7 周,周次 W1W7
> **说明**:表内「负责人」以 OKR 归属为准;与仓库内 `模块负责人与代码映射总表.md` 中的陈电/李玩等可并行标注为协作,不替代业务 Owner。
---
## 一、会议摘录 → 开发板块映射(用于拆解锚点)
| 会议要点 | 归并板块 | 对应建设项 |
|----------|----------|------------|
| 中台:渠道 ID、内容分发、粉丝池、产品、分钱 | 中台与数据 | 渠道标识、分润、粉丝池统计、商品订单域 |
| 存客宝:私域承接、加微、谁点了什么/捆绑 | 存客宝与触达 | Webhook/回调、外部 ID 映射、旅程事件入库 |
| 用户旅程写清、停留、归因、商城内二次绑定 | 用户旅程与归因 | 埋点模型、绑定状态机、商城归因规则 |
| 第一阶段:登录 + 直播;直播腾讯/犀牛、礼物、课程 | 直播与 APP 骨架 | 登录会话、直播 SDK、房间/推流管理端 |
| 主播页:粉丝捆绑、收益可见 | 主播侧 | 绑定列表、收益摘要 API、管理端配置 |
| 首页:补贴 + 多直播间入口、锁客 | 用户端首页 | 直播间列表、活动位、进房来源参数 |
| 商城、分销、福禄点卡、接口化交付 | 商城与分销 | SKU、支付回调、分润流水、对账导出 |
| 玩值币/数字产品、兑换不提现、与估值打通预留 | 数字资产(合规) | 账务模型、兑换目录、与订单字段对齐 |
| 公会管理主播、排期、抽成、奖励递进 | 公会与运营策略 | 公会-主播关系、抽成规则表、活动阶梯配置 |
| CP/匹配付费、音视频第三方(腾讯为主) | 社交增值 | IM/音视频能力占位,与订单支付联动可后置 |
---
## 二、王明政 — OKR 任务拆解与排期
**Owner王明政**
**协作(按需)**:王值(需求/验收)、陈电/李玩(与现有 `app/``app/api/` 分工对齐)
### O1 用户管理与数据中台
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| WM-O1-KR1-01 | 用户旅程事件模型与埋点字典(观看主播、进房、浏览商品、停留时长、打赏) | 数据中台 / 埋点 | 王明政 | 2026-04-14 | 2026-04-20 | 无 | 字典含事件名、属性、写入端App/H5/管理端);**交付**`玩值开发文档/2、架构/用户旅程埋点字典_WM-O1-KR1-01.md` |
| WM-O1-KR1-02 | 埋点上报 API + 落库(或接入现有日志管道)与去重策略 | 数据中台 / 后端 | 王明政 | 2026-04-18 | 2026-04-25 | WM-O1-KR1-01 | 抽样查询可还原单次会话路径;**交付**`POST /api/analytics/journey-events` + `userJourneyEvents` 集合与唯一索引 |
| WM-O1-KR1-03 | 管理端「旅程查询」最小页(按用户 ID / 时间筛选) | 管理端 | 王明政 | 2026-04-22 | 2026-04-28 | WM-O1-KR1-02 | 可展示关键事件时间线;**交付**`/admin/journey` + `GET /api/admin/journey-events` |
| WM-O1-KR2-01 | RFM 规则配置表结构(消费额、观看时长等维度可配置) | 中台 / 规则引擎 | 王明政 | 2026-04-21 | 2026-04-30 | WM-O1-KR1-02 | 规则可增删改、生效版本号;**交付**`rfmRuleSets` + `GET/POST /api/admin/rfm-rules`(管理端列表与草稿创建见 `/admin/rfm-rules` |
| WM-O1-KR2-02 | 管理端 RFM 规则编辑页 + 权限 | 管理端 | 王明政 | 2026-04-25 | 2026-05-09 | WM-O1-KR2-01 | 非开发可配置一版默认规则 |
| WM-O1-KR3-01 | RFM 跑批任务(定时/手动)与结果表 | 数据中台 | 王明政 | 2026-05-05 | 2026-05-16 | WM-O1-KR2-01 | 单用户可输出 R/F/M 分值与标签 |
| WM-O1-KR3-02 | 用户估值看板(消费、购买、打赏、观看时长汇总) | 管理端 / 看板 | 王明政 | 2026-05-12 | 2026-05-26 | WM-O1-KR3-01 | 图表或表格可导出;口径与 KR2 一致 |
### O2 用户数据留存及转化
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| WM-O2-KR1-01 | 存客宝侧加微/好友状态回调对接设计(字段映射、幂等) | 存客宝 / 集成 | 王明政 | 2026-04-14 | 2026-04-22 | 存客宝账号与接口文档 | 回调验签与重试说明文档 |
| WM-O2-KR1-02 | 用户外部 ID ↔ 平台用户绑定表 + 同步任务 | 存客宝 / 后端 | 王明政 | 2026-04-20 | 2026-05-02 | WM-O2-KR1-01 | 测试号可走通绑定与更新 |
| WM-O2-KR1-03 | 旅程关键节点实时推送(内部消息队列或 Webhook 出站) | 触达 / 中台 | 王明政 | 2026-04-25 | 2026-05-12 | WM-O1-KR1-02、WM-O2-KR1-02 | 配置 1 条规则可触发测试推送 |
| WM-O2-KR2-01 | 用户偏好特征(常看主播、类目)聚合任务 | 推荐 / 数据 | 王明政 | 2026-05-06 | 2026-05-18 | WM-O1-KR3-01 | 输出可读的偏好标签表 |
| WM-O2-KR2-02 | 推送策略配置(推荐主播、商品)+ 发送记录 | 触达 | 王明政 | 2026-05-13 | 2026-05-28 | WM-O2-KR2-01 | 管理端可配频率上限与开关 |
### O3 主播管理及任务安排(工会)
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| WM-O3-KR1-01 | 直播场次/房间与主播、公会关联数据模型 | 直播管理 / 数据 | 王明政 | 2026-04-16 | 2026-04-24 | XY-O1 房间 ID 方案对齐 | ER 图 + 与订单/礼物可选关联 |
| WM-O3-KR1-02 | 管理端:直播排期与时段配置(读写字段) | 管理端 | 王明政 | 2026-04-22 | 2026-05-09 | WM-O3-KR1-01 | 公会运营可维护排期 |
| WM-O3-KR1-03 | 主播粉丝画像摘要接口(供排期参考,只读) | 直播管理 / API | 王明政 | 2026-05-05 | 2026-05-16 | WM-O1-KR3-01 | 返回粉丝消费力粗分档 |
| WM-O3-KR2-01 | 礼物与商品分账比例配置(按主播/公会维度) | 分润 / 管理端 | 王明政 | 2026-04-28 | 2026-05-14 | 现有订单与分销模块 | 配置变更留痕、生效时间 |
| WM-O3-KR2-02 | 抽成与订单试算接口(不改变线上结算可先影子模式) | 分润 / 后端 | 王明政 | 2026-05-10 | 2026-05-23 | WM-O3-KR2-01 | 输入订单可输出各方分成预览 |
| WM-O3-KR3-01 | 直播奖励递进规则表 + 管理端配置 | 运营策略 / 管理端 | 王明政 | 2026-05-12 | 2026-05-30 | WM-O3-KR2-01 | 至少 3 档阶梯可配置 |
---
## 三、许永平 — OKR 任务拆解与排期
**Owner许永平**
**协作(按需)**:王值(产品边界)、陈电/李玩(接口联调)
### O1 对接主播引流粉丝、增大粉丝蓄水池
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| XY-O1-KR1-01 | 蓄水落地页 / 活码参数 `channel_id` + 进端埋点 | 用户端 / 小程序 | 许永平 | 2026-04-14 | 2026-04-21 | 无 | 可从链接区分主播来源 |
| XY-O1-KR1-02 | App 路由模块化拆分(按会议「拆路由、减单文件体积」) | App 架构 | 许永平 | 2026-04-18 | 2026-05-02 | 无 | 核心 tab 与直播入口独立 chunk/路由 |
| XY-O1-KR1-03 | 首页:补贴位 + 多直播间入口(锁客导向) | 用户端首页 | 许永平 | 2026-04-25 | 2026-05-12 | XY-O2-KR1-04 直播列表数据源 | 点击进入带 source 参数 |
| XY-O1-KR2-01 | 主播侧「绑定粉丝列表」页(只读 MVP | 主播端 | 许永平 | 2026-05-06 | 2026-05-20 | WM 归因/用户绑定 API | 与后台数据一致抽样通过 |
| XY-O1-KR2-02 | 主播入驻引导流程(表单 + 状态) | 主播端 / 运营 | 许永平 | 2026-05-13 | 2026-05-28 | 无 | 可提交审核状态流转 |
### O2 构建商业分销体系、增强主播变现能力
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| XY-O2-KR1-01 | 登录与会话(会议 P1先登录再直播 | 用户与身份 | 许永平 | 2026-04-14 | 2026-04-20 | 现有 auth | 小程序与管理端 TOKEN 隔离已保留 |
| XY-O2-KR1-02 | 直播 SDK 集成 POC腾讯云优先备选犀牛 | 直播 | 许永平 | 2026-04-18 | 2026-05-05 | XY-O2-KR1-01 | 内测可开播、收流 |
| XY-O2-KR1-03 | 观众端直播间页:弹幕/进房(最小集) | 直播 | 许永平 | 2026-04-28 | 2026-05-12 | XY-O2-KR1-02 | 与 WM 房间模型字段对齐 |
| XY-O2-KR1-04 | 主播端开播端封面标题、OBS/推流地址配置 | 直播 | 许永平 | 2026-05-05 | 2026-05-18 | XY-O2-KR1-02 | 主播可独立完成一次开播 |
| XY-O2-KR1-05 | 礼物与打赏入口(可先对接已有订单类型) | 直播 / 商城 | 许永平 | 2026-05-12 | 2026-05-26 | WM-O3-KR2-01 | 打赏产生订单流水 |
| XY-O2-KR1-06 | 商城商品页挂「推荐码/分销参数」与直播间小黄车占位 | 商城 / 分销 | 许永平 | 2026-05-06 | 2026-05-23 | 王明政 分润接口 | 从直播进商详带 attribution |
| XY-O2-KR1-07 | 分销链路联调(归因 → 下单 → 分润预览) | 商城 / 分销 | 许永平 | 2026-05-15 | 2026-05-30 | WM-O3-KR2-02 | 端到端测试单通过 |
### O3 以 Web5 技术协助玩家多元变现增值
| 任务 ID | 任务说明 | 板块 | 负责人 | 计划开始 | 计划结束 | 依赖 | 验收要点 |
|---------|----------|------|--------|----------|----------|------|----------|
| XY-O3-KR1-01 | 游戏账号交易与内容生产:链/哈希锚定或审计日志方案技术一页纸 | 数字资产 / 架构 | 许永平 | 2026-04-21 | 2026-04-30 | 合规评审 | 评审记录存档 |
| XY-O3-KR1-02 | 账号内容存证 POC生成 hash、校验接口 | 数字资产 | 许永平 | 2026-05-06 | 2026-05-20 | XY-O3-KR1-01 | Postman/脚本可演示 |
| XY-O3-KR2-01 | 多元变现产品映射表(陪练/代练/点卡/酒店等)与路由入口 | 产品架构 / App | 许永平 | 2026-05-01 | 2026-05-15 | 现有 mall/services 路由 | 架构图与代码路由一致 |
| XY-O3-KR2-02 | 订单域字段对齐:`order_id` / 分润主体 / 结算状态 与数字资产预留关联 | 集成 | 许永平 | 2026-05-13 | 2026-05-30 | 王明政 订单表 | 数据字典更新并入仓库文档 |
---
## 四、周历总览(甘特简表)
| 周次 | 日期范围 | 王明政重点 | 许永平重点 |
|------|----------|------------|------------|
| W1 | 04-1404-20 | 埋点字典、存客宝对接设计、RFM 表结构启动 | 登录巩固、蓄水页 channel、直播 POC 启动 |
| W2 | 04-2104-27 | 埋点 API、旅程查询、公会直播数据模型 | 直播 POC、App 路由拆分 |
| W3 | 04-2805-04 | RFM 规则编辑、直播排期管理、分账配置启动 | 观众直播间 MVP、首页直播间入口 |
| W4 | 05-0505-11 | 存客宝绑定表、RFM 跑批、推送策略 | 主播开播端、商城分销参数 |
| W5 | 05-1205-18 | 旅程推送、用户偏好、直播奖励方案 | 礼物入口、变现路由映射 |
| W6 | 05-1905-25 | 用户估值看板、抽成试算、奖励阶梯 | 分销 E2E、存证 POC |
| W7 | 05-2605-31 | 看板导出与规则验收、整体验收口 | 字段对齐与整链演示 |
---
## 五、风险与依赖(仅列阻塞项)
- **存客宝**:接口与凭证到位时间影响 WM-O2-KR1 全系。
- **直播云厂商**:腾讯云 key/计费开通影响 XY-O2-KR1-02 及后续。
- **分润规则**:产品与法务口径确认影响 WM-O3-KR2 与 XY-O2-KR1-07。
---
*文档版本2026-04-14与《开发进度总表》同步引用。*

View File

@@ -83,7 +83,12 @@
1.`模块负责人与代码映射总表.md` 为主清单,逐模块补“接口状态/联调状态/验收状态”三列。
2. AI互动与任务撮合模块进入 API 细化(`5、接口`)与集合设计(`7、数据库`)。
3. 大屏完成迁移后由张星按部署文档完成现网启动与回归验收。
4. **OKR 驱动排期**:产研第 70 场会议要点 + 王明政 / 许永平 OKR 已拆解为任务表与周历,见 **`OKR任务拆解与排期_王明政_许永平_4-5月.md`**(含任务 ID、板块、负责人、起止日期、依赖与验收
5. **王明政 O1数据中台代码落地2026-04-14**:埋点字典文档、`userJourneyEvents` / `rfmRuleSets` / `rfmUserScores` 集合与索引、`POST /api/analytics/journey-events``GET /api/admin/journey-events`、管理端 `/admin/journey``/admin/rfm-rules`(默认规则草稿)、`lib/analytics/journey-client.ts`
6. **王明政 O1 续2026-04-14**:已执行 `pnpm db:mongo:indexes`;业务侧已接 `live.room.enter``live.gift.send``app/live/[id]/page.tsx`)、`mall.product.view``app/mall/points/[game]/client.tsx`);封装 `lib/analytics/journey-session.ts``journey-track.ts`
7. **王明政 O1 再续2026-04-14**`getOrCreateAnonymousId` 贯通客户端上报;`live.room.stay.heartbeat``streamer.profile.view``app/live/page.tsx`RFM 单条 `PUT/PATCH``app/api/admin/rfm-rules/[id]/route.ts`+ 管理端 JSON 编辑与激活;用户估值摘要 `GET /api/admin/user-value-summary` + `/admin/user-value`
8. **观众→主播触达2026-04-14**:埋点 `live.audience.*`20s/30s 里程碑、赞/评/享);进房后自动 `POST /api/streamer/bind`(无其它绑定时);`GET /api/streamer/audience-insights` + 主播页 `/streamer/audience` + 工作台入口;`userJourneyEvents` 增加 `properties.streamerId` 索引。
---
*最后更新2026-04-10已完成需求文档与代码映射整合)。*
*最后更新2026-04-14OKR 文档名修正 + 王明政 O1 数据中台首轮代码交付说明)。*

View File

@@ -22,7 +22,7 @@ graph TD
- 任务域:`tasks``task_claims``task_submissions``task_rewards`
- 交易收益域:`orders``payments``wallet_ledgers``revenue_splits`
- AI 域:`ai_sessions``ai_messages``ai_jobs``ai_cost_logs`
- 运营域:`event_logs``metrics_daily``risk_events`
- 运营域:`event_logs``metrics_daily``risk_events`**用户旅程与 RFMWM-O1**`userJourneyEvents``rfmRuleSets``rfmUserScores`(与 `lib/db/mongo/collections.ts` 一致)
## 4. 存客宝数据策略(强制)

View File

@@ -0,0 +1,62 @@
# 用户旅程埋点字典WM-O1-KR1-01
> **对齐**:王明政 O1-KR1 用户旅程事件模型;写入端覆盖 App / H5 / 管理端。
> **落库**`userJourneyEvents`(见 `lib/db/mongo/collections.ts`),经 `POST /api/analytics/journey-events` 上报。
---
## 1. 公共字段(所有事件)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `eventName` | string | 是 | 下表「事件名」枚举之一 |
| `properties` | object | 否 | 事件私有属性(见各事件) |
| `sessionId` | string | 是 | 端上会话 ID同一会话内唯一 |
| `clientEventId` | string | 是 | 端上生成的唯一 ID用于与服务端去重 |
| `occurredAt` | string (ISO8601) | 否 | 事件发生时间;缺省为服务端接收时间 |
| `source` | `"app"` \| `"h5"` \| `"admin"` | 是 | 写入端 |
服务端补充:`userId`(来自登录 cookie可空`anonymousId`(未登录 H5 可选)、`receivedAt`
---
## 2. 事件一览
| 事件名 | 说明 | 典型 properties | 写入端 |
|--------|------|-----------------|--------|
| `streamer.profile.view` | 观看/进入主播个人页 | `streamerId`, `durationMs?`, `sourceTab?` | App, H5 |
| `live.room.enter` | 进房(进入直播间) | `liveRoomId`, `streamerId`, `entrySource?`(如 home、deeplink、mall | App, H5 |
| `live.room.stay.heartbeat` | 停留心跳(用于汇总时长) | `liveRoomId`, `streamerId`, `elapsedMs`(建议与发送间隔一致), `seq` | App, H5 |
| `streamer.profile.view` | 推荐流/卡片曝光主播(非详情页) | `streamerId`, `sourceTab?`(如 `live_feed`, `cardIndex?` | App, H5 |
| `mall.product.view` | 浏览商品详情 | `productId`, `fromLiveRoomId?`, `attribution?` | App, H5 |
| `mall.product.stay.heartbeat` | 商详停留 | `productId`, `elapsedMs`, `seq` | App, H5 |
| `live.gift.send` | 打赏/送礼(业务成功后补记) | `liveRoomId`, `streamerId`, `giftId`, `amount`, `orderId?` | App, H5, 管理端 |
| `live.audience.watch_milestone` | 单主播观看达到 20s / 30s 里程碑 | `streamerId`, `liveRoomId?`, `secondMark`20 或 30, `surface``live_room` \| `live_feed` | App, H5 |
| `live.audience.like` | 点赞(直播间或推荐流) | `streamerId`, `liveRoomId?`, `surface` | App, H5 |
| `live.audience.comment` | 评论成功 | `streamerId`, `liveRoomId?`, `surface`, `length`(字数,不含原文) | App, H5 |
| `live.audience.share` | 分享/复制链接 | `streamerId`, `liveRoomId?`, `platform`(渠道名或 `copy_link`, `surface` | App, H5 |
**主播侧查看**`GET /api/streamer/audience-insights`(已登录且为已审核主播)汇总上表触达,并给出 **触达分 `engagePercent`0100**;工作台入口:`/streamer/audience`
**捆绑(默认最简)**:用户进房且已登录、成功 `postLiveEnter` 后,客户端自动调用 `POST /api/streamer/bind``userId` + `streamerId`);若用户已有未过期绑定则不改(沿用现有 `streamerBindings` 策略)。
---
## 3. 属性约定
- **ID 类**:一律传 Mongo `ObjectId` 字符串,与业务 API 一致。
- **时长**:毫秒非负整数;心跳间隔由客户端控制(建议 1530s
- **去重**:同一 `sessionId` + `clientEventId` 重复上报将被忽略HTTP 仍 200`deduped` 计数)。
---
## 4. 接口索引
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/analytics/journey-events` | 批量上报body.events 数组) |
| GET | `/api/admin/journey-events` | 管理端按 `userId` + 时间筛选 |
---
*版本2026-04-14随 WM-O1-KR1-02 实现同步维护。*

View File

@@ -0,0 +1,64 @@
# GiteaRelease / Project / Issues 一键填充HTTP API
> 在**本机终端**执行;将 `YOUR_FNVTK_PASSWORD` 换成 **fnvtk** 的 Gitea 密码,或先 `export FNVTK_PASSWORD=…`。
> **勿**把密码写入仓库或提交到 Git。
## 1. 创建 Release版本发布
```bash
export GITEA="http://192.168.1.201:3000"
export FNVTK_PASSWORD="YOUR_FNVTK_PASSWORD"
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "$GITEA/api/v1/repos/fnvtk/wzdj/releases" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "wzdj-baseline-2026.04.14",
"target_commitish": "main",
"name": "玩值电竞 · 百科与元数据基线",
"body": "## 概要\n- 百科:多页 + 侧栏\n- 仓库:`.gitea/` 工单模板、PR 模板、Actions 占位\n- 详见 Wiki [Home](Home)",
"draft": false,
"prerelease": false,
"make_latest": true
}'
```
若返回 `422` 且提示 tag 已存在,可改 `tag_name` 或忽略。
## 2. 创建 Project项目看板
```bash
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "$GITEA/api/v1/repos/fnvtk/wzdj/projects" \
-H "Content-Type: application/json" \
-d '{"title":"玩值电竞 交付看板","description":"待办 → 进行中 → 待发布 → 已上线"}'
```
返回 JSON 中的 `id`**project_id**。在 Gitea 网页打开该 Project**手动添加列**(不同版本 REST 路径可能不一致):`待办` / `进行中` / `待发布` / `已上线`
## 3. 创建 Issues工单模板
```bash
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "$GITEA/api/v1/repos/fnvtk/wzdj/issues" \
-H "Content-Type: application/json" \
-d '{"title":"【模板】Bug 报告","body":"复现步骤、期望行为、分支/commit、脱敏日志"}'
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "$GITEA/api/v1/repos/fnvtk/wzdj/issues" \
-H "Content-Type: application/json" \
-d '{"title":"【模板】功能建议","body":"用户故事、验收标准、是否影响直播/支付"}'
```
## 4. Wiki 首次无 Git 远程时
`git clone …wzdj.wiki.git` 失败,先创建首页(**multipart**
```bash
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "$GITEA/api/v1/repos/fnvtk/wzdj/wiki/new" \
-F "title=Home" \
-F "content=# 临时首页\n\n下一行将用 git 推送覆盖为完整百科。" \
-F "message=init"
```
再执行仓库内脚本:`bash 玩值开发文档/8、部署/sync_gitea_wiki.sh`(需 `FNVTK_PASSWORD` 或钥匙串)。
## 5. Packages软件包
本阶段以 **源码 + 服务器构建** 为主;若需 **npm / OCI** 制品,见百科页 **[软件包与制品说明](软件包与制品说明)**,并在有 Runner 时扩展 `.gitea/workflows/`

View File

@@ -17,6 +17,14 @@
完成后:`GET /api/v1/repos/fnvtk/wzdj` 应为 **200**,浏览器不再 404。
### 百科Wiki与「版本发布 / 项目 / 软件包」等标签页
- **百科源 Markdown**`玩值开发文档/8、部署/gitea-wiki/`(可版本管理)。
- **同步到 Gitea**`FNVTK_PASSWORD='…' bash 玩值开发文档/8、部署/sync_gitea_wiki.sh`(脚本内若克隆失败会自动 **HTTP 创建首页** 再重试)。
- **Release / Project / Issues**:见 **`玩值开发文档/8、部署/Gitea_API_一键填充说明.md`**curl 模板,避免把密码写入仓库)。
- **Actions**:见 `.gitea/workflows/ci.yml`(占位;需 Gitea Actions Runner 才真正跑 `pnpm`)。
- **工单模板**`.gitea/ISSUE_TEMPLATE/`**PR 模板**`.gitea/pull_request_template.md`
## 〇、从其它账号迁到 fnvtk必统一到一处
若历史上存在 **`yongpxu/wzdj`** 等副本,请**迁到 `fnvtk/wzdj`** 后只保留一处:

View File

@@ -0,0 +1,38 @@
# 玩值电竞 Appwzdj· 百科首页
> 本仓库:**Next.js** 电竞平台 Web 应用(直播、主播工作台、用户侧页面等)。
> **Gitea**`http://192.168.1.201:3000/fnvtk/wzdj` · **生产预览**`https://wzdj.quwanzhi.com`(以实际部署为准)
> **与卡若AI协作**需求、Skill、复盘与流程以 **卡若AI** 仓库为真源;本百科描述 **玩值业务与部署**,不写入口令与明文密钥。
---
## 快速导航
| 主题 | 说明 |
|------|------|
| [项目概述与范围](项目概述与范围) | 产品边界、当前迭代重点 |
| [本地开发与运行](本地开发与运行) | `pnpm`、端口 **3088**、Mongo、环境变量 |
| [架构与技术栈](架构与技术栈) | Next.js App Router、Mongo、直播相关模块 |
| [直播与主播工作台](直播与主播工作台) | 开播流程、API 目录、云厂商解析 |
| [部署与运维](部署与运维) | Gitea、宝塔/PM2、内网与外网 |
| [版本发布与标签约定](版本发布与标签约定) | 与 Gitea **Releases**、标签对齐 |
| [项目看板使用说明](项目看板使用说明) | Gitea **Projects** 列与工单联动 |
| [软件包与制品说明](软件包与制品说明) | **Packages**、Docker、npm 与现状 |
| [工单与合并请求规范](工单与合并请求规范) | Issues / PR 模板与自检项 |
---
## 当前基线文档更新日2026-04-14
- **开发端口**`pnpm dev` 使用 **`http://localhost:3088`**(勿与 3000 端口其它服务混淆)。
- **数据库**MongoDB连接串见项目根 `.env.example` / `.env.local`
- **直播**:见需求目录 `玩值开发文档/1、需求/修改/` 与 API `app/api/live/``app/api/streamer/`
- **代码同步**:默认推 **Gitea `fnvtk/wzdj`**,说明见仓库内 `玩值开发文档/8、部署/Gitea同步说明.md`
---
## 维护约定
1. **百科与代码**:重大行为变更(部署方式、端口、环境变量名)须同时改 **百科****`玩值开发文档`**。
2. **发布**:对外里程碑在 **版本发布** 建 Release可复制命令见仓库 **`玩值开发文档/8、部署/Gitea_API_一键填充说明.md`**。
3. **卡若AI**:统一使用名称 **卡若AI**;语音/输入法误写由卡若侧 ASR 纠错库处理,本百科正文不使用旧别名。

View File

@@ -0,0 +1,12 @@
## 百科目录
- [首页](Home)
- [项目概述与范围](项目概述与范围)
- [本地开发与运行](本地开发与运行)
- [架构与技术栈](架构与技术栈)
- [直播与主播工作台](直播与主播工作台)
- [部署与运维](部署与运维)
- [版本发布与标签约定](版本发布与标签约定)
- [项目看板使用说明](项目看板使用说明)
- [软件包与制品说明](软件包与制品说明)
- [工单与合并请求规范](工单与合并请求规范)

View File

@@ -0,0 +1,35 @@
# 工单与合并请求规范
## Issues工单
### 提交前
1. 确认是否已有同类工单(搜索标题关键词:`直播``Mongo``部署`)。
2. 选择合适模板(若仓库已配置):**功能建议** / **Bug** / **任务报备**
### 正文建议包含
- **现象**URL、用户角色、复现步骤。
- **期望**:正确行为描述。
- **环境**:分支/commit、开发或生产、浏览器版本。
- **日志**:可粘贴脱敏后的接口返回或 PM2 日志片段。
## Pull Requests合并请求
### 自检清单(合并前)
- [ ] `pnpm lint` 通过(或说明例外原因)
- [ ] 涉及接口时补充/更新 **玩值开发文档** 或本 **百科**
- [ ] 无密钥与内网密码入库
- [ ] 大变更已在工单或看板中关联
### 评审关注点
- 直播与支付相关:**边界条件**与错误处理。
- 数据库:**索引与迁移**是否可逆。
- 性能:列表分页、避免 N+1 查询。
## 与卡若AI的边界
- **卡若AI**跨项目规范、Skill、复盘格式。
- **本仓库**:玩值业务与部署;工单中引用卡若文档时用 **「卡若AI」** 统一称呼。

View File

@@ -0,0 +1,37 @@
# 本地开发与运行
## 环境要求
- **Node.js**:与 `package.json` / `pnpm` 锁文件一致(建议使用 **pnpm**)。
- **MongoDB**:本地或内网可访问实例;连接串写入 **`.env.local`**(勿提交)。
- **操作系统**macOS / Linux 均可Windows 建议使用 WSL2 以减少路径问题。
## 安装与启动
```bash
pnpm install
pnpm dev
```
浏览器打开:**`http://localhost:3088`**
> **重要**:开发服务固定为 **3088** 端口(见 `package.json` 中 `next dev -p 3088`)。若误用 **3000**,可能出现其它项目占用或路由 404如 `/live`)。
## 数据库
- 示例变量:见项目根 **`.env.example`**。
- 初始化种子(按需):`pnpm db:mongo:seed`
- 索引维护(若有脚本):`pnpm db:mongo:indexes`(以 `package.json` 为准)
## 常用脚本(摘录)
| 命令 | 用途 |
|------|------|
| `pnpm build` | 生产构建 |
| `pnpm start` | 生产启动(默认 Next 端口,部署时由 PM2/平台覆盖) |
| `pnpm db:set-password` | 开发期重置用户密码(脚本说明见源码) |
| `pnpm db:bind-streamer` | 绑定审核通过主播等(见脚本注释) |
## 直播开播(产品操作)
图文步骤:**`玩值开发文档/1、需求/修改/直播开播操作教程_20260413.md`**

View File

@@ -0,0 +1,33 @@
# 架构与技术栈
## 前端框架
- **Next.js**App Router页面与 API Route 均在 `app/` 下。
- **React** + **Tailwind CSS** + **Radix UI** 等(见 `package.json` 依赖)。
## 数据层
- **MongoDB**:集合定义与访问在 `lib/db/mongo/`;种子 `lib/db/mongo/seed-mongo.ts`
- 业务 API 与页面通过服务端组件 / Route Handler 访问数据库。
## 直播与推流
- **云厂商解析**`lib/live/cloud/`(类型、解析入口、各厂商占位/实现)。
- **常量与礼物目录**`lib/live/constants.ts``lib/live/gift-catalog.ts` 等。
- **客户端组件**:如 `components/live/live-playback-video.tsx`
## 鉴权与主播准入
- 主播相关 API 前校验:`lib/streamer/ensure-approved-streamer.ts`(名称以仓库为准)。
## 目录结构(高层)
```
app/ # 页面与 API
components/ # 通用与业务组件
lib/ # API 封装、DB、直播逻辑
scripts/ # 运维/数据脚本
玩值开发文档/ # 需求、架构、部署、调研
```
更细的库表说明见 **`玩值开发文档/2、架构/数据库.md`**(若与代码不一致,以迁移后的代码为准并回写文档)。

View File

@@ -0,0 +1,26 @@
# 版本发布与标签约定
## 与 Gitea「版本发布」的关系
- **Release**:面向测试/运营/老板的**可读变更说明** + 可选附件构建产物、SQL 包等)。
- **Git Tag**:与 `package.json``version` 不必强行一致,但建议在发版时**同时打 tag**,便于回滚。
## 推荐标签格式
| 类型 | 示例 | 说明 |
|------|------|------|
| 里程碑 | `v0.1.0` | 首个可演示版本 |
| 日期基线 | `wzdj-2026.04.14` | 与百科/Release 文案对齐的基线 |
## Release 正文建议结构
1. **概要**:本版目标(例如「直播 API 初版 + 文档补齐」)。
2. **新增 / 变更**:条列用户可见与接口变更。
3. **迁移**:数据库迁移、环境变量新增(只写变量名)。
4. **已知问题**:限制与后续计划。
5. **验证**:已执行的 `pnpm build`、冒烟路径(如 `/live`)。
## 谁负责发版
- **开发**:打 tag、写 Release、推 Gitea/GitHub。
- **运维**:按 Release 说明在服务器拉代码、安装依赖、构建、重启 PM2。

View File

@@ -0,0 +1,30 @@
# 直播与主播工作台
## 用户侧
- **直播间**`app/live/[id]/page.tsx`(以仓库为准)。
- **回放**`components/live/live-playback-video.tsx`
## API 路由(示例)
以下路径随迭代可能增减,以 `app/api/` 实际目录为准:
- `app/api/live/comments/[streamerId]/` — 评论相关
- `app/api/live/context/[streamerId]/` — 上下文
- `app/api/live/gift/` — 礼物
- `app/api/streamer/live/` — 主播开播状态
- `app/api/streamer/live/cloud/` — 云端推流配置
- `app/api/streamer/me/` — 当前主播信息
## 主播工作台
- 页面:`app/streamer/studio/page.tsx`
- 客户端封装:`lib/api/streamer-studio.ts``lib/api/live.ts`
## 云厂商
统一从业务配置解析到具体厂商(阿里、腾讯、七牛等),入口见 **`lib/live/cloud/resolve.ts`** 及各厂商文件。
## 操作教程
**`玩值开发文档/1、需求/修改/直播开播操作教程_20260413.md`**

View File

@@ -0,0 +1,28 @@
# 软件包与制品说明Packages
## 当前阶段说明
**玩值电竞 App****源码交付 + 服务器构建pnpm build** 为主,**未强制**将运行时发布到 Gitea **Packages** 注册表。本页说明:何时需要开 Packages、与 Docker/镜像的关系。
## Gitea Packages 能做什么
- **npm**:私有 scope 包发布到 Gitea npm registry适合抽离共用 SDK
- **Container**Docker/OCI 镜像推送(适合标准化部署单元)。
- **Generic**任意压缩包SQL 迁移包、静态资源包等)。
## 与本项目的关系
| 制品类型 | 现状建议 |
|----------|----------|
| 生产运行 | 服务器 Git 拉取 + `pnpm install` + `pnpm build` + PM2 |
| 镜像(可选) | 若后续 `docker/` 流程固化,可构建镜像并 `docker push` 到 Gitea **Container** 注册表 |
| npm 包(可选) | 若抽出 `lib/live` 等为独立包,再配置 **npm** registry |
## 安全
- **禁止**将 `.env`、云密钥、Mongo 口令打进任何包体。
- 发布前用 `.dockerignore` / `npmignore` 排除敏感文件。
## 后续动作(可选)
若启用 **Container**:在 CIGitea Actions`docker build``docker push``192.168.1.201:3000` 对应 registry并在 **版本发布** 中写明镜像 tag 与部署命令。

View File

@@ -0,0 +1,24 @@
# 部署与运维
## Gitea源码真源
- **仓库**`http://192.168.1.201:3000/fnvtk/wzdj`
- **默认账号**`fnvtk` 名下 **`wzdj`**(详见 `玩值开发文档/8、部署/Gitea同步说明.md`)。
- **百科**:通过 Wiki 仓库或网页维护;本套 Markdown 源在 **`玩值开发文档/8、部署/gitea-wiki/`**,可用脚本同步到 Gitea Wiki。
## 生产环境(示例)
- **域名**`https://wzdj.quwanzhi.com`(见项目管理文档)。
- **进程**:宝塔 **PM2** 管理 Node 项目 **`wzdj`**;端口以服务器配置为准(文档中曾用 **3055** 等,以现网为准)。
- **一键上传构建**(若使用仓库脚本):`玩值开发文档/8、部署/上传并部署到wzdj服务器.sh`(依赖 `sshpass`、SSH 端口等,见脚本内变量)。
## 环境变量
- **勿**在百科与工单中粘贴 `.env` 全文。
- 仅列出变量**名称**与含义,真值存 **服务器环境 / 密钥管理**
## 回滚思路
1. Gitea / GitHub 检出上一稳定 **tag****commit**
2. 服务器目录重新 `pnpm install && pnpm build`
3. **PM2 restart wzdj**(或等价命令)。

View File

@@ -0,0 +1,23 @@
# 项目概述与范围
## 产品定位
**玩值电竞 App** 是面向电竞场景的 Web 应用:用户侧浏览、直播观看与互动、主播侧开播与工作室能力等。与 **卡若AI** 体系协作时:本仓库负责 **业务代码与玩值文档**,卡若侧负责 **跨项目 Skill、复盘与运营中枢**
## 当前能力边界(随迭代扩展)
- **用户与资料**:登录、个人资料等基础能力(以代码与 `app/profile` 等为准)。
- **直播**:直播间页、回放组件、评论/上下文/礼物等 API`app/api/live/``app/live/`)。
- **主播**:主播工作台、开播与云端推流配置解析(阿里云/腾讯/七牛等抽象,见 `lib/live/cloud/``app/api/streamer/``app/streamer/studio`)。
- **数据**MongoDB 集合与种子脚本(`lib/db/mongo/``pnpm db:mongo:seed`)。
## 非目标(本阶段)
- 不替代网吧计费/桌面管控等 **线下计费系统** 全链路(竞品调研见 `玩值开发文档/1、需求/调研/`)。
- 不在百科中存放 **生产密钥、Mongo 口令、云厂商 AK/SK**;仅说明变量名与配置位置。
## 相关文档(仓库内)
- 需求与计划:`玩值开发文档/1、需求/`
- 架构与库表:`玩值开发文档/2、架构/`
- 部署与 Gitea`玩值开发文档/8、部署/`

View File

@@ -0,0 +1,24 @@
# 项目看板使用说明Gitea Projects
## 用途
**Projects** 用于把「需求 → 开发 → 待发布 → 已上线」可视化,与 **Issues** 联动(将工单拖入列中)。
## 推荐列(看板)
| 列名 | 含义 |
|------|------|
| 待办 | 已确认需求,未排期或未开工 |
| 进行中 | 开发或联调中 |
| 待发布 | 已合并主分支,等待部署窗口 |
| 已上线 | 生产验证通过 |
## 使用方式
1. 在仓库页打开 **项目** → 选择或新建看板。
2. 新建 **工单** 描述需求,关联里程碑(若有)。
3. 开发完成后把工单拖到 **待发布**;运维部署后拖到 **已上线**
## 与玩值文档的对应
- 大需求仍写在 **`玩值开发文档/1、需求/`**;看板工单可链到具体 Markdown 路径或 Gitea 提交。

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# 将「玩值开发文档/8、部署/gitea-wiki/」下的 Markdown 同步到 Gitea Wikifnvtk/wzdj.wiki
# 依赖:本机已安装 git环境变量
# export FNVTK_PASSWORD='…' # 或已在钥匙串配置 192.168.1.201 + fnvtk
#
# 用法(仓库根): FNVTK_PASSWORD='…' bash 玩值开发文档/8、部署/sync_gitea_wiki.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SRC="${REPO_ROOT}/玩值开发文档/8、部署/gitea-wiki"
GITEA_HOST="${GITEA_HOST:-192.168.1.201:3000}"
OWNER="${GITEA_OWNER:-fnvtk}"
REPO="${GITEA_REPO:-wzdj}"
if [[ -z "${FNVTK_PASSWORD:-}" ]]; then
FNVTK_PASSWORD=$(security find-internet-password -s 192.168.1.201 -a fnvtk -w 2>/dev/null || true)
fi
if [[ -z "${FNVTK_PASSWORD:-}" ]]; then
echo "[sync_gitea_wiki] 请 export FNVTK_PASSWORD=… 或在钥匙串保存 fnvtk@192.168.1.201" >&2
exit 1
fi
WIKI_URL="http://fnvtk:${FNVTK_PASSWORD}@${GITEA_HOST}/${OWNER}/${REPO}.wiki.git"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
GITEA_HTTP="${GITEA_HTTP:-http://${GITEA_HOST}}"
if git clone "$WIKI_URL" "$TMP/wiki" 2>/dev/null; then
:
else
echo "[sync_gitea_wiki] Wiki 克隆失败,尝试用 HTTP 创建首页以初始化 .wiki 仓库…" >&2
curl -sS -u "fnvtk:${FNVTK_PASSWORD}" -X POST "${GITEA_HTTP}/api/v1/repos/${OWNER}/${REPO}/wiki/new" \
-F "title=Home" \
-F "content=# Wiki 初始化\n\n下一提交将替换为完整百科。" \
-F "message=init" >/dev/null || true
sleep 1
git clone "$WIKI_URL" "$TMP/wiki" || {
echo "[sync_gitea_wiki] 仍无法克隆,请查:玩值开发文档/8、部署/Gitea_API_一键填充说明.md §4" >&2
exit 1
}
fi
cp "${SRC}"/*.md "$TMP/wiki/"
cd "$TMP/wiki"
git add -A
if git diff --cached --quiet; then
echo "[sync_gitea_wiki] 无变更"
exit 0
fi
git -c user.email=gitea-wiki@local -c user.name=wiki-sync commit -m "docs(wiki): sync from 玩值 gitea-wiki $(date '+%Y-%m-%d %H:%M')" || true
if git show-ref --verify --quiet refs/heads/main; then
BR=main
elif git show-ref --verify --quiet refs/heads/master; then
BR=master
else
BR=$(git branch --show-current || echo main)
fi
git push -u origin "$BR" 2>/dev/null || git push -u origin "$BR:main" || git push -u origin HEAD:main
echo "[sync_gitea_wiki] 完成 → http://${GITEA_HOST}/${OWNER}/${REPO}/wiki"