diff --git a/.env.example b/.env.example index 9b4d7cd..96aae1d 100644 --- a/.env.example +++ b/.env.example @@ -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/ diff --git a/README.md b/README.md index 5d6a9b1..5e89f0f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/api/live/comments/[streamerId]/route.ts b/app/api/live/comments/[streamerId]/route.ts new file mode 100644 index 0000000..00cffab --- /dev/null +++ b/app/api/live/comments/[streamerId]/route.ts @@ -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 }) + } +} diff --git a/app/api/live/context/[streamerId]/route.ts b/app/api/live/context/[streamerId]/route.ts new file mode 100644 index 0000000..f983631 --- /dev/null +++ b/app/api/live/context/[streamerId]/route.ts @@ -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 }) + } +} diff --git a/app/api/live/gift/route.ts b/app/api/live/gift/route.ts new file mode 100644 index 0000000..9edf27c --- /dev/null +++ b/app/api/live/gift/route.ts @@ -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) +} diff --git a/app/api/streamer/live/cloud/route.ts b/app/api/streamer/live/cloud/route.ts new file mode 100644 index 0000000..9e1e29c --- /dev/null +++ b/app/api/streamer/live/cloud/route.ts @@ -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 }) + } +} diff --git a/app/api/streamer/live/route.ts b/app/api/streamer/live/route.ts new file mode 100644 index 0000000..f6ae353 --- /dev/null +++ b/app/api/streamer/live/route.ts @@ -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 }) + } +} diff --git a/app/api/streamer/me/route.ts b/app/api/streamer/me/route.ts new file mode 100644 index 0000000..64bd670 --- /dev/null +++ b/app/api/streamer/me/route.ts @@ -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 }) + } +} diff --git a/app/live/[id]/page.tsx b/app/live/[id]/page.tsx index d4a753b..5eb594a 100644 --- a/app/live/[id]/page.tsx +++ b/app/live/[id]/page.tsx @@ -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(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([]) + const enterPostedRef = useRef(false) - const gameBackgrounds: Record = { - "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 (
- {/* Background Live Stream */} + {/* Background Live Stream:有拉流地址时优先 HTML5 预览(mp4 兼容性最好;m3u8 后续可接 hls.js) */}
- Live -
+ {playUrl ? ( + + ) : ( + Live + )} +
@@ -189,7 +346,9 @@ export default function LiveRoomPage({ params }: { params: Promise<{ id: string

{displayName}

-

12.5w观看 · {displayGame}

+

+ {ctxLoading ? "…" : viewerLabel} · {displayGame} +

+ + + + + + + + + 直播间橱窗 + + +

+ 成交携带主播归因参数;工会分成以签约与后台为准。 +

+
+ {(liveCtx?.showcase?.length ? liveCtx.showcase : []).length === 0 ? ( +

暂无橱窗商品,请稍后重试或联系运营配置。

+ ) : ( + (liveCtx?.showcase ?? []).map((item) => ( + + )) + )} +
+
+
+ +

主播工作台

+
+
+
+ +

+ 当前账号未绑定「已审核」主播档案。请先在管理端完成主播入驻与审核,并确保 MongoDB{" "} + streamers.userId 与您的用户{" "} + _id 一致。 +

+
+ +
+ + ) + } + + return ( +
+
+
+ +

主播工作台

+
+ {self.isLive ? ( + + + 直播中 + + ) : ( + 未开播 + )} +
+ +

+ 在此将账号标记为「直播中」并维护房间信息。下方支持腾讯云(优先)、阿里云、七牛云、火山占位与自定义 + RTMP;OBS 推流后把播放 HLS填进拉流预览即可开播。 +

+ +
+
+ + setTitle(e.target.value)} placeholder="展示在直播广场与房间内" /> +
+
+ + setPullUrl(e.target.value)} + placeholder="https://…/xxx.m3u8 或 mp4(腾讯云转码后播放域名)" + /> +
+ +
+
+ + 云厂商推流 / 播放(服务端签名) +
+

+ StreamName 规则:wanzhi_<主播MongoId> + ,与 OBS 中「串流密钥」里的流名一致即可。密钥仅放服务端 .env,勿提交仓库。 +

+
+ + + + +
+
+
+ + +
+
+ + setCloudTtl(Number(e.target.value) || 7200)} + /> +
+
+ + + {cloudResult ? ( +
+
+ 状态 + + {cloudResult.configured ? "已配置密钥" : "待配置 .env"} + +
+
+ 过期 + {cloudResult.expiresAt} +
+ {cloudResult.setupHint ? ( +

{cloudResult.setupHint}

+ ) : null} + {cloudResult.push?.rtmp ? ( +
+
+ RTMP 推流 + +
+

{cloudResult.push.rtmp}

+ {cloudResult.push.note ?

{cloudResult.push.note}

: null} +
+ ) : null} + {cloudResult.play?.hls ? ( +
+
+ HLS 播放 +
+ + +
+
+

{cloudResult.play.hls}

+
+ ) : null} + {cloudResult.play?.flv ? ( +
+
+ HTTP-FLV(可选) + +
+

{cloudResult.play.flv}

+
+ ) : null} + {cloudResult.play?.note && !cloudResult.play?.hls ? ( +

{cloudResult.play.note}

+ ) : null} + {cloudResult.docs?.length ? ( +
+ {cloudResult.docs.map((d) => ( + + {d.label} + + ))} +
+ ) : null} +
+ ) : null} +
+ +
+ + +
+ +
+
+ 去我的直播间 + + + +
+ +
+ +
+

OBS 说明(旧版模板)

+ {rtmpHint ? ( +

+ NEXT_PUBLIC_LIVE_RTMP_TEMPLATE:{rtmpHint} +

+ ) : ( +

+ 可选配置 NEXT_PUBLIC_LIVE_RTMP_TEMPLATE 作为纯展示文案;签名地址以服务端「生成推流」为准。 +

+ )} +
+
+
+ ) +} diff --git a/components/layout/dev-port-banner.tsx b/components/layout/dev-port-banner.tsx new file mode 100644 index 0000000..fe2ef88 --- /dev/null +++ b/components/layout/dev-port-banner.tsx @@ -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 ( +
+ 开发环境:本应用请访问{" "} + http://localhost:{port} + 。勿用 3000(易与其它 Node 项目如 Remotion 冲突导致 /live 404)。 +
+ ) +} diff --git a/components/layout/root-providers.tsx b/components/layout/root-providers.tsx index 3297765..bc26c2e 100644 --- a/components/layout/root-providers.tsx +++ b/components/layout/root-providers.tsx @@ -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 = ( <> + diff --git a/components/live/live-playback-video.tsx b/components/live/live-playback-video.tsx new file mode 100644 index 0000000..185a63f --- /dev/null +++ b/components/live/live-playback-video.tsx @@ -0,0 +1,67 @@ +"use client" + +import { useEffect, useRef } from "react" + +type Props = { + playUrl: string + className?: string +} + +/** + * mp4 等:原生 src;m3u8:Safari 原生 HLS,其它浏览器用 hls.js + */ +export function LivePlaybackVideo({ playUrl, className }: Props) { + const ref = useRef(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