增加订阅消息的发送逻辑

This commit is contained in:
zhl 2021-08-06 15:01:09 +08:00
parent 6d906615cd
commit 221fffb169
17 changed files with 584 additions and 14 deletions

View File

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

View File

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

68
doc/api.md Normal file
View File

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

57
doc/subscribe.md Normal file
View File

@ -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的时间

View File

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

49
src/cache/GameInfoCache.ts vendored Normal file
View File

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

View File

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

View File

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

43
src/models/GameInfo.ts Normal file
View File

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

View File

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

View File

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

View File

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

102
src/services/jcfw.svr.ts Normal file
View File

@ -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<any>}
*/
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
}

View File

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

View File

@ -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<any>}
*/
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)
}

View File

@ -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('_')

View File

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