Compare commits

..

4 Commits

Author SHA1 Message Date
Developer 1ca73922d8 feat: 管理后台用户详情页增加订阅状态和测试消息发送
- 后端新增 /api/admin/users/:id 返回用户订阅列表
- 后端新增 POST /api/admin/users/:id/test-message 发送测试订阅消息
- 前端用户详情页展示订阅状态(只读,按类型分组)
- 前端增加发送版本更新测试消息功能
2026-05-20 20:01:38 +08:00
Developer 344b25a2d8 fix: 放宽 CORS origin 配置为允许所有来源以支持小程序环境 2026-05-18 22:00:40 +08:00
Developer 608ee017d4 fix: 配置微信小程序域名的跨域 CORS
- 添加 servicewechat.com、miniapp-api-test.dxz99wyr.cn、api-miniapp.dxz99wyr.cn 到 CORS origin
- 允许 GET/POST/PUT/DELETE/OPTIONS 方法
- 允许 Content-Type/Authorization/Accept/Origin/X-Requested-With 请求头
2026-05-18 21:41:42 +08:00
Developer 1117bd0bd7 refactor: 端口改为内部映射,通过 Nginx 反向代理访问
- docker-compose.test.yml: API 和 MongoDB 端口改为 expose(仅容器内部可见)
- webhook-server.js: 端口改为 19001,绑定 127.0.0.1(仅本机访问)
- 新增 deploy/nginx-test.conf Nginx 反向代理配置
  - API: miniapp-api-test.dxz99wyr.cn → miniapp-api_test:3001
  - Webhook: miniapp-api-test-webhook.dxz99wyr.cn/webhook → 127.0.0.1:19001
- 更新 setup.sh 和 README.md 文档
2026-05-18 20:51:54 +08:00
8 changed files with 180 additions and 15 deletions
+23 -5
View File
@@ -52,7 +52,7 @@ chmod +x deploy/setup.sh
在 Git 仓库设置中添加 Webhook:
- **Payload URL**: `http://your-server-ip:9001/webhook`
- **Payload URL**: `https://miniapp-api-test-webhook.dxz99wyr.cn/webhook`
- **Content type**: `application/json`
- **Secret**: 你设置的 `WEBHOOK_SECRET`
- **触发事件**: Push events (main 分支)
@@ -60,23 +60,41 @@ chmod +x deploy/setup.sh
### 5. 开放防火墙端口
```bash
# 开放 9001 端口(webhook)和 3001 端口(API)
ufw allow 9001/tcp
ufw allow 3001/tcp
# 仅需开放 80/443 端口,所有服务通过 Nginx 反向代理
ufw allow 80/tcp
ufw allow 443/tcp
```
### 6. 配置 Nginx 反向代理 (推荐)
```nginx
# 复制到服务器的 Nginx 配置目录
# cp deploy/nginx-test.conf /opt/ALiYunManager/nginx/conf.d/miniapp-api-test.conf
# docker exec main-nginx nginx -t && docker exec main-nginx nginx -s reload
server {
listen 80;
server_name miniapp-api-test.dxz99wyr.cn;
location / {
proxy_pass http://localhost:3001;
proxy_pass http://miniapp-api_test:3001;
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;
}
}
server {
listen 80;
server_name miniapp-api-test-webhook.dxz99wyr.cn;
location /webhook {
proxy_pass http://127.0.0.1:19001/webhook;
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;
}
}
```
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name miniapp-api-test.dxz99wyr.cn;
location / {
proxy_pass http://miniapp-api_test:3001;
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;
}
}
server {
listen 80;
server_name miniapp-api-test-webhook.dxz99wyr.cn;
location /webhook {
proxy_pass http://127.0.0.1:19001/webhook;
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;
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ echo "=========================================="
echo " 配置完成!"
echo "=========================================="
echo ""
echo "Webhook 接收地址: http://$(curl -s ifconfig.me):9001/webhook"
echo "Webhook 接收地址: https://miniapp-api-test-webhook.dxz99wyr.cn/webhook"
echo "API 测试地址: https://miniapp-api-test.dxz99wyr.cn"
echo ""
echo "查看 webhook 日志: journalctl -u miniapp-api_test-webhook -f"
+4 -3
View File
@@ -3,7 +3,7 @@ const { exec } = require('child_process');
const path = require('path');
const crypto = require('crypto');
const PORT = 9001;
const PORT = 19001;
const DEPLOY_DIR = '/opt/miniapp-api_test';
const COMPOSE_FILE = 'docker-compose.test.yml';
@@ -98,8 +98,9 @@ const server = http.createServer(async (req, res) => {
});
});
server.listen(PORT, '0.0.0.0', () => {
server.listen(PORT, '127.0.0.1', () => {
console.log(`Webhook 服务器已启动, 监听端口 ${PORT}`);
console.log(`部署目录: ${DEPLOY_DIR}`);
console.log(`接收地址: http://your-server-ip:${PORT}/webhook`);
console.log(`接收地址: http://127.0.0.1:${PORT}/webhook`);
console.log(`Nginx 代理地址: http://your-server-ip/webhook`);
});
+4 -4
View File
@@ -5,8 +5,8 @@ services:
dockerfile: Dockerfile
container_name: miniapp-api_test
restart: unless-stopped
ports:
- "3001:3001"
expose:
- "3001"
environment:
- PORT=3001
- NODE_ENV=production
@@ -38,8 +38,8 @@ services:
image: mongo:7.0
container_name: miniapp-api_test-mongo
restart: unless-stopped
ports:
- "27019:27017"
expose:
- "27017"
volumes:
- mongo_data_test:/data/db
environment:
+78
View File
@@ -67,6 +67,9 @@ tr:hover td { background: #f8f9fa; }
.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); }
.btn-send-msg { padding: 8px 16px; background: var(--primary); color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-send-msg:hover { background: var(--primary-hover); }
.btn-send-msg:disabled { opacity: .6; cursor: not-allowed; }
/* Pagination */
.pagination { padding: 16px 20px; display: flex; justify-content: center; align-items: center; gap: 12px; }
@@ -355,6 +358,7 @@ async function openModal(id) {
try {
const d = await api('/api/admin/users/' + id);
S.editingUser = d.data;
S.subscriptions = d.subscriptions || [];
renderModal(d.data);
} catch (e) {
showToast('加载用户详情失败', false);
@@ -404,7 +408,81 @@ function renderModal(u) {
<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>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
<div class="field"><label>订阅通知状态(只读)</label><div id="subscribeStatus" class="ro">加载中...</div></div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
<div class="field"><label>发送测试消息</label>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input id="test-tplId" value="DaPPZVFsLsHzj6TJAFK5rUp8B4DW-9JOtJF00v-uLMQ" style="flex:1;min-width:180px" placeholder="模板ID">
<input id="test-str1" value="v2.0" style="width:100px" placeholder="character_string1">
<input id="test-t3" value="版本已更新" style="width:100px" placeholder="thing3">
<input id="test-t6" value="请查看更新内容" style="width:120px" placeholder="thing6">
<button class="btn-send-msg" onclick="sendTestMessage()">发送测试</button>
</div>
<div id="testResult" style="margin-top:8px"></div>
</div>`;
if (S.subscriptions) {
renderSubscriptions(S.subscriptions);
}
}
function renderSubscriptions(subs) {
const el = document.getElementById('subscribeStatus');
if (!subs || subs.length === 0) {
el.innerHTML = '<span style="color:var(--text-secondary)">该用户未订阅任何通知</span>';
return;
}
const typeMap = {};
subs.forEach(s => {
if (!typeMap[s.type]) typeMap[s.type] = [];
typeMap[s.type].push(s);
});
let html = '<div style="font-size:13px">';
for (const [type, items] of Object.entries(typeMap)) {
const latest = items[0];
const badgeClass = latest.status === 'active' ? 'badge-active' : (latest.status === 'used' ? 'badge-inactive' : 'badge-banned');
const label = latest.status === 'active' ? '已授权' : (latest.status === 'used' ? '已使用' : latest.status);
const typeName = type === 'version-update' ? '版本更新通知' : (type === 'expiry-reminder' ? '到期提醒通知' : type);
html += `<div style="margin-bottom:8px">
<span class="badge ${badgeClass}">${label}</span>
<span style="margin-left:8px">${typeName}</span>
<span style="margin-left:8px;color:var(--text-secondary)">模板: ${latest.templateId.substring(0,8)}...</span>
<span style="margin-left:8px;color:var(--text-secondary)">订阅: ${fmtDate(latest.subscribedAt)}</span>
</div>`;
}
html += '</div>';
el.innerHTML = html;
}
async function sendTestMessage() {
if (!S.editingUser) return;
const btn = document.querySelector('.btn-send-msg');
const resultEl = document.getElementById('testResult');
btn.disabled = true;
btn.textContent = '发送中...';
resultEl.innerHTML = '';
try {
const res = await api('/api/admin/users/' + S.editingUser._id + '/test-message', {
method: 'POST',
body: JSON.stringify({
templateId: document.getElementById('test-tplId').value.trim(),
character_string1: document.getElementById('test-str1').value.trim(),
thing3: document.getElementById('test-t3').value.trim(),
thing6: document.getElementById('test-t6').value.trim()
})
});
if (res.success) {
resultEl.innerHTML = `<span style="color:#137333">✅ 发送成功 (msgid: ${res.data.messageId})</span>`;
} else {
resultEl.innerHTML = `<span style="color:var(--danger)">❌ 发送失败: errcode=${res.data.errcode} ${res.data.errmsg || ''}</span>`;
}
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--danger)">❌ 异常: ${e.message}</span>`;
} finally {
btn.disabled = false;
btn.textContent = '发送测试';
}
}
function toggleVipExpire() {
+6 -1
View File
@@ -41,7 +41,12 @@ app.use(helmet({
},
},
}));
app.use(cors());
app.use(cors({
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With'],
credentials: false
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
+39 -1
View File
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const UserSubscription = require('../models/UserSubscription');
const WechatSubscribeService = require('../services/wechatSubscribeService');
const { adminAuth } = require('../middleware/adminAuth');
router.post('/login', (req, res) => {
@@ -115,7 +117,43 @@ router.get('/users/:id', adminAuth, async (req, res, next) => {
return res.status(404).json({ success: false, error: '用户不存在' });
}
res.json({ success: true, data: user });
if (!user.openid) {
return res.json({ success: true, data: user, subscriptions: [] });
}
const subscriptions = await UserSubscription.find({ openid: user.openid })
.sort({ subscribedAt: -1 })
.lean();
res.json({ success: true, data: user, subscriptions });
} catch (error) {
next(error);
}
});
router.post('/users/:id/test-message', 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: '用户不存在' });
}
if (!user.openid) {
return res.status(400).json({ success: false, error: '该用户无 OpenID' });
}
const { templateId, character_string1, thing3, thing6 } = req.body;
const result = await WechatSubscribeService.sendVersionUpdateMessage({
openid: user.openid,
templateId: templateId || 'DaPPZVFsLsHzj6TJAFK5rUp8B4DW-9JOtJF00v-uLMQ',
character_string1: character_string1 || 'v2.0',
thing3: thing3 || '版本已更新',
thing6: thing6 || '请查看更新内容'
});
res.json({ success: result.success, data: result });
} catch (error) {
next(error);
}