190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
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 }
|
|
|
|
try {
|
|
await this.quotaService.grantGravity(sharerIdStr, 1)
|
|
} catch (e) {
|
|
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,
|
|
gravity: user?.gravity ?? 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 }
|
|
}
|
|
}
|