diff --git a/docs/uaw.md b/docs/uaw.md index e529a18..13836c4 100644 --- a/docs/uaw.md +++ b/docs/uaw.md @@ -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 // 开启时间 - }] -} -``` - -### \ No newline at end of file +``` \ No newline at end of file diff --git a/src/api.server.ts b/src/api.server.ts index 22ce8f4..9d2260c 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -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() } diff --git a/src/controllers/ingame.controller.ts b/src/controllers/ingame.controller.ts index fb6e340..9badf77 100644 --- a/src/controllers/ingame.controller.ts +++ b/src/controllers/ingame.controller.ts @@ -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 }, {}) diff --git a/src/controllers/mail.controller.ts b/src/controllers/mail.controller.ts new file mode 100644 index 0000000..f277a64 --- /dev/null +++ b/src/controllers/mail.controller.ts @@ -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 {} + } +} diff --git a/src/controllers/sign.controller.ts b/src/controllers/sign.controller.ts index 3a94b7a..6f546c3 100644 --- a/src/controllers/sign.controller.ts +++ b/src/controllers/sign.controller.ts @@ -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 } diff --git a/src/models/ActivityUser.ts b/src/models/ActivityUser.ts index d7d8d58..64edd6c 100644 --- a/src/models/ActivityUser.ts +++ b/src/models/ActivityUser.ts @@ -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 }) diff --git a/src/models/CodeRecord.ts b/src/models/CodeRecord.ts new file mode 100644 index 0000000..8066a6a --- /dev/null +++ b/src/models/CodeRecord.ts @@ -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('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, code: string) { + return this.findOne({ code }).exec() + } + + public static async findByEmail(this: ReturnModelType, email: string, type: CodeType) { + return this.findOne({ email, type, status: CodeStatus.PENDING }).exec() + } +} + +export const CodeRecord = getModelForClass(CodeRecordClass, { existingConnection: CodeRecordClass.db }) diff --git a/src/schedule/codetask.schedule.ts b/src/schedule/codetask.schedule.ts new file mode 100644 index 0000000..7d79078 --- /dev/null +++ b/src/schedule/codetask.schedule.ts @@ -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() + }) + } +} diff --git a/src/services/email.svr.ts b/src/services/email.svr.ts new file mode 100644 index 0000000..c8c88f4 --- /dev/null +++ b/src/services/email.svr.ts @@ -0,0 +1,76 @@ +import { singleton } from 'zutils' +import { timeoutFetch } from 'zutils/utils/net.util' + +export const DEFAULT_VERIFY_HTML = ` +

Email Verification

+

CEBG needs to confirm your email address is still valid. Please click the link below to confirm you received this mail.

+

Verify Email

+

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.

+` + +export const DEFAULT_REGIST_SUBJECT = 'CEBG regist code' +export const DEFAULT_REGIST_HTML = ` +

Your CEBG regist code is

+

{{ocde}}

+

{{time}}

+

This is your one time code that expires in 5 minutes.

+

Use it to login CEBG. Never share this code with anyone.

+` + +export const DEFAULT_RESET_SUBJECT = 'CEBG reset password code' +export const DEFAULT_RESET_HTML = ` +

Your CEBG reset password code is

+

{{ocde}}

+

{{time}}

+

This is your one time code that expires in 5 minutes.

+

Use it to login CEBG. Never share this code with anyone.

+` +export const DEFAULT_VERIFY_MAIL_SUBJECT = 'CEBG verify email code' +export const DEFAULT_VERIFY_MAIL_HTML = ` +

Your CEBG verify email code is

+

{{ocde}}

+

{{time}}

+

This is your one time code that expires in 5 minutes.

+

Use it to login CEBG. Never share this code with anyone.

+` + +export const DEFAULT_LOGIN_MAIL_SUBJECT = 'Counter Fire email login code' +export const DEFAULT_LOGIN_MAIL_HTML = ` +

Your Counter Fire email login code is

+

{{ocde}}

+

{{time}}

+

This is your one time code that expires in 5 minutes.

+

Use it to login Counter Fire. Never share this code with anyone.

+` + +export interface IMailData { + from?: string + to: string + subject?: string + text?: string + html?: string +} + +const DEFAULT_MSG_DATA: IMailData = { + from: 'CEBG ', + 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()) + } +} diff --git a/src/taskingame/GoogleConnect.ts b/src/taskingame/GoogleConnect.ts index 38f8043..459560a 100644 --- a/src/taskingame/GoogleConnect.ts +++ b/src/taskingame/GoogleConnect.ts @@ -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() } } diff --git a/src/tasks/GoogleConnect.ts b/src/tasks/GoogleConnect.ts index c2c5348..40effca 100644 --- a/src/tasks/GoogleConnect.ts +++ b/src/tasks/GoogleConnect.ts @@ -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) {