feat: realistic face avatar + voice input + ASR endpoint

This commit is contained in:
yuzhiran
2026-06-12 15:32:04 +08:00
parent 6fe84b6ef8
commit 8191cf4b41
26 changed files with 1934 additions and 228 deletions
@@ -0,0 +1,69 @@
import { Controller, Get, Post, Body, Param, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { ShareService } from './share.service'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { Public } from '../../common/decorators/public.decorator'
@Controller('share')
export class ShareController {
constructor(
private shareService: ShareService,
private jwtService: JwtService,
) {}
@UseGuards(JwtAuthGuard)
@Post('create')
async create(
@CurrentUser('userId') userId: string,
@Body() body: { type: string; refId?: string; title?: string; description?: string },
) {
return this.shareService.create(userId, body)
}
@Public()
@Get('visit/:shareCode')
async visit(
@Param('shareCode') shareCode: string,
@Query('visitorId') visitorId?: string,
@Query('token') token?: string,
) {
if (!visitorId) {
throw new HttpException('缺少访问者标识', HttpStatus.BAD_REQUEST)
}
let visitorUserId: string | undefined
if (token) {
try {
const payload = this.jwtService.verify(token) as any
visitorUserId = payload.userId
} catch {}
}
return this.shareService.visit(shareCode, visitorId, visitorUserId)
}
@UseGuards(JwtAuthGuard)
@Get('stats')
async stats(@CurrentUser('userId') userId: string) {
return this.shareService.stats(userId)
}
@UseGuards(JwtAuthGuard)
@Get('records')
async records(
@CurrentUser('userId') userId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.shareService.records(userId, Number(page) || 1, Number(pageSize) || 20)
}
@UseGuards(JwtAuthGuard)
@Get('visitors')
async visitors(
@CurrentUser('userId') userId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.shareService.visitors(userId, Number(page) || 1, Number(pageSize) || 20)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { MongooseModule } from '@nestjs/mongoose'
import { ShareController } from './share.controller'
import { ShareService } from './share.service'
import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from './share.schema'
import { UserModule } from '../user/user.module'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: ShareRecord.name, schema: ShareRecordSchema },
{ name: ShareVisit.name, schema: ShareVisitSchema },
{ name: User.name, schema: UserSchema },
]),
UserModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '7d' },
}),
],
controllers: [ShareController],
providers: [ShareService],
})
export class ShareModule {}
+72
View File
@@ -0,0 +1,72 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
export type ShareRecordDocument = ShareRecord & Document
export type ShareVisitDocument = ShareVisit & Document
@Schema({ timestamps: true })
export class ShareRecord {
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
userId: Types.ObjectId
@Prop({ required: true, unique: true })
shareCode: string
@Prop({ default: 'app' })
type: string
@Prop({ default: '' })
refId: string
@Prop({ default: '' })
title: string
@Prop({ default: '' })
description: string
@Prop({ default: 'link' })
channel: string
@Prop({ default: 0 })
visitCount: number
@Prop({ default: 0 })
creditedCount: number
@Prop({ default: true })
isActive: boolean
readonly createdAt?: Date
readonly updatedAt?: Date
}
@Schema({ timestamps: true })
export class ShareVisit {
@Prop({ type: Types.ObjectId, ref: 'ShareRecord', required: true })
shareId: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
sharerId: Types.ObjectId
@Prop({ required: true })
visitorId: string
@Prop({ type: Types.ObjectId, ref: 'User' })
visitorUserId: Types.ObjectId
@Prop({ default: false })
credited: boolean
@Prop()
creditedAt?: Date
readonly createdAt?: Date
readonly updatedAt?: Date
}
export const ShareRecordSchema = SchemaFactory.createForClass(ShareRecord)
export const ShareVisitSchema = SchemaFactory.createForClass(ShareVisit)
ShareRecordSchema.index({ userId: 1, createdAt: -1 })
ShareVisitSchema.index({ shareId: 1, visitorId: 1 }, { unique: true })
ShareVisitSchema.index({ sharerId: 1, createdAt: -1 })
+186
View File
@@ -0,0 +1,186 @@
import * as crypto from 'crypto'
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from './share.schema'
import { QuotaService } from '../user/quota.service'
import { User, UserDocument } from '../user/user.schema'
const DAILY_LIMIT = 3
const LINK_TTL_DAYS = 30
@Injectable()
export class ShareService {
private readonly logger = new Logger(ShareService.name)
constructor(
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
@InjectModel(ShareVisit.name) private visitModel: Model<ShareVisitDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
private quotaService: QuotaService,
) {}
async create(userId: string, body: { type: string; refId?: string; title?: string; description?: string }) {
const shareCode = crypto.randomBytes(4).toString('hex')
const record = await this.shareModel.create({
userId: new Types.ObjectId(userId),
shareCode,
type: body.type || 'app',
refId: body.refId || '',
title: body.title || '我在职引发现了好东西',
description: body.description || '快来一起体验吧',
})
return {
shareCode: record.shareCode,
shareUrl: `/share/${record.shareCode}`,
wechatShareInfo: {
title: record.title,
description: record.description,
path: `/pages/share/share?code=${record.shareCode}`,
},
}
}
async visit(shareCode: string, visitorId: string, visitorUserId?: string) {
const share = await this.shareModel.findOne({ shareCode, isActive: true }).exec()
if (!share) throw new HttpException('分享链接不存在或已失效', HttpStatus.NOT_FOUND)
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { visitCount: 1 } }).exec()
const sharerIdStr = share.userId.toString()
if (!visitorUserId || visitorUserId === sharerIdStr) {
return { sharer: true, visitorUserId }
}
const existing = await this.visitModel.findOne({
shareId: share._id,
visitorId,
}).exec()
if (existing) return { alreadyVisited: true, visitorUserId }
await this.visitModel.create({
shareId: share._id,
sharerId: share.userId,
visitorId,
visitorUserId: new Types.ObjectId(visitorUserId),
}).catch(() => {})
const alreadyCredited = await this.visitModel.findOne({
shareId: share._id,
visitorId,
credited: true,
}).exec()
if (alreadyCredited) return { credited: true, visitorUserId }
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayCredited = await this.visitModel.countDocuments({
sharerId: share.userId,
credited: true,
creditedAt: { $gte: todayStart },
}).exec()
if (todayCredited >= DAILY_LIMIT) return { dailyLimitReached: true, visitorUserId }
const shareCreditsResult = await this.quotaService.grantShareCredits(sharerIdStr)
if (!shareCreditsResult) return { creditFailed: true, visitorUserId }
await this.visitModel.updateOne(
{ shareId: share._id, visitorId },
{ $set: { credited: true, creditedAt: new Date() } },
).exec()
await this.shareModel.findByIdAndUpdate(share._id, { $inc: { creditedCount: 1 } }).exec()
return { credited: true, visitorUserId }
}
async stats(userId: string) {
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const [totalShares, visitAgg, todayAgg, user] = await Promise.all([
this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec(),
this.visitModel.aggregate([
{ $match: { sharerId: new Types.ObjectId(userId) } },
{
$group: {
_id: null,
totalVisits: { $sum: 1 },
creditedCount: { $sum: { $cond: ['$credited', 1, 0] } },
},
},
]).exec(),
this.visitModel.countDocuments({
sharerId: new Types.ObjectId(userId),
credited: true,
creditedAt: { $gte: todayStart },
}).exec(),
this.userModel.findById(userId).exec(),
])
return {
totalShares,
totalVisits: visitAgg[0]?.totalVisits ?? 0,
creditedCount: visitAgg[0]?.creditedCount ?? 0,
todayCredited: todayAgg,
shareCredits: user?.shareCredits ?? 0,
}
}
async records(userId: string, page = 1, pageSize = 20) {
const list = await this.shareModel.find({ userId: new Types.ObjectId(userId) })
.sort({ createdAt: -1 })
.skip((page - 1) * pageSize)
.limit(pageSize)
.exec()
const total = await this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec()
return {
list: list.map(r => ({
shareCode: r.shareCode,
type: r.type,
title: r.title,
visitCount: r.visitCount,
creditedCount: r.creditedCount,
createdAt: r.createdAt,
})),
total,
page,
pageSize,
}
}
async visitors(userId: string, page = 1, pageSize = 20) {
const [list, total] = await Promise.all([
this.visitModel.aggregate([
{ $match: { sharerId: new Types.ObjectId(userId) } },
{ $sort: { createdAt: -1 } },
{ $skip: (page - 1) * pageSize },
{ $limit: pageSize },
{
$lookup: {
from: 'users',
localField: 'visitorUserId',
foreignField: '_id',
as: 'visitorUser',
},
},
{ $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } },
{
$project: {
_id: 0,
visitorId: 1,
credited: 1,
creditedAt: 1,
createdAt: 1,
nickname: { $ifNull: ['$visitorUser.nickname', '匿名用户'] },
avatar: { $ifNull: ['$visitorUser.avatar', ''] },
},
},
]).exec(),
this.visitModel.countDocuments({ sharerId: new Types.ObjectId(userId) }).exec(),
])
return { list, total, page, pageSize }
}
}