feat(live): 直播与主播工作台 API、云厂商解析与文档调研

- 新增直播评论/上下文/礼物等 API 与回放组件
- 主播开播、云端推流配置与 streamer 校验
- 依赖与 Mongo 集合/种子更新
- 需求与调研文档补充

Made-with: Cursor
This commit is contained in:
卡若
2026-04-14 16:24:17 +08:00
parent 3c624737ee
commit b95d1a94c6
53 changed files with 5383 additions and 67 deletions

View File

@@ -1,20 +1,35 @@
# 玩值电竞 - 环境变量示例(复制为 .env.local 后按需修改
# 建库/种子脚本会读取 .env.local将玩值电竞库建到本机 27017(库名 wanzhi_esports
# 本机 27017 的账号密码:见 卡若AI 工作台《00_账号与API索引》二、数据库 · 本机 MongoDB统一
# Mongo必填本地种子见 package.json db:mongo:seed
MONGODB_URI=mongodb://127.0.0.1:27017/wanzhi_esports
# MongoDB 本机统一实例(若 27017 开启认证账号密码向卡若AI 要,再按下面格式填
# 无认证mongodb://localhost:27017
# 有认证mongodb://账号:密码@localhost:27017?authSource=admin
MONGODB_URI=mongodb://localhost:27017
# 本地开发:`pnpm dev` 默认端口 3088 → http://localhost:3088勿与占用 3000 的其它 Node 进程混淆
# 从 KR/腾讯云 导入用户(老游条、老坑爹、存客宝等)到玩值电竞
# 不配置则 pnpm run import:lytiao:mongo 仅做公会「玩值电竞」归属与主播同步
# SOURCE_MONGODB_URI=mongodb://用户:密码@腾讯云或KR主机:27017?authSource=admin
# SOURCE_DB_NAME=KR
# SOURCE_COLLECTIONS_KR=用户估值,用户资产整合,存客宝用户资产,老坑爹论坛 www.lkdie.com_已扫描,老坑爹商店 shop.lkdie.com_已扫描
# —— 腾讯云直播(服务端签名,推流/播放防盗链)
# 推流域名、播放域名:仅填 host不要带 rtmp:// 或 https://
TENCENT_LIVE_PUSH_DOMAIN=your-push.livepush.myqcloud.com
TENCENT_LIVE_PLAY_DOMAIN=your-play.liveplay.myqcloud.com
# 播放与推流若使用不同鉴权 KEY可分开配置否则可只配推流 KEY播放同用
TENCENT_LIVE_PUSH_AUTH_KEY=
TENCENT_LIVE_PLAY_AUTH_KEY=
# AppName控制台模板常见为 live
TENCENT_LIVE_APP_NAME=live
# 用户资产 API玩值电竞管理端「用户详情」完善/清洗数据用,可选)
# 不配置则详情页仅展示本库用户数据
USER_ASSET_API_BASE_URL=http://localhost:3117
USER_ASSET_API_KEY=
USER_ASSET_API_SECRET=
# —— 阿里云视频直播 URL 鉴权
ALIYUN_LIVE_PUSH_DOMAIN=your-push.aliyunlive.com
ALIYUN_LIVE_PLAY_DOMAIN=your-play.aliyunlive.com
ALIYUN_LIVE_APP_NAME=live
ALIYUN_LIVE_PUSH_PRIVATE_KEY=
ALIYUN_LIVE_PLAY_PRIVATE_KEY=
# —— 七牛云 Pili 限时鉴权推流
QINIU_LIVE_PUSH_DOMAIN=your-publish.qiniup.com
QINIU_LIVE_HUB=your-hub
QINIU_LIVE_ACCESS_KEY=
QINIU_LIVE_SECRET_KEY=
# 可选:播放域名(若与推流 token 策略不一致,请改在控制台复制播放地址)
QINIU_LIVE_PLAY_DOMAIN=
# —— 自定义无鉴权 RTMP仅测试
LIVE_GENERIC_RTMP_BASE=rtmp://127.0.0.1/live/
# —— 前端展示用(不含密钥)
NEXT_PUBLIC_LIVE_RTMP_TEMPLATE=rtmp://your-push-domain/live/

View File

@@ -2,6 +2,13 @@
*Automatically synced with your [v0.app](https://v0.app) deployments*
## 本地运行(玩值电竞 App
- 安装依赖:`pnpm install`
- 启动开发服务:`pnpm dev` → 浏览器打开 **`http://localhost:3088`****不是 3000**3000 上常为其它项目,会导致 `/live` 等路由 404
- MongoDB在项目根配置 `.env.local``MONGODB_URI`,需要时可执行 `pnpm db:mongo:seed`
- **直播开播图文步骤**(按钮与页面位置):`玩值开发文档/1、需求/修改/直播开播操作教程_20260413.md`
[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?style=for-the-badge&logo=vercel)](https://vercel.com/fnvtks-projects/v0--aa)
[![Built with v0](https://img.shields.io/badge/Built%20with-v0.app-black?style=for-the-badge)](https://v0.app/chat/pDE9S8I1cvU)

View File

@@ -0,0 +1,160 @@
/**
* GET/POST /api/live/comments/[streamerId]
* 直播间公屏HTTP 轮询 + 持久化(后续 WebSocket 可对齐同集合)
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
import { DEFAULT_LIVE_FORBIDDEN_WORDS, hitsLiveForbiddenWord } from "@/lib/live/constants"
type CommentType = "normal" | "enter" | "gift" | "system"
function pseudoLevel(userId: string): number {
let h = 0
for (let i = 0; i < userId.length; i++) h = (h * 31 + userId.charCodeAt(i)) | 0
return Math.abs(h % 29) + 1
}
export async function GET(_req: NextRequest, { params }: { params: Promise<{ streamerId: string }> }) {
try {
const { streamerId } = await params
if (!streamerId || !ObjectId.isValid(streamerId)) {
return NextResponse.json({ success: false, message: "无效主播 id" }, { status: 400 })
}
const oid = new ObjectId(streamerId)
const scoll = await getCollection("streamers")
const streamer = await scoll.findOne({ _id: oid, status: "approved" })
if (!streamer) {
return NextResponse.json({ success: false, message: "主播不存在或未审核" }, { status: 404 })
}
const coll = await getCollection("liveComments")
const rows = await coll
.find({ streamerId: oid })
.sort({ createdAt: -1 })
.limit(120)
.toArray()
const mapped = rows.reverse().map((doc) => {
const d = doc as {
_id: ObjectId
userId?: string
userName?: string
content?: string
type?: string
createdAt?: Date
}
const uid = String(d.userId ?? "")
const typ = (d.type as CommentType) || "normal"
return {
id: d._id.toString(),
userId: uid,
userName: String(d.userName ?? "用户"),
content: String(d.content ?? ""),
type: typ,
level: pseudoLevel(uid || d._id.toString()),
createdAt: d.createdAt instanceof Date ? d.createdAt.toISOString() : new Date().toISOString(),
}
})
return NextResponse.json({ success: true, data: { comments: mapped } })
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
console.error("GET /api/live/comments error:", err.message)
return NextResponse.json({ success: false, message: "服务暂不可用" }, { status: 500 })
}
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ streamerId: string }> }) {
try {
const userId = req.cookies.get("wanzhi_user_id")?.value
if (!userId || !ObjectId.isValid(userId)) {
return NextResponse.json({ success: false, message: "请先登录后再发言" }, { status: 401 })
}
const { streamerId } = await params
if (!streamerId || !ObjectId.isValid(streamerId)) {
return NextResponse.json({ success: false, message: "无效主播 id" }, { status: 400 })
}
const sid = new ObjectId(streamerId)
const uid = new ObjectId(userId)
const scoll = await getCollection("streamers")
const streamer = await scoll.findOne({ _id: sid, status: "approved" })
if (!streamer) {
return NextResponse.json({ success: false, message: "主播不存在或未审核" }, { status: 404 })
}
const body = (await req.json().catch(() => ({}))) as {
content?: string
type?: string
}
if (body.type === "gift" || body.type === "system") {
return NextResponse.json({ success: false, message: "该类型仅由系统写入" }, { status: 400 })
}
const typ: CommentType = body.type === "enter" ? "enter" : "normal"
const roomsColl = await getCollection("liveRooms")
const room = await roomsColl.findOne({ streamerId: sid })
const liveRoomId = room?._id instanceof ObjectId ? room._id : null
const usersColl = await getCollection("users")
const user = await usersColl.findOne({ _id: uid })
const userName = String(
(user as { name?: string; phone?: string } | null)?.name?.trim() ||
(user as { phone?: string } | null)?.phone?.trim() ||
"用户",
).slice(0, 24)
let content = typeof body.content === "string" ? body.content.trim() : ""
if (typ === "enter") {
content = "进入了直播间"
} else {
if (!content) {
return NextResponse.json({ success: false, message: "内容不能为空" }, { status: 400 })
}
if (content.length > 200) {
return NextResponse.json({ success: false, message: "内容过长" }, { status: 400 })
}
const bad = hitsLiveForbiddenWord(content, [...DEFAULT_LIVE_FORBIDDEN_WORDS])
if (bad) {
return NextResponse.json(
{ success: false, message: `包含违禁词,请修改后发送(${bad}` },
{ status: 400 },
)
}
}
const coll = await getCollection("liveComments")
const doc = {
streamerId: sid,
liveRoomId,
userId: userId,
userName,
content,
type: typ,
createdAt: new Date(),
}
const r = await coll.insertOne(doc)
return NextResponse.json({
success: true,
data: {
comment: {
id: r.insertedId.toString(),
userId,
userName,
content,
type: typ,
level: pseudoLevel(userId),
createdAt: doc.createdAt.toISOString(),
},
},
})
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
console.error("POST /api/live/comments error:", err.message)
return NextResponse.json({ success: false, message: "服务暂不可用" }, { status: 500 })
}
}

View File

@@ -0,0 +1,97 @@
/**
* GET /api/live/context/[streamerId]
* 聚合主播、liveRooms、橱窗小黄车、违禁词、公会分成信息供直播间首屏与后续 WS 扩展)
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
import { DEFAULT_LIVE_FORBIDDEN_WORDS } from "@/lib/live/constants"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ streamerId: string }> }) {
try {
const { streamerId } = await params
if (!streamerId || !ObjectId.isValid(streamerId)) {
return NextResponse.json({ success: false, message: "无效主播 id" }, { status: 400 })
}
const oid = new ObjectId(streamerId)
const scoll = await getCollection("streamers")
const streamer = await scoll.findOne({ _id: oid, status: "approved" })
if (!streamer) {
return NextResponse.json({ success: false, message: "主播不存在或未审核" }, { status: 404 })
}
const roomsColl = await getCollection("liveRooms")
const room = await roomsColl.findOne({ streamerId: oid })
const showColl = await getCollection("liveShowcaseItems")
const items = await showColl.find({ streamerId: oid, status: "active" }).sort({ sortOrder: 1 }).limit(50).toArray()
const rawGid = (streamer as { guildId?: string | ObjectId | null }).guildId
let guildSummary: { id: string; name: string; commissionRate: number } | null = null
if (rawGid) {
try {
const gid =
typeof rawGid === "string" && ObjectId.isValid(rawGid)
? new ObjectId(rawGid)
: rawGid instanceof ObjectId
? rawGid
: null
if (gid) {
const gc = await getCollection("guilds")
const g = await gc.findOne({ _id: gid })
if (g && g.name != null) {
guildSummary = {
id: gid.toString(),
name: String(g.name),
commissionRate: Number((g as { commissionRate?: number }).commissionRate) || 0,
}
}
}
} catch {
/* ignore invalid guild ref */
}
}
const showcase = items.map((it) => ({
id: (it._id as ObjectId).toString(),
title: String(it.title ?? ""),
subtitle: it.subtitle != null ? String(it.subtitle) : undefined,
landingPath: String((it as { landingPath?: string }).landingPath ?? "/mall"),
refQuery: String((it as { refQuery?: string }).refQuery ?? `streamerId=${streamerId}&channel=live`),
}))
return NextResponse.json({
success: true,
data: {
streamer: {
id: streamerId,
name: String(streamer.name ?? "主播"),
isLive: !!streamer.isLive,
commissionRate: Number(streamer.commissionRate) || 0,
guildId: guildSummary?.id ?? (rawGid ? String(rawGid) : null),
},
guild: guildSummary,
room: room
? {
id: (room._id as ObjectId).toString(),
title: String(room.title ?? ""),
status: String((room as { status?: string }).status ?? "offline"),
game: String((room as { game?: string }).game ?? "电竞"),
viewerCount: Number((room as { viewerCount?: number }).viewerCount) || 0,
pullPlaybackUrl: String((room as { pullPlaybackUrl?: string }).pullPlaybackUrl ?? ""),
roomMode: String((room as { roomMode?: string }).roomMode ?? "video"),
}
: null,
showcase,
forbiddenWords: [...DEFAULT_LIVE_FORBIDDEN_WORDS],
revenueHint:
"直播间礼物与橱窗成交将按主播及签约公会配置记入分润台账commissionLedger比例以后台与合同为准。",
},
})
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
console.error("GET /api/live/context error:", err.message)
return NextResponse.json({ success: false, message: "服务暂不可用" }, { status: 500 })
}
}

209
app/api/live/gift/route.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* POST /api/live/gift
* body: { streamerId: string, giftId: number }
* 扣减用户玩值币、写 transactions、liveGifts、commissionLedger并写入公屏 type=gift
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
import { getLiveGiftOrNull } from "@/lib/live/gift-catalog"
const PLATFORM_GIFT_SHARE = 0.1
function nowIso() {
return new Date().toISOString()
}
export async function POST(req: NextRequest) {
try {
const userIdStr = req.cookies.get("wanzhi_user_id")?.value
if (!userIdStr || !ObjectId.isValid(userIdStr)) {
return NextResponse.json({ success: false, message: "请先登录" }, { status: 401 })
}
const body = (await req.json().catch(() => ({}))) as {
streamerId?: string
giftId?: number
}
const streamerId = typeof body.streamerId === "string" ? body.streamerId.trim() : ""
const giftId = Number(body.giftId)
if (!streamerId || !ObjectId.isValid(streamerId)) {
return NextResponse.json({ success: false, message: "无效主播 id" }, { status: 400 })
}
const gift = getLiveGiftOrNull(giftId)
if (!gift) {
return NextResponse.json({ success: false, message: "无效礼物" }, { status: 400 })
}
const sid = new ObjectId(streamerId)
const uid = new ObjectId(userIdStr)
const streamersColl = await getCollection("streamers")
const streamer = await streamersColl.findOne({ _id: sid, status: "approved" })
if (!streamer) {
return NextResponse.json({ success: false, message: "主播不存在或未审核" }, { status: 404 })
}
const roomsColl = await getCollection("liveRooms")
const room = await roomsColl.findOne({ streamerId: sid })
const liveRoomId = room?._id instanceof ObjectId ? room._id : null
const sessionsColl = await getCollection("liveSessions")
const activeSession = await sessionsColl.findOne({ streamerId: sid, status: "active" })
const liveSessionId = activeSession?._id instanceof ObjectId ? activeSession._id : null
const usersColl = await getCollection("users")
const user = await usersColl.findOne({ _id: uid })
if (!user) {
return NextResponse.json({ success: false, message: "用户不存在" }, { status: 404 })
}
const price = gift.priceCoins
const rawGid = (streamer as { guildId?: string | ObjectId | null }).guildId
let guildId: ObjectId | null = null
let guildShareRate = 0
if (rawGid) {
try {
const gid =
typeof rawGid === "string" && ObjectId.isValid(rawGid)
? new ObjectId(rawGid)
: rawGid instanceof ObjectId
? rawGid
: null
if (gid) {
const gc = await getCollection("guilds")
const g = await gc.findOne({ _id: gid })
if (g) {
guildId = gid
guildShareRate = Math.min(1, Math.max(0, Number((g as { commissionRate?: number }).commissionRate) || 0))
}
}
} catch {
guildId = null
}
}
const platformCoins = Math.floor(price * PLATFORM_GIFT_SHARE)
const pool = price - platformCoins
const guildCoins = guildId ? Math.floor(pool * guildShareRate) : 0
const streamerCoins = pool - guildCoins
const orderNo = `LIVEGIFT-${Date.now()}-${randomSuffix()}`
const afterDeduct = await usersColl.findOneAndUpdate(
{ _id: uid, balance: { $gte: price } },
{ $inc: { balance: -price }, $set: { updatedAt: new Date() } },
{ returnDocument: "after" },
)
if (!afterDeduct) {
return NextResponse.json({ success: false, message: "玩值币不足" }, { status: 400 })
}
const giftDocUserName = String(
(user as { name?: string; phone?: string }).name?.trim() ||
(user as { phone?: string }).phone?.trim() ||
"用户",
).slice(0, 24)
try {
const txColl = await getCollection("transactions")
await txColl.insertOne({
userId: userIdStr,
orderNo,
type: "live_gift",
amount: price,
remark: `直播送礼-${gift.name}`,
streamerId: streamerId,
liveRoomId: liveRoomId?.toString() ?? null,
liveSessionId: liveSessionId?.toString() ?? null,
createdAt: nowIso(),
} as never)
const giftsColl = await getCollection("liveGifts")
const giftDoc = {
streamerId: sid,
liveRoomId,
liveSessionId,
userId: userIdStr,
userName: giftDocUserName,
giftId,
giftName: gift.name,
icon: gift.icon,
amountCoins: price,
orderNo,
createdAt: new Date(),
}
const giftIns = await giftsColl.insertOne(giftDoc as never)
const ledgerColl = await getCollection("commissionLedger")
const baseLedger = {
sourceType: "live_gift" as const,
sourceId: giftIns.insertedId,
streamerId: sid,
orderNo,
liveSessionId,
createdAt: new Date(),
}
await ledgerColl.insertMany(
[
{
...baseLedger,
beneficiaryType: "platform",
guildId: null,
amountCoins: platformCoins,
},
{
...baseLedger,
beneficiaryType: "guild",
guildId: guildId ?? null,
amountCoins: guildCoins,
},
{
...baseLedger,
beneficiaryType: "streamer",
guildId: null,
amountCoins: streamerCoins,
},
] as never[],
)
await streamersColl.updateOne(
{ _id: sid },
{ $inc: { totalGifts: price, totalIncome: streamerCoins }, $set: { updatedAt: nowIso() } },
)
const commentsColl = await getCollection("liveComments")
const enterText = `送出了 ${gift.icon} ${gift.name}`
await commentsColl.insertOne({
streamerId: sid,
liveRoomId,
userId: userIdStr,
userName: giftDocUserName,
content: enterText,
type: "gift",
createdAt: new Date(),
} as never)
return NextResponse.json({
success: true,
data: {
orderNo,
giftId: giftIns.insertedId.toString(),
deducted: price,
split: { platformCoins, guildCoins, streamerCoins },
},
})
} catch (e) {
await usersColl.updateOne({ _id: uid }, { $inc: { balance: price }, $set: { updatedAt: new Date() } })
console.error("POST /api/live/gift persist error:", e)
return NextResponse.json({ success: false, message: "送礼失败,请稍后重试" }, { status: 500 })
}
} catch (e) {
console.error("POST /api/live/gift error:", e)
return NextResponse.json({ success: false, message: "送礼失败" }, { status: 500 })
}
}
function randomSuffix() {
return Math.random().toString(36).slice(2, 8)
}

View File

@@ -0,0 +1,50 @@
/**
* GET /api/streamer/live/cloud?vendor=tencent|aliyun|qiniu|volcengine|generic&ttl=3600
* 登录且已审核主播:返回当前流名(优先活跃 sessionKey及对应厂商推拉流地址服务端读 env 签名)
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
import { ensureApprovedStreamerForUser } from "@/lib/streamer/ensure-approved-streamer"
import { parseVendor, resolveLiveCloudUrls } from "@/lib/live/cloud/resolve"
export async function GET(req: NextRequest) {
try {
const userId = req.cookies.get("wanzhi_user_id")?.value
if (!userId || !ObjectId.isValid(userId)) {
return NextResponse.json({ success: false, message: "未登录" }, { status: 401 })
}
const vendor = parseVendor(req.nextUrl.searchParams.get("vendor"))
if (!vendor) {
return NextResponse.json(
{ success: false, message: "vendor 须为 tencent|aliyun|qiniu|volcengine|generic" },
{ status: 400 },
)
}
const ttlRaw = Number(req.nextUrl.searchParams.get("ttl") ?? "7200")
const ttl = Number.isFinite(ttlRaw) ? ttlRaw : 7200
const streamersColl = await getCollection("streamers")
let streamer = await streamersColl.findOne({ userId, status: "approved" })
if (!streamer) {
streamer = await ensureApprovedStreamerForUser(userId)
}
if (!streamer) {
return NextResponse.json({ success: false, message: "未找到已审核的主播档案" }, { status: 404 })
}
const sid = streamer._id as ObjectId
/** 与云厂商 StreamName 对齐:每主播固定,便于 OBS 保存配置;与 liveSessions.sessionKey 解耦 */
const streamName = `wanzhi_${sid.toString()}`
const data = resolveLiveCloudUrls(vendor, streamName, ttl)
return NextResponse.json({ success: true, data })
} catch (e) {
console.error("GET /api/streamer/live/cloud error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,153 @@
/**
* POST /api/streamer/live
* body: { action: "start" | "stop", title?: string, pullPlaybackUrl?: string | null }
* 主播在本平台一键开/关播:写 streamers.isLive + liveRooms + liveSessions会话占位
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { randomBytes } from "node:crypto"
import { getCollection } from "@/lib/db/mongo"
import { ensureApprovedStreamerForUser } from "@/lib/streamer/ensure-approved-streamer"
function nowIso() {
return new Date().toISOString()
}
function safeHttpUrl(s: string | undefined | null): string | null {
if (s == null || String(s).trim() === "") return null
const t = String(s).trim()
try {
const u = new URL(t)
if (u.protocol !== "http:" && u.protocol !== "https:") return null
return t
} catch {
return null
}
}
export async function POST(req: NextRequest) {
try {
const userId = req.cookies.get("wanzhi_user_id")?.value
if (!userId || !ObjectId.isValid(userId)) {
return NextResponse.json({ success: false, message: "未登录" }, { status: 401 })
}
const body = (await req.json().catch(() => ({}))) as {
action?: string
title?: string
pullPlaybackUrl?: string | null
}
const action = body.action === "stop" ? "stop" : body.action === "start" ? "start" : null
if (!action) {
return NextResponse.json({ success: false, message: "action 须为 start 或 stop" }, { status: 400 })
}
const streamersColl = await getCollection("streamers")
let streamer = await streamersColl.findOne({ userId, status: "approved" })
if (!streamer) {
streamer = await ensureApprovedStreamerForUser(userId)
}
if (!streamer) {
return NextResponse.json({ success: false, message: "未找到已审核的主播档案" }, { status: 403 })
}
const sid = streamer._id as ObjectId
const streamerIdStr = sid.toString()
const roomsColl = await getCollection("liveRooms")
const sessionsColl = await getCollection("liveSessions")
if (action === "start") {
const titleIn =
typeof body.title === "string" && body.title.trim()
? body.title.trim().slice(0, 80)
: String(streamer.name ?? "主播") + " 的直播间"
const pull = safeHttpUrl(body.pullPlaybackUrl ?? undefined)
await sessionsColl.updateMany(
{ streamerId: sid, status: "active" },
{ $set: { status: "ended", endedAt: nowIso() } },
)
await streamersColl.updateOne(
{ _id: sid },
{
$set: {
isLive: true,
title: titleIn,
updatedAt: nowIso(),
},
},
)
await roomsColl.updateOne(
{ streamerId: sid },
{
$set: {
title: titleIn,
game: String((streamer as { game?: string }).game ?? "电竞"),
status: "live",
roomMode: "video",
pullPlaybackUrl: pull ?? "",
pushEndpointNote:
"OBS将服务器与串流密钥填入云直播控制台生成的地址浏览器可先填拉流预览 URL如 mp4/hls 播放地址)。",
viewerCount: Math.min(Number((streamer as { fans?: number }).fans) || 0, 99999),
updatedAt: nowIso(),
},
$setOnInsert: {
streamerId: sid,
guildId: (streamer as { guildId?: unknown }).guildId ?? null,
createdAt: nowIso(),
},
},
{ upsert: true },
)
const sessionKey = `wk_${randomBytes(6).toString("hex")}`
await sessionsColl.insertOne({
streamerId: sid,
status: "active",
sessionKey,
startedAt: nowIso(),
clientHint: "app-studio",
})
return NextResponse.json({
success: true,
data: {
streamerId: streamerIdStr,
isLive: true,
sessionKey,
liveUrl: `/live/${streamerIdStr}`,
studioUrl: `/streamer/studio`,
},
})
}
// stop
await streamersColl.updateOne({ _id: sid }, { $set: { isLive: false, updatedAt: nowIso() } })
await roomsColl.updateOne(
{ streamerId: sid },
{
$set: {
status: "ended",
updatedAt: nowIso(),
},
},
)
await sessionsColl.updateMany(
{ streamerId: sid, status: "active" },
{ $set: { status: "ended", endedAt: nowIso() } },
)
return NextResponse.json({
success: true,
data: { streamerId: streamerIdStr, isLive: false },
})
} catch (e) {
console.error("POST /api/streamer/live error:", e)
return NextResponse.json({ success: false, message: "操作失败" }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
/**
* GET /api/streamer/me
* 当前登录用户是否绑定「已审核」主播档案cookie: wanzhi_user_id
*/
import { NextRequest, NextResponse } from "next/server"
import { ObjectId } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
import { ensureApprovedStreamerForUser } from "@/lib/streamer/ensure-approved-streamer"
export async function GET(req: NextRequest) {
try {
const userId = req.cookies.get("wanzhi_user_id")?.value
if (!userId || !ObjectId.isValid(userId)) {
return NextResponse.json({ success: false, message: "未登录" }, { status: 401 })
}
const coll = await getCollection("streamers")
let doc = await coll.findOne({
userId: userId,
status: "approved",
})
if (!doc) {
doc = await ensureApprovedStreamerForUser(userId)
}
if (!doc) {
return NextResponse.json({ success: false, message: "未找到已审核的主播档案" }, { status: 404 })
}
const sid = (doc._id as ObjectId).toString()
const roomsColl = await getCollection("liveRooms")
const room = await roomsColl.findOne({ streamerId: doc._id })
return NextResponse.json({
success: true,
data: {
streamerId: sid,
name: String(doc.name ?? ""),
title: String((doc as { title?: string }).title ?? ""),
game: String((doc as { game?: string }).game ?? ""),
isLive: !!(doc as { isLive?: boolean }).isLive,
room: room
? {
id: (room._id as ObjectId).toString(),
title: String((room as { title?: string }).title ?? ""),
status: String((room as { status?: string }).status ?? "offline"),
pullPlaybackUrl: String((room as { pullPlaybackUrl?: string }).pullPlaybackUrl ?? ""),
}
: null,
},
})
} catch (e) {
console.error("GET /api/streamer/me error:", e)
return NextResponse.json({ success: false, message: "查询失败" }, { status: 500 })
}
}

View File

@@ -1,8 +1,19 @@
"use client"
import { use, useState, useRef } from "react"
import { use, useState, useRef, useEffect, useMemo, useCallback } from "react"
import { useRouter } from "next/navigation"
import { ArrowLeft, Heart, Share2, Gift, Send, MoreHorizontal, Users, MessageCircle, ThumbsUp } from "lucide-react"
import {
ArrowLeft,
Heart,
Share2,
Gift,
Send,
MoreHorizontal,
Users,
MessageCircle,
ThumbsUp,
ShoppingCart,
} from "lucide-react"
import Image from "next/image"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
@@ -10,35 +21,125 @@ import { toast } from "@/hooks/use-toast"
import { useAppContext } from "@/components/providers/app-provider"
import { useStreamer } from "@/hooks/use-streamers"
import { getStreamerAvatarUrl, getStreamerCoverUrl } from "@/lib/streamer-avatar"
import {
buildShowcaseHref,
getLiveContext,
getLiveComments,
postLiveComment,
postLiveEnter,
postLiveGift,
type LiveCommentDTO,
type LiveContextData,
} from "@/lib/api/live"
import { LivePlaybackVideo } from "@/components/live/live-playback-video"
import { DEFAULT_LIVE_FORBIDDEN_WORDS, hitsLiveForbiddenWord } from "@/lib/live/constants"
function formatViewerCount(n: number): string {
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`
return String(Math.max(0, Math.floor(n)))
}
type ChatRow = {
id: string
level: number
badge: string
user: string
text: string
isSystem?: boolean
isGift?: boolean
isEnter?: boolean
}
function badgeForUser(userId: string): string {
const badges = ["yellow", "primary", "green", "blue"] as const
let h = 0
for (let i = 0; i < userId.length; i++) h = (h * 31 + userId.charCodeAt(i)) | 0
return badges[Math.abs(h) % badges.length]!
}
function mapDtoToRow(c: LiveCommentDTO): ChatRow {
const base: ChatRow = {
id: c.id,
level: c.level,
badge: c.type === "enter" ? "green" : badgeForUser(c.userId),
user: c.userName,
text: c.content,
}
if (c.type === "enter") return { ...base, isEnter: true }
if (c.type === "gift") return { ...base, isGift: true }
return base
}
export default function LiveRoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { pay } = useAppContext()
const { syncFromDatabase } = useAppContext()
const { streamer, loading } = useStreamer(id)
const [liveCtx, setLiveCtx] = useState<LiveContextData | null>(null)
const [ctxLoading, setCtxLoading] = useState(true)
const [comment, setComment] = useState("")
const [isFollowed, setIsFollowed] = useState(false)
const [showCommunity, setShowCommunity] = useState(false)
const [showGiftPanel, setShowGiftPanel] = useState(false)
const [showSharePanel, setShowSharePanel] = useState(false)
const [showCartPanel, setShowCartPanel] = useState(false)
const [likeCount, setLikeCount] = useState(1258)
const [isLiked, setIsLiked] = useState(false)
const [floatingHearts, setFloatingHearts] = useState<{ id: number; x: number }[]>([])
const [comments, setComments] = useState([
{ id: 1, level: 12, badge: "yellow", user: "小飞侠", text: "主播这波操作太细了666" },
{ id: 2, level: 5, badge: "primary", user: "电竞迷", text: "求BGM" },
{ id: 3, level: 0, badge: "purple", user: "系统公告", text: "严禁违规发言,文明直播间", isSystem: true },
])
const commentIdRef = useRef(4)
const [serverChat, setServerChat] = useState<ChatRow[]>([])
const enterPostedRef = useRef(false)
const gameBackgrounds: Record<string, string> = {
"1": "/game-lol-bg.jpg",
"2": "/game-valorant-bg.jpg",
"3": "/game-pubg-bg.jpg",
"4": "/game-pubg-bg.jpg",
"5": "/game-genshin-bg.jpg",
"6": "/game-lol-bg.jpg",
}
useEffect(() => {
let cancelled = false
setCtxLoading(true)
getLiveContext(id)
.then((c) => {
if (!cancelled) setLiveCtx(c)
})
.finally(() => {
if (!cancelled) setCtxLoading(false)
})
return () => {
cancelled = true
}
}, [id])
const refreshComments = useCallback(async () => {
const list = await getLiveComments(id)
setServerChat(list.map(mapDtoToRow))
}, [id])
useEffect(() => {
void refreshComments()
const t = setInterval(() => void refreshComments(), 5000)
return () => clearInterval(t)
}, [refreshComments])
useEffect(() => {
if (!streamer?.id || enterPostedRef.current) return
void postLiveEnter(streamer.id).then((ok) => {
if (ok) {
enterPostedRef.current = true
void refreshComments()
}
})
}, [streamer?.id, refreshComments])
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`
}, [id])
const viewerLabel = useMemo(() => {
const fromRoom = liveCtx?.room?.viewerCount
const n =
fromRoom != null && fromRoom > 0
? fromRoom
: streamer?.fans != null
? Math.min(Number(streamer.fans), 9_999_999)
: 0
return `${formatViewerCount(n)}观看`
}, [liveCtx?.room?.viewerCount, streamer?.fans])
const gifts = [
{ id: 1, name: "棒棒糖", price: 10, icon: "🍭" },
@@ -70,6 +171,40 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
? getStreamerCoverUrl(streamer.game ?? streamer.name, streamer.coverImage, 400, 720)
: "/esports-live-stream-gameplay.jpg"
const playUrl = useMemo(() => {
const u = liveCtx?.room?.pullPlaybackUrl?.trim()
if (!u || !(u.startsWith("https://") || u.startsWith("http://"))) return null
return u
}, [liveCtx?.room?.pullPlaybackUrl])
const systemRows = useMemo((): ChatRow[] => {
const rows: ChatRow[] = [
{
id: "sys-welcome",
level: 0,
badge: "purple",
user: "系统公告",
text: "严禁违规发言,文明直播间",
isSystem: true,
},
]
if (liveCtx?.revenueHint) {
const hint =
liveCtx.revenueHint.length > 96 ? `${liveCtx.revenueHint.slice(0, 96)}` : liveCtx.revenueHint
rows.push({
id: "sys-revenue",
level: 0,
badge: "purple",
user: "系统公告",
text: hint,
isSystem: true,
})
}
return rows
}, [liveCtx?.revenueHint])
const displayComments = useMemo(() => [...systemRows, ...serverChat], [systemRows, serverChat])
const handleLike = () => {
setLikeCount((prev) => prev + 1)
setIsLiked(true)
@@ -81,33 +216,48 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
}, 1500)
}
const handleSendComment = () => {
const handleSendComment = async () => {
if (!comment.trim()) return
const newComment = {
id: commentIdRef.current++,
level: Math.floor(Math.random() * 20) + 1,
badge: ["yellow", "primary", "green", "blue"][Math.floor(Math.random() * 4)],
user: "",
text: comment,
const words = liveCtx?.forbiddenWords?.length ? liveCtx.forbiddenWords : [...DEFAULT_LIVE_FORBIDDEN_WORDS]
const bad = hitsLiveForbiddenWord(comment.trim(), words)
if (bad) {
toast({
title: "包含违禁词",
description: `请修改后发送,触发词:${bad}。多次违规可能被禁言或下播。`,
variant: "destructive",
})
return
}
const r = await postLiveComment(id, { content: comment.trim() })
if (!r.ok) {
toast({
title: "发送失败",
description: r.message ?? "请登录后再试",
variant: "destructive",
})
return
}
setComments((prev) => [...prev, newComment])
setComment("")
toast({ title: "评论成功", description: "你的评论已发送" })
void refreshComments()
}
const handleSendGift = (gift: (typeof gifts)[0]) => {
pay(gift.price, "diamonds", `送出${gift.name}`, "product")
setShowGiftPanel(false)
// 添加礼物弹幕
const giftComment = {
id: commentIdRef.current++,
level: 0,
badge: "pink",
user: "我",
text: `送出了 ${gift.icon} ${gift.name}`,
isGift: true,
const handleSendGift = async (gift: (typeof gifts)[0]) => {
const r = await postLiveGift(id, gift.id)
if (!r.ok) {
toast({
title: r.message?.includes("登录") ? "请先登录" : "送礼失败",
description:
r.message?.includes("登录") ? "登录后扣玩值币,礼物记入公屏与主播分润台账" : (r.message ?? "请稍后重试"),
variant: "destructive",
})
setShowGiftPanel(false)
return
}
setComments((prev) => [...prev, giftComment])
setShowGiftPanel(false)
toast({ title: "送礼成功", description: `已送出 ${gift.icon} ${gift.name}` })
await syncFromDatabase()
void refreshComments()
}
const handleShare = (platform: string) => {
@@ -150,10 +300,17 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
return (
<div className="h-screen w-full bg-black relative overflow-hidden flex flex-col">
{/* Background Live Stream */}
{/* Background Live Stream:有拉流地址时优先 HTML5 预览mp4 兼容性最好m3u8 后续可接 hls.js */}
<div className="absolute inset-0 z-0">
<Image src={backgroundImage || "/placeholder.svg"} alt="Live" fill className="object-cover" priority />
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60" />
{playUrl ? (
<LivePlaybackVideo
playUrl={playUrl}
className="absolute inset-0 h-full w-full object-cover bg-black"
/>
) : (
<Image src={backgroundImage || "/placeholder.svg"} alt="Live" fill className="object-cover" priority />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60 pointer-events-none" />
</div>
<div className="absolute bottom-24 right-4 z-30 pointer-events-none">
@@ -189,7 +346,9 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
</div>
<div>
<h3 className="text-xs font-bold text-white">{displayName}</h3>
<p className="text-[10px] text-white/70">12.5w观看 · {displayGame}</p>
<p className="text-[10px] text-white/70">
{ctxLoading ? "…" : viewerLabel} · {displayGame}
</p>
</div>
</div>
<button
@@ -300,9 +459,9 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
{/* Chat Area */}
<div className="relative z-20 px-4 pb-2 space-y-2 max-h-[30vh] overflow-y-auto mask-image-t">
{comments.map((c) => (
{displayComments.map((c) => (
<div key={c.id} className="flex items-start gap-2">
{!c.isSystem && (
{!c.isSystem && !c.isEnter && (
<span
className={`px-1.5 rounded text-[10px] font-bold mt-0.5 ${
c.badge === "yellow"
@@ -322,10 +481,29 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
{c.isSystem ? (
<span className="bg-purple-500/80 px-1.5 rounded text-[10px] font-bold text-white mt-0.5"></span>
) : null}
{c.isEnter ? (
<span className="bg-cyan-600/70 px-1.5 rounded text-[10px] font-bold text-white mt-0.5"></span>
) : null}
<p
className={`text-sm ${c.isSystem ? "text-yellow-400 font-bold" : c.isGift ? "text-pink-400" : "text-white/90"}`}
className={`text-sm ${
c.isSystem
? "text-yellow-400 font-bold"
: c.isGift
? "text-pink-400"
: c.isEnter
? "text-cyan-200/95"
: "text-white/90"
}`}
>
<span className="text-blue-400 font-bold opacity-80">{c.user}:</span> {c.text}
{c.isEnter ? (
<>
<span className="text-cyan-300 font-bold">{c.user}</span> {c.text}
</>
) : (
<>
<span className="text-blue-400 font-bold opacity-80">{c.user}:</span> {c.text}
</>
)}
</p>
</div>
))}
@@ -347,6 +525,54 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
</button>
</div>
<Dialog open={showCartPanel} onOpenChange={setShowCartPanel}>
<DialogTrigger asChild>
<button
type="button"
className="p-2.5 rounded-full bg-amber-500/90 backdrop-blur-md hover:bg-amber-500 transition-colors border border-amber-300/40"
aria-label="小黄车"
>
<ShoppingCart size={22} className="text-white" />
</button>
</DialogTrigger>
<DialogContent className="bg-zinc-900 border-white/10 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="text-amber-400" size={20} />
</DialogTitle>
</DialogHeader>
<p className="text-xs text-white/60 -mt-1 mb-2">
</p>
<div className="space-y-2 max-h-[50vh] overflow-y-auto">
{(liveCtx?.showcase?.length ? liveCtx.showcase : []).length === 0 ? (
<p className="text-sm text-white/50 py-6 text-center"></p>
) : (
(liveCtx?.showcase ?? []).map((item) => (
<button
key={item.id}
type="button"
className="w-full text-left p-3 rounded-xl bg-white/5 border border-white/10 hover:border-amber-500/40 transition-colors"
onClick={() => {
const href =
typeof window !== "undefined"
? buildShowcaseHref(window.location.origin, item)
: `${item.landingPath}?${item.refQuery.replace(/^\?/, "")}`
router.push(href)
setShowCartPanel(false)
}}
>
<div className="font-bold text-sm text-white">{item.title}</div>
{item.subtitle ? <div className="text-xs text-white/55 mt-0.5">{item.subtitle}</div> : null}
<div className="text-[10px] text-amber-200/80 mt-2 truncate">{item.landingPath}?{item.refQuery}</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={showGiftPanel} onOpenChange={setShowGiftPanel}>
<DialogTrigger asChild>
<button className="p-2.5 rounded-full bg-black/40 backdrop-blur-md hover:bg-white/10 transition-colors">
@@ -405,7 +631,7 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
<div className="flex items-center gap-2 p-3 bg-white/5 rounded-xl">
<input
type="text"
value={`https://wanzhi.gg/live/${id}`}
value={shareUrl}
readOnly
className="flex-1 bg-transparent text-xs text-white/70 outline-none"
/>
@@ -414,7 +640,7 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string
variant="outline"
className="h-7 text-xs bg-transparent"
onClick={() => {
navigator.clipboard.writeText(`https://wanzhi.gg/live/${id}`)
navigator.clipboard.writeText(shareUrl)
toast({ title: "链接已复制" })
}}
>

View File

@@ -17,6 +17,7 @@ import {
Edit3,
Camera,
HelpCircle,
Video,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
@@ -188,6 +189,17 @@ export default function ProfilePage() {
</Link>
</div>
<Link
href="/streamer/studio"
className="flex items-center justify-between p-4 rounded-xl bg-gradient-to-r from-red-600/90 to-rose-700/90 text-white shadow-md active:scale-[0.99] transition-transform"
>
<span className="text-sm font-bold flex items-center gap-3">
<Video size={20} className="opacity-90" />
/
</span>
<ChevronRight size={18} className="opacity-70" />
</Link>
<div className="space-y-1 rounded-xl overflow-hidden bg-card border border-border">
{[
{ icon: FileText, label: "我的订单", href: "/orders" },

View File

@@ -0,0 +1,402 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Radio, Square, ExternalLink, Copy, AlertCircle, Cloud } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { toast } from "@/hooks/use-toast"
import { useAppContext } from "@/components/providers/app-provider"
import {
fetchStreamerSelf,
fetchStreamerCloudUrls,
postStreamerLiveAction,
type LiveCloudUrlsPayload,
type LiveCloudVendorId,
type StreamerSelfData,
} from "@/lib/api/streamer-studio"
function copyText(label: string, text: string) {
void navigator.clipboard.writeText(text)
toast({ title: "已复制", description: label })
}
export default function StreamerStudioPage() {
const router = useRouter()
const { authChecked, isLoggedIn } = useAppContext()
const [loading, setLoading] = useState(true)
const [self, setSelf] = useState<StreamerSelfData | null>(null)
const [notStreamer, setNotStreamer] = useState(false)
const [title, setTitle] = useState("")
const [pullUrl, setPullUrl] = useState("")
const [busy, setBusy] = useState(false)
const [cloudVendor, setCloudVendor] = useState<LiveCloudVendorId>("tencent")
const [cloudTtl, setCloudTtl] = useState(7200)
const [cloudLoading, setCloudLoading] = useState(false)
const [cloudResult, setCloudResult] = useState<LiveCloudUrlsPayload | null>(null)
const load = useCallback(async () => {
setLoading(true)
const r = await fetchStreamerSelf()
if (r.ok) {
setSelf(r.data)
setNotStreamer(false)
setTitle(r.data.title || `${r.data.name} 的直播间`)
setPullUrl(r.data.room?.pullPlaybackUrl ?? "")
} else {
setSelf(null)
setNotStreamer(r.status === 404)
}
setLoading(false)
}, [])
useEffect(() => {
if (!authChecked) return
if (!isLoggedIn) {
router.replace("/login")
return
}
load()
}, [authChecked, isLoggedIn, load, router])
const onFetchCloud = async () => {
setCloudLoading(true)
setCloudResult(null)
const r = await fetchStreamerCloudUrls(cloudVendor, cloudTtl)
setCloudLoading(false)
if (!r.ok) {
toast({ title: "获取失败", description: r.message, variant: "destructive" })
return
}
setCloudResult(r.data)
if (!r.data.configured) {
toast({
title: "尚未配置环境变量",
description: "请按下方说明在 .env / 部署平台填写密钥与域名后再生成。",
})
} else {
toast({ title: "已生成", description: `StreamName${r.data.streamName}` })
}
}
const onStart = async () => {
setBusy(true)
const r = await postStreamerLiveAction("start", {
title: title.trim() || undefined,
pullPlaybackUrl: pullUrl.trim() || null,
})
setBusy(false)
if (!r.ok) {
toast({ title: "开播失败", description: r.message, variant: "destructive" })
return
}
toast({ title: "已开播", description: "观众可在直播广场进入你的直播间。" })
await load()
}
const onStop = async () => {
setBusy(true)
const r = await postStreamerLiveAction("stop")
setBusy(false)
if (!r.ok) {
toast({ title: "关播失败", description: r.message, variant: "destructive" })
return
}
toast({ title: "已关播" })
await load()
}
const rtmpHint = process.env.NEXT_PUBLIC_LIVE_RTMP_TEMPLATE ?? ""
if (!authChecked || loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background text-foreground/60 text-sm">
</div>
)
}
if (notStreamer || !self) {
return (
<div className="min-h-screen bg-background pb-24 px-4 pt-safe max-w-md mx-auto">
<div className="flex items-center gap-2 h-12">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft size={20} />
</Button>
<h1 className="font-bold text-lg"></h1>
</div>
<div className="mt-8 rounded-xl border border-border bg-card p-6 space-y-4">
<div className="flex gap-2 text-amber-600">
<AlertCircle size={20} />
<p className="text-sm leading-relaxed">
MongoDB{" "}
<code className="text-xs bg-muted px-1 rounded">streamers.userId</code> {" "}
<code className="text-xs bg-muted px-1 rounded">_id</code>
</p>
</div>
<Button variant="outline" className="w-full" onClick={() => router.push("/profile")}>
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background pb-28 px-4 pt-safe max-w-md mx-auto">
<div className="flex items-center justify-between h-12 mb-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft size={20} />
</Button>
<h1 className="font-bold text-lg"></h1>
</div>
{self.isLive ? (
<span className="text-xs font-bold text-red-500 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<p className="text-xs text-muted-foreground mb-4">
<strong></strong>
RTMPOBS <strong> HLS</strong>
</p>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground"></label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="展示在直播广场与房间内" />
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground"> URL</label>
<Input
value={pullUrl}
onChange={(e) => setPullUrl(e.target.value)}
placeholder="https://…/xxx.m3u8 或 mp4腾讯云转码后播放域名"
/>
</div>
<div className="rounded-xl border border-primary/25 bg-primary/5 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Cloud size={18} className="text-primary" />
/
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
StreamName <code className="bg-background/80 px-1 rounded">wanzhi_&lt;MongoId&gt;</code>
OBS .env
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="text-xs h-8"
onClick={() => window.open("https://console.cloud.tencent.com/live", "_blank", "noopener,noreferrer")}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-xs h-8"
onClick={() => window.open("https://live.console.aliyun.com/", "_blank", "noopener,noreferrer")}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-xs h-8"
onClick={() => window.open("https://developer.qiniu.com/pili", "_blank", "noopener,noreferrer")}
>
Pili
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-xs h-8"
onClick={() => window.open("https://console.volcengine.com/live", "_blank", "noopener,noreferrer")}
>
</Button>
</div>
<div className="grid grid-cols-2 gap-2 items-end">
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground"></label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-xs"
value={cloudVendor}
onChange={(e) => setCloudVendor(e.target.value as LiveCloudVendorId)}
>
<option value="tencent"></option>
<option value="aliyun"></option>
<option value="qiniu"></option>
<option value="volcengine"></option>
<option value="generic"> RTMP </option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground"></label>
<Input
type="number"
min={300}
max={604800}
className="h-9 text-xs"
value={cloudTtl}
onChange={(e) => setCloudTtl(Number(e.target.value) || 7200)}
/>
</div>
</div>
<Button type="button" className="w-full" size="sm" disabled={cloudLoading} onClick={() => void onFetchCloud()}>
{cloudLoading ? "生成中…" : "生成推流 / 播放地址"}
</Button>
{cloudResult ? (
<div className="rounded-lg border border-border bg-card p-3 space-y-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-muted-foreground"></span>
<span className={cloudResult.configured ? "text-green-600 font-medium" : "text-amber-600 font-medium"}>
{cloudResult.configured ? "已配置密钥" : "待配置 .env"}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-muted-foreground"></span>
<span className="text-right break-all">{cloudResult.expiresAt}</span>
</div>
{cloudResult.setupHint ? (
<p className="text-[11px] text-muted-foreground leading-relaxed">{cloudResult.setupHint}</p>
) : null}
{cloudResult.push?.rtmp ? (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="font-medium text-foreground">RTMP </span>
<Button type="button" variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => copyText("RTMP", cloudResult.push!.rtmp!)}>
<Copy size={12} className="mr-1" />
</Button>
</div>
<p className="break-all text-[10px] bg-muted/50 p-2 rounded-md">{cloudResult.push.rtmp}</p>
{cloudResult.push.note ? <p className="text-[10px] text-muted-foreground">{cloudResult.push.note}</p> : null}
</div>
) : null}
{cloudResult.play?.hls ? (
<div className="space-y-1">
<div className="flex justify-between items-center gap-2">
<span className="font-medium text-foreground">HLS </span>
<div className="flex gap-1 shrink-0">
<Button type="button" variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => copyText("HLS", cloudResult.play!.hls!)}>
<Copy size={12} className="mr-1" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 text-[10px]"
onClick={() => {
setPullUrl(cloudResult.play!.hls!)
toast({ title: "已填入拉流预览", description: "确认后点击「开始直播」" })
}}
>
</Button>
</div>
</div>
<p className="break-all text-[10px] bg-muted/50 p-2 rounded-md">{cloudResult.play.hls}</p>
</div>
) : null}
{cloudResult.play?.flv ? (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="font-medium text-foreground">HTTP-FLV</span>
<Button type="button" variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => copyText("FLV", cloudResult.play!.flv!)}>
<Copy size={12} className="mr-1" />
</Button>
</div>
<p className="break-all text-[10px] bg-muted/50 p-2 rounded-md">{cloudResult.play.flv}</p>
</div>
) : null}
{cloudResult.play?.note && !cloudResult.play?.hls ? (
<p className="text-[10px] text-muted-foreground">{cloudResult.play.note}</p>
) : null}
{cloudResult.docs?.length ? (
<div className="flex flex-wrap gap-2 pt-1">
{cloudResult.docs.map((d) => (
<a
key={d.href}
href={d.href}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-primary underline"
>
{d.label}
</a>
))}
</div>
) : null}
</div>
) : null}
</div>
<div className="flex gap-2">
<Button className="flex-1 gap-2" disabled={busy || self.isLive} onClick={onStart}>
<Radio size={18} />
</Button>
<Button variant="destructive" className="flex-1 gap-2" disabled={busy || !self.isLive} onClick={onStop}>
<Square size={18} />
</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={`/live/${self.streamerId}`}>
<Button size="sm" variant="outline" className="gap-1">
<ExternalLink size={14} />
</Button>
</Link>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full text-xs h-8"
onClick={() => {
const u = `${typeof window !== "undefined" ? window.location.origin : ""}/live/${self.streamerId}?streamerId=${self.streamerId}&channel=share`
void navigator.clipboard.writeText(u)
toast({ title: "已复制观众分享链接" })
}}
>
<Copy size={14} className="mr-1" />
</Button>
</div>
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-4 space-y-2 text-xs text-muted-foreground">
<p className="font-medium text-foreground">OBS </p>
{rtmpHint ? (
<p className="break-all">
<code>NEXT_PUBLIC_LIVE_RTMP_TEMPLATE</code>{rtmpHint}
</p>
) : (
<p>
<code className="bg-muted px-1 rounded">NEXT_PUBLIC_LIVE_RTMP_TEMPLATE</code>
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
"use client"
/**
* 开发环境提示:避免误用 3000常为 Remotion 等)或与 Docker 占用的 3001 冲突。
* 端口与 package.json 中 `pnpm dev` 保持一致。
*/
export function DevPortBanner() {
if (process.env.NODE_ENV !== "development") return null
/** 与 package.json `pnpm dev` 中 `-p` 保持一致 */
const port = "3088"
return (
<div className="sticky top-0 z-[100] bg-amber-950/95 text-amber-50 text-center text-[11px] leading-snug py-2 px-3 border-b border-amber-800/80">
<strong></strong>访{" "}
<code className="rounded bg-black/30 px-1 py-0.5">http://localhost:{port}</code>
<code className="opacity-80">3000</code> Node Remotion <code>/live</code> 404
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { Suspense } from "react"
import { ConditionalShell } from "@/components/layout/conditional-shell"
import { DevPortBanner } from "@/components/layout/dev-port-banner"
import { AppProvider } from "@/components/providers/app-provider"
import { StreamerRefCapture } from "@/components/streamer-ref-capture"
import { Toaster } from "@/components/ui/toaster"
@@ -10,6 +11,7 @@ import { Toaster } from "@/components/ui/toaster"
export function RootProviders({ children }: { children: React.ReactNode }) {
const content = (
<>
<DevPortBanner />
<Suspense fallback={null}>
<StreamerRefCapture />
</Suspense>

View File

@@ -0,0 +1,67 @@
"use client"
import { useEffect, useRef } from "react"
type Props = {
playUrl: string
className?: string
}
/**
* mp4 等:原生 srcm3u8Safari 原生 HLS其它浏览器用 hls.js
*/
export function LivePlaybackVideo({ playUrl, className }: Props) {
const ref = useRef<HTMLVideoElement>(null)
useEffect(() => {
const video = ref.current
if (!video) return
const isM3u8 = /\.m3u8(\?|$)/i.test(playUrl)
let cancelled = false
let hls: import("hls.js").default | null = null
const run = async () => {
if (isM3u8) {
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = playUrl
try {
await video.play()
} catch {
/* autoplay 策略可能拦截,用户可点控件播放 */
}
return
}
const { default: Hls } = await import("hls.js")
if (cancelled || !Hls.isSupported()) return
hls = new Hls({ enableWorker: true, lowLatencyMode: true })
hls.loadSource(playUrl)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
void video.play().catch(() => {})
})
return
}
video.src = playUrl
try {
await video.play()
} catch {
/* 同上 */
}
}
void run()
return () => {
cancelled = true
if (hls) {
hls.destroy()
hls = null
}
video.removeAttribute("src")
video.load()
}
}, [playUrl])
return <video ref={ref} className={className} playsInline controls muted autoPlay />
}

116
lib/api/live.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* 直播上下文 API 客户端(房间、橱窗、风控词、公会分成提示)
*/
export interface LiveShowcaseItemDTO {
id: string
title: string
subtitle?: string
landingPath: string
refQuery: string
}
export interface LiveContextData {
streamer: {
id: string
name: string
isLive: boolean
commissionRate: number
guildId: string | null
}
guild: { id: string; name: string; commissionRate: number } | null
room: {
id: string
title: string
status: string
game: string
viewerCount: number
pullPlaybackUrl: string
roomMode: string
} | null
showcase: LiveShowcaseItemDTO[]
forbiddenWords: string[]
revenueHint: string
}
export function buildShowcaseHref(origin: string, item: LiveShowcaseItemDTO): string {
const path = item.landingPath.startsWith("/") ? item.landingPath : `/${item.landingPath}`
const q = item.refQuery.startsWith("?") ? item.refQuery.slice(1) : item.refQuery
return `${origin.replace(/\/$/, "")}${path}?${q}`
}
export async function getLiveContext(streamerId: string): Promise<LiveContextData | null> {
const res = await fetch(`/api/live/context/${streamerId}`, { cache: "no-store" })
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
data?: LiveContextData
message?: string
}
if (!res.ok || !json.success || !json.data) return null
return json.data
}
export interface LiveCommentDTO {
id: string
userId: string
userName: string
content: string
type: "normal" | "enter" | "gift" | "system"
level: number
createdAt: string
}
export async function getLiveComments(streamerId: string): Promise<LiveCommentDTO[]> {
const res = await fetch(`/api/live/comments/${streamerId}`, { cache: "no-store" })
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
data?: { comments?: LiveCommentDTO[] }
}
if (!res.ok || !json.success || !json.data?.comments) return []
return json.data.comments
}
export async function postLiveComment(
streamerId: string,
body: { content: string; type?: "normal" },
): Promise<{ ok: boolean; message?: string }> {
const res = await fetch(`/api/live/comments/${streamerId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: body.content, type: body.type ?? "normal" }),
})
const json = (await res.json().catch(() => ({}))) as { success?: boolean; message?: string }
if (!res.ok || !json.success) {
return { ok: false, message: json.message ?? "发送失败" }
}
return { ok: true }
}
/** 进房提示(需登录;失败静默) */
export async function postLiveEnter(streamerId: string): Promise<boolean> {
const res = await fetch(`/api/live/comments/${streamerId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "enter", content: "" }),
})
const json = (await res.json().catch(() => ({}))) as { success?: boolean }
return res.ok && !!json.success
}
/** 直播送礼:服务端扣币 + liveGifts + commissionLedger + 公屏 gift */
export async function postLiveGift(
streamerId: string,
giftId: number,
): Promise<{ ok: boolean; message?: string }> {
const res = await fetch(`/api/live/gift`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ streamerId, giftId }),
})
const json = (await res.json().catch(() => ({}))) as { success?: boolean; message?: string }
if (!res.ok || !json.success) {
return { ok: false, message: json.message ?? "送礼失败" }
}
return { ok: true }
}

View File

@@ -0,0 +1,81 @@
/** 主播工作台:当前登录用户对应的主播档案与开/关播 */
export type StreamerSelfData = {
streamerId: string
name: string
title: string
game: string
isLive: boolean
room: {
id: string
title: string
status: string
pullPlaybackUrl: string
} | null
}
export async function fetchStreamerSelf(): Promise<{ ok: true; data: StreamerSelfData } | { ok: false; status: number; message: string }> {
const res = await fetch("/api/streamer/me", { credentials: "include", cache: "no-store" })
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
data?: StreamerSelfData
message?: string
}
if (!res.ok || !json.success || !json.data) {
return { ok: false, status: res.status, message: json.message ?? "无法加载主播档案" }
}
return { ok: true, data: json.data }
}
export type LiveCloudVendorId = "tencent" | "aliyun" | "qiniu" | "volcengine" | "generic"
export type LiveCloudUrlsPayload = {
vendor: LiveCloudVendorId
configured: boolean
streamName: string
expiresAt: string
setupHint?: string
docs?: { label: string; href: string }[]
push?: { rtmp?: string; srt?: string; note?: string }
play?: { hls?: string; flv?: string; rtmp?: string; note?: string }
}
export async function fetchStreamerCloudUrls(
vendor: LiveCloudVendorId,
ttlSec = 7200,
): Promise<{ ok: true; data: LiveCloudUrlsPayload } | { ok: false; message: string }> {
const res = await fetch(
`/api/streamer/live/cloud?vendor=${encodeURIComponent(vendor)}&ttl=${encodeURIComponent(String(ttlSec))}`,
{ credentials: "include", cache: "no-store" },
)
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
data?: LiveCloudUrlsPayload
message?: string
}
if (!res.ok || !json.success || !json.data) {
return { ok: false, message: json.message ?? "无法获取云厂商地址" }
}
return { ok: true, data: json.data }
}
export async function postStreamerLiveAction(
action: "start" | "stop",
payload?: { title?: string; pullPlaybackUrl?: string | null },
): Promise<{ ok: boolean; message?: string; data?: Record<string, unknown> }> {
const res = await fetch("/api/streamer/live", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action, ...payload }),
})
const json = (await res.json().catch(() => ({}))) as {
success?: boolean
message?: string
data?: Record<string, unknown>
}
if (!res.ok || !json.success) {
return { ok: false, message: json.message ?? "操作失败" }
}
return { ok: true, data: json.data }
}

View File

@@ -39,6 +39,12 @@ export const COLLECTIONS = {
liveRooms: "liveRooms",
liveGifts: "liveGifts",
liveComments: "liveComments",
/** 直播间橱窗(小黄车):站内路径 + 归因 query */
liveShowcaseItems: "liveShowcaseItems",
/** 单次开播/推流会话(断线续推、关播再开) */
liveSessions: "liveSessions",
/** 直播相关分润台账(只追加) */
commissionLedger: "commissionLedger",
// 商城
products: "products",
cartItems: "cartItems",
@@ -173,10 +179,25 @@ export const INDEX_SPECS: Record<string, IndexDescription[]> = {
liveGifts: [
{ key: { liveRoomId: 1 }, name: "liveRoomId_1" },
{ key: { userId: 1 }, name: "userId_1" },
{ key: { streamerId: 1, createdAt: -1 }, name: "streamerId_createdAt_-1" },
],
liveComments: [
{ key: { liveRoomId: 1 }, name: "liveRoomId_1" },
{ key: { userId: 1 }, name: "userId_1" },
{ key: { streamerId: 1, createdAt: -1 }, name: "streamerId_createdAt_-1" },
],
liveShowcaseItems: [
{ key: { streamerId: 1, sortOrder: 1 }, name: "streamerId_sortOrder_1" },
{ key: { status: 1 }, name: "status_1" },
],
liveSessions: [
{ key: { streamerId: 1, startedAt: -1 }, name: "streamerId_startedAt_-1" },
{ key: { status: 1 }, name: "status_1" },
],
commissionLedger: [
{ key: { streamerId: 1, createdAt: -1 }, name: "streamerId_createdAt_-1" },
{ key: { guildId: 1 }, name: "guildId_1", sparse: true },
{ key: { beneficiaryType: 1, streamerId: 1 }, name: "beneficiaryType_streamerId_1" },
],
products: [
{ key: { category: 1 }, name: "category_1" },

View File

@@ -109,7 +109,7 @@ async function seed() {
console.log("[Mongo] admins: 1 (admin)")
}
// 2c. 前端测试账号 15880802661 卡若 k123456仅当该手机号未注册时写入
// 2c. 前端测试账号 15880802661 卡若 key123456仅当该手机号未注册时写入;与 scripts/set-user-password 默认一致
const authsColl = db.collection(COLLECTIONS.userAuths)
const hasKaruo = await authsColl.findOne({ authType: "phone", authId: "15880802661" })
if (!hasKaruo) {
@@ -129,13 +129,44 @@ async function seed() {
userId: karuoId,
authType: "phone",
authId: "15880802661",
credential: hashPassword("k123456"),
credential: hashPassword("key123456"),
createdAt: new Date(),
updatedAt: new Date(),
})
console.log("[Mongo] 测试用户: 15880802661 卡若")
}
// 2d. 测试手机号 15880802661幂等绑定一条「已审核」主播档案否则主播工作台无入口
const karuoUserDoc = await usersColl.findOne({ phone: "15880802661" })
if (karuoUserDoc && (karuoUserDoc as { _id?: ObjectId })._id) {
const kUid = (karuoUserDoc as { _id: ObjectId })._id.toString()
const hasKaruoStreamer = await streamersColl.findOne({ userId: kUid, status: "approved" })
if (!hasKaruoStreamer) {
await streamersColl.insertOne({
userId: kUid,
name: "卡若",
title: "玩值测试主播 · 工作台",
game: "英雄联盟",
gameId: 2,
avatar: "/gamer-boy-esports-jersey.jpg",
coverImage: "/lol-stream.jpg",
tags: ["测试", "工作台"],
level: 1,
fans: 888,
hotValue: 8.8,
isLive: false,
totalGifts: 0,
totalIncome: 0,
commissionRate: 0.7,
status: "approved",
verifiedAt: now(),
createdAt: now(),
updatedAt: now(),
})
console.log("[Mongo] 测试账号 15880802661 已绑定已审核主播 streamers.userId =", kUid)
}
}
// 3. 主播:默认 6 条(仅当无主播时写入);老游条导师(按 source+name 去重补充)
const streamersCount = await streamersColl.countDocuments()
if (streamersCount === 0) {
@@ -189,6 +220,64 @@ async function seed() {
}
if (lytiaoInserted > 0) console.log("[Mongo] 老游条主播 新增:", lytiaoInserted)
// 3b. 直播liveRooms + 橱窗(幂等,与 isLive 主播同步;不依赖全量种子)
const liveRoomsColl = db.collection(COLLECTIONS.liveRooms)
const showcaseColl = db.collection(COLLECTIONS.liveShowcaseItems)
const liveOnes = await streamersColl.find({ status: "approved", isLive: true }).limit(40).toArray()
for (const s of liveOnes) {
const sid = s._id as ObjectId
await liveRoomsColl.updateOne(
{ streamerId: sid },
{
$set: {
title: (typeof s.title === "string" && s.title) || `${(s.name as string) ?? "主播"} 的直播间`,
game: (s.game as string) || "电竞",
status: "live",
roomMode: "video",
updatedAt: now(),
pullPlaybackUrl: "",
pushEndpointNote: "placeholder: 接入云直播后配置推流密钥",
},
$setOnInsert: {
streamerId: sid,
guildId: (s as { guildId?: unknown }).guildId ?? null,
viewerCount: Math.min(Number(s.fans) || 0, 99999),
createdAt: now(),
},
},
{ upsert: true }
)
const existingShow = await showcaseColl.countDocuments({ streamerId: sid })
if (existingShow === 0) {
const q = `streamerId=${sid.toString()}&channel=live`
await showcaseColl.insertMany([
{
streamerId: sid,
title: "玩值会员 / 黑卡",
subtitle: "站内开通 · 自动归因",
landingPath: "/mall",
refQuery: q,
sortOrder: 1,
status: "active",
createdAt: now(),
},
{
streamerId: sid,
title: "外设商城",
subtitle: "键盘鼠标耳机",
landingPath: "/mall",
refQuery: `${q}&tab=peripheral`,
sortOrder: 2,
status: "active",
createdAt: now(),
},
])
}
}
if (liveOnes.length > 0) {
console.log("[Mongo] liveRooms / liveShowcaseItems 已同步 isLive 主播数:", liveOnes.length)
}
// 以下仅在全量种子时写入(即 games 原本为空时)
if (gamesCount > 0) {
console.log("[Mongo] 其余种子(明星/公会等)已有数据,跳过")

52
lib/live/cloud/aliyun.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* 阿里云视频直播 URL 鉴权auth_key
* 文档https://help.aliyun.com/zh/live/user-guide/url-signing
*/
import crypto from "node:crypto"
import type { LiveCloudUrlsResult } from "./types"
function aliyunAuthKey(uriPath: string, expireUnix: number, privateKey: string) {
const rand = "0"
const uid = "0"
const s = `${uriPath}-${expireUnix}-${rand}-${uid}-${privateKey}`
const md5hash = crypto.createHash("md5").update(s, "utf8").digest("hex")
return `${expireUnix}-${rand}-${uid}-${md5hash}`
}
export function tryAliyunFromEnv(streamName: string, ttlSeconds: number): LiveCloudUrlsResult | null {
const pushDomain = process.env.ALIYUN_LIVE_PUSH_DOMAIN?.trim()
const playDomain = process.env.ALIYUN_LIVE_PLAY_DOMAIN?.trim()
const appName = process.env.ALIYUN_LIVE_APP_NAME?.trim() || "live"
const pushKey = process.env.ALIYUN_LIVE_PUSH_PRIVATE_KEY?.trim()
const playKey = process.env.ALIYUN_LIVE_PLAY_PRIVATE_KEY?.trim() || pushKey
if (!pushDomain || !playDomain || !pushKey || !playKey) return null
const now = Math.floor(Date.now() / 1000)
const exp = now + Math.max(60, ttlSeconds)
const uriPush = `/${appName}/${streamName}`
const authPush = aliyunAuthKey(uriPush, exp, pushKey)
const hostPush = pushDomain.replace(/^rtmp:\/\//i, "").replace(/\/$/, "")
const rtmp = `rtmp://${hostPush}${uriPush}?auth_key=${authPush}`
const uriPlay = `/${appName}/${streamName}`
const authPlay = aliyunAuthKey(uriPlay, exp, playKey)
const hostPlay = playDomain.replace(/^https?:\/\//i, "").replace(/\/$/, "")
const hls = `https://${hostPlay}${uriPlay}.m3u8?auth_key=${authPlay}`
const flv = `https://${hostPlay}${uriPlay}.flv?auth_key=${authPlay}`
return {
vendor: "aliyun",
configured: true,
streamName,
expiresAt: new Date(exp * 1000).toISOString(),
docs: [
{ label: "视频直播控制台", href: "https://live.console.aliyun.com/" },
{ label: "URL 鉴权", href: "https://help.aliyun.com/zh/live/user-guide/url-signing" },
],
push: { rtmp, note: "OBS 推流地址见上;推流域名需在阿里云开启 URL 鉴权并配置主 KEY。" },
play: { hls, flv, note: "HLS 填入工作台「拉流预览 URL」。" },
}
}

50
lib/live/cloud/qiniu.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* 七牛云直播Pili限时鉴权推流
* 文档https://developer.qiniu.com/pili/2767/the-rtmp-push-flow-address
*/
import crypto from "node:crypto"
import type { LiveCloudUrlsResult } from "./types"
function urlSafeBase64(buf: Buffer) {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
export function tryQiniuFromEnv(streamName: string, ttlSeconds: number): LiveCloudUrlsResult | null {
const domain = process.env.QINIU_LIVE_PUSH_DOMAIN?.trim()
const hub = process.env.QINIU_LIVE_HUB?.trim()
const ak = process.env.QINIU_LIVE_ACCESS_KEY?.trim()
const sk = process.env.QINIU_LIVE_SECRET_KEY?.trim()
if (!domain || !hub || !ak || !sk) return null
const now = Math.floor(Date.now() / 1000)
const e = now + Math.max(60, ttlSeconds)
const path = `/${hub}/${streamName}?e=${e}`
const sign = crypto.createHmac("sha1", sk).update(path).digest()
const token = `${ak}:${urlSafeBase64(sign)}`
const host = domain.replace(/^rtmp:\/\//i, "").replace(/\/$/, "")
const rtmp = `rtmp://${host}/${hub}/${streamName}?e=${e}&token=${encodeURIComponent(token)}`
const playHost = process.env.QINIU_LIVE_PLAY_DOMAIN?.trim()
const hls = playHost
? `https://${playHost.replace(/^https?:\/\//i, "").replace(/\/$/, "")}/${hub}/${streamName}.m3u8?e=${e}&token=${encodeURIComponent(token)}`
: undefined
return {
vendor: "qiniu",
configured: true,
streamName,
expiresAt: new Date(e * 1000).toISOString(),
docs: [
{ label: "七牛 Pili 文档", href: "https://developer.qiniu.com/pili/2767/the-rtmp-push-flow-address" },
{ label: "快速入门", href: "https://developer.qiniu.com/pili/1221/the-console-quick-start" },
],
push: { rtmp, note: "Hub 与域名在七牛直播控制台创建;播放域名可选配置 QINIU_LIVE_PLAY_DOMAIN。" },
play: hls ? { hls, note: "若播放鉴权与推流不同,请在控制台核对后改用厂商播放地址。" } : { note: "未配置 QINIU_LIVE_PLAY_DOMAIN请在控制台复制播放地址。" },
}
}

110
lib/live/cloud/resolve.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { LiveCloudUrlsResult, LiveCloudVendor } from "./types"
import { tryTencentFromEnv } from "./tencent"
import { tryAliyunFromEnv } from "./aliyun"
import { tryQiniuFromEnv } from "./qiniu"
const VENDORS: LiveCloudVendor[] = ["tencent", "aliyun", "qiniu", "volcengine", "generic"]
export function parseVendor(v: string | null): LiveCloudVendor | null {
if (!v) return null
const x = v.toLowerCase().trim() as LiveCloudVendor
return VENDORS.includes(x) ? x : null
}
function notConfigured(
vendor: LiveCloudVendor,
streamName: string,
hint: string,
docs: { label: string; href: string }[],
): LiveCloudUrlsResult {
return {
vendor,
configured: false,
streamName,
expiresAt: new Date().toISOString(),
setupHint: hint,
docs,
}
}
export function resolveLiveCloudUrls(
vendor: LiveCloudVendor,
streamName: string,
ttlSeconds: number,
): LiveCloudUrlsResult {
const ttl = Math.min(Math.max(ttlSeconds || 3600, 300), 86400 * 7)
if (vendor === "tencent") {
const r = tryTencentFromEnv(streamName, ttl)
if (r) return r
return notConfigured(
"tencent",
streamName,
"请在服务端环境变量配置TENCENT_LIVE_PUSH_DOMAIN、TENCENT_LIVE_PLAY_DOMAIN、TENCENT_LIVE_APP_NAME可选默认 live、TENCENT_LIVE_PUSH_AUTH_KEY、TENCENT_LIVE_PLAY_AUTH_KEY可同推流密钥。",
[
{ label: "腾讯云直播控制台", href: "https://console.cloud.tencent.com/live" },
{ label: "鉴权计算说明", href: "https://cloud.tencent.com/document/product/267/32735" },
],
)
}
if (vendor === "aliyun") {
const r = tryAliyunFromEnv(streamName, ttl)
if (r) return r
return notConfigured(
"aliyun",
streamName,
"请配置ALIYUN_LIVE_PUSH_DOMAIN、ALIYUN_LIVE_PLAY_DOMAIN、ALIYUN_LIVE_APP_NAME可选、ALIYUN_LIVE_PUSH_PRIVATE_KEY、ALIYUN_LIVE_PLAY_PRIVATE_KEY可同推流 KEY。",
[
{ label: "阿里云视频直播", href: "https://help.aliyun.com/zh/live/user-guide/url-signing" },
{ label: "控制台", href: "https://live.console.aliyun.com/" },
],
)
}
if (vendor === "qiniu") {
const r = tryQiniuFromEnv(streamName, ttl)
if (r) return r
return notConfigured(
"qiniu",
streamName,
"请配置QINIU_LIVE_PUSH_DOMAIN、QINIU_LIVE_HUB、QINIU_LIVE_ACCESS_KEY、QINIU_LIVE_SECRET_KEY可选 QINIU_LIVE_PLAY_DOMAIN。",
[{ label: "七牛 Pili RTMP 地址", href: "https://developer.qiniu.com/pili/2767/the-rtmp-push-flow-address" }],
)
}
if (vendor === "volcengine") {
return notConfigured(
"volcengine",
streamName,
"火山引擎直播推拉流签名与域名策略与控制台配置强相关,本仓库暂未内置生成器。可在控制台生成 OBS 地址后,粘贴到「拉流预览 URL」或自建微服务签名后对接。",
[
{ label: "火山引擎视频直播", href: "https://www.volcengine.com/docs/6469" },
{ label: "控制台", href: "https://console.volcengine.com/live" },
],
)
}
// generic
const base = process.env.LIVE_GENERIC_RTMP_BASE?.trim()
if (base) {
const normalized = base.endsWith("/") ? base : `${base}/`
const rtmp = `${normalized}${streamName}`
return {
vendor: "generic",
configured: true,
streamName,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
docs: [{ label: "OBS 官方文档", href: "https://obsproject.com/wiki/" }],
push: { rtmp, note: "无鉴权模板:仅适合内网/测试;生产请改用腾讯云/阿里云等签名推流。" },
play: { note: "播放地址请从 CDN/云厂商控制台复制后填「拉流预览 URL」。" },
}
}
return notConfigured(
"generic",
streamName,
"可配置 LIVE_GENERIC_RTMP_BASE如 rtmp://push.example.com/live/)生成无鉴权推流前缀;或配置腾讯云/阿里云环境变量。",
[{ label: "腾讯云直播", href: "https://console.cloud.tencent.com/live" }],
)
}

87
lib/live/cloud/tencent.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* 腾讯云直播:推流/播放 URL 鉴权txTime + txSecret
* 文档https://cloud.tencent.com/document/product/267/32735
*/
import crypto from "node:crypto"
import type { LiveCloudUrlsResult } from "./types"
function md5Lower(s: string) {
return crypto.createHash("md5").update(s, "utf8").digest("hex")
}
/** txTime过期时间 Unix 秒 → 十六进制大写 */
export function tencentTxTime(expiresAtUnixSec: number) {
return Math.floor(expiresAtUnixSec).toString(16).toUpperCase()
}
/** txSecret = MD5(KEY + StreamName + txTime) 小写十六进制 */
export function tencentTxSecret(authKey: string, streamName: string, txTimeHex: string) {
return md5Lower(`${authKey}${streamName}${txTimeHex}`)
}
export function buildTencentSignedUrls(opts: {
pushDomain: string
playDomain: string
appName: string
streamName: string
pushAuthKey: string
playAuthKey: string
ttlSeconds: number
}): LiveCloudUrlsResult {
const now = Math.floor(Date.now() / 1000)
const exp = now + Math.max(60, opts.ttlSeconds)
const txTime = tencentTxTime(exp)
const pushSecret = tencentTxSecret(opts.pushAuthKey, opts.streamName, txTime)
const playSecret = tencentTxSecret(opts.playAuthKey, opts.streamName, txTime)
const q = `txSecret=${pushSecret}&txTime=${txTime}`
const hostPush = opts.pushDomain.replace(/^rtmp:\/\//i, "").replace(/\/$/, "")
const hostPlay = opts.playDomain.replace(/^https?:\/\//i, "").replace(/\/$/, "")
const rtmp = `rtmp://${hostPush}/${opts.appName}/${opts.streamName}?${q}`
const pq = `txSecret=${playSecret}&txTime=${txTime}`
const hls = `https://${hostPlay}/${opts.appName}/${opts.streamName}.m3u8?${pq}`
const flv = `https://${hostPlay}/${opts.appName}/${opts.streamName}.flv?${pq}`
return {
vendor: "tencent",
configured: true,
streamName: opts.streamName,
expiresAt: new Date(exp * 1000).toISOString(),
docs: [
{ label: "云直播控制台", href: "https://console.cloud.tencent.com/live" },
{ label: "防盗链/鉴权说明", href: "https://cloud.tencent.com/document/product/267/32735" },
],
push: {
rtmp,
note: "OBS设置 → 推流 → 服务器填 rtmp://推流域名/AppName ,串流密钥填 StreamName?txSecret=…&txTime=…(或整段填入「服务器」视 OBS 版本而定)。",
},
play: {
hls,
flv,
note: "将 HLS 填入「拉流预览 URL」后点「开始直播」观众端用 hls.js/Safari 播放。",
},
}
}
export function tryTencentFromEnv(streamName: string, ttlSeconds: number): LiveCloudUrlsResult | null {
const pushDomain = process.env.TENCENT_LIVE_PUSH_DOMAIN?.trim()
const playDomain = process.env.TENCENT_LIVE_PLAY_DOMAIN?.trim()
const appName = process.env.TENCENT_LIVE_APP_NAME?.trim() || "live"
const pushKey = process.env.TENCENT_LIVE_PUSH_AUTH_KEY?.trim()
const playKey =
process.env.TENCENT_LIVE_PLAY_AUTH_KEY?.trim() || process.env.TENCENT_LIVE_PUSH_AUTH_KEY?.trim()
if (!pushDomain || !playDomain || !pushKey || !playKey) return null
return buildTencentSignedUrls({
pushDomain,
playDomain,
appName,
streamName,
pushAuthKey: pushKey,
playAuthKey: playKey,
ttlSeconds,
})
}

13
lib/live/cloud/types.ts Normal file
View File

@@ -0,0 +1,13 @@
export type LiveCloudVendor = "tencent" | "aliyun" | "qiniu" | "volcengine" | "generic"
export type LiveCloudUrlsResult = {
vendor: LiveCloudVendor
configured: boolean
streamName: string
expiresAt: string
/** 未配置时给运营/主播的说明 */
setupHint?: string
docs?: { label: string; href: string }[]
push?: { rtmp?: string; srt?: string; note?: string }
play?: { hls?: string; flv?: string; rtmp?: string; note?: string }
}

9
lib/live/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
/** 直播场景默认违禁词(与 context API 一致,可后续迁至配置集合) */
export const DEFAULT_LIVE_FORBIDDEN_WORDS = ["赌博", "色情", "法轮功", "台独", "淫秽", "毒品"] as const
export function hitsLiveForbiddenWord(text: string, words: readonly string[]): string | null {
for (const w of words) {
if (w && text.includes(w)) return w
}
return null
}

17
lib/live/gift-catalog.ts Normal file
View File

@@ -0,0 +1,17 @@
/** 直播间礼物价目(服务端以此为准,防篡改金额) */
export const LIVE_GIFT_CATALOG: Record<
number,
{ name: string; priceCoins: number; icon: string }
> = {
1: { name: "棒棒糖", priceCoins: 10, icon: "🍭" },
2: { name: "玫瑰花", priceCoins: 52, icon: "🌹" },
3: { name: "告白气球", priceCoins: 520, icon: "🎈" },
4: { name: "跑车", priceCoins: 5000, icon: "🏎️" },
5: { name: "火箭", priceCoins: 10000, icon: "🚀" },
6: { name: "城堡", priceCoins: 50000, icon: "🏰" },
}
export function getLiveGiftOrNull(giftId: number) {
return LIVE_GIFT_CATALOG[giftId] ?? null
}

View File

@@ -0,0 +1,69 @@
/**
* 开发环境:当前登录用户若无「已审核」主播档案,则自动创建或把已有记录升为 approved。
* 生产环境默认关闭;可通过环境变量 STREAMER_DEV_AUTOPROVISION=1 显式打开(仅用于内网验收)。
*/
import { ObjectId, type WithId, type Document } from "mongodb"
import { getCollection } from "@/lib/db/mongo"
function nowIso() {
return new Date().toISOString()
}
export function isStreamerDevAutoprovisionEnabled(): boolean {
return process.env.NODE_ENV === "development" || process.env.STREAMER_DEV_AUTOPROVISION === "1"
}
/**
* @returns 已审核的 streamers 文档;无法满足时 null
*/
export async function ensureApprovedStreamerForUser(userIdStr: string): Promise<WithId<Document> | null> {
if (!ObjectId.isValid(userIdStr)) return null
const streamersColl = await getCollection("streamers")
const approved = await streamersColl.findOne({ userId: userIdStr, status: "approved" })
if (approved) return approved as WithId<Document>
if (!isStreamerDevAutoprovisionEnabled()) return null
const usersColl = await getCollection("users")
const oid = new ObjectId(userIdStr)
const user = await usersColl.findOne({ _id: oid })
if (!user) return null
const anyStreamer = await streamersColl.findOne({ userId: userIdStr })
if (anyStreamer) {
await streamersColl.updateOne(
{ _id: anyStreamer._id as ObjectId },
{ $set: { status: "approved", verifiedAt: nowIso(), updatedAt: nowIso(), devAutoApproved: true } },
)
const again = await streamersColl.findOne({ _id: anyStreamer._id as ObjectId })
return again as WithId<Document> | null
}
await streamersColl.insertOne({
userId: userIdStr,
name: String((user as { name?: string }).name ?? "主播"),
title: "开发环境 · 自动过审主播",
game: "英雄联盟",
gameId: 2,
avatar: String((user as { avatar?: string }).avatar ?? "") || "/gamer-boy-esports-jersey.jpg",
coverImage: "/lol-stream.jpg",
tags: ["dev", "auto-approved"],
level: 1,
fans: 100,
hotValue: 1,
isLive: false,
totalGifts: 0,
totalIncome: 0,
commissionRate: 0.7,
status: "approved",
verifiedAt: nowIso(),
createdAt: nowIso(),
updatedAt: nowIso(),
devAutoProvisioned: true,
})
const created = await streamersColl.findOne({ userId: userIdStr, status: "approved" })
return created as WithId<Document> | null
}

View File

@@ -4,10 +4,12 @@
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev -p 3001",
"dev": "next dev -p 3088",
"lint": "eslint .",
"start": "next start",
"db:mongo:seed": "tsx lib/db/mongo/seed-mongo.ts",
"db:set-password": "tsx scripts/set-user-password.ts",
"db:bind-streamer": "tsx scripts/bind-approved-streamer.ts",
"db:mongo:indexes": "tsx lib/db/mongo/run-ensure-indexes.ts",
"seed:lytiao": "tsx scripts/seed-lytiao-streamers.ts",
"import:lytiao:mongo": "tsx scripts/import-lytiao-from-source-mongo.ts",
@@ -54,6 +56,7 @@
"dexie": "4.2.1",
"embla-carousel-react": "8.5.1",
"framer-motion": "12.23.24",
"hls.js": "^1.6.15",
"input-otp": "1.4.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.454.0",

8
pnpm-lock.yaml generated
View File

@@ -125,6 +125,9 @@ importers:
framer-motion:
specifier: 12.23.24
version: 12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
hls.js:
specifier: ^1.6.15
version: 1.6.15
input-otp:
specifier: 1.4.1
version: 1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1661,6 +1664,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hls.js@1.6.15:
resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==}
input-otp@1.4.1:
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
peerDependencies:
@@ -3411,6 +3417,8 @@ snapshots:
graceful-fs@4.2.11: {}
hls.js@1.6.15: {}
input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0

View File

@@ -0,0 +1,77 @@
/**
* 为指定手机号用户插入一条「已审核」主播档案(若尚无 approved + userId 绑定)
* 用法pnpm exec tsx scripts/bind-approved-streamer.ts [手机号]
* 默认15880802661
*/
import { config } from "dotenv"
import { resolve } from "path"
config({ path: resolve(process.cwd(), ".env.local") })
import { MongoClient, ObjectId } from "mongodb"
import { COLLECTIONS } from "../lib/db/mongo/collections"
const phone = (process.argv[2] || "15880802661").trim()
function nowIso() {
return new Date().toISOString()
}
async function main() {
const uri = process.env.MONGODB_URI
if (!uri) {
console.error("缺少 MONGODB_URI")
process.exit(1)
}
const client = new MongoClient(uri)
await client.connect()
try {
const db = client.db("wanzhi_esports")
const users = db.collection(COLLECTIONS.users)
const streamers = db.collection(COLLECTIONS.streamers)
const u = await users.findOne({ phone })
if (!u || !(u as { _id?: ObjectId })._id) {
console.error(`未找到用户 phone=${phone},请先注册`)
process.exit(1)
}
const uid = (u as { _id: ObjectId })._id.toString()
const exists = await streamers.findOne({ userId: uid, status: "approved" })
if (exists) {
console.log(`已有已审核主播绑定 userId=${uid},跳过`)
return
}
await streamers.insertOne({
userId: uid,
name: (u as { name?: string }).name || "主播",
title: "玩值测试主播 · 工作台",
game: "英雄联盟",
gameId: 2,
avatar: "/gamer-boy-esports-jersey.jpg",
coverImage: "/lol-stream.jpg",
tags: ["测试", "工作台"],
level: 1,
fans: 888,
hotValue: 8.8,
isLive: false,
totalGifts: 0,
totalIncome: 0,
commissionRate: 0.7,
status: "approved",
verifiedAt: nowIso(),
createdAt: nowIso(),
updatedAt: nowIso(),
})
console.log(`已为 ${phone} 创建已审核主播userId=${uid}`)
} finally {
await client.close()
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,48 @@
/**
* 重置指定手机号的登录密码userAuths.credential
* 用法pnpm exec tsx scripts/set-user-password.ts [手机号] [新密码]
* 默认15880802661 key123456
*/
import { config } from "dotenv"
import { resolve } from "path"
config({ path: resolve(process.cwd(), ".env.local") })
import { MongoClient } from "mongodb"
import { hashPassword } from "../lib/auth-utils"
import { COLLECTIONS } from "../lib/db/mongo/collections"
const phone = process.argv[2]?.trim() || "15880802661"
const newPassword = process.argv[3] || "key123456"
async function main() {
const uri = process.env.MONGODB_URI
if (!uri) {
console.error("缺少 MONGODB_URI请在 .env.local 配置")
process.exit(1)
}
const client = new MongoClient(uri)
await client.connect()
try {
const db = client.db("wanzhi_esports")
const auths = db.collection(COLLECTIONS.userAuths)
const hash = hashPassword(newPassword)
const r = await auths.updateOne(
{ authType: "phone", authId: phone },
{ $set: { credential: hash, updatedAt: new Date() } },
)
if (r.matchedCount === 0) {
console.error(`未找到手机号 ${phone} 的认证记录,请先注册或检查号码`)
process.exit(1)
}
console.log(`已更新 ${phone} 的登录密码(${newPassword.length} 位)`)
} finally {
await client.close()
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,12 @@
功能一:
![](images/2026-04-13-19-24-31.png)
![](images/2026-04-13-19-25-14.png)
对吧?什么打赏这些?还有一个是关于粉丝和用户之间的弹幕互动,还有一些评论互动,然后谁进房间了,这些都显示,然后现在还有什么。技术层面上的话,关于一个推流断点续传,还有是关闭之后重新再开播这样的一个过程,还有可能主播他一直要临时要走开,然后除此之外这个直播里面可能还有一个禁词的检测,如果这些违禁词有的话,还要提示主播禁止使用名词,否则会被房间。然后还有一些关于房间退游的一些安全的拦截,防止串流,就可能会有黑客恶意使用串流去劫持你的这个直播内容,还有。关于,对,还有一些什么小黄车,是吧?是不是也要推?就是。嗯,主播可能会在直播的过程中挂一些自己的商品,还有一些超链接,那这些链接里面甚至还每个链接里面肯定是需要我们的推荐码,因此在我们这套系统里面还要有一个比较大的一个分销的功能,这个是为主核心模块,因为所有的商品,所有的推广的整个过程之中。它都要涉及这一个的,这个分销和分佣的这一整套的这个逻辑好好
好,我现在要增加一个文字电竞的这么一个直播的这个功能,然后你帮我去市场调研一下那个斗鱼的和抖音的直播相应的那个功能,详细的一个,帮我做一个详细的一个调研报告,然后把,然后第二个参考这个丸子电竞的开发文档的需求来帮我把这个直播功能按你的理解和调研的结果帮我整一个开发计划。出来,然后目标是让主播,所有的主播能得到他跟主播的所有的粉,那个粉丝的消费都跟他有捆绑关系,以及工会也可以获得所有主播的捆绑的相应的收益
/Users/karuo/Documents/开发/3、自营项目/玩值电竞App/玩值开发文档
/Users/karuo/Documents/开发/3、自营项目/玩值电竞App
**执行计划(已落盘)**[app-直播-开发计划_20260413.md](./app-直播-开发计划_20260413.md)

View File

@@ -0,0 +1,194 @@
# 玩值电竞 App · 直播功能开发计划
> **版本**2026-04-13修订主播工作台与开源对标补充
> **关联需求**[app-直播-20260413.plan.md](./app-直播-20260413.plan.md)
> **目标**:主播侧绑定粉丝在直播场景下的消费归因;工会从签约主播可分成收入中获得分润;技术侧覆盖推拉流、互动、风控、小黄车与分销台账。
---
## 一、总体进度(人工维护)
| 阶段 | 说明 | 状态 | 仓库落地 |
|:---|:---|:---|:---|
| P0 | 口径与数据模型(房间、会话、橱窗、分润台账) | 🟡 进行中 | Mongo`liveShowcaseItems` / `liveSessions` / `commissionLedger` + 索引;`pnpm db:mongo:seed` 同步 `liveRooms` + 橱窗 |
| P1 | MVP 直播间(列表/详情、接口聚合、小黄车展示) | 🟡 进行中 | 同上;**云厂商**`GET /api/streamer/live/cloud` + 工作台「生成推流/播放」**腾讯云/阿里云/七牛**签名env 配置);火山占位说明 |
| P1.5 | **主播端开播(平台内)** | 🟢 首版已上 | `/streamer/studio``GET /api/streamer/me``POST /api/streamer/live`;写 `isLive` + `liveRooms` + `liveSessions`;我的页入口 |
| P2 | 打赏与分润闭环 | 🟡 进行中 | **`POST /api/live/gift`**:扣 `users.balance`、写 `transactions`/`liveGifts`/`commissionLedger`、公屏 `gift`;订单/商城归因仍待接 |
| P3 | 风控(违禁词、推流鉴权说明、防盗链) | ⚪ 未开始 | 端上违禁词拦截已示例;服务端审核待接 |
| P4 | 分销中台与商城归因 | ⚪ 未开始 | `refQuery` 与订单回写 |
| P5 | 数据看板与运营玩法 | ⚪ 未开始 | — |
**当前完成度(约)****58%** — 含云厂商 **服务端签名推拉流地址**(腾讯/阿里/七牛、送礼分润、HLS 工作台填入;仍未含 WebSocket、商城订单归因、火山内置签名。
---
## 二、竞品与范围摘要
- **斗鱼**:弹幕文化、礼物/贵族/粉丝团、游戏长直播;可参考 AI 辅助弹幕等互动降门槛。
- **抖音**:小黄车、讲解置顶、投流与复盘;推拉流与电商归因成熟。
- **玩值约束**:玩值币/商城合规不做法币代币兑换RTC 可参考「云直播 + RTC」双栈见市场调研 UniLive 文档)。
---
## 三、里程碑任务清单
### P0 口径与账本
- [x] Mongo`liveShowcaseItems`(橱窗)、`liveSessions`(推流会话占位)、`commissionLedger`(分润台账占位)集合名与索引
- [x] 种子:`liveRooms``isLive` 主播幂等同步;默认橱窗两条(玩值站内路径 + ref
- [x]`数据库.md` 对齐:已补「直播扩展集合」一节(`玩值开发文档/2、架构/数据库.md`
- [ ] 财务/合规评审(外链商品、分润比例上限)
### P1 MVP 直播间
- [x] `GET /api/live/context/[streamerId]`:聚合主播、房间、橱窗、违禁词配置、分润说明字段
- [x] 直播间页:拉取 context小黄车抽屉评论违禁词拦截分享链接带 `streamerId` 归因参数
- [x] **拉流预览**`pullPlaybackUrl``http(s)` 时播放;**m3u8** 走 Safari 原生 HLS 或 **hls.js**`LivePlaybackVideo`
- [x] **RTMP 推流地址生成**(腾讯云 txSecret / 阿里云 auth_key / 七牛 tokenOBS 粘贴)+ 播放 HLS 生成并一键填入工作台
- [ ] 云控制台 **转码模板 / 延播 / 录制** 与玩值后台运营配置化(仍依赖各云控制台)
- [x] 公屏 **HTTP 轮询**`GET/POST /api/live/comments/[streamerId]` → Mongo `liveComments`(含 `streamerId` 索引);登录后进房 `type=enter`
- [ ] WebSocket 弹幕/进房(替换轮询、合并信令)
### P1.5 主播端开播(平台内)
- [x] `GET /api/streamer/me`:登录态下返回已审核主播 + 当前房间拉流字段
- [x] `POST /api/streamer/live``action=start|stop`,写 `streamers.isLive``liveRooms``liveSessions`(重复开播会先结束旧 session
- [x] 页面 `/streamer/studio`:标题、拉流预览 URL、开/关播、复制观众链接、OBS 说明(`NEXT_PUBLIC_LIVE_RTMP_TEMPLATE`
- [x] `/profile` 入口:**主播工作台**
- [ ] 与云厂商控制台联动:**推流密钥动态签发**、防盗链、断线重连策略
- [x] **测试号绑定**`pnpm db:mongo:seed``2d` 幂等绑定 `15880802661`;脚本 `pnpm db:bind-streamer <手机号>` 补绑任意用户
- [x] **开发环境自动过审**`pnpm dev``ensureApprovedStreamerForUser``/api/streamer/me``live``live/cloud` 无档案时自动 `approved`(生产默认关闭)
- [ ] 主播身份:除 `userId` 绑定外,后续可增加「主播子账号 / 代播授权」
### P2 打赏与分润
- [x] 礼物单写入 `liveGifts` + `commissionLedger`platform / guild / streamer 三行)+ `transactions.type=live_gift`
- [ ] 与钱包强一致:当前为「先原子扣款再写流水」,失败回滚余额;**跨集合 Mongo 事务**(副本集)待选型
- [ ] 订单/玩值币对账任务与异常补偿队列
### P3 风控与安全
- [ ] 服务端敏感词与异步审核
- [ ] 推流 URL 限时签名、异常推流告警
### P4 小黄车与分销
- [ ] 订单创建携带 `liveSessionId` + `streamerId`
- [ ] 短链服务与点击埋点
### P5 数据与运营
- [ ] 直播结束报表 API
- [ ] 心愿单/PK 等二期玩法评估
---
## 四、关键 API已实现 / 规划)
| 方法 | 路径 | 说明 |
|:---|:---|:---|
| GET | `/api/live/context/[streamerId]` | 直播上下文:房间态、橱窗、违禁词、公会/分成提示 |
| GET | `/api/live/comments/[streamerId]` | 公屏历史Mongo `liveComments`,按 `streamerId` |
| POST | `/api/live/comments/[streamerId]` | 发送评论 / 进房(需登录 cookie服务端违禁词校验 |
| GET | `/api/streamer/me` | 当前用户绑定的主播档案与房间拉流字段(需已审核) |
| POST | `/api/streamer/live` | `action=start|stop`:开/关播,写 `liveRooms` / `liveSessions` |
| GET | `/api/streamer/live/cloud?vendor=&ttl=` | 返回 `wanzhi_<主播Id>` 流名及对应厂商 RTMP/HLS服务端读 env |
| POST | `/api/live/gift` | `body: { streamerId, giftId }`:扣币、写 `liveGifts`+`commissionLedger`+公屏 `gift` |
| WS | `/ws`(规划) | 弹幕、进房、礼物信令(与系统架构一致) |
---
## 五、归因与分润(实现原则)
1. **粉丝消费绑定主播**:下单或礼物必须携带 `streamerId`;直播场景额外携带 `liveRoomId` / `liveSessionId`(待订单侧接)。
2. **工会**`streamers.guildId``guilds.commissionRate` 参与 ledger 行拆分。
3. **台账**`commissionLedger` 只追加、冲正走反向行,便于审计。
---
## 六、下一步(执行队列)
1. 本地执行 `pnpm db:mongo:seed` 刷新索引与直播种子(需可用 `MONGODB_URI`)。
2. **云直播 PoC**RTMP 推流地址 + 播放域名,将 `pullPlaybackUrl` 改为转码后的 HLS/mp4或评估 **LiveKit** / **腾讯云** 官方 Next 示例。
3. 将公屏从 HTTP 轮询升级为 **WebSocket** 房间频道(仍写入 `liveComments`)。
4. 礼物 POST + ledger 与钱包事务同一 Mongo session或分布式事务方案选型
5. **绑定验收**:确保业务用户 `users._id``streamers.userId`(字符串)一致,否则工作台提示「未找到主播档案」。
---
## 七、本期代码功能拆解(完整度矩阵)
| 域 | 能力 | 状态 | 备注 |
|:---|:---|:---|:---|
| 观众 | 直播广场 `/live` | 已有 | `useStreamers``/api/streamers` |
| 观众 | 直播间 `/live/[id]` | 部分 | **context + HLS/mp4 拉流**;公屏 **Mongo + 轮询**;礼物仍演示 |
| 观众 | 小黄车 / 分享归因 / 违禁词 | 已有 | 见 `lib/api/live.ts` |
| 主播 | **工作台开/关播** `/streamer/studio` | **首版** | Cookie 登录 + `streamers.userId` 绑定 |
| 主播 | OBS 云推流 | 部分 | `GET /api/streamer/live/cloud` + 工作台生成 RTMP/HLSOBS 仍本机安装 |
| 数据 | `liveRooms` / `liveShowcaseItems` / `liveSessions` / `commissionLedger` | 部分 | ledger 尚无写入 |
| 管理 | `/admin/live` 等 | 已有 | 与 `streamers.isLive` 同源列表 |
| 实时 | WebSocket | 未接 | 当前为 **HTTP 5s 轮询**;与 `系统架构.md` 规划一致 |
| 交易 | 礼物 → 分润台账 | **首版** | `/api/live/gift` + 直播间调用;广场页仍演示 `pay()` |
---
## 八、GitHub 开源对标与衍生计划
以下仓库可作为 **技术验证 / 架构抄作业**(非玩值业务代码 fork 必选,按合规与成本选型):
| 项目 | 地址 | 可借鉴点 |
|:---|:---|:---|
| **livekit-examples/livestream** | https://github.com/livekit-examples/livestream | Next 全栈 + **LiveKit**;浏览器或 **OBS WHIP** 推流;低延迟互动房间模型。 |
| **iamkingarsh/nextjs-livestream-webapp** | https://github.com/iamkingarsh/nextjs-livestream-webapp | Next + **OBS**、缩略图、**在线状态**、观众侧路由组织。 |
| **Helga-sov/Next-stream-app** | https://github.com/Helga-sov/Next-stream-app | 流的 **CRUD**、与 OBS 的运维向配置说明。 |
| **OBS.Ninja / VDO.Ninja** | https://github.com/livecoders/obsninja | 远程机位 WebRTC 进 OBS适合「连麦嘉宾」PoC。 |
**衍生开发计划(建议顺序)**
1. **两周内**:克隆 `livestream` 或云厂商官方 Demo跑通「OBS → 云端 → HLS/mp4 → 填入玩值 `pullPlaybackUrl`」最小闭环。
2. **与玩值合并方式**媒体层独立服务LiveKit 自托管或云直播),玩值 Next 只保留 **房间/用户/订单/ledger**;推流密钥由玩值后端签发后与 `liveSessions.sessionKey` 对账。
3. **备选**:不走 WebRTC SFU仅用 **腾讯云直播 / 阿里云直播** RTMP + 播放域名(与 UniLive 调研一致)。
---
## 九、对照内部开发文档的补充(先前未写入本计划的项)
依据 `玩值开发文档/2、架构/系统架构.md` 等:
| 项 | 说明 |
|:---|:---|
| **双栈后端** | 文档建议 Java Spring `/api/v1` + Next 现状并存;需列 **接口归属表**(哪些继续走 Next Mongo哪些必须迁 Spring避免鉴权分裂。 |
| **Redis** | 限流(评论/送礼)、直播间在线人数、热点榜;键名与 TTL 规范未定。 |
| **OSS + CDN** | 封面、回放、短视频;与 `pullPlaybackUrl` 域名白名单联动。 |
| **WebSocket `/ws`** | 与 HTTP 会话一致性cookie / token房间 id 与 `liveRooms._id` 映射。 |
| **合规** | 未成年人保护、打赏限额、涉赌涉诈话术;需产品与法务二次清单。 |
| **数据库.md** | 增补 `liveShowcaseItems` / `liveSessions` / `commissionLedger` 字段说明,与代码一致。 |
---
## 十、环境变量(可选)
| 变量 | 用途 |
|:---|:---|
| `NEXT_PUBLIC_LIVE_RTMP_TEMPLATE` | 主播工作台展示的 RTMP 前缀说明(**不含密钥** |
| `TENCENT_LIVE_PUSH_DOMAIN` / `TENCENT_LIVE_PLAY_DOMAIN` | 腾讯云推流/播放 **host**(无协议头) |
| `TENCENT_LIVE_PUSH_AUTH_KEY` / `TENCENT_LIVE_PLAY_AUTH_KEY` | 腾讯云鉴权 KEY可相同与控制台「推流/播放防盗链」一致 |
| `TENCENT_LIVE_APP_NAME` | 默认 `live` |
| `ALIYUN_LIVE_PUSH_DOMAIN` / `ALIYUN_LIVE_PLAY_DOMAIN` | 阿里云推流/播放 host |
| `ALIYUN_LIVE_PUSH_PRIVATE_KEY` / `ALIYUN_LIVE_PLAY_PRIVATE_KEY` | URL 鉴权主 KEY可相同 |
| `ALIYUN_LIVE_APP_NAME` | 默认 `live` |
| `QINIU_LIVE_PUSH_DOMAIN` / `QINIU_LIVE_HUB` | 七牛推流域名、Hub |
| `QINIU_LIVE_ACCESS_KEY` / `QINIU_LIVE_SECRET_KEY` | 七牛 AK/SK |
| `QINIU_LIVE_PLAY_DOMAIN` | 可选,用于拼 HLS |
| `LIVE_GENERIC_RTMP_BASE` | 无鉴权 RTMP 前缀,如 `rtmp://127.0.0.1/live/` |
完整示例见仓库根目录 **`.env.example`**。
### 本地开发与端口
- **源码调试**`pnpm dev` 默认 **`http://localhost:3088`**(避免占用 **3000** 的其它 Node 项目如 Remotion以及本机 **3001** 上常见 Docker 栈冲突)。
- **Docker 本仓库镜像**:仍以编排文件为准(常见为 **3001**)。
- **逐步按钮与页面位置**:见 **[直播开播操作教程_20260413.md](./直播开播操作教程_20260413.md)**。
---
**文档维护**:每完成一大项更新「一、总体进度」表中的完成度与「三」中勾选框。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,162 @@
# 玩值电竞 App · 直播开播操作教程
> 版本2026-04-13
> 适用:**本地源码 `pnpm dev`** 与 **Docker 部署** 两套入口(端口不同,勿混用)。
---
## 〇、先解决「打不开 /live」或 404
| 现象 | 原因 | 处理 |
|:---|:---|:---|
| 访问 `http://localhost:3000/live` 出现 JSON 404且含 `remotion-webpack-bundle` | **3000 端口不是你的玩值 Next 应用**(常为其它 Node 项目,如 Remotion | 改用下文「正确地址」 |
| `pnpm dev` 起不来、端口占用 | 本机 **3001** 常被 Docker 其它栈占用 | 本项目 **`pnpm dev` 已固定为 3088 端口** |
**正确地址(本地源码调试)**
- 首页:`http://localhost:3088/`
- 直播广场:`http://localhost:3088/live`
- 某主播间:`http://localhost:3088/live/<主播MongoId>`Mongo `streamers._id` 的 24 位十六进制字符串)
**Docker 运行本仓库镜像时**(与 `docker-compose` 一致):一般为 **`http://localhost:3001`**,以你机器上 `docker-compose.yml` 映射为准。
页顶 **琥珀色横条** 为开发环境提示,可再次确认当前应使用的端口。
---
## 一、开播前准备(一次性)
1. **MongoDB** 已启动,且项目根目录 `.env.local``MONGODB_URI` 可连上;库名默认 `wanzhi_esports`
2. 终端在项目根执行(可选,用于索引与直播种子):`pnpm db:mongo:seed`
3. **测试账号登录**:手机号 **`15880802661`**,密码 **`key123456`**(与种子脚本默认一致)。若曾用旧密码 `k123456` 或改乱密码,可在项目根执行:
`pnpm db:set-password 15880802661 key123456`
或任意:`pnpm db:set-password <手机号> <新密码>`
4. **主播账号(已审核绑定)**Mongo 中需存在 `streamers` 文档:`status: "approved"`,且 **`streamers.userId`(字符串)= 当前登录用户的 `users._id`**。
- **本地 `pnpm dev`NODE_ENV=development**:访问工作台相关接口时,若已登录且 `users` 中存在该账号,会 **自动创建或升为已审核**`lib/streamer/ensure-approved-streamer.ts`),无需手工改库即可进入 `/streamer/studio`
- **测试号 `15880802661`**:种子 `2d``pnpm db:bind-streamer 15880802661` 仍推荐保留,便于与生产行为一致。
- **生产 / `next start`**:不会自动过审;需管理端审核或设置 `STREAMER_DEV_AUTOPROVISION=1`(仅内网验收用)。
5. **(可选)腾讯云推拉流**:在部署环境或 `.env.local` 配置 `TENCENT_LIVE_*` 等变量,详见同目录 `app-直播-开发计划_20260413.md` 第十章与仓库根目录 `.env.example`
---
## 二、启动本应用(源码模式)
1. 打开终端,`cd` 到本仓库根目录(含 `package.json` 的目录)。
2. 执行:`pnpm dev`
3. 等待终端出现 **Ready**,浏览器打开:**`http://localhost:3088`****不要用 3000**)。
---
## 三、观众侧:进入直播广场与单个直播间
### 步骤 1进入直播广场
| 项目 | 说明 |
|:---|:---|
| **地址栏** | 输入并打开 `http://localhost:3088/live` |
| **或从首页** | 打开 `http://localhost:3088/` → 页面内找到 **「直播」** 相关入口链接(若有)→ 进入 `/live` |
页面为**竖滑全屏直播广场**(抖音式),可上下滑切换不同主播卡片。
### 步骤 2进入某一间直播间
| 操作 | 位置 |
|:---|:---|
| 在直播广场上 **点击当前卡片****进入直播间** 类按钮 | 画面中央主区域 / 底部操作区(以当前 UI 为准) |
| 或直接改地址 | `http://localhost:3088/live/<主播Id>` |
**主播 Id 获取**管理端主播列表、Mongo `streamers._id`、或主播工作台里「去我的直播间」按钮跳转的 URL 中均带有该 Id。
---
## 四、主播侧:工作台开播(核心步骤)
### 步骤 1登录
| 步骤 | 页面与位置 |
|:---|:---|
| 打开 | `http://localhost:3088/login` |
| 输入手机号、密码 | 登录表单 |
| 点击登录按钮 | 提交后应进入已登录态(依赖 cookie `wanzhi_user_id` |
测试账号以你本地 `seed-mongo` / 管理端配置为准(如文档中的测试手机号)。
### 步骤 2进入「我的」并打开主播工作台
| 步骤 | 路径 / 位置 |
|:---|:---|
| 打开底部或导航 **「我的」** | 路由一般为 **`/profile`** → 地址 `http://localhost:3088/profile` |
| 找到 **「主播工作台」** 按钮 / 入口 | 在「我的」页面列表中,点击进入 **`/streamer/studio`** |
若提示未绑定主播档案,回到本文「一、开播前准备」第 3 条核对 Mongo。
### 步骤 3生成云厂商推流与播放地址可选推荐腾讯云
页面:**`http://localhost:3088/streamer/studio`**(主播工作台)
| 顺序 | 你要点的 / 填的 | 在页面上的区域 |
|:---:|:---|:---|
| 1 | **厂商** 下拉框 | 「云厂商推流 / 播放」卡片内,默认可选「腾讯云」 |
| 2 | **地址有效秒数** | 同一卡片内数字框,默认如 7200 |
| 3 | 快捷链接 **「腾讯云控制台」**(可选) | 新开标签页,到控制台配置域名与鉴权 KEY |
| 4 | 按钮 **「生成推流 / 播放地址」** | 点击后下方出现灰色结果卡片 |
| 5 | 若已配置 env结果卡片内 **「HLS 播放」** 一行 | 点右侧 **「填入拉流」** → 自动写入上方 **「拉流预览 URL」** 输入框 |
| 6 | **「RTMP 推流」** 一行 | 点 **「复制」**,打开 **OBS** → 设置 → 推流 → 服务选「自定义」→ 将地址按 OBS 要求拆成「服务器」与「串流密钥」(或整段粘贴,视 OBS 版本而定) |
**OBS 不在本仓库内**,需本机已安装 [OBS Studio](https://obsproject.com/)。
### 步骤 4填写直播间标题可选
| 控件 | 位置 |
|:---|:---|
| **「直播间标题」** 输入框 | 工作台页面上方,云厂商卡片之上 |
### 步骤 5开始直播平台标记为「直播中」
| 控件 | 位置 |
|:---|:---|
| 绿色主按钮 **「开始直播」** | 标题、拉流 URL、云厂商卡片 **下方** 一排按钮的 **左侧** |
成功后:页头状态变为 **「直播中」**红点脉冲Mongo 中 `streamers.isLive``liveRooms``liveSessions` 会更新(与接口实现一致)。
### 步骤 6用 OBS 向云端推流
在 OBS 点击 **「开始串流」**;腾讯云控制台或播放地址应能看到流已上行;观众端 **拉流预览 URL** 已填时,打开直播间应能看到 HLS/mp4 画面。
### 步骤 7自检观众页
| 操作 | 位置 |
|:---|:---|
| 工作台卡片 **「去我的直播间」** | 点 **「打开」** 按钮 → 跳转 `/live/<你的streamerId>` |
| 或 **「复制观众端链接」** | 复制后到浏览器新标签粘贴打开 |
### 步骤 8关播
| 控件 | 位置 |
|:---|:---|
| 红色按钮 **「结束直播」** | 与「开始直播」同一行 **右侧** |
---
## 五、页面对照速查表
| 目的 | URL 路径 | 关键按钮 / 区域 |
|:---|:---|:---|
| 直播广场 | `/live` | 滑动切换主播、进入单房间 |
| 单主播直播间 | `/live/[id]` | 底部输入框发评论、礼物、小黄车、分享 |
| 我的 | `/profile` | **主播工作台** 入口 |
| 主播工作台 | `/streamer/studio` | **生成推流/播放**、**填入拉流**、**开始直播**、**结束直播**、**去我的直播间** |
| 登录 | `/login` | 登录按钮 |
---
## 六、仍无法播放时检查清单
1. 地址是否为 **`http://localhost:3088`**(源码 dev而不是 3000。
2. 拉流地址是否为 **https 的 `.m3u8` 或 mp4**,且未过期(腾讯云 `txTime` 过期需重新点「生成」)。
3. OBS 是否已 **开始串流**,且 StreamName 与控制台/workbench 生成的一致(工作台文案:**`wanzhi_<主播MongoId>`**)。
4. 浏览器控制台是否有 **混合内容/CORS** 报错(少见,多为 https 播放域名证书问题)。
---
**文档维护**:若 `package.json``pnpm dev` 端口再变更,请同步修改页顶 `DevPortBanner` 与本教程中的端口号。

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title>README</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
<link rel="stylesheet" href="_html_assets/karuo_report.css" />
</head>
<body>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#调研报告目录" id="toc-调研报告目录">调研报告目录</a>
<ul>
<li><a href="#命名" id="toc-命名">命名</a></li>
<li><a href="#当前索引" id="toc-当前索引">当前索引</a></li>
</ul></li>
</ul>
</nav>
<h1 id="调研报告目录">调研报告目录</h1>
<p>本目录存放 <strong>市场调研类 Markdown</strong> 及配套 <strong>SVG /
截图 / Obsidian Canvas</strong>JSON Canvas 1.0)。同名
<strong>HTML</strong><code>pandoc</code> 批量导出(展厅风样式见
<code>_html_assets/karuo_report.css</code>),可直接用浏览器打开。</p>
<h2 id="命名">命名</h2>
<ul>
<li><code>市场调研_&lt;主题简写&gt;_&lt;YYYYMMDD&gt;.md</code></li>
<li><code>市场调研_&lt;主题简写&gt;_&lt;YYYYMMDD&gt;.canvas</code>(与
Markdown <strong>同名</strong>,用 Obsidian
打开库根为本仓库根目录)</li>
</ul>
<h2 id="当前索引">当前索引</h2>
<table>
<colgroup>
<col style="width: 15%" />
<col style="width: 26%" />
<col style="width: 36%" />
<col style="width: 21%" />
</colgroup>
<thead>
<tr>
<th>主题</th>
<th>Markdown</th>
<th>HTML导出</th>
<th>Canvas</th>
</tr>
</thead>
<tbody>
<tr>
<td>易乐游(网吧网娱平台)</td>
<td><a
href="./市场调研_易乐游网吧网娱平台_20260413.md">市场调研_易乐游网吧网娱平台_20260413.md</a></td>
<td><a href="./市场调研_易乐游网吧网娱平台_20260413.html">同名
.html</a></td>
<td></td>
</tr>
<tr>
<td>嘟嘟牛(网吧计费与管理)</td>
<td><a
href="./市场调研_嘟嘟牛网吧工具_20260413.md">市场调研_嘟嘟牛网吧工具_20260413.md</a></td>
<td><a href="./市场调研_嘟嘟牛网吧工具_20260413.html">同名
.html</a></td>
<td><a href="./市场调研_嘟嘟牛网吧工具_20260413.canvas">同名
.canvas</a></td>
</tr>
<tr>
<td>计费系统竞品对比与玩值电竞融合度</td>
<td><a
href="./市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.md">市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.md</a></td>
<td><a
href="./市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.html">同名
.html</a></td>
<td></td>
</tr>
<tr>
<td>WFGAME 游戏聚合平台</td>
<td><a
href="./市场调研_WFGAME游戏平台_20260413.md">市场调研_WFGAME游戏平台_20260413.md</a></td>
<td><a href="./市场调研_WFGAME游戏平台_20260413.html">同名
.html</a></td>
<td><a href="./市场调研_WFGAME游戏平台_20260413.canvas">同名
.canvas</a></td>
</tr>
</tbody>
</table>
<p>流程真源:<strong>卡若AI</strong><code>SKILL_REGISTRY.md</code>
<strong>W03a</strong> +
<code>02_卡人/水溪_整理归档/项目调研/SKILL.md</code>;画布细节联动
<strong>F30 星图阁</strong>。正文可按
<strong>卡若五行营销</strong>(金→水→木→火→土)归类,真源:<code>03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md</code>
第六节。</p>
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>
</body>
</html>

View File

@@ -0,0 +1,19 @@
# 调研报告目录
本目录存放 **市场调研类 Markdown** 及配套 **SVG / 截图 / Obsidian Canvas**JSON Canvas 1.0)。同名 **HTML**`pandoc` 批量导出(展厅风样式见 `_html_assets/karuo_report.css`),可直接用浏览器打开。
## 命名
- `市场调研_<主题简写>_<YYYYMMDD>.md`
- `市场调研_<主题简写>_<YYYYMMDD>.canvas`(与 Markdown **同名**,用 Obsidian 打开库根为本仓库根目录)
## 当前索引
| 主题 | Markdown | HTML导出 | Canvas |
|------|----------|--------------|--------|
| 易乐游(网吧网娱平台) | [市场调研_易乐游网吧网娱平台_20260413.md](./市场调研_易乐游网吧网娱平台_20260413.md) | [同名 .html](./市场调研_易乐游网吧网娱平台_20260413.html) | — |
| 嘟嘟牛(网吧计费与管理) | [市场调研_嘟嘟牛网吧工具_20260413.md](./市场调研_嘟嘟牛网吧工具_20260413.md) | [同名 .html](./市场调研_嘟嘟牛网吧工具_20260413.html) | [同名 .canvas](./市场调研_嘟嘟牛网吧工具_20260413.canvas) |
| 计费系统竞品对比与玩值电竞融合度 | [市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.md](./市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.md) | [同名 .html](./市场调研_网吧计费系统竞品对比与玩值电竞融合度_20260413.html) | — |
| WFGAME 游戏聚合平台 | [市场调研_WFGAME游戏平台_20260413.md](./市场调研_WFGAME游戏平台_20260413.md) | [同名 .html](./市场调研_WFGAME游戏平台_20260413.html) | [同名 .canvas](./市场调研_WFGAME游戏平台_20260413.canvas) |
流程真源:**卡若AI** → `SKILL_REGISTRY.md` **W03a** + `02_卡人/水溪_整理归档/项目调研/SKILL.md`;画布细节联动 **F30 星图阁**。正文可按 **卡若五行营销**(金→水→木→火→土)归类,真源:`03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md` 第六节。

View File

@@ -0,0 +1 @@
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>

View File

@@ -0,0 +1,108 @@
/* 卡若展厅风 · Markdown 导出 HTML 共用样式 */
:root {
--ink: #1a1a1a;
--ink-soft: #3c3c3c;
--muted: #6b6b6b;
--line: #e8e8e6;
--paper: #fafaf8;
--card: #ffffff;
--accent: #bb0a21;
--accent-soft: rgba(187, 10, 33, 0.08);
--radius: 4px;
--shadow: 0 12px 48px rgba(0, 0, 0, 0.06);
}
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--paper);
color: var(--ink-soft);
line-height: 1.65;
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
-webkit-font-smoothing: antialiased;
}
header#title-block-header { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 2px solid var(--accent); }
header#title-block-header h1.title {
color: var(--ink);
font-size: 1.65rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.35rem;
}
header#title-block-header p.subtitle { margin: 0; color: var(--muted); font-size: 0.9rem; }
nav#TOC {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem 1.1rem;
margin-bottom: 2rem;
}
nav#TOC > h2 { font-size: 0.75rem; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent); margin: 0 0 0.6rem; }
nav#TOC ul { list-style: none; padding-left: 0; margin: 0; font-size: 0.88rem; }
nav#TOC ul ul { padding-left: 1rem; margin-top: 0.25rem; }
nav#TOC a { color: var(--accent); text-decoration: none; border-bottom: 1px solid rgba(187,10,33,0.25); }
nav#TOC a:hover { border-bottom-color: var(--accent); }
h1:not(.title) {
color: var(--ink);
font-size: 1.35rem;
margin-top: 2rem;
padding-left: 12px;
border-left: 3px solid var(--accent);
}
h2 {
color: var(--ink);
font-size: 1.1rem;
margin-top: 1.75rem;
padding-left: 10px;
border-left: 3px solid var(--accent);
}
h3 { color: var(--ink-soft); font-size: 0.98rem; margin-top: 1.25rem; }
p { margin: 0.65rem 0; }
blockquote {
margin: 1rem 0;
padding: 0.65rem 1rem;
border-left: 3px solid var(--accent);
background: var(--accent-soft);
color: var(--ink-soft);
font-size: 0.92rem;
}
hr { border: none; border-top: 1px solid var(--line); margin: 2rem 0; }
a { color: var(--accent); }
code { font-size: 0.86em; background: var(--accent-soft); padding: 0.12em 0.4em; border-radius: 3px; color: #8b0618; }
pre {
background: #1e1e1e;
color: #e8e8e6;
padding: 1rem 1.1rem;
border-radius: var(--radius);
overflow-x: auto;
font-size: 0.82rem;
line-height: 1.45;
}
pre code { background: none; color: inherit; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
margin: 1rem 0;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
th, td { padding: 0.55rem 0.65rem; text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; }
th { background: #f3f3f1; color: var(--ink); font-weight: 600; border-bottom: 2px solid var(--accent); }
tr:nth-child(even) td { background: #fcfcfb; }
img { max-width: 100%; height: auto; border-radius: var(--radius); border: 1px solid var(--line); }
ul, ol { padding-left: 1.25em; }
li { margin: 0.35rem 0; }
footer.report-foot {
margin-top: 3rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line);
font-size: 0.78rem;
color: var(--muted);
text-align: center;
}

View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 400" width="680" height="400">
<defs>
<style type="text/css"><![CDATA[
.t { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; fill: #1c2130; }
.sub { font-size: 12px; fill: #5a6270; }
.h { font-size: 15px; font-weight: 600; }
.cap { font-size: 11px; fill: #8b92a1; }
.card { fill: #ffffff; stroke: #dde2eb; stroke-width: 1; }
.arr { stroke: #9aa3b2; stroke-width: 2; fill: none; marker-end: url(#m); }
]]></style>
<marker id="m" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#9aa3b2"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="#f7f8fa"/>
<text class="t h" x="340" y="36" text-anchor="middle">Fig.1 Stack (internet cafe context)</text>
<text class="t cap" x="340" y="56" text-anchor="middle">Public sources only - not vendor architecture</text>
<g transform="translate(80,88)">
<rect class="card" x="0" y="0" width="520" height="76" rx="10"/>
<circle cx="36" cy="38" r="14" fill="#2563eb"/>
<text x="36" y="43" text-anchor="middle" fill="#fff" font-size="12" font-weight="700" font-family="system-ui,sans-serif">1</text>
<text class="t h" x="62" y="32">Store floor</text>
<text class="t sub" x="62" y="52">PC billing / cafe manager: sessions, members, shifts, reports, POS hooks</text>
</g>
<path class="arr" d="M 340 164 L 340 182"/>
<g transform="translate(80,188)">
<rect class="card" x="0" y="0" width="520" height="76" rx="10"/>
<circle cx="36" cy="38" r="14" fill="#0891b2"/>
<text x="36" y="43" text-anchor="middle" fill="#fff" font-size="12" font-weight="700" font-family="system-ui,sans-serif">2</text>
<text class="t h" x="62" y="32">Operations layer</text>
<text class="t sub" x="62" y="52">Manager app; WeChat / merchant and payment rails (dedicated mini program TBD)</text>
</g>
<path class="arr" d="M 340 264 L 340 282"/>
<g transform="translate(80,288)">
<rect class="card" x="0" y="0" width="520" height="76" rx="10"/>
<circle cx="36" cy="38" r="14" fill="#7c3aed"/>
<text x="36" y="43" text-anchor="middle" fill="#fff" font-size="12" font-weight="700" font-family="system-ui,sans-serif">3</text>
<text class="t h" x="62" y="32">Settlement and upsell</text>
<text class="t sub" x="62" y="52">QR, stored value, virtual goods and campaigns (per vendor materials)</text>
</g>
<text class="t cap" x="340" y="392" text-anchor="middle">Dodonew stack - illustrative only</text>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320" width="640" height="320">
<defs>
<style type="text/css"><![CDATA[
.t { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
.title { font-size: 15px; font-weight: 600; fill: #1c2130; }
.note { font-size: 11px; fill: #6b7280; }
.bar1 { fill: rgba(37,99,235,0.12); stroke: #2563eb; stroke-width: 1.2; }
.bar2 { fill: rgba(8,145,178,0.12); stroke: #0891b2; stroke-width: 1.2; }
.bar3 { fill: rgba(124,58,237,0.10); stroke: #7c3aed; stroke-width: 1.2; }
.foot { fill: #f3f4f6; stroke: #e5e7eb; }
]]></style>
</defs>
<rect width="100%" height="100%" fill="#fafafa"/>
<text class="t title" x="320" y="34" text-anchor="middle">Fig.2 Market tiers (billing / cafe stack)</text>
<text class="t note" x="320" y="54" text-anchor="middle">Share stats need year and segment; tiers are conceptual</text>
<rect class="bar1" x="72" y="84" width="496" height="52" rx="8"/>
<text class="t title" x="92" y="116" font-size="13" fill="#1e40af">Tier A</text>
<text class="t note" x="160" y="116" font-size="13" fill="#374151">Shunwang family: Wanxiang, Pubwin, ...</text>
<rect class="bar2" x="112" y="152" width="416" height="52" rx="8"/>
<text class="t title" x="132" y="184" font-size="13" fill="#0e7490">Tier B</text>
<text class="t note" x="210" y="184" font-size="13" fill="#374151">Mid: Long manager, Hengxin, Dodoniu, ...</text>
<rect class="bar3" x="168" y="220" width="304" height="52" rx="8"/>
<text class="t title" x="188" y="252" font-size="13" fill="#5b21b6">Tier C</text>
<text class="t note" x="270" y="252" font-size="13" fill="#374151">Regional / value offers</text>
<rect class="foot" x="72" y="288" width="496" height="22" rx="4"/>
<text class="t note" x="88" y="303" font-size="10" fill="#6b7280">Game update (WAN maintenance) is often a separate product line</text>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -0,0 +1,21 @@
{
"nodes":[
{"id":"7a2f9c1e0d4b8a36","type":"text","text":"# WFGAME · 游戏聚合平台\n\n**市场调研** · 2026-04-13\n\n---\n\n网吧/电竞酒店桌面 **内容+工具+社交** 聚合入口;公开叙事侧重 **资源规模、更新体验、存档漫游**。","x":-640,"y":-420,"width":520,"height":240,"color":"1"},
{"id":"8b3e0d2f1e5c9b47","type":"text","text":"## 一、元信息\n\n对象WFGame / WFGAME · 场景B 端桌面\n\n来源官网 + 行业公开稿 + 本仓界面截图","x":40,"y":-420,"width":420,"height":110,"color":"4"},
{"id":"9c4f1e3a2f6d0c58","type":"text","text":"## 二、结论摘要\n\n聚合启动器 · 运营位密集\n\n对玩值可借鉴信息架构租号/账号类须单独合规","x":40,"y":-290,"width":420,"height":120,"color":"5"},
{"id":"0d5a2f4b3a7e1d69","type":"text","text":"## 三~五、形态 · 客户 · 竞争\n\n游戏库×平台筛选 + 本地/网页 + 外设/更新\n\n竞品网维更新、官方启动器、各地菜单壳","x":40,"y":-150,"width":420,"height":130,"color":"3"},
{"id":"1e6b3a5c4b8f2e7a","type":"text","text":"## 六~九、商业 · 合规 · 验证 · 修订\n\n收入授权 / 带宽与资源 / 导流分润 / 打包\n\n合规授权链、未成年人、租号风险\n\n下一步白皮书、门店访谈、竞品矩阵","x":40,"y":0,"width":420,"height":135,"color":"2"},
{"id":"2f7c4b6d5c9a3f8b","type":"file","file":"玩值开发文档/1、需求/调研/市场调研_WFGAME游戏平台_20260413.md","x":-640,"y":-150,"width":520,"height":90},
{"id":"3a8d5c7e6d0b4a9c","type":"file","file":"玩值开发文档/1、需求/调研/images/wfgame-ui-overview-20260413.png","x":-640,"y":-40,"width":117,"height":88},
{"id":"4b9e6d8f7e1c5b0d","type":"link","url":"https://www.wf-game.cn/","x":500,"y":50,"width":200,"height":64}
],
"edges":[
{"id":"5c0f7e9a8f2d6c1e","fromNode":"7a2f9c1e0d4b8a36","fromSide":"right","toNode":"8b3e0d2f1e5c9b47","toSide":"left"},
{"id":"6d1a8f0b9a3e7d2f","fromNode":"7a2f9c1e0d4b8a36","fromSide":"right","toNode":"9c4f1e3a2f6d0c58","toSide":"left"},
{"id":"7e2b9a1c0b4f8e3a","fromNode":"7a2f9c1e0d4b8a36","fromSide":"right","toNode":"0d5a2f4b3a7e1d69","toSide":"left"},
{"id":"8f3c0b2d1c5a9f4b","fromNode":"7a2f9c1e0d4b8a36","fromSide":"right","toNode":"1e6b3a5c4b8f2e7a","toSide":"left"},
{"id":"9a4d1c3e2d6b0a5c","fromNode":"7a2f9c1e0d4b8a36","fromSide":"bottom","toNode":"2f7c4b6d5c9a3f8b","toSide":"top"},
{"id":"0b5e2d4f3e7c1b6d","fromNode":"2f7c4b6d5c9a3f8b","fromSide":"bottom","toNode":"3a8d5c7e6d0b4a9c","toSide":"top"},
{"id":"1c6f3e5a4f8d2c7e","fromNode":"1e6b3a5c4b8f2e7a","fromSide":"right","toNode":"4b9e6d8f7e1c5b0d","toSide":"left","label":"官网"}
]
}

View File

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="dcterms.date" content="2026-04-13" />
<title>市场调研_WFGAME游戏平台_20260413</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
<link rel="stylesheet" href="_html_assets/karuo_report.css" />
</head>
<body>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#市场调研-wfgame-游戏聚合平台"
id="toc-市场调研-wfgame-游戏聚合平台">市场调研 · WFGAME 游戏聚合平台</a>
<ul>
<li><a href="#一调研元信息"
id="toc-一调研元信息">一、调研元信息</a></li>
<li><a href="#二结论摘要executive"
id="toc-二结论摘要executive">二、结论摘要Executive</a></li>
<li><a href="#三主体与产品形态"
id="toc-三主体与产品形态">三、主体与产品形态</a>
<ul>
<li><a href="#公开功能归纳与界面相互印证"
id="toc-公开功能归纳与界面相互印证">3.1
公开功能归纳(与界面相互印证)</a></li>
<li><a href="#界面参考门店实拍" id="toc-界面参考门店实拍">3.2
界面参考(门店实拍)</a></li>
<li><a href="#价值链条定性" id="toc-价值链条定性">3.3
价值链条(定性)</a></li>
</ul></li>
<li><a href="#四客户痛点与采购关注点"
id="toc-四客户痛点与采购关注点">四、客户、痛点与采购关注点</a></li>
<li><a href="#五竞争格局" id="toc-五竞争格局">五、竞争格局</a></li>
<li><a href="#六商业模式" id="toc-六商业模式">六、商业模式</a></li>
<li><a href="#七合规与舆情"
id="toc-七合规与舆情">七、合规与舆情</a></li>
<li><a href="#八下一步验证清单"
id="toc-八下一步验证清单">八、下一步验证清单</a></li>
<li><a href="#九修订记录" id="toc-九修订记录">九、修订记录</a></li>
</ul></li>
</ul>
</nav>
<h1 id="市场调研-wfgame-游戏聚合平台">市场调研 · WFGAME
游戏聚合平台</h1>
<hr />
<h2 id="一调研元信息">一、调研元信息</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>维度</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>调研对象</td>
<td><strong>WFGame / WFGAME</strong>(公开站点以 <a
href="https://www.wf-game.cn/">wf-game.cn</a> 为代表;客户端界面品牌多为
<strong>WFGAME</strong></td>
</tr>
<tr>
<td>场景边界</td>
<td>网吧、电竞馆、电竞酒店等 <strong>B 端桌面环境</strong> 下的
<strong>游戏资源聚合、分发更新、存档与外设体验</strong>;不含商务报价、不含未公开合同条款</td>
</tr>
<tr>
<td>信息来源</td>
<td>官网与公开宣传页、行业媒体/社区转载、门店场景实拍界面(本仓
<code>images/wfgame-ui-overview-20260413.png</code></td>
</tr>
<tr>
<td>局限</td>
<td>未做厂商访谈;<strong></strong>核验各子模块(租号/买号、第三方跳转等)的
<strong>实时合规状态</strong>;份额类数字以 <strong>官方自述 +
第三方文章</strong> 为准,交叉口径不混用</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="二结论摘要executive">二、结论摘要Executive</h2>
<ol type="1">
<li><strong>定位</strong>:面向线下娱乐上网场景的 <strong>Windows
桌面聚合入口</strong>,集
<strong>多平台游戏库、本地/网页游戏、更新分发、社交与工具快捷区</strong>
于一体,与「纯网维无盘」或「纯计费」产品
<strong>相邻但侧重不同</strong>——更偏 <strong>C
端体验与内容运营面</strong><br />
</li>
<li><strong>差异化叙事(公开材料)</strong>:强调
<strong>海量资源、高速更新/下载节点、存档与个人习惯漫游、外设与着色器等体验优化、门店侧监控与数据看板</strong>(如「信使」类公众号触达)。<br />
</li>
<li><strong>对玩值电竞的启发</strong>:若做
<strong>门店/主播侧内容聚合</strong>,可对照其 <strong>信息架构(游戏库
× 平台筛选 × 福利/租号等变现位)</strong><strong>「工具 + 内容 +
社交」三栏</strong> 布局,但须在
<strong>直播合规、账号安全、未成年人与平台 ToS</strong>
上单独设计,不可照搬「租号/买号」等高风险模块。</li>
</ol>
<hr />
<h2 id="三主体与产品形态">三、主体与产品形态</h2>
<h3 id="公开功能归纳与界面相互印证">3.1
公开功能归纳(与界面相互印证)</h3>
<table>
<colgroup>
<col style="width: 25%" />
<col style="width: 75%" />
</colgroup>
<thead>
<tr>
<th>模块</th>
<th>能力要点(归纳)</th>
</tr>
</thead>
<tbody>
<tr>
<td>游戏库</td>
<td>多平台 tab如 Steam、Epic、Battle.net、EA、国产平台等+
热门/运营位 Banner + 图标矩阵分发</td>
</tr>
<tr>
<td>本地与网页</td>
<td>「本地游戏」「网页游戏」等分区,承接门店既有资产与轻量内容</td>
</tr>
<tr>
<td>外设与更新</td>
<td>「外设管理」「游戏更新」等运维向入口,贴近网吧 IT 工作流</td>
</tr>
<tr>
<td>顶部运营位</td>
<td>「福利广场」「租号/买号」、单游戏大 IP
专区等——<strong>强运营与变现导向</strong></td>
</tr>
<tr>
<td>侧栏</td>
<td>「社交娱乐」(短视频、通讯、直播、视频平台等)与「游戏工具」(加速器、租号等)——<strong>流量分发枢纽</strong></td>
</tr>
</tbody>
</table>
<h3 id="界面参考门店实拍">3.2 界面参考(门店实拍)</h3>
<figure>
<img src="./images/wfgame-ui-overview-20260413.png"
alt="WFGAME 客户端主界面概览" />
<figcaption aria-hidden="true">WFGAME 客户端主界面概览</figcaption>
</figure>
<h3 id="价值链条定性">3.3 价值链条(定性)</h3>
<pre class="mermaid"><code>flowchart LR
subgraph 供给
A[游戏与平台安装包/更新源]
end
subgraph 平台
B[WFGAME 客户端 / 管理端]
end
subgraph 门店
C[网吧 电竞酒店 终端]
end
subgraph 用户
D[玩家开玩与付费行为]
end
A --&gt; B
B --&gt; C
C --&gt; D
B -.-&gt;|运营位 工具 社交| D</code></pre>
<hr />
<h2 id="四客户痛点与采购关注点">四、客户、痛点与采购关注点</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>客户类型</th>
<th>典型诉求</th>
</tr>
</thead>
<tbody>
<tr>
<td>单店/小连锁网吧</td>
<td>省人力更新游戏、减少投诉(启动慢、缺包)、桌面整洁可管理</td>
</tr>
<tr>
<td>电竞酒店</td>
<td>与客房场景结合的个人存档、外设一致性、内容合规展示</td>
</tr>
<tr>
<td>网维/集成商</td>
<td>与无盘/计费/安全方案的
<strong>兼容性</strong>、批量部署与回滚、日志与告警</td>
</tr>
</tbody>
</table>
<p><strong>采购关注点</strong>:授权与责任边界(尤其
<strong>第三方游戏平台账号</strong>)、更新源
<strong>版权与稳定性</strong>、是否捆绑广告/插件、<strong>数据驻留与隐私</strong>、售后响应
SLA。</p>
<hr />
<h2 id="五竞争格局">五、竞争格局</h2>
<p><strong>同生态位产品类型</strong>(非穷尽):</p>
<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th>类型</th>
<th>说明</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr>
<td>网维 + 游戏更新</td>
<td>易乐游、云更新、网维大师等</td>
<td><strong>镜像与更新管道</strong>,与聚合桌面可组合或替代</td>
</tr>
<tr>
<td>平台官方启动器</td>
<td>Steam、WeGame、Epic 等</td>
<td><strong>账号与商店</strong> 绑定;聚合壳常作快捷跳转</td>
</tr>
<tr>
<td>游戏菜单 / 平台壳</td>
<td>各地菜单方案、WFGame 等</td>
<td><strong>展示、运营与一站式入口</strong></td>
</tr>
</tbody>
</table>
<p><strong>格局判断(定性,无统一市占口径)</strong></p>
<ul>
<li>计费与网维赛道
<strong>头部集中</strong><strong>游戏聚合桌面</strong> 多为
<strong>区域代理 + 方案商生态</strong>,品牌分散。<br />
</li>
<li>WFGAME 在公开传播中强调
<strong>全国节点与资源规模</strong>,具体覆盖率需以
<strong>招标材料或样本门店</strong> 核实。<br />
</li>
<li><strong>禁止</strong>将不同统计口径(装机台数、活跃
UV、流水混成「市场份额 %」。</li>
</ul>
<hr />
<h2 id="六商业模式">六、商业模式</h2>
<p>公开信息可归纳为(比例以商务为准):</p>
<pre class="mermaid"><code>flowchart TB
M1[软件或方案授权/服务费]
M2[CDN/更新与增值通道费]
M3[运营位与导流分润]
M4[与网维/硬件/支付打包]
M1 --&gt; M4
M2 --&gt; M4
M3 --&gt; M4</code></pre>
<ul>
<li><strong>B 端</strong>:按终端/门店/模块订阅或一次性方案费。<br />
</li>
<li><strong>资源与带宽</strong>:更新加速、热门资源位可能单独计费。<br />
</li>
<li><strong>流量变现</strong>:福利、租号、工具导流等(合规前提见下节)。</li>
</ul>
<hr />
<h2 id="七合规与舆情">七、合规与舆情</h2>
<ol type="1">
<li><strong>正规产品与灰黑产</strong>:任何
<strong>破解版游戏、绕过平台付费、非法租售账号</strong>
均可能触犯法律与平台协议;本报告 <strong>不展开</strong>
灰产操作细节。采购与联运须核验 <strong>授权链</strong><br />
</li>
<li><strong>未成年人保护</strong>:线下场所须落实
<strong>实名与时长管理</strong>;聚合壳内的外链与游戏
<strong>适龄展示</strong> 需单独审计。<br />
</li>
<li><strong>「租号/买号」模块</strong>:涉及 Steam 等平台的
<strong>用户协议与地区政策</strong>,存在 <strong>封号与纠纷</strong>
风险;产品若保留该入口,须在 <strong>法务与运营</strong>
层单独评估,<strong>不宜</strong>与玩值自有直播产品无差别混用。<br />
</li>
<li><strong>公众号/信使类能力</strong>:须核对
<strong>主体、类目、隐私政策</strong>
是否与官方一致,避免误绑山寨服务号。</li>
</ol>
<hr />
<h2 id="八下一步验证清单">八、下一步验证清单</h2>
<ol type="1">
<li>获取
<strong>官方产品白皮书或功能清单(含管理端截图)</strong>,与客户端菜单
<strong>逐项对齐</strong><br />
</li>
<li>样本 <strong>510 家门店</strong>
访谈:实际使用模块排序、与计费/无盘品牌组合、对「租号/福利」的真实点击率与纠纷。<br />
</li>
<li><strong>12 家</strong> 网维更新方案做
<strong>功能矩阵对比</strong>(更新延迟、带宽成本、异常回滚)。<br />
</li>
<li>法务粗评:<strong>聚合跳转、存档漫游、运营 Banner</strong>
的知识产权与数据合规路径。<br />
</li>
<li>若玩值产品借鉴 UX输出 <strong>低保真线框</strong> +
<strong>红线清单</strong>(不做账号交易、不做未授权分发等)。</li>
</ol>
<hr />
<h2 id="九修订记录">九、修订记录</h2>
<table>
<thead>
<tr>
<th>日期</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>2026-04-13</td>
<td>v0.1</td>
<td>初稿:公开信息 + 界面素材整理</td>
</tr>
</tbody>
</table>
<hr />
<p><em>本报告基于公开信息与界面归纳,不构成投资或采购建议。</em></p>
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>
</body>
</html>

View File

@@ -0,0 +1,150 @@
---
type: 市场调研
topic: WFGAME 游戏聚合与网吧资源管理平台
date: 2026-04-13
project: 玩值电竞App
canvas: ./市场调研_WFGAME游戏平台_20260413.canvas
---
# 市场调研 · WFGAME 游戏聚合平台
---
## 一、调研元信息
| 维度 | 内容 |
|------|------|
| 调研对象 | **WFGame / WFGAME**(公开站点以 [wf-game.cn](https://www.wf-game.cn/) 为代表;客户端界面品牌多为 **WFGAME** |
| 场景边界 | 网吧、电竞馆、电竞酒店等 **B 端桌面环境** 下的 **游戏资源聚合、分发更新、存档与外设体验**;不含商务报价、不含未公开合同条款 |
| 信息来源 | 官网与公开宣传页、行业媒体/社区转载、门店场景实拍界面(本仓 `images/wfgame-ui-overview-20260413.png` |
| 局限 | 未做厂商访谈;**未**核验各子模块(租号/买号、第三方跳转等)的 **实时合规状态**;份额类数字以 **官方自述 + 第三方文章** 为准,交叉口径不混用 |
---
## 二、结论摘要Executive
1. **定位**:面向线下娱乐上网场景的 **Windows 桌面聚合入口**,集 **多平台游戏库、本地/网页游戏、更新分发、社交与工具快捷区** 于一体,与「纯网维无盘」或「纯计费」产品 **相邻但侧重不同**——更偏 **C 端体验与内容运营面**
2. **差异化叙事(公开材料)**:强调 **海量资源、高速更新/下载节点、存档与个人习惯漫游、外设与着色器等体验优化、门店侧监控与数据看板**(如「信使」类公众号触达)。
3. **对玩值电竞的启发**:若做 **门店/主播侧内容聚合**,可对照其 **信息架构(游戏库 × 平台筛选 × 福利/租号等变现位)****「工具 + 内容 + 社交」三栏** 布局,但须在 **直播合规、账号安全、未成年人与平台 ToS** 上单独设计,不可照搬「租号/买号」等高风险模块。
---
## 三、主体与产品形态
### 3.1 公开功能归纳(与界面相互印证)
| 模块 | 能力要点(归纳) |
|------|------------------|
| 游戏库 | 多平台 tab如 Steam、Epic、Battle.net、EA、国产平台等+ 热门/运营位 Banner + 图标矩阵分发 |
| 本地与网页 | 「本地游戏」「网页游戏」等分区,承接门店既有资产与轻量内容 |
| 外设与更新 | 「外设管理」「游戏更新」等运维向入口,贴近网吧 IT 工作流 |
| 顶部运营位 | 「福利广场」「租号/买号」、单游戏大 IP 专区等——**强运营与变现导向** |
| 侧栏 | 「社交娱乐」(短视频、通讯、直播、视频平台等)与「游戏工具」(加速器、租号等)——**流量分发枢纽** |
### 3.2 界面参考(门店实拍)
![WFGAME 客户端主界面概览](./images/wfgame-ui-overview-20260413.png)
### 3.3 价值链条(定性)
```mermaid
flowchart LR
subgraph 供给
A[游戏与平台安装包/更新源]
end
subgraph 平台
B[WFGAME 客户端 / 管理端]
end
subgraph 门店
C[网吧 电竞酒店 终端]
end
subgraph 用户
D[玩家开玩与付费行为]
end
A --> B
B --> C
C --> D
B -.->|运营位 工具 社交| D
```
---
## 四、客户、痛点与采购关注点
| 客户类型 | 典型诉求 |
|----------|----------|
| 单店/小连锁网吧 | 省人力更新游戏、减少投诉(启动慢、缺包)、桌面整洁可管理 |
| 电竞酒店 | 与客房场景结合的个人存档、外设一致性、内容合规展示 |
| 网维/集成商 | 与无盘/计费/安全方案的 **兼容性**、批量部署与回滚、日志与告警 |
**采购关注点**:授权与责任边界(尤其 **第三方游戏平台账号**)、更新源 **版权与稳定性**、是否捆绑广告/插件、**数据驻留与隐私**、售后响应 SLA。
---
## 五、竞争格局
**同生态位产品类型**(非穷尽):
| 类型 | 说明 | 备注 |
|------|------|------|
| 网维 + 游戏更新 | 易乐游、云更新、网维大师等 | 偏 **镜像与更新管道**,与聚合桌面可组合或替代 |
| 平台官方启动器 | Steam、WeGame、Epic 等 | 强 **账号与商店** 绑定;聚合壳常作快捷跳转 |
| 游戏菜单 / 平台壳 | 各地菜单方案、WFGame 等 | 偏 **展示、运营与一站式入口** |
**格局判断(定性,无统一市占口径)**
- 计费与网维赛道 **头部集中****游戏聚合桌面** 多为 **区域代理 + 方案商生态**,品牌分散。
- WFGAME 在公开传播中强调 **全国节点与资源规模**,具体覆盖率需以 **招标材料或样本门店** 核实。
- **禁止**将不同统计口径(装机台数、活跃 UV、流水混成「市场份额 %」。
---
## 六、商业模式
公开信息可归纳为(比例以商务为准):
```mermaid
flowchart TB
M1[软件或方案授权/服务费]
M2[CDN/更新与增值通道费]
M3[运营位与导流分润]
M4[与网维/硬件/支付打包]
M1 --> M4
M2 --> M4
M3 --> M4
```
- **B 端**:按终端/门店/模块订阅或一次性方案费。
- **资源与带宽**:更新加速、热门资源位可能单独计费。
- **流量变现**:福利、租号、工具导流等(合规前提见下节)。
---
## 七、合规与舆情
1. **正规产品与灰黑产**:任何 **破解版游戏、绕过平台付费、非法租售账号** 均可能触犯法律与平台协议;本报告 **不展开** 灰产操作细节。采购与联运须核验 **授权链**
2. **未成年人保护**:线下场所须落实 **实名与时长管理**;聚合壳内的外链与游戏 **适龄展示** 需单独审计。
3. **「租号/买号」模块**:涉及 Steam 等平台的 **用户协议与地区政策**,存在 **封号与纠纷** 风险;产品若保留该入口,须在 **法务与运营** 层单独评估,**不宜**与玩值自有直播产品无差别混用。
4. **公众号/信使类能力**:须核对 **主体、类目、隐私政策** 是否与官方一致,避免误绑山寨服务号。
---
## 八、下一步验证清单
1. 获取 **官方产品白皮书或功能清单(含管理端截图)**,与客户端菜单 **逐项对齐**
2. 样本 **510 家门店** 访谈:实际使用模块排序、与计费/无盘品牌组合、对「租号/福利」的真实点击率与纠纷。
3.**12 家** 网维更新方案做 **功能矩阵对比**(更新延迟、带宽成本、异常回滚)。
4. 法务粗评:**聚合跳转、存档漫游、运营 Banner** 的知识产权与数据合规路径。
5. 若玩值产品借鉴 UX输出 **低保真线框** + **红线清单**(不做账号交易、不做未授权分发等)。
---
## 九、修订记录
| 日期 | 版本 | 说明 |
|------|------|------|
| 2026-04-13 | v0.1 | 初稿:公开信息 + 界面素材整理 |
---
*本报告基于公开信息与界面归纳,不构成投资或采购建议。*

View File

@@ -0,0 +1,23 @@
{
"nodes":[
{"id":"d111111111111111","type":"text","x":-620,"y":-400,"width":500,"height":230,"color":"1","text":"# 嘟嘟牛 · 网吧计费与管理\n\n**市场调研** · 2026-04-13 · **卡若五行营销** 结构\n\n---\n\n金目标 · 水流程 · 木落地 · 火分析 · 土资源"},
{"id":"e111111111111111","type":"text","x":20,"y":-400,"width":400,"height":110,"color":"4","text":"## 金 · 目标\n\n人群单店/小连锁网咖 · 定位:多业态收银、网吧为中腰部认知"},
{"id":"e222222222222222","type":"text","x":20,"y":-270,"width":400,"height":115,"color":"5","text":"## 水 · 流程\n\n认知→评估→成交→日营→增值与玩值用户漏斗对照见正文"},
{"id":"e333333333333333","type":"text","x":20,"y":-135,"width":400,"height":130,"color":"3","text":"## 木 · 落地\n\n三层架构图 + 产品线表 + 与玩值 MVP 边界"},
{"id":"e444444444444444","type":"text","x":20,"y":15,"width":400,"height":130,"color":"2","text":"## 火 · 分析\n\n竞争格局 · 收入结构 · 合规红线 · 待补齐指标\n\n## 土 · 资源\n\n接口/人力/预算 · 试点边界"},
{"id":"0f0f0f0f0f0f0f0f","type":"file","x":-620,"y":-140,"width":500,"height":88,"file":"玩值开发文档/1、需求/调研/市场调研_嘟嘟牛网吧工具_20260413.md"},
{"id":"b111111111111111","type":"file","x":-620,"y":-30,"width":500,"height":82,"file":"玩值开发文档/1、需求/调研/images/architecture-overview.svg"},
{"id":"b222222222222222","type":"file","x":-620,"y":70,"width":500,"height":82,"file":"玩值开发文档/1、需求/调研/images/market-structure.svg"},
{"id":"abcdabcdabcdabcd","type":"link","x":440,"y":40,"width":200,"height":64,"url":"https://www.dodonew.com/"}
],
"edges":[
{"id":"f0f0f0f0f0f0f001","fromNode":"d111111111111111","fromSide":"right","toNode":"e111111111111111","toSide":"left","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f002","fromNode":"d111111111111111","fromSide":"right","toNode":"e222222222222222","toSide":"left","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f003","fromNode":"d111111111111111","fromSide":"right","toNode":"e333333333333333","toSide":"left","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f004","fromNode":"d111111111111111","fromSide":"right","toNode":"e444444444444444","toSide":"left","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f005","fromNode":"d111111111111111","fromSide":"bottom","toNode":"0f0f0f0f0f0f0f0f","toSide":"top","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f006","fromNode":"0f0f0f0f0f0f0f0f","fromSide":"bottom","toNode":"b111111111111111","toSide":"top","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f007","fromNode":"b111111111111111","fromSide":"bottom","toNode":"b222222222222222","toSide":"top","fromEnd":"none","toEnd":"arrow"},
{"id":"f0f0f0f0f0f0f008","fromNode":"e444444444444444","fromSide":"right","toNode":"abcdabcdabcdabcd","toSide":"left","fromEnd":"none","toEnd":"arrow","label":"公开站点"}
]
}

View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="dcterms.date" content="2026-04-13" />
<title>市场调研报告 · 嘟嘟牛(网吧计费与管理)</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
<link rel="stylesheet" href="_html_assets/karuo_report.css" />
</head>
<body>
<header id="title-block-header">
<h1 class="title">市场调研报告 · 嘟嘟牛(网吧计费与管理)</h1>
<p class="date">2026-04-13</p>
</header>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#市场调研报告-嘟嘟牛网吧计费与管理"
id="toc-市场调研报告-嘟嘟牛网吧计费与管理">市场调研报告 ·
嘟嘟牛(网吧计费与管理)</a>
<ul>
<li><a href="#金-目标人群-流量-定位" id="toc-金-目标人群-流量-定位">金 ·
目标(人群 / 流量 / 定位)</a></li>
<li><a href="#水-流程用户路径-漏斗-关键节点"
id="toc-水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</a></li>
<li><a href="#木-落地产品形态-功能清单-mvp-边界"
id="toc-木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</a>
<ul>
<li><a href="#业务架构公开信息归纳"
id="toc-业务架构公开信息归纳">业务架构(公开信息归纳)</a></li>
<li><a href="#产品线功能域归纳"
id="toc-产品线功能域归纳">产品线(功能域归纳)</a></li>
<li><a href="#mvp-边界若与玩值电竞等自研系统对接时的最小可讲清范围"
id="toc-mvp-边界若与玩值电竞等自研系统对接时的最小可讲清范围">MVP
边界(若与玩值电竞等自研系统对接时的「最小可讲清范围」)</a></li>
</ul></li>
<li><a href="#火-分析数据-指标-复盘与迭代"
id="toc-火-分析数据-指标-复盘与迭代">火 · 分析(数据 / 指标 /
复盘与迭代)</a>
<ul>
<li><a href="#竞争格局定性"
id="toc-竞争格局定性">竞争格局(定性)</a></li>
<li><a href="#商业模式拆解收入结构类型"
id="toc-商业模式拆解收入结构类型">商业模式拆解(收入结构类型)</a></li>
<li><a href="#合规红线分析项"
id="toc-合规红线分析项">合规红线(分析项)</a></li>
<li><a href="#待补齐指标复盘用"
id="toc-待补齐指标复盘用">待补齐指标(复盘用)</a></li>
</ul></li>
<li><a href="#土-资源技术-人力-预算与合作"
id="toc-土-资源技术-人力-预算与合作">土 · 资源(技术 / 人力 /
预算与合作)</a></li>
</ul></li>
</ul>
</nav>
<h1 id="市场调研报告-嘟嘟牛网吧计费与管理">市场调研报告 ·
嘟嘟牛(网吧计费与管理)</h1>
<blockquote>
<p><strong>结构说明</strong>:下文按 <strong>卡若五行营销</strong>
重组——<strong></strong> 目标与定位,<strong></strong>
路径与节点,<strong></strong> 产品落地形态,<strong></strong>
数据与复盘,<strong></strong> 资源与投入边界。真源卡若AI
<code>03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md</code>
第六节。)</p>
</blockquote>
<hr />
<h2 id="金-目标人群-流量-定位">金 · 目标(人群 / 流量 / 定位)</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>子项</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>目标人群</strong></td>
<td>单店网吧、小型连锁、兼营简餐/零售的网咖老板/店长;采购侧关注
<strong>稳定性、对账、储值安全、移动收款、连锁漫游</strong></td>
</tr>
<tr>
<td><strong>流量与渠道</strong></td>
<td>厂商侧以
<strong>官网/商户中心、代理渠道、应用商店(嘟嘟牛管家)</strong>
等为主;本报告不评估投放效果</td>
</tr>
<tr>
<td><strong>品牌与定位</strong></td>
<td>公开信息:多业态 <strong>门店收银 / 一卡通 / 会员计费 +
硬件</strong>
方案商,<strong>网吧为子场景之一</strong>;在计费细分赛道上,行业材料常将嘟嘟牛与头部顺网系并列列举时置于
<strong>中腰部</strong> 认知区间(须带年份与口径,见 <strong></strong>
节)</td>
</tr>
<tr>
<td><strong>竞争位势一句话</strong></td>
<td>「通用门店数字化能力强、网吧计费垂直份额不占优」——定性,非尽调结论</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</h2>
<table>
<colgroup>
<col style="width: 14%" />
<col style="width: 60%" />
<col style="width: 24%" />
</colgroup>
<thead>
<tr>
<th>阶段</th>
<th>B 端(网吧采购与使用)</th>
<th>关键节点</th>
</tr>
</thead>
<tbody>
<tr>
<td>认知</td>
<td>同行推荐、代理触达、搜索厂商名</td>
<td>是否识别 <strong>多业态</strong><strong>纯网吧方案</strong>
差异</td>
</tr>
<tr>
<td>评估</td>
<td>下载试用、对接支付与硬件、看报表与交接班</td>
<td><strong>迁移成本</strong>、本地实施响应</td>
</tr>
<tr>
<td>成交</td>
<td>签约模块(计费 / 会员 / 增值)、机具采购</td>
<td>合同中的 <strong>支付分润、数据归属</strong></td>
</tr>
<tr>
<td>日营</td>
<td>开班上机、储值、对账、移动收款</td>
<td><strong>防漏单、防舞弊</strong>、连锁漫游规则</td>
</tr>
<tr>
<td>迭代</td>
<td>版本升级、增值模块加购</td>
<td><strong>微信侧能力</strong>(公众号/小程序等)是否增购</td>
</tr>
</tbody>
</table>
<p><strong>与 C 端网民</strong>:动线集中在 <strong>到店 → 上机/登录 →
储值消费 → 离店</strong>;本报告不展开 C 端运营细节。</p>
<hr />
<h2 id="木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</h2>
<h3 id="业务架构公开信息归纳">业务架构(公开信息归纳)</h3>
<p><strong>图1</strong>(英文标注,避免部分环境 SVG 中文乱码)</p>
<figure>
<img src="./images/architecture-overview.svg" alt="图1" />
<figcaption aria-hidden="true">图1</figcaption>
</figure>
<p><strong>图2</strong></p>
<figure>
<img src="./images/market-structure.svg" alt="图2" />
<figcaption aria-hidden="true">图2</figcaption>
</figure>
<pre class="mermaid"><code>flowchart TB
subgraph L1[&quot;门店侧&quot;]
A[PC 计费 / 网吧管家]
end
subgraph L2[&quot;经营侧&quot;]
B[嘟嘟牛管家 App]
C[公众号 / 商户与支付能力]
end
subgraph L3[&quot;结算与增值&quot;]
D[扫码与储值]
E[虚拟商品与营销活动等]
end
A --&gt; B
B --&gt; C
A --&gt; D
C --&gt; D
D --&gt; E</code></pre>
<h3 id="产品线功能域归纳">产品线(功能域归纳)</h3>
<table>
<colgroup>
<col style="width: 23%" />
<col style="width: 38%" />
<col style="width: 38%" />
</colgroup>
<thead>
<tr>
<th>层级</th>
<th>产品形态</th>
<th>能力要点</th>
</tr>
</thead>
<tbody>
<tr>
<td>门店核心</td>
<td>PC 端计费 / 网吧管家</td>
<td>上机计费、会员、交接班、报表、支付通道对接</td>
</tr>
<tr>
<td>移动经营</td>
<td>嘟嘟牛管家App</td>
<td>经营数据查看、移动侧收款(以商店说明为准)</td>
</tr>
<tr>
<td>交付与运营</td>
<td>商户中心、下载与文档</td>
<td>B 端开户、版本交付与运维资料</td>
</tr>
<tr>
<td>增值</td>
<td>增收 / 营销类能力</td>
<td>公开宣传中曾出现「增收宝」等表述</td>
</tr>
</tbody>
</table>
<h3 id="mvp-边界若与玩值电竞等自研系统对接时的最小可讲清范围">MVP
边界(若与玩值电竞等自研系统对接时的「最小可讲清范围」)</h3>
<table>
<colgroup>
<col style="width: 34%" />
<col style="width: 65%" />
</colgroup>
<thead>
<tr>
<th>纳入 MVP 讨论</th>
<th>暂不纳入(须商务/法务另开)</th>
</tr>
</thead>
<tbody>
<tr>
<td>会员/订单 <strong>结果回调</strong>、门店维度
<strong>对账字段</strong></td>
<td>计费内核替换、深度改 Pubwin/万象栈</td>
</tr>
<tr>
<td>支付成功/储值流水 <strong>对账接口是否存在</strong></td>
<td>未披露的 <strong>内部表结构</strong></td>
</tr>
<tr>
<td><strong>官方小程序主体</strong> 核验清单</td>
<td>任何 <strong>灰产破解</strong> 相关能力</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="火-分析数据-指标-复盘与迭代">火 · 分析(数据 / 指标 /
复盘与迭代)</h2>
<h3 id="竞争格局定性">竞争格局(定性)</h3>
<table>
<colgroup>
<col style="width: 16%" />
<col style="width: 16%" />
<col style="width: 66%" />
</colgroup>
<thead>
<tr>
<th>类型</th>
<th>说明</th>
<th>常见品牌(公开资料)</th>
</tr>
</thead>
<tbody>
<tr>
<td>收银 / 计费 / 网管</td>
<td>网吧前台核心系统</td>
<td>万象网管、Pubwin、龙管家、恒信一卡通、嘟嘟牛等</td>
</tr>
<tr>
<td>网维 / 游戏内容分发</td>
<td>常与计费 <strong>分开采购</strong></td>
<td>网维大师、易乐游、云更新等</td>
</tr>
</tbody>
</table>
<p><strong>格局判断</strong>:头部 <strong>顺网系</strong> 在「计费 +
网维」组合上集中度高;嘟嘟牛与区域/性价比方案同列
<strong>中腰部</strong> 认知;<strong>市占率 %</strong>
须单独引用可追溯表(见竞品专篇 <strong></strong> 节 2019
协会口径),本报告不重复贴数以免双源不一致。</p>
<h3 id="商业模式拆解收入结构类型">商业模式拆解(收入结构类型)</h3>
<pre class="mermaid"><code>flowchart LR
R1[软件授权或服务费] --&gt; R2[支付相关分润]
R2 --&gt; R3[硬件与机具]
R3 --&gt; R4[增值服务]</code></pre>
<ol type="1">
<li>软件:授权、订阅或按门店/模块收取的服务费。<br />
</li>
<li>支付:扫码、储值等手续费或分润。<br />
</li>
<li>硬件:收银机、读卡/人脸/消费机等。<br />
</li>
<li>增值:点卡、活动运营包、异业分润等。</li>
</ol>
<h3 id="合规红线分析项">合规红线(分析项)</h3>
<ul>
<li><strong>灰黑产</strong>:任何「破解、秒破、绕过计费」工具与正规产品
<strong>无关且违法</strong>;采购侧 <strong>一票否决</strong><br />
</li>
<li><strong>微信生态</strong>:小程序须核对
<strong>注册主体、类目、隐私政策、支付资质</strong></li>
</ul>
<h3 id="待补齐指标复盘用">待补齐指标(复盘用)</h3>
<table>
<thead>
<tr>
<th>指标</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>官方小程序 <strong>主体与类目</strong></td>
<td>判断是否可纳为玩值侧 <strong>合规导流</strong></td>
</tr>
<tr>
<td><strong>白皮书/API 清单</strong></td>
<td>评估对接工期与 <strong>火·土</strong> 资源投入</td>
</tr>
<tr>
<td>5 家样本店 <strong>App/小程序/纯 PC 占比</strong></td>
<td>验证 <strong></strong> 段真实动线</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="土-资源技术-人力-预算与合作">土 · 资源(技术 / 人力 /
预算与合作)</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>子项</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>技术资源</strong></td>
<td>计费系统多为 <strong>闭源垂直套件</strong>;与玩值 Mongo/直播栈
<strong>无默认中间件</strong>,需厂商或代理开放
<strong>HTTP/Webhook</strong> 能力才谈集成</td>
</tr>
<tr>
<td><strong>人力投入</strong></td>
<td>典型为 <strong>代理实施 + 店内网管</strong>;自研侧需 <strong>后端
12 人周</strong> 起做 POC仅当厂商接口明确时</td>
</tr>
<tr>
<td><strong>预算与商务</strong></td>
<td>模块授权 + 机具 + 支付分润条款;迁移旧系统可能产生
<strong>一次性数据清洗成本</strong></td>
</tr>
<tr>
<td><strong>合作边界建议</strong></td>
<td><strong>试点单店</strong> 验证对账与回调,再扩连锁;不把
<strong>计费内核</strong> 与玩值 <strong>内容内核</strong> 混为一谈</td>
</tr>
</tbody>
</table>
<hr />
<p><em>本报告基于公开信息整理,不构成投资或采购建议。</em></p>
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>
</body>
</html>

View File

@@ -0,0 +1,142 @@
---
title: 市场调研报告 · 嘟嘟牛(网吧计费与管理)
date: 2026-04-13
version: "2026-04-13"
framework: 卡若五行营销(金→水→木→火→土)
framework_ref: 卡若AI / 木果_项目模板 / 开发模板 / 参考资料 / 快速参考.md · 第六节
---
# 市场调研报告 · 嘟嘟牛(网吧计费与管理)
> **结构说明**:下文按 **卡若五行营销** 重组——**金** 目标与定位,**水** 路径与节点,**木** 产品落地形态,**火** 数据与复盘,**土** 资源与投入边界。真源卡若AI `03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md` 第六节。)
---
## 金 · 目标(人群 / 流量 / 定位)
| 子项 | 内容 |
|------|------|
| **目标人群** | 单店网吧、小型连锁、兼营简餐/零售的网咖老板/店长;采购侧关注 **稳定性、对账、储值安全、移动收款、连锁漫游** |
| **流量与渠道** | 厂商侧以 **官网/商户中心、代理渠道、应用商店(嘟嘟牛管家)** 等为主;本报告不评估投放效果 |
| **品牌与定位** | 公开信息:多业态 **门店收银 / 一卡通 / 会员计费 + 硬件** 方案商,**网吧为子场景之一**;在计费细分赛道上,行业材料常将嘟嘟牛与头部顺网系并列列举时置于 **中腰部** 认知区间(须带年份与口径,见 **火** 节) |
| **竞争位势一句话** | 「通用门店数字化能力强、网吧计费垂直份额不占优」——定性,非尽调结论 |
---
## 水 · 流程(用户路径 / 漏斗 / 关键节点)
| 阶段 | B 端(网吧采购与使用) | 关键节点 |
|------|-------------------------|----------|
| 认知 | 同行推荐、代理触达、搜索厂商名 | 是否识别 **多业态****纯网吧方案** 差异 |
| 评估 | 下载试用、对接支付与硬件、看报表与交接班 | **迁移成本**、本地实施响应 |
| 成交 | 签约模块(计费 / 会员 / 增值)、机具采购 | 合同中的 **支付分润、数据归属** |
| 日营 | 开班上机、储值、对账、移动收款 | **防漏单、防舞弊**、连锁漫游规则 |
| 迭代 | 版本升级、增值模块加购 | 与 **微信侧能力**(公众号/小程序等)是否增购 |
**与 C 端网民**:动线集中在 **到店 → 上机/登录 → 储值消费 → 离店**;本报告不展开 C 端运营细节。
---
## 木 · 落地(产品形态 / 功能清单 / MVP 边界)
### 业务架构(公开信息归纳)
**图1**(英文标注,避免部分环境 SVG 中文乱码)
![图1](./images/architecture-overview.svg)
**图2**
![图2](./images/market-structure.svg)
```mermaid
flowchart TB
subgraph L1["门店侧"]
A[PC 计费 / 网吧管家]
end
subgraph L2["经营侧"]
B[嘟嘟牛管家 App]
C[公众号 / 商户与支付能力]
end
subgraph L3["结算与增值"]
D[扫码与储值]
E[虚拟商品与营销活动等]
end
A --> B
B --> C
A --> D
C --> D
D --> E
```
### 产品线(功能域归纳)
| 层级 | 产品形态 | 能力要点 |
|------|----------|----------|
| 门店核心 | PC 端计费 / 网吧管家 | 上机计费、会员、交接班、报表、支付通道对接 |
| 移动经营 | 嘟嘟牛管家App | 经营数据查看、移动侧收款(以商店说明为准) |
| 交付与运营 | 商户中心、下载与文档 | B 端开户、版本交付与运维资料 |
| 增值 | 增收 / 营销类能力 | 公开宣传中曾出现「增收宝」等表述 |
### MVP 边界(若与玩值电竞等自研系统对接时的「最小可讲清范围」)
| 纳入 MVP 讨论 | 暂不纳入(须商务/法务另开) |
|----------------|------------------------------|
| 会员/订单 **结果回调**、门店维度 **对账字段** | 计费内核替换、深度改 Pubwin/万象栈 |
| 支付成功/储值流水 **对账接口是否存在** | 未披露的 **内部表结构** |
| **官方小程序主体** 核验清单 | 任何 **灰产破解** 相关能力 |
---
## 火 · 分析(数据 / 指标 / 复盘与迭代)
### 竞争格局(定性)
| 类型 | 说明 | 常见品牌(公开资料) |
|------|------|------------------------|
| 收银 / 计费 / 网管 | 网吧前台核心系统 | 万象网管、Pubwin、龙管家、恒信一卡通、嘟嘟牛等 |
| 网维 / 游戏内容分发 | 常与计费 **分开采购** | 网维大师、易乐游、云更新等 |
**格局判断**:头部 **顺网系** 在「计费 + 网维」组合上集中度高;嘟嘟牛与区域/性价比方案同列 **中腰部** 认知;**市占率 %** 须单独引用可追溯表(见竞品专篇 **火** 节 2019 协会口径),本报告不重复贴数以免双源不一致。
### 商业模式拆解(收入结构类型)
```mermaid
flowchart LR
R1[软件授权或服务费] --> R2[支付相关分润]
R2 --> R3[硬件与机具]
R3 --> R4[增值服务]
```
1. 软件:授权、订阅或按门店/模块收取的服务费。
2. 支付:扫码、储值等手续费或分润。
3. 硬件:收银机、读卡/人脸/消费机等。
4. 增值:点卡、活动运营包、异业分润等。
### 合规红线(分析项)
- **灰黑产**:任何「破解、秒破、绕过计费」工具与正规产品 **无关且违法**;采购侧 **一票否决**
- **微信生态**:小程序须核对 **注册主体、类目、隐私政策、支付资质**
### 待补齐指标(复盘用)
| 指标 | 用途 |
|------|------|
| 官方小程序 **主体与类目** | 判断是否可纳为玩值侧 **合规导流** |
| **白皮书/API 清单** | 评估对接工期与 **火·土** 资源投入 |
| 5 家样本店 **App/小程序/纯 PC 占比** | 验证 **水** 段真实动线 |
---
## 土 · 资源(技术 / 人力 / 预算与合作)
| 子项 | 内容 |
|------|------|
| **技术资源** | 计费系统多为 **闭源垂直套件**;与玩值 Mongo/直播栈 **无默认中间件**,需厂商或代理开放 **HTTP/Webhook** 能力才谈集成 |
| **人力投入** | 典型为 **代理实施 + 店内网管**;自研侧需 **后端 12 人周** 起做 POC仅当厂商接口明确时 |
| **预算与商务** | 模块授权 + 机具 + 支付分润条款;迁移旧系统可能产生 **一次性数据清洗成本** |
| **合作边界建议** | 先 **试点单店** 验证对账与回调,再扩连锁;不把 **计费内核** 与玩值 **内容内核** 混为一谈 |
---
*本报告基于公开信息整理,不构成投资或采购建议。*

View File

@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="dcterms.date" content="2026-04-13" />
<title>市场调研报告 · 易乐游(网吧网娱平台)</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
<link rel="stylesheet" href="_html_assets/karuo_report.css" />
</head>
<body>
<header id="title-block-header">
<h1 class="title">市场调研报告 · 易乐游(网吧网娱平台)</h1>
<p class="date">2026-04-13</p>
</header>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#市场调研报告-易乐游网吧网娱平台"
id="toc-市场调研报告-易乐游网吧网娱平台">市场调研报告 ·
易乐游(网吧网娱平台)</a>
<ul>
<li><a href="#金-目标人群-流量-定位" id="toc-金-目标人群-流量-定位">金 ·
目标(人群 / 流量 / 定位)</a></li>
<li><a href="#水-流程用户路径-漏斗-关键节点"
id="toc-水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</a></li>
<li><a href="#木-落地产品形态-功能清单-mvp-边界"
id="toc-木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</a></li>
<li><a href="#火-分析份额口径-竞品-复盘指标"
id="toc-火-分析份额口径-竞品-复盘指标">火 · 分析(份额口径 / 竞品 /
复盘指标)</a></li>
<li><a href="#土-资源技术-人力-合作" id="toc-土-资源技术-人力-合作">土 ·
资源(技术 / 人力 / 合作)</a></li>
<li><a href="#风险与信息缺口"
id="toc-风险与信息缺口">风险与信息缺口</a></li>
<li><a href="#参考链接" id="toc-参考链接">参考链接</a></li>
</ul></li>
</ul>
</nav>
<h1 id="市场调研报告-易乐游网吧网娱平台">市场调研报告 ·
易乐游(网吧网娱平台)</h1>
<blockquote>
<p><strong>对象</strong><strong>易乐游Yileyoo</strong>,湖北<strong>盛天网络</strong>技术股份有限公司(深交所
<strong>300494</strong>)旗下面向网吧、电竞酒店等公众上网场所的
<strong>网娱 / 运维管理平台</strong><br />
<strong>结构</strong>:按 <strong>卡若五行营销</strong>
归纳——<strong></strong> 目标与定位,<strong></strong>
路径与漏斗,<strong></strong> 产品落地,<strong></strong>
数据与份额口径,<strong></strong>
资源与合作。(真源:<code>03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md</code>
第六节。)</p>
</blockquote>
<p><strong>姊妹材料(本机 HTML展厅风</strong></p>
<ul>
<li><a
href="file:///Users/karuo/Documents/卡若Ai的文件夹/报告/易乐游_五行营销调研报告_20260413.html">易乐游
· 五行营销调研报告</a></li>
<li><a
href="file:///Users/karuo/Documents/卡若Ai的文件夹/报告/易乐游_深度市场调研_市占率与合作对象_20260413.html">易乐游
· 深度调研(市占率与合作对象)</a></li>
</ul>
<hr />
<h2 id="金-目标人群-流量-定位">金 · 目标(人群 / 流量 / 定位)</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>子项</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>目标人群</strong></td>
<td>网吧 / 网咖 / 电竞酒店
<strong>经营者</strong><strong>区域渠道、系统集成商</strong>(实施与售后)</td>
</tr>
<tr>
<td><strong>流量来源</strong></td>
<td>场所 <strong>终端装机与活跃</strong>;游戏
<strong>分发与更新</strong> 入口;与盛天
<strong>广告及增值服务、联运</strong> 的协同;腾讯 <strong>游戏人生 / QQ
网吧</strong> 等官方场景项目(盛天已公示入选 2026
年一期官方合作渠道)</td>
</tr>
<tr>
<td><strong>品牌定位</strong></td>
<td>官网与盛天产品页:<strong>网吧网娱平台</strong>、系统与内容管理、桌面虚拟化与安全;官网传播「近
5 万家网吧」「千万用户」量级</td>
</tr>
<tr>
<td><strong>竞争位势(定性)</strong></td>
<td>
<strong>顺网系(网维大师等)</strong><strong>云更新</strong>、区域
<strong>无盘总包</strong> 在同一价值链不同层竞争;计费侧常与
<strong>万象、Pubwin</strong><strong>拼接</strong> 而非完全替代</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</h2>
<table style="width:100%;">
<colgroup>
<col style="width: 17%" />
<col style="width: 54%" />
<col style="width: 28%" />
</colgroup>
<thead>
<tr>
<th>阶段</th>
<th>B 端(采购与续约)</th>
<th>关键节点</th>
</tr>
</thead>
<tbody>
<tr>
<td>触达</td>
<td>渠道拜访、展会、同行介绍、电商技术服务页</td>
<td>资质与 <strong>案例真实性</strong></td>
</tr>
<tr>
<td>评估</td>
<td>PoC、镜像试用、与计费/支付对接清单</td>
<td><strong>迁移成本</strong>、无盘 IO 与万兆网络</td>
</tr>
<tr>
<td>成交</td>
<td>软授权 + 服务器存储 + 实施人天(常见总包)</td>
<td>合同 <strong>SLA、数据归属、版本回滚</strong></td>
</tr>
<tr>
<td>交付</td>
<td>上架、压测、镜像下发、网管培训</td>
<td><strong>首月稳定率</strong>决定口碑</td>
</tr>
<tr>
<td>续约 / 增值</td>
<td>活动资源、广告分成透明、特权项目(如 QQ 网吧)</td>
<td><strong>售后响应速度</strong></td>
</tr>
</tbody>
</table>
<hr />
<h2 id="木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</h2>
<table>
<colgroup>
<col style="width: 15%" />
<col style="width: 45%" />
<col style="width: 40%" />
</colgroup>
<thead>
<tr>
<th>模块</th>
<th>能力要点(归纳)</th>
<th>MVP 边界提示</th>
</tr>
</thead>
<tbody>
<tr>
<td>系统与镜像</td>
<td>操作系统管理、有盘/无盘、混合部署、还原</td>
<td>与客户现有 <strong>计费账号体系</strong> 对齐</td>
</tr>
<tr>
<td>内容与更新</td>
<td>游戏与补丁分发、缓存策略(视版本)</td>
<td><strong>版权与版号</strong> 责任边界写清</td>
</tr>
<tr>
<td>安全与管控</td>
<td>安全策略、审计、行业共性防盗链思路</td>
<td>可与第三方安全 <strong>并存</strong></td>
</tr>
<tr>
<td>移动协同</td>
<td><strong>易乐游手机助手</strong>(应用/影音等分发)</td>
<td>增量场景,非网吧主路径必需</td>
</tr>
</tbody>
</table>
<p>公开入口:<a href="https://www.yileyoo.com/">易乐游官网</a> · 客服
<strong>400-8858-580</strong>(官网公示)。</p>
<hr />
<h2 id="火-分析份额口径-竞品-复盘指标">火 · 分析(份额口径 / 竞品 /
复盘指标)</h2>
<p><strong>多口径并列(不可混加)</strong></p>
<table>
<colgroup>
<col style="width: 38%" />
<col style="width: 23%" />
<col style="width: 38%" />
</colgroup>
<thead>
<tr>
<th>指标表述</th>
<th>数值</th>
<th>来源类型</th>
</tr>
</thead>
<tbody>
<tr>
<td>盛天产品页「中国网吧市场份额」</td>
<td><strong>&gt;38%</strong></td>
<td>企业自述</td>
</tr>
<tr>
<td>易乐游官网「网吧家数」</td>
<td><strong>近 5 万家</strong></td>
<td>企业自述</td>
</tr>
<tr>
<td>研报常见回溯「综合娱乐管理平台」</td>
<td><strong>30.7%</strong>(约 2019 语境)</td>
<td>二级市场整理,须回年报原页</td>
</tr>
<tr>
<td>招股书区间(整理)</td>
<td><strong>29.5% → 37.7%</strong>(约 20112013</td>
<td>须以深交所 PDF 核数</td>
</tr>
<tr>
<td>顺网媒体稿「游戏更新软件」覆盖率(历史)</td>
<td><strong>47.6%</strong>2009 语境)</td>
<td><strong>子赛道 ≠ 全平台</strong></td>
</tr>
</tbody>
</table>
<p><strong>竞品提示</strong>:顺网
<strong>网维大师</strong><strong>万象/Pubwin</strong>(计费)、<strong>云更新</strong>、区域无盘方案商。</p>
<p><strong>建议自建指标</strong>:活跃终端、镜像版本覆盖率、故障
MTTR、续费率、丢标原因标签。</p>
<hr />
<h2 id="土-资源技术-人力-合作">土 · 资源(技术 / 人力 / 合作)</h2>
<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<thead>
<tr>
<th>类型</th>
<th>要点</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>硬资源</strong></td>
<td>服务器、存储、万兆/2.5G 交换、带宽与缓存</td>
</tr>
<tr>
<td><strong>人力</strong></td>
<td>实施网工、驻场、400 客服与区域代理体系</td>
</tr>
<tr>
<td><strong>生态合作(公开)</strong></td>
<td><strong>腾讯游戏人生</strong> — 2026 年 <strong>QQ
网吧分区域拓展运营项目(一期)官方合作渠道</strong>
公示,盛天入选;负责部分区域
<strong>安装覆盖、官方直连及运营</strong>(以公示正文为准)</td>
</tr>
<tr>
<td><strong>游戏厂商</strong></td>
<td>推广、联运、广告 — <strong>具体客户与合同</strong><strong>300494
定期报告 / 巨潮</strong> 披露为准</td>
</tr>
</tbody>
</table>
<p>盛天联系(官网公开):武汉市东湖新技术开发区光谷大道 77 号金融港 B7
栋 911 楼;电话 <strong>027-86655050</strong></p>
<hr />
<h2 id="风险与信息缺口">风险与信息缺口</h2>
<ul>
<li>传统网吧终端若 <strong>结构性缩量</strong>,增量依赖
<strong>替换、电竞酒店、增值</strong><br />
</li>
<li>份额数字用于投融资前建议 <strong>第三方或抽样</strong>
复核口径。<br />
</li>
<li>集团另有游戏、IP 等业务,读「易乐游」勿用 <strong>整体营收</strong>
替代 <strong>单产品线</strong> 健康度。</li>
</ul>
<hr />
<h2 id="参考链接">参考链接</h2>
<ul>
<li><a href="https://www.yileyoo.com/"
class="uri">https://www.yileyoo.com/</a></li>
<li><a href="https://www.shengtian.com/Product/platform"
class="uri">https://www.shengtian.com/Product/platform</a></li>
<li><a href="https://www.shengtian.com/News/newsInfo/nid/475"
class="uri">https://www.shengtian.com/News/newsInfo/nid/475</a>QQ
网吧合作方新闻2026-04-01</li>
</ul>
<hr />
<p><em>本 Markdown 与同源 HTML 由卡若 AI
整理;不构成投资建议或采购承诺。</em></p>
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>
</body>
</html>

View File

@@ -0,0 +1,105 @@
---
title: 市场调研报告 · 易乐游(网吧网娱平台)
date: 2026-04-13
version: "2026-04-13"
project: 玩值电竞App
framework: 卡若五行营销(金→水→木→火→土)
framework_ref: 卡若AI / 木果_项目模板 / 开发模板 / 参考资料 / 快速参考.md · 第六节
---
# 市场调研报告 · 易乐游(网吧网娱平台)
> **对象****易乐游Yileyoo**,湖北**盛天网络**技术股份有限公司(深交所 **300494**)旗下面向网吧、电竞酒店等公众上网场所的 **网娱 / 运维管理平台**。
> **结构**:按 **卡若五行营销** 归纳——**金** 目标与定位,**水** 路径与漏斗,**木** 产品落地,**火** 数据与份额口径,**土** 资源与合作。(真源:`03_卡木/木果_项目模板/开发模板/参考资料/快速参考.md` 第六节。)
**姊妹材料(本机 HTML展厅风**
- [易乐游 · 五行营销调研报告](file:///Users/karuo/Documents/卡若Ai的文件夹/报告/易乐游_五行营销调研报告_20260413.html)
- [易乐游 · 深度调研(市占率与合作对象)](file:///Users/karuo/Documents/卡若Ai的文件夹/报告/易乐游_深度市场调研_市占率与合作对象_20260413.html)
---
## 金 · 目标(人群 / 流量 / 定位)
| 子项 | 内容 |
|------|------|
| **目标人群** | 网吧 / 网咖 / 电竞酒店 **经营者****区域渠道、系统集成商**(实施与售后) |
| **流量来源** | 场所 **终端装机与活跃**;游戏 **分发与更新** 入口;与盛天 **广告及增值服务、联运** 的协同;腾讯 **游戏人生 / QQ 网吧** 等官方场景项目(盛天已公示入选 2026 年一期官方合作渠道) |
| **品牌定位** | 官网与盛天产品页:**网吧网娱平台**、系统与内容管理、桌面虚拟化与安全;官网传播「近 5 万家网吧」「千万用户」量级 |
| **竞争位势(定性)** | 与 **顺网系(网维大师等)**、**云更新**、区域 **无盘总包** 在同一价值链不同层竞争;计费侧常与 **万象、Pubwin****拼接** 而非完全替代 |
---
## 水 · 流程(用户路径 / 漏斗 / 关键节点)
| 阶段 | B 端(采购与续约) | 关键节点 |
|------|-------------------|----------|
| 触达 | 渠道拜访、展会、同行介绍、电商技术服务页 | 资质与 **案例真实性** |
| 评估 | PoC、镜像试用、与计费/支付对接清单 | **迁移成本**、无盘 IO 与万兆网络 |
| 成交 | 软授权 + 服务器存储 + 实施人天(常见总包) | 合同 **SLA、数据归属、版本回滚** |
| 交付 | 上架、压测、镜像下发、网管培训 | **首月稳定率**决定口碑 |
| 续约 / 增值 | 活动资源、广告分成透明、特权项目(如 QQ 网吧) | **售后响应速度** |
---
## 木 · 落地(产品形态 / 功能清单 / MVP 边界)
| 模块 | 能力要点(归纳) | MVP 边界提示 |
|------|------------------|----------------|
| 系统与镜像 | 操作系统管理、有盘/无盘、混合部署、还原 | 与客户现有 **计费账号体系** 对齐 |
| 内容与更新 | 游戏与补丁分发、缓存策略(视版本) | **版权与版号** 责任边界写清 |
| 安全与管控 | 安全策略、审计、行业共性防盗链思路 | 可与第三方安全 **并存** |
| 移动协同 | **易乐游手机助手**(应用/影音等分发) | 增量场景,非网吧主路径必需 |
公开入口:[易乐游官网](https://www.yileyoo.com/) · 客服 **400-8858-580**(官网公示)。
---
## 火 · 分析(份额口径 / 竞品 / 复盘指标)
**多口径并列(不可混加)**
| 指标表述 | 数值 | 来源类型 |
|----------|------|----------|
| 盛天产品页「中国网吧市场份额」 | **>38%** | 企业自述 |
| 易乐游官网「网吧家数」 | **近 5 万家** | 企业自述 |
| 研报常见回溯「综合娱乐管理平台」 | **30.7%**(约 2019 语境) | 二级市场整理,须回年报原页 |
| 招股书区间(整理) | **29.5% → 37.7%**(约 20112013 | 须以深交所 PDF 核数 |
| 顺网媒体稿「游戏更新软件」覆盖率(历史) | **47.6%**2009 语境) | **子赛道 ≠ 全平台** |
**竞品提示**:顺网 **网维大师**、**万象/Pubwin**(计费)、**云更新**、区域无盘方案商。
**建议自建指标**:活跃终端、镜像版本覆盖率、故障 MTTR、续费率、丢标原因标签。
---
## 土 · 资源(技术 / 人力 / 合作)
| 类型 | 要点 |
|------|------|
| **硬资源** | 服务器、存储、万兆/2.5G 交换、带宽与缓存 |
| **人力** | 实施网工、驻场、400 客服与区域代理体系 |
| **生态合作(公开)** | **腾讯游戏人生** — 2026 年 **QQ 网吧分区域拓展运营项目(一期)官方合作渠道** 公示,盛天入选;负责部分区域 **安装覆盖、官方直连及运营**(以公示正文为准) |
| **游戏厂商** | 推广、联运、广告 — **具体客户与合同****300494 定期报告 / 巨潮** 披露为准 |
盛天联系(官网公开):武汉市东湖新技术开发区光谷大道 77 号金融港 B7 栋 911 楼;电话 **027-86655050**
---
## 风险与信息缺口
- 传统网吧终端若 **结构性缩量**,增量依赖 **替换、电竞酒店、增值**
- 份额数字用于投融资前建议 **第三方或抽样** 复核口径。
- 集团另有游戏、IP 等业务,读「易乐游」勿用 **整体营收** 替代 **单产品线** 健康度。
---
## 参考链接
- <https://www.yileyoo.com/>
- <https://www.shengtian.com/Product/platform>
- <https://www.shengtian.com/News/newsInfo/nid/475>QQ 网吧合作方新闻2026-04-01
---
*本 Markdown 与同源 HTML 由卡若 AI 整理;不构成投资建议或采购承诺。*

View File

@@ -0,0 +1,615 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN" xml:lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="dcterms.date" content="2026-04-13" />
<title>网吧计费/网管系统竞品对比与玩值电竞融合度</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
<link rel="stylesheet" href="_html_assets/karuo_report.css" />
</head>
<body>
<header id="title-block-header">
<h1 class="title">网吧计费/网管系统竞品对比与玩值电竞融合度</h1>
<p class="date">2026-04-13</p>
</header>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#网吧计费-网管系统竞品对比与玩值电竞融合度"
id="toc-网吧计费-网管系统竞品对比与玩值电竞融合度">网吧计费 /
网管系统竞品对比与玩值电竞融合度</a>
<ul>
<li><a href="#金-目标人群-流量-定位" id="toc-金-目标人群-流量-定位">金 ·
目标(人群 / 流量 / 定位)</a>
<ul>
<li><a href="#调研目标与边界"
id="toc-调研目标与边界">调研目标与边界</a></li>
<li><a href="#各厂金层一句话定位"
id="toc-各厂金层一句话定位">各厂「金」层一句话定位</a></li>
</ul></li>
<li><a href="#水-流程用户路径-漏斗-关键节点"
id="toc-水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</a>
<ul>
<li><a href="#b-端采购与落地漏斗共性" id="toc-b-端采购与落地漏斗共性">B
端采购与落地漏斗(共性)</a></li>
<li><a href="#与玩值电竞用户动线对照"
id="toc-与玩值电竞用户动线对照">与玩值电竞用户动线(对照)</a></li>
</ul></li>
<li><a href="#木-落地产品形态-功能清单-mvp-边界"
id="toc-木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</a>
<ul>
<li><a href="#功能域对比公开能力归纳"
id="toc-功能域对比公开能力归纳">功能域对比(公开能力归纳)</a></li>
<li><a href="#与玩值对接的-mvp-边界木层约束"
id="toc-与玩值对接的-mvp-边界木层约束">与玩值对接的 MVP
边界(木层约束)</a></li>
</ul></li>
<li><a href="#火-分析数据-指标-复盘与追踪"
id="toc-火-分析数据-指标-复盘与追踪">火 · 分析(数据 / 指标 /
复盘与追踪)</a>
<ul>
<li><a href="#市场占有率可追溯口径"
id="toc-市场占有率可追溯口径">市场占有率(可追溯口径)</a></li>
<li><a href="#优劣对比决策用火侧结论"
id="toc-优劣对比决策用火侧结论">优劣对比(决策用「火」侧结论)</a></li>
<li><a href="#与玩值电竞融合度火-度量表"
id="toc-与玩值电竞融合度火-度量表">与玩值电竞融合度(火 ·
度量表)</a></li>
</ul></li>
<li><a href="#土-资源技术-人力-预算与合作"
id="toc-土-资源技术-人力-预算与合作">土 · 资源(技术 / 人力 /
预算与合作)</a>
<ul>
<li><a href="#厂商侧资源印象定性"
id="toc-厂商侧资源印象定性">厂商侧资源印象(定性)</a></li>
<li><a href="#玩值侧资源建议土"
id="toc-玩值侧资源建议土">玩值侧资源建议(土)</a></li>
<li><a href="#追踪结果清单土-火-收口"
id="toc-追踪结果清单土-火-收口">追踪结果清单(土 + 火 收口)</a></li>
</ul></li>
</ul></li>
</ul>
</nav>
<h1 id="网吧计费-网管系统竞品对比与玩值电竞融合度">网吧计费 /
网管系统竞品对比与玩值电竞融合度</h1>
<blockquote>
<p><strong>结构说明</strong>:按 <strong>卡若五行营销</strong>
归类——<strong></strong> 目标与定位,<strong></strong>
路径与节点,<strong></strong> 产品落地与功能清单,<strong></strong>
数据指标与追踪复盘,<strong></strong> 资源投入与合作边界。</p>
</blockquote>
<hr />
<h2 id="金-目标人群-流量-定位">金 · 目标(人群 / 流量 / 定位)</h2>
<h3 id="调研目标与边界">调研目标与边界</h3>
<table>
<colgroup>
<col style="width: 40%" />
<col style="width: 60%" />
</colgroup>
<thead>
<tr>
<th></th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>目的</td>
<td>对比 <strong>嘟嘟牛</strong>
<strong>顺网系(万象/Pubwin/网维等)、龙管家、恒信一卡通</strong>
<strong>市占、功能、优劣</strong> 上的公开结论,并给出与
<strong>玩值电竞</strong><strong>融合度</strong>
<strong>追踪要点</strong></td>
</tr>
<tr>
<td>数据来源</td>
<td>智研咨询整理(引
<strong>中国互联网上网服务行业协会</strong>)、厂商公开介绍、百科/下载站</td>
</tr>
<tr>
<td>局限</td>
<td>计费市占以 <strong>2019 年、上网服务营业场所样本</strong>
为主;<strong>不得</strong>直接写成 2026 实时份额</td>
</tr>
<tr>
<td>主体名称</td>
<td>行业统计常写 <strong>「希之光嘟嘟牛」</strong>;与
<strong>深圳市嘟嘟牛科技有限公司</strong>
是否同一链条须工商/合同核实</td>
</tr>
</tbody>
</table>
<h3 id="各厂金层一句话定位">各厂「金」层一句话定位</h3>
<table>
<colgroup>
<col style="width: 14%" />
<col style="width: 24%" />
<col style="width: 60%" />
</colgroup>
<thead>
<tr>
<th>厂商/系</th>
<th>目标人群 / 典型客户</th>
<th>品牌与渠道叙事</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>顺网系</strong></td>
<td>中大型连锁、电竞酒店/综合体</td>
<td><strong>计费 + 网维 + 营销 + 云/电竞</strong>
全栈;渠道强调全国网吧渗透</td>
</tr>
<tr>
<td><strong>龙管家</strong></td>
<td>中大型网吧、重 C 端运营</td>
<td><strong>用户侧 App</strong>(订座、充值等)公开描述多</td>
</tr>
<tr>
<td><strong>恒信一卡通</strong></td>
<td>重实名与安全的单体/区域连锁</td>
<td><strong>计费 + 实名 + 安全限制</strong></td>
</tr>
<tr>
<td><strong>嘟嘟牛</strong></td>
<td>多业态门店(网吧为子场景之一)</td>
<td><strong>通用门店数字化 + 一卡通 + 硬件</strong></td>
</tr>
</tbody>
</table>
<hr />
<h2 id="水-流程用户路径-漏斗-关键节点">水 · 流程(用户路径 / 漏斗 /
关键节点)</h2>
<h3 id="b-端采购与落地漏斗共性">B 端采购与落地漏斗(共性)</h3>
<table>
<colgroup>
<col style="width: 20%" />
<col style="width: 33%" />
<col style="width: 46%" />
</colgroup>
<thead>
<tr>
<th>阶段</th>
<th>关键节点</th>
<th>各系差异提示</th>
</tr>
</thead>
<tbody>
<tr>
<td>触达</td>
<td>代理、同行、展会</td>
<td>顺网系 <strong>打包销售</strong> 多;嘟嘟牛
<strong>多业态案例</strong></td>
</tr>
<tr>
<td>评估</td>
<td>试用、对接支付/硬件</td>
<td>顺网 <strong>模块多</strong>、评估周期长;恒信
<strong>安全/实名</strong> 评审权重高</td>
</tr>
<tr>
<td>成交</td>
<td>模块与机具合同</td>
<td>注意 <strong>数据归属、分润、迁移条款</strong></td>
</tr>
<tr>
<td>日营</td>
<td>上机—储值—对账</td>
<td>龙管家 <strong>C 端 App</strong> 在动线上权重相对高</td>
</tr>
<tr>
<td>增值加购</td>
<td>营销、网维、云</td>
<td>顺网系 <strong>同厂组合</strong> 顺滑;其余常
<strong>异厂集成</strong></td>
</tr>
</tbody>
</table>
<h3 id="与玩值电竞用户动线对照">与玩值电竞用户动线(对照)</h3>
<p>玩值侧:<strong>注册/关注 → 看播/互动 → 打赏/礼物 →
复盘活跃</strong>(内容+交易)。与网吧 <strong>上机计费</strong>
非同一漏斗;融合点主要在
<strong>门店核销、会员映射、支付结果对齐</strong>(见
<strong></strong><strong></strong>)。</p>
<hr />
<h2 id="木-落地产品形态-功能清单-mvp-边界">木 · 落地(产品形态 /
功能清单 / MVP 边界)</h2>
<h3 id="功能域对比公开能力归纳">功能域对比(公开能力归纳)</h3>
<p>说明:<strong>○△×</strong> = 公开信息中相对强 / 视版本与插件 /
少披露或非主线。</p>
<table>
<thead>
<tr>
<th>功能域</th>
<th>顺网系</th>
<th>龙管家</th>
<th>恒信一卡通</th>
<th>嘟嘟牛</th>
</tr>
</thead>
<tbody>
<tr>
<td>PC 计费 / 上机 / 会员</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>交接班 / 报表 / 对账</td>
<td></td>
<td>△~○</td>
<td></td>
<td>△~○</td>
</tr>
<tr>
<td>移动收款 / 经营端 App</td>
<td></td>
<td>○(偏 C 端与订座等)</td>
<td></td>
<td></td>
</tr>
<tr>
<td>实名 / 合规链路</td>
<td></td>
<td>△~○</td>
<td></td>
<td>△~○</td>
</tr>
<tr>
<td>网维 / 游戏更新协同</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>营销 / 活动平台</td>
<td></td>
<td></td>
<td></td>
<td>△~○</td>
</tr>
<tr>
<td>云电脑 / 边缘算力</td>
<td></td>
<td></td>
<td>×</td>
<td></td>
</tr>
<tr>
<td>多业态(餐饮零售等)</td>
<td></td>
<td>×</td>
<td></td>
<td></td>
</tr>
<tr>
<td>开放 API 可见度(公开推断)</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<h3 id="与玩值对接的-mvp-边界木层约束">与玩值对接的 MVP
边界(木层约束)</h3>
<table>
<thead>
<tr>
<th>建议纳入 MVP</th>
<th>建议不纳入首期</th>
</tr>
</thead>
<tbody>
<tr>
<td>支付结果/订单 <strong>回调</strong>、会员 <strong>外部 ID
映射</strong></td>
<td>替换计费内核</td>
</tr>
<tr>
<td>门店维度 <strong>日汇总字段</strong> 对齐</td>
<td>未授权的内部数据表访问</td>
</tr>
<tr>
<td><strong>官方主体</strong> 核验通过的小程序/H5 跳转</td>
<td>任何灰产接口</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="火-分析数据-指标-复盘与追踪">火 · 分析(数据 / 指标 /
复盘与追踪)</h2>
<h3 id="市场占有率可追溯口径">市场占有率(可追溯口径)</h3>
<p><strong>口径</strong>2019 年中国互联网上网服务营业场所
<strong>收银计费软件</strong>(智研咨询 2021-04
整理,源:协会数据图示)。</p>
<table>
<thead>
<tr>
<th>产品/品牌</th>
<th>市占率(约)</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr>
<td>万象网管</td>
<td><strong>27.47%</strong></td>
<td>与 Pubwin 同属顺网生态常见并列统计</td>
</tr>
<tr>
<td>Pubwin</td>
<td><strong>23.20%</strong></td>
<td>实名登记细分中亦常单独出现</td>
</tr>
<tr>
<td>龙管家</td>
<td><strong>16.17%</strong></td>
<td></td>
</tr>
<tr>
<td>恒信一卡通</td>
<td><strong>8.05%</strong></td>
<td></td>
</tr>
<tr>
<td>希之光嘟嘟牛</td>
<td><strong>6.92%</strong></td>
<td>与工商主体须核对</td>
</tr>
<tr>
<td>其他8 圈、鱼人等)</td>
<td>余量</td>
<td></td>
</tr>
</tbody>
</table>
<p><strong>实名登记软件2019对照</strong>:任子行 NET100
26.80%Pubwin 22.79%,万象 12.35%Ikeeper 8.82%,过滤王
7.02%,恒信一卡通 6.35%,鱼人旺旺吧 3.77%。</p>
<p><strong>娱乐综合管理平台2019</strong>:网维大师 44.50%,易乐游
30.70%,云更新 16.10%。</p>
<p><strong>营销管理平台2019</strong>:他山 26.70%,顺网网吧管家
21.10%,领航 20.20%,其余见智研原文。</p>
<p><strong>参考链接</strong><code>https://www.chyxx.com/industry/202104/944300.html</code></p>
<h3 id="优劣对比决策用火侧结论">优劣对比(决策用「火」侧结论)</h3>
<table style="width:100%;">
<colgroup>
<col style="width: 14%" />
<col style="width: 19%" />
<col style="width: 19%" />
<col style="width: 28%" />
<col style="width: 19%" />
</colgroup>
<thead>
<tr>
<th>维度</th>
<th>顺网系</th>
<th>龙管家</th>
<th>恒信一卡通</th>
<th>嘟嘟牛</th>
</tr>
</thead>
<tbody>
<tr>
<td>规模与生态</td>
<td>优:全栈;劣:绑定深、迁移成本高</td>
<td>C 端链;劣:异厂集成多</td>
<td>优:实名安全;劣:生态宽度</td>
<td>多业态计费细分份额不占优2019</td>
</tr>
<tr>
<td>连锁精细化</td>
<td></td>
<td>优~中</td>
<td></td>
<td>中~优</td>
</tr>
<tr>
<td>单体成本</td>
<td></td>
<td></td>
<td>优~中</td>
<td></td>
</tr>
<tr>
<td>电竞综合体叙事</td>
<td></td>
<td>中~优</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<h3 id="与玩值电竞融合度火-度量表">与玩值电竞融合度(火 · 度量表)</h3>
<p>玩值侧归纳:<strong>用户/主播、直播、礼物、Mongo 业务</strong> = 内容
+ 交易 + 社区。</p>
<table style="width:100%;">
<colgroup>
<col style="width: 8%" />
<col style="width: 14%" />
<col style="width: 14%" />
<col style="width: 14%" />
<col style="width: 20%" />
<col style="width: 14%" />
<col style="width: 14%" />
</colgroup>
<thead>
<tr>
<th>对象</th>
<th>身份映射</th>
<th>支付分账</th>
<th>线下场景</th>
<th>内容赛事协同</th>
<th>开放集成</th>
<th><strong>综合</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>顺网系</td>
<td></td>
<td></td>
<td>中~高</td>
<td><strong></strong></td>
<td></td>
<td><strong>中高</strong></td>
</tr>
<tr>
<td>龙管家</td>
<td></td>
<td></td>
<td>中~高</td>
<td></td>
<td></td>
<td><strong></strong></td>
</tr>
<tr>
<td>恒信一卡通</td>
<td></td>
<td></td>
<td></td>
<td>低~中</td>
<td>中~低</td>
<td><strong>中偏低</strong></td>
</tr>
<tr>
<td>嘟嘟牛</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td><strong></strong></td>
</tr>
</tbody>
</table>
<p><strong>复盘用追踪指标(建议 12 个月一轮)</strong></p>
<table>
<colgroup>
<col style="width: 37%" />
<col style="width: 62%" />
</colgroup>
<thead>
<tr>
<th>指标</th>
<th>触发动作</th>
</tr>
</thead>
<tbody>
<tr>
<td>是否有 <strong>2024 后</strong> 同口径市占新表</td>
<td>更新 <strong></strong> 节全部百分比或声明「仍以 2019 为锚」</td>
</tr>
<tr>
<td>顺网年报「网吧/电竞酒店」产品名与收入段</td>
<td>调整 <strong></strong> 层叙事与 <strong></strong>
层模块清单</td>
</tr>
<tr>
<td>厂商 <strong>ISV/API 白皮书</strong> 版本号</td>
<td>调整 <strong>木/土</strong> 对接工期假设</td>
</tr>
<tr>
<td>玩值 POC<strong>1 店·会员映射 + 1 条支付回调</strong></td>
<td>验证 <strong></strong> 漏斗是否 worth 扩大投入</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="土-资源技术-人力-预算与合作">土 · 资源(技术 / 人力 /
预算与合作)</h2>
<h3 id="厂商侧资源印象定性">厂商侧资源印象(定性)</h3>
<table>
<colgroup>
<col style="width: 20%" />
<col style="width: 36%" />
<col style="width: 43%" />
</colgroup>
<thead>
<tr>
<th>厂商/系</th>
<th>实施与渠道资源</th>
<th>对玩值的「土」含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>顺网系</td>
<td>全国渠道、模块多、商务链条长</td>
<td><strong>预算与周期上浮</strong>;换来
<strong>叙事与纵深</strong></td>
</tr>
<tr>
<td>龙管家</td>
<td>C 端与门店工具成熟</td>
<td>中等实施资源;需自研 <strong>桥接层</strong></td>
</tr>
<tr>
<td>恒信一卡通</td>
<td>偏区域与合规实施</td>
<td>安全审计成本可能 <strong>前置</strong></td>
</tr>
<tr>
<td>嘟嘟牛</td>
<td>多业态代理网络</td>
<td><strong>非网咖</strong> 门店并联时 <strong></strong>
投入可能更分散</td>
</tr>
</tbody>
</table>
<h3 id="玩值侧资源建议土">玩值侧资源建议(土)</h3>
<ol type="1">
<li><strong>人力</strong>:未拿到接口清单前,<strong></strong>承诺超过
<strong>1 后端 + 0.5 产品</strong> 的常驻投入。<br />
</li>
<li><strong>预算</strong>:按 <strong>试点店数 × 接口人天 +
厂商联调费</strong> 预留;顺网系通常 <strong>商务门槛高于</strong>
嘟嘟牛/区域方案。<br />
</li>
<li><strong>合作边界</strong>:计费系统
<strong>不做玩值核心</strong>;仅作
<strong>线下门店资金与身份对齐</strong> 的外围系统。</li>
</ol>
<h3 id="追踪结果清单土-火-收口">追踪结果清单(土 + 火 收口)</h3>
<ol type="1">
<li><strong>市占</strong>:对外引用必须 <strong>年份 + 口径 +
来源</strong>;禁止把 2019 写成「现在第一」。<br />
</li>
<li><strong>选型</strong>:要 <strong>电竞综合体叙事</strong>
顺网系优先谈;要 <strong>多业态门店并联</strong> → 嘟嘟牛等更贴。<br />
</li>
<li><strong>玩值</strong>:首期 <strong>POC</strong> 锁定 <strong>会员
ID + 支付回调</strong> 即可验证 <strong>水/木</strong> 是否闭环。<br />
</li>
<li><strong>法务</strong>:「希之光嘟嘟牛」与
<strong>深圳嘟嘟牛科技</strong> 关系未核实前,合同主体
<strong>不得</strong> 混写。</li>
</ol>
<hr />
<p><em>本报告与《市场调研报告 ·
嘟嘟牛(网吧计费与管理)》并列存档;不构成采购或投资建议。</em></p>
<footer class="report-foot">卡若 · 玩值电竞App · 需求调研目录 Markdown 导出 HTML展厅风样式</footer>
</body>
</html>

View File

@@ -0,0 +1,163 @@
---
title: 网吧计费/网管系统竞品对比与玩值电竞融合度
date: 2026-04-13
scope: 公开信息 · 非商务尽调
framework: 卡若五行营销(金→水→木→火→土)
framework_ref: 卡若AI / 木果_项目模板 / 开发模板 / 参考资料 / 快速参考.md · 第六节
---
# 网吧计费 / 网管系统竞品对比与玩值电竞融合度
> **结构说明**:按 **卡若五行营销** 归类——**金** 目标与定位,**水** 路径与节点,**木** 产品落地与功能清单,**火** 数据指标与追踪复盘,**土** 资源投入与合作边界。
---
## 金 · 目标(人群 / 流量 / 定位)
### 调研目标与边界
| 项 | 说明 |
|----|------|
| 目的 | 对比 **嘟嘟牛****顺网系(万象/Pubwin/网维等)、龙管家、恒信一卡通****市占、功能、优劣** 上的公开结论,并给出与 **玩值电竞****融合度****追踪要点** |
| 数据来源 | 智研咨询整理(引 **中国互联网上网服务行业协会**)、厂商公开介绍、百科/下载站 |
| 局限 | 计费市占以 **2019 年、上网服务营业场所样本** 为主;**不得**直接写成 2026 实时份额 |
| 主体名称 | 行业统计常写 **「希之光嘟嘟牛」**;与 **深圳市嘟嘟牛科技有限公司** 是否同一链条须工商/合同核实 |
### 各厂「金」层一句话定位
| 厂商/系 | 目标人群 / 典型客户 | 品牌与渠道叙事 |
| --------- | --------------- | ------------------------------------- |
| **顺网系** | 中大型连锁、电竞酒店/综合体 | **计费 + 网维 + 营销 + 云/电竞** 全栈;渠道强调全国网吧渗透 |
| **龙管家** | 中大型网吧、重 C 端运营 | **用户侧 App**(订座、充值等)公开描述多 |
| **恒信一卡通** | 重实名与安全的单体/区域连锁 | **计费 + 实名 + 安全限制** |
| **嘟嘟牛** | 多业态门店(网吧为子场景之一) | **通用门店数字化 + 一卡通 + 硬件** |
---
## 水 · 流程(用户路径 / 漏斗 / 关键节点)
### B 端采购与落地漏斗(共性)
| 阶段 | 关键节点 | 各系差异提示 |
|------|----------|--------------|
| 触达 | 代理、同行、展会 | 顺网系 **打包销售** 多;嘟嘟牛 **多业态案例** 多 |
| 评估 | 试用、对接支付/硬件 | 顺网 **模块多**、评估周期长;恒信 **安全/实名** 评审权重高 |
| 成交 | 模块与机具合同 | 注意 **数据归属、分润、迁移条款** |
| 日营 | 上机—储值—对账 | 龙管家 **C 端 App** 在动线上权重相对高 |
| 增值加购 | 营销、网维、云 | 顺网系 **同厂组合** 顺滑;其余常 **异厂集成** |
### 与玩值电竞用户动线(对照)
玩值侧:**注册/关注 → 看播/互动 → 打赏/礼物 → 复盘活跃**(内容+交易)。与网吧 **上机计费** 非同一漏斗;融合点主要在 **门店核销、会员映射、支付结果对齐**(见 **木**、**土**)。
---
## 木 · 落地(产品形态 / 功能清单 / MVP 边界)
### 功能域对比(公开能力归纳)
说明:**○△×** = 公开信息中相对强 / 视版本与插件 / 少披露或非主线。
| 功能域 | 顺网系 | 龙管家 | 恒信一卡通 | 嘟嘟牛 |
|--------|--------|--------|------------|--------|
| PC 计费 / 上机 / 会员 | ○ | ○ | ○ | ○ |
| 交接班 / 报表 / 对账 | ○ | △~○ | ○ | △~○ |
| 移动收款 / 经营端 App | ○ | ○(偏 C 端与订座等) | △ | ○ |
| 实名 / 合规链路 | ○ | △~○ | ○ | △~○ |
| 网维 / 游戏更新协同 | ○ | △ | △ | △ |
| 营销 / 活动平台 | ○ | △ | △ | △~○ |
| 云电脑 / 边缘算力 | ○ | △ | × | △ |
| 多业态(餐饮零售等) | △ | × | △ | ○ |
| 开放 API 可见度(公开推断) | △ | △ | △ | △ |
### 与玩值对接的 MVP 边界(木层约束)
| 建议纳入 MVP | 建议不纳入首期 |
|----------------|------------------|
| 支付结果/订单 **回调**、会员 **外部 ID 映射** | 替换计费内核 |
| 门店维度 **日汇总字段** 对齐 | 未授权的内部数据表访问 |
| **官方主体** 核验通过的小程序/H5 跳转 | 任何灰产接口 |
---
## 火 · 分析(数据 / 指标 / 复盘与追踪)
### 市场占有率(可追溯口径)
**口径**2019 年中国互联网上网服务营业场所 **收银计费软件**(智研咨询 2021-04 整理,源:协会数据图示)。
| 产品/品牌 | 市占率(约) | 备注 |
|-----------|-------------|------|
| 万象网管 | **27.47%** | 与 Pubwin 同属顺网生态常见并列统计 |
| Pubwin | **23.20%** | 实名登记细分中亦常单独出现 |
| 龙管家 | **16.17%** | |
| 恒信一卡通 | **8.05%** | |
| 希之光嘟嘟牛 | **6.92%** | 与工商主体须核对 |
| 其他8 圈、鱼人等) | 余量 | |
**实名登记软件2019对照**:任子行 NET100 26.80%Pubwin 22.79%,万象 12.35%Ikeeper 8.82%,过滤王 7.02%,恒信一卡通 6.35%,鱼人旺旺吧 3.77%。
**娱乐综合管理平台2019**:网维大师 44.50%,易乐游 30.70%,云更新 16.10%。
**营销管理平台2019**:他山 26.70%,顺网网吧管家 21.10%,领航 20.20%,其余见智研原文。
**参考链接**`https://www.chyxx.com/industry/202104/944300.html`
### 优劣对比(决策用「火」侧结论)
| 维度 | 顺网系 | 龙管家 | 恒信一卡通 | 嘟嘟牛 |
|------|--------|--------|------------|--------|
| 规模与生态 | 优:全栈;劣:绑定深、迁移成本高 | 优C 端链;劣:异厂集成多 | 优:实名安全;劣:生态宽度 | 优多业态计费细分份额不占优2019 |
| 连锁精细化 | 优 | 优~中 | 中 | 中~优 |
| 单体成本 | 中 | 中 | 优~中 | 中 |
| 电竞综合体叙事 | 优 | 中~优 | 中 | 中 |
### 与玩值电竞融合度(火 · 度量表)
玩值侧归纳:**用户/主播、直播、礼物、Mongo 业务** = 内容 + 交易 + 社区。
| 对象 | 身份映射 | 支付分账 | 线下场景 | 内容赛事协同 | 开放集成 | **综合** |
|------|----------|----------|----------|--------------|----------|----------|
| 顺网系 | 中 | 中 | 中~高 | **高** | 中 | **中高** |
| 龙管家 | 中 | 中 | 中~高 | 中 | 中 | **中** |
| 恒信一卡通 | 中 | 中 | 中 | 低~中 | 中~低 | **中偏低** |
| 嘟嘟牛 | 中 | 中 | 中 | 中 | 中 | **中** |
**复盘用追踪指标(建议 12 个月一轮)**
| 指标 | 触发动作 |
|------|----------|
| 是否有 **2024 后** 同口径市占新表 | 更新 **火** 节全部百分比或声明「仍以 2019 为锚」 |
| 顺网年报「网吧/电竞酒店」产品名与收入段 | 调整 **金** 层叙事与 **木** 层模块清单 |
| 厂商 **ISV/API 白皮书** 版本号 | 调整 **木/土** 对接工期假设 |
| 玩值 POC**1 店·会员映射 + 1 条支付回调** | 验证 **水** 漏斗是否 worth 扩大投入 |
---
## 土 · 资源(技术 / 人力 / 预算与合作)
### 厂商侧资源印象(定性)
| 厂商/系 | 实施与渠道资源 | 对玩值的「土」含义 |
|---------|----------------|-------------------|
| 顺网系 | 全国渠道、模块多、商务链条长 | **预算与周期上浮**;换来 **叙事与纵深** |
| 龙管家 | C 端与门店工具成熟 | 中等实施资源;需自研 **桥接层** |
| 恒信一卡通 | 偏区域与合规实施 | 安全审计成本可能 **前置** |
| 嘟嘟牛 | 多业态代理网络 | **非网咖** 门店并联时 **土** 投入可能更分散 |
### 玩值侧资源建议(土)
1. **人力**:未拿到接口清单前,**勿**承诺超过 **1 后端 + 0.5 产品** 的常驻投入。
2. **预算**:按 **试点店数 × 接口人天 + 厂商联调费** 预留;顺网系通常 **商务门槛高于** 嘟嘟牛/区域方案。
3. **合作边界**:计费系统 **不做玩值核心**;仅作 **线下门店资金与身份对齐** 的外围系统。
### 追踪结果清单(土 + 火 收口)
1. **市占**:对外引用必须 **年份 + 口径 + 来源**;禁止把 2019 写成「现在第一」。
2. **选型**:要 **电竞综合体叙事** → 顺网系优先谈;要 **多业态门店并联** → 嘟嘟牛等更贴。
3. **玩值**:首期 **POC** 锁定 **会员 ID + 支付回调** 即可验证 **水/木** 是否闭环。
4. **法务**:「希之光嘟嘟牛」与 **深圳嘟嘟牛科技** 关系未核实前,合同主体 **不得** 混写。
---
*本报告与《市场调研报告 · 嘟嘟牛(网吧计费与管理)》并列存档;不构成采购或投资建议。*

View File

@@ -234,6 +234,49 @@ userAssets
- `follows``fromUserId + toUserId`(唯一约束)
- `footprints``userId + createdAt``bizType + bizId`
## 直播扩展集合(与 `lib/db/mongo/collections.ts` 一致)
以下为 Next API 已使用的字段约定;索引以 `pnpm db:mongo:seed` / `ensureIndexes` 为准。
### liveRooms
- `_id`
- `streamerId`ObjectId
- `title``game``status`(如 `live` / `ended` / `offline`)、`roomMode`
- `pullPlaybackUrl`HLS/mp4 拉流预览)
- `viewerCount``guildId``createdAt``updatedAt`
### liveSessions
- `_id`
- `streamerId``status``active` / `ended`
- `sessionKey`(与云推流对账占位)、`startedAt``endedAt``clientHint`
### liveShowcaseItems小黄车
- `streamerId``title``subtitle``landingPath``refQuery`(归因 query
- `sortOrder``status``active`
### liveComments公屏HTTP 轮询 / 后续 WS 同源)
- `streamerId``liveRoomId`(可空)、`userId``userName``content``type``normal` | `enter` | `gift``gift`/`system` 仅服务端写入)
- `createdAt`
### liveGifts
- `streamerId``liveRoomId``liveSessionId``userId``userName`
- `giftId``giftName``icon``amountCoins``orderNo``createdAt`
### commissionLedger分润台账只追加
- `sourceType`(如 `live_gift`)、`sourceId`(关联 `liveGifts._id`
- `streamerId``guildId`(可空)、`beneficiaryType``platform` | `guild` | `streamer`
- `amountCoins``orderNo``liveSessionId``createdAt`
### transactions 扩展字段(直播送礼)
- 在原有 `userId``orderNo``type``amount``remark``createdAt` 基础上,可附带:`streamerId``liveRoomId``liveSessionId`(字符串或 null`type = live_gift`
## 迁移与兼容
- 当前前端的演示数据来源于本地 `localStorage`(见 `components/providers/app-provider.tsx:75``components/providers/app-provider.tsx:78`)。