diff --git a/.env.development b/.env.development index dd24a4e..e5b87e1 100644 --- a/.env.development +++ b/.env.development @@ -6,6 +6,6 @@ API_TOKEN_EXPIRESIN=1d DB_MAIN=mongodb://localhost/ghost-development DB_PIKACHU=mongodb://localhost/pikachu-development -REDIS=redis://10.10.4.8:6379/13 +REDIS=redis://localhost:6379 SUSU_HOST = http://154.8.214.202:8010 diff --git a/.env.production b/.env.production index 7302cf2..a6f2b88 100644 --- a/.env.production +++ b/.env.production @@ -6,6 +6,6 @@ API_TOKEN_EXPIRESIN=1d DB_MAIN=mongodb://10.10.5.6/ghost-production DB_PIKACHU=mongodb://10.10.5.6/pikachu-production -REDIS=redis://:crs-9ltb97ds:i33dkxshh@10.10.4.8:6379/13 +REDIS=redis://:crs-9ltb97ds:i33dkxshh@10.10.4.8:6379 SUSU_HOST = http://10.10.3.10:8010 diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..196accd --- /dev/null +++ b/doc/api.md @@ -0,0 +1,68 @@ + +## 一. 说明 + +所有接口均需上传sessionid + +通用返回JSON结构, 接口Response的数据结构说明只包含data部分 +``` JSON +{ + "errcode": 0, //0:成功 2: 缺少必要参数(accountid, sessionid) 4: 帐号被封, 5: 帐号未找到 100: 所有未定义的错误 + "errmsg": "", //错误描述 + "data": {}, // 数据 +} +``` + +## 二. 客户端接口列表 + +### 1. 上报消息订阅状态 + +1. Method: POST +2. URI: /api/svr/:accountid/update_subscribe +3. HOST: https://ghost.kingsome.cn + +> 说明: 该接口有2个作用 +> 1: 给wx.requestSubscribeMessage获取到的用户同意的模板id增加发送次数 +> 2: 更新某模板id的lastCheck时间, 目的是告诉服务端, 相关记录当天不需要再发送了 + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| accountid | 帐号id| + +> POST参数 + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| templateids | 模板id列表或者模板类型(jsub_xxx)列表 | +| counts | 1或0组成的数组, 须和templateids数量一致, 1: 表示增加一次发送机会, 0: 只更新lastCheck的时间 | + +3. Response: JSON + +```js +{} + +``` + +### 2. 发送订阅消息 + +1. Method: POST +2. URI: /api/svr/:accountid/send_subscribe_msg +3. HOST: https://ghost.kingsome.cn + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| accountid | 帐号id| + +> POST参数 + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| type | 消息的类型(jsub_xxx) | + +3. Response: JSON + +```js +{ + msgid: '发送成功后, 微信返回的消息id' +} + +``` diff --git a/doc/subscribe.md b/doc/subscribe.md new file mode 100644 index 0000000..6e2bd27 --- /dev/null +++ b/doc/subscribe.md @@ -0,0 +1,57 @@ +#订阅消息流程 + +##1. 相关文档 + + [服务端发送接口](https://developers.weixin.qq.com/minigame/dev/api-backend/open-api/subscribe-message/subscribeMessage.send.html) + + [前端接口](https://developers.weixin.qq.com/minigame/dev/api/open-api/subscribe-message/wx.requestSubscribeSystemMessage.html) + + +##2. 微信mp后台选择相关模板消息 + mp.weixin.qq.com - 功能-订阅消息 + 选择对应的模板, 获得 模板ID 和 消息格式 + +##3. 金蚕mp后台-游戏配置-普通配置 + 增加 订阅消息配置(subscribe_cfg) + 格式如下: +```json +{ + "jsub_login": "wL3rEJ4zSbHpIiKX8ZPpO1z77dAOO8HdFbUZzfAh-VA", + "jsub_sign": "37Te9WPEzd-nB65_KxwiAF00DucDRjb4JzlWQBXjtsY" +} +``` + jsub_login 指 离线收益消息 + jsub_sign 指 每日签到消息 + 值为微信mp后台获取到的模板ID + +##4. 金蚕mp后台-游戏配置-服务端配置 + + 增加 订阅消息配置(subscribe_cfg) + 格式如下(消息格式取自微信mp后台): +```json +{ + "jsub_login": { + "thing4": "离线奖励快满了, 快来领取吧", + "date2": "$date" + }, + "jsub_sign": { + "thing5": "今天还未签到, 快来签到领取好礼吧", + "date4": "$date" + } +} +``` + +##5. 客户端逻辑 + + 客户端启动游戏时, 获取到相关订阅消息的模板id列表, + 通过wx.requestSubscribeMessage取得用户授权后, 将对应的模板id通过上报接口传给服务端 + +##6. 服务端逻辑 + + 服务端暂时采用定时发送的机制, 到达指定时间后, 会遍历所有可发送的记录, 优先发送签到消息 + 判断可发送的条件: count > 0 && lastCheck < 今天0点 + 发送成功后, 会更新lastCheck, + 上报接口也会更新lastCheck的时间 + + + diff --git a/src/api.server.ts b/src/api.server.ts index acad37c..ff2e24d 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -6,6 +6,7 @@ import { mongoose } from '@typegoose/typegoose' import logger from 'logger/logger' import config from 'config/config' import { RedisClient } from './redis/RedisClient' +import { GameInfoCache } from './cache/GameInfoCache' const zReqParserPlugin = require('plugins/zReqParser') @@ -139,6 +140,11 @@ export class ApiServer { return payload }) } + + private async initCaches() { + await new GameInfoCache().init() + } + public async start() { let self = this return new Promise(async (resolve, reject) => { @@ -147,6 +153,7 @@ export class ApiServer { self.registerRouter() self.setErrHandler() self.setFormatSend() + await self.initCaches() this.server.listen({ port: config.api.port }, (err: any, address: any) => { if (err) { logger.log(err) diff --git a/src/cache/GameInfoCache.ts b/src/cache/GameInfoCache.ts new file mode 100644 index 0000000..3702223 --- /dev/null +++ b/src/cache/GameInfoCache.ts @@ -0,0 +1,49 @@ +import { singleton } from '../decorators/singleton' +import { GameInfo } from '../models/GameInfo' + +export class GameMiniInfo { + public appId: string + public appSecret: string + + constructor(appId: string, appSecret: string) { + this.appId = appId + this.appSecret = appSecret + } +} + +@singleton +export class GameInfoCache { + private _cache: Map<[string, string], GameMiniInfo> = new Map() + + public async init() { + let games = await GameInfo.find({ deleted: false }) + for (let game of games) { + const gameId = game.game_id + for (let p of game.platforms) { + const channel = p.platform.platform_id + const appId = p.app_id + const appSecret = p.app_secret + this._cache.set([gameId, channel], new GameMiniInfo(appId, appSecret)) + } + } + } + + /** + * + */ + public async getInfo(channel: string, gameId: string) { + let info = this._cache.get([gameId, channel]) + if (!info) { + let games = await GameInfo.find({ deleted: false, game_id: gameId, 'platforms.platform.platform_id': channel }) + if (games?.length > 0) { + let game = games[0] + let platform = game.platforms.find(o => o.platform.platform_id === channel) + if (platform) { + info = new GameMiniInfo(platform.app_id, platform.app_secret) + this._cache.set([gameId, channel], info) + } + } + } + return info + } +} diff --git a/src/config/config.ts b/src/config/config.ts index bdb706d..56414c6 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -23,7 +23,7 @@ let baseConfig = { }, db_main: process.env.DB_MAIN, - db_second: process.env.DB_SECOND, + db_pikachu: process.env.DB_PIKACHU, redis: process.env.REDIS, susuHost: process.env.SUSU_HOST, } diff --git a/src/controllers/subscribe.controller.ts b/src/controllers/subscribe.controller.ts index e5e4dd0..035e415 100644 --- a/src/controllers/subscribe.controller.ts +++ b/src/controllers/subscribe.controller.ts @@ -1,10 +1,72 @@ import { role, router } from '../decorators/router' import BaseController from '../common/base.controller' +import { checkAccountId, parseGameAccountId } from '../utils/string.util' +import { ZError } from '../common/ZError' +import { SubscribeRecord } from '../models/SubscribeRecord' +import { getSubscribeInfo } from '../services/jcfw.svr' +import { sendSubscribeMessage } from '../services/subscribe.svr' +import { isToday } from '../utils/time.util' class SubscribeController extends BaseController { @role('anon') - @router('post /api/svr/send_msg') - async receive(req: any) { + @router('post /api/svr/:accountId/send_subscribe_msg') + async sendSubscribeMsg(req: any) { + let { accountId, type } = req.params + let record = await SubscribeRecord.findOne({ accountId, type }) + if (!record || record.count < 1) { + throw new ZError(20, '没有可用的模板id') + } + if (isToday(record.lastCheck)) { + throw new ZError(21, '今天不需要发送') + } + const result = await sendSubscribeMessage({ accountId, type }) + record.lastCheck = Date.now() + record.lastSend = Date.now() + await record.save() + return { msgid: result.msgid } + } + + @role('anon') + @router('post /api/svr/:accountId/update_subscribe') + async updateSubscribeStata(req: any) { + let { accountId, templateids, counts } = req.params + if (!accountId || !templateids) { + throw new ZError(11, 'params mismatch') + } + if (!checkAccountId(accountId)) { + throw new ZError(10, 'accountId error') + } + counts = counts || [] + let { gameId, channel, openId } = parseGameAccountId(accountId) + for (let i = 0, l = templateids.length; i < l; i++) { + const templateId = templateids[i] + let record + if (templateId.indexOf('jsub_') >= 0) { + const cfg = await getSubscribeInfo({ gameId, channel, type: templateId }) + if (cfg.type && cfg.templateId) { + record = await SubscribeRecord.insertOrUpdate( + { gameId, channel, accountId: accountId, templateId: cfg.templateId, type: cfg.type }, + {}, + ) + } + } else { + const cfg = await getSubscribeInfo({ gameId, channel, templateId }) + if (cfg.type && cfg.templateId) { + record = await SubscribeRecord.insertOrUpdate( + { gameId, channel, accountId: accountId, templateId: cfg.templateId, type: cfg.type }, + {}, + ) + } + } + if (record) { + const count = counts[i] || 0 + if (count > 0) { + record.count += 1 + } + record.lastCheck = Date.now() + await record.save() + } + } return {} } } diff --git a/src/models/GameInfo.ts b/src/models/GameInfo.ts new file mode 100644 index 0000000..edfc182 --- /dev/null +++ b/src/models/GameInfo.ts @@ -0,0 +1,43 @@ +import { BaseModule } from './Base' +import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose' +import { dbconn } from '../decorators/dbconn' + +class Platform { + @prop() + public platform_id: string + @prop() + public name: string +} + +class GamePlatform { + @prop() + public app_id: string + @prop() + public app_secret: string + @prop() + public platform: Platform +} +@dbconn('pikachu') +@modelOptions({ schemaOptions: { collection: 'game_info', timestamps: true } }) +class GameInfoClass extends BaseModule { + @prop({ required: true, alias: 'gameName' }) + public game_name: string + @prop({ alias: 'gameNameEn' }) + public game_name_en: string + @prop({ alias: 'gameId' }) + public game_id: string + @prop({ alias: 'gameType' }) + public game_type: string + @prop({ default: '512*1024', alias: 'gameSize' }) + public game_size: string + @prop({ alias: 'gameIcon' }) + public game_icon: string + @prop({ alias: 'appKey' }) + public app_key: string + @prop({ type: () => [GamePlatform] }) + public platforms: GamePlatform[] + @prop() + public deleted: boolean +} + +export const GameInfo = getModelForClass(GameInfoClass, { existingConnection: GameInfoClass.db }) diff --git a/src/models/SubscribeHistory.ts b/src/models/SubscribeHistory.ts new file mode 100644 index 0000000..56fe741 --- /dev/null +++ b/src/models/SubscribeHistory.ts @@ -0,0 +1,38 @@ +import { BaseModule } from './Base' +import { dbconn } from '../decorators/dbconn' +import { getModelForClass, modelOptions, prop, Severity, mongoose } from '@typegoose/typegoose' +import { parseGameAccountId } from '../utils/string.util' + +@dbconn() +@modelOptions({ + schemaOptions: { collection: 'subscribe_history', timestamps: true }, + options: { allowMixed: Severity.ALLOW }, +}) +class SubscribeHistoryClass extends BaseModule { + @prop() + public gameId: string + @prop() + public channel: string + @prop() + public accountId: string + @prop() + public templateId: string + + @prop({ type: mongoose.Schema.Types.Mixed }) + public result: any + + public static async log(accountId: string, templateId: string, result: any) { + let record = new SubscribeHistory() + record.accountId = accountId + record.templateId = templateId + record.result = result + let { gameId, channel } = parseGameAccountId(accountId) + record.gameId = gameId + record.channel = channel + await record.save() + } +} + +export const SubscribeHistory = getModelForClass(SubscribeHistoryClass, { + existingConnection: SubscribeHistoryClass.db, +}) diff --git a/src/models/SubscribeRecord.ts b/src/models/SubscribeRecord.ts new file mode 100644 index 0000000..f30edac --- /dev/null +++ b/src/models/SubscribeRecord.ts @@ -0,0 +1,35 @@ +import { BaseModule } from './Base' +import { dbconn } from '../decorators/dbconn' +import { getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' + +@dbconn() +@index({ accountId: 1 }, { unique: false }) +@index({ templateId: 1 }, { unique: false }) +@index({ type: 1 }, { unique: false }) +@index({ accountId: 1, templateId: 1 }, { unique: true }) +@index({ accountId: 1, type: 1 }, { unique: true }) +@modelOptions({ schemaOptions: { collection: 'subscribe_record', timestamps: true } }) +class SubscribeRecordClass extends BaseModule { + @prop() + public gameId: string + @prop() + public channel: string + @prop() + public accountId: string + @prop() + public templateId: string + /** + * 定义好的类型, 以 jsub_ 开头 + * @type {string} + */ + @prop() + public type: string + @prop({ default: 0 }) + public count: number + @prop({ default: 0 }) + public lastSend: number + @prop({ default: 0 }) + public lastCheck: number +} + +export const SubscribeRecord = getModelForClass(SubscribeRecordClass, { existingConnection: SubscribeRecordClass.db }) diff --git a/src/schedule/subscribe.schedule.ts b/src/schedule/subscribe.schedule.ts new file mode 100644 index 0000000..0f1bf43 --- /dev/null +++ b/src/schedule/subscribe.schedule.ts @@ -0,0 +1,33 @@ +import { singleton } from '../decorators/singleton' +import * as schedule from 'node-schedule' +import { SubscribeRecord } from '../models/SubscribeRecord' +import { todayStart } from '../utils/time.util' +import { sendSubscribeMessage } from '../services/subscribe.svr' + +@singleton +export default class SubscribeSchedule { + async parseAllRecord() { + let records = await SubscribeRecord.find({ count: { $gt: 0 }, lastCheck: { $lt: todayStart() } }) + let sendSet: Set = new Set() + for (let record of records) { + if (sendSet.has(record.accountId)) { + continue + } + try { + await sendSubscribeMessage({ accountId: record.accountId, type: record.type }) + record.lastCheck = Date.now() + record.lastSend = Date.now() + record.count -= 1 + sendSet.add(record.accountId) + await record.save() + } catch (err) { + console.log(err) + } + } + } + scheduleAll() { + const job = schedule.scheduleJob('1 0 20 * * *', async () => { + await this.parseAllRecord() + }) + } +} diff --git a/src/services/jcfw.svr.ts b/src/services/jcfw.svr.ts new file mode 100644 index 0000000..4242cf4 --- /dev/null +++ b/src/services/jcfw.svr.ts @@ -0,0 +1,102 @@ +import { RedisClient } from '../redis/RedisClient' +import exp from 'constants' + +/** + * 获取某游戏的所有客户端配置 + * @param {string} gameId + * @param {string} channel + * @return {Promise<{key: string, value: string}[]>} + */ +export async function getGameClientCfg(gameId: string, channel: string) { + const key = `config:${gameId}:${channel}` + const records = await new RedisClient().get(key) + let results: { key: string; value: string }[] = [] + if (records) { + results = JSON.parse(records as string) + } + return results +} + +export async function getGameSvrCfg(gameId: string, channel: string) { + const key = `server_config:${gameId}:${channel}` + const records = await new RedisClient().get(key) + let results: { key: string; value: string }[] = [] + if (records) { + results = JSON.parse(records as string) + } + return results +} + +/** + * 获取游戏的订阅消息配置 + * @param {string} gameId + * @param {string} channel + * @param svr 是否是服务端配置 + * @return {Promise} + */ +export async function getSubscribeCfg(gameId: string, channel: string, svr: boolean = false) { + const cfgs = svr ? await getGameSvrCfg(gameId, channel) : await getGameClientCfg(gameId, channel) + const result: any = {} + for (let i = 0, l = cfgs.length; i < l; i++) { + if (cfgs[i].key === 'subscribe_cfg') { + const val = JSON.parse(cfgs[i].value) + Object.assign(result, val) + break + } + } + return result +} + +/** + * 根据type获取对应的订阅消息模板id + * @param {string} gameId + * @param {string} channel + * @param {string} type + * @param {string} templateId + * @return {Promise<{type: string, templateId: string}>} + */ +export async function getSubscribeInfo({ + gameId, + channel, + type, + templateId, +}: { + gameId: string + channel: string + type?: string + templateId?: string +}) { + const cfg = await getSubscribeCfg(gameId, channel) + if (type) { + templateId = cfg[type] + } else if (templateId) { + for (let key of Object.keys(cfg)) { + if (cfg[key] === templateId) { + type = key + break + } + } + } + return { type, templateId } +} + +/** + * 获取订阅消息的消息模板 + * @param {string} gameId + * @param {string} channel + * @param {string} type + */ +export async function getSubscribeMsg(gameId: string, channel: string, type: string) { + const cfg = await getSubscribeCfg(gameId, channel, true) + const data = cfg[type] + if (data) { + for (let key of Object.keys(data)) { + if (data[key].indexOf('$date') > -1) { + data[key] = { value: data[key].replace('$date', new Date().format('yyyy-MM-dd')) } + } else { + data[key] = { value: data[key] } + } + } + } + return data +} diff --git a/src/services/subscribe.svr.ts b/src/services/subscribe.svr.ts new file mode 100644 index 0000000..7f8bb01 --- /dev/null +++ b/src/services/subscribe.svr.ts @@ -0,0 +1,51 @@ +import { checkAccountId, parseGameAccountId } from '../utils/string.util' +import { ZError } from '../common/ZError' +import { GameInfoCache } from '../cache/GameInfoCache' +import { getSubscribeInfo, getSubscribeMsg } from './jcfw.svr' +import { sendWxSubscribeMessage } from './wechat/Wechat' +import { SubscribeHistory } from '../models/SubscribeHistory' + +export async function sendSubscribeMessage({ + accountId, + type, + templateId, +}: { + accountId: string + type?: string + templateId?: string +}) { + if (!checkAccountId(accountId)) { + throw new ZError(10, 'accountId error') + } + let { gameId, channel, openId } = parseGameAccountId(accountId) + if (channel !== '6001') { + throw new ZError(12, 'this platform can`t send subscribe msg') + } + if (!type && !templateId) { + throw new ZError(13, 'no msg type specify') + } + let info = await new GameInfoCache().getInfo(channel, gameId) + if (!info) { + throw new ZError(11, 'game cfg not found') + } + const cfg = await getSubscribeInfo({ gameId, channel, type, templateId }) + if (!cfg.type || !cfg.templateId) { + throw new ZError(14, 'subscribe cfg not found') + } + const msgData = await getSubscribeMsg(gameId, channel, cfg.type) + if (!msgData) { + throw new ZError(15, 'msg cfg not found') + } + const result = await sendWxSubscribeMessage({ + appId: info.appId, + appSecret: info.appSecret, + openId, + templateId: cfg.templateId, + data: msgData, + }) + await SubscribeHistory.log(accountId, cfg.templateId, result.data) + if (result?.data?.errcode) { + throw new ZError(result.data.errcode, result.data.errmsg) + } + return result.data +} diff --git a/src/services/wechat/Wechat.ts b/src/services/wechat/Wechat.ts index 09db8d1..e5338b8 100644 --- a/src/services/wechat/Wechat.ts +++ b/src/services/wechat/Wechat.ts @@ -14,7 +14,6 @@ const URL_GENERATE_SCHEMA = 'https://api.weixin.qq.com/wxa/generatescheme' * @param {string} appId * @param {string} appSecret * @param {boolean} refresh 是否强制刷新 - * @return {Promise} */ export async function refreshToken(appId: string, appSecret: string, refresh: boolean = false) { const util = new WxTokenCache() @@ -143,7 +142,7 @@ export async function msgSecCheck(content: string) { * @param templateId * @param data */ -export async function sendSubscribeMessage({ appId, appSecret, openId, templateId, data }) { +export async function sendWxSubscribeMessage({ appId, appSecret, openId, templateId, data }) { const params = { touser: openId, template_id: templateId, data } const token = await refreshToken(appId, appSecret) const url = `${URL_SUBSCRIBE_MSG}?access_token=${token}` @@ -154,7 +153,7 @@ export async function sendSubscribeMessage({ appId, appSecret, openId, templateI 'Cache-Control': 'no-cache', 'Content-Type': 'application/json', }, - data: params, + data: JSON.stringify(params), } return axios(reqConfig) } diff --git a/src/utils/string.util.ts b/src/utils/string.util.ts index 56d9ee4..1699787 100644 --- a/src/utils/string.util.ts +++ b/src/utils/string.util.ts @@ -120,7 +120,7 @@ export function isJsonString(str: string) { } export function checkAccountId(accountId: string) { - return /^\d{4}_\d{4}_.+$/.test(accountId) + return /^\d{4}_\d{4,6}_.+$/.test(accountId) } export function parseGameAccountId(accountId: string) { const arr = accountId.split('_') diff --git a/src/utils/time.util.ts b/src/utils/time.util.ts index 5f44fe7..5761a6e 100644 --- a/src/utils/time.util.ts +++ b/src/utils/time.util.ts @@ -1,16 +1,42 @@ +export const ONE_DAY_MILLISECOND = 1000 * 3600 * 24 /** * 获取n天前的time * @param {number} day * @return {number} */ export function timeBeforeDay(day: number): number { - let time = Date.now(); - return time - day * 1000 * 24 * 24; + let time = Date.now() + return time - day * ONE_DAY_MILLISECOND } //间隔天数 export function calcBetweenDays(time1: number, time2: number) { - let v1 = Math.floor(time1/3600/24/1000); - let v2 = Math.floor(time2/3600/24/1000); - return Math.abs(v1 - v2); + let v1 = Math.floor(time1 / ONE_DAY_MILLISECOND) + let v2 = Math.floor(time2 / ONE_DAY_MILLISECOND) + return Math.abs(v1 - v2) +} + +/** + * 判断是否是今天 + * @param {number} time + * @return {boolean} + */ +export function isToday(time: number) { + return new Date().toDateString() === new Date(time).toDateString() +} + +/** + * 今天开始的时间 + * @return {number} + */ +export function todayStart() { + return new Date(new Date().toLocaleDateString()).getTime() +} + +/** + * 今天结束的时间 + * @return {number} + */ +export function todayEnd() { + return todayStart() + ONE_DAY_MILLISECOND - 1 }