diff --git a/src/api.server.ts b/src/api.server.ts index 2bd6133..220f7af 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -8,6 +8,7 @@ import config from 'config/config' import { RedisClient } from './redis/RedisClient' import { GameInfoCache } from './cache/GameInfoCache' import SubscribeSchedule from './schedule/subscribe.schedule' +import RankSchedule from './schedule/rank.schedule' const zReqParserPlugin = require('plugins/zReqParser') @@ -129,13 +130,17 @@ export class ApiServer { * @private */ private setFormatSend() { - this.server.addHook('preSerialization', async (request: FastifyRequest, reply: FastifyReply, payload) => { + this.server.addHook('preSerialization', async (request: FastifyRequest, reply: FastifyReply, payload: any) => { reply.header('X-Powered-By', 'PHP/5.4.16') // @ts-ignore if (!payload.errcode) { - payload = { - errcode: 0, - data: payload, + if (payload.nt) { + delete payload.nt + } else { + payload = { + errcode: 0, + data: payload, + } } } return payload @@ -148,6 +153,7 @@ export class ApiServer { private async initSchedules() { await new SubscribeSchedule().scheduleAll() + await new RankSchedule().scheduleAll() } public async start() { diff --git a/src/cache/GameCfgCache.ts b/src/cache/GameCfgCache.ts new file mode 100644 index 0000000..0a58bc0 --- /dev/null +++ b/src/cache/GameCfgCache.ts @@ -0,0 +1,25 @@ +import { singleton } from '../decorators/singleton' + +export interface CfgInfo { + time: number + datas: any[] +} +const MAX_TIME = 5 * 60 * 1000 +@singleton +export class GameCfgCache { + private _cache: Map<[string, string], any> = new Map() + + public getCfg(gameId: string, channel: string) { + const now = Date.now() + let info = this._cache.get([gameId, channel]) + if (info && now - info.time >= MAX_TIME) { + return null + } + return info?.datas + } + + public updateCfg(gameId: string, channel: string, datas: any) { + let info = { time: Date.now(), datas } + this._cache.set([gameId, channel], info) + } +} diff --git a/src/constants/BaseConsts.ts b/src/constants/BaseConsts.ts new file mode 100644 index 0000000..80094f2 --- /dev/null +++ b/src/constants/BaseConsts.ts @@ -0,0 +1 @@ +export const RANK_SCORE = 'r' diff --git a/src/controllers/rank.controller.ts b/src/controllers/rank.controller.ts new file mode 100644 index 0000000..bd60c89 --- /dev/null +++ b/src/controllers/rank.controller.ts @@ -0,0 +1,100 @@ +import BaseController from '../common/base.controller' +import { role, router } from '../decorators/router' +import { getAccountRank, getAccountScore, getRankCount, getRankList, rankKey, updateRank } from '../services/rank.svr' +import { RankUser } from '../models/RankUser' +import { fetchRankCfg, randomUsers } from '../services/jcfw.svr' +import RankSchedule from '../schedule/rank.schedule' + +class RankController extends BaseController { + @role('anon') + @router('post /api/svr/games/rank') + async reqRankList(req: any) { + let { gameId, channelId, limit, skip, accountId, all, rankType } = req.params + const { min, max, type, valType } = await fetchRankCfg(gameId, channelId) + skip = +skip || 0 + limit = +limit || 20 + all = all || type > 0 + rankType = rankType || rankKey(gameId, channelId, all) + let datas: any = await getRankList(skip, limit, rankType) + const rankList = await RankUser.parseRankList(datas) + const userInfo = await RankUser.getByAccountID(accountId) + let userRank = (await getAccountRank(accountId, rankType)) ?? 999 + let userScore = (await getAccountScore(accountId, rankType)) || 0 + let rankTotal = await getRankCount(rankType) + return { + nt: 1, + errcode: 0, + errmsg: '', + records: rankList, + total: rankTotal, + userRank, + userScore, + userTitle: userInfo?.rankTitle, + } + } + + @role('anon') + @router('post /api/svr/games/rank/update') + async reqUpdateRank(req: any) { + let { gameId, channelId, accountId, score, needType, all, rankTitle, nickname, avatar, extInfo } = req.params + const { min, max, type, valType } = await fetchRankCfg(gameId, channelId) + all = all || type > 0 + needType = needType || 0 + await fetchRankCfg(gameId, channelId) + const weekKey = rankKey(gameId, channelId, false) + let week = await updateRank(accountId, score, weekKey, valType) + const totalKey = rankKey(gameId, channelId, true) + let total = await updateRank(accountId, score, totalKey, valType) + const key = all ? totalKey : weekKey + const totalCount = all ? total.total : week.total + let userRank = (await getAccountRank(accountId, key)) as number + let userScore = (await getAccountScore(accountId, key)) as number + await RankUser.insertOrUpdate({ gameId, channelId, accountId }, { rankTitle, nickname, avatar, extInfo }) + if (!needType) { + return { nt: 1, errcode: 0, errmsg: '', userRank, userScore } + } else { + let preRecord = null + let nextRecord = null + if (userRank > 0) { + let preDatas: any = await getRankList(userRank - 1, userRank, key) + let preList = await RankUser.parseRankList(preDatas) + preRecord = preList[0] + } + if (userRank < totalCount - 1) { + let nextDatas: any = await getRankList(userRank, userRank + 1, key) + let nextList = await RankUser.parseRankList(nextDatas) + nextRecord = nextList[0] + } + return { + nt: 1, + errcode: 0, + errmsg: '', + preRecord: preRecord, + nextRecord: nextRecord, + userRank, + userScore, + userTitle: rankTitle, + over: all ? total.over : week.over, + } + } + } + + @role('anon') + @router('get /api/svr/games/rank/challenge/:score/:accountId/:count') + @router('get /api/svr/games/rank/challenge/:score/:accountId') + async challenge(req: any) { + let { score, accountId, count } = req.params + count = count || 10 + count = count > 100 ? 100 : count + const users = await randomUsers(accountId, count) + return { nt: 1, errcode: 0, errmsg: '', users: users } + } + + @role('anon') + @router('post /api/svr/games/rank/init') + async update(req: any) { + let { gameId, channelId } = req.params + await new RankSchedule().initRankData({ gameId, channelId }) + return {} + } +} diff --git a/src/models/Base.ts b/src/models/Base.ts index c77cb54..2989d2f 100644 --- a/src/models/Base.ts +++ b/src/models/Base.ts @@ -7,7 +7,7 @@ import { ObjectId } from 'bson' import { isTrue } from '../utils/string.util' import { AnyParamConstructor } from '@typegoose/typegoose/lib/types' -const jsonExcludeKeys = ['updatedAt', '__v'] +const jsonExcludeKeys = ['updatedAt', '__v', 'isBot'] const saveExcludeKeys = ['createdAt', 'updatedAt', '__v', '_id'] export abstract class BaseModule extends FindOrCreate { diff --git a/src/models/RankUser.ts b/src/models/RankUser.ts new file mode 100644 index 0000000..6094d4b --- /dev/null +++ b/src/models/RankUser.ts @@ -0,0 +1,89 @@ +import { dbconn } from '../decorators/dbconn' +import { getModelForClass, index, modelOptions, prop, ReturnModelType } from '@typegoose/typegoose' +import { BaseModule } from './Base' +import { customAlphabet } from 'nanoid' +const nanoid = customAlphabet('2345678abcdefghjkmnpqrstwxy', 32) + +const jsonExcludeKeys = ['updatedAt', '__v', '_id', 'createdAt', 'isBot', 'type'] + +/** + * 用于存储排行榜的用户信息 + * 从redis中获取accountId和积分, 然后从这里获取其他信息 + */ +@dbconn() +@index({ accountId: 1 }, { unique: true }) +@index({ gameId: 1, channelId: 1 }, { unique: false }) +@modelOptions({ + schemaOptions: { collection: 'rank_user', timestamps: true }, +}) +class RankUserClass extends BaseModule { + @prop() + accountId: string + @prop() + gameId: string + @prop() + channelId: string + @prop({ default: false }) + isBot: boolean + @prop() + nickname: string + @prop() + avatar: string + + @prop() + rankTitle: string + @prop() + extInfo: string + /** + * @type {number} 0: 只在总榜有, 1: 在周榜和总榜都有 + */ + @prop({ default: 1 }) + type: number + + public static async getByAccountID(this: ReturnModelType, accountId: string) { + let records = await this.find({ accountId }).limit(1) + return records.length > 0 ? records[0] : null + } + + public static async userMapByAccountIDS(this: ReturnModelType, accountIds: string[]) { + const records = await this.find({ accountId: { $in: accountIds } }) + let map: Map = new Map() + for (let record of records) { + map.set(record.accountId, record.toJson()) + } + return map + } + + public static async parseRankList(this: ReturnModelType, datas: any[]) { + let rankList: { accountId: string; score: any }[] = [] + let accountIDS: string[] = [] + for (let i = 0, l = datas.length; i < l; i += 2) { + rankList.push({ accountId: datas[i] as string, score: datas[i + 1] << 0 }) + accountIDS.push(datas[i]) + } + const userMap = await RankUser.userMapByAccountIDS(accountIDS) + for (let d of rankList) { + if (userMap.has(d.accountId)) { + Object.assign(d, userMap.get(d.accountId)) + } + } + return rankList + } + + public static randomAccountId(gameId: string, channelId: string) { + return `${channelId}_${gameId}_${nanoid()}` + } + + public toJson(): any { + let result: any = {} + // @ts-ignore + for (let key in this._doc) { + if (jsonExcludeKeys.indexOf(key) == -1) { + result[key] = this[key] + } + } + return result + } +} + +export const RankUser = getModelForClass(RankUserClass, { existingConnection: RankUserClass.db }) diff --git a/src/schedule/rank.schedule.ts b/src/schedule/rank.schedule.ts new file mode 100644 index 0000000..e98d550 --- /dev/null +++ b/src/schedule/rank.schedule.ts @@ -0,0 +1,92 @@ +import { singleton } from '../decorators/singleton' +import * as schedule from 'node-schedule' +import logger from '../logger/logger' +import { RankUser } from '../models/RankUser' +import { delRankList, rankKey, updateRank } from '../services/rank.svr' +import { fetchRankCfg, randomUsers } from '../services/jcfw.svr' + +// 每天晚上1点更新 +const WORLD_RANK_TASK = '1 0 1 * * *' +// 总榜机器人总数 +const MAX_ROBOT_COUNT = 40 +@singleton +export default class RankSchedule { + /** + * 取出原来的机器人列表 + * 移除n个, 同时补充n个机器人 + * 补充的n个机器人更新至总榜 + */ + async initRankData({ gameId, channelId }) { + let oldList = await RankUser.find({ gameId, channelId, isBot: true, type: 1 }) + if (oldList.length > 0) { + const removeCount = Math.random2(5, 10) | 0 + let deleteCount = 0 + let tmpList = [] + for (let i = 0, l = oldList.length; i < l; i++) { + let record = oldList[i] + if (Math.random() > 0.5 && deleteCount <= removeCount) { + record.type = 0 + await record.save() + deleteCount++ + } else { + tmpList.push(record) + } + } + oldList = tmpList + } + let needCount = MAX_ROBOT_COUNT - oldList.length + try { + let users = await randomUsers('', needCount) + for (const user of users) { + let accountId = RankUser.randomAccountId(gameId, channelId) + let record = await RankUser.insertOrUpdate( + { gameId, channelId, accountId }, + { nickname: user.nickname, avatar: user.avatar_url, isBot: true, type: 1 }, + ) + oldList.push(record) + } + } catch (err) { + logger.error('error get random user', err) + } + + let weekKey = rankKey(gameId, channelId, false) + await delRankList(weekKey) + let totalKey = rankKey(gameId, channelId, true) + const { min, max, valType } = await fetchRankCfg(gameId, channelId) + for (let record of oldList) { + try { + let score = Math.random2(min, max) | 0 + await updateRank(record.accountId, score, weekKey, valType) + await updateRank(record.accountId, score, totalKey, valType) + } catch (err) { + logger.error('update exists rank error') + } + } + } + async parseAllRecord() { + logger.info('=================begin update world rank.=================') + let gameList = [] + try { + gameList = await RankUser.aggregate([ + { $match: { gameId: { $exists: true }, channelId: { $exists: true } } }, + { $group: { _id: { gameId: '$gameId', channelId: '$channelId' } } }, + ]) + } catch (err) { + logger.error('err get game list for update world rank schedule', err) + } + for (const game of gameList) { + try { + await this.initRankData({ gameId: game._id.gameId, channelId: game._id.channelId }) + } catch (err) { + logger.error(`err init game world rank, ${game}`, err) + } + } + logger.info('=================end update world rank.=================') + } + scheduleAll() { + logger.log('已添加更新世界排行榜的定时任务') + const job = schedule.scheduleJob(WORLD_RANK_TASK, async () => { + await this.parseAllRecord() + }) + } +} diff --git a/src/services/jcfw.svr.ts b/src/services/jcfw.svr.ts index 4242cf4..0634bc8 100644 --- a/src/services/jcfw.svr.ts +++ b/src/services/jcfw.svr.ts @@ -1,5 +1,7 @@ import { RedisClient } from '../redis/RedisClient' -import exp from 'constants' +import crypto from 'crypto' +import axios from 'axios' +import { GameCfgCache } from '../cache/GameCfgCache' /** * 获取某游戏的所有客户端配置 @@ -100,3 +102,71 @@ export async function getSubscribeMsg(gameId: string, channel: string, type: str } return data } + +function createSign(secretKey: string, paramStr: string, timestamp: number) { + paramStr = `${paramStr}:${timestamp}${secretKey}` + return crypto.createHash('md5').update(paramStr, 'utf8').digest('hex') +} + +/** + * 获取随机用户 + * @param {string} accountId + * @param {number} num + */ +export async function randomUsers(accountId: string, num: number) { + num = num || 10 + const excludeAccountids = accountId ? `[${accountId}]` : '[]' + const timestamp = new Date().getTime() + const url = 'https://service.kingsome.cn/webapp/index.php?c=Voodoo&a=getRobotList' + const paramStr = `exclude_accountids=${excludeAccountids}&exclude_names=[]&num=${num}` + const signStr = createSign('70e32abc60367adccaa9eb7b56ed821b', paramStr, timestamp) + const link = `${url}&${paramStr}&sign=${signStr}×tamp=${timestamp}` + const { data } = await axios.get(link) + if (data.errcode === 0) { + return data.robot_list + } else { + return [] + } +} +const RANK_MAX_KEY = 'world_rank_max' +const RANK_MIN_KEY = 'world_rank_min' +const RANK_TYPE_KEY = 'world_rank_type' +const RANK_VALTYPE_KEY = 'world_rank_valtype' +/** + * 获取世界排行榜的配置 + * @param {string} gameId + * @param {string} channel + * @return min 生成排行榜的最小值 + * @return max 生成排行榜的最大值 + * @return type 排行榜类型, 0: 周榜, 1: 总榜 + * @return valType: 排行榜值存储类型, 0: 存最大值, 1: 存最新值 + */ +export async function fetchRankCfg(gameId: string, channel: string) { + let cfgs = new GameCfgCache().getCfg(gameId, channel) + if (!cfgs) { + cfgs = await getGameSvrCfg(gameId, channel) + new GameCfgCache().updateCfg(gameId, channel, cfgs) + } + let type = 0 + let max = 1000 + let min = 500 + let valType = 0 + for (let i = 0, l = cfgs.length; i < l; i++) { + const cfg = cfgs[i] + switch (cfg.key) { + case RANK_MAX_KEY: + max = +cfg.value + break + case RANK_MIN_KEY: + min = +cfg.value + break + case RANK_TYPE_KEY: + type = +cfg.value + break + case RANK_VALTYPE_KEY: + valType = +cfg.value + break + } + } + return { min, max, type, valType } +} diff --git a/src/services/rank.svr.ts b/src/services/rank.svr.ts new file mode 100644 index 0000000..47d25d4 --- /dev/null +++ b/src/services/rank.svr.ts @@ -0,0 +1,153 @@ +import { RedisClient } from '../redis/RedisClient' +import { RANK_SCORE } from '../constants/BaseConsts' + +const MAX_TIME = 5000000000000 + +function generateRankKey(subKey?: string) { + if (subKey) { + return `${RANK_SCORE}_${subKey}` + } else { + return RANK_SCORE + } +} + +/** + * 更新排行榜数据 + * @param subKey + * @param {string} accountId + * @param {number} score + * @param valType 值存储类型, 0: 存最大值, 1: 存最新值 + */ +export async function updateRank(accountId: string, score: number, subKey: string, valType: number) { + let scoreL = parseFloat(`${score | 0}.${MAX_TIME - Date.now()}`) + const key = generateRankKey(subKey) + let scoreResult = score + if (valType === 0) { + let scoreOld = await getAccountScore(accountId, subKey) + if (score > scoreOld) { + await new RedisClient().zadd(key, scoreL, accountId) + } else { + scoreResult = scoreOld + } + } else { + await new RedisClient().zadd(key, scoreL, accountId) + } + const countTotal = (await new RedisClient().zcard(key)) as number + const countOver = (await new RedisClient().zcount(key, 0, scoreL)) as number + const over = countTotal ? countOver / countTotal : 0.99 + return { scoreReal: scoreResult, over, total: countTotal } +} + +/** + * 获取指定用户的积分 + * @param {string} accountId + * @param subKey + */ +export async function getAccountScore(accountId: string, subKey?: string) { + let score = await new RedisClient().zscore(generateRankKey(subKey), accountId) + if (score) { + return +score << 0 + } else { + return 0 + } +} + +/** + * 获取超越只 + * @param {string} accountId + * @param {number} score + * @param {string} subKey + * @return {Promise} + */ +export async function currentIdx(accountId: string, score: number, subKey?: string) { + const key = generateRankKey(subKey) + let countTotal = (await new RedisClient().zcard(key)) as number + let scoreL = parseFloat(`${score | 0}.${MAX_TIME - Date.now()}`) + let countOver = (await new RedisClient().zcount(key, 0, scoreL)) as number + return countOver / countTotal +} + +/** + * 获取指定分数段的玩家 + * @param {number} min + * @param {number} max + * @param subKey + */ +export async function usersByScore(min: number, max: number, subKey?: string) { + return await new RedisClient().zrangebyscore(generateRankKey(subKey), min, max) +} + +/** + * 获取指定用户的排名 + * @param {string} accountId + * @param subKey + */ +export async function getAccountRank(accountId: string, subKey?: string) { + return await new RedisClient().zrevrank(generateRankKey(subKey), accountId) +} + +/** + * 获取从start到end的玩家列表 + * @param {number} start + * @param {number} end + * @param subKey + */ +export async function getRankList(start: number, end: number, subKey?: string) { + return await new RedisClient().zrevrange(generateRankKey(subKey), start, end) +} + +/** + * 获取某排行榜的成员总数 + * @param {string} subKey + */ +export async function getRankCount(subKey?: string) { + return await new RedisClient().zcard(generateRankKey(subKey)) +} + +/** + * 获取帐号附近的几个记录 + * @param {string} accountId + * @param subKey + */ +export async function getRankNear(accountId: string, subKey?: string) { + let rank: any = await getAccountRank(accountId) + if (rank == null) { + return [] + } + let total: any = await new RedisClient().zcard(generateRankKey(subKey)) + let start = 0 + let count = 2 + let end = start + count + if (rank > 0 && rank < total - 1) { + start = rank - 1 + end = start + count + } else if (rank == total - 1) { + start = rank - 2 + end = total + } + start = start < 0 ? 0 : start + end = end > total - 1 ? total - 1 : end + let datas: any = await getRankList(start, end) + let results: any[] = [] + for (let i = 0, l = datas.length; i < l; i += 2) { + results.push({ + rank: start + i / 2, + accountId: datas[i], + score: datas[i + 1] << 0, + }) + } + return results +} + +/** + * 移除某个榜单 + * @param {string} subKey + * @return {Promise} + */ +export async function delRankList(subKey?: string) { + await new RedisClient().del(generateRankKey(subKey)) +} + +export function rankKey(gameId: string, channelId: string, all: boolean) { + return all ? `${gameId}_${channelId}_total` : `${gameId}_${channelId}_week` +} diff --git a/src/utils/time.util.ts b/src/utils/time.util.ts index 5761a6e..734d196 100644 --- a/src/utils/time.util.ts +++ b/src/utils/time.util.ts @@ -40,3 +40,29 @@ export function todayStart() { export function todayEnd() { return todayStart() + ONE_DAY_MILLISECOND - 1 } + +/** + * 获取本周第一天和最后一天(周一开始) + * @return {{startDay: string, endDay: string}} + */ +export function getThisWeekData() { + return weekData(0) +} + +/** + * 获取前后n周的周一和周日的日期 + * @param {number} n 0为当前周, 1为下一周, -1为上周 + * @return {{startDay: string, endDay: string}} + */ +export function weekData(n: number) { + const weekData = { startDay: '', endDay: '' } + const date = new Date() + // 上周一的日期 + date.setDate(date.getDate() + 7 * n - date.getDay() + 1) + weekData.startDay = date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + + // 上周日的日期 + date.setDate(date.getDate() + 6) + weekData.endDay = date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + return weekData +}