更新完善页面
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
const KEYWORD_MAX = 200
|
||||
const URL_MAX = 2048
|
||||
const BASE64_MAX = 5 * 1024 * 1024
|
||||
|
||||
const BANNED_PATTERNS = [
|
||||
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/i,
|
||||
/system\s*(prompt|message|role)/i,
|
||||
/disregard/i,
|
||||
/forget\s+(everything|all)/i,
|
||||
/act\s+as\s+(a\s+)?(developer|admin|root)/i,
|
||||
/reveal\s+(your|the)\s+(prompt|system|secret|key)/i,
|
||||
/api[_-]?key/i,
|
||||
/password|secret/i
|
||||
]
|
||||
|
||||
function sanitizeKeyword(input) {
|
||||
if (input === undefined || input === null) return ''
|
||||
const text = String(input).trim()
|
||||
if (text.length === 0) return ''
|
||||
if (text.length > KEYWORD_MAX) {
|
||||
return { error: `关键词不能超过 ${KEYWORD_MAX} 个字符` }
|
||||
}
|
||||
for (const re of BANNED_PATTERNS) {
|
||||
if (re.test(text)) return { error: '关键词包含非法内容' }
|
||||
}
|
||||
return { value: text }
|
||||
}
|
||||
|
||||
function sanitizeImageUrl(input) {
|
||||
if (!input) return { value: '' }
|
||||
const text = String(input).trim()
|
||||
if (text.length === 0) return { value: '' }
|
||||
if (text.length > URL_MAX) return { error: '图片 URL 过长' }
|
||||
if (/^https?:\/\//i.test(text)) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(text)
|
||||
} catch (e) {
|
||||
return { error: '图片 URL 格式不合法' }
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { error: '图片 URL 协议必须是 http/https' }
|
||||
}
|
||||
const host = parsed.hostname
|
||||
if (!host || host.length === 0) return { error: '图片 URL 缺少主机' }
|
||||
if (host.includes('\u0000') || /[\s<>"'`\\]/.test(host)) {
|
||||
return { error: '图片 URL 主机名非法' }
|
||||
}
|
||||
const labels = host.split('.')
|
||||
for (const label of labels) {
|
||||
if (label.length === 0 || label.length > 63) return { error: '图片 URL 主机名非法' }
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)) {
|
||||
return { error: '图片 URL 主机名非法' }
|
||||
}
|
||||
}
|
||||
if (text.includes('..') || text.includes('\u0000')) return { error: '图片 URL 格式不合法' }
|
||||
} else if (!text.startsWith('/uploads/')) {
|
||||
return { error: '图片 URL 必须是 https/http 链接或 /uploads/ 相对路径' }
|
||||
}
|
||||
return { value: text }
|
||||
}
|
||||
|
||||
function sanitizeImageBase64(input) {
|
||||
if (!input) return { error: '缺少图片数据' }
|
||||
const text = String(input)
|
||||
if (text.length > BASE64_MAX) return { error: `图片不能超过 ${(BASE64_MAX / 1024 / 1024).toFixed(1)}MB` }
|
||||
if (!/^[A-Za-z0-9+/=\r\n]+$/.test(text)) return { error: '图片 base64 格式不合法' }
|
||||
return { value: text }
|
||||
}
|
||||
|
||||
function makeCacheKey(prefix, payload) {
|
||||
const json = JSON.stringify(payload, Object.keys(payload).sort())
|
||||
return prefix + ':' + crypto.createHash('sha1').update(json).digest('hex')
|
||||
}
|
||||
|
||||
class LRU {
|
||||
constructor(max = 100, ttlMs = 5 * 60 * 1000) {
|
||||
this.max = max
|
||||
this.ttlMs = ttlMs
|
||||
this.map = new Map()
|
||||
}
|
||||
get(key) {
|
||||
const item = this.map.get(key)
|
||||
if (!item) return undefined
|
||||
if (Date.now() > item.expire) {
|
||||
this.map.delete(key)
|
||||
return undefined
|
||||
}
|
||||
this.map.delete(key)
|
||||
this.map.set(key, item)
|
||||
return item.value
|
||||
}
|
||||
set(key, value) {
|
||||
if (this.map.has(key)) this.map.delete(key)
|
||||
this.map.set(key, { value, expire: Date.now() + this.ttlMs })
|
||||
if (this.map.size > this.max) {
|
||||
const first = this.map.keys().next().value
|
||||
this.map.delete(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TokenBucket {
|
||||
constructor(capacity, refillPerSec) {
|
||||
this.capacity = capacity
|
||||
this.tokens = capacity
|
||||
this.refillPerSec = refillPerSec
|
||||
this.lastRefill = Date.now()
|
||||
this.queue = []
|
||||
}
|
||||
take() {
|
||||
this._refill()
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
_refill() {
|
||||
const now = Date.now()
|
||||
const elapsed = (now - this.lastRefill) / 1000
|
||||
if (elapsed > 0) {
|
||||
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec)
|
||||
this.lastRefill = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KEYWORD_MAX,
|
||||
URL_MAX,
|
||||
BASE64_MAX,
|
||||
sanitizeKeyword,
|
||||
sanitizeImageUrl,
|
||||
sanitizeImageBase64,
|
||||
makeCacheKey,
|
||||
LRU,
|
||||
TokenBucket
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v2 API 统一错误码系统
|
||||
*
|
||||
* 设计原则:
|
||||
* - code: 0 表示成功(与 HTTP 状态码解耦)
|
||||
* - 非零 code 表示失败,按模块分段
|
||||
* - 兼容 v1(code: 200 = 成功),通过 v2 响应头区分版本
|
||||
*
|
||||
* 错误码分段:
|
||||
* 0 — 成功
|
||||
* 1xxx — 通用错误(参数/鉴权/权限/限流)
|
||||
* 2xxx — 用户模块
|
||||
* 3xxx — 商品模块
|
||||
* 4xxx — 订单模块
|
||||
* 5xxx — 购物车模块
|
||||
* 6xxx — 库存模块
|
||||
* 7xxx — 退款模块
|
||||
* 8xxx — 积分模块
|
||||
* 9xxx — 其他模块
|
||||
*/
|
||||
|
||||
// ============ 通用错误码 1xxx ============
|
||||
const SUCCESS = 0
|
||||
|
||||
const ERR_BAD_REQUEST = 1000 // 请求参数错误
|
||||
const ERR_UNAUTHORIZED = 1001 // 未登录/Token 无效
|
||||
const ERR_FORBIDDEN = 1002 // 无权限
|
||||
const ERR_NOT_FOUND = 1003 // 资源不存在
|
||||
const ERR_CONFLICT = 1004 // 资源冲突
|
||||
const ERR_RATE_LIMIT = 1005 // 请求过于频繁
|
||||
const ERR_INTERNAL = 1006 // 服务器内部错误
|
||||
const ERR_VALIDATION = 1007 // 数据校验失败
|
||||
const ERR_DEPRECATED = 1008 // 接口已废弃
|
||||
|
||||
// ============ 用户模块 2xxx ============
|
||||
const ERR_USER_NOT_FOUND = 2001
|
||||
const ERR_USER_PASSWORD = 2002 // 密码错误
|
||||
const ERR_USER_DISABLED = 2003 // 账号已禁用
|
||||
const ERR_USER_EXISTS = 2004 // 用户已存在
|
||||
const ERR_USER_PHONE_INVALID = 2005
|
||||
|
||||
// ============ 商品模块 3xxx ============
|
||||
const ERR_GOODS_NOT_FOUND = 3001
|
||||
const ERR_GOODS_OFF_SHELF = 3002 // 商品已下架
|
||||
const ERR_GOODS_STOCK_LOW = 3003 // 库存不足
|
||||
const ERR_GOODS_NAME_DUPLICATE = 3004
|
||||
|
||||
// ============ 订单模块 4xxx ============
|
||||
const ERR_ORDER_NOT_FOUND = 4001
|
||||
const ERR_ORDER_STATUS = 4002 // 订单状态不允许此操作
|
||||
const ERR_ORDER_EMPTY = 4003 // 订单为空
|
||||
const ERR_ORDER_CANNOT_CANCEL = 4004
|
||||
|
||||
// ============ 购物车模块 5xxx ============
|
||||
const ERR_CART_ITEM_NOT_FOUND = 5001
|
||||
const ERR_CART_QUANTITY_INVALID = 5002
|
||||
const ERR_CART_GOODS_OFF_SHELF = 5003
|
||||
|
||||
// ============ 库存模块 6xxx ============
|
||||
const ERR_STOCK_NEGATIVE = 6001 // 库存不能为负
|
||||
const ERR_STOCK_LOG_NOT_FOUND = 6002
|
||||
|
||||
// ============ 退款模块 7xxx ============
|
||||
const ERR_REFUND_NOT_FOUND = 7001
|
||||
const ERR_REFUND_AMOUNT_INVALID = 7002
|
||||
const ERR_REFUND_DUPLICATE = 7003 // 已有待处理退款
|
||||
const ERR_REFUND_STATUS = 7004 // 退款状态不允许此操作
|
||||
|
||||
// ============ 积分模块 8xxx ============
|
||||
const ERR_POINTS_INSUFFICIENT = 8001
|
||||
const ERR_POINTS_GOODS_NOT_FOUND = 8002
|
||||
const ERR_POINTS_GOODS_OFF_SHELF = 8003
|
||||
const ERR_POINTS_GOODS_NO_STOCK = 8004
|
||||
const ERR_POINTS_DELTA_EXCEED = 8005 // 积分变动超限
|
||||
|
||||
// ============ 错误码映射表 ============
|
||||
const ERROR_MESSAGES = {
|
||||
[SUCCESS]: 'success',
|
||||
[ERR_BAD_REQUEST]: '请求参数错误',
|
||||
[ERR_UNAUTHORIZED]: '未登录或登录已过期',
|
||||
[ERR_FORBIDDEN]: '无权限访问',
|
||||
[ERR_NOT_FOUND]: '资源不存在',
|
||||
[ERR_CONFLICT]: '资源冲突',
|
||||
[ERR_RATE_LIMIT]: '请求过于频繁,请稍后再试',
|
||||
[ERR_INTERNAL]: '服务器内部错误',
|
||||
[ERR_VALIDATION]: '数据校验失败',
|
||||
[ERR_DEPRECATED]: '接口已废弃',
|
||||
[ERR_USER_NOT_FOUND]: '用户不存在',
|
||||
[ERR_USER_PASSWORD]: '密码错误',
|
||||
[ERR_USER_DISABLED]: '账号已禁用',
|
||||
[ERR_USER_EXISTS]: '用户已存在',
|
||||
[ERR_USER_PHONE_INVALID]: '手机号格式错误',
|
||||
[ERR_GOODS_NOT_FOUND]: '商品不存在',
|
||||
[ERR_GOODS_OFF_SHELF]: '商品已下架',
|
||||
[ERR_GOODS_STOCK_LOW]: '库存不足',
|
||||
[ERR_GOODS_NAME_DUPLICATE]: '商品名称已存在',
|
||||
[ERR_ORDER_NOT_FOUND]: '订单不存在',
|
||||
[ERR_ORDER_STATUS]: '订单状态不允许此操作',
|
||||
[ERR_ORDER_EMPTY]: '订单为空',
|
||||
[ERR_ORDER_CANNOT_CANCEL]: '订单无法取消',
|
||||
[ERR_CART_ITEM_NOT_FOUND]: '购物车商品不存在',
|
||||
[ERR_CART_QUANTITY_INVALID]: '数量无效',
|
||||
[ERR_CART_GOODS_OFF_SHELF]: '商品已下架',
|
||||
[ERR_STOCK_NEGATIVE]: '库存不能为负数',
|
||||
[ERR_STOCK_LOG_NOT_FOUND]: '库存记录不存在',
|
||||
[ERR_REFUND_NOT_FOUND]: '退款申请不存在',
|
||||
[ERR_REFUND_AMOUNT_INVALID]: '退款金额无效',
|
||||
[ERR_REFUND_DUPLICATE]: '已有待处理的退款申请',
|
||||
[ERR_REFUND_STATUS]: '退款状态不允许此操作',
|
||||
[ERR_POINTS_INSUFFICIENT]: '积分不足',
|
||||
[ERR_POINTS_GOODS_NOT_FOUND]: '积分商品不存在',
|
||||
[ERR_POINTS_GOODS_OFF_SHELF]: '积分商品已下架',
|
||||
[ERR_POINTS_GOODS_NO_STOCK]: '积分商品库存不足',
|
||||
[ERR_POINTS_DELTA_EXCEED]: '积分变动超出允许范围',
|
||||
}
|
||||
|
||||
// ============ v1 错误码 → v2 映射 ============
|
||||
const V1_TO_V2_MAP = {
|
||||
200: SUCCESS,
|
||||
400: ERR_BAD_REQUEST,
|
||||
401: ERR_UNAUTHORIZED,
|
||||
403: ERR_FORBIDDEN,
|
||||
404: ERR_NOT_FOUND,
|
||||
500: ERR_INTERNAL,
|
||||
}
|
||||
|
||||
// ============ v2 错误码 → HTTP 状态码映射 ============
|
||||
const CODE_TO_HTTP_STATUS = {
|
||||
[SUCCESS]: 200,
|
||||
[ERR_BAD_REQUEST]: 400,
|
||||
[ERR_UNAUTHORIZED]: 401,
|
||||
[ERR_FORBIDDEN]: 403,
|
||||
[ERR_NOT_FOUND]: 404,
|
||||
[ERR_CONFLICT]: 409,
|
||||
[ERR_RATE_LIMIT]: 429,
|
||||
[ERR_INTERNAL]: 500,
|
||||
[ERR_VALIDATION]: 422,
|
||||
[ERR_DEPRECATED]: 410,
|
||||
}
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/**
|
||||
* 生成 v2 标准响应
|
||||
* @param {number} code - 错误码(0 = 成功)
|
||||
* @param {*} data - 响应数据
|
||||
* @param {string} [message] - 自定义消息(默认从映射表取)
|
||||
* @returns {{ code: number, data: *, message: string }}
|
||||
*/
|
||||
function respond(code, data, message) {
|
||||
return {
|
||||
code,
|
||||
data: code === SUCCESS ? data : null,
|
||||
message: message || ERROR_MESSAGES[code] || '未知错误',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应快捷方法
|
||||
*/
|
||||
function success(data, message) {
|
||||
return respond(SUCCESS, data, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应快捷方法
|
||||
*/
|
||||
function error(code, message) {
|
||||
return respond(code, null, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 v1 风格响应转换为 v2 风格
|
||||
* v1: { code: 200, data, message }
|
||||
* v2: { code: 0, data, message }
|
||||
*/
|
||||
function fromV1(v1Body) {
|
||||
if (!v1Body || typeof v1Body.code !== 'number') return v1Body
|
||||
const v2Code = V1_TO_V2_MAP[v1Body.code] ?? v1Body.code
|
||||
return {
|
||||
code: v2Code,
|
||||
data: v2Code === SUCCESS ? v1Body.data : null,
|
||||
message: v1Body.message || ERROR_MESSAGES[v2Code] || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 v2 错误码对应的 HTTP 状态码
|
||||
*/
|
||||
function toHttpStatus(code) {
|
||||
return CODE_TO_HTTP_STATUS[code] || 400
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// 错误码常量
|
||||
SUCCESS,
|
||||
ERR_BAD_REQUEST, ERR_UNAUTHORIZED, ERR_FORBIDDEN, ERR_NOT_FOUND,
|
||||
ERR_CONFLICT, ERR_RATE_LIMIT, ERR_INTERNAL, ERR_VALIDATION, ERR_DEPRECATED,
|
||||
ERR_USER_NOT_FOUND, ERR_USER_PASSWORD, ERR_USER_DISABLED, ERR_USER_EXISTS, ERR_USER_PHONE_INVALID,
|
||||
ERR_GOODS_NOT_FOUND, ERR_GOODS_OFF_SHELF, ERR_GOODS_STOCK_LOW, ERR_GOODS_NAME_DUPLICATE,
|
||||
ERR_ORDER_NOT_FOUND, ERR_ORDER_STATUS, ERR_ORDER_EMPTY, ERR_ORDER_CANNOT_CANCEL,
|
||||
ERR_CART_ITEM_NOT_FOUND, ERR_CART_QUANTITY_INVALID, ERR_CART_GOODS_OFF_SHELF,
|
||||
ERR_STOCK_NEGATIVE, ERR_STOCK_LOG_NOT_FOUND,
|
||||
ERR_REFUND_NOT_FOUND, ERR_REFUND_AMOUNT_INVALID, ERR_REFUND_DUPLICATE, ERR_REFUND_STATUS,
|
||||
ERR_POINTS_INSUFFICIENT, ERR_POINTS_GOODS_NOT_FOUND, ERR_POINTS_GOODS_OFF_SHELF,
|
||||
ERR_POINTS_GOODS_NO_STOCK, ERR_POINTS_DELTA_EXCEED,
|
||||
// 映射表
|
||||
ERROR_MESSAGES,
|
||||
V1_TO_V2_MAP,
|
||||
CODE_TO_HTTP_STATUS,
|
||||
// 工具函数
|
||||
respond,
|
||||
success,
|
||||
error,
|
||||
fromV1,
|
||||
toHttpStatus,
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* JWT(JSON Web Token)工具函数
|
||||
* @module services/utils/jwt
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const SECRET = process.env.JWT_SECRET || (() => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET environment variable is required in production')
|
||||
}
|
||||
return 'dev-only-jwt-secret-change-in-production-' + crypto.randomBytes(16).toString('hex')
|
||||
})()
|
||||
|
||||
const ACCESS_TTL = parseInt(process.env.JWT_ACCESS_TTL || 7 * 24 * 3600)
|
||||
const REFRESH_TTL = parseInt(process.env.JWT_REFRESH_TTL || 30 * 24 * 3600)
|
||||
const ISSUER = 'miniprogram'
|
||||
const ALG = 'HS256'
|
||||
|
||||
/**
|
||||
* 编码为 URL 安全的 base64
|
||||
* @param {string|Buffer} input - 输入
|
||||
* @returns {string} 编码后的字符串
|
||||
*/
|
||||
function base64url(input) {
|
||||
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 URL 安全的 base64
|
||||
* @param {string} input - 编码后的字符串
|
||||
* @returns {Buffer} 解码后的 Buffer
|
||||
*/
|
||||
function base64urlDecode(input) {
|
||||
const pad = 4 - (input.length % 4)
|
||||
const normalized = input.replace(/-/g, '+').replace(/_/g, '/') + (pad < 4 ? '='.repeat(pad) : '')
|
||||
return Buffer.from(normalized, 'base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名 JWT
|
||||
* @param {Object} payload - JWT 载荷
|
||||
* @param {number} [ttlSeconds=ACCESS_TTL] - TTL(秒)
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function sign(payload, ttlSeconds = ACCESS_TTL) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = { alg: ALG, typ: 'JWT' }
|
||||
const body = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds,
|
||||
iss: ISSUER
|
||||
}
|
||||
const head = base64url(JSON.stringify(header))
|
||||
const data = base64url(JSON.stringify(body))
|
||||
const sig = crypto.createHmac('sha256', SECRET).update(`${head}.${data}`).digest()
|
||||
return `${head}.${data}.${base64url(sig)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT
|
||||
* @param {string} token - JWT token
|
||||
* @returns {Object|null} 验证后的载荷或 null
|
||||
*/
|
||||
function verify(token) {
|
||||
if (!token || typeof token !== 'string') return null
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const [head, data, sig] = parts
|
||||
const expected = crypto.createHmac('sha256', SECRET).update(`${head}.${data}`).digest()
|
||||
const provided = base64urlDecode(sig)
|
||||
if (expected.length !== provided.length || !crypto.timingSafeEqual(expected, provided)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(base64urlDecode(data).toString('utf8'))
|
||||
if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) return null
|
||||
if (payload.iss && payload.iss !== ISSUER) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名访问令牌
|
||||
* @param {Object} user - 用户对象
|
||||
* @returns {string} 访问令牌
|
||||
*/
|
||||
function signAccess(user) {
|
||||
return sign({ sub: user.id, role: user.role, type: 'access' }, ACCESS_TTL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名刷新令牌
|
||||
* @param {Object} user - 用户对象
|
||||
* @returns {string} 刷新令牌
|
||||
*/
|
||||
function signRefresh(user) {
|
||||
return sign({ sub: user.id, type: 'refresh' }, REFRESH_TTL)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sign,
|
||||
verify,
|
||||
signAccess,
|
||||
signRefresh,
|
||||
ACCESS_TTL,
|
||||
REFRESH_TTL
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const { query } = require('../config/database')
|
||||
|
||||
const LEGACY_PREFIX = 'legacy.'
|
||||
|
||||
async function decodeBasicAuth(token) {
|
||||
if (!token || !token.startsWith(LEGACY_PREFIX)) return null
|
||||
const raw = token.slice(LEGACY_PREFIX.length)
|
||||
const users = await query(
|
||||
'SELECT id, phone, name, avatar, points, role, status, openid FROM users WHERE token = ? AND status = 1',
|
||||
[raw]
|
||||
)
|
||||
return users[0] || null
|
||||
}
|
||||
|
||||
function toLegacyToken(token) {
|
||||
return token && token.startsWith(LEGACY_PREFIX) ? token : LEGACY_PREFIX + token
|
||||
}
|
||||
|
||||
module.exports = { decodeBasicAuth, toLegacyToken, LEGACY_PREFIX }
|
||||
@@ -1,3 +1,17 @@
|
||||
/**
|
||||
* 数据库分页查询工具函数
|
||||
* @module services/utils/pagination
|
||||
*/
|
||||
|
||||
/**
|
||||
* 分页查询函数
|
||||
* @param {Function} queryFn - 数据库查询函数
|
||||
* @param {string} sql - SQL 查询语句
|
||||
* @param {Array} params - SQL 参数
|
||||
* @param {number} [page=1] - 页码
|
||||
* @param {number} [pageSize=20] - 每页大小
|
||||
* @returns {Promise<Object>} 分页结果 { data, total, page, pageSize, totalPages }
|
||||
*/
|
||||
async function paginate(queryFn, sql, params, page = 1, pageSize = 20) {
|
||||
const p = Math.max(1, parseInt(page) || 1)
|
||||
const ps = Math.min(100, Math.max(1, parseInt(pageSize) || 20))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 密码加密和验证工具函数
|
||||
* @module services/utils/password
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const N = 16384
|
||||
const r = 8
|
||||
const p = 1
|
||||
const KEYLEN = 64
|
||||
const SALT_LEN = 16
|
||||
const MD5_LEN = 32
|
||||
|
||||
/**
|
||||
* 计算 MD5 哈希
|
||||
* @param {string} str - 输入字符串
|
||||
* @returns {string} MD5 哈希值(十六进制)
|
||||
*/
|
||||
function md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 scrypt 计算哈希
|
||||
* @param {string} password - 密码
|
||||
* @param {Buffer} [saltBuf] - 可选的 salt
|
||||
* @returns {Object} { salt, hash }
|
||||
*/
|
||||
function scryptHash(password, saltBuf) {
|
||||
const salt = saltBuf || crypto.randomBytes(SALT_LEN)
|
||||
const derived = crypto.scryptSync(String(password), salt, KEYLEN, { N, r, p, maxmem: 64 * 1024 * 1024 })
|
||||
return { salt, hash: derived }
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希密码(使用 scrypt)
|
||||
* @param {string} password - 密码
|
||||
* @returns {string} 编码后的密码哈希
|
||||
*/
|
||||
function hashPassword(password) {
|
||||
const { salt, hash } = scryptHash(password)
|
||||
const saltB64 = salt.toString('base64').replace(/=+$/, '')
|
||||
const hashB64 = hash.toString('base64').replace(/=+$/, '')
|
||||
return `scrypt$${N}$${r}$${p}$${saltB64}$${hashB64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 scrypt 密码
|
||||
* @param {string} password - 密码
|
||||
* @param {string} encoded - 编码后的密码哈希
|
||||
* @returns {boolean} 是否验证通过
|
||||
*/
|
||||
function verifyScrypt(password, encoded) {
|
||||
try {
|
||||
const parts = encoded.split('$')
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
|
||||
const saltB64 = parts[4]
|
||||
const expectedB64 = parts[5]
|
||||
const salt = Buffer.from(saltB64, 'base64')
|
||||
const expected = Buffer.from(expectedB64, 'base64')
|
||||
const { hash } = scryptHash(password, salt)
|
||||
if (hash.length !== expected.length) return false
|
||||
return crypto.timingSafeEqual(hash, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码(支持 scrypt 和旧版 MD5)
|
||||
* @param {string} password - 密码
|
||||
* @param {string} stored - 存储的密码哈希
|
||||
* @returns {boolean} 是否验证通过
|
||||
*/
|
||||
function verifyPassword(password, stored) {
|
||||
if (!stored) return false
|
||||
if (stored.startsWith('scrypt$')) return verifyScrypt(password, stored)
|
||||
if (/^[a-f0-9]{32}$/i.test(stored)) return md5(password).toLowerCase() === stored.toLowerCase()
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是旧版 MD5 哈希
|
||||
* @param {string} stored - 存储的密码哈希
|
||||
* @returns {boolean} 是否是旧版哈希
|
||||
*/
|
||||
function isLegacyHash(stored) {
|
||||
return stored && /^[a-f0-9]{32}$/i.test(stored)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要重新哈希密码
|
||||
* @param {string} stored - 存储的密码哈希
|
||||
* @returns {boolean} 是否需要重新哈希
|
||||
*/
|
||||
function needsRehash(stored) {
|
||||
return !stored || !stored.startsWith('scrypt$')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
isLegacyHash,
|
||||
needsRehash,
|
||||
md5
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 数据验证和清理工具函数
|
||||
* @module services/utils/validators
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清理并验证整数
|
||||
* @param {*} value - 输入值
|
||||
* @param {number} [fallback] - 默认值
|
||||
* @param {number} [min] - 最小值
|
||||
* @param {number} [max] - 最大值
|
||||
* @returns {number|null} 清理后的整数或 null
|
||||
*/
|
||||
function sanitizeInt(value, fallback, min, max) {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const n = parseInt(value, 10)
|
||||
if (isNaN(n)) return null
|
||||
if (min !== undefined && n < min) return null
|
||||
if (max !== undefined && n > max) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理并验证浮点数
|
||||
* @param {*} value - 输入值
|
||||
* @param {number} [fallback] - 默认值
|
||||
* @param {number} [min] - 最小值
|
||||
* @param {number} [max] - 最大值
|
||||
* @returns {number|null} 清理后的浮点数或 null
|
||||
*/
|
||||
function sanitizeFloat(value, fallback, min, max) {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const n = parseFloat(value)
|
||||
if (isNaN(n)) return null
|
||||
if (min !== undefined && n < min) return null
|
||||
if (max !== undefined && n > max) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理并验证字符串
|
||||
* @param {*} value - 输入值
|
||||
* @param {number} [max=255] - 最大长度
|
||||
* @returns {string} 清理后的字符串
|
||||
*/
|
||||
function sanitizeString(value, max = 255) {
|
||||
if (value === undefined || value === null) return ''
|
||||
const s = String(value)
|
||||
return s.length > max ? s.slice(0, max) : s
|
||||
}
|
||||
|
||||
module.exports = { sanitizeInt, sanitizeFloat, sanitizeString }
|
||||
+1
-1
@@ -77,7 +77,7 @@ async function sendOrderStatusNotification(openid, orderId, status, totalPrice)
|
||||
amount3: { value: `¥${totalPrice}` },
|
||||
date4: { value: new Date().toLocaleString('zh-CN') },
|
||||
thing5: { value: '点击查看订单详情' }
|
||||
}, `/pages/customer/order-detail/order-detail?id=${orderId}`)
|
||||
}, `/pages/customer-extra/order-detail/order-detail?id=${orderId}`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user