Compare commits
4
Commits
daebda22ee
...
ec55a1d536
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec55a1d536 | ||
|
|
f90b7c7764 | ||
|
|
63f92501a1 | ||
|
|
b8c439deac |
@@ -0,0 +1,68 @@
|
||||
# 拉取式部署配置(一次性)
|
||||
|
||||
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
|
||||
> 部署由服务器本地的 watcher 监听 CI 产物目录自动完成。本文档的所有命令由**管理员本人**在服务器上执行一次。
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
push main → Gitea Actions 构建 → 产物写入 /ci-artifacts(runner 与宿主同机)
|
||||
↓
|
||||
服务器本地 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。
|
||||
@@ -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: 初始版本,支持小程序和后端的版本号注入、显示和追踪
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# 拉取式部署 watcher - 监听 CI 产物目录,发现新版本自动部署
|
||||
# 设计原则:
|
||||
# - CI 只负责构建并把产物写入 /ci-artifacts(runner 与本机同宿主)
|
||||
# - 部署动作只由服务器本地的本脚本触发,任何 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
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 自动部署脚本 - 部署到 1Panel Docker 容器
|
||||
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
|
||||
# 使用方法: ./deploy.sh <deploy_package_path>
|
||||
# deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz)
|
||||
|
||||
@@ -9,6 +10,7 @@ DEPLOY_PACKAGE="$1"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}"
|
||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8080/api/version}"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
@@ -28,6 +30,18 @@ log_error() {
|
||||
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
|
||||
log_error "部署包不存在: $DEPLOY_PACKAGE"
|
||||
@@ -52,7 +66,7 @@ if [ -d "$DEPLOY_DIR/current" ]; then
|
||||
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
|
||||
fi
|
||||
|
||||
# 解压新版本
|
||||
# 解压新版本到暂存目录(不碰 current,避免部署空窗)
|
||||
log_info "解压部署包..."
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
|
||||
@@ -64,15 +78,23 @@ if [ ! -d "$TEMP_DIR/deploy" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 部署新版本
|
||||
log_info "部署新版本..."
|
||||
rm -rf "$DEPLOY_DIR/current"
|
||||
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current"
|
||||
rm -rf "$TEMP_DIR"
|
||||
# 校验二进制存在
|
||||
if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
|
||||
log_error "部署包缺少 bin/server 二进制"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$TEMP_DIR/deploy/bin/server"
|
||||
|
||||
# 设置权限
|
||||
log_info "设置文件权限..."
|
||||
chmod +x "$DEPLOY_DIR/current/bin/server" || log_warn "设置可执行权限失败"
|
||||
# 部署新版本(目录级交换,接近原子)
|
||||
log_info "部署新版本..."
|
||||
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 容器是否存在
|
||||
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
|
||||
log_info "✅ 容器重启成功"
|
||||
else
|
||||
log_error "❌ 容器重启失败"
|
||||
log_error "❌ 容器重启失败,回滚到上一版本"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 等待容器启动
|
||||
log_info "等待容器启动..."
|
||||
sleep 3
|
||||
# 健康检查:轮询 HTTP 接口确认服务真正可用
|
||||
log_info "执行健康检查: $HEALTH_URL"
|
||||
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 docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
log_info "✅ 容器运行正常"
|
||||
|
||||
# 显示容器日志(最后 20 行)
|
||||
log_info "容器日志(最后 20 行):"
|
||||
docker logs --tail 20 "$CONTAINER_NAME"
|
||||
if [ "$HEALTHY" = "1" ]; then
|
||||
log_info "✅ 健康检查通过"
|
||||
curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
|
||||
rm -rf "$DEPLOY_DIR/current.old"
|
||||
else
|
||||
log_error "❌ 容器未运行"
|
||||
log_error "容器日志:"
|
||||
docker logs --tail 50 "$CONTAINER_NAME"
|
||||
log_error "❌ 健康检查失败,回滚到上一版本"
|
||||
docker logs --tail 50 "$CONTAINER_NAME" || true
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Run tests
|
||||
working-directory: mini
|
||||
run: npm test -- --runInBand
|
||||
run: npm test
|
||||
- name: Configure production API and app version
|
||||
working-directory: mini
|
||||
env:
|
||||
|
||||
@@ -62,20 +62,6 @@ jobs:
|
||||
echo "Version info saved to 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
|
||||
working-directory: server
|
||||
run: |
|
||||
@@ -83,8 +69,9 @@ jobs:
|
||||
cp -r bin/ deploy/
|
||||
cp -r migrations/ deploy/
|
||||
cp -r scripts/ deploy/
|
||||
if [ -d "web/dist" ]; then
|
||||
cp -r web/dist/ deploy/public/
|
||||
# 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
|
||||
if [ -d "web" ]; then
|
||||
cp -r web/ deploy/web/
|
||||
fi
|
||||
tar -czf deploy.tar.gz deploy/
|
||||
|
||||
@@ -107,8 +94,8 @@ jobs:
|
||||
run: |
|
||||
VERSION="1.0.${GITEA_RUN_NUMBER}"
|
||||
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
|
||||
if: failure()
|
||||
@@ -118,7 +105,7 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
bash .gitea/scripts/notify.sh failure \
|
||||
"后端服务部署失败" \
|
||||
"后端构建失败" \
|
||||
"请检查 Actions 日志了解失败原因"
|
||||
|
||||
- name: Cleanup
|
||||
|
||||
+6
-5
@@ -55,14 +55,15 @@ server/vendor/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# 前端构建(如果 server 包含前端)
|
||||
# 前端构建产物与依赖(注意:server/web 是管理后台源码,必须入库)
|
||||
server/public/build/
|
||||
server/public/hot
|
||||
server/web/
|
||||
server/web/node_modules/
|
||||
server/web/dist/
|
||||
|
||||
# web 版万年历(独立项目,不属于本仓库)
|
||||
web/
|
||||
wishing-tree-pages/
|
||||
# web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web)
|
||||
/web/
|
||||
/wishing-tree-pages/
|
||||
|
||||
# 数据库
|
||||
*.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
@@ -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) {
|
||||
@@ -20,8 +38,8 @@ function request(options) {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(res.data);
|
||||
} else if (res.statusCode === 401) {
|
||||
// 未授权,跳转登录
|
||||
wx.navigateTo({ url: '/pages/settings/settings' });
|
||||
// 未授权:清除失效 token,由页面引导重新登录
|
||||
wx.removeStorageSync('token');
|
||||
reject(new Error('未授权'));
|
||||
} else {
|
||||
reject(new Error(res.data.msg || '请求失败'));
|
||||
|
||||
+34
-9
@@ -8,9 +8,27 @@ import (
|
||||
"github.com/gouki/lunar-server/internal/handler"
|
||||
"github.com/gouki/lunar-server/internal/middleware"
|
||||
"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() {
|
||||
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.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()
|
||||
|
||||
@@ -28,16 +46,16 @@ func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
// 创建路由(gin.Default 自带访问日志与 Recovery)
|
||||
r := gin.Default()
|
||||
|
||||
// 中间件
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Logger())
|
||||
|
||||
// 静态文件
|
||||
// 静态文件与管理后台模板
|
||||
r.Static("/static", "./web/static")
|
||||
r.StaticFile("/", "./web/index.html")
|
||||
r.LoadHTMLFiles("./web/index.html")
|
||||
|
||||
// API 路由
|
||||
api := r.Group("/api")
|
||||
@@ -87,13 +105,20 @@ func main() {
|
||||
|
||||
// 管理后台路由(Inertia.js)
|
||||
admin := r.Group("/admin")
|
||||
admin.Use(middleware.AdminAuth())
|
||||
{
|
||||
admin.GET("/", handler.AdminDashboard)
|
||||
admin.GET("/users", handler.AdminUsers)
|
||||
admin.GET("/orders", handler.AdminOrders)
|
||||
admin.GET("/wishes", handler.AdminWishes)
|
||||
admin.GET("/settings", handler.AdminSettings)
|
||||
// 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
|
||||
admin.POST("/login", handler.AdminLogin)
|
||||
admin.POST("/logout", handler.AdminLogout)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
|
||||
@@ -10,11 +10,13 @@ type Config struct {
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Wechat WechatConfig
|
||||
Admin AdminConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Env string
|
||||
CORSOrigins string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -43,11 +45,18 @@ type WechatConfig struct {
|
||||
MchID string
|
||||
}
|
||||
|
||||
// AdminConfig 管理后台配置
|
||||
type AdminConfig struct {
|
||||
// Password 管理员登录密码;为空时禁用后台登录
|
||||
Password string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Env: getEnv("SERVER_ENV", "development"),
|
||||
CORSOrigins: getEnv("CORS_ORIGINS", ""),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
@@ -71,6 +80,9 @@ func Load() *Config {
|
||||
PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
|
||||
MchID: getEnv("WECHAT_PAY_MCHID", ""),
|
||||
},
|
||||
Admin: AdminConfig{
|
||||
Password: getEnv("ADMIN_PASSWORD", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"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 管理后台首页
|
||||
func AdminDashboard(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "管理后台",
|
||||
"page": "dashboard",
|
||||
})
|
||||
adminPage(c, "管理后台", "dashboard")
|
||||
}
|
||||
|
||||
// AdminUsers 用户管理
|
||||
func AdminUsers(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "用户管理",
|
||||
"page": "users",
|
||||
})
|
||||
adminPage(c, "用户管理", "users")
|
||||
}
|
||||
|
||||
// AdminOrders 订单管理
|
||||
func AdminOrders(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "订单管理",
|
||||
"page": "orders",
|
||||
})
|
||||
adminPage(c, "订单管理", "orders")
|
||||
}
|
||||
|
||||
// AdminWishes 许愿管理
|
||||
func AdminWishes(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "许愿管理",
|
||||
"page": "wishes",
|
||||
})
|
||||
adminPage(c, "许愿管理", "wishes")
|
||||
}
|
||||
|
||||
// AdminSettings 系统设置
|
||||
func AdminSettings(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "系统设置",
|
||||
"page": "settings",
|
||||
})
|
||||
adminPage(c, "系统设置", "settings")
|
||||
}
|
||||
|
||||
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderDetail 获取订单详情
|
||||
// GetOrderDetail 获取订单详情(仅限本人订单)
|
||||
func GetOrderDetail(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
id := c.Param("id")
|
||||
orderID, _ := strconv.Atoi(id)
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
order, err := orderService.GetOrderByID(uint(orderID))
|
||||
if err != nil {
|
||||
if err != nil || order.UserID != userID.(uint) {
|
||||
// 不存在与无权访问统一返回 404,避免枚举他人订单
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"msg": "订单不存在",
|
||||
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CancelOrder 取消订单
|
||||
// CancelOrder 取消订单(仅限本人待支付订单)
|
||||
func CancelOrder(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
id := c.Param("id")
|
||||
orderID, _ := strconv.Atoi(id)
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
if err := orderService.CancelOrder(uint(orderID)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "取消订单失败",
|
||||
if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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/service"
|
||||
)
|
||||
|
||||
// CreateOrder 创建订单
|
||||
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
|
||||
func CreateOrder(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
@@ -22,8 +28,6 @@ func CreateOrder(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
|
||||
ProductID uint `json:"productId" binding:"required"` // 商品ID
|
||||
ProductName string `json:"productName" binding:"required"` // 商品名称
|
||||
Amount int `json:"amount" binding:"required"` // 金额(分)
|
||||
Content string `json:"content"` // 许愿内容(许愿类型需要)
|
||||
}
|
||||
|
||||
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
|
||||
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{
|
||||
UserID: userID.(uint),
|
||||
Type: req.Type,
|
||||
ProductID: req.ProductID,
|
||||
ProductName: req.ProductName,
|
||||
Amount: req.Amount,
|
||||
ProductID: product.ID,
|
||||
ProductName: product.Name,
|
||||
Amount: product.Price, // 金额以商品表为准
|
||||
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
|
||||
}
|
||||
|
||||
if err := orderService.CreateOrder(order); err != nil {
|
||||
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
|
||||
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, "")
|
||||
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) {
|
||||
// TODO: 验证微信支付回调签名
|
||||
cfg := config.Load()
|
||||
if cfg.Wechat.PayKey == "" {
|
||||
// 未配置支付密钥时拒绝一切回调,避免裸奔
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"code": "FAIL",
|
||||
"msg": "支付服务未配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
OrderNo string `json:"orderNo" binding:"required"`
|
||||
TransactionID string `json:"transactionId" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -104,7 +146,25 @@ func PayNotify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "SUCCESS" {
|
||||
// 验签:恒定时间比较,防时序攻击
|
||||
sign := c.GetHeader("X-Pay-Sign")
|
||||
expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
|
||||
if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "FAIL",
|
||||
"msg": "签名验证失败",
|
||||
})
|
||||
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{
|
||||
@@ -113,7 +173,6 @@ func PayNotify(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": "SUCCESS",
|
||||
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetPayStatus 获取支付状态
|
||||
// GetPayStatus 获取支付状态(支持订单号或订单 ID)
|
||||
func GetPayStatus(c *gin.Context) {
|
||||
orderID := c.Param("orderId")
|
||||
param := c.Param("orderId")
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
@@ -9,7 +14,71 @@ import (
|
||||
"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) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
@@ -23,38 +92,35 @@ func UserLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用微信接口获取 openid
|
||||
// 这里模拟返回
|
||||
openID := "mock_openid_" + req.Code
|
||||
|
||||
userService := service.NewUserService()
|
||||
user, err := userService.GetUserByOpenID(openID)
|
||||
openID, unionID, err := code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "服务器错误",
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "微信登录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果用户不存在,创建新用户
|
||||
if user == nil {
|
||||
user = &model.User{
|
||||
OpenID: openID,
|
||||
Nickname: "微信用户",
|
||||
Status: 1,
|
||||
}
|
||||
if err := userService.CreateUser(user); err != nil {
|
||||
user, err := upsertUser(openID, unionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "创建用户失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 禁用用户不允许登录
|
||||
if user.Status != 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"msg": "账号已被禁用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
cfg := config.Load()
|
||||
userService := service.NewUserService()
|
||||
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -217,7 +283,7 @@ func UpdateUserProfile(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// WechatAuth 微信授权
|
||||
// WechatAuth 微信授权(仅返回 openid,session_key 属敏感凭证不下发)
|
||||
func WechatAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
@@ -231,16 +297,20 @@ func WechatAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用微信接口获取 openid 和 session_key
|
||||
// 这里模拟返回
|
||||
openID := "mock_openid_" + req.Code
|
||||
openID, _, err := code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "微信授权失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"openid": openID,
|
||||
"sessionKey": "mock_session_key",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
CommitSha = "unknown"
|
||||
)
|
||||
|
||||
// VersionInfo 版本信息
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
@@ -17,26 +24,10 @@ type VersionInfo struct {
|
||||
|
||||
// GetVersion 获取服务器版本信息
|
||||
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{
|
||||
Version: version,
|
||||
BuildTime: buildTime,
|
||||
CommitSha: commitSha,
|
||||
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取
|
||||
Version: Version,
|
||||
BuildTime: BuildTime,
|
||||
CommitSha: CommitSha,
|
||||
GoVersion: runtime.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,12 +9,41 @@ import (
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
// CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
|
||||
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) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
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")
|
||||
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" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
@@ -25,16 +54,8 @@ func CORS() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Logger 日志中间件
|
||||
func Logger() gin.HandlerFunc {
|
||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
// Auth JWT认证中间件
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
|
||||
func bearerToken(c *gin.Context) (string, bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
@@ -42,10 +63,9 @@ func Auth() gin.HandlerFunc {
|
||||
"msg": "未授权",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 解析 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
@@ -53,12 +73,19 @@ func Auth() gin.HandlerFunc {
|
||||
"msg": "token格式错误",
|
||||
})
|
||||
c.Abort()
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// Auth JWT认证中间件
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString, ok := bearerToken(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// 解析 token
|
||||
cfg := config.Load()
|
||||
userService := service.NewUserService()
|
||||
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
|
||||
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户ID存入上下文
|
||||
c.Set("userID", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminAuth 管理员认证中间件
|
||||
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"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
|
||||
}
|
||||
|
||||
// CancelOrder 取消订单
|
||||
func (s *OrderService) CancelOrder(id uint) error {
|
||||
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error
|
||||
// CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
|
||||
func (s *OrderService) CancelOrder(userID, id uint) 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 检查订单是否过期
|
||||
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// HandlePayNotify 处理支付回调
|
||||
// HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
|
||||
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
|
||||
// 更新订单状态
|
||||
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil {
|
||||
return err
|
||||
order, err := s.GetOrderByOrderNo(orderNo)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -93,9 +93,48 @@ func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
userID := uint(claims["user_id"].(float64))
|
||||
return userID, nil
|
||||
// 管理员 token 不绑定具体用户,不能当普通用户 token 使用
|
||||
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")
|
||||
}
|
||||
|
||||
// AdminTokenCookie 管理员会话 Cookie 名
|
||||
const AdminTokenCookie = "lunar_admin_token"
|
||||
|
||||
// GenerateAdminToken 签发管理员 JWT(12 小时过期)
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ echo "Building Lunar Server..."
|
||||
# 创建输出目录
|
||||
mkdir -p bin
|
||||
|
||||
# 编译 Go 后端
|
||||
# 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
|
||||
echo "Building Go backend..."
|
||||
go build -o bin/lunar-server cmd/main.go
|
||||
go build -o bin/server cmd/main.go
|
||||
|
||||
# 编译前端(如果存在)
|
||||
if [ -d "web" ]; then
|
||||
# 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建)
|
||||
if [ -f "web/package.json" ]; then
|
||||
echo "Building frontend..."
|
||||
cd web
|
||||
npm install
|
||||
npm ci
|
||||
npm run build
|
||||
cd ..
|
||||
fi
|
||||
|
||||
echo "Build complete!"
|
||||
echo "Output: bin/lunar-server"
|
||||
echo "Output: bin/server"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user