init: 小程序后台 — 到期提醒、定时任务、Docker部署配置
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(node -e \"const s = require\\('sharp'\\); console.log\\('sharp version:', s.versions ? JSON.stringify\\(s.versions\\) : 'ok'\\); console.log\\('sharp loaded successfully'\\);\")",
|
||||||
|
"Bash(node -e \"const fs=require\\('fs'\\);const d=fs.readFileSync\\('/dev/stdin','utf8'\\);const j=JSON.parse\\(d\\);const s=j.packages['node_modules/sharp'];console.log\\(s?'sharp in lockfile version:'+\\(s.version||'unknown'\\):'sharp NOT in package-lock'\\);\")",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(ssh *)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\middleware\\\\adminAuth.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/middleware/adminAuth.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\routes\\\\admin.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\public\\\\admin\\\\index.html\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\app.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/app.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\docker-compose.yml\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/docker-compose.yml)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\Dockerfile\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/Dockerfile)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/models/User.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/models/User.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/auth.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/auth.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/admin.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)",
|
||||||
|
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no public/admin/index.html root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(GIT_SSH_COMMAND='ssh -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no' git push *)",
|
||||||
|
"Bash(scp *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
README.md
|
||||||
|
project.md
|
||||||
|
*.md
|
||||||
|
*.bat
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
scripts/
|
||||||
|
OwnershipEquity/
|
||||||
|
nginx-api.conf
|
||||||
|
nul
|
||||||
|
backend-deploy.tar.gz
|
||||||
|
new-backend-deploy.zip
|
||||||
+1
-1
@@ -21,7 +21,7 @@ BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT
|
|||||||
BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
||||||
|
|
||||||
# 服务器地址
|
# 服务器地址
|
||||||
SERVER_URL=https://api.dxz99wyr.cn
|
SERVER_URL=https://api-miniapp.dxz99wyr.cn
|
||||||
|
|
||||||
# 数据导出加密密钥(建议设置一个复杂的密钥)
|
# 数据导出加密密钥(建议设置一个复杂的密钥)
|
||||||
EXPORT_ENCRYPT_KEY=your_export_encrypt_key_here
|
EXPORT_ENCRYPT_KEY=your_export_encrypt_key_here
|
||||||
|
|||||||
+11
@@ -40,3 +40,14 @@ build/
|
|||||||
|
|
||||||
# MongoDB local data
|
# MongoDB local data
|
||||||
mongodb/
|
mongodb/
|
||||||
|
|
||||||
|
# Deploy archives
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Claude conversation logs
|
||||||
|
*-claude.txt
|
||||||
|
|
||||||
|
# Junk files
|
||||||
|
nul
|
||||||
|
VIP:*
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:24-slim
|
||||||
|
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY public/ ./public/
|
||||||
|
|
||||||
|
RUN mkdir -p public/uploads/avatars public/avatars public/admin
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "src/app.js"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: quanyixiaozhushou-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
- WECHAT_APPID=wxa83262674846ca1a
|
||||||
|
- WECHAT_APPSECRET=365653aa1214a5523a6a0e7d793eec6a
|
||||||
|
- MONGODB_URI=mongodb://mongo:27017/quanyixiaozhushou
|
||||||
|
- JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
||||||
|
- JWT_EXPIRES_IN=7d
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT
|
||||||
|
- BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
||||||
|
- SERVER_URL=https://api-miniapp.dxz99wyr.cn
|
||||||
|
- EXPORT_ENCRYPT_KEY=QuanYiXiaoZhuShou_2026_Secret_Key
|
||||||
|
- ADMIN_KEY=quanyiAdmin2026
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/public/uploads
|
||||||
|
- ./public/avatars:/app/public/avatars
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: quanyixiaozhushou-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "27018:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_DATABASE=quanyixiaozhushou
|
||||||
|
healthcheck:
|
||||||
|
test: echo 'db.runCommand("ping").ok' | mongosh --quiet
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads_data:
|
||||||
|
mongo_data:
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name api-miniapp.dxz99wyr.cn;
|
||||||
|
|
||||||
|
ssl_certificate /ssl/cert.pem;
|
||||||
|
ssl_certificate_key /ssl/cret.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /home/QuanYiXiaoZhuShou/Backend/public/uploads/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>权益小助手 - 管理后台</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--primary: #1a73e8; --primary-hover: #1557b0; --danger: #d93025;
|
||||||
|
--bg: #f5f7fa; --card: #fff; --text: #202124; --text-secondary: #5f6368;
|
||||||
|
--border: #dadce0; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
.login-card { background: var(--card); padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.12); width: 100%; max-width: 400px; text-align: center; }
|
||||||
|
.login-card h1 { font-size: 22px; margin-bottom: 8px; }
|
||||||
|
.login-card p { color: var(--text-secondary); margin-bottom: 24px; font-size: 14px; }
|
||||||
|
.login-card input { width: 100%; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 15px; outline: none; transition: border .2s; }
|
||||||
|
.login-card input:focus { border-color: var(--primary); }
|
||||||
|
.login-card button { width: 100%; margin-top: 16px; padding: 12px; background: var(--primary); color: #fff; border: none; border-radius: var(--radius); font-size: 15px; cursor: pointer; font-weight: 500; }
|
||||||
|
.login-card button:hover { background: var(--primary-hover); }
|
||||||
|
.login-card .err { color: var(--danger); margin-top: 12px; font-size: 14px; min-height: 20px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 0 20px; display: flex; justify-content: space-between; align-items: center; height: 56px; }
|
||||||
|
.header h2 { font-size: 18px; font-weight: 600; }
|
||||||
|
.header button { padding: 6px 16px; background: none; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.header button:hover { background: #f1f3f4; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||||
|
.stat-card { background: var(--card); padding: 20px; border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||||
|
.stat-card .label { font-size: 13px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.stat-card .value { font-size: 28px; font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.filters { background: var(--card); padding: 16px 20px; border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.filters input, .filters select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
||||||
|
.filters input:focus, .filters select:focus { border-color: var(--primary); }
|
||||||
|
.filters input { flex: 1; min-width: 180px; }
|
||||||
|
.filters select { min-width: 100px; }
|
||||||
|
.filters label { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.filters button { padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; background: var(--card); }
|
||||||
|
.filters button:hover { background: #f1f3f4; }
|
||||||
|
.filters .btn-search { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.filters .btn-search:hover { background: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th { text-align: left; padding: 12px 16px; background: #f8f9fa; color: var(--text-secondary); font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||||
|
th.sortable { cursor: pointer; user-select: none; transition: background .2s; }
|
||||||
|
th.sortable:hover { background: #e8eaed; }
|
||||||
|
th.sortable .arrow { margin-left: 4px; font-size: 11px; color: #999; }
|
||||||
|
th.sortable.active { color: var(--primary); }
|
||||||
|
th.sortable.active .arrow { color: var(--primary); }
|
||||||
|
td { padding: 10px 16px; border-bottom: 1px solid #f1f3f4; }
|
||||||
|
tr:hover td { background: #f8f9fa; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||||
|
.badge-active { background: #e6f4ea; color: #137333; }
|
||||||
|
.badge-inactive { background: #fef7e0; color: #b06000; }
|
||||||
|
.badge-banned { background: #fce8e6; color: #c5221f; }
|
||||||
|
.badge-vip { background: #e8f0fe; color: #1967d2; }
|
||||||
|
.btn-detail { padding: 4px 12px; background: var(--primary); color: #fff; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; }
|
||||||
|
.btn-detail:hover { background: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination { padding: 16px 20px; display: flex; justify-content: center; align-items: center; gap: 12px; }
|
||||||
|
.pagination button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; cursor: pointer; background: var(--card); }
|
||||||
|
.pagination button:hover:not(:disabled) { background: #f1f3f4; }
|
||||||
|
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.pagination span { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.4); z-index: 1000; display: flex; justify-content: center; align-items: flex-start; padding-top: 40px; }
|
||||||
|
.modal { background: var(--card); border-radius: 12px; width: 100%; max-width: 640px; max-height: 85vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
|
||||||
|
.modal-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background: var(--card); z-index: 1; }
|
||||||
|
.modal-header h3 { font-size: 16px; }
|
||||||
|
.modal-header button { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); padding: 4px; }
|
||||||
|
.modal-body { padding: 24px; }
|
||||||
|
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
.modal-footer button { padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||||
|
.btn-save { background: var(--primary); color: #fff; border: none; }
|
||||||
|
.btn-save:hover { background: var(--primary-hover); }
|
||||||
|
.btn-cancel { background: var(--card); border: 1px solid var(--border); }
|
||||||
|
.btn-cancel:hover { background: #f1f3f4; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
||||||
|
.field input:focus, .field select:focus { border-color: var(--primary); }
|
||||||
|
.field .ro { padding: 8px 12px; background: #f8f9fa; border-radius: 6px; font-size: 14px; word-break: break-all; color: var(--text-secondary); }
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.avatar-thumb { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border); }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast { position: fixed; top: 20px; right: 20px; z-index: 2000; padding: 12px 24px; border-radius: 8px; color: #fff; font-size: 14px; animation: slideIn .3s; }
|
||||||
|
.toast-ok { background: #137333; }
|
||||||
|
.toast-err { background: var(--danger); }
|
||||||
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #e3e3e3; border-top-color: var(--primary); border-radius: 50%; animation: spin .6s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loading-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
||||||
|
.empty-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="loginScreen" class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>权益小助手</h1>
|
||||||
|
<p>管理后台</p>
|
||||||
|
<input type="password" id="keyInput" placeholder="请输入管理密钥" onkeydown="if(event.key==='Enter')login()">
|
||||||
|
<button onclick="login()">登 录</button>
|
||||||
|
<div class="err" id="loginErr"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Screen -->
|
||||||
|
<div id="appScreen" class="hidden">
|
||||||
|
<div class="header">
|
||||||
|
<h2>权益小助手 · 管理后台</h2>
|
||||||
|
<button onclick="logout()">退出登录</button>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
<div class="filters">
|
||||||
|
<input type="text" id="searchInput" placeholder="搜索 userId / 昵称..." onkeydown="if(event.key==='Enter')searchUsers()">
|
||||||
|
<select id="statusFilter">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="active">活跃</option>
|
||||||
|
<option value="inactive">未激活</option>
|
||||||
|
<option value="banned">封禁</option>
|
||||||
|
</select>
|
||||||
|
<label><input type="checkbox" id="vipFilter" onchange="searchUsers()"> 仅VIP</label>
|
||||||
|
<button class="btn-search" onclick="searchUsers()">搜索</button>
|
||||||
|
<button onclick="resetFilters()">重置</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable" onclick="toggleSort('userId')" id="th-userId">userId</th>
|
||||||
|
<th class="sortable" onclick="toggleSort('nickname')" id="th-nickname">昵称</th>
|
||||||
|
<th>状态</th><th>VIP</th>
|
||||||
|
<th class="sortable" onclick="toggleSort('ocrCount')" id="th-ocrCount">OCR</th>
|
||||||
|
<th class="sortable" onclick="toggleSort('platformCount')" id="th-platformCount">平台</th>
|
||||||
|
<th class="sortable" onclick="toggleSort('lastLoginAt')" id="th-lastLoginAt">最后登录</th>
|
||||||
|
<th class="sortable" onclick="toggleSort('loginDays')" id="th-loginDays">活跃天数</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Edit Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="modalOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>用户详情</h3>
|
||||||
|
<button onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" onclick="closeModal()">取消</button>
|
||||||
|
<button class="btn-save" id="btnSave" onclick="saveUser()">保存修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const S = {
|
||||||
|
key: sessionStorage.getItem('admin_key') || '',
|
||||||
|
page: 1,
|
||||||
|
pages: 1,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
editingUser: null
|
||||||
|
};
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (S.key) headers['Authorization'] = 'Bearer ' + S.key;
|
||||||
|
const res = await fetch(path, { headers, ...opts });
|
||||||
|
if (res.status === 401) {
|
||||||
|
sessionStorage.removeItem('admin_key');
|
||||||
|
S.key = '';
|
||||||
|
showLogin();
|
||||||
|
throw new Error('会话已过期');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok && !data.success) throw new Error(data.error || '请求失败');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('loginScreen').classList.remove('hidden');
|
||||||
|
document.getElementById('appScreen').classList.add('hidden');
|
||||||
|
document.getElementById('loginErr').textContent = '';
|
||||||
|
document.getElementById('keyInput').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, ok) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const key = document.getElementById('keyInput').value.trim();
|
||||||
|
if (!key) { document.getElementById('loginErr').textContent = '请输入管理密钥'; return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
document.getElementById('loginErr').textContent = d.error || '密钥无效';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
S.key = key;
|
||||||
|
sessionStorage.setItem('admin_key', key);
|
||||||
|
document.getElementById('loginScreen').classList.add('hidden');
|
||||||
|
document.getElementById('appScreen').classList.remove('hidden');
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loginErr').textContent = '网络错误,请重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
sessionStorage.removeItem('admin_key');
|
||||||
|
S.key = '';
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadStats(), loadUsers()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/admin/stats');
|
||||||
|
const s = d.data;
|
||||||
|
document.getElementById('stats').innerHTML = [
|
||||||
|
{ label: '总用户', value: s.totalUsers },
|
||||||
|
{ label: '活跃用户', value: s.activeUsers },
|
||||||
|
{ label: 'VIP 用户', value: s.vipUsers },
|
||||||
|
{ label: '封禁用户', value: s.bannedUsers },
|
||||||
|
{ label: '今日新增', value: s.todayNewUsers }
|
||||||
|
].map(c => `<div class="stat-card"><div class="label">${c.label}</div><div class="value">${c.value}</div></div>`).join('');
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const tbody = document.getElementById('tableBody');
|
||||||
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="9"><div class="spinner"></div> 加载中...</td></tr>';
|
||||||
|
try {
|
||||||
|
const search = document.getElementById('searchInput').value.trim();
|
||||||
|
const status = document.getElementById('statusFilter').value;
|
||||||
|
const isVip = document.getElementById('vipFilter').checked;
|
||||||
|
const params = new URLSearchParams({ page: S.page, limit: 20 });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (isVip) params.set('isVip', 'true');
|
||||||
|
params.set('sortBy', S.sortBy);
|
||||||
|
params.set('order', S.sortOrder);
|
||||||
|
const d = await api('/api/admin/users?' + params);
|
||||||
|
const { users, pagination } = d.data;
|
||||||
|
S.pages = pagination.pages || 0;
|
||||||
|
if (users.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="9">没有找到匹配的用户</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = users.map(u => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(u.userId)}</td>
|
||||||
|
<td>${esc(u.nickname || '-')}</td>
|
||||||
|
<td>${statusBadge(u.status)}</td>
|
||||||
|
<td>${u.isVip ? '<span class="badge badge-vip">是</span>' : '否'}</td>
|
||||||
|
<td>${u.ocrCount}/${u.ocrCountTotal}</td>
|
||||||
|
<td>${u.platformCount}/${u.platformLimit}</td>
|
||||||
|
<td>${fmtDate(u.lastLoginAt)}</td>
|
||||||
|
<td>${u.loginDays || 1}</td>
|
||||||
|
<td><button class="btn-detail" onclick="openModal('${u._id}')">详情</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
renderPagination();
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="9">加载失败</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination() {
|
||||||
|
document.getElementById('pagination').innerHTML = `
|
||||||
|
<button onclick="prevPage()" ${S.page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||||
|
<span>第 ${S.page} 页 / 共 ${S.pages} 页</span>
|
||||||
|
<button onclick="nextPage()" ${S.page >= S.pages ? 'disabled' : ''}>下一页</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(field) {
|
||||||
|
if (S.sortBy === field) {
|
||||||
|
S.sortOrder = S.sortOrder === 'desc' ? 'asc' : 'desc';
|
||||||
|
} else {
|
||||||
|
S.sortBy = field;
|
||||||
|
S.sortOrder = 'desc';
|
||||||
|
}
|
||||||
|
S.page = 1;
|
||||||
|
renderSortIndicators();
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSortIndicators() {
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
const arrow = th.querySelector('.arrow');
|
||||||
|
if (arrow) arrow.remove();
|
||||||
|
th.classList.remove('active');
|
||||||
|
});
|
||||||
|
const active = document.getElementById('th-' + S.sortBy);
|
||||||
|
if (active) {
|
||||||
|
active.classList.add('active');
|
||||||
|
active.innerHTML += '<span class="arrow">' + (S.sortOrder === 'asc' ? '▲' : '▼') + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() { if (S.page > 1) { S.page--; loadUsers(); } }
|
||||||
|
function nextPage() { if (S.page < S.pages) { S.page++; loadUsers(); } }
|
||||||
|
|
||||||
|
function searchUsers() { S.page = 1; loadUsers(); }
|
||||||
|
function resetFilters() {
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
document.getElementById('statusFilter').value = '';
|
||||||
|
document.getElementById('vipFilter').checked = false;
|
||||||
|
S.page = 1;
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openModal(id) {
|
||||||
|
document.getElementById('modalOverlay').classList.remove('hidden');
|
||||||
|
document.getElementById('modalBody').innerHTML = '<div class="spinner"></div> 加载中...';
|
||||||
|
try {
|
||||||
|
const d = await api('/api/admin/users/' + id);
|
||||||
|
S.editingUser = d.data;
|
||||||
|
renderModal(d.data);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('加载用户详情失败', false);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(u) {
|
||||||
|
const body = document.getElementById('modalBody');
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>用户ID</label><div class="ro">${esc(u.userId)}</div></div>
|
||||||
|
<div class="field"><label>OpenID</label><div class="ro">${u.openid ? u.openid.substring(0,6) + '***' + u.openid.slice(-4) : '-'}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>头像</label>${u.avatarUrl ? `<img class="avatar-thumb" src="${esc(u.avatarUrl)}" onerror="this.style.display='none'">` : '<div class="ro">无</div>'}</div>
|
||||||
|
<div class="field"><label>注册时间</label><div class="ro">${fmtDate(u.createdAt)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>最后登录</label><div class="ro">${fmtDate(u.lastLoginAt)}</div></div>
|
||||||
|
<div class="field"><label>更新时间</label><div class="ro">${fmtDate(u.updatedAt)}</div></div>
|
||||||
|
</div>
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>昵称</label><input id="edit-nickname" value="${esc(u.nickname || '')}"></div>
|
||||||
|
<div class="field"><label>手机号</label><input id="edit-phone" value="${esc(u.phoneNumber || '')}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>状态</label><select id="edit-status">
|
||||||
|
<option value="active" ${u.status==='active'?'selected':''}>活跃</option>
|
||||||
|
<option value="inactive" ${u.status==='inactive'?'selected':''}>未激活</option>
|
||||||
|
<option value="banned" ${u.status==='banned'?'selected':''}>封禁</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="field"><label>VIP</label><select id="edit-vip" onchange="toggleVipExpire()">
|
||||||
|
<option value="true" ${u.isVip?'selected':''}>是</option>
|
||||||
|
<option value="false" ${!u.isVip?'selected':''}>否</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="vipExpireField" style="${u.isVip?'':'display:none'}">
|
||||||
|
<label>VIP 到期时间</label>
|
||||||
|
<input type="date" id="edit-vipExpire" value="${u.vipExpireAt ? u.vipExpireAt.substring(0,10) : ''}">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>OCR 剩余次数</label><input type="number" id="edit-ocrCount" value="${u.ocrCount || 0}" min="0"></div>
|
||||||
|
<div class="field"><label>OCR 总次数</label><input type="number" id="edit-ocrTotal" value="${u.ocrCountTotal || 0}" min="0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field"><label>平台限额</label><input type="number" id="edit-platformLimit" value="${u.platformLimit || 0}" min="0"></div>
|
||||||
|
<div class="field"><label>当前平台数</label><input type="number" id="edit-platformCount" value="${u.platformCount || 0}" min="0"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVipExpire() {
|
||||||
|
document.getElementById('vipExpireField').style.display =
|
||||||
|
document.getElementById('edit-vip').value === 'true' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modalOverlay').classList.add('hidden');
|
||||||
|
S.editingUser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
if (!S.editingUser) return;
|
||||||
|
const btn = document.getElementById('btnSave');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '保存中...';
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
nickname: document.getElementById('edit-nickname').value.trim(),
|
||||||
|
phoneNumber: document.getElementById('edit-phone').value.trim(),
|
||||||
|
status: document.getElementById('edit-status').value,
|
||||||
|
isVip: document.getElementById('edit-vip').value === 'true',
|
||||||
|
vipExpireAt: document.getElementById('edit-vip').value === 'true' ? document.getElementById('edit-vipExpire').value || null : null,
|
||||||
|
ocrCount: parseInt(document.getElementById('edit-ocrCount').value) || 0,
|
||||||
|
ocrCountTotal: parseInt(document.getElementById('edit-ocrTotal').value) || 0,
|
||||||
|
platformLimit: parseInt(document.getElementById('edit-platformLimit').value) || 0,
|
||||||
|
platformCount: parseInt(document.getElementById('edit-platformCount').value) || 0
|
||||||
|
};
|
||||||
|
await api('/api/admin/users/' + S.editingUser._id, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
showToast('保存成功', true);
|
||||||
|
loadUsers();
|
||||||
|
loadStats();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '保存失败', false);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '保存修改';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
function statusBadge(s) {
|
||||||
|
const map = { active: 'badge-active', inactive: 'badge-inactive', banned: 'badge-banned' };
|
||||||
|
const label = { active: '活跃', inactive: '未激活', banned: '封禁' };
|
||||||
|
return `<span class="badge ${map[s] || ''}">${label[s] || s}</span>`;
|
||||||
|
}
|
||||||
|
function fmtDate(d) { if (!d) return '-'; const t = new Date(d); return t.getFullYear()+'-'+String(t.getMonth()+1).padStart(2,'0')+'-'+String(t.getDate()).padStart(2,'0')+' '+String(t.getHours()).padStart(2,'0')+':'+String(t.getMinutes()).padStart(2,'0'); }
|
||||||
|
|
||||||
|
// Init
|
||||||
|
if (S.key) {
|
||||||
|
document.getElementById('loginScreen').classList.add('hidden');
|
||||||
|
document.getElementById('appScreen').classList.remove('hidden');
|
||||||
|
renderSortIndicators();
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function assignUserIds() {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log('数据库已连接');
|
||||||
|
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const users = await db.collection('users').find({}).toArray();
|
||||||
|
console.log(`找到 ${users.length} 个用户`);
|
||||||
|
|
||||||
|
for (let i = 0; i < users.length; i++) {
|
||||||
|
const user = users[i];
|
||||||
|
const userId = (i + 1).toString().padStart(8, '0');
|
||||||
|
|
||||||
|
await db.collection('users').updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{ $set: { userId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`已分配: ${user.nickname || '(空昵称)'} -> ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('全部分配完成!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('错误:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignUserIds();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function setVip() {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log('数据库已连接');
|
||||||
|
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const result = await db.collection('users').updateOne(
|
||||||
|
{ userId: '00000001' },
|
||||||
|
{ $set: { isVip: true, vipExpireAt: new Date('2026-12-31') } }
|
||||||
|
);
|
||||||
|
console.log('更新结果:', result.modifiedCount);
|
||||||
|
|
||||||
|
const user = await db.collection('users').findOne({ userId: '00000001' });
|
||||||
|
console.log('用户信息:', JSON.stringify({
|
||||||
|
nickname: user.nickname,
|
||||||
|
userId: user.userId,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipExpireAt: user.vipExpireAt
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('错误:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVip();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const adminAuth = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ success: false, error: '未提供管理密钥' });
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
if (token !== process.env.ADMIN_KEY) {
|
||||||
|
return res.status(401).json({ success: false, error: '管理密钥无效' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { adminAuth };
|
||||||
@@ -83,6 +83,14 @@ const userSchema = new mongoose.Schema({
|
|||||||
platformCount: {
|
platformCount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
|
},
|
||||||
|
loginDays: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
lastLoginDay: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { adminAuth } = require('../middleware/adminAuth');
|
||||||
|
|
||||||
|
router.post('/login', (req, res) => {
|
||||||
|
const { key } = req.body;
|
||||||
|
if (!key) {
|
||||||
|
return res.status(400).json({ success: false, error: '缺少管理密钥' });
|
||||||
|
}
|
||||||
|
if (key !== process.env.ADMIN_KEY) {
|
||||||
|
return res.status(401).json({ success: false, error: '管理密钥无效' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { message: '验证成功' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', adminAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [totalUsers, activeUsers, bannedUsers, vipUsers, todayNewUsers] = await Promise.all([
|
||||||
|
User.countDocuments(),
|
||||||
|
User.countDocuments({ status: 'active' }),
|
||||||
|
User.countDocuments({ status: 'banned' }),
|
||||||
|
User.countDocuments({ isVip: true }),
|
||||||
|
User.countDocuments({ createdAt: { $gte: today } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
bannedUsers,
|
||||||
|
vipUsers,
|
||||||
|
todayNewUsers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/users', adminAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
isVip,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
order = 'desc'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query.$or = [
|
||||||
|
{ userId: { $regex: search, $options: 'i' } },
|
||||||
|
{ nickname: { $regex: search, $options: 'i' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && ['active', 'inactive', 'banned'].includes(status)) {
|
||||||
|
query.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVip === 'true') {
|
||||||
|
query.isVip = true;
|
||||||
|
} else if (isVip === 'false') {
|
||||||
|
query.isVip = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = order === 'asc' ? 1 : -1;
|
||||||
|
const sortField = ['userId', 'createdAt', 'lastLoginAt', 'nickname', 'ocrCount', 'platformCount', 'loginDays'].includes(sortBy) ? sortBy : 'createdAt';
|
||||||
|
|
||||||
|
const pageNum = Math.max(1, parseInt(page));
|
||||||
|
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 20));
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
User.find(query)
|
||||||
|
.select('-openid -unionid -__v')
|
||||||
|
.sort({ [sortField]: sortOrder })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limitNum)
|
||||||
|
.lean(),
|
||||||
|
User.countDocuments(query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
users,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limitNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/users/:id', adminAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.params.id).select('-__v').lean();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, error: '用户不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/users/:id', adminAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const allowedUpdates = [
|
||||||
|
'nickname', 'phoneNumber', 'status', 'isVip',
|
||||||
|
'vipExpireAt', 'ocrCount', 'ocrCountTotal',
|
||||||
|
'platformLimit', 'platformCount'
|
||||||
|
];
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
for (const key of allowedUpdates) {
|
||||||
|
if (req.body[key] !== undefined) {
|
||||||
|
updates[key] = req.body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.status && !['active', 'inactive', 'banned'].includes(updates.status)) {
|
||||||
|
return res.status(400).json({ success: false, error: '无效的用户状态' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.isVip === false && !req.body.vipExpireAt) {
|
||||||
|
updates.vipExpireAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findByIdAndUpdate(
|
||||||
|
req.params.id,
|
||||||
|
{ $set: updates },
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
).select('-__v').lean();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, error: '用户不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+11
-2
@@ -76,11 +76,20 @@ router.post('/wechat-login', async (req, res, next) => {
|
|||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
user.nickname = userInfo.nickName || user.nickname;
|
user.nickname = userInfo.nickName || user.nickname;
|
||||||
user.avatarUrl = userInfo.avatarUrl || user.avatarUrl;
|
user.avatarUrl = userInfo.avatarUrl || user.avatarUrl;
|
||||||
user.lastLoginAt = new Date();
|
|
||||||
await user.save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
if (user.lastLoginDay !== today) {
|
||||||
|
user.loginDays = (user.loginDays || 1) + 1;
|
||||||
|
user.lastLoginDay = today;
|
||||||
|
}
|
||||||
|
user.lastLoginAt = now;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user._id, openid: user.openid },
|
{ id: user._id, openid: user.openid },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
|
|||||||
+154
-13
@@ -1,13 +1,47 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { auth } = require('../middleware/auth');
|
const { auth } = require('../middleware/auth');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { downloadAndSaveAvatar } = require('../services/avatarService');
|
const { downloadAndSaveAvatar } = require('../services/avatarService');
|
||||||
|
const { compressAvatar } = require('../services/imageService');
|
||||||
|
|
||||||
|
const uploadDir = path.join(__dirname, '../../public/uploads/avatars');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname || '') || '.jpg';
|
||||||
|
cb(null, 'avatar-' + uniqueSuffix + ext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||||
|
const extname = allowedTypes.test(path.extname(file.originalname || '').toLowerCase());
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype || '');
|
||||||
|
if (extname || mimetype) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('只允许上传图片文件'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/profile', auth, async (req, res, next) => {
|
router.get('/profile', auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const user = await User.findById(req.user._id)
|
const user = await User.findById(req.user._id);
|
||||||
.select('userId nickname avatarUrl status isVip vipExpireAt ocrCount ocrCountTotal platformLimit platformCount lastLoginAt');
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -19,17 +53,17 @@ router.get('/profile', auth, async (req, res, next) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
userId: user.userId,
|
userId: user.userId || '',
|
||||||
nickname: user.nickname,
|
nickname: user.nickname || '',
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl || '',
|
||||||
status: user.status,
|
status: user.status || 'active',
|
||||||
isVip: user.isVip,
|
isVip: user.isVip || false,
|
||||||
vipExpireAt: user.vipExpireAt,
|
vipExpireAt: user.vipExpireAt || null,
|
||||||
ocrCount: user.ocrCount,
|
ocrCount: user.ocrCount || 10,
|
||||||
ocrCountTotal: user.ocrCountTotal,
|
ocrCountTotal: user.ocrCountTotal || 10,
|
||||||
platformLimit: user.platformLimit,
|
platformLimit: user.platformLimit || 15,
|
||||||
platformCount: user.platformCount,
|
platformCount: user.platformCount || 0,
|
||||||
lastLoginAt: user.lastLoginAt
|
lastLoginAt: user.lastLoginAt || null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -70,6 +104,62 @@ router.put('/profile', auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/avatar', auth, upload.single('avatar'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
console.error('[头像上传] 未收到文件,req.body keys:', Object.keys(req.body || {}));
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '没有上传文件'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[头像上传] 收到文件:', req.file.originalname, '大小:', req.file.size, '类型:', req.file.mimetype);
|
||||||
|
console.log('[头像上传] 临时路径:', req.file.path);
|
||||||
|
|
||||||
|
const originalPath = req.file.path;
|
||||||
|
try {
|
||||||
|
await compressAvatar(originalPath);
|
||||||
|
console.log('[头像上传] 压缩完成');
|
||||||
|
} catch (compressError) {
|
||||||
|
console.error('[头像上传] 压缩失败:', compressError.message);
|
||||||
|
console.error('[头像上传] 压缩错误栈:', compressError.stack);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: '图片处理失败,请重试'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalFilename = req.file.filename;
|
||||||
|
const ext = path.extname(finalFilename).toLowerCase();
|
||||||
|
if (ext !== '.jpg' && ext !== '.jpeg') {
|
||||||
|
const newFilename = finalFilename.replace(ext, '.jpg');
|
||||||
|
const newPath = path.join(uploadDir, newFilename);
|
||||||
|
fs.renameSync(originalPath, newPath);
|
||||||
|
finalFilename = newFilename;
|
||||||
|
console.log('[头像上传] 文件名从', req.file.filename, '改为', newFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = `${process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn'}/uploads/avatars/${finalFilename}`;
|
||||||
|
|
||||||
|
const user = await User.findByIdAndUpdate(
|
||||||
|
req.user._id,
|
||||||
|
{ avatarUrl },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
url: avatarUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/stats', auth, async (req, res, next) => {
|
router.get('/stats', auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const Equity = require('../models/Equity');
|
const Equity = require('../models/Equity');
|
||||||
@@ -96,4 +186,55 @@ router.get('/stats', auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/growth-stats', auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dailyCounts = await User.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
createdAt: { $gte: thirtyDaysAgo }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
$dateToString: { format: '%Y-%m-%d', date: '$createdAt', timezone: 'Asia/Shanghai' }
|
||||||
|
},
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { _id: 1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countMap = {};
|
||||||
|
dailyCounts.forEach(item => {
|
||||||
|
countMap[item._id] = item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = [];
|
||||||
|
let cumulative = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 30; i++) {
|
||||||
|
const d = new Date(thirtyDaysAgo);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
const month = d.getMonth() + 1;
|
||||||
|
const day = d.getDate();
|
||||||
|
|
||||||
|
cumulative += countMap[dateStr] || 0;
|
||||||
|
list.push({
|
||||||
|
date: `${month}月${day}日`,
|
||||||
|
userCount: cumulative
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: { list } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
const AVATAR_DIR = path.join(process.cwd(), 'public', 'avatars');
|
||||||
|
|
||||||
|
if (!fs.existsSync(AVATAR_DIR)) {
|
||||||
|
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAndSaveAvatar(avatarUrl) {
|
||||||
|
if (!avatarUrl || !avatarUrl.startsWith('http')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(avatarUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${crypto.randomUUID()}.jpg`;
|
||||||
|
const filepath = path.join(AVATAR_DIR, filename);
|
||||||
|
|
||||||
|
const compressed = await sharp(response.data)
|
||||||
|
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
fs.writeFileSync(filepath, compressed);
|
||||||
|
|
||||||
|
const serverUrl = process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn';
|
||||||
|
return `${serverUrl}/avatars/${filename}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载头像失败:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadAndSaveAvatar
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const AVATAR_MAX_SIZE = 400;
|
||||||
|
const AVATAR_QUALITY = 80;
|
||||||
|
|
||||||
|
async function compressAvatar(inputPath, outputPath) {
|
||||||
|
try {
|
||||||
|
const exists = fs.existsSync(inputPath);
|
||||||
|
const stats = exists ? fs.statSync(inputPath) : null;
|
||||||
|
console.log('[图像压缩] 输入文件:', inputPath, '存在:', exists, '大小:', stats?.size, '字节');
|
||||||
|
|
||||||
|
const metadata = await sharp(inputPath).metadata();
|
||||||
|
console.log('[图像压缩] 原图尺寸:', metadata.width, 'x', metadata.height, '格式:', metadata.format);
|
||||||
|
|
||||||
|
if (metadata.width <= AVATAR_MAX_SIZE && metadata.height <= AVATAR_MAX_SIZE && metadata.format === 'jpeg') {
|
||||||
|
console.log('[图像压缩] 跳过压缩(已满足要求)');
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalOutputPath = outputPath || inputPath;
|
||||||
|
|
||||||
|
if (finalOutputPath === inputPath) {
|
||||||
|
const tempPath = inputPath + '.tmp';
|
||||||
|
await sharp(inputPath)
|
||||||
|
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||||
|
.toFile(tempPath);
|
||||||
|
|
||||||
|
fs.unlinkSync(inputPath);
|
||||||
|
fs.renameSync(tempPath, inputPath);
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharp(inputPath)
|
||||||
|
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||||
|
.toFile(finalOutputPath);
|
||||||
|
|
||||||
|
return finalOutputPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('图像压缩失败:', error.message);
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressAvatarBuffer(buffer) {
|
||||||
|
try {
|
||||||
|
const compressed = await sharp(buffer)
|
||||||
|
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('图像缓冲区压缩失败:', error.message);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compressAvatar,
|
||||||
|
compressAvatarBuffer
|
||||||
|
};
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const BAIDU_OCR_API = {
|
||||||
|
token: 'https://aip.baidubce.com/oauth/2.0/token',
|
||||||
|
generalBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic',
|
||||||
|
accurateBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic'
|
||||||
|
};
|
||||||
|
|
||||||
|
let accessTokenCache = {
|
||||||
|
token: null,
|
||||||
|
expireAt: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (accessTokenCache.token && accessTokenCache.expireAt > now + 60000) {
|
||||||
|
return accessTokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.BAIDU_OCR_API_KEY;
|
||||||
|
const secretKey = process.env.BAIDU_OCR_SECRET_KEY;
|
||||||
|
|
||||||
|
if (!apiKey || !secretKey) {
|
||||||
|
throw new Error('百度OCR配置缺失:请检查 BAIDU_OCR_API_KEY 和 BAIDU_OCR_SECRET_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(BAIDU_OCR_API.token, null, {
|
||||||
|
params: {
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: apiKey,
|
||||||
|
client_secret: secretKey
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token, expires_in } = response.data;
|
||||||
|
|
||||||
|
if (!access_token) {
|
||||||
|
throw new Error(`获取百度OCR Token失败: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenCache = {
|
||||||
|
token: access_token,
|
||||||
|
expireAt: now + (expires_in * 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
return access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recognizeText(imageBase64, options = {}) {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
|
const url = `${BAIDU_OCR_API.generalBasic}?access_token=${accessToken}`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('image', imageBase64);
|
||||||
|
|
||||||
|
if (options.language_type) {
|
||||||
|
params.append('language_type', options.language_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(url, params.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recognizeTextAccurate(imageBase64, options = {}) {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
|
const url = `${BAIDU_OCR_API.accurateBasic}?access_token=${accessToken}`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('image', imageBase64);
|
||||||
|
|
||||||
|
if (options.language_type) {
|
||||||
|
params.append('language_type', options.language_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(url, params.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPrice(type, times) {
|
||||||
|
const prices = {
|
||||||
|
year: '120',
|
||||||
|
halfYear: '60',
|
||||||
|
quarter: '30',
|
||||||
|
month: '10'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'times') {
|
||||||
|
const timesCount = parseInt(times) || 1;
|
||||||
|
return String(timesCount * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices[type] || '10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMembershipInfo(ocrResult) {
|
||||||
|
if (!ocrResult || !ocrResult.words_result || ocrResult.words_result.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = ocrResult.words_result.map(w => w.words).join('\n');
|
||||||
|
const lines = ocrResult.words_result.map(w => w.words);
|
||||||
|
|
||||||
|
const platformKeywords = {
|
||||||
|
'淘宝': ['淘宝', 'taobao', '88vip', '88VIP'],
|
||||||
|
'京东': ['京东', 'jd', 'JD', 'plus', 'PLUS'],
|
||||||
|
'拼多多': ['拼多多', 'pdd', 'PDD'],
|
||||||
|
'美团': ['美团', 'meituan'],
|
||||||
|
'饿了么': ['饿了么', 'eleme', 'ele.me'],
|
||||||
|
'抖音': ['抖音', 'douyin', 'tiktok'],
|
||||||
|
'快手': ['快手', 'kuaishou'],
|
||||||
|
'网易云音乐': ['网易云', 'netease', '163'],
|
||||||
|
'QQ音乐': ['QQ音乐', 'qq音乐'],
|
||||||
|
'优酷': ['优酷', 'youku'],
|
||||||
|
'爱奇艺': ['爱奇艺', 'iqiyi'],
|
||||||
|
'腾讯视频': ['腾讯视频', 'v.qq'],
|
||||||
|
'哔哩哔哩': ['哔哩哔哩', 'bilibili', 'B站'],
|
||||||
|
'喜马拉雅': ['喜马拉雅', 'ximalaya'],
|
||||||
|
'知乎': ['知乎', 'zhihu'],
|
||||||
|
'百度网盘': ['百度网盘', '百度云']
|
||||||
|
};
|
||||||
|
|
||||||
|
let platform = null;
|
||||||
|
for (const [pName, keywords] of Object.entries(platformKeywords)) {
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
if (text.toLowerCase().includes(keyword.toLowerCase())) {
|
||||||
|
platform = pName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typePatterns = [
|
||||||
|
{ patterns: [/年卡/, /年度会员/, /\d+年/], type: 'year' },
|
||||||
|
{ patterns: [/半年卡/, /半年会员/, /6个月/], type: 'halfYear' },
|
||||||
|
{ patterns: [/季卡/, /季度会员/, /3个月/], type: 'quarter' },
|
||||||
|
{ patterns: [/月卡/, /月度会员/, /1个月/], type: 'month' },
|
||||||
|
{ patterns: [/次卡/, /按次数/], type: 'times' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let detectedType = 'month';
|
||||||
|
for (const { patterns, type } of typePatterns) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
detectedType = type;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (detectedType !== 'month') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePatterns = [
|
||||||
|
/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||||
|
/(\d{4})(\d{2})(\d{2})/,
|
||||||
|
/(\d{2})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||||
|
/有效期[至到::]\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||||
|
/到期[时间日]:?\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||||
|
/(\d{4})\.(\d{1,2})\.(\d{1,2})/
|
||||||
|
];
|
||||||
|
|
||||||
|
let expireDate = '9999-12-31';
|
||||||
|
for (const pattern of datePatterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
let year = match[1];
|
||||||
|
const month = match[2].padStart(2, '0');
|
||||||
|
const day = match[3].padStart(2, '0');
|
||||||
|
|
||||||
|
if (year.length === 2) {
|
||||||
|
year = '20' + year;
|
||||||
|
}
|
||||||
|
|
||||||
|
expireDate = `${year}-${month}-${day}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const benefitKeywords = [
|
||||||
|
'优酷', '网易云', 'QQ音乐', '酷狗', '酷我',
|
||||||
|
'爱奇艺', '腾讯视频', '芒果TV', '哔哩哔哩',
|
||||||
|
'饿了么', '美团', '高德打车', '滴滴',
|
||||||
|
'夸克', '百度网盘', '迅雷',
|
||||||
|
'喜马拉雅', '知乎', '微博',
|
||||||
|
'淘票票', '飞猪', '希尔顿', '万豪',
|
||||||
|
'视频会员', '超级吃货卡', '天猫超市', '天猫国际',
|
||||||
|
'阿里健康', '专属客服', '省钱卡', '网盘会员',
|
||||||
|
'打车会员', '金卡', '皮肤装扮', '每日领券',
|
||||||
|
'出行礼遇', '专享立减', '游戏特权'
|
||||||
|
];
|
||||||
|
|
||||||
|
const benefits = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
for (const keyword of benefitKeywords) {
|
||||||
|
if (line.includes(keyword)) {
|
||||||
|
const existing = benefits.find(b => b.name === keyword);
|
||||||
|
if (!existing) {
|
||||||
|
benefits.push({
|
||||||
|
name: keyword,
|
||||||
|
type: detectedType,
|
||||||
|
times: detectedType === 'times' ? null : null,
|
||||||
|
price: getDefaultPrice(detectedType, null),
|
||||||
|
expireDate: expireDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benefits.length === 0 && platform) {
|
||||||
|
benefits.push({
|
||||||
|
name: platform,
|
||||||
|
type: detectedType,
|
||||||
|
times: null,
|
||||||
|
price: getDefaultPrice(detectedType, null),
|
||||||
|
expireDate: expireDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return benefits;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAccessToken,
|
||||||
|
recognizeText,
|
||||||
|
recognizeTextAccurate,
|
||||||
|
extractMembershipInfo
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user