start
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules/
|
||||
/logs/
|
||||
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "debug",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}\\index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
75
app.js
Normal file
75
app.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import Koa from 'koa';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
import cors from 'koa-cors';
|
||||
import http from 'http';
|
||||
import { DBModel } from './models/index.js';
|
||||
import registerRoutes from './routes/index.js';
|
||||
import ResponseUtil from './utils/api_response.js';
|
||||
|
||||
class APP {
|
||||
constructor() {
|
||||
this.app = new Koa();
|
||||
this.setupDB();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupFallback();
|
||||
}
|
||||
|
||||
setupDB() {
|
||||
DBModel.init();
|
||||
}
|
||||
|
||||
setupMiddleware() {
|
||||
this.app.use(cors());
|
||||
this.app.use(bodyParser());
|
||||
|
||||
this.app.use(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const ms = Date.now() - start;
|
||||
console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.app.use(async (ctx, next) => {
|
||||
if (ctx.path === '/') {
|
||||
ResponseUtil.success(ctx, { name: 'attendant-api', version: '1.0.0' });
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
this.app.use(registerRoutes.getRoutes());
|
||||
this.app._router = registerRoutes.router;
|
||||
}
|
||||
|
||||
setupFallback() {
|
||||
this.app.use(async (ctx) => {
|
||||
ctx.status = 404;
|
||||
ctx.body = { code: 404, msg: 'Not Found' };
|
||||
});
|
||||
}
|
||||
|
||||
start(port) {
|
||||
this.server = http.createServer(this.app.callback());
|
||||
this.server.listen(port, () => {
|
||||
console.log(`Server: running on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
console.log('Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
static createKoaApp() {
|
||||
const instance = new APP();
|
||||
return instance.app;
|
||||
}
|
||||
}
|
||||
|
||||
export { APP };
|
||||
24
conf.json
Normal file
24
conf.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"port": 9010,
|
||||
"app": {
|
||||
"wxapp-escort": {
|
||||
"appid": "wxf73c79e16837af07",
|
||||
"secret": "5a061d65f4d35d19e62e83b98f6c63cf"
|
||||
}
|
||||
},
|
||||
"mongodb": {
|
||||
"str": "mongodb://huashengtec.com:6000",
|
||||
"host": "huashengtec.com",
|
||||
"db": "health",
|
||||
"option": {
|
||||
"user": "ehason",
|
||||
"pass": "Ehason_dbuser_2026",
|
||||
"dbName": "eiot_user",
|
||||
"authSource": "admin",
|
||||
"autoIndex": true,
|
||||
"socketTimeoutMS": 3000,
|
||||
"serverSelectionTimeoutMS": 30000
|
||||
},
|
||||
"debug": true
|
||||
}
|
||||
}
|
||||
191
handler/users.js
Normal file
191
handler/users.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { DBModel } from "../models/index.js";
|
||||
import ResponseUtil from "../utils/api_response.js";
|
||||
import config from "../conf.json" with { type: "json" };
|
||||
|
||||
class HandlerUser {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
// 获取微信的手机号码
|
||||
async wxGetPhoneNumber(ctx) {
|
||||
try {
|
||||
const { code, appId } = ctx.request.body;
|
||||
if (!code || !appId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少手机号凭证 code 或 appId");
|
||||
}
|
||||
|
||||
let app = config.app[appId];
|
||||
if (!app) {
|
||||
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||
}
|
||||
|
||||
// 获取access_token
|
||||
const client_credential_url = `https://api.weixin.qq.com/cgi-bin/token?appid=${app.appid}&secret=${app.secret}&grant_type=client_credential`;
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
let sessionRes = await fetch(client_credential_url);
|
||||
const resp = await sessionRes.json();
|
||||
if (!resp.access_token) {
|
||||
return ResponseUtil.internalError(ctx, "获取微信 access_token 失败");
|
||||
}
|
||||
|
||||
// 获取phoneNumber
|
||||
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${resp.access_token}`;
|
||||
const phoneRes = await fetch(phoneUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: code })
|
||||
});
|
||||
const phoneData = await phoneRes.json();
|
||||
if (phoneData.errcode) {
|
||||
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
|
||||
}
|
||||
|
||||
// 从上图获取手机号
|
||||
const phoneNumber = phoneData.phone_info?.phoneNumber;
|
||||
return ResponseUtil.success(ctx, {phoneNumber}, "获取手机号成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 微信登录
|
||||
async wxSignin(ctx) {
|
||||
try {
|
||||
const { code, phoneNumber, appId } = ctx.request.body;
|
||||
if (!code || !appId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少微信登录凭证 code 或 appId");
|
||||
}
|
||||
|
||||
let app = config.app[appId];
|
||||
if (!app) {
|
||||
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||
}
|
||||
|
||||
// 通过 code 换取 openid/session_key
|
||||
const sessionUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${app.appid}&secret=${app.secret}&js_code=${code}&grant_type=authorization_code`;
|
||||
const wxSessionRes = await fetch(sessionUrl);
|
||||
const sessionData = await wxSessionRes.json();
|
||||
if (sessionData.errcode) {
|
||||
return ResponseUtil.error(ctx, `微信接口错误: ${sessionData.errmsg}`, null, 400);
|
||||
}
|
||||
|
||||
const { openid } = sessionData;
|
||||
if (!openid) {
|
||||
return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400);
|
||||
}
|
||||
|
||||
let user = await DBModel.User.findOne({ "social.wechat.openid": openid });
|
||||
if (!user) {
|
||||
if (!phoneNumber) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
||||
}
|
||||
|
||||
const newUser = {
|
||||
profile: { name: phoneNumber, mobile: phoneNumber, },
|
||||
social: {
|
||||
wechat: { openid: openid },
|
||||
},
|
||||
status: { account: "normal", },
|
||||
app: {
|
||||
attendant: { role: ["patient"], },
|
||||
},
|
||||
};
|
||||
user = await DBModel.User.setUser(newUser);
|
||||
} else if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) {
|
||||
user.profile.mobile = phoneNumber;
|
||||
}
|
||||
|
||||
const token = await this.genToken(user._id.toString());
|
||||
user.security.token = token;
|
||||
user.security.tokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await user.save();
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
delete user.security.passwd;
|
||||
delete user.security.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user }, "登录成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// update user
|
||||
async updateUser(ctx) {
|
||||
try {
|
||||
const userInfo = ctx.request.body;
|
||||
if (!userInfo) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少用户信息");
|
||||
}
|
||||
|
||||
// 从 token 获取当前用户
|
||||
const token = ctx.request.body?.token
|
||||
|| ctx.request.query?.token
|
||||
|| ctx.header?.authorization
|
||||
|| ctx.header?.token;
|
||||
|
||||
if (!token) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||
}
|
||||
|
||||
const user = await DBModel.User.findOne({ "security.token": token });
|
||||
if (!user) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||
}
|
||||
|
||||
// 检查 token 是否过期
|
||||
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
const updatedUser = await DBModel.User.updateFromUserInfo(
|
||||
user._id,
|
||||
userInfo
|
||||
);
|
||||
|
||||
if (!updatedUser) {
|
||||
return ResponseUtil.internalError(ctx, "更新用户失败");
|
||||
}
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
const safeUser = updatedUser.toObject();
|
||||
delete safeUser.security?.passwd;
|
||||
delete safeUser.security?.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user: safeUser }, "更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
async signout(ctx) {
|
||||
const token = ctx.request.body?.token
|
||||
|| ctx.request.query?.token
|
||||
|| ctx.header?.authorization
|
||||
|| ctx.header?.token;
|
||||
|
||||
if (!token) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||
}
|
||||
|
||||
const user = await DBModel.User.findOne({ "security.token": token });
|
||||
if (user) {
|
||||
user.security.token = null;
|
||||
user.security.tokenExpiry = null;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, null, "退出登录成功");
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
async genToken(uid) {
|
||||
const crypto = await import("crypto");
|
||||
const hash = crypto.createHash("md5");
|
||||
hash.update(uid + Date.now() + Math.random());
|
||||
return hash.digest("hex");
|
||||
}
|
||||
}
|
||||
|
||||
export { HandlerUser };
|
||||
5
index.js
Normal file
5
index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { APP } from './app.js'
|
||||
|
||||
// HTTP server
|
||||
const koaApp = new APP();
|
||||
koaApp.start(9010);
|
||||
45
models/index.js
Normal file
45
models/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import { UserSchema } from "./schema/user.js"
|
||||
import config from '../conf.json' with { type: 'json' };
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
class MongoDBSchema {
|
||||
constructor() {
|
||||
this.dbConnection = null;
|
||||
this.User = null;
|
||||
this.EscortRecord = null;
|
||||
this.Hospital = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
mongoose.set("debug", config.mongodb.debug);
|
||||
|
||||
this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option);
|
||||
this.dbConnection.on("error", () => {
|
||||
logger.error.bind(logger, "...mongodb connect error ...")
|
||||
});
|
||||
this.dbConnection.on("connected", async () => {
|
||||
logger.info("Mongodb: " + config.mongodb.str + " connected");
|
||||
});
|
||||
this.dbConnection.on("disconnected", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnected")
|
||||
);
|
||||
this.dbConnection.on("reconnected", () =>
|
||||
logger.info("Mongodb: " + config.mongodb.str + " reconnected")
|
||||
);
|
||||
this.dbConnection.on("disconnecting", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnecting")
|
||||
);
|
||||
this.dbConnection.on("close", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " closed")
|
||||
);
|
||||
|
||||
this.User = this.dbConnection.model('user', UserSchema)
|
||||
}
|
||||
}
|
||||
|
||||
const DBModel = new MongoDBSchema()
|
||||
export { DBModel };
|
||||
|
||||
293
models/schema/user.js
Normal file
293
models/schema/user.js
Normal file
@@ -0,0 +1,293 @@
|
||||
"use strict";
|
||||
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* 用户Schema定义
|
||||
*
|
||||
* 包含6个主要分类块:基本信息、安全信息、社交信息、状态信息、中医相关信息和元数据。
|
||||
* 支持多种登录方式和中医系统中的角色权限管理。
|
||||
*/
|
||||
const UserSchema = mongoose.Schema(
|
||||
{
|
||||
// 基本信息 - 用户的个人标识信息
|
||||
profile: {
|
||||
name: { type: String, comment: '用户姓名' },
|
||||
pinyin: { type: String, default: '', comment: '姓名的拼音,用于搜索' },
|
||||
pinyinFL: { type: String, default: '', comment: '姓名拼音的首字母,用于搜索' },
|
||||
mobile: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '手机号码' },
|
||||
email: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '电子邮箱' },
|
||||
idnumber: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '身份证号码' },
|
||||
ssn: { type: String, index: true, trim: true, unique: true, sparse: true, comment: '社保卡号' },
|
||||
sex: { type: String, enum: ["male", "female"], default: "male", comment: '性别' },
|
||||
birth: { type: Date, default: 0, comment: '出生日期' },
|
||||
},
|
||||
|
||||
// 安全信息 - 用户的登录凭证和安全相关数据
|
||||
security: {
|
||||
passwd: { type: String, comment: '密码(加密存储)' },
|
||||
passwdSalt: { type: String, comment: '密码盐值' },
|
||||
token: { type: String, index: true, sparse: true, comment: '认证令牌' },
|
||||
tokenExpiry: { type: Date, comment: '令牌过期时间' },
|
||||
failedLoginAttempts: { type: Number, default: 0, comment: '失败登录尝试次数' },
|
||||
lockedUntil: { type: Date, comment: '账户锁定时间' },
|
||||
},
|
||||
|
||||
// 位置信息 - 用户的省份、城市和区县
|
||||
location: {
|
||||
province: { type: String, default: '', comment: '省份' },
|
||||
city: { type: String, default: '', comment: '城市' },
|
||||
district: { type: String, default: '', comment: '区县' },
|
||||
},
|
||||
|
||||
// 地址信息 - 用户多个地址(用于邮寄等)
|
||||
addresses: [
|
||||
{
|
||||
label: { type: String, default: '', comment: '地址标签,如"家庭"、"公司"' },
|
||||
province: { type: String, default: '', comment: '省份' },
|
||||
city: { type: String, default: '', comment: '城市' },
|
||||
district: { type: String, default: '', comment: '区县' },
|
||||
address: { type: String, default: '', comment: '详细地址' },
|
||||
postcode: { type: String, default: '', comment: '邮政编码' },
|
||||
isDefault: { type: Boolean, default: false, comment: '是否默认地址' },
|
||||
},
|
||||
],
|
||||
|
||||
// 社交信息 - 用户的社交账号关联
|
||||
social: {
|
||||
wechat:{
|
||||
account: { type: String, default: "", comment: '微信账号' },
|
||||
unionid: { type: String, index: true, unique: true, sparse: true, comment: '微信UnionID' },
|
||||
openid: { type: String, index: true, unique: true, sparse: true, comment: '微信OpenID' },
|
||||
}
|
||||
},
|
||||
|
||||
// 状态信息 - 用户账户的状态标记
|
||||
status: {
|
||||
account: { type: String, enum: ["normal", "lock"], default: "normal", comment: '账户状态' },
|
||||
},
|
||||
|
||||
app: {
|
||||
admin: {
|
||||
role: [{ type: String, enum: ["admin"], comment: '用户在admin应用中的权限' }],
|
||||
},
|
||||
// 中医相关信息 - 用户在中医系统中的角色和权限
|
||||
attendant: {
|
||||
role: [{ type: String, enum: ["admin", "attendant", "patient"], comment: '用户在attendant应用中的权限' }],
|
||||
},
|
||||
},
|
||||
|
||||
// 元数据 - 系统管理信息
|
||||
meta: {
|
||||
createtime: { type: Date, default: Date.now, comment: '创建时间' },
|
||||
updatetime: { type: Date, default: Date.now, comment: '更新时间' },
|
||||
},
|
||||
},
|
||||
{
|
||||
minimize: false, // 允许保存空对象
|
||||
strict: false, // 允许添加Schema中未定义的字段
|
||||
collection: "user", // MongoDB集合名称
|
||||
timestamps: false, // 不自动添加createdAt和updatedAt字段
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据用户标识信息查找用户
|
||||
*
|
||||
* 支持通过手机号、邮箱、token或微信unionid查找用户
|
||||
*
|
||||
* @param {Object} _user - 包含查找条件的用户对象
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 找到的用户对象或null
|
||||
*/
|
||||
UserSchema.statics.findUser = async function (_user, cb) {
|
||||
let filter = {};
|
||||
if (_user.profile && _user.profile.mobile) {
|
||||
filter = { "profile.mobile": _user.profile.mobile };
|
||||
} else if (_user.profile && _user.profile.email) {
|
||||
filter = { "profile.email": new RegExp(_user.profile.email, "i") };
|
||||
} else if (_user.security && _user.security.token) {
|
||||
filter = { "security.token": new RegExp(_user.security.token, "i") };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.findOne(filter, cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置/更新用户信息
|
||||
*
|
||||
* @param {Object} _user - 要设置或更新的用户对象
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 操作结果
|
||||
*/
|
||||
UserSchema.statics.setUser = async function (_user, cb) {
|
||||
const filter = {};
|
||||
let update = {};
|
||||
|
||||
// 如果有_id字段,说明是更新操作
|
||||
if (_user._id) {
|
||||
filter._id = _user._id;
|
||||
// 移除_id字段,避免尝试更新它
|
||||
delete _user._id;
|
||||
update = _user;
|
||||
update.meta.updatetime = Date.now();
|
||||
} else {
|
||||
// 新建用户
|
||||
update = _user;
|
||||
update.meta = {
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (filter._id) {
|
||||
// 更新用户
|
||||
return await this.findOneAndUpdate(filter, { $set: update }, { new: true, upsert: true }, cb);
|
||||
} else {
|
||||
// 创建新用户
|
||||
const newUser = new this(update);
|
||||
return await newUser.save(cb);
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据用户信息更新用户文档
|
||||
*
|
||||
* @param {ObjectId} userId - 用户ID
|
||||
* @param {Object} userInfo - 包含要更新字段的用户信息对象
|
||||
* @returns {Promise<Object|null>} 更新后的用户对象或null
|
||||
*/
|
||||
UserSchema.statics.updateFromUserInfo = async function (userId, userInfo) {
|
||||
const updateData = {};
|
||||
|
||||
updateData['meta.updatetime'] = Date.now();
|
||||
|
||||
if (userInfo.profile) {
|
||||
if (userInfo.profile.name !== undefined) {
|
||||
updateData['profile.name'] = userInfo.profile.name;
|
||||
}
|
||||
if (userInfo.profile.sex !== undefined) {
|
||||
updateData['profile.sex'] = userInfo.profile.sex;
|
||||
}
|
||||
if (userInfo.profile.birth !== undefined) {
|
||||
updateData['profile.birth'] = userInfo.profile.birth ? new Date(userInfo.profile.birth) : null;
|
||||
}
|
||||
if (userInfo.profile.mobile !== undefined) {
|
||||
updateData['profile.mobile'] = userInfo.profile.mobile;
|
||||
}
|
||||
}
|
||||
|
||||
if (userInfo.location) {
|
||||
if (userInfo.location.province !== undefined) {
|
||||
updateData['location.province'] = userInfo.location.province;
|
||||
}
|
||||
if (userInfo.location.city !== undefined) {
|
||||
updateData['location.city'] = userInfo.location.city;
|
||||
}
|
||||
if (userInfo.location.district !== undefined) {
|
||||
updateData['location.district'] = userInfo.location.district;
|
||||
}
|
||||
}
|
||||
|
||||
if (userInfo.addresses) {
|
||||
updateData['addresses'] = userInfo.addresses;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.findOneAndUpdate(
|
||||
{ _id: userId },
|
||||
{ $set: updateData },
|
||||
{ new: true, upsert: false }
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*
|
||||
* @param {ObjectId} _id - 用户ID
|
||||
* @param {string} mobile - 手机号码
|
||||
* @param {string} email - 电子邮箱
|
||||
* @param {string} wxunionid - 微信unionid
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 操作结果
|
||||
*/
|
||||
UserSchema.statics.delUser = async function (_id, mobile, email, wxunionid, cb) {
|
||||
const filter = {};
|
||||
|
||||
if (_id) {
|
||||
filter._id = _id;
|
||||
} else if (mobile) {
|
||||
filter['profile.mobile'] = mobile;
|
||||
} else if (email) {
|
||||
filter['profile.email'] = email;
|
||||
} else if (wxunionid) {
|
||||
filter['social.wechat.unionid'] = wxunionid;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.findOneAndDelete(filter, cb);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 增加失败登录尝试次数
|
||||
*
|
||||
* @param {ObjectId} _id - 用户ID
|
||||
* @returns {Promise<Object|null>} 操作结果
|
||||
*/
|
||||
UserSchema.statics.incrementFailedLoginAttempts = async function (_id) {
|
||||
try {
|
||||
return await this.findOneAndUpdate(
|
||||
{ _id },
|
||||
{
|
||||
$inc: { 'security.failedLoginAttempts': 1 },
|
||||
'meta.updatetime': Date.now()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置失败登录尝试次数
|
||||
*
|
||||
* @param {ObjectId} _id - 用户ID
|
||||
* @returns {Promise<Object|null>} 操作结果
|
||||
*/
|
||||
UserSchema.statics.resetFailedLoginAttempts = async function (_id) {
|
||||
try {
|
||||
return await this.findOneAndUpdate(
|
||||
{ _id },
|
||||
{
|
||||
'security.failedLoginAttempts': 0,
|
||||
'security.lockedUntil': null,
|
||||
'meta.updatetime': Date.now()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 索引设置 - 优化查询性能
|
||||
|
||||
// 手机号和密码复合索引,用于登录验证
|
||||
UserSchema.index({ "profile.mobile": 1, "security.passwd": 1 });
|
||||
|
||||
export { UserSchema };
|
||||
1492
package-lock.json
generated
Normal file
1492
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "api_user",
|
||||
"version": "1.0.0",
|
||||
"description": "user api service",
|
||||
"type": "module",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"koa": "^2.15.3",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-cors": "^0.0.16",
|
||||
"koa-router": "^12.0.1",
|
||||
"mongoose": "^8.4.0",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
38
routes/index.js
Normal file
38
routes/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Router from 'koa-router';
|
||||
import { HandlerUser } from '../handler/users.js';
|
||||
|
||||
class ApiRouter {
|
||||
constructor() {
|
||||
this.router = new Router();
|
||||
this.handler = new HandlerUser();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
const userRouter = new Router({ prefix: '/user' });
|
||||
|
||||
userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler));
|
||||
userRouter.post('/wxsignin', this.handler.wxSignin.bind(this.handler));
|
||||
userRouter.post('/update', this.handler.updateUser.bind(this.handler));
|
||||
userRouter.post('/signout', this.handler.signout.bind(this.handler));
|
||||
|
||||
this.router.use(userRouter.routes());
|
||||
|
||||
this.printRoutes(this.router.stack);
|
||||
}
|
||||
|
||||
getRoutes() {
|
||||
return this.router.routes();
|
||||
}
|
||||
|
||||
printRoutes(stack) {
|
||||
for (const layer of stack) {
|
||||
if (layer.path) {
|
||||
const methods = layer.methods.filter(m => m !== '_all');
|
||||
methods.forEach(m => console.log(` [${m.toUpperCase()}] ${layer.path}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiRouter();
|
||||
85
utils/api_response.js
Normal file
85
utils/api_response.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 响应工具类
|
||||
* 提供统一的响应格式
|
||||
*/
|
||||
class ResponseUtil {
|
||||
/**
|
||||
* 成功响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {Object} data - 响应数据
|
||||
* @param {string} message - 响应消息
|
||||
*/
|
||||
static success(ctx, data, message = '操作成功') {
|
||||
ctx.status = 200;
|
||||
ctx.body = {
|
||||
code: 0,
|
||||
msg: message,
|
||||
data,
|
||||
ts: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
* @param {number} errorCode - 应用错误码
|
||||
*/
|
||||
static error(ctx, message, data = null, errorCode = 500) {
|
||||
// 根据规则,只要进入controller,http状态码都是200
|
||||
ctx.status = 200;
|
||||
ctx.body = {
|
||||
code: errorCode,
|
||||
msg: message,
|
||||
data,
|
||||
ts: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求参数错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static badRequest(ctx, message = '请求参数错误', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授权响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static unauthorized(ctx, message = '未授权', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止访问响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static forbidden(ctx, message = '禁止访问', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static notFound(ctx, message = '资源不存在', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器内部错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static internalError(ctx, message = '服务器内部错误', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default ResponseUtil;
|
||||
48
utils/logger.js
Normal file
48
utils/logger.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const logDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
const LEVELS = {
|
||||
ERROR: 'ERROR',
|
||||
WARN: 'WARN',
|
||||
INFO: 'INFO',
|
||||
DEBUG: 'DEBUG'
|
||||
};
|
||||
|
||||
const getCurrentTimestamp = () => {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
const writeLog = (level, message, meta = {}) => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
console.log(`[${timestamp}] ${level}: ${message}`, meta);
|
||||
|
||||
const logFileName = `${new Date().toISOString().slice(0, 10)}.log`;
|
||||
const logFilePath = path.join(logDir, logFileName);
|
||||
|
||||
fs.appendFile(logFilePath, JSON.stringify(logEntry) + '\n', (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to write log to file:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const logger = {
|
||||
error: (message, meta) => writeLog(LEVELS.ERROR, message, meta),
|
||||
warn: (message, meta) => writeLog(LEVELS.WARN, message, meta),
|
||||
info: (message, meta) => writeLog(LEVELS.INFO, message, meta),
|
||||
debug: (message, meta) => writeLog(LEVELS.DEBUG, message, meta)
|
||||
};
|
||||
|
||||
export default logger;
|
||||
Reference in New Issue
Block a user