增加绑定游戏邮件帐号功能

This commit is contained in:
CounterFire2023 2024-05-14 16:23:18 +08:00
parent 06bfb2f6de
commit a1f4920ebd
11 changed files with 375 additions and 232 deletions

View File

@ -64,12 +64,9 @@
1. 增加验证google的access token(32)
2. 增加两种任务类型: GoogleConnect, GameAchievement
#### 20240508
1. 用户状态接口(10) 增加字段, gameScore, gameTicket, googleId, rankGame
1. 增加接口: 游戏任务列表(33), 领取游戏任务奖励(34), 大转盘抽奖(35), 大转盘抽奖记录(36), 游戏积分详情列表(37), 游戏积分排行榜(38)
1. 社交任务活动信息(3), 增加字段drawTime, 表示转盘开始时间
1. 增加接口: 转盘配置(39), 用户NFT列表(40)
#### 20240514
1. 用户状态(10) 增加返回emailId, email
2. 增加接口: 发送邮件验证码(33), 验证邮件地址(34)
### 1. 钱包预登录
@ -171,7 +168,6 @@ SiweMessage的nonce说明(具体参考例子):
"autoclaim": false // 任务完成后是否自动获取奖励
}
],
"drawTime": 1702628292366, // 抽奖活动开始时间
"startTime": 1702628292366, // 活动开始时间
"endTime": 1705220292366 // 活动结束时间
}
@ -194,7 +190,7 @@ SiweMessage的nonce说明(具体参考例子):
```js
[
{
"status": 2, // 任务状态, 0: 未开始, 1: 进行中, 2: 成功, 3: 已领取, 9: 失败
"status": 2, // 任务状态, 0: 未开始, 1: 进行中, 2: 成功, 9: 失败
"id": "TwitterConnect", // 任务id
"timeStart": 1703150269527, // 任务开始时间
"data": { // 当前任务带的额外信息, 比如twitter的id和昵称等
@ -365,14 +361,10 @@ body:
"twitterName": "",
"twitterAvatar": "", // twitter头像
"discordId": "",
"googleId": "",
"discordName": "",
"scoreToday": 100, // 今日获得积分
"scoreTotal": 200, // 总积分
"gameScore": 100, // 游戏内积分
"gameTicket": 1, // 游戏内可抽奖次数
"rankTotal": "-",
"rankGame": "", // 游戏内积分排行榜
"invite": "邀请人address",
"inviteCount": 0, // 我邀请的用户总数
"inviteScore": 0, // 我邀请用户总数获得的分数
@ -789,7 +781,7 @@ body:
```
### 26. 宝箱助力状态查询
### 26.\ 宝箱助力状态查询
#### Request
@ -1005,40 +997,11 @@ body:
}
```
### 33. 游戏任务列表
### 33.\* 发送邮件验证码
#### Request
- URL`/api/ingame/tasks`
- 方法:`GET`
#### Response
```js
[
{
"id": "任务id",
"task": "任务类型",
"title": "任务名",
"desc": "任务描述",
"show": 1, // 是否显示, 0: 不显示, 1: 显示
"type": 1, //任务类型, 1: 一次性任务, 2: 日常任务
"pretasks": ["task id 1"], //前置任务
"score": 0, // 完成任务可获得的积分
"ticket": 0, // 完成任务可获得抽奖次数
"cfg": {}, // 其他一些任务相关配置参数, 比如icon, 或者其他未考虑的参数
"end": false, // 是否已经结束
"status": 1,// 任务状态, -1:不可用 0: 未开始, 1: 进行中, 2: 成功, 3: 已领取, 9: 失败
"autoclaim": false // 任务完成后是否自动获取奖励
}
]
```
### 34.\* 领取游戏任务奖励
#### Request
- URL`/api/ingame/claim`
- URL`/api/email/send_code`
- 方法POST
- 头部:
- Authorization: Bearer JWT_token
@ -1048,26 +1011,30 @@ body:
```js
{
"task": "g31x9wzja7eg18t3vtm" // 任务id
"email": "email"
}
```
> 验证email的正则
```js
export const isEmail = (email) => {
const reg = /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/
return reg.test(email)
}
```
#### Response
```json
{
"status": 1, // 任务状态, 0: 未开始, 1: 进行中, 2: 成功, 3: 已领取 9: 失败
"score": 1,
"ticket": 3, // 获得的ticket, 可能没这个字段
}
```js
{
}
```
### 35.\* 大转盘抽奖
### 34.\* 验证邮件地址
#### Request
- URL`/api/ingame/draw`
- URL`/api/user/verify_email`
- 方法POST
- 头部:
- Authorization: Bearer JWT_token
@ -1077,175 +1044,21 @@ body:
```js
{
"step": 1
"email": "email",
"code": "123221"
}
```
> 验证code的正则
```js
export const isValiedCode = (code) => {
return /^\d{6}$/.test(code)
}
```
#### Response
```json
{
score: 100, // 获得的积分
items: [
{
id: "001", // 物品id
type: 1, // 1白单, 2: nft
name: "", // 物品名
desc: "", // 描述
amount: 1 // 数量
}
]
}
```
### 36.\* 大转盘抽奖记录
#### Request
- URL`/api/ingame/draw_history`
- 方法GET
- 头部:
- Authorization: Bearer JWT_token
#### Response
```json
[{
time: 12123, // 抽奖时间
score: 100, // 获得的积分
items: [
{
id: "001", // 物品id
type: 1, // 1白单, 2: nft
name: "", // 物品名
desc: "", // 描述
amount: 1 // 数量
}
],
position: [1, 2] //每次转动的位置
}]
```
### 37.\* 游戏积分详情列表
#### Request
- URL`/api/ingame/score_list`
- 方法:`GET`
- 头部:
- Authorization: Bearer JWT_token
#### Response
```js
[{
score: 100, // 获得的积分
type: '', // 获取原因
time: 111 // 开启时间
}]
```
### 38. 游戏 积分排行榜
#### Request
- URL`/api/ingame/leaderboard/:page`
- 方法:`GET`
- 参数:
- `page` (返回数据的分页序号, 0 开始)
> 默认返回100条记录, 如果要返回不同数量, query param传 limit
#### Response
```json
[
{
"rank": 1, // 排名, 从1开始
"level": 1, // 段位
"nickname": "昵称",
"score": 1 //获得的积分
}
]
```
### 39. 转盘配置
#### Request
- URL`/api/ingame/draw_cfg`
- 方法:`GET`
#### Response
```json
[
{
"position": 1, // 位置, 从1开始
"type": 0, // 类型, 0: 积分, 1: 白单, 2: nft
"amount": 1, // 数量
"name": "", // 没有的话, 就是score
}
]
```
### 40. \* 用户NFT列表
#### Request
- URL`/api/ingame/nft_list`
- 方法:`GET`
#### Response
```json
[
{
"tokenId": "1", // tokenID
"rarity": "", // Common, Rare, Legendary
}
]
```
### 41.\* 积分详情列表(分页)
#### Request
- URL`/api/activity/user_score_list`
- 方法:`POST`
- 头部:
- Authorization: Bearer JWT_token
body:
```js
{
"page": 0, // 第几页
"limit": 8, // 每页数据数量
{
}
```
####
#### Response
```js
{
"page": 0,
"limit": 8,
"total": 1000,
"records": [{
"score": 100, // 获得的积分
"type": "", // 获取原因
"time": 111 // 开启时间
}]
}
```
###
```

View File

@ -10,6 +10,7 @@ import NonceRecordSchedule from 'schedule/noncerecord.schedule'
import { RouterMap, ZRedisClient } from 'zutils'
import { SyncLocker } from 'common/SyncLocker'
import CacheSchedule from 'schedule/cache.schedule'
import CodeTaskSchedule from 'schedule/codetask.schedule'
const zReqParserPlugin = require('plugins/zReqParser')
@ -118,6 +119,7 @@ export class ApiServer {
}
private initSchedules() {
new CacheSchedule().scheduleAll()
new CodeTaskSchedule().scheduleAll()
new NonceRecordSchedule().scheduleAll()
}

View File

@ -68,10 +68,15 @@ class InGameController extends BaseController {
}
}
if (user) {
if (user.googleId) {
if (user.gameAccountBinded()) {
try {
let res = await queryInGameInfo(user.googleId, '0')
Object.assign(gameData, res)
if (user.googleId) {
let res = await queryInGameInfo(user.googleId, '0')
Object.assign(gameData, res)
} else if (user.emailId) {
let res = await queryInGameInfo(user.emailId, '6')
Object.assign(gameData, res)
}
} catch (e) {
logger.info('queryInGameInfo with err: ', e.message || e)
}
@ -108,7 +113,7 @@ class InGameController extends BaseController {
logger.db('claim_ingame', req)
const { task } = req.params
const user = req.user
if (!user.googleId) {
if (!user.gameAccountBinded()) {
throw new ZError(10, 'need connect game account first')
}
if (!taskMap.has(task)) {
@ -145,7 +150,7 @@ class InGameController extends BaseController {
logger.db('draw_ingame', req)
const user = req.user
const { step } = req.params
if (!user.googleId) {
if (!user.gameAccountBinded()) {
throw new ZError(10, 'need connect game account first')
}
const ingameStat = await InGameStats.insertOrUpdate({ user: user.id }, {})

View File

@ -0,0 +1,126 @@
import logger from 'logger/logger'
import { ActivityUser } from 'models/ActivityUser'
import {
CodeRecord,
CodeStatus,
CodeType,
DEFAULT_CODE,
DEFAULT_EXPIRE_TIME,
isEmail,
isValiedCode,
} from 'models/CodeRecord'
import { DEFAULT_LOGIN_MAIL_HTML, DEFAULT_LOGIN_MAIL_SUBJECT, EmailSvr } from 'services/email.svr'
import { BaseController, router, ZError } from 'zutils'
import { sha1 } from 'zutils/utils/security.util'
import { SyncLocker } from 'common/SyncLocker'
class MailController extends BaseController {
/**
* ,
*/
@router('post /api/user/verify_email')
async loginWithEmail(req, res) {
await new SyncLocker().checkLock(req)
logger.db('verify_email', req)
let user = req.user
const { email, code } = req.params
if (!email || !code) {
throw new ZError(10, 'params mismatch')
}
if (!isEmail(email)) {
throw new ZError(11, 'Invalid email')
}
if (!isValiedCode(code)) {
throw new ZError(11, 'code error')
}
if (user.gameAccountBinded()) {
throw new ZError(12, 'already bind game account')
}
let openId = sha1(email)
let userCheck = await ActivityUser.findOne({ emailId: openId })
if (userCheck && userCheck.id !== user.id) {
throw new ZError(13, 'Email already binded to another account')
}
let recordCode = await CodeRecord.findByEmail(email, CodeType.LOGIN)
if (!recordCode) {
throw new ZError(14, 'code expired')
}
if (recordCode.status !== CodeStatus.PENDING) {
throw new ZError(15, 'code expired')
}
if (recordCode.code !== code) {
throw new ZError(16, 'code error')
}
user.emailId = openId
user.email = email
recordCode.status = CodeStatus.SUCCESS
await recordCode.save()
await user.save()
return {}
}
/**
*
*/
@router('post /api/email/send_code')
async sendVerifyCode(req, res) {
await new SyncLocker().checkLock(req)
logger.db('send_mail_code', req)
let user = req.user
let { email, type } = req.params
type = type || CodeType.LOGIN
if (!email) {
throw new ZError(10, 'params mismatch')
}
if (!isEmail(email)) {
throw new ZError(11, 'Invalid email')
}
if (user.gameAccountBinded()) {
throw new ZError(12, 'already bind game account')
}
let openId = sha1(email)
let userCheck = await ActivityUser.findOne({ emailId: openId })
if (userCheck && userCheck.id !== user.id) {
throw new ZError(13, 'Email already binded to another account')
}
type = parseInt(type)
let record = await CodeRecord.findByEmail(email, type)
if (!record || record.status === CodeStatus.EXPIRED || record.status === CodeStatus.FAIL) {
record = new CodeRecord({ email, type, code: DEFAULT_CODE, user: user.id })
await record.save()
}
let html, subject
switch (type) {
case CodeType.LOGIN:
html = DEFAULT_LOGIN_MAIL_HTML
subject = DEFAULT_LOGIN_MAIL_SUBJECT
}
if (!html || !subject) {
throw new ZError(15, 'type error')
}
subject = record.code + ' ' + subject
html = html.replace('{{ocde}}', record.code)
html = html.replace('{{time}}', new Date().format('yyyy-MM-dd hh:mm:ss'))
let msgData = {
to: email,
html,
subject,
}
setImmediate(async () => {
try {
let result = await new EmailSvr().sendMail(msgData)
record.mailSend = true
record.emailId = result.messageId
record.expiredAt = Date.now() + DEFAULT_EXPIRE_TIME
await record.save()
} catch (err) {
logger.info(`error send mail:: email: ${email}, type: ${type}`)
logger.error(err)
record.status = CodeStatus.FAIL
await record.save()
}
})
return {}
}
}

View File

@ -167,6 +167,8 @@ class SignController extends BaseController {
googleMail: user.googleEmail,
gameScore: ingameStat.score,
gameTicket: ingameStat.ticket,
emailId: user.emailId,
email: user.email,
}
return result
}

View File

@ -49,7 +49,8 @@ export interface ActivityUserClass extends Base, TimeStamps {}
@index({ inviteCode: 1, activity: 1 }, { unique: true, partialFilterExpression: { inviteCode: { $exists: true } } })
@index({ inviteUser: 1, activity: 1 }, { unique: false })
@index({ twitterId: 1 }, { unique: true, partialFilterExpression: { twitterId: { $exists: true } } })
@index({ googleId: 1 }, { unique: true, partialFilterExpression: { twitterId: { $exists: true } } })
@index({ googleId: 1 }, { unique: true, partialFilterExpression: { googleId: { $exists: true } } })
@index({ emailId: 1 }, { unique: true, partialFilterExpression: { emailId: { $exists: true } } })
@index({ discordId: 1 }, { unique: true, partialFilterExpression: { discordId: { $exists: true } } })
@modelOptions({
schemaOptions: { collection: 'activity_user', timestamps: true },
@ -111,6 +112,11 @@ export class ActivityUserClass extends BaseModule {
@prop()
public googleEmail?: string
@prop()
public emailId?: string
@prop()
public email?: string
@prop({ default: false })
public inWhiteList: boolean
@ -142,6 +148,10 @@ export class ActivityUserClass extends BaseModule {
return task.status !== TaskStatusEnum.NOT_START && task.status !== TaskStatusEnum.RUNNING
})
}
public gameAccountBinded() {
return this.googleId || this.emailId
}
}
export const ActivityUser = getModelForClass(ActivityUserClass, { existingConnection: ActivityUserClass.db })

90
src/models/CodeRecord.ts Normal file
View File

@ -0,0 +1,90 @@
import { getModelForClass, index, modelOptions, pre, prop, ReturnModelType } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import { BaseModule } from './Base'
import { customAlphabet } from 'nanoid'
const nanoid = customAlphabet('1234567890', 6)
export const DEFAULT_CODE = '000000'
export const DEFAULT_EXPIRE_TIME = 5 * 60 * 1000
export enum CodeType {
REGIST = 1, // 注册
RESET = 2, // 重置密码
VERIFY = 3, // 验证邮箱
LOGIN = 4, // 验证码登录
}
export enum CodeStatus {
PENDING = 1,
SUCCESS = 2,
FAIL = 3,
EXPIRED = 4,
}
export const isEmail = (email: string) => {
const reg = /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/
return reg.test(email)
}
export const isValiedCode = (code: string) => {
return /^\d{6}$/.test(code)
}
/**
*
*/
@dbconn()
@index({ email: 1, type: 1, status: 1 }, { unique: true, partialFilterExpression: { status: 1 } })
@index({ code: 1 }, { unique: true })
@index({ expiredAt: 1 }, { unique: false })
@modelOptions({
schemaOptions: { collection: 'code_send_record', timestamps: true },
})
@pre<CodeRecordClass>('save', async function () {
if (this.code === DEFAULT_CODE) {
let exists = false
while (!exists) {
const code = nanoid()
const record = await CodeRecord.findByCode(code)
if (!record) {
exists = true
this.code = code
}
}
}
})
class CodeRecordClass extends BaseModule {
@prop({ required: true })
public email!: string
@prop()
public user?: string
@prop({ required: true })
public code!: string
@prop({ default: Date.now() + DEFAULT_EXPIRE_TIME })
public expiredAt?: number
@prop({ required: true, default: CodeType.REGIST })
public type: CodeType
@prop({ required: true, default: CodeStatus.PENDING })
public status: CodeStatus
@prop({ default: false })
public mailSend: boolean
@prop()
public emailId?: string
public static async findByCode(this: ReturnModelType<typeof CodeRecordClass>, code: string) {
return this.findOne({ code }).exec()
}
public static async findByEmail(this: ReturnModelType<typeof CodeRecordClass>, email: string, type: CodeType) {
return this.findOne({ email, type, status: CodeStatus.PENDING }).exec()
}
}
export const CodeRecord = getModelForClass(CodeRecordClass, { existingConnection: CodeRecordClass.db })

View File

@ -0,0 +1,19 @@
import { singleton } from 'zutils'
import { CodeRecord, CodeStatus } from 'models/CodeRecord'
import * as schedule from 'node-schedule'
/**
*
*/
@singleton
export default class CodeTaskSchedule {
async parseAllRecord() {
let now = Date.now()
await CodeRecord.deleteMany({ expiredAt: { $lt: now } })
}
scheduleAll() {
const job = schedule.scheduleJob('*/1 * * * *', async () => {
await this.parseAllRecord()
})
}
}

76
src/services/email.svr.ts Normal file
View File

@ -0,0 +1,76 @@
import { singleton } from 'zutils'
import { timeoutFetch } from 'zutils/utils/net.util'
export const DEFAULT_VERIFY_HTML = `
<h1>Email Verification</h1>
<p>CEBG needs to confirm your email address is still valid. Please click the link below to confirm you received this mail.</p>
<p><a href="{{href}}" target="_blank">Verify Email</a></p>
<p>If you're worried about this email being legitimate, you can visit CEBG directly to confirm your email needs verifying. After doing so, please don't forget to click the link above.</p>
`
export const DEFAULT_REGIST_SUBJECT = 'CEBG regist code'
export const DEFAULT_REGIST_HTML = `
<h1>Your CEBG regist code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login CEBG. Never share this code with anyone.</p>
`
export const DEFAULT_RESET_SUBJECT = 'CEBG reset password code'
export const DEFAULT_RESET_HTML = `
<h1>Your CEBG reset password code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login CEBG. Never share this code with anyone.</p>
`
export const DEFAULT_VERIFY_MAIL_SUBJECT = 'CEBG verify email code'
export const DEFAULT_VERIFY_MAIL_HTML = `
<h1>Your CEBG verify email code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login CEBG. Never share this code with anyone.</p>
`
export const DEFAULT_LOGIN_MAIL_SUBJECT = 'Counter Fire email login code'
export const DEFAULT_LOGIN_MAIL_HTML = `
<h1>Your Counter Fire email login code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login Counter Fire. Never share this code with anyone.</p>
`
export interface IMailData {
from?: string
to: string
subject?: string
text?: string
html?: string
}
const DEFAULT_MSG_DATA: IMailData = {
from: 'CEBG <noreply@cebg.games>',
to: '',
subject: 'Please verify your email address',
}
const MAIL_SVR = process.env.EMAIL_SERVER
@singleton
export class EmailSvr {
public sendMail(msg: IMailData) {
let url = MAIL_SVR + '/mail/send'
Object(DEFAULT_MSG_DATA).zssign(msg)
const options: any = {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({ message: msg }),
}
return timeoutFetch(url, options, 10000).then(res => res.json())
}
}

View File

@ -9,13 +9,13 @@ export default class GoogleConnect extends ITask {
}
public async claimReward(task: any) {
if (!this.user.googleId) {
throw new ZError(100, 'google account not binded')
if (!this.user.gameAccountBinded()) {
throw new ZError(100, 'game account not binded')
}
return { score: 0, ticket: 0 }
}
public async check(cfg: any, gameData: any) {
return !!this.user.googleId
return !!this.user.gameAccountBinded()
}
}

View File

@ -8,21 +8,21 @@ export default class GoogleConnect extends ITask {
static show: boolean = true
async execute(data: any) {
if (!this.user.googleId) {
throw new ZError(100, 'google account already binded')
if (!this.user.gameAccountBinded()) {
throw new ZError(100, 'game account already binded')
}
const { task } = data
if (task.status === TaskStatusEnum.RUNNING) {
task.status = TaskStatusEnum.SUCCESS
task.timeFinish = Date.now()
task.data = {
userid: this.user.googleId,
email: this.user.googleEmail,
userid: this.user.googleId || this.user.emailId,
email: this.user.googleEmail || this.user.email,
}
try {
await this.user.save()
} catch (err) {
throw new ZError(100, 'google account already binded')
throw new ZError(100, 'game account already binded')
}
let cfg = this.activity.tasks.find((t: TaskCfg) => t.id === task.id)
if (cfg.autoclaim) {