增加排行榜相关接口

This commit is contained in:
zhl 2021-08-11 15:42:25 +08:00
parent 79ae8c195d
commit 12c78ab264
10 changed files with 568 additions and 6 deletions

View File

@ -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() {

25
src/cache/GameCfgCache.ts vendored Normal file
View File

@ -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)
}
}

View File

@ -0,0 +1 @@
export const RANK_SCORE = 'r'

View File

@ -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 {}
}
}

View File

@ -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 {

89
src/models/RankUser.ts Normal file
View File

@ -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<typeof RankUserClass>, accountId: string) {
let records = await this.find({ accountId }).limit(1)
return records.length > 0 ? records[0] : null
}
public static async userMapByAccountIDS(this: ReturnModelType<typeof RankUserClass>, accountIds: string[]) {
const records = await this.find({ accountId: { $in: accountIds } })
let map: Map<string, any> = new Map()
for (let record of records) {
map.set(record.accountId, record.toJson())
}
return map
}
public static async parseRankList(this: ReturnModelType<typeof RankUserClass>, 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 })

View File

@ -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()
})
}
}

View File

@ -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}&timestamp=${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 }
}

153
src/services/rank.svr.ts Normal file
View File

@ -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<number>}
*/
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<void>}
*/
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`
}

View File

@ -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
}