chore: sync docs, gitea wiki assets, templates
Some checks failed
CI / notice (push) Has been cancelled
Some checks failed
CI / notice (push) Has been cancelled
Made-with: Cursor
This commit is contained in:
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 缺陷与异常行为
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
|
||||
## 期望行为
|
||||
|
||||
|
||||
## 实际行为
|
||||
|
||||
|
||||
## 环境
|
||||
|
||||
- 分支 / commit:
|
||||
- 开发 / 生产:
|
||||
- 浏览器(若相关):
|
||||
|
||||
## 日志或截图(脱敏)
|
||||
|
||||
```
|
||||
粘贴关键日志或接口返回
|
||||
```
|
||||
27
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
27
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 新功能或改进
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## 用户故事
|
||||
|
||||
作为 … 我希望 … 以便 …
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## 影响范围
|
||||
|
||||
- [ ] 直播 / 推流
|
||||
- [ ] 支付 / 订单
|
||||
- [ ] 管理后台
|
||||
- [ ] 其它:
|
||||
|
||||
## 参考文档
|
||||
|
||||
- Wiki:
|
||||
- 玩值开发文档路径:
|
||||
19
.gitea/ISSUE_TEMPLATE/task.md
Normal file
19
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: 任务报备
|
||||
about: 运维、迁移、文档等非功能任务
|
||||
title: "[Task] "
|
||||
labels: task
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
|
||||
## 要做的事
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## 完成定义(DoD)
|
||||
|
||||
|
||||
## 风险 / 回滚
|
||||
15
.gitea/pull_request_template.md
Normal file
15
.gitea/pull_request_template.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 变更说明
|
||||
|
||||
|
||||
## 关联工单
|
||||
|
||||
Closes #
|
||||
|
||||
## 自检
|
||||
|
||||
- [ ] `pnpm lint`(或说明未跑原因)
|
||||
- [ ] 无 `.env` / 密钥入库
|
||||
- [ ] 已更新「玩值开发文档」或 Gitea 百科(若行为或接口有变)
|
||||
|
||||
## 截图 / 演示(可选)
|
||||
|
||||
17
.gitea/workflows/ci.yml
Normal file
17
.gitea/workflows/ci.yml
Normal 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
126
app/admin/journey/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
227
app/admin/rfm-rules/page.tsx
Normal file
227
app/admin/rfm-rules/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
app/admin/user-value/page.tsx
Normal file
76
app/admin/user-value/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
app/api/admin/journey-events/route.ts
Normal file
67
app/api/admin/journey-events/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
82
app/api/admin/rfm-rules/[id]/route.ts
Normal file
82
app/api/admin/rfm-rules/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
83
app/api/admin/rfm-rules/route.ts
Normal file
83
app/api/admin/rfm-rules/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
105
app/api/admin/user-value-summary/route.ts
Normal file
105
app/api/admin/user-value-summary/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
115
app/api/analytics/journey-events/route.ts
Normal file
115
app/api/analytics/journey-events/route.ts
Normal 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: "未登录时需传 anonymousId(H5 匿名会话)" },
|
||||
{ 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 })
|
||||
}
|
||||
}
|
||||
170
app/api/streamer/audience-insights/route.ts
Normal file
170
app/api/streamer/audience-insights/route.ts
Normal 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
|
||||
/** 0–100:观看里程碑 + 互动加权,便于主播一眼对比 */
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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: "链接已复制" })
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
117
app/streamer/audience/page.tsx
Normal file
117
app/streamer/audience/page.tsx
Normal 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> 位观众有行为数据;「触达分」为 0–100
|
||||
的启发式汇总,便于对比热度。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
lib/analytics/journey-client.ts
Normal file
39
lib/analytics/journey-client.ts
Normal 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 }
|
||||
}
|
||||
43
lib/analytics/journey-session.ts
Normal file
43
lib/analytics/journey-session.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** 浏览器会话内稳定的旅程 sessionId(WM-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()}`
|
||||
}
|
||||
}
|
||||
29
lib/analytics/journey-track.ts
Normal file
29
lib/analytics/journey-track.ts
Normal 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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
}
|
||||
|
||||
126
玩值开发文档/10、项目管理/OKR任务拆解与排期_王明政_许永平_4-5月.md
Normal file
126
玩值开发文档/10、项目管理/OKR任务拆解与排期_王明政_许永平_4-5月.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# OKR 任务拆解与排期(产研第 70 场对齐)
|
||||
|
||||
> **依据**:`产研团队 第70场 20260413聊ai.txt`(中台/存客宝/用户旅程与锁客/直播腾讯或犀牛/商城分销/玩值与兑换/公会主播/CP 与音视频/模块化 APP 等)+ 王明政、许永平两套 OKR。
|
||||
> **周期**:2026-04-14~2026-05-31(共 7 周,周次 W1~W7)。
|
||||
> **说明**:表内「负责人」以 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-14~04-20 | 埋点字典、存客宝对接设计、RFM 表结构启动 | 登录巩固、蓄水页 channel、直播 POC 启动 |
|
||||
| W2 | 04-21~04-27 | 埋点 API、旅程查询、公会直播数据模型 | 直播 POC、App 路由拆分 |
|
||||
| W3 | 04-28~05-04 | RFM 规则编辑、直播排期管理、分账配置启动 | 观众直播间 MVP、首页直播间入口 |
|
||||
| W4 | 05-05~05-11 | 存客宝绑定表、RFM 跑批、推送策略 | 主播开播端、商城分销参数 |
|
||||
| W5 | 05-12~05-18 | 旅程推送、用户偏好、直播奖励方案 | 礼物入口、变现路由映射 |
|
||||
| W6 | 05-19~05-25 | 用户估值看板、抽成试算、奖励阶梯 | 分销 E2E、存证 POC |
|
||||
| W7 | 05-26~05-31 | 看板导出与规则验收、整体验收口 | 字段对齐与整链演示 |
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与依赖(仅列阻塞项)
|
||||
|
||||
- **存客宝**:接口与凭证到位时间影响 WM-O2-KR1 全系。
|
||||
- **直播云厂商**:腾讯云 key/计费开通影响 XY-O2-KR1-02 及后续。
|
||||
- **分润规则**:产品与法务口径确认影响 WM-O3-KR2 与 XY-O2-KR1-07。
|
||||
|
||||
---
|
||||
|
||||
*文档版本:2026-04-14;与《开发进度总表》同步引用。*
|
||||
@@ -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-14(OKR 文档名修正 + 王明政 O1 数据中台首轮代码交付说明)。*
|
||||
|
||||
@@ -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`;**用户旅程与 RFM(WM-O1)**:`userJourneyEvents`、`rfmRuleSets`、`rfmUserScores`(与 `lib/db/mongo/collections.ts` 一致)。
|
||||
|
||||
## 4. 存客宝数据策略(强制)
|
||||
|
||||
|
||||
62
玩值开发文档/2、架构/用户旅程埋点字典_WM-O1-KR1-01.md
Normal file
62
玩值开发文档/2、架构/用户旅程埋点字典_WM-O1-KR1-01.md
Normal 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`(0–100)**;工作台入口:`/streamer/audience`。
|
||||
|
||||
**捆绑(默认最简)**:用户进房且已登录、成功 `postLiveEnter` 后,客户端自动调用 `POST /api/streamer/bind`(`userId` + `streamerId`);若用户已有未过期绑定则不改(沿用现有 `streamerBindings` 策略)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 属性约定
|
||||
|
||||
- **ID 类**:一律传 Mongo `ObjectId` 字符串,与业务 API 一致。
|
||||
- **时长**:毫秒非负整数;心跳间隔由客户端控制(建议 15~30s)。
|
||||
- **去重**:同一 `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 实现同步维护。*
|
||||
64
玩值开发文档/8、部署/Gitea_API_一键填充说明.md
Normal file
64
玩值开发文档/8、部署/Gitea_API_一键填充说明.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Gitea:Release / 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/`。
|
||||
@@ -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`** 后只保留一处:
|
||||
|
||||
38
玩值开发文档/8、部署/gitea-wiki/Home.md
Normal file
38
玩值开发文档/8、部署/gitea-wiki/Home.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 玩值电竞 App(wzdj)· 百科首页
|
||||
|
||||
> 本仓库:**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 纠错库处理,本百科正文不使用旧别名。
|
||||
12
玩值开发文档/8、部署/gitea-wiki/_Sidebar.md
Normal file
12
玩值开发文档/8、部署/gitea-wiki/_Sidebar.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## 百科目录
|
||||
|
||||
- [首页](Home)
|
||||
- [项目概述与范围](项目概述与范围)
|
||||
- [本地开发与运行](本地开发与运行)
|
||||
- [架构与技术栈](架构与技术栈)
|
||||
- [直播与主播工作台](直播与主播工作台)
|
||||
- [部署与运维](部署与运维)
|
||||
- [版本发布与标签约定](版本发布与标签约定)
|
||||
- [项目看板使用说明](项目看板使用说明)
|
||||
- [软件包与制品说明](软件包与制品说明)
|
||||
- [工单与合并请求规范](工单与合并请求规范)
|
||||
35
玩值开发文档/8、部署/gitea-wiki/工单与合并请求规范.md
Normal file
35
玩值开发文档/8、部署/gitea-wiki/工单与合并请求规范.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 工单与合并请求规范
|
||||
|
||||
## Issues(工单)
|
||||
|
||||
### 提交前
|
||||
|
||||
1. 确认是否已有同类工单(搜索标题关键词:`直播`、`Mongo`、`部署`)。
|
||||
2. 选择合适模板(若仓库已配置):**功能建议** / **Bug** / **任务报备**。
|
||||
|
||||
### 正文建议包含
|
||||
|
||||
- **现象**:URL、用户角色、复现步骤。
|
||||
- **期望**:正确行为描述。
|
||||
- **环境**:分支/commit、开发或生产、浏览器版本。
|
||||
- **日志**:可粘贴脱敏后的接口返回或 PM2 日志片段。
|
||||
|
||||
## Pull Requests(合并请求)
|
||||
|
||||
### 自检清单(合并前)
|
||||
|
||||
- [ ] `pnpm lint` 通过(或说明例外原因)
|
||||
- [ ] 涉及接口时补充/更新 **玩值开发文档** 或本 **百科**
|
||||
- [ ] 无密钥与内网密码入库
|
||||
- [ ] 大变更已在工单或看板中关联
|
||||
|
||||
### 评审关注点
|
||||
|
||||
- 直播与支付相关:**边界条件**与错误处理。
|
||||
- 数据库:**索引与迁移**是否可逆。
|
||||
- 性能:列表分页、避免 N+1 查询。
|
||||
|
||||
## 与卡若AI的边界
|
||||
|
||||
- **卡若AI**:跨项目规范、Skill、复盘格式。
|
||||
- **本仓库**:玩值业务与部署;工单中引用卡若文档时用 **「卡若AI」** 统一称呼。
|
||||
37
玩值开发文档/8、部署/gitea-wiki/本地开发与运行.md
Normal file
37
玩值开发文档/8、部署/gitea-wiki/本地开发与运行.md
Normal 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`**
|
||||
33
玩值开发文档/8、部署/gitea-wiki/架构与技术栈.md
Normal file
33
玩值开发文档/8、部署/gitea-wiki/架构与技术栈.md
Normal 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`**(若与代码不一致,以迁移后的代码为准并回写文档)。
|
||||
26
玩值开发文档/8、部署/gitea-wiki/版本发布与标签约定.md
Normal file
26
玩值开发文档/8、部署/gitea-wiki/版本发布与标签约定.md
Normal 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。
|
||||
30
玩值开发文档/8、部署/gitea-wiki/直播与主播工作台.md
Normal file
30
玩值开发文档/8、部署/gitea-wiki/直播与主播工作台.md
Normal 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`**
|
||||
28
玩值开发文档/8、部署/gitea-wiki/软件包与制品说明.md
Normal file
28
玩值开发文档/8、部署/gitea-wiki/软件包与制品说明.md
Normal 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**:在 CI(Gitea Actions)中 `docker build` → `docker push` 到 `192.168.1.201:3000` 对应 registry,并在 **版本发布** 中写明镜像 tag 与部署命令。
|
||||
24
玩值开发文档/8、部署/gitea-wiki/部署与运维.md
Normal file
24
玩值开发文档/8、部署/gitea-wiki/部署与运维.md
Normal 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**(或等价命令)。
|
||||
23
玩值开发文档/8、部署/gitea-wiki/项目概述与范围.md
Normal file
23
玩值开发文档/8、部署/gitea-wiki/项目概述与范围.md
Normal 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、部署/`
|
||||
24
玩值开发文档/8、部署/gitea-wiki/项目看板使用说明.md
Normal file
24
玩值开发文档/8、部署/gitea-wiki/项目看板使用说明.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 项目看板使用说明(Gitea Projects)
|
||||
|
||||
## 用途
|
||||
|
||||
**Projects** 用于把「需求 → 开发 → 待发布 → 已上线」可视化,与 **Issues** 联动(将工单拖入列中)。
|
||||
|
||||
## 推荐列(看板)
|
||||
|
||||
| 列名 | 含义 |
|
||||
|------|------|
|
||||
| 待办 | 已确认需求,未排期或未开工 |
|
||||
| 进行中 | 开发或联调中 |
|
||||
| 待发布 | 已合并主分支,等待部署窗口 |
|
||||
| 已上线 | 生产验证通过 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在仓库页打开 **项目** → 选择或新建看板。
|
||||
2. 新建 **工单** 描述需求,关联里程碑(若有)。
|
||||
3. 开发完成后把工单拖到 **待发布**;运维部署后拖到 **已上线**。
|
||||
|
||||
## 与玩值文档的对应
|
||||
|
||||
- 大需求仍写在 **`玩值开发文档/1、需求/`**;看板工单可链到具体 Markdown 路径或 Gitea 提交。
|
||||
60
玩值开发文档/8、部署/sync_gitea_wiki.sh
Executable file
60
玩值开发文档/8、部署/sync_gitea_wiki.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将「玩值开发文档/8、部署/gitea-wiki/」下的 Markdown 同步到 Gitea Wiki(fnvtk/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"
|
||||
Reference in New Issue
Block a user