| import { Injectable, Logger } from '@nestjs/common'; |
| import { ConfigService } from '@nestjs/config'; |
| import { ConfigurationType } from '@server/configuration'; |
| import { statusMap } from '@server/constants'; |
| import { PrismaService } from '@server/prisma/prisma.service'; |
| import { TRPCError, initTRPC } from '@trpc/server'; |
| import Axios, { AxiosInstance } from 'axios'; |
| import dayjs from 'dayjs'; |
| import timezone from 'dayjs/plugin/timezone'; |
| import utc from 'dayjs/plugin/utc'; |
|
|
| dayjs.extend(utc); |
| dayjs.extend(timezone); |
|
|
| |
| |
| |
| const blockedAccountsMap = new Map<string, string[]>(); |
|
|
| @Injectable() |
| export class TrpcService { |
| trpc = initTRPC.create(); |
| publicProcedure = this.trpc.procedure; |
| protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => { |
| const errorMsg = (ctx as any).errorMsg; |
| if (errorMsg) { |
| throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg }); |
| } |
| return next({ ctx }); |
| }); |
| router = this.trpc.router; |
| mergeRouters = this.trpc.mergeRouters; |
| request: AxiosInstance; |
|
|
| private readonly logger = new Logger(this.constructor.name); |
|
|
| constructor( |
| private readonly prismaService: PrismaService, |
| private readonly configService: ConfigService, |
| ) { |
| const { url } = |
| this.configService.get<ConfigurationType['platform']>('platform')!; |
| this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 }); |
|
|
| this.request.interceptors.response.use( |
| (response) => { |
| return response; |
| }, |
| async (error) => { |
| this.logger.log('error: ', error); |
| const errMsg = error.response?.data?.message || ''; |
|
|
| const id = (error.config.headers as any).xid; |
| if (errMsg.includes('WeReadError401')) { |
| |
| await this.prismaService.account.update({ |
| where: { id }, |
| data: { status: statusMap.INVALID }, |
| }); |
| this.logger.error(`账号(${id})登录失效,已禁用`); |
| } else { |
| if (errMsg.includes('WeReadError400')) { |
| |
| this.logger.error( |
| `账号(${id})处理请求参数出错,可能是账号被限制导致的,打入小黑屋`, |
| ); |
| this.logger.error('WeReadError400: ', errMsg); |
| } else if (errMsg.includes('WeReadError429')) { |
| |
| this.logger.error(`账号(${id})请求频繁,打入小黑屋`); |
| } |
|
|
| const today = this.getTodayDate(); |
|
|
| const blockedAccounts = blockedAccountsMap.get(today); |
|
|
| if (Array.isArray(blockedAccounts)) { |
| blockedAccounts.push(id); |
| blockedAccountsMap.set(today, blockedAccounts); |
| } else { |
| blockedAccountsMap.set(today, [id]); |
| } |
| } |
|
|
| return Promise.reject(error); |
| }, |
| ); |
| } |
|
|
| private getTodayDate() { |
| return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD'); |
| } |
|
|
| getBlockedAccountIds() { |
| const today = this.getTodayDate(); |
| const disabledAccounts = blockedAccountsMap.get(today) || []; |
| this.logger.debug('disabledAccounts: ', disabledAccounts); |
| return disabledAccounts.filter(Boolean); |
| } |
|
|
| private async getAvailableAccount() { |
| const disabledAccounts = this.getBlockedAccountIds(); |
| const account = await this.prismaService.account.findFirst({ |
| where: { |
| status: statusMap.ENABLE, |
| NOT: { |
| id: { in: disabledAccounts }, |
| }, |
| }, |
| }); |
|
|
| if (!account) { |
| throw new Error('暂无可用读书账号!'); |
| } |
|
|
| return account; |
| } |
|
|
| async getMpArticles(mpId: string) { |
| const account = await this.getAvailableAccount(); |
|
|
| return this.request |
| .get< |
| { |
| id: string; |
| title: string; |
| picUrl: string; |
| publishTime: number; |
| }[] |
| >(`/api/platform/mps/${mpId}/articles`, { |
| headers: { |
| xid: account.id, |
| Authorization: `Bearer ${account.token}`, |
| }, |
| }) |
| .then((res) => res.data) |
| .then((res) => { |
| this.logger.log(`getMpArticles(${mpId}): ${res.length} articles`); |
| return res; |
| }); |
| } |
|
|
| async refreshMpArticlesAndUpdateFeed(mpId: string) { |
| const articles = await this.getMpArticles(mpId); |
|
|
| if (articles.length > 0) { |
| let results; |
| const { type } = |
| this.configService.get<ConfigurationType['database']>('database')!; |
| if (type === 'sqlite') { |
| |
| const inserts = articles.map(({ id, picUrl, publishTime, title }) => |
| this.prismaService.article.upsert({ |
| create: { id, mpId, picUrl, publishTime, title }, |
| update: { |
| publishTime, |
| title, |
| }, |
| where: { id }, |
| }), |
| ); |
| results = await this.prismaService.$transaction(inserts); |
| } else { |
| results = await (this.prismaService.article as any).createMany({ |
| data: articles.map(({ id, picUrl, publishTime, title }) => ({ |
| id, |
| mpId, |
| picUrl, |
| publishTime, |
| title, |
| })), |
| skipDuplicates: true, |
| }); |
| } |
|
|
| this.logger.debug('refreshMpArticlesAndUpdateFeed results: ', results); |
| } |
|
|
| await this.prismaService.feed.update({ |
| where: { id: mpId }, |
| data: { |
| syncTime: Math.floor(Date.now() / 1e3), |
| }, |
| }); |
| } |
|
|
| async getMpInfo(url: string) { |
| url = url.trim(); |
| const account = await this.getAvailableAccount(); |
|
|
| return this.request |
| .post< |
| { |
| id: string; |
| cover: string; |
| name: string; |
| intro: string; |
| updateTime: number; |
| }[] |
| >( |
| `/api/platform/wxs2mp`, |
| { url }, |
| { |
| headers: { |
| xid: account.id, |
| Authorization: `Bearer ${account.token}`, |
| }, |
| }, |
| ) |
| .then((res) => res.data); |
| } |
|
|
| async createLoginUrl() { |
| return this.request |
| .post<{ |
| uuid: string; |
| scanUrl: string; |
| }>(`/api/login/platform`) |
| .then((res) => res.data); |
| } |
|
|
| async getLoginResult(id: string) { |
| return this.request |
| .get<{ |
| message: 'waiting' | 'success'; |
| vid?: number; |
| token?: string; |
| username?: string; |
| }>(`/api/login/platform/${id}`) |
| .then((res) => res.data); |
| } |
| } |
|
|