feat: realistic face avatar + voice input + ASR endpoint
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 })
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user