更新完善页面

This commit is contained in:
董海洋
2026-06-03 14:15:55 +08:00
parent 4b7ae9c933
commit 1675662537
57 changed files with 7625 additions and 883 deletions
+141
View File
@@ -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
}
+217
View File
@@ -0,0 +1,217 @@
/**
* v2 API 统一错误码系统
*
* 设计原则:
* - code: 0 表示成功(与 HTTP 状态码解耦)
* - 非零 code 表示失败,按模块分段
* - 兼容 v1code: 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
View File
@@ -0,0 +1,112 @@
/**
* JWTJSON 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
}
+19
View File
@@ -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 }
+14
View File
@@ -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))
+107
View File
@@ -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
}
+52
View File
@@ -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
View File
@@ -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 = {