From 3e4f1297f21fd36ad830e5444fab55dede57881c Mon Sep 17 00:00:00 2001 From: zhl Date: Tue, 22 Jun 2021 17:38:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=BB=99=E5=90=88=E4=BD=9C?= =?UTF-8?q?=E4=BC=99=E4=BC=B4=E7=9A=84=E6=8E=A5=E5=8F=A3=E5=92=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/partner.md | 254 +++++++++++++++++++- src/admin/controllers/partner.controller.ts | 180 +++++++++++++- src/models/match/PuzzleSession.ts | 43 ++++ src/models/shop/Shop.ts | 19 +- src/models/shop/ShopExam.ts | 21 ++ 5 files changed, 511 insertions(+), 6 deletions(-) diff --git a/doc/partner.md b/doc/partner.md index 55e0882..f8f2a65 100644 --- a/doc/partner.md +++ b/doc/partner.md @@ -2,12 +2,55 @@ ## 修改记录 - +### 20210620 +第一版 ## 说明 -1. 所有请求参数中带*号的不能为空 -2. 接口签名字段说明 +1. 所有接口均使用POST提交数据, Content-Type为application/json + +例: curl +```shell +curl --location --request POST 'https://puzzle-admin.kingsome.cn/api/partner/login' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "name": "北京金蚕网络科技有限公司", + "sid": "123456789j", + "timestamp": 1624333003587, + "sign": "c4cfdad34de8fe8787cb8c74abe7a8692e11725ff582271b57ff1ebaa00b488a", + "logo": "https://resource.kingsome.cn/test60d07f3fb40504740fdccb6e.png" +}' +``` +例: php +```php +setUrl('http://127.0.0.1:2900/api/partner/login'); +$request->setMethod(HTTP_Request2::METHOD_POST); +$request->setConfig(array( + 'follow_redirects' => TRUE +)); +$request->setHeader(array( + 'Content-Type' => 'application/json' +)); +$request->setBody('{\n "name": "北京金蚕网络科技有限公司",\n "sid": "123456789j",\n "timestamp": 1624333003587,\n "sign": "c4cfdad34de8fe8787cb8c74abe7a8692e11725ff582271b57ff1ebaa00b488a",\n "logo": "https://resource.kingsome.cn/test60d07f3fb40504740fdccb6e.png"\n}'); +try { + $response = $request->send(); + if ($response->getStatus() == 200) { + echo $response->getBody(); + } + else { + echo 'Unexpected HTTP status: ' . $response->getStatus() . ' ' . + $response->getReasonPhrase(); + } +} +catch(HTTP_Request2_Exception $e) { + echo 'Error: ' . $e->getMessage(); +} +``` +2. 所有请求参数中带*号的不能为空 +3. 接口签名字段说明 ``` # 1. 将参与签名的参数按照key=value的格式,并按照参数名ASCII字典序升序排序, 例如: @@ -16,7 +59,7 @@ var signStr = 'name=一品漫城&sid=65AB7856FE×tamp=1624332778169' var sign = HmacSHA256(signStr, secretKey) ``` -3. 如无特殊说明, 所有接口返回json, 顶级结构如下, 接口Response的数据结构说明只包含data部分 +4. 如无特殊说明, 所有接口返回json, 顶级结构如下, 接口Response的数据结构说明只包含data部分 ``` JSON { @@ -51,6 +94,7 @@ var sign = HmacSHA256(signStr, secretKey) ### 1. 获取token +> 这里获取的token用于调用页面时拼接url 1. Method: POST 2. URI: /api/partner/login @@ -77,3 +121,205 @@ var sign = HmacSHA256(signStr, secretKey) } ``` +### 2. 查询店铺信息 + +1. Method: POST +2. URI: /api/partner/shop/info + +> POST参数 + + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| shop | *店铺id, 1号接口里上传的sid | +| timestamp | *10或13位均可 | +| sign | *签名 | + +> 签名字段: shop, timestamp + + +3. Response: JSON + +```js +{ + "_id": "店铺唯一id", + "sid": "店铺的短id", + "partnerId": "你们提供的店铺唯一id", + "name": "店铺名", + "showName": "店铺显示名", + "logo": "店铺logo的url地址", + "createdAt": "店铺创建时间", + "publish": true // 是否已发布 +} +``` + +### 3. 导入题库 + +1. Method: POST +2. URI: /api/partner/puzzle/import + +> POST参数 + + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| shop| *店铺id | +| datas| *数据列表, 结构如下 | +| timestamp | *10或13位均可 | +| sign| *签名 | + +> 签名字段: shop, timestamp + +datas的数据示例 +```json +{ + "question": "烊行的“烊”字怎么念", // 题目 + "a1": "羊", // 正确答案 + "a2": "火", // 混淆答案1 + "a3": "样", // 混淆答案2 + "a4": "你猜我猜不猜8964", // 混淆答案3 + "type": 1, // 题目类型, 1: 普通的题目, 3: 问卷 + "groups": [ // 题目标签, 可当分类来用 + "问卷" + ] +} +``` + + +3. Response: JSON + +```js +{ + "insert": 1, // 插入数量 + "update": 0, // 更新同名题目数量 + "skip": 0, // skip数量 + "batch": 1624346272949 // 本次导入的批次 +} +``` + +### 4. 查询题库审核结果 + +1. Method: POST +2. URI: /api/partner/puzzle/status + +> POST参数 + + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| shop | 店铺id | +| batch | *导入时返回的批次 | +| timestamp | *10或13位均可 | +| sign| *签名 | + +> 签名字段: shop, batch, timestamp + + +3. Response: JSON + +```js +[ // 题目审核状态数组 + { + "id": "60d18ea07938dbda87d2e346", // 题目唯一id + "q": "烊行的“烊”字怎么念", // 题目 + "status": 0 // 0: 未审核, 1: 审核通过, -1: 审核未通过 + } +] +``` + + + +### 5. 挑战列表 + +1. Method: POST +2. URI: /api/partner/exams + +> POST参数 + + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| shop | 店铺id | +| timestamp | *10或13位均可 | +| sign| *签名 | +| start| 跳过的记录条数 | +| limit|读取记录条数 | +| key| 查询的关键字 | + +> 签名字段: shop, timestamp + + +3. Response: JSON + +```js +{ + "total": 1, // 总数 + "start": 0, // 当前跳过的记录条数 + "limit": 10, // 读取记录条数 + "records": [ + { + "_id": "60b4d97545f1f1530f2404de", // 挑战记录id + "name": "test", // 挑战名称 + "createdAt": "2021-05-31T12:41:25.864Z", // 记录创建时间 + "active": true, // 是否启用 + "beginTime": 0, // 开始时间 + "endTime": 1924963201000, // 结束时间 + "source": 3, // 题目来源,0: 系统题目, 1: 自定义, 2: 店铺自定义题库, 3: 混合题库 + "qcount": 2, // 每次挑战的题目数量 + "timeone": 50 // 每道题目回答的最长时间(秒) + } + ] +} +``` + +### 6. 挑战记录导出 + +1. Method: POST +2. URI: /api/partner/historys + +> POST参数 + + +| 字段 | 说明 | +| -------- | -------------------------------------- | +| shop | *店铺id | +| exam| 挑战活动id | +| timestamp | *10或13位均可 | +| sign| *签名 | +| start| 跳过的记录条数 | +| limit|读取记录条数 | + + +> 签名字段: shop, timestamp + + +3. Response: JSON + +```js +{ + "total": 6, + "start": 0, + "limit": 10, + "records": [{ // 记录数组 + "_id": "60b4d97545f1f1530f2404df", // 记录唯一id + "status": 1, // 记录状态, 0: 未开始, 1: 进行中, 9: 结束 + "createdAt": "2021-05-31T12:45:21.395Z", // 答题开始时间 + "type": 2, // 记录类型, 2: 挑战活动 + "activityId": "60b4d97545f1f1530f2404de", // 挑战活动id + "qcount": 10, // 当前局一共多少题 + "accountId": "1111", // 玩家帐号id + "questions": [{ // 当前局的所有题目 + "title": "1126", // 题目标题 + "answers": [ // 答案数组, 第一个是正确答案 + "你好啊啊啊", + "b", + "246" + ], + "answer": 0 // 玩家回答的答案, -1为未回答 + }], + "nickname": "", // 玩家昵称 + "score": 300.57172, // 得分 + "count": 2 // 玩家已回答题目数量 + }] +} +``` \ No newline at end of file diff --git a/src/admin/controllers/partner.controller.ts b/src/admin/controllers/partner.controller.ts index 635e3e1..a09208c 100644 --- a/src/admin/controllers/partner.controller.ts +++ b/src/admin/controllers/partner.controller.ts @@ -8,6 +8,12 @@ import { Admin } from '../../models/admin/Admin' import { Game } from '../../models/content/Game' import { downloadRemoteFile } from '../../services/File' import { uploadToCDN } from '../../services/TencentCDN' +import { ShopPuzzle } from '../../models/shop/ShopPuzzle' +import { AuditTask } from '../../models/AuditTask' +import { AuditSvr } from '../../services/AuditSvr' +import { ShopExam } from '../../models/shop/ShopExam' +import { PuzzleSession } from '../../models/match/PuzzleSession' +import { GameUser } from '../../models/user/GameUser' const SECRET_KEY = '37284c327e10d8b73cf4325f33a3de4b34032e3e' const passwd = customAlphabet('2345678abcdefghjkmnpqrstwxy', 10) @@ -25,7 +31,7 @@ class PartnerController extends BaseController { async login(req: any, res: any) { let { name, sname, sid, logo, timestamp, sign } = req.params if (!name || !sid || !timestamp || !sign) { - throw new ZError(10, '缺少必要参数') + throw new ZError(10, 'params mismatch') } const signKeys = ['name', 'sid', 'timestamp'] if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { @@ -67,4 +73,176 @@ class PartnerController extends BaseController { const token = await res.jwtSign({ id: account.id }) return { token } } + @router('post /api/partner/shop/list') + async shopList(req: any) { + const { shop, timestamp, sign } = req.params + if (!shop || !timestamp || !sign) { + throw new ZError(10, 'params mismatch') + } + const signKeys = ['shop', 'timestamp'] + if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { + throw new ZError(21, 'sign error') + } + const record = await Shop.findByPartnerId(shop) + if (!record) { + throw new ZError(11, 'shop not found') + } + + return record.toPartnerJson() + } + + @router('post /api/partner/puzzle/import') + async importPuzzle(req: any) { + let { datas, shop, sign, timestamp } = req.params + if (!Array.isArray(datas)) { + throw new ZError(11, '数据结构不正确, datas必须为数组') + } + if (!shop || !sign || !timestamp) { + throw new ZError(10, 'params mismatch') + } + const signKeys = ['shop', 'timestamp'] + if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { + throw new ZError(21, 'sign error') + } + const record = await Shop.findByPartnerId(shop) + if (!record) { + throw new ZError(11, 'shop not found') + } + for (let data of datas) { + data.shop = record.id + if (!data.groups && data.tags) { + data.groups = data.tags.split(',') + } + delete data.status + } + let result: any = {} + const batchTag = Date.now() + const initStatus = { status: 0, deleted: 0, is_hide: 0, quality: 1, batchTag } + try { + datas.map(data => Object.assign(data, initStatus)) + let results = await ShopPuzzle.bulkWrite( + datas.map(data => ({ + updateOne: { + filter: { shop: data.shop, question: data.question }, + update: { $set: data }, + upsert: true, + }, + })), + { ordered: false }, + ) + result.insert = results.upsertedCount + results.insertedCount + result.update = results.modifiedCount + result.skip = 0 + let records = await ShopPuzzle.find({ batchTag }) + let ids = records.map(data => data._id + '') + let id = await AuditTask.addOneTask('shop_puzzle', ids) + new AuditSvr().addTask(id) + } catch (err) { + console.log(err) + result.insert = err.upsertedCount + err.insertedCount + result.update = err.modifiedCount + } + result.batch = batchTag + return result + } + + @router('post /api/partner/puzzle/status') + async importPuzzleStatus(req: any) { + let { batch, shop, sign, timestamp } = req.params + if (!shop || !batch || !sign || !timestamp) { + throw new ZError(10, 'params mismatch') + } + const signKeys = ['batch', 'shop', 'timestamp'] + if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { + throw new ZError(21, 'sign error') + } + let records = await ShopPuzzle.find({ batchTag: batch }) + return records.map(data => { + return { + id: data._id, + q: data.question, + status: data.status, + } + }) + } + + @router('post /api/partner/exams') + async shopExams(req: any) { + let { shop, sign, timestamp } = req.params + if (!shop || !sign || !timestamp) { + throw new ZError(10, 'params mismatch') + } + const signKeys = ['shop', 'timestamp'] + if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { + throw new ZError(21, 'sign error') + } + const record = await Shop.findByPartnerId(shop) + if (!record) { + throw new ZError(11, 'shop not found') + } + let { start, limit } = req.params + limit = +limit || 10 + start = +start || 0 + req.params.shop = record.id + let { opt, sort } = ShopExam.parseQueryParam(req.params) + let records = await ShopExam.find(opt).sort(sort).skip(start).limit(limit) + let count = await ShopExam.countDocuments(opt) + let results = records.map(data => data.toPartnerJson()) + return { + total: count, + start, + limit, + records: results, + } + } + + @router('post /api/partner/historys') + async shopRecords(req: any) { + let { shop, sign, timestamp } = req.params + if (!shop || !sign || !timestamp) { + throw new ZError(10, 'params mismatch') + } + const signKeys = ['shop', 'timestamp'] + if (!checkSign({ secretKey: SECRET_KEY, data: req.params, sign, signKeys })) { + throw new ZError(21, 'sign error') + } + const shopRecord = await Shop.findByPartnerId(shop) + if (!shopRecord) { + throw new ZError(11, 'shop not found') + } + let { start, limit } = req.params + limit = +limit || 10 + start = +start || 0 + req.params.shop = shopRecord.id + let { opt, sort } = PuzzleSession.parseQueryParam(req.params) + let records = await PuzzleSession.find(opt).sort(sort).skip(start).limit(limit) + let count = await PuzzleSession.countDocuments(opt) + let results: any[] = [] + let userMap = new Map() + for (let record of records) { + let obj: any = record.toPartnerJson() + const accountId = obj.accountId + const stat = record.members.get(accountId) + let nickname = '' + if (userMap.has(accountId)) { + nickname = userMap.get(accountId) + } else { + const user = await GameUser.getByAccountID(accountId) + if (user) { + nickname = user.nickname + userMap.set(accountId, nickname) + } + } + obj.nickname = nickname + obj.score = stat.score + obj.count = stat.answer.size + results.push(obj) + } + return { + total: count, + start, + limit, + records: results, + } + } } diff --git a/src/models/match/PuzzleSession.ts b/src/models/match/PuzzleSession.ts index b61e79a..3e827a0 100644 --- a/src/models/match/PuzzleSession.ts +++ b/src/models/match/PuzzleSession.ts @@ -212,6 +212,49 @@ export class PuzzleSessionClass extends BaseModule { // @ts-ignore return this.room + this._id } + + public static parseQueryParam(params) { + let { exam, timeBegin, timeEnd, shop } = params + let opt: any = { type: 2 } + if (shop) { + opt.shop = shop + } + if (exam) { + opt.activityId = exam + } + if (timeBegin && !timeEnd) { + opt.createdAt = { $gte: new Date(timeBegin) } + } else if (timeBegin && timeEnd) { + opt['$and'] = [{ createdAt: { $gte: new Date(timeBegin) } }, { createdAt: { $lte: new Date(timeEnd) } }] + } else if (!timeBegin && timeEnd) { + opt.createdAt = { $lte: new Date(timeEnd) } + } + let sort = { _id: 1 } + return { opt, sort } + } + + public toPartnerJson() { + const exportKeys = ['_id', 'status', 'createdAt', 'type', 'activityId'] + let result: any = {} + for (let key of exportKeys) { + result[key] = this[key] + } + result.qcount = this.questions.size + let questions: any = [] + const accountId = this.members.keys().next().value + const udata = this.members.get(accountId) + result.accountId = accountId + for (let [key, data] of this.questions) { + let obj = { + title: data.title, + answers: data.answers, + answer: udata.answer.has(key) ? udata.answer.get(key) : -1, + } + questions.push(obj) + } + result.questions = questions + return result + } } export const PuzzleSession = getModelForClass(PuzzleSessionClass, { existingConnection: PuzzleSessionClass.db }) diff --git a/src/models/shop/Shop.ts b/src/models/shop/Shop.ts index 957d79c..c701e69 100644 --- a/src/models/shop/Shop.ts +++ b/src/models/shop/Shop.ts @@ -6,6 +6,7 @@ import { customAlphabet } from 'nanoid' import { isObjectId } from '../../utils/string.util' import { ZError } from '../../common/ZError' import { INIT_KEY_NUM, MongoTool } from '../../services/MongoTool' +import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses' const nanoid = customAlphabet('2345678abcdefghjkmnpqrstwxy', 10) @@ -17,6 +18,9 @@ class GameInfo { public versionid: string } +// @ts-ignore +export interface ShopClass extends Base, TimeStamps {} + @dbconn() @index({ location: '2dsphere' }) @index({ sid: 1 }, { unique: true }) @@ -41,7 +45,7 @@ class GameInfo { this.numid = sid } }) -class ShopClass extends BaseModule { +export class ShopClass extends BaseModule { @prop() public sid: string @@ -215,6 +219,10 @@ class ShopClass extends BaseModule { } } + public static findByPartnerId(pid: string) { + return Shop.findOne({ partnerId: pid }) + } + public static async sid2ID(id: string) { if (isObjectId(id)) { return id @@ -234,6 +242,15 @@ class ShopClass extends BaseModule { } return result } + + public toPartnerJson() { + const exportKeys = ['_id', 'sid', 'partnerId', 'name', 'showName', 'logo', 'createdAt', 'publish'] + let result: any = {} + for (let key of exportKeys) { + result[key] = this[key] + } + return result + } } export function isShopSid(id: string) { diff --git a/src/models/shop/ShopExam.ts b/src/models/shop/ShopExam.ts index 46f7843..8415621 100644 --- a/src/models/shop/ShopExam.ts +++ b/src/models/shop/ShopExam.ts @@ -238,6 +238,27 @@ export class ShopExamClass extends BaseModule { } return results } + + public toPartnerJson() { + const exportKeys = [ + '_id', + 'name', + 'desc', + 'logo', + 'createdAt', + 'active', + 'beginTime', + 'endTime', + 'source', + 'qcount', + 'timeone', + ] + let result: any = {} + for (let key of exportKeys) { + result[key] = this[key] + } + return result + } } export const ShopExam = getModelForClass(ShopExamClass, { existingConnection: ShopExamClass.db })