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, @InjectModel(ShareVisit.name) private visitModel: Model, @InjectModel(User.name) private userModel: Model, 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 } } }