4 Commits
Author SHA1 Message Date
gouki ec55a1d536 fix(ci): 部署链路重构为拉取式,修复工作流问题
Publish Mini Program Dev Version / publish (push) Canceled after 0s
Build and Deploy Server / build (push) Canceled after 0s
- deploy.sh: 目录级原子切换(current.new/current.old)替代 rm -rf 空窗式替换;
  健康检查改为轮询 /api/version HTTP 接口;失败自动回滚上一版本
- 新增 deploy-watch.sh: 服务器本地 systemd timer 轮询 /ci-artifacts 产物指纹,
  发现新版本自动部署——CI 与 AI 不再需要 SSH 到服务器执行任何命令
- server-deploy.yml: 部署包补入 web/ 后台静态资源;移除永不生效的前端构建死代码;
  通知文案如实改为'构建成功'(部署由服务器侧 watcher 完成)
- miniapp-preview.yml: 移除无意义的 --runInBand 测试参数
- 新增 PULL_DEPLOY.md 一次性安装文档(管理员手工执行)
2026-08-09 00:10:45 +00:00
gouki f90b7c7764 fix(mini): BASE_URL 按运行环境自动切换
- develop 走本地开发服务,trial/release 走 https://lunar.neatcn.com(原为硬编码 localhost:8080,线上无法连通)
- 401 时清除失效 token 交由页面引导重新登录,不再跳转设置页
- 补交 version-badge 组件源码
2026-08-09 00:10:05 +00:00
gouki 63f92501a1 fix(server): 管理后台源码入库,修正 .gitignore 误忽略
- server/web 之前被 web/ 忽略规则误伤,从未提交到仓库,导致 CI 部署包缺失后台静态资源
- 锚定根目录 /web/、/wishing-tree-pages/ 规则,仅忽略构建产物 node_modules/dist
2026-08-09 00:10:05 +00:00
gouki b8c439deac fix(server): 安全加固与版本链路修复
- 微信登录改为真实 jscode2session,session_key 不再下发客户端
- 支付回调增加 HMAC 验签(X-Pay-Sign)与幂等处理,未配置密钥时拒绝回调
- 订单金额一律以服务端商品表定价,禁止客户端传入金额
- 付费许愿改为支付成功后创建,不再先许愿后付款
- 管理后台增加登录认证(ADMIN_PASSWORD + role=admin JWT + HttpOnly Cookie)
- 订单详情/取消增加本人归属校验,修复越权访问
- 版本信息改为 ldflags 注入单一链路,GoVersion 用 runtime.Version()
- 恢复 gin 默认访问日志(原 Logger 中间件输出为空)
- 加载 HTML 模板修复后台页面 500;godotenv 加载 .env.local
- CORS 支持 CORS_ORIGINS 白名单配置
2026-08-09 00:09:24 +00:00
29 changed files with 1557 additions and 230 deletions
+68
View File
@@ -0,0 +1,68 @@
# 拉取式部署配置(一次性)
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由服务器本地的 watcher 监听 CI 产物目录自动完成。本文档的所有命令由**管理员本人**在服务器上执行一次。
## 工作原理
```
push main → Gitea Actions 构建 → 产物写入 /ci-artifactsrunner 与宿主同机)
服务器本地 systemd timer 每分钟运行 deploy-watch.sh → 发现新 sha256 → 执行 deploy.sh
目录级原子切换 → docker restart → HTTP 健康检查 → 失败自动回滚
```
## 一次性安装(管理员手工执行)
```bash
# 1. 放置脚本
sudo mkdir -p /opt/lunar/scripts
sudo cp deploy.sh deploy-watch.sh /opt/lunar/scripts/ # 从仓库 .gitea/scripts/ 复制
sudo chmod +x /opt/lunar/scripts/*.sh
# 2. systemd timer(每分钟轮询)
sudo tee /etc/systemd/system/lunar-deploy-watch.service > /dev/null <<'EOF'
[Unit]
Description=Lunar pull-based deploy watcher
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/lunar/scripts/deploy-watch.sh
EOF
sudo tee /etc/systemd/system/lunar-deploy-watch.timer > /dev/null <<'EOF'
[Unit]
Description=Run lunar deploy watcher every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now lunar-deploy-watch.timer
```
## 验证
```bash
systemctl list-timers | grep lunar
tail -f /opt/lunar/deploy-watch.log
curl -s http://localhost:8080/api/version
```
## 生产容器必备环境变量
在 1Panel 容器配置中设置(切勿写入仓库):
- `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`
- `JWT_SECRET`(必须为强随机值,禁止使用默认值)
- `SERVER_ENV=production`
- `MINI_APP_ID` / `MINI_APP_SECRET`(微信登录)
- `WECHAT_PAY_APIKEY`(支付回调验签,未配置时回调接口直接返回 503)
- `ADMIN_PASSWORD`(管理后台登录,未配置时后台登录禁用)
## 回滚
deploy.sh 健康检查失败会自动回滚;手工回滚使用 `/opt/lunar/backups/` 下的备份包重新执行 deploy.sh。
+282
View File
@@ -0,0 +1,282 @@
# 版本号管理指南
本文档说明如何在小程序和后端服务中管理和显示版本号。
## 版本号机制
### 小程序版本号
- **格式**: `x.y.z`(微信要求每段 0-999
- **生成规则**:
- `x` = package.json 中的 major 版本
- `y` = CI 构建编号 / 100
- `z` = CI 构建编号 % 100
- **示例**: 构建编号 42 → 版本 `1.0.42`,构建编号 123 → 版本 `1.1.23`
### 后端版本号
- **格式**: `1.0.{BUILD_NUMBER}`
- **生成规则**: 使用 CI 构建编号作为 patch 版本
- **示例**: 构建编号 42 → 版本 `1.0.42`
## 版本号注入流程
### 小程序(CI 构建时)
1. CI 读取 `MINIAPP_BUILD_NUMBER`Gitea Actions 运行编号)
2. 调用 `resolve-version.cjs` 生成版本号
3. 将版本号写入 `mini/utils/version.js`
4. 上传小程序时使用该版本号
**生成的 version.js 示例**:
```javascript
module.exports = {
version: "1.0.42",
buildNumber: "42",
buildTime: "2026-08-08T12:34:56Z",
commitSha: "abc1234",
};
```
### 后端(CI 构建时)
1. CI 读取 `GITEA_RUN_NUMBER`Gitea Actions 运行编号)
2. 生成版本号 `1.0.{RUN_NUMBER}`
3. 使用 `ldflags` 注入到 Go 二进制文件
4. 同时保存到 `bin/version.env` 文件
**生成的 version.env 示例**:
```
APP_VERSION=1.0.42
APP_BUILD_TIME=2026-08-08T12:34:56Z
APP_COMMIT_SHA=abc1234
```
## 版本号显示
### 小程序端
#### 1. 个人中心页(settings
自动显示版本号和构建信息:
```
老黄历小程序 v1.0.42
构建 #42 (abc1234)
提供农历、黄历、八字、许愿等功能
```
#### 2. 使用版本号组件
在其他页面中使用 `version-badge` 组件:
**wxml**:
```xml
<!-- 简单显示 -->
<version-badge />
<!-- 显示详细信息 -->
<version-badge showDetail="{{true}}" />
<!-- 显示构建时间 -->
<version-badge showDetail="{{true}}" showTime="{{true}}" />
```
**json**:
```json
{
"usingComponents": {
"version-badge": "/components/version-badge/version-badge"
}
}
```
**显示效果**:
- 简单: `v1.0.42`
- 详细: `v1.0.42 #42 (abc1234)`
- 完整: `v1.0.42 #42 (abc1234) 2026/08/08 12:34`
### 后端 API
#### 获取版本信息
**接口**: `GET /api/version`
**响应**:
```json
{
"version": "1.0.42",
"buildTime": "2026-08-08T12:34:56Z",
"commitSha": "abc1234",
"goVersion": "go1.22"
}
```
**使用示例**:
```bash
curl http://localhost:8080/api/version
```
## 部署通知中的版本号
### 小程序部署通知
```
✅ 小程序开发版上传成功
状态: 成功
仓库: gouki/lunar
分支: main
提交: abc1234
作者: gouki
工作流: Publish Mini Program Dev Version #42
版本: 1.0.42 上传结果已保存到: /opt/lunar/ci-artifacts/miniapp-upload-latest.json
```
### 后端部署通知
```
✅ 后端服务部署成功
状态: 成功
仓库: gouki/lunar
分支: main
提交: abc1234
作者: gouki
工作流: Build and Deploy Server #42
版本: v1.0.42
部署包: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz
```
## 版本号一致性检查
### 本地开发环境
**小程序**:
```bash
# 查看 package.json 中的版本
cat mini/package.json | grep version
# 查看 version.js 中的版本
cat mini/utils/version.js
```
**后端**:
```bash
# 本地运行时版本为 "dev"
curl http://localhost:8080/api/version
```
### 生产环境
**小程序**:
1. 打开小程序
2. 进入"我的"页面
3. 查看"关于"部分的版本号
**后端**:
```bash
# 访问生产环境的版本接口
curl https://your-domain.com/api/version
```
### CI 构建产物
**小程序**:
```bash
# SSH 到服务器
ssh doc79
# 查看上传结果
cat /opt/lunar/ci-artifacts/miniapp-upload-latest.json | jq .version
```
**后端**:
```bash
# 查看版本信息文件
cat /opt/lunar/production/current/bin/version.env
# 或者访问 API
curl http://localhost:8080/api/version
```
## 版本号追踪
### 通过 Git Commit SHA
每个版本都包含 Git commit SHA(前 7 位),可以追溯到具体的代码提交:
```bash
# 查看某个版本的完整 commit 信息
git show abc1234
# 查看某个版本的变更内容
git log --oneline abc1234^..abc1234
```
### 通过构建编号
构建编号对应 Gitea Actions 的运行编号:
1. 访问 Gitea 网页界面
2. 进入仓库 → Actions
3. 查找对应编号的运行记录
4. 查看完整的构建日志和产物
## 故障排查
### 版本号不正确
**问题**: 小程序显示的版本号与预期不符
**排查步骤**:
1. 检查 `mini/utils/version.js` 是否被正确生成
2. 检查 CI 日志中的版本号解析过程
3. 确认 `MINIAPP_BUILD_NUMBER` 环境变量是否正确传递
### 版本号未更新
**问题**: 部署后版本号没有变化
**可能原因**:
1. CI 构建缓存导致 `version.js` 未被重新生成
2. 小程序缓存导致旧版本仍在使用
**解决方法**:
1. 清除 CI 构建缓存
2. 在小程序中清除缓存并重新编译
3. 确认 CI 日志中版本号已更新
### 后端版本号显示为 "dev"
**问题**: 生产环境的 `/api/version` 返回 `"version": "dev"`
**原因**: 环境变量未正确注入
**解决方法**:
1. 检查 CI 构建日志,确认 `ldflags` 注入成功
2. 检查部署脚本,确认 `version.env` 文件被正确加载
3. 检查容器环境变量配置
## 最佳实践
1. **版本号语义化**: 遵循语义化版本规范(SemVer)
2. **版本号可见性**: 在用户界面和 API 中都显示版本号
3. **版本号追踪**: 通过 commit SHA 和构建编号追踪版本
4. **版本号一致性**: 确保本地、CI、生产环境的版本号一致
5. **版本号文档**: 在 CHANGELOG 中记录每个版本的变更
## 相关文件
- 小程序版本配置: `mini/utils/version.js`
- 小程序版本组件: `mini/components/version-badge/`
- 后端版本接口: `server/internal/handler/version.go`
- 小程序 CI 配置: `.gitea/workflows/miniapp-preview.yml`
- 后端 CI 配置: `.gitea/workflows/server-deploy.yml`
- 版本解析脚本: `mini/ci/resolve-version.cjs`
## 更新日志
- 2026-08-08: 初始版本,支持小程序和后端的版本号注入、显示和追踪
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# 拉取式部署 watcher - 监听 CI 产物目录,发现新版本自动部署
# 设计原则:
# - CI 只负责构建并把产物写入 /ci-artifactsrunner 与本机同宿主)
# - 部署动作只由服务器本地的本脚本触发,任何 AI/CI 均不通过 SSH 操作服务器
# 安装方式见 .gitea/docs/PULL_DEPLOY.md(由管理员在服务器上手工执行一次)
set -u
ARTIFACT="${ARTIFACT:-/ci-artifacts/server-deploy-latest.tar.gz}"
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-sha256}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
# 产物不存在时静默等待
if [ ! -f "$ARTIFACT" ]; then
exit 0
fi
# 计算产物指纹
CURRENT_SHA="$(sha256sum "$ARTIFACT" | awk '{print $1}')"
LAST_SHA=""
if [ -f "$STATE_FILE" ]; then
LAST_SHA="$(cat "$STATE_FILE")"
fi
# 无变化则跳过
if [ "$CURRENT_SHA" = "$LAST_SHA" ]; then
exit 0
fi
log "发现新版本产物 sha256=${CURRENT_SHA:0:12},开始部署"
# 执行部署;成功后才记录指纹(失败时下次轮询会重试)
if bash "$DEPLOY_SCRIPT" "$ARTIFACT" >> "$LOG_FILE" 2>&1; then
mkdir -p "$(dirname "$STATE_FILE")"
printf '%s' "$CURRENT_SHA" > "$STATE_FILE"
log "部署成功"
else
log "部署失败,保留旧指纹,等待下次轮询重试或人工介入"
exit 1
fi
+50 -23
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# 自动部署脚本 - 部署到 1Panel Docker 容器 # 自动部署脚本 - 部署到 1Panel Docker 容器
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
# 使用方法: ./deploy.sh <deploy_package_path> # 使用方法: ./deploy.sh <deploy_package_path>
# deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz # deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz
@@ -9,6 +10,7 @@ DEPLOY_PACKAGE="$1"
CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}" CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}" DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}"
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}" BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}"
HEALTH_URL="${HEALTH_URL:-http://localhost:8080/api/version}"
# 颜色输出 # 颜色输出
RED='\033[0;31m' RED='\033[0;31m'
@@ -28,6 +30,18 @@ log_error() {
echo -e "${RED}[ERROR]${NC} $1" echo -e "${RED}[ERROR]${NC} $1"
} }
# 回滚函数:恢复 current.old 并重启容器
rollback() {
if [ -d "$DEPLOY_DIR/current.old" ]; then
rm -rf "$DEPLOY_DIR/current"
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
docker restart "$CONTAINER_NAME" || log_error "回滚后容器重启失败,需人工介入"
log_warn "已回滚到上一版本"
else
log_error "无可回滚版本,需人工介入"
fi
}
# 检查部署包是否存在 # 检查部署包是否存在
if [ ! -f "$DEPLOY_PACKAGE" ]; then if [ ! -f "$DEPLOY_PACKAGE" ]; then
log_error "部署包不存在: $DEPLOY_PACKAGE" log_error "部署包不存在: $DEPLOY_PACKAGE"
@@ -52,7 +66,7 @@ if [ -d "$DEPLOY_DIR/current" ]; then
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署" tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
fi fi
# 解压新版本 # 解压新版本到暂存目录(不碰 current,避免部署空窗)
log_info "解压部署包..." log_info "解压部署包..."
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR" tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
@@ -64,15 +78,23 @@ if [ ! -d "$TEMP_DIR/deploy" ]; then
exit 1 exit 1
fi fi
# 部署新版本 # 校验二进制存在
log_info "部署新版本..." if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
rm -rf "$DEPLOY_DIR/current" log_error "部署包缺少 bin/server 二进制"
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current" rm -rf "$TEMP_DIR"
rm -rf "$TEMP_DIR" exit 1
fi
chmod +x "$TEMP_DIR/deploy/bin/server"
# 设置权限 # 部署新版本(目录级交换,接近原子)
log_info "设置文件权限..." log_info "部署新版本..."
chmod +x "$DEPLOY_DIR/current/bin/server" || log_warn "设置可执行权限失败" mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
rm -rf "$DEPLOY_DIR/current.old"
if [ -d "$DEPLOY_DIR/current" ]; then
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
fi
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
rm -rf "$TEMP_DIR"
# 检查 Docker 容器是否存在 # 检查 Docker 容器是否存在
if ! docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then if ! docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
@@ -95,25 +117,30 @@ log_info "重启 Docker 容器: $CONTAINER_NAME"
if docker restart "$CONTAINER_NAME"; then if docker restart "$CONTAINER_NAME"; then
log_info "✅ 容器重启成功" log_info "✅ 容器重启成功"
else else
log_error "❌ 容器重启失败" log_error "❌ 容器重启失败,回滚到上一版本"
rollback
exit 1 exit 1
fi fi
# 等待容器启动 # 健康检查:轮询 HTTP 接口确认服务真正可用
log_info "等待容器启动..." log_info "执行健康检查: $HEALTH_URL"
sleep 3 HEALTHY=0
for i in $(seq 1 10); do
sleep 2
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=1
break
fi
done
# 检查容器状态 if [ "$HEALTHY" = "1" ]; then
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then log_info "✅ 健康检查通过"
log_info "✅ 容器运行正常" curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
rm -rf "$DEPLOY_DIR/current.old"
# 显示容器日志(最后 20 行)
log_info "容器日志(最后 20 行):"
docker logs --tail 20 "$CONTAINER_NAME"
else else
log_error "❌ 容器未运行" log_error "❌ 健康检查失败,回滚到上一版本"
log_error "容器日志:" docker logs --tail 50 "$CONTAINER_NAME" || true
docker logs --tail 50 "$CONTAINER_NAME" rollback
exit 1 exit 1
fi fi
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
run: npm ci run: npm ci
- name: Run tests - name: Run tests
working-directory: mini working-directory: mini
run: npm test -- --runInBand run: npm test
- name: Configure production API and app version - name: Configure production API and app version
working-directory: mini working-directory: mini
env: env:
+6 -19
View File
@@ -62,20 +62,6 @@ jobs:
echo "Version info saved to bin/version.env:" echo "Version info saved to bin/version.env:"
cat bin/version.env cat bin/version.env
- name: Build frontend (if exists)
working-directory: server
run: |
if [ -d "web" ]; then
echo "Building frontend..."
cd web
if [ -f "package.json" ]; then
npm ci
npm run build
fi
else
echo "No frontend directory found, skipping frontend build"
fi
- name: Create deployment package - name: Create deployment package
working-directory: server working-directory: server
run: | run: |
@@ -83,8 +69,9 @@ jobs:
cp -r bin/ deploy/ cp -r bin/ deploy/
cp -r migrations/ deploy/ cp -r migrations/ deploy/
cp -r scripts/ deploy/ cp -r scripts/ deploy/
if [ -d "web/dist" ]; then # 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
cp -r web/dist/ deploy/public/ if [ -d "web" ]; then
cp -r web/ deploy/web/
fi fi
tar -czf deploy.tar.gz deploy/ tar -czf deploy.tar.gz deploy/
@@ -107,8 +94,8 @@ jobs:
run: | run: |
VERSION="1.0.${GITEA_RUN_NUMBER}" VERSION="1.0.${GITEA_RUN_NUMBER}"
bash .gitea/scripts/notify.sh success \ bash .gitea/scripts/notify.sh success \
"后端服务部署成功" \ "后端构建成功" \
"版本: v${VERSION}\n部署包: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz" "版本: v${VERSION}\n产物: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz\n部署由服务器侧 watcher 自动拉取完成"
- name: Send failure notification - name: Send failure notification
if: failure() if: failure()
@@ -118,7 +105,7 @@ jobs:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: | run: |
bash .gitea/scripts/notify.sh failure \ bash .gitea/scripts/notify.sh failure \
"后端服务部署失败" \ "后端构建失败" \
"请检查 Actions 日志了解失败原因" "请检查 Actions 日志了解失败原因"
- name: Cleanup - name: Cleanup
+6 -5
View File
@@ -55,14 +55,15 @@ server/vendor/
*.test *.test
*.out *.out
# 前端构建(如果 server 包含前端 # 前端构建产物与依赖(注意:server/web 是管理后台源码,必须入库
server/public/build/ server/public/build/
server/public/hot server/public/hot
server/web/ server/web/node_modules/
server/web/dist/
# web 版万年历(独立项目,不属于本仓库) # web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web
web/ /web/
wishing-tree-pages/ /wishing-tree-pages/
# 数据库 # 数据库
*.db *.db
@@ -0,0 +1,46 @@
/**
* 版本号显示组件
* 使用方法:
* <version-badge />
*/
const versionInfo = require('../../utils/version.js');
Component({
properties: {
// 是否显示详细信息(构建编号、commit SHA)
showDetail: {
type: Boolean,
value: false
},
// 是否显示构建时间
showTime: {
type: Boolean,
value: false
}
},
data: {
version: versionInfo.version,
buildNumber: versionInfo.buildNumber,
buildTime: versionInfo.buildTime,
commitSha: versionInfo.commitSha
},
lifetimes: {
attached() {
// 格式化构建时间
if (this.data.buildTime && this.data.buildTime !== 'unknown') {
const date = new Date(this.data.buildTime);
const formatted = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
this.setData({ formattedTime: formatted });
}
}
}
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,6 @@
<view class="version-badge">
<text class="version-text">v{{version}}</text>
<text class="version-detail" wx:if="{{showDetail && buildNumber !== '0'}}">#{{buildNumber}}</text>
<text class="version-detail" wx:if="{{showDetail && commitSha !== 'unknown'}}">({{commitSha}})</text>
<text class="version-time" wx:if="{{showTime && formattedTime}}">{{formattedTime}}</text>
</view>
@@ -0,0 +1,22 @@
.version-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #9E8E7E;
}
.version-text {
font-weight: 500;
}
.version-detail {
font-size: 11px;
opacity: 0.8;
}
.version-time {
font-size: 11px;
opacity: 0.6;
margin-left: 4px;
}
+21 -3
View File
@@ -1,5 +1,23 @@
// 网络请求封装 // 网络请求封装
const BASE_URL = 'http://localhost:8080'; // 开发环境 // 按小程序运行环境自动切换后端地址:
// - develop(开发者工具):本地开发服务
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
const API_HOSTS = {
develop: 'http://localhost:8080',
trial: 'https://lunar.neatcn.com',
release: 'https://lunar.neatcn.com',
};
function resolveBaseUrl() {
try {
const envVersion = wx.getAccountInfoSync().miniProgram.envVersion;
return API_HOSTS[envVersion] || API_HOSTS.release;
} catch (e) {
return API_HOSTS.release;
}
}
const BASE_URL = resolveBaseUrl();
// 请求拦截器 // 请求拦截器
function request(options) { function request(options) {
@@ -20,8 +38,8 @@ function request(options) {
if (res.statusCode === 200) { if (res.statusCode === 200) {
resolve(res.data); resolve(res.data);
} else if (res.statusCode === 401) { } else if (res.statusCode === 401) {
// 未授权,跳转登录 // 未授权:清除失效 token,由页面引导重新登录
wx.navigateTo({ url: '/pages/settings/settings' }); wx.removeStorageSync('token');
reject(new Error('未授权')); reject(new Error('未授权'));
} else { } else {
reject(new Error(res.data.msg || '请求失败')); reject(new Error(res.data.msg || '请求失败'));
+34 -9
View File
@@ -8,9 +8,27 @@ import (
"github.com/gouki/lunar-server/internal/handler" "github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware" "github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
"github.com/joho/godotenv"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
) )
func main() { func main() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
_ = godotenv.Load(".env.local", "../.env.local", ".env")
// 将构建时注入的版本信息传递给 handler
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
// 加载配置 // 加载配置
cfg := config.Load() cfg := config.Load()
@@ -28,16 +46,16 @@ func main() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
// 创建路由 // 创建路由gin.Default 自带访问日志与 Recovery
r := gin.Default() r := gin.Default()
// 中间件 // 中间件
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.Logger())
// 静态文件 // 静态文件与管理后台模板
r.Static("/static", "./web/static") r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html") r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html")
// API 路由 // API 路由
api := r.Group("/api") api := r.Group("/api")
@@ -87,13 +105,20 @@ func main() {
// 管理后台路由(Inertia.js // 管理后台路由(Inertia.js
admin := r.Group("/admin") admin := r.Group("/admin")
admin.Use(middleware.AdminAuth())
{ {
admin.GET("/", handler.AdminDashboard) // 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
admin.GET("/users", handler.AdminUsers) admin.POST("/login", handler.AdminLogin)
admin.GET("/orders", handler.AdminOrders) admin.POST("/logout", handler.AdminLogout)
admin.GET("/wishes", handler.AdminWishes)
admin.GET("/settings", handler.AdminSettings) authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/settings", handler.AdminSettings)
}
} }
// 启动服务器 // 启动服务器
+16 -4
View File
@@ -10,11 +10,13 @@ type Config struct {
Redis RedisConfig Redis RedisConfig
JWT JWTConfig JWT JWTConfig
Wechat WechatConfig Wechat WechatConfig
Admin AdminConfig
} }
type ServerConfig struct { type ServerConfig struct {
Port string Port string
Env string Env string
CORSOrigins string
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -43,11 +45,18 @@ type WechatConfig struct {
MchID string MchID string
} }
// AdminConfig 管理后台配置
type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录
Password string
}
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"), Port: getEnv("SERVER_PORT", "8080"),
Env: getEnv("SERVER_ENV", "development"), Env: getEnv("SERVER_ENV", "development"),
CORSOrigins: getEnv("CORS_ORIGINS", ""),
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"), Host: getEnv("DB_HOST", "localhost"),
@@ -71,6 +80,9 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""), PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""), MchID: getEnv("WECHAT_PAY_MCHID", ""),
}, },
Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""),
},
} }
} }
+81 -20
View File
@@ -1,47 +1,108 @@
package handler package handler
import ( import (
"crypto/subtle"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/service"
) )
// adminPage 统一的管理后台页面渲染(补充 Inertia 需要的 url 属性)
func adminPage(c *gin.Context, title, page string) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": title,
"page": page,
"url": c.Request.URL.Path,
})
}
// AdminLogin 管理员登录:密码正确签发 role=admin 的 JWT
func AdminLogin(c *gin.Context) {
cfg := config.Load()
if cfg.Admin.Password == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": 503,
"msg": "后台登录未启用",
})
return
}
var req struct {
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "参数错误",
})
return
}
// 恒定时间比较,防时序攻击
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(cfg.Admin.Password)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "密码错误",
})
return
}
userService := service.NewUserService()
token, err := userService.GenerateAdminToken(cfg.JWT.Secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "生成token失败",
})
return
}
// 同时写入 HttpOnly Cookie,支持浏览器直接导航后台页面
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(service.AdminTokenCookie, token, 12*3600, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"token": token,
},
})
}
// AdminLogout 管理员登出:清除会话 Cookie
func AdminLogout(c *gin.Context) {
cfg := config.Load()
c.SetCookie(service.AdminTokenCookie, "", -1, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
})
}
// AdminDashboard 管理后台首页 // AdminDashboard 管理后台首页
func AdminDashboard(c *gin.Context) { func AdminDashboard(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "管理后台", "dashboard")
"title": "管理后台",
"page": "dashboard",
})
} }
// AdminUsers 用户管理 // AdminUsers 用户管理
func AdminUsers(c *gin.Context) { func AdminUsers(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "用户管理", "users")
"title": "用户管理",
"page": "users",
})
} }
// AdminOrders 订单管理 // AdminOrders 订单管理
func AdminOrders(c *gin.Context) { func AdminOrders(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "订单管理", "orders")
"title": "订单管理",
"page": "orders",
})
} }
// AdminWishes 许愿管理 // AdminWishes 许愿管理
func AdminWishes(c *gin.Context) { func AdminWishes(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "许愿管理", "wishes")
"title": "许愿管理",
"page": "wishes",
})
} }
// AdminSettings 系统设置 // AdminSettings 系统设置
func AdminSettings(c *gin.Context) { func AdminSettings(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "系统设置", "settings")
"title": "系统设置",
"page": "settings",
})
} }
+10 -7
View File
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
}) })
} }
// GetOrderDetail 获取订单详情 // GetOrderDetail 获取订单详情(仅限本人订单)
func GetOrderDetail(c *gin.Context) { func GetOrderDetail(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByID(uint(orderID)) order, err := orderService.GetOrderByID(uint(orderID))
if err != nil { if err != nil || order.UserID != userID.(uint) {
// 不存在与无权访问统一返回 404,避免枚举他人订单
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
"msg": "订单不存在", "msg": "订单不存在",
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
}) })
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人待支付订单)
func CancelOrder(c *gin.Context) { func CancelOrder(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
if err := orderService.CancelOrder(uint(orderID)); err != nil { if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"code": 500, "code": 400,
"msg": "取消订单失败", "msg": err.Error(),
}) })
return return
} }
+103 -38
View File
@@ -1,14 +1,20 @@
package handler package handler
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model" "github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CreateOrder 创建订单 // CreateOrder 创建订单
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
func CreateOrder(c *gin.Context) { func CreateOrder(c *gin.Context) {
userID, exists := c.Get("userID") userID, exists := c.Get("userID")
if !exists { if !exists {
@@ -20,11 +26,9 @@ func CreateOrder(c *gin.Context) {
} }
var req struct { var req struct {
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员 Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
ProductID uint `json:"productId" binding:"required"` // 商品ID ProductID uint `json:"productId" binding:"required"` // 商品ID
ProductName string `json:"productName" binding:"required"` // 商品名称 Content string `json:"content"` // 许愿内容(许愿类型需要)
Amount int `json:"amount" binding:"required"` // 金额(分)
Content string `json:"content"` // 许愿内容(许愿类型需要)
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
return return
} }
orderService := service.NewOrderService() // 服务端定价:按商品 ID 查库取价格,防止客户端篡改金额
wishService := service.NewWishService()
product, err := wishService.GetWishProductByID(req.ProductID)
if err != nil || product.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "商品不存在或已下架",
})
return
}
if req.Type == "wish" {
if req.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "请输入许愿内容",
})
return
}
if len([]rune(req.Content)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "许愿内容超过 100 字限制",
})
return
}
} else {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "不支持的订单类型",
})
return
}
orderService := service.NewOrderService()
order := &model.Order{ order := &model.Order{
UserID: userID.(uint), UserID: userID.(uint),
Type: req.Type, Type: req.Type,
ProductID: req.ProductID, ProductID: product.ID,
ProductName: req.ProductName, ProductName: product.Name,
Amount: req.Amount, Amount: product.Price, // 金额以商品表为准
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
} }
if err := orderService.CreateOrder(order); err != nil { if err := orderService.CreateOrder(order); err != nil {
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
return return
} }
// 如果是许愿类型,创建许愿记录
if req.Type == "wish" && req.Content != "" {
wishService := service.NewWishService()
wish := &model.Wish{
UserID: userID.(uint),
TreeID: 1, // 默认许愿树
Content: req.Content,
Type: "paid",
Status: 1,
}
wishService.CreateWish(wish)
}
// 创建微信支付订单 // 创建微信支付订单
payParams, err := orderService.CreateWechatPayOrder(order, "") payParams, err := orderService.CreateWechatPayOrder(order, "")
if err != nil { if err != nil {
@@ -87,12 +112,29 @@ func CreateOrder(c *gin.Context) {
}) })
} }
// PayNotify 支付回调 // paySignOf 计算支付回调签名:HMAC-SHA256(orderNo|transactionId, APIKEY)
// 过渡方案:真实微信支付 V3 回调验签接入前的内部协议
func paySignOf(orderNo, transactionID, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(orderNo + "|" + transactionID))
return hex.EncodeToString(mac.Sum(nil))
}
// PayNotify 支付回调:必须携带 X-Pay-Sign 签名头,验签通过且幂等处理
func PayNotify(c *gin.Context) { func PayNotify(c *gin.Context) {
// TODO: 验证微信支付回调签名 cfg := config.Load()
if cfg.Wechat.PayKey == "" {
// 未配置支付密钥时拒绝一切回调,避免裸奔
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": "FAIL",
"msg": "支付服务未配置",
})
return
}
var req struct { var req struct {
OrderNo string `json:"orderNo"` OrderNo string `json:"orderNo" binding:"required"`
TransactionID string `json:"transactionId"` TransactionID string `json:"transactionId" binding:"required"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -104,15 +146,32 @@ func PayNotify(c *gin.Context) {
return return
} }
if req.Status == "SUCCESS" { // 验签:恒定时间比较,防时序攻击
orderService := service.NewOrderService() sign := c.GetHeader("X-Pay-Sign")
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil { expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
c.JSON(http.StatusInternalServerError, gin.H{ if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
"code": "FAIL", c.JSON(http.StatusUnauthorized, gin.H{
"msg": "处理失败", "code": "FAIL",
}) "msg": "签名验证失败",
return })
} return
}
if req.Status != "SUCCESS" {
c.JSON(http.StatusOK, gin.H{
"code": "SUCCESS",
"msg": "OK",
})
return
}
orderService := service.NewOrderService()
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": "FAIL",
"msg": "处理失败",
})
return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
}) })
} }
// GetPayStatus 获取支付状态 // GetPayStatus 获取支付状态(支持订单号或订单 ID
func GetPayStatus(c *gin.Context) { func GetPayStatus(c *gin.Context) {
orderID := c.Param("orderId") param := c.Param("orderId")
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByOrderNo(orderID) order, err := orderService.GetOrderByOrderNo(param)
if err != nil {
// 兼容传数字 ID 的调用方
if id, convErr := strconv.Atoi(param); convErr == nil {
order, err = orderService.GetOrderByID(uint(id))
}
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
+101 -31
View File
@@ -1,7 +1,12 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/url"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/config"
@@ -9,7 +14,71 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// UserLogin 用户登录 // code2Session 调用微信 jscode2session 接口换取 openid
// session_key 仅保存在服务端,绝不下发给客户端
func code2Session(code string) (openID, unionID string, err error) {
cfg := config.Load()
if cfg.Wechat.AppID == "" || cfg.Wechat.AppSecret == "" {
return "", "", fmt.Errorf("wechat appid/secret not configured")
}
query := url.Values{
"appid": {cfg.Wechat.AppID},
"secret": {cfg.Wechat.AppSecret},
"js_code": {code},
"grant_type": {"authorization_code"},
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.weixin.qq.com/sns/jscode2session?" + query.Encode())
if err != nil {
return "", "", fmt.Errorf("jscode2session request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", "", fmt.Errorf("read jscode2session response failed: %w", err)
}
var result struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("parse jscode2session response failed: %w", err)
}
if result.ErrCode != 0 || result.OpenID == "" {
return "", "", fmt.Errorf("wechat auth failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return result.OpenID, result.UnionID, nil
}
// upsertUser 根据 openid 获取用户,不存在则创建
func upsertUser(openID, unionID string) (*model.User, error) {
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil {
return nil, err
}
if user == nil {
user = &model.User{
OpenID: openID,
UnionID: unionID,
Nickname: "微信用户",
Status: 1,
}
if err := userService.CreateUser(user); err != nil {
return nil, err
}
}
return user, nil
}
// UserLogin 用户登录(小程序 wx.login 的 code 换取 token
func UserLogin(c *gin.Context) { func UserLogin(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -23,38 +92,35 @@ func UserLogin(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid openID, unionID, err := code2Session(req.Code)
// 这里模拟返回
openID := "mock_openid_" + req.Code
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"code": 500, "code": 401,
"msg": "服务器错误", "msg": "微信登录失败",
}) })
return return
} }
// 如果用户不存在,创建新用户 user, err := upsertUser(openID, unionID)
if user == nil { if err != nil {
user = &model.User{ c.JSON(http.StatusInternalServerError, gin.H{
OpenID: openID, "code": 500,
Nickname: "微信用户", "msg": "创建用户失败",
Status: 1, })
} return
if err := userService.CreateUser(user); err != nil { }
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, // 禁用用户不允许登录
"msg": "创建用户失败", if user.Status != 1 {
}) c.JSON(http.StatusForbidden, gin.H{
return "code": 403,
} "msg": "账号已被禁用",
})
return
} }
// 生成 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService()
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret) token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -217,7 +283,7 @@ func UpdateUserProfile(c *gin.Context) {
}) })
} }
// WechatAuth 微信授权 // WechatAuth 微信授权(仅返回 openidsession_key 属敏感凭证不下发)
func WechatAuth(c *gin.Context) { func WechatAuth(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -231,16 +297,20 @@ func WechatAuth(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid 和 session_key openID, _, err := code2Session(req.Code)
// 这里模拟返回 if err != nil {
openID := "mock_openid_" + req.Code c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "微信授权失败",
})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"msg": "success", "msg": "success",
"data": gin.H{ "data": gin.H{
"openid": openID, "openid": openID,
"sessionKey": "mock_session_key",
}, },
}) })
} }
+12 -21
View File
@@ -2,11 +2,18 @@ package handler
import ( import (
"net/http" "net/http"
"os" "runtime"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// VersionInfo 版本信息 // VersionInfo 版本信息
type VersionInfo struct { type VersionInfo struct {
Version string `json:"version"` Version string `json:"version"`
@@ -17,26 +24,10 @@ type VersionInfo struct {
// GetVersion 获取服务器版本信息 // GetVersion 获取服务器版本信息
func GetVersion(c *gin.Context) { func GetVersion(c *gin.Context) {
// 从环境变量读取版本信息(由 CI 构建时注入)
version := os.Getenv("APP_VERSION")
if version == "" {
version = "dev"
}
buildTime := os.Getenv("APP_BUILD_TIME")
if buildTime == "" {
buildTime = "unknown"
}
commitSha := os.Getenv("APP_COMMIT_SHA")
if commitSha == "" {
commitSha = "unknown"
}
c.JSON(http.StatusOK, VersionInfo{ c.JSON(http.StatusOK, VersionInfo{
Version: version, Version: Version,
BuildTime: buildTime, BuildTime: BuildTime,
CommitSha: commitSha, CommitSha: CommitSha,
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取 GoVersion: runtime.Version(),
}) })
} }
+87 -33
View File
@@ -9,12 +9,41 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CORS 跨域中间件 // CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
cfg := config.Load()
origins := strings.TrimSpace(cfg.Server.CORSOrigins)
allowAll := origins == ""
allowed := map[string]bool{}
if !allowAll {
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed[o] = true
}
}
}
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.GetHeader("Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if origin != "" {
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if allowed[origin] {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
} else {
// 非白名单源:不输出 CORS 头,浏览器会拦截跨域请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Pay-Sign")
}
if c.Request.Method == "OPTIONS" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent) c.AbortWithStatus(http.StatusNoContent)
@@ -25,40 +54,38 @@ func CORS() gin.HandlerFunc {
} }
} }
// Logger 日志中间件 // bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
func Logger() gin.HandlerFunc { func bearerToken(c *gin.Context) (string, bool) {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { authHeader := c.GetHeader("Authorization")
return "" if authHeader == "" {
}) c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return "", false
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return "", false
}
return parts[1], true
} }
// Auth JWT认证中间件 // Auth JWT认证中间件
func Auth() gin.HandlerFunc { func Auth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") tokenString, ok := bearerToken(c)
if authHeader == "" { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return return
} }
// 解析 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return
}
tokenString := parts[1]
// 解析 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService() userService := service.NewUserService()
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret) userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
return return
} }
// 将用户ID存入上下文
c.Set("userID", userID) c.Set("userID", userID)
c.Next() c.Next()
} }
} }
// AdminAuth 管理员认证中间件 // AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWTAuthorization 头或 HttpOnly Cookie
func AdminAuth() gin.HandlerFunc { func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// TODO: 实现管理员认证 tokenString := ""
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// 浏览器导航场景:登录时写入的 HttpOnly Cookie
tokenString, _ = c.Cookie(service.AdminTokenCookie)
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
cfg := config.Load()
userService := service.NewUserService()
if !userService.IsAdminToken(tokenString, cfg.JWT.Secret) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
c.Next() c.Next()
} }
} }
+58 -8
View File
@@ -3,7 +3,9 @@ package service
import ( import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"log"
"math/rand" "math/rand"
"time" "time"
@@ -98,9 +100,18 @@ func (s *OrderService) UpdateOrderStatus(orderNo, status string) error {
return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
func (s *OrderService) CancelOrder(id uint) error { func (s *OrderService) CancelOrder(userID, id uint) error {
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error result := s.db.Model(&model.Order{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, "pending").
Update("status", "cancelled")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("订单不存在或当前状态不可取消")
}
return nil
} }
// CheckOrderExpired 检查订单是否过期 // CheckOrderExpired 检查订单是否过期
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// HandlePayNotify 处理支付回调 // HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error { func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
// 更新订单状态 order, err := s.GetOrderByOrderNo(orderNo)
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil { if err != nil {
return err return fmt.Errorf("order not found: %w", err)
} }
// TODO: 处理业务逻辑(如创建许愿、开通会员等) // 幂等:已支付直接返回成功
if order.Status == "paid" {
return nil
}
if order.Status != "pending" {
return fmt.Errorf("order %s in unexpected status %s", orderNo, order.Status)
}
// 条件更新防止并发重复入账
updates := map[string]interface{}{
"status": "paid",
"pay_time": time.Now(),
"remark": order.Remark + " [tx:" + transactionID + "]",
}
result := s.db.Model(&model.Order{}).
Where("order_no = ? AND status = ?", orderNo, "pending").
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 并发下已被其他回调处理,视为幂等成功
return nil
}
// 支付成功后才创建付费许愿
if order.Type == "wish" && order.Remark != "" {
wishService := NewWishService()
wish := &model.Wish{
UserID: order.UserID,
TreeID: 1, // 默认祈福树
Content: order.Remark,
Type: "paid",
Status: 1,
}
if err := wishService.CreateWish(wish); err != nil {
// 许愿创建失败不回滚支付状态,由后台补处理
log.Printf("create paid wish for order %s failed: %v", orderNo, err)
}
}
return nil return nil
} }
+41 -2
View File
@@ -93,9 +93,48 @@ func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
} }
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := uint(claims["user_id"].(float64)) // 管理员 token 不绑定具体用户,不能当普通用户 token 使用
return userID, nil if role, _ := claims["role"].(string); role == "admin" {
return 0, errors.New("admin token")
}
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
return 0, errors.New("invalid token claims")
}
return uint(userIDFloat), nil
} }
return 0, errors.New("invalid token") return 0, errors.New("invalid token")
} }
// AdminTokenCookie 管理员会话 Cookie 名
const AdminTokenCookie = "lunar_admin_token"
// GenerateAdminToken 签发管理员 JWT12 小时过期)
func (s *UserService) GenerateAdminToken(secret string) (string, error) {
claims := jwt.MapClaims{
"role": "admin",
"exp": time.Now().Add(time.Hour * 12).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// IsAdminToken 校验是否为有效的管理员 JWT
func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return false
}
role, _ := claims["role"].(string)
return role == "admin"
}
+6 -6
View File
@@ -7,18 +7,18 @@ echo "Building Lunar Server..."
# 创建输出目录 # 创建输出目录
mkdir -p bin mkdir -p bin
# 编译 Go 后端 # 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
echo "Building Go backend..." echo "Building Go backend..."
go build -o bin/lunar-server cmd/main.go go build -o bin/server cmd/main.go
# 编译前端(如果存在 # 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建
if [ -d "web" ]; then if [ -f "web/package.json" ]; then
echo "Building frontend..." echo "Building frontend..."
cd web cd web
npm install npm ci
npm run build npm run build
cd .. cd ..
fi fi
echo "Build complete!" echo "Build complete!"
echo "Output: bin/lunar-server" echo "Output: bin/server"
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .title }} - 祈福小助手管理后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/@inertiajs/vue3@1.0.0/dist/index.umd.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-100">
<div id="app" data-page='{"component":"{{ .page }}","props":{},"url":"{{ .url }}","version":""}'></div>
<script>
const { createApp, h } = Vue;
const { createInertiaApp } = Inertia;
createInertiaApp({
resolve: name => {
const pages = {
dashboard: () => import('/static/js/pages/Dashboard.js'),
users: () => import('/static/js/pages/Users.js'),
orders: () => import('/static/js/pages/Orders.js'),
wishes: () => import('/static/js/pages/Wishes.js'),
settings: () => import('/static/js/pages/Settings.js'),
};
return pages[name] ? pages[name]() : pages.dashboard();
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
</script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
// Dashboard 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-users text-2xl text-blue-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总用户数</dt>
<dd class="text-lg font-medium text-gray-900">1,234</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-shopping-cart text-2xl text-green-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总订单数</dt>
<dd class="text-lg font-medium text-gray-900">567</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-star text-2xl text-yellow-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总许愿数</dt>
<dd class="text-lg font-medium text-gray-900">890</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-money-bill-wave text-2xl text-red-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总收入</dt>
<dd class="text-lg font-medium text-gray-900">¥12,345</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8">
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">最近订单</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">用户A</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥10.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+71
View File
@@ -0,0 +1,71 @@
// Orders 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">许愿</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">普通许愿条</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥1.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+68
View File
@@ -0,0 +1,68 @@
// Settings 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">系统设置</h2>
<form class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">许愿树最大许愿数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">免费许愿最大字数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="20">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">付费许愿最大字数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">机器人许愿间隔(小时)</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="2">
</div>
<div>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
保存设置
</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
`
}
+69
View File
@@ -0,0 +1,69 @@
// Users 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">用户列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">昵称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">OpenID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">注册时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">1</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">微信用户</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">mock_openid_xxx</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">正常</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+69
View File
@@ -0,0 +1,69 @@
// Wishes 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">许愿列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">1</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">愿世界和平</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">付费</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">用户A</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}