commit 2e2c161770b1fb6e623172a5b5a683866f16d1d9 Author: becomer Date: Fri Jul 3 15:37:55 2026 +0800 feat: init cpdaily-xiaozhushou with blue-green deploy - backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d351e0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Go +backed/server +backed/cpdaily-new +backed/cpdaily-server +backed/*.log +backed/*.har + +# Node +frondend/node_modules/ +frondend/dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker +*.tar diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..629d68e --- /dev/null +++ b/.woodpecker/build.yml @@ -0,0 +1,188 @@ +when: + - event: push + branch: [main, master] + - event: tag + branch: [v*] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + remote: http://10.10.10.128:3000/becomer/cpdaily_xiaozhushou.git + +steps: + build-image: + name: 构建 Docker 镜像 + image: docker + commands: | + docker build -t cpdaily-xiaozhushou:latest . + docker tag cpdaily-xiaozhushou:latest cpdaily-xiaozhushou:${CI_COMMIT_TAG:-${CI_COMMIT_SHA:0:8}} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + + deploy: + name: 蓝绿部署 + image: docker + commands: | + set -e + + IMAGE_TAG="${CI_COMMIT_TAG:-latest}" + + # 确保网络存在 + docker network create likedream-net 2>/dev/null || true + + # ============================================================ + # 第一步:初始化 1Panel 站点配置(首次部署时才需要) + # ============================================================ + if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then + echo "=== 首次部署:创建 1Panel 站点配置 ===" + docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log + cat <<'NGINX_EOF' | docker exec -i 1Panel-openresty-ESmn tee /usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf +server { + listen 80 ; + server_name xiaozhushou.eu.cc; + index index.php index.html index.htm default.php default.htm default.html; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + access_log /www/sites/xiaozhushou/log/access.log main; + error_log /www/sites/xiaozhushou/log/error.log; + location ^~ /.well-known/acme-challenge { + allow all; + root /usr/share/nginx/html; + } + include /www/sites/xiaozhushou/proxy/*.conf; +} +NGINX_EOF + cat <<'PROXY_EOF' | docker exec -i 1Panel-openresty-ESmn tee /www/sites/xiaozhushou/proxy/api.conf +location ^~ / { + proxy_pass http://10.10.10.128:8884/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Port $server_port; + proxy_http_version 1.1; + add_header X-Cache $upstream_cache_status; + add_header Cache-Control no-cache; + proxy_ssl_server_name off; + proxy_ssl_name $proxy_host; +} +PROXY_EOF + docker exec 1Panel-openresty-ESmn nginx -s reload + echo " 1Panel 站点配置完成 ✓" + fi + + # ============================================================ + # 第二步:读取当前代理端口,确定蓝绿状态 + # ============================================================ + CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \ + grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \ + /www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$') + echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ===" + + if [ "$CURRENT_PORT" = "8884" ]; then + PORT_ACTIVE=8884 + PORT_STANDBY=8885 + elif [ "$CURRENT_PORT" = "8885" ]; then + PORT_ACTIVE=8885 + PORT_STANDBY=8884 + else + echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885" + PORT_ACTIVE=8884 + PORT_STANDBY=8885 + fi + echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY" + + # 清理之前可能残留的临时容器 + docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true + + # ============================================================ + # 第三步:在备用端口启动新容器 + # ============================================================ + echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ===" + + docker run -d --name cpdaily-xiaozhushou-new \ + --restart unless-stopped \ + --network likedream-net \ + -p $PORT_STANDBY:8093 \ + -v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \ + cpdaily-xiaozhushou:$IMAGE_TAG + + # ============================================================ + # 第四步:健康检查 + # ============================================================ + HEALTHY=false + for i in $(seq 1 15); do + if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then + HEALTHY=true + break + fi + echo " 等待新容器就绪... ($i/15)" + sleep 2 + done + + if [ "$HEALTHY" = false ]; then + echo "ERROR: 新容器未通过健康检查,回滚" + docker stop cpdaily-xiaozhushou-new + docker rm cpdaily-xiaozhushou-new + exit 1 + fi + echo " 新容器已就绪 ✓" + + # ============================================================ + # 第五步:切换 1Panel 代理到备用端口 + # ============================================================ + echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ===" + docker exec 1Panel-openresty-ESmn \ + sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \ + /www/sites/xiaozhushou/proxy/api.conf + docker exec 1Panel-openresty-ESmn nginx -s reload + echo " Nginx 热加载完成(零中断)✓" + + # ============================================================ + # 第六步:停止旧容器 + # ============================================================ + echo "=== 清理旧容器 ===" + docker stop cpdaily-xiaozhushou 2>/dev/null || true + docker rm cpdaily-xiaozhushou 2>/dev/null || true + + # ============================================================ + # 第七步:在活跃端口启动正式容器 + # ============================================================ + echo "=== 启动正式容器 on :$PORT_ACTIVE ===" + docker run -d --name cpdaily-xiaozhushou \ + --restart unless-stopped \ + --network likedream-net \ + -p $PORT_ACTIVE:8093 \ + -v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \ + cpdaily-xiaozhushou:$IMAGE_TAG + + # ============================================================ + # 第八步:切回 1Panel 代理到活跃端口 + # ============================================================ + echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ===" + docker exec 1Panel-openresty-ESmn \ + sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \ + /www/sites/xiaozhushou/proxy/api.conf + docker exec 1Panel-openresty-ESmn nginx -s reload + echo " Nginx 热加载完成(零中断)✓" + + # ============================================================ + # 第九步:清理 + # ============================================================ + echo "=== 清理 ===" + docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true + docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do + docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true + done + echo "=== 部署完成 ✓ ===" + volumes: + - /var/run/docker.sock:/var/run/docker.sock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..147f60c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ============================================================ +# Stage 1: 构建前端 (Vue 3 + Vite) +# ============================================================ +FROM node:23-alpine AS frontend-builder +WORKDIR /build +COPY frondend/package*.json ./ +RUN npm ci +COPY frondend/ ./ +RUN npm run build + +# ============================================================ +# Stage 2: 构建后端 (Go + Fiber) +# ============================================================ +FROM golang:1.26-alpine AS backend-builder +WORKDIR /build +ENV GOPROXY=https://goproxy.cn,direct +COPY backed/go.mod backed/go.sum ./ +RUN go mod download +COPY backed/ ./ +RUN go build -ldflags="-s -w" -o server ./cmd/server/ + +# ============================================================ +# Stage 3: 运行时镜像(仅含二进制 + 前端静态文件) +# ============================================================ +FROM alpine:latest +RUN apk add --no-cache ca-certificates tzdata +COPY --from=backend-builder /build/server /app/server +COPY --from=frontend-builder /build/dist /app/dist +WORKDIR /app +EXPOSE 8093 +CMD ["./server"] diff --git a/backed/cmd/check_admin/main.go b/backed/cmd/check_admin/main.go new file mode 100644 index 0000000..02b2489 --- /dev/null +++ b/backed/cmd/check_admin/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "fmt" + "log" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if err := config.Load(""); err != nil { + log.Fatal(err) + } + database.Init() + var agents []model.AgentUser + database.DB.Find(&agents) + if len(agents) == 0 { + fmt.Println("数据库中没有任何管理员/代理账号") + return + } + for _, a := range agents { + fmt.Printf("ID=%d Username=%s Role=%s Status=%s PasswordHash=%s\n", a.ID, a.Username, a.Role, a.Status, a.Password) + testPasswords := []string{"baca122...", "baca122", "admin123", "123456"} + for _, p := range testPasswords { + err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(p)) + if err == nil { + fmt.Printf(" >>> 密码匹配成功: '%s'\n", p) + } + } + } +} diff --git a/backed/cmd/reset_pwd/main.go b/backed/cmd/reset_pwd/main.go new file mode 100644 index 0000000..d8e83be --- /dev/null +++ b/backed/cmd/reset_pwd/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "fmt" + "log" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if err := config.Load(""); err != nil { + log.Fatal(err) + } + database.Init() + + hashed, err := bcrypt.GenerateFromPassword([]byte("baca122..."), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("加密失败: %v", err) + } + + result := database.DB.Model(&model.AgentUser{}). + Where("username = ?", "becomer"). + Update("password", string(hashed)) + + if result.Error != nil { + log.Fatalf("更新失败: %v", result.Error) + } + fmt.Printf("密码已重置,影响行数: %d\n", result.RowsAffected) +} diff --git a/backed/cmd/server/main.go b/backed/cmd/server/main.go new file mode 100644 index 0000000..e905a35 --- /dev/null +++ b/backed/cmd/server/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "time" + + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/handler" + "cpdaily-new/internal/middleware" + "cpdaily-new/internal/ratelimit" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" +) + +func main() { + // 加载配置 + if err := config.Load(""); err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + // 连接数据库 + database.Init() + + // 启动限流记录定时清理(每5分钟清理一次) + ratelimit.Cleanup(5 * time.Minute) + + // 创建 Fiber 应用 + app := fiber.New(fiber.Config{ + AppName: "Cpdaily New", + }) + + // 中间件 + app.Use(middleware.CORS()) + + // 路由 + api := app.Group("/api") + + // 认证相关 + auth := api.Group("/auth") + auth.Get("/captcha", handler.GetCaptcha) + auth.Get("/schools", handler.SearchSchools) + auth.Post("/login", handler.Login) + auth.Post("/refresh", handler.RefreshToken) + auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo) + auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode) + auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo) + auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward) + + // 签到任务相关 + task := api.Group("/task", middleware.AuthRequired()) + task.Get("/titles", handler.GetTaskTitles) + task.Post("/create", handler.CreateTask) + task.Get("/list", handler.ListTasks) + task.Get("/form/:id", handler.GetTaskForm) + task.Post("/form/:id", handler.SaveTaskForm) + task.Put("/update/:id", handler.UpdateTask) + task.Delete("/delete/:id", handler.DeleteTask) + task.Get("/execute/:id", handler.ExecuteTask) + task.Get("/logs", handler.ListTaskLogs) + task.Delete("/logs/:id", handler.DeleteTaskLog) + task.Post("/upload-policy", handler.GetUploadPolicy) + task.Post("/preview-url", handler.GetPreviewUrl) + + // CDK 兑换 + cdk := api.Group("/cdk", middleware.AuthRequired()) + cdk.Post("/redeem", handler.UserRedeemCDK) + + // 设备绑定 + device := api.Group("/device") + device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr) + device.Post("/submit", handler.SubmitDeviceCode) + device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus) + + // 管理后台 API + admin := api.Group("/admin") + admin.Get("/setup/status", handler.AdminSetupStatus) + admin.Post("/setup/init", handler.AdminSetupInit) + admin.Post("/login", handler.AdminLogin) + admin.Get("/me", middleware.AdminAuthRequired(), handler.AdminMe) + + // 管理后台 - 代理/管理员 + adminAuth := api.Group("/admin", middleware.AdminAuthRequired()) + adminAuth.Get("/cdks", handler.ListCDKs) + adminAuth.Post("/cdks", handler.GenerateCDK) + adminAuth.Get("/all-cdks", handler.ListAllCDKs) + adminAuth.Get("/agents", handler.ListSubAgents) + adminAuth.Get("/agent/list", handler.ListAllAgents) + adminAuth.Post("/agent/create", handler.CreateSubAgent) + adminAuth.Put("/agent/update/:id", handler.UpdateSubAgent) + adminAuth.Put("/agent/password/:id", handler.UpdateAgentPassword) + adminAuth.Put("/agent/status/:id", handler.UpdateAgentStatus) + adminAuth.Get("/agent/detail/:id", handler.GetAgentDetail) + + // 管理后台 - 公告 + adminAuth.Post("/announcement/create", handler.CreateAnnouncement) + adminAuth.Get("/announcements", handler.ListAnnouncements) + adminAuth.Get("/announcement/:id", handler.GetAnnouncement) + adminAuth.Delete("/announcement/:id", handler.DeleteAnnouncement) + + // 管理后台 - 系统设置 + adminAuth.Get("/settings", handler.GetSettings) + adminAuth.Post("/settings", handler.UpdateSetting) + + // 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent) + adminAuth.Get("/my-announcements", handler.ListUserAnnouncements) + adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount) + adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo) + adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead) + + // 用户公告 + api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements) + api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount) + api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo) + api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead) + + // 设备授权页面(纯 HTML) + api.Get("/device/authorize/:token", handler.AuthorizePage) + api.Get("/device/close", handler.ClosePage) + + // 工单系统 + ticket := api.Group("/ticket", middleware.AuthRequired()) + ticket.Post("/create", handler.CreateTicket) + ticket.Get("/list", handler.ListMyTickets) + ticket.Get("/:id", handler.GetTicketDetail) + ticket.Post("/:id/message", handler.AddTicketMessage) + ticket.Put("/:id/close", handler.CloseTicket) + ticket.Delete("/:id", handler.DeleteTicket) + ticket.Get("/notifications/list", handler.ListTicketNotifications) + ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount) + ticket.Post("/notifications/:id/read", handler.MarkTicketNotificationRead) + + // 用户报告登录失败(无需认证,有独立速率限制 + 验证码校验) + // 使用独立路径避免与 /ticket 组的 AuthRequired 中间件冲突 + api.Post("/report-failed-login", handler.ReportFailedLogin) + + // 管理后台 - 工单管理 + adminAuth.Post("/ticket/reply/:id", handler.AdminReplyTicket) + adminAuth.Put("/ticket/status/:id", handler.AdminUpdateTicketStatus) + adminAuth.Get("/ticket/list", handler.AdminListTickets) + adminAuth.Get("/ticket/:id", handler.AdminGetTicket) + + // 管理后台 - 待适配学校 + adminAuth.Get("/pending-schools", handler.AdminListPendingSchools) + adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone) + + // 健康检查(用于蓝绿部署) + app.Get("/health", func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + // 静态文件服务(前端 SPA) + app.Static("/assets", "./dist/assets", fiber.Static{ + MaxAge: 86400, + }) + app.Use(filesystem.New(filesystem.Config{ + Root: http.Dir("./dist"), + Index: "index.html", + NotFoundFile: "index.html", + MaxAge: 3600, + })) + + // 启动 + addr := fmt.Sprintf(":%d", config.Global.Server.Port) + log.Printf("服务器启动在 %s", addr) + if err := app.Listen(addr); err != nil { + log.Fatalf("服务器启动失败: %v", err) + } +} diff --git a/backed/cmd/verify_pwd/main.go b/backed/cmd/verify_pwd/main.go new file mode 100644 index 0000000..57c9261 --- /dev/null +++ b/backed/cmd/verify_pwd/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "fmt" + "log" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if err := config.Load(""); err != nil { + log.Fatal(err) + } + database.Init() + var agent model.AgentUser + database.DB.Where("username = ?", "becomer").First(&agent) + err := bcrypt.CompareHashAndPassword([]byte(agent.Password), []byte("baca122...")) + if err == nil { + fmt.Println("✅ 密码验证通过: baca122...") + } else { + fmt.Println("❌ 密码验证失败:", err) + } +} \ No newline at end of file diff --git a/backed/config.toml b/backed/config.toml new file mode 100644 index 0000000..63a22bb --- /dev/null +++ b/backed/config.toml @@ -0,0 +1,31 @@ +[server] +port = 8093 +mode = "dev" + +[database] +host = "127.0.0.1" +port = 3306 +user = "likedream-go" +password = "bh7S4JzPVyY0VLJh44i" +dbname = "likedream-go" + +[jwt] +secret = "cpdaily-new-jwt-secret-key-2026" +accessExpire = 3600 +refreshExpire = 864000 + +[captcha] +width = 150 +height = 46 +length = 4 + +[cpdaily] +appVersion = "9.8.8" +systemVersion = "iOS 17" +calVersion = "first_v4" +sliderIds = ["1018764144934965", "sdhyxy", "hqdx"] +captchaBaseUrl = "http://10.10.10.128:8890" +captchaApiKey = "baca122..." + +[agent] +systemCdkPrice = 10.0 diff --git a/backed/go.mod b/backed/go.mod new file mode 100644 index 0000000..a1ebbbb --- /dev/null +++ b/backed/go.mod @@ -0,0 +1,37 @@ +module cpdaily-new + +go 1.26.4 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/PuerkitoBio/goquery v1.12.0 + github.com/go-resty/resty/v2 v2.16.5 + github.com/go-sql-driver/mysql v1.8.1 + github.com/gofiber/fiber/v2 v2.52.13 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.50.0 + golang.org/x/time v0.6.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/backed/go.sum b/backed/go.sum new file mode 100644 index 0000000..80a92a3 --- /dev/null +++ b/backed/go.sum @@ -0,0 +1,123 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= +github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= +github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backed/internal/config/config.go b/backed/internal/config/config.go new file mode 100644 index 0000000..c9b1721 --- /dev/null +++ b/backed/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "os" + "strconv" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Server ServerConfig `toml:"server"` + Database DatabaseConfig `toml:"database"` + Jwt JwtConfig `toml:"jwt"` + Captcha CaptchaConfig `toml:"captcha"` + Cpdaily CpdailyConfig `toml:"cpdaily"` + Agent AgentConfig `toml:"agent"` +} + +// AgentConfig 代理系统配置 +type AgentConfig struct { + SystemCdkPrice float64 `toml:"systemCdkPrice"` // CDK系统底价(元) +} + +type ServerConfig struct { + Port int `toml:"port"` + Mode string `toml:"mode"` +} + +type DatabaseConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + User string `toml:"user"` + Password string `toml:"password"` + Dbname string `toml:"dbname"` +} + +type JwtConfig struct { + Secret string `toml:"secret"` + AccessExpire int `toml:"accessExpire"` + RefreshExpire int `toml:"refreshExpire"` +} + +type CaptchaConfig struct { + Width int `toml:"width"` + Height int `toml:"height"` + Length int `toml:"length"` +} + +type CpdailyConfig struct { + AppVersion string `toml:"appVersion"` + SystemVersion string `toml:"systemVersion"` + CalVersion string `toml:"calVersion"` + SliderIds []string `toml:"sliderIds"` + CaptchaBaseUrl string `toml:"captchaBaseUrl"` + CaptchaApiKey string `toml:"captchaApiKey"` +} + +var Global Config + +func Load(path string) error { + if path == "" { + path = "config.toml" + if _, err := os.Stat(path); os.IsNotExist(err) { + path = "../config.toml" + } + } + _, err := toml.DecodeFile(path, &Global) + if err != nil { + return err + } + // 环境变量覆盖 + if v := os.Getenv("SERVER_PORT"); v != "" { + Global.Server.Port, _ = strconv.Atoi(v) + } + if v := os.Getenv("DB_PASSWORD"); v != "" { + Global.Database.Password = v + } + if v := os.Getenv("JWT_SECRET"); v != "" { + Global.Jwt.Secret = v + } + return nil +} + +func DSN() string { + cfg := Global.Database + return cfg.User + ":" + cfg.Password + "@tcp(" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + ")/" + cfg.Dbname + "?charset=utf8mb4&parseTime=True&loc=Local" +} diff --git a/backed/internal/database/mysql.go b/backed/internal/database/mysql.go new file mode 100644 index 0000000..87c7e9f --- /dev/null +++ b/backed/internal/database/mysql.go @@ -0,0 +1,35 @@ +package database + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/model" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init() { + dsn := config.DSN() + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + + // 自动迁移 + if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil { + log.Fatalf("自动迁移失败: %v", err) + } + + sqlDB, _ := DB.DB() + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + + log.Println("数据库连接成功") +} diff --git a/backed/internal/handler/admin_announcement.go b/backed/internal/handler/admin_announcement.go new file mode 100644 index 0000000..7138076 --- /dev/null +++ b/backed/internal/handler/admin_announcement.go @@ -0,0 +1,170 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "errors" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// CreateAnnouncementRequest 创建公告请求 +type CreateAnnouncementRequest struct { + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"targetType"` // agent / user + ForcePopup bool `json:"forcePopup"` + LoopPopup bool `json:"loopPopup"` +} + +// CreateAnnouncement 创建公告 +func CreateAnnouncement(c *fiber.Ctx) error { + adminID := c.Locals("adminId").(uint) + + var req CreateAnnouncementRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Title == "" { + return utils.FailParam(c, "公告标题不能为空") + } + if req.Content == "" { + return utils.FailParam(c, "公告内容不能为空") + } + if req.TargetType != "agent" && req.TargetType != "user" { + return utils.Fail(c, 400, "目标类型必须为 agent 或 user") + } + + announcement := model.Announcement{ + Title: req.Title, + Content: req.Content, + TargetType: req.TargetType, + ForcePopup: req.ForcePopup, + LoopPopup: req.LoopPopup, + CreatedBy: adminID, + } + + if err := database.DB.Create(&announcement).Error; err != nil { + slog.Error("创建公告失败", "error", err) + return utils.FailServer(c, "创建公告失败") + } + + slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType) + return utils.Success(c, announcement) +} + +// AnnouncementListItem 公告列表项(含统计) +type AnnouncementListItem struct { + model.Announcement + ReadCount int `json:"readCount"` + UnreadCount int `json:"unreadCount"` + TotalTargets int64 `json:"totalTargets"` +} + +// ListAnnouncements 公告列表(含已读/未读统计) +func ListAnnouncements(c *fiber.Ctx) error { + var announcements []model.Announcement + database.DB.Order("created_at DESC").Find(&announcements) + + // 获取目标总数 + var totalAgents int64 + var totalUsers int64 + database.DB.Model(&model.AgentUser{}).Count(&totalAgents) + database.DB.Model(&model.SysUser{}).Count(&totalUsers) + + items := make([]AnnouncementListItem, 0, len(announcements)) + for _, a := range announcements { + var readCount int64 + database.DB.Model(&model.AnnouncementRead{}). + Where("announcement_id = ?", a.ID). + Count(&readCount) + + var totalTargets int64 + if a.TargetType == "agent" { + totalTargets = totalAgents + } else { + totalTargets = totalUsers + } + + items = append(items, AnnouncementListItem{ + Announcement: a, + ReadCount: int(readCount), + UnreadCount: int(max(0, totalTargets-readCount)), + TotalTargets: totalTargets, + }) + } + + return utils.Success(c, items) +} + +// GetAnnouncement 公告详情(含已读用户列表) +func GetAnnouncement(c *fiber.Ctx) error { + id := c.Params("id") + + var announcement model.Announcement + if err := database.DB.First(&announcement, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "公告不存在") + } + return utils.FailServer(c, "查询失败") + } + + // 查询已读记录(带用户信息) + type ReadUser struct { + UserID uint `json:"userId"` + Username string `json:"username"` + ReadAt time.Time `json:"readAt"` + } + + var reads []model.AnnouncementRead + database.DB.Where("announcement_id = ?", announcement.ID).Find(&reads) + + readUsers := make([]ReadUser, 0, len(reads)) + for _, r := range reads { + username := "" + if r.UserType == "agent" { + var agent model.AgentUser + if err := database.DB.First(&agent, r.UserID).Error; err == nil { + username = agent.Username + } + } else { + var user model.SysUser + if err := database.DB.First(&user, r.UserID).Error; err == nil { + username = user.Username + } + } + readUsers = append(readUsers, ReadUser{ + UserID: r.UserID, + Username: username, + ReadAt: r.ReadAt, + }) + } + + return utils.Success(c, fiber.Map{ + "announcement": announcement, + "readUsers": readUsers, + }) +} + +// DeleteAnnouncement 删除公告 +func DeleteAnnouncement(c *fiber.Ctx) error { + id := c.Params("id") + + result := database.DB.Delete(&model.Announcement{}, id) + if result.Error != nil { + return utils.FailServer(c, "删除失败") + } + if result.RowsAffected == 0 { + return utils.Fail(c, 404, "公告不存在") + } + + // 同时删除已读记录 + database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{}) + + slog.Info("删除公告", "id", id) + return utils.Success(c, nil) +} diff --git a/backed/internal/handler/admin_auth.go b/backed/internal/handler/admin_auth.go new file mode 100644 index 0000000..10457a0 --- /dev/null +++ b/backed/internal/handler/admin_auth.go @@ -0,0 +1,183 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/service" + "cpdaily-new/internal/utils" + "log/slog" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" +) + +// AdminSetupStatus 检查是否已安装(是否有admin账号) +func AdminSetupStatus(c *fiber.Ctx) error { + var count int64 + database.DB.Model(&model.AgentUser{}).Where("role = ?", "admin").Count(&count) + return utils.Success(c, fiber.Map{ + "installed": count > 0, + }) +} + +// AdminSetupInitRequest 初始化安装请求 +type AdminSetupInitRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// AdminSetupInit 初始化创建管理员账号 +func AdminSetupInit(c *fiber.Ctx) error { + var req AdminSetupInitRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Username == "" || req.Password == "" { + return utils.FailParam(c, "用户名和密码不能为空") + } + if len(req.Password) < 6 { + return utils.FailParam(c, "密码长度不能少于6位") + } + + // 检查是否已存在admin + var count int64 + database.DB.Model(&model.AgentUser{}).Where("role = ?", "admin").Count(&count) + if count > 0 { + return utils.Fail(c, 400, "管理员账号已存在,不能重复初始化") + } + + // 加密密码 + hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + slog.Error("密码加密失败", "error", err) + return utils.FailServer(c, "初始化失败") + } + + // 创建管理员 + admin := model.AgentUser{ + Username: req.Username, + Password: string(hashedPwd), + Role: "admin", + Rate: 1.00, + Balance: 0, + Status: "active", + } + if err := database.DB.Create(&admin).Error; err != nil { + slog.Error("创建管理员失败", "error", err) + return utils.FailServer(c, "初始化失败") + } + + // 生成 token 直接登录 + token, expiresIn, err := service.GenerateAdminToken(admin.ID, admin.Username, admin.Role) + if err != nil { + slog.Error("生成token失败", "error", err) + return utils.FailServer(c, "初始化成功但生成token失败,请前往登录") + } + + slog.Info("管理员初始化成功", "username", req.Username) + + return utils.Success(c, fiber.Map{ + "token": fiber.Map{ + "accessToken": token, + "expiresIn": expiresIn, + "tokenType": "Bearer", + }, + "agent": fiber.Map{ + "id": admin.ID, + "username": admin.Username, + "role": admin.Role, + "rate": admin.Rate, + "balance": admin.Balance, + "status": admin.Status, + }, + }) +} + +// AdminLoginRequest 管理员登录请求 +type AdminLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// AdminLogin 管理员/代理登录 +func AdminLogin(c *fiber.Ctx) error { + var req AdminLoginRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Username == "" || req.Password == "" { + return utils.FailParam(c, "用户名或密码不能为空") + } + + // 查找管理员/代理 + var agent model.AgentUser + if err := database.DB.Where("username = ?", req.Username).First(&agent).Error; err != nil { + return utils.Fail(c, 401, "用户名或密码错误") + } + + // 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(agent.Password), []byte(req.Password)); err != nil { + return utils.Fail(c, 401, "用户名或密码错误") + } + + // 检查状态 + if agent.Status == "disabled" { + return utils.Fail(c, 403, "账号已被禁用") + } + + // 生成 token + token, expiresIn, err := service.GenerateAdminToken(agent.ID, agent.Username, agent.Role) + if err != nil { + slog.Error("生成管理员token失败", "error", err) + return utils.FailServer(c, "登录失败") + } + + slog.Info("管理员登录成功", "agentId", agent.ID, "username", agent.Username, "role", agent.Role) + + return utils.Success(c, fiber.Map{ + "token": fiber.Map{ + "accessToken": token, + "expiresIn": expiresIn, + "tokenType": "Bearer", + }, + "agent": fiber.Map{ + "id": agent.ID, + "username": agent.Username, + "role": agent.Role, + "rate": agent.Rate, + "balance": agent.Balance, + "status": agent.Status, + }, + }) +} + +// AdminMe 获取当前管理员/代理信息 +func AdminMe(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + + var agent model.AgentUser + if err := database.DB.First(&agent, agentID).Error; err != nil { + return utils.FailAuth(c, "账号不存在") + } + + // 查询下级代理数量 + var subCount int64 + database.DB.Model(&model.AgentUser{}).Where("parent_id = ?", agentID).Count(&subCount) + + // 查询CDK数量 + var cdkCount int64 + database.DB.Model(&model.CDKCode{}).Where("agent_id = ?", agentID).Count(&cdkCount) + + return utils.Success(c, fiber.Map{ + "id": agent.ID, + "username": agent.Username, + "role": agent.Role, + "rate": agent.Rate, + "balance": agent.Balance, + "status": agent.Status, + "parentId": agent.ParentID, + "subCount": subCount, + "cdkCount": cdkCount, + "createdAt": agent.CreatedAt, + }) +} diff --git a/backed/internal/handler/agent.go b/backed/internal/handler/agent.go new file mode 100644 index 0000000..54dfdfb --- /dev/null +++ b/backed/internal/handler/agent.go @@ -0,0 +1,381 @@ +package handler + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "errors" + "log/slog" + "strconv" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// CreateAgentRequest 创建代理请求 +type CreateAgentRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Rate float64 `json:"rate"` + Balance float64 `json:"balance"` +} + +// CreateSubAgent 创建下级代理 +func CreateSubAgent(c *fiber.Ctx) error { + parentID := c.Locals("adminId").(uint) + parentRole := c.Locals("adminRole").(string) + + var req CreateAgentRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Username == "" || req.Password == "" { + return utils.FailParam(c, "用户名和密码不能为空") + } + if req.Rate <= 0 { + return utils.FailParam(c, "费率必须大于0") + } + if req.Balance < 0 { + return utils.FailParam(c, "充值金额不能为负数") + } + + // 获取当前代理信息 + var parentAgent model.AgentUser + if err := database.DB.First(&parentAgent, parentID).Error; err != nil { + return utils.Fail(c, 404, "当前账号不存在") + } + + // 校验费率:下级费率不能低于自身费率 + if req.Rate < parentAgent.Rate { + return utils.Fail(c, 400, "下级代理费率("+strconv.FormatFloat(req.Rate, 'f', 2, 64)+ + ")不得低于自身费率("+strconv.FormatFloat(parentAgent.Rate, 'f', 2, 64)+")") + } + + // 校验密码长度 + if len(req.Password) < 6 { + return utils.FailParam(c, "密码长度不能少于6位") + } + + // 检查用户名是否已存在 + var count int64 + database.DB.Model(&model.AgentUser{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + return utils.Fail(c, 400, "用户名已存在") + } + + // 加密密码 + hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + slog.Error("密码加密失败", "error", err) + return utils.FailServer(c, "创建失败") + } + + // 如果上级是admin,设置费率为系统默认 + rate := req.Rate + if parentRole == "admin" && rate == 0 { + rate = config.Global.Agent.SystemCdkPrice + } + + // 在事务中创建代理并扣减上级余额 + err = database.DB.Transaction(func(tx *gorm.DB) error { + // 如果上级不是admin(即上级也是代理),扣减其余额 + if parentRole != "admin" && req.Balance > 0 { + result := tx.Model(&model.AgentUser{}). + Where("id = ? AND balance >= ?", parentID, req.Balance). + Update("balance", gorm.Expr("balance - ?", req.Balance)) + if result.RowsAffected == 0 { + return errors.New("余额不足") + } + } + + // 创建下级代理 + subAgent := model.AgentUser{ + Username: req.Username, + Password: string(hashedPwd), + Role: "agent", + ParentID: &parentID, + Rate: rate, + Balance: req.Balance, + Status: "active", + } + if err := tx.Create(&subAgent).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + if err.Error() == "余额不足" { + return utils.Fail(c, 400, "余额不足,无法充值") + } + slog.Error("创建代理失败", "error", err) + return utils.FailServer(c, "创建失败") + } + + slog.Info("创建下级代理成功", "parentId", parentID, "username", req.Username, "rate", rate, "balance", req.Balance) + + return utils.Success(c, fiber.Map{ + "username": req.Username, + "role": "agent", + "rate": rate, + "balance": req.Balance, + }) +} + +// UpdateAgentRequest 更新代理请求(充值/修改费率) +type UpdateAgentRequest struct { + Balance *float64 `json:"balance,omitempty"` // 充值金额(追加) + Rate *float64 `json:"rate,omitempty"` // 修改费率 +} + +// UpdateSubAgent 更新下级代理(充值/修改费率) +func UpdateSubAgent(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + adminRole := c.Locals("adminRole").(string) + targetID := c.Params("id") + + var req UpdateAgentRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + + // 查找目标代理 + var target model.AgentUser + if err := database.DB.First(&target, targetID).Error; err != nil { + return utils.Fail(c, 404, "代理不存在") + } + + // 权限校验:admin可操作所有,agent只能操作下级 + if adminRole != "admin" { + if target.ParentID == nil || *target.ParentID != agentID { + return utils.Fail(c, 403, "只能操作自己的下级代理") + } + } + + // 获取当前代理信息(用于费率校验) + var currentAgent model.AgentUser + if err := database.DB.First(¤tAgent, agentID).Error; err != nil { + return utils.Fail(c, 404, "当前账号不存在") + } + + updates := map[string]interface{}{} + + // 修改费率 + if req.Rate != nil { + if *req.Rate <= 0 { + return utils.Fail(c, 400, "费率必须大于0") + } + // 校验费率:不能低于自身费率 + if *req.Rate < currentAgent.Rate { + return utils.Fail(c, 400, "下级代理费率不得低于自身费率("+ + strconv.FormatFloat(currentAgent.Rate, 'f', 2, 64)+")") + } + updates["rate"] = *req.Rate + } + + // 充值(原子操作) + if req.Balance != nil { + if *req.Balance < 0 { + return utils.Fail(c, 400, "充值金额不能为负数") + } + if *req.Balance > 0 { + // 如果不是admin(即代理给下级充值),扣减自己的余额 + if adminRole != "admin" { + result := database.DB.Model(&model.AgentUser{}). + Where("id = ? AND balance >= ?", agentID, *req.Balance). + Update("balance", gorm.Expr("balance - ?", *req.Balance)) + if result.RowsAffected == 0 { + return utils.Fail(c, 400, "余额不足") + } + } + // 给目标代理增加余额 + updates["balance"] = gorm.Expr("balance + ?", *req.Balance) + } + } + + if len(updates) > 0 { + if err := database.DB.Model(&target).Updates(updates).Error; err != nil { + slog.Error("更新代理失败", "error", err) + return utils.FailServer(c, "更新失败") + } + } + + // 重新查询返回最新数据 + database.DB.First(&target, target.ID) + + slog.Info("更新代理成功", "agentId", target.ID, "updates", updates) + + return utils.Success(c, target) +} + +// UpdateAgentPassword 修改代理密码(仅admin) +func UpdateAgentPassword(c *fiber.Ctx) error { + targetID := c.Params("id") + + var req struct { + Password string `json:"password"` + } + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Password == "" || len(req.Password) < 6 { + return utils.FailParam(c, "密码长度不能少于6位") + } + + var target model.AgentUser + if err := database.DB.First(&target, targetID).Error; err != nil { + return utils.Fail(c, 404, "代理不存在") + } + + hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return utils.FailServer(c, "修改失败") + } + + database.DB.Model(&target).Update("password", string(hashedPwd)) + + slog.Info("修改代理密码成功", "agentId", target.ID) + return utils.Success(c, nil) +} + +// UpdateAgentStatus 启用/禁用代理 +func UpdateAgentStatus(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + adminRole := c.Locals("adminRole").(string) + targetID := c.Params("id") + + var req struct { + Status string `json:"status"` + } + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Status != "active" && req.Status != "disabled" { + return utils.FailParam(c, "状态值无效,必须为 active 或 disabled") + } + + var target model.AgentUser + if err := database.DB.First(&target, targetID).Error; err != nil { + return utils.Fail(c, 404, "代理不存在") + } + + if target.Role == "admin" { + return utils.Fail(c, 400, "不能禁用管理员账号") + } + + // 非管理员只能操作自己的下级 + if adminRole != "admin" { + if target.ParentID == nil || *target.ParentID != agentID { + return utils.Fail(c, 403, "只能操作自己的下级代理") + } + } + + database.DB.Model(&target).Update("status", req.Status) + + slog.Info("修改代理状态成功", "agentId", target.ID, "status", req.Status) + return utils.Success(c, fiber.Map{ + "id": target.ID, + "status": req.Status, + }) +} + +// ListSubAgents 获取下级代理列表(包含上级用户名) +func ListSubAgents(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + adminRole := c.Locals("adminRole").(string) + + var agents []model.AgentUser + + if adminRole == "admin" { + // admin 查看所有下级代理(parent_id 不为 null 的都是子代理) + database.DB.Where("parent_id IS NOT NULL").Order("created_at DESC").Find(&agents) + } else { + // agent 只查看自己的下级 + database.DB.Where("parent_id = ?", agentID).Order("created_at DESC").Find(&agents) + } + + // 为每个代理填充上级用户名 + type AgentWithParent struct { + model.AgentUser + ParentUsername string `json:"parentUsername"` + } + + result := make([]AgentWithParent, 0, len(agents)) + for _, a := range agents { + parentUsername := "" + if a.ParentID != nil { + var parent model.AgentUser + if err := database.DB.Select("username").First(&parent, *a.ParentID).Error; err == nil { + parentUsername = parent.Username + } + } + result = append(result, AgentWithParent{ + AgentUser: a, + ParentUsername: parentUsername, + }) + } + + return utils.Success(c, result) +} + +// ListAllAgents 获取所有代理列表(仅admin) +func ListAllAgents(c *fiber.Ctx) error { + var agents []model.AgentUser + database.DB.Order("role ASC, created_at DESC").Find(&agents) + + // 为每个代理填充上级用户名 + type AgentWithParent struct { + model.AgentUser + ParentUsername string `json:"parentUsername"` + } + + result := make([]AgentWithParent, 0, len(agents)) + for _, a := range agents { + parentUsername := "" + if a.ParentID != nil { + var parent model.AgentUser + if err := database.DB.Select("username").First(&parent, *a.ParentID).Error; err == nil { + parentUsername = parent.Username + } + } + result = append(result, AgentWithParent{ + AgentUser: a, + ParentUsername: parentUsername, + }) + } + + return utils.Success(c, result) +} + +// GetAgentDetail 获取代理详情 +func GetAgentDetail(c *fiber.Ctx) error { + targetID := c.Params("id") + + var agent model.AgentUser + if err := database.DB.First(&agent, targetID).Error; err != nil { + return utils.Fail(c, 404, "代理不存在") + } + + // 统计下级代理数量 + var subCount int64 + database.DB.Model(&model.AgentUser{}).Where("parent_id = ?", agent.ID).Count(&subCount) + + // 统计CDK数量 + var cdkTotal, cdkUsed, cdkUnused int64 + database.DB.Model(&model.CDKCode{}).Where("agent_id = ?", agent.ID).Count(&cdkTotal) + database.DB.Model(&model.CDKCode{}).Where("agent_id = ? AND status = 'used'", agent.ID).Count(&cdkUsed) + database.DB.Model(&model.CDKCode{}).Where("agent_id = ? AND status = 'unused'", agent.ID).Count(&cdkUnused) + + return utils.Success(c, fiber.Map{ + "agent": agent, + "subCount": subCount, + "cdkStats": fiber.Map{ + "total": cdkTotal, + "used": cdkUsed, + "unused": cdkUnused, + }, + }) +} diff --git a/backed/internal/handler/auth.go b/backed/internal/handler/auth.go new file mode 100644 index 0000000..c75987b --- /dev/null +++ b/backed/internal/handler/auth.go @@ -0,0 +1,381 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/service" + cpd "cpdaily-new/internal/service/cpdaily" + "cpdaily-new/internal/utils" + "errors" + "log/slog" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// GetCaptcha 获取图片验证码 +func GetCaptcha(c *fiber.Ctx) error { + result, err := service.GenerateCaptcha() + if err != nil { + return utils.FailServer(c, "生成验证码失败") + } + // 调试:记录验证码 + if code, ok := service.GetCaptchaDebug(result.CaptchaID); ok { + slog.Info("验证码", "captchaId", result.CaptchaID[:8], "code", code) + } + return utils.Success(c, result) +} + +// SearchSchools 搜索学校 (透传 cpdaily API) +func SearchSchools(c *fiber.Ctx) error { + name := c.Query("keyword") + if name == "" { + name = c.Query("name") + } + if name == "" { + return utils.Success(c, []interface{}{}) + } + schools, err := cpd.SearchSchools(name) + if err != nil { + slog.Warn("搜索学校失败", "error", err) + return utils.FailServer(c, "搜索学校失败") + } + return utils.Success(c, schools) +} + +// LoginRequest 登录请求 +type LoginRequest struct { + SchoolID string `json:"schoolId"` + Username string `json:"username"` + Password string `json:"password"` + CaptchaID string `json:"captchaId"` + CaptchaCode string `json:"captchaCode"` + InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码 +} + +// Login 登录 +func Login(c *fiber.Ctx) error { + var req LoginRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.SchoolID == "" || req.Username == "" || req.Password == "" { + return utils.FailParam(c, "参数不完整") + } + + // 校验图片验证码 + if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) { + return utils.FailParam(c, "验证码错误或已过期") + } + + // 获取学校详情 + school, err := cpd.GetSchoolDetail(req.SchoolID) + if err != nil { + slog.Error("获取学校详情失败", "schoolId", req.SchoolID, "error", err) + return utils.FailServer(c, "获取学校信息失败") + } + + // 执行 cpdaily 登录 + cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password) + if err != nil { + slog.Error("cpdaily登录失败", "error", err) + return c.Status(401).JSON(utils.Response{ + Code: 401, + Message: "登录失败: " + err.Error(), + Data: fiber.Map{ + "schoolUrl": school.LoginUrl(), + }, + }) + } + + // 检查 MOD_ cookie + hasMOD := false + for _, cookie := range cookies { + if strings.HasPrefix(cookie.Name, "MOD_") { + hasMOD = true + break + } + } + if !hasMOD { + return c.Status(401).JSON(utils.Response{ + Code: 401, + Message: "登录失败,未获取到有效的认证信息", + Data: fiber.Map{ + "schoolUrl": school.LoginUrl(), + }, + }) + } + + // Cookie 序列化 + cookieStr := cpd.SerializeCookies(cookies) + + // 构建用户名: 学校简称 + 学号 + userName := buildUsername(school, req.Username) + + // 查找或创建用户 + user, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode) + if err != nil { + slog.Error("用户处理失败", "error", err) + return utils.FailServer(c, "用户处理失败") + } + + // 更新 cookie 和最后登录时间 + now := time.Now() + database.DB.Model(&user).Updates(map[string]interface{}{ + "cookie": cookieStr, + "last_login_at": now, + }) + + // 保存明文密码(用于 cookie 过期时自动重新登录) + if err := saveRawPassword(user.ID, req.Password); err != nil { + slog.Error("保存密码失败", "userId", user.ID, "error", err) + } + + // 尝试从 cpdaily 获取用户主页信息并更新本地数据 + go syncUserProfile(user, cookieStr, school.WecloudUrl) + + // 生成 JWT + tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID) + if err != nil { + return utils.FailServer(c, "生成token失败") + } + + return utils.Success(c, fiber.Map{ + "user": user, + "token": tokenPair, + }) +} + +// RefreshToken 刷新 token +func RefreshToken(c *fiber.Ctx) error { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" { + return utils.FailParam(c, "参数不完整") + } + + tokenPair, err := service.RefreshTokens(req.RefreshToken) + if err != nil { + return utils.FailAuth(c, "refresh token无效或已过期") + } + + return utils.Success(c, tokenPair) +} + +// GetUserInfo 获取当前用户信息(含 cpdaily 扩展信息) +func GetUserInfo(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + // 如果还没有同步过 cpdaily 信息,或缺少头像/手机号,尝试同步 + if (user.RealName == "" || user.Avatar == "" || user.MobilePhone == "") && user.Cookie != "" { + cookieMap := cpd.ParseCookieJSON(user.Cookie) + // 从学校ID获取 wecloudUrl + school, err := cpd.GetSchoolDetail(user.SchoolID) + wecloudUrl := "" + if err == nil { + wecloudUrl = school.WecloudUrl + } + if profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl); err == nil { + updates := syncProfileToUser(profile) + database.DB.Model(&user).Updates(updates) + user.RealName = profile.Name + user.Gender = profile.Gender + user.Avatar = profile.Img + user.Academy = profile.Academy + user.Major = profile.Major + user.ClassName = profile.ClassName + user.Grade = profile.Grade + user.MobilePhone = profile.MobilePhone + user.OrgFullName = profile.OrgFullName + } + } + + return utils.Success(c, user) +} + +// syncUserProfile 同步 cpdaily 用户信息到数据库 +func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) { + cookieMap := cpd.ParseCookieJSON(cookieStr) + profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl) + if err != nil { + slog.Warn("同步用户信息失败", "userId", user.ID, "error", err) + return + } + + updates := syncProfileToUser(profile) + updates["cookie"] = cookieStr // 确保 cookie 最新 + database.DB.Model(&user).Updates(updates) + slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name) +} + +func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} { + return map[string]interface{}{ + "real_name": profile.Name, + "gender": profile.Gender, + "avatar": profile.Img, + "academy": profile.Academy, + "major": profile.Major, + "class_name": profile.ClassName, + "grade": profile.Grade, + "mobile_phone": profile.MobilePhone, + "org_full_name": profile.OrgFullName, + } +} + +// 构建用户名: 学校简称 + 学工号 +func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string { + abbr := school.NameAbbr + if abbr == "" { + abbr = school.NamePinYin + if len(abbr) > 6 { + abbr = abbr[:6] + } + } + return abbr + studentID +} + +// 查找或创建用户 +func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, inviteCode string) (*model.SysUser, error) { + var user model.SysUser + err := database.DB.Where("username = ?", username).First(&user).Error + if err == nil { + return &user, nil + } + + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + // 自动注册 + hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user = model.SysUser{ + Username: username, + Password: string(hashedPwd), + SchoolID: schoolID, + SchoolName: schoolName, + StudentID: studentID, + Cookie: cookie, + } + + if err := database.DB.Create(&user).Error; err != nil { + return nil, err + } + + // 处理邀请码奖励 + if inviteCode != "" { + var inviter model.SysUser + if err := database.DB.Where("invite_code = ?", inviteCode).First(&inviter).Error; err == nil { + // 给邀请人 +0.5 奖励 + 增加邀请计数 + database.DB.Model(&inviter).Updates(map[string]interface{}{ + "invite_reward": gorm.Expr("invite_reward + 0.5"), + "invited_count": gorm.Expr("invited_count + 1"), + }) + slog.Info("邀请奖励已发放", "inviterId", inviter.ID, "newUserId", user.ID) + } + } + + slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode) + return &user, nil +} + +// GenerateMyInviteCode 生成/获取当前用户的邀请码 +func GenerateMyInviteCode(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + // 已有邀请码直接返回 + if user.InviteCode != "" { + return utils.Success(c, fiber.Map{ + "inviteCode": user.InviteCode, + "inviteReward": user.InviteReward, + "invitedCount": user.InvitedCount, + "canRedeem": user.InviteReward >= 1.0, + }) + } + + // 生成新邀请码(6 位随机大写字母数字) + code := generateRandomCode(6) + user.InviteCode = code + if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil { + return utils.FailServer(c, "生成邀请码失败") + } + + slog.Info("邀请码已生成", "userId", userID, "inviteCode", code) + + return utils.Success(c, fiber.Map{ + "inviteCode": code, + "inviteReward": 0, + "invitedCount": 0, + "canRedeem": false, + }) +} + +// GetMyInviteInfo 获取当前用户的邀请信息 +func GetMyInviteInfo(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + return utils.Success(c, fiber.Map{ + "inviteCode": user.InviteCode, + "inviteReward": user.InviteReward, + "invitedCount": user.InvitedCount, + "canRedeem": user.InviteReward >= 1.0, + }) +} + +// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到) +func RedeemInviteReward(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + if user.InviteReward < 1.0 { + return utils.Fail(c, 400, "邀请奖励不足,满 1 才可兑换") + } + + // 扣 1 积分,加 1 签到次数 + if err := database.DB.Model(&user).Updates(map[string]interface{}{ + "invite_reward": gorm.Expr("invite_reward - 1"), + "sign_count": gorm.Expr("sign_count + 1"), + }).Error; err != nil { + return utils.FailServer(c, "兑换失败") + } + + slog.Info("邀请奖励兑换成功", "userId", userID, "reward", user.InviteReward) + + return utils.Success(c, fiber.Map{ + "quota": 1, + }) +} + +// generateRandomCode 生成 N 位随机大写字母数字码 +func generateRandomCode(n int) string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + code := make([]byte, n) + for i := range code { + code[i] = chars[time.Now().UnixNano()%int64(len(chars))] + time.Sleep(1) + } + return string(code) +} diff --git a/backed/internal/handler/auth_helper.go b/backed/internal/handler/auth_helper.go new file mode 100644 index 0000000..743aafd --- /dev/null +++ b/backed/internal/handler/auth_helper.go @@ -0,0 +1,133 @@ +package handler + +import ( + "context" + "encoding/base64" + "errors" + "log/slog" + "strings" + + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + cpd "cpdaily-new/internal/service/cpdaily" +) + +// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误 +var ErrPasswordChanged = errors.New("cpdaily_password_error") + +// encryptRawPassword AES 加密明文密码并 base64 编码 +func encryptRawPassword(rawPassword string) (string, error) { + encrypted := cpd.AesEncrypt([]byte(rawPassword)) + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +// decryptRawPassword base64 解码后 AES 解密 +func decryptRawPassword(encrypted string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + decrypted := cpd.AesDecrypt(data) + return string(decrypted), nil +} + +// saveRawPassword 将明文密码 AES 加密后存入数据库 +func saveRawPassword(userID uint, rawPassword string) error { + encrypted, err := encryptRawPassword(rawPassword) + if err != nil { + return err + } + return database.DB.Model(&model.SysUser{}).Where("id = ?", userID). + Update("raw_password", encrypted).Error +} + +// updateRawPassword 更新用户的 raw_password(密码可能已修改) +func updateRawPassword(userID uint, rawPassword string) error { + return saveRawPassword(userID, rawPassword) +} + +// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起 +func isCookieExpired(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "未登录") || + strings.Contains(msg, "登录已过期") +} + +// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie +// 密码错误时返回 ErrPasswordChanged +func refreshCpdailyCookie(user *model.SysUser) error { + // 重新从数据库加载用户(获取最新 raw_password) + var fresh model.SysUser + if err := database.DB.First(&fresh, user.ID).Error; err != nil { + slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err) + return err + } + + if fresh.RawPassword == "" { + slog.Warn("刷新cookie: 未保存密码", "userId", user.ID) + return errors.New("未保存登录密码,无法自动重新登录") + } + + // 解密密码 + rawPassword, err := decryptRawPassword(fresh.RawPassword) + if err != nil { + slog.Error("刷新cookie: 解密密码失败", "userId", user.ID, "error", err) + return err + } + + // 获取学校详情 + school, err := cpd.GetSchoolDetail(fresh.SchoolID) + if err != nil { + slog.Error("刷新cookie: 获取学校详情失败", "userId", user.ID, "schoolId", fresh.SchoolID, "error", err) + return err + } + + slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name) + + // 执行 cpdaily 登录 + cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword) + if err != nil { + errMsg := err.Error() + slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg) + + // 检测密码错误 + if strings.Contains(errMsg, "账号或密码错误") || + strings.Contains(errMsg, "密码错误") || + strings.Contains(errMsg, "账号密码") { + return ErrPasswordChanged + } + return err + } + + // 检查 MOD_ cookie 确保登录成功 + hasMOD := false + for _, cookie := range cookies { + if strings.HasPrefix(cookie.Name, "MOD_") { + hasMOD = true + break + } + } + if !hasMOD { + slog.Warn("刷新cookie: 未获取到MOD_cookie", "userId", user.ID) + return ErrPasswordChanged + } + + // 序列化并更新 cookie + cookieStr := cpd.SerializeCookies(cookies) + if err := database.DB.Model(&fresh).Update("cookie", cookieStr).Error; err != nil { + slog.Error("刷新cookie: 更新数据库失败", "userId", user.ID, "error", err) + return err + } + + // 同步用户信息(异步) + go syncUserProfile(user, cookieStr, school.WecloudUrl) + + // 更新传入用户的 cookie(引用),上层重试时使用 + user.Cookie = cookieStr + + slog.Info("刷新cookie: 成功", "userId", user.ID) + return nil +} diff --git a/backed/internal/handler/cdk.go b/backed/internal/handler/cdk.go new file mode 100644 index 0000000..5464f4f --- /dev/null +++ b/backed/internal/handler/cdk.go @@ -0,0 +1,248 @@ +package handler + +import ( + "cpdaily-new/internal/config" + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/service" + "cpdaily-new/internal/utils" + "errors" + "log/slog" + "math/rand" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// GenerateCDKRequest 生成CDK请求 +type GenerateCDKRequest struct { + Count int `json:"count"` // 生成数量,默认1 + MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1 +} + +// 获取基础扣费(从系统设置或配置) +func getBaseDeduction() float64 { + var cfg model.SystemConfig + result := database.DB.Where("`key` = ?", "base_deduction_per_run").First(&cfg) + if result.Error == nil { + if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 { + return v + } + } + // 兜底:使用 config.toml 中的值 + return config.Global.Agent.SystemCdkPrice +} + +// GenerateCDK 生成CDK +func GenerateCDK(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + + var req GenerateCDKRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Count <= 0 { + req.Count = 1 + } + if req.Count > 100 { + return utils.Fail(c, 400, "单次最多生成100个CDK") + } + if req.MaxRuns <= 0 { + req.MaxRuns = 1 + } + if req.MaxRuns > 999 { + return utils.Fail(c, 400, "每张CDK最多999次") + } + + // 获取代理信息 + var agent model.AgentUser + if err := database.DB.First(&agent, agentID).Error; err != nil { + return utils.Fail(c, 404, "账号不存在") + } + + if agent.Status == "disabled" { + return utils.Fail(c, 403, "账号已被禁用") + } + + baseDeduction := getBaseDeduction() + cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate + costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count) + + // 事务:扣余额 + 生成CDK + cdkCodes := make([]model.CDKCode, 0, req.Count) + + err := database.DB.Transaction(func(tx *gorm.DB) error { + // 扣减代理余额(原子操作) + result := tx.Model(&model.AgentUser{}). + Where("id = ? AND balance >= ?", agentID, costTotal). + Update("balance", gorm.Expr("balance - ?", costTotal)) + if result.RowsAffected == 0 { + return errors.New("余额不足") + } + + // 批量生成CDK + now := time.Now() + for i := 0; i < req.Count; i++ { + code := generateCDKCode() + cdk := model.CDKCode{ + Code: code, + Price: cdkPrice, + CostPrice: baseDeduction * float64(req.MaxRuns), + AgentID: agentID, + MaxRuns: req.MaxRuns, + Status: "unused", + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(&cdk).Error; err != nil { + return err + } + cdkCodes = append(cdkCodes, cdk) + } + return nil + }) + + if err != nil { + if err.Error() == "余额不足" { + return utils.Fail(c, 400, "账户余额不足,需要 "+formatFloat(costTotal)+" 元") + } + slog.Error("生成CDK失败", "error", err) + return utils.FailServer(c, "生成失败") + } + + slog.Info("生成CDK成功", "agentId", agentID, "count", req.Count, "maxRuns", req.MaxRuns, "price", cdkPrice) + + return utils.Success(c, fiber.Map{ + "count": len(cdkCodes), + "price": cdkPrice, + "maxRuns": req.MaxRuns, + "costTotal": costTotal, + "cdkCodes": cdkCodes, + }) +} + +// ListCDKs 获取自己生成的CDK列表 +func ListCDKs(c *fiber.Ctx) error { + agentID := c.Locals("adminId").(uint) + + var cdks []model.CDKCode + database.DB.Where("agent_id = ?", agentID).Order("created_at DESC").Limit(100).Find(&cdks) + + return utils.Success(c, cdks) +} + +// ListAllCDKs 获取所有CDK列表(仅admin) +func ListAllCDKs(c *fiber.Ctx) error { + var cdks []model.CDKCode + database.DB.Preload("Agent").Order("created_at DESC").Limit(200).Find(&cdks) + + return utils.Success(c, cdks) +} + +// UserRedeemCDKRequest 用户兑换CDK请求 +type UserRedeemCDKRequest struct { + Code string `json:"code"` + CaptchaID string `json:"captchaId"` + CaptchaCode string `json:"captchaCode"` +} + +func UserRedeemCDK(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var req UserRedeemCDKRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Code == "" { + return utils.FailParam(c, "CDK码不能为空") + } + if req.CaptchaID == "" || req.CaptchaCode == "" { + return utils.FailParam(c, "验证码不能为空") + } + + // 校验图片验证码 + if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) { + return utils.FailParam(c, "验证码错误或已过期") + } + + // 查找CDK + var cdk model.CDKCode + err := database.DB.Where("code = ? AND status = 'unused'", req.Code).First(&cdk).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "CDK码无效或已被使用") + } + return utils.FailServer(c, "查询失败") + } + + // 使用行锁(悲观锁)确保并发安全 + err = database.DB.Transaction(func(tx *gorm.DB) error { + // 重新查询并锁定该行 + var lockedCDK model.CDKCode + if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedCDK, cdk.ID).Error; err != nil { + return err + } + if lockedCDK.Status != "unused" { + return errors.New("CDK码已被使用") + } + if lockedCDK.MaxRuns <= 0 { + return errors.New("CDK码已无可运行次数") + } + + now := time.Now() + // 一次性消耗全部次数 + totalRuns := lockedCDK.MaxRuns + updates := map[string]interface{}{ + "status": "used", + "user_id": userID, + "used_at": now, + } + if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil { + return err + } + + // 增加用户签到次数(一次性加上所有剩余次数) + if err := tx.Model(&model.SysUser{}).Where("id = ?", userID). + Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + if err.Error() == "CDK码已被使用" { + return utils.Fail(c, 400, "CDK码已被使用") + } + if err.Error() == "CDK码已无可运行次数" { + return utils.Fail(c, 400, "CDK码已无可运行次数") + } + slog.Error("兑换CDK失败", "error", err) + return utils.FailServer(c, "兑换失败") + } + + slog.Info("CDK兑换成功", "userId", userID, "code", req.Code) + + return utils.Success(c, fiber.Map{ + "message": "兑换成功", + "totalRuns": cdk.MaxRuns, + }) +} + +// 生成CDK码(12位大写字母数字) +func generateCDKCode() string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + code := make([]byte, 12) + for i := range code { + code[i] = chars[rng.Intn(len(chars))] + } + return string(code) +} + +// formatFloat 格式化浮点数为字符串保留2位小数 +func formatFloat(f float64) string { + return utils.FormatFloat(f) +} diff --git a/backed/internal/handler/device.go b/backed/internal/handler/device.go new file mode 100644 index 0000000..8c60db0 --- /dev/null +++ b/backed/internal/handler/device.go @@ -0,0 +1,390 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "crypto/rand" + "encoding/hex" + "fmt" + "html/template" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// 生成随机 token +func generateToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// GenerateDeviceQr 生成设备绑定二维码 +func GenerateDeviceQr(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + // 生成 token + token := generateToken() + + // 创建绑定记录,5 分钟过期 + record := model.DeviceBindToken{ + UserID: userID, + Token: token, + Status: "pending", + ExpiresAt: time.Now().Add(5 * time.Minute), + } + if err := database.DB.Create(&record).Error; err != nil { + return utils.FailServer(c, "生成二维码失败") + } + + return utils.Success(c, fiber.Map{ + "token": token, + "expiresIn": 300, + }) +} + +// GetDeviceStatus 查询设备绑定状态(长轮询用) +func GetDeviceStatus(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + token := c.Query("token") + if token == "" { + return utils.FailParam(c, "缺少 token") + } + + var record model.DeviceBindToken + err := database.DB.Where("token = ? AND user_id = ?", token, userID).First(&record).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "二维码已过期或无效") + } + return utils.FailServer(c, "查询失败") + } + + if record.Status == "expired" { + return utils.Fail(c, 410, "二维码已过期") + } + + // 检查是否过期 + if time.Now().After(record.ExpiresAt) { + database.DB.Model(&record).Update("status", "expired") + return utils.Fail(c, 410, "二维码已过期") + } + + if record.Status == "bound" { + // 更新用户的 mobile_code + database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "mobile_code": record.DeviceCode, + "mobile_type": "authorize", + }) + return utils.Success(c, fiber.Map{ + "status": "bound", + "deviceCode": record.DeviceCode, + }) + } + + return utils.Success(c, fiber.Map{ + "status": "pending", + }) +} + +// SubmitDeviceCode 授权页提交设备码 +type SubmitDeviceCodeRequest struct { + Token string `json:"token"` + DeviceCode string `json:"deviceCode"` +} + +func SubmitDeviceCode(c *fiber.Ctx) error { + var req SubmitDeviceCodeRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Token == "" || req.DeviceCode == "" { + return utils.FailParam(c, "参数不完整") + } + + var record model.DeviceBindToken + err := database.DB.Where("token = ? AND status = ?", req.Token, "pending").First(&record).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "二维码无效或已过期") + } + return utils.FailServer(c, "查询失败") + } + + // 检查过期 + if time.Now().After(record.ExpiresAt) { + database.DB.Model(&record).Update("status", "expired") + return utils.Fail(c, 410, "二维码已过期") + } + + // 更新绑定状态 + database.DB.Model(&record).Updates(map[string]interface{}{ + "device_code": req.DeviceCode, + "status": "bound", + }) + + slog.Info("设备绑定成功", "userId", record.UserID, "deviceCode", req.DeviceCode) + + // 也直接更新用户 + database.DB.Model(&model.SysUser{}).Where("id = ?", record.UserID).Updates(map[string]interface{}{ + "mobile_code": req.DeviceCode, + "mobile_type": "authorize", + }) + + return utils.Success(c, fiber.Map{"status": "bound"}) +} + +// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此) +func ClosePage(c *fiber.Ctx) error { + c.Type("html") + return c.SendString(` + + + + +授权完成 + + + +
+
✓
+

授权已完成

+

设备绑定成功,请继续操作

+
+ +`) +} + +// AuthorizePage 扫码后打开的授权页面(HTML) +func AuthorizePage(c *fiber.Ctx) error { + token := c.Params("token") + if token == "" { + return c.Status(400).SendString("无效的二维码") + } + + // 验证 token 有效性 + var record model.DeviceBindToken + err := database.DB.Where("token = ?", token).First(&record).Error + if err != nil || record.Status != "pending" || time.Now().After(record.ExpiresAt) { + if err == nil && time.Now().After(record.ExpiresAt) { + database.DB.Model(&record).Update("status", "expired") + } + return c.Status(400).Type("html").SendString(` + +设备授权 +
✕

二维码无效

二维码无效或已过期,请重新获取

+`) + } + + // 构建提交 URL + submitURL := fmt.Sprintf("/api/device/submit") + + tmpl := template.Must(template.New("authorize").Parse(` + + + + +设备授权 + + + +
+
+
+
+ +
+
📱
+

正在验证...

+

请稍候

+
+
+ + + + + + + + + + +
+
+ + +`)) + + c.Type("html") + return tmpl.Execute(c.Response().BodyWriter(), map[string]string{ + "Token": token, + "SubmitURL": submitURL, + }) +} diff --git a/backed/internal/handler/pending_school.go b/backed/internal/handler/pending_school.go new file mode 100644 index 0000000..a3f69ea --- /dev/null +++ b/backed/internal/handler/pending_school.go @@ -0,0 +1,216 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/service" + "cpdaily-new/internal/utils" + "log/slog" + "sync" + "time" + + "github.com/gofiber/fiber/v2" +) + +// ---------- 速率限制 ---------- + +type rateEntry struct { + count int + resetTime time.Time +} + +var ( + rateMu sync.Mutex + rateMap = make(map[string]*rateEntry) + rateLimit = 5 // 每个 IP 最多提交次数 + rateWindow = 24 * time.Hour // 时间窗口 +) + +func checkRateLimit(ip string) bool { + rateMu.Lock() + defer rateMu.Unlock() + + now := time.Now() + entry, ok := rateMap[ip] + if !ok || now.After(entry.resetTime) { + rateMap[ip] = &rateEntry{count: 1, resetTime: now.Add(rateWindow)} + return true + } + if entry.count >= rateLimit { + return false + } + entry.count++ + return true +} + +// ---------- 请求结构体 ---------- + +type ReportFailedLoginRequest struct { + SchoolID string `json:"schoolId"` + SchoolName string `json:"schoolName"` + Username string `json:"username"` + Password string `json:"password"` + CaptchaID string `json:"captchaId"` + CaptchaCode string `json:"captchaCode"` +} + +type PendingSchoolRes struct { + ID uint `json:"id"` + SchoolID string `json:"schoolId"` + SchoolName string `json:"schoolName"` + Username string `json:"username"` + Password string `json:"password"` + IP string `json:"ip"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` +} + +// ---------- 用户端 API ---------- + +// ReportFailedLogin 用户报告登录失败(提交测试账号信息) +func ReportFailedLogin(c *fiber.Ctx) error { + // userId 可能不存在(未登录用户提交) + var userID uint + if uid, ok := c.Locals("userId").(uint); ok { + userID = uid + } + + var req ReportFailedLoginRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.SchoolID == "" || req.SchoolName == "" || req.Username == "" || req.Password == "" { + return utils.FailParam(c, "参数不完整") + } + if req.CaptchaID == "" || req.CaptchaCode == "" { + return utils.FailParam(c, "请输入验证码") + } + + // 校验图片验证码 + if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) { + return utils.FailParam(c, "验证码错误或已过期") + } + + // 0. 速率限制:同 IP 每天最多 5 次 + clientIP := c.IP() + slog.Info("[pending-school] 请求到达", + "schoolId", req.SchoolID, + "schoolName", req.SchoolName, + "username", req.Username, + "ip", clientIP, + "userId", userID, + ) + + if !checkRateLimit(clientIP) { + slog.Warn("[pending-school] 速率限制触发", "ip", clientIP, "schoolId", req.SchoolID, "userId", userID) + return utils.Success(c, fiber.Map{"accepted": true}) + } + + // 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告) + today := time.Now().Truncate(24 * time.Hour) + var todayLoginCount int64 + database.DB.Model(&model.SysUser{}). + Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today). + Count(&todayLoginCount) + if todayLoginCount > 0 { + slog.Info("[pending-school] 该校有今日登录的用户,提示用户检查账号", + "schoolId", req.SchoolID, + "schoolName", req.SchoolName, + "todayLoginCount", todayLoginCount, + ) + return utils.Fail(c, 400, "当前学校已有用户正常登录,请仔细检查学工号密码信息") + } + + // 2. 先查询待处理表中该校已存在多少条未处理记录 + var pendingCount int64 + database.DB.Model(&model.PendingSchool{}). + Where("school_id = ? AND status = ?", req.SchoolID, "pending"). + Count(&pendingCount) + + if pendingCount >= 5 { + slog.Info("[pending-school] 该校待处理记录已达上限,忽略提交", + "schoolId", req.SchoolID, + "schoolName", req.SchoolName, + "pendingCount", pendingCount, + ) + return utils.Success(c, fiber.Map{"accepted": true}) + } + + // 2. 插入记录 + record := model.PendingSchool{ + SchoolID: req.SchoolID, + SchoolName: req.SchoolName, + Username: req.Username, + Password: req.Password, + IP: clientIP, + Status: "pending", + } + if err := database.DB.Create(&record).Error; err != nil { + slog.Error("[pending-school] 保存记录失败", "error", err) + return utils.FailServer(c, "提交失败") + } + + slog.Info("[pending-school] 记录已保存", + "recordId", record.ID, + "schoolId", req.SchoolID, + "schoolName", req.SchoolName, + "username", req.Username, + "ip", clientIP, + ) + + return utils.Success(c, fiber.Map{"accepted": true}) +} + +// ---------- 管理端 API ---------- + +// AdminListPendingSchools 管理端查看待适配学校列表(按学校分组) +func AdminListPendingSchools(c *fiber.Ctx) error { + status := c.Query("status", "pending") + + var records []model.PendingSchool + query := database.DB.Order("created_at DESC") + if status == "pending" { + query = query.Where("status = ?", "pending") + } + if err := query.Find(&records).Error; err != nil { + return utils.FailServer(c, "查询失败") + } + + items := make([]PendingSchoolRes, 0, len(records)) + for _, r := range records { + items = append(items, PendingSchoolRes{ + ID: r.ID, + SchoolID: r.SchoolID, + SchoolName: r.SchoolName, + Username: r.Username, + Password: r.Password, + IP: r.IP, + Status: r.Status, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"), + }) + } + + return utils.Success(c, items) +} + +// AdminMarkSchoolDone 标记某条记录为已完成 +func AdminMarkSchoolDone(c *fiber.Ctx) error { + recordID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的记录ID") + } + + var record model.PendingSchool + if err := database.DB.First(&record, recordID).Error; err != nil { + return utils.Fail(c, 404, "记录不存在") + } + + // 将该学校所有待处理的记录标记为已完成 + database.DB.Model(&model.PendingSchool{}). + Where("school_id = ? AND status = ?", record.SchoolID, "pending"). + Update("status", "completed") + + slog.Info("学校已标记为适配完成", "schoolId", record.SchoolID, "schoolName", record.SchoolName) + + return utils.Success(c, fiber.Map{"completed": true}) +} diff --git a/backed/internal/handler/settings.go b/backed/internal/handler/settings.go new file mode 100644 index 0000000..2c21293 --- /dev/null +++ b/backed/internal/handler/settings.go @@ -0,0 +1,80 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "log/slog" + + "github.com/gofiber/fiber/v2" +) + +// GetSettings 获取所有系统设置 +func GetSettings(c *fiber.Ctx) error { + var configs []model.SystemConfig + database.DB.Find(&configs) + + // 转为 map + settings := make(map[string]string) + for _, cfg := range configs { + settings[cfg.Key] = cfg.Value + } + + // 设置默认值 + if _, ok := settings["base_deduction_per_run"]; !ok { + settings["base_deduction_per_run"] = "1.00" + } + + return utils.Success(c, settings) +} + +// UpdateSettingRequest 更新设置请求 +type UpdateSettingRequest struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// UpdateSetting 更新单条系统设置 +func UpdateSetting(c *fiber.Ctx) error { + var req UpdateSettingRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Key == "" { + return utils.FailParam(c, "key 不能为空") + } + + // 支持的 key 白名单 + allowedKeys := map[string]bool{ + "base_deduction_per_run": true, + } + if !allowedKeys[req.Key] { + return utils.Fail(c, 400, "不支持的设置项: "+req.Key) + } + + var cfg model.SystemConfig + result := database.DB.Where("`key` = ?", req.Key).First(&cfg) + if result.Error != nil { + // 不存在则创建 + cfg = model.SystemConfig{ + Key: req.Key, + Value: req.Value, + } + if err := database.DB.Create(&cfg).Error; err != nil { + slog.Error("创建设置失败", "key", req.Key, "error", err) + return utils.FailServer(c, "保存失败") + } + } else { + // 存在则更新 + if err := database.DB.Model(&cfg).Update("value", req.Value).Error; err != nil { + slog.Error("更新设置失败", "key", req.Key, "error", err) + return utils.FailServer(c, "保存失败") + } + } + + slog.Info("更新系统设置", "key", req.Key, "value", req.Value) + return utils.Success(c, fiber.Map{ + "key": req.Key, + "value": req.Value, + }) +} diff --git a/backed/internal/handler/task.go b/backed/internal/handler/task.go new file mode 100644 index 0000000..981741c --- /dev/null +++ b/backed/internal/handler/task.go @@ -0,0 +1,639 @@ +package handler + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/ratelimit" + cpd "cpdaily-new/internal/service/cpdaily" + "cpdaily-new/internal/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// GetTaskTitles 并发获取 sign + sleep_check 两种类型的近期任务 +func GetTaskTitles(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + if user.Cookie == "" { + return utils.Fail(c, 400, "请先登录") + } + + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + tasks := cpd.FetchAllRecentTasks(cookieMap, host) + return utils.Success(c, tasks) +} + +// CreateTask 创建任务 +type CreateTaskRequest struct { + TaskType string `json:"taskType"` + Title string `json:"title"` + RealTitle string `json:"realTitle"` + SignWid string `json:"signWid"` + SignInstanceWid string `json:"signInstanceWid"` +} + +func CreateTask(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var req CreateTaskRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.TaskType == "" || req.Title == "" { + return utils.FailParam(c, "参数不完整") + } + + task := model.CpTask{ + UserID: userID, + TaskType: req.TaskType, + Title: req.Title, + RealTitle: req.RealTitle, + SignWid: req.SignWid, + SignInstanceWid: req.SignInstanceWid, + TaskStatus: "pending", + } + if err := database.DB.Create(&task).Error; err != nil { + slog.Error("创建任务失败", "error", err) + return utils.FailServer(c, "创建任务失败") + } + + return utils.Success(c, task) +} + +// GetTaskForm 获取任务表单详情(从 cpdaily 拉取并解析) +func GetTaskForm(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + var task model.CpTask + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "任务不存在") + } + return utils.FailServer(c, "查询失败") + } + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + // 从 cpdaily 拉取任务详情 + detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title) + if err != nil { + // cookie 过期时自动尝试重新登录 + if isCookieExpired(err) { + slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id) + if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { + cookieMap = cpd.ParseCookieJSON(user.Cookie) + detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title) + } + } + } + if err != nil { + slog.Warn("获取任务表单失败", "taskId", id, "error", err) + if errors.Is(err, ErrPasswordChanged) { + return utils.Fail(c, 401, "登录凭证已过期,请重新登录") + } + return utils.Fail(c, 503, "获取任务表单失败: "+err.Error()) + } + + // 获取历史数据用于预填充(含月份降级) + historyRows := cpd.FetchHistoryFormData(cookieMap, host, cpd.TaskType(task.TaskType), detail.RealTitle, task.SignInstanceWid) + + // 解析为表单字段 + formItems := cpd.ParseTaskForm(detail, historyRows) + + return utils.Success(c, fiber.Map{ + "formItems": formItems, + "realTitle": detail.RealTitle, + "isPhoto": detail.IsPhoto, + }) +} + +// SaveTaskForm 保存用户配置的表单数据 +type SaveTaskFormRequest struct { + FormData []model.CpTaskFormItem `json:"formData"` +} + +func SaveTaskForm(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + var req SaveTaskFormRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + + var task model.CpTask + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "任务不存在") + } + return utils.FailServer(c, "查询失败") + } + + // 序列化为 JSON + formData, _ := json.Marshal(req.FormData) + database.DB.Model(&task).Update("form_data", string(formData)) + + return utils.Success(c, nil) +} + +// TaskWithMatch 任务列表附带匹配状态 +type TaskWithMatch struct { + model.CpTask + MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed +} + +// globMatch 简单通配符匹配(仅支持 *) +func globMatch(str, pattern string) bool { + if pattern == "" { + return str == "" + } + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return str == pattern + } + // 检查前缀 + if parts[0] != "" && !strings.HasPrefix(str, parts[0]) { + return false + } + str = str[len(parts[0]):] + // 检查中间部分 + for i := 1; i < len(parts)-1; i++ { + part := parts[i] + if part == "" { + continue + } + idx := strings.Index(str, part) + if idx < 0 { + return false + } + str = str[idx+len(part):] + } + // 检查后缀 + last := parts[len(parts)-1] + if last != "" && !strings.HasSuffix(str, last) { + return false + } + return true +} + +// ListTasks 查询用户任务列表(附带今日任务匹配状态) +func ListTasks(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var tasks []model.CpTask + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks) + + // 尝试获取今日任务进行匹配 + var user model.SysUser + var todayTasks []cpd.TodayTaskInfo + fetchOk := false + + if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" { + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + // 并发获取两种任务类型 + type result struct { + tasks []cpd.TodayTaskInfo + err error + } + ch := make(chan result, 2) + for _, tt := range []cpd.TaskType{cpd.TaskTypeSign, cpd.TaskTypeSleepCheck} { + go func(t cpd.TaskType) { + tasks, err := cpd.FetchTodayTasks(cookieMap, host, t) + if err == nil { + for i := range tasks { + tasks[i].TaskType = string(t) + } + } + ch <- result{tasks, err} + }(tt) + } + for i := 0; i < 2; i++ { + r := <-ch + if r.err == nil { + todayTasks = append(todayTasks, r.tasks...) + fetchOk = true + } + } + } + + // 构造返回结果 + result := make([]TaskWithMatch, len(tasks)) + for i, task := range tasks { + item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"} + if fetchOk { + item.MatchStatus = "no_match" + // 用 title 做 glob 匹配(支持 * 通配符) + for _, tt := range todayTasks { + if tt.TaskType != task.TaskType { + continue + } + if globMatch(tt.TaskName, task.Title) { + if tt.IsSigned { + item.MatchStatus = "matched_signed" + } else { + item.MatchStatus = "matched_unsigned" + } + break + } + } + } + result[i] = item + } + + return utils.Success(c, result) +} + +// DeleteTask 删除任务 +func DeleteTask(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + result := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CpTask{}) + if result.RowsAffected == 0 { + return utils.Fail(c, 404, "任务不存在") + } + + return utils.Success(c, nil) +} + +// ExecuteTask 同步执行签到(SSE 流式返回状态) +func ExecuteTask(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + var task model.CpTask + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "任务不存在") + } + return utils.FailServer(c, "查询失败") + } + + if task.TaskStatus == "executing" { + return utils.Fail(c, 400, "任务正在执行中") + } + if task.FormData == "" { + return utils.Fail(c, 400, "请先配置表单数据") + } + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + + // 设置 SSE 头 + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + c.Set("X-Accel-Buffering", "no") + + // SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果 + c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { + sendEvent := func(event string, data map[string]interface{}) { + d, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d)) + w.Flush() + } + + // 保存执行日志 + saveLog := func(isSuccess bool, errMsg, realTitle string) { + logEntry := model.CpTaskLog{ + CpTaskID: task.ID, + UserID: userID, + TaskType: task.TaskType, + Title: task.Title, + RealTitle: realTitle, + IsSuccess: isSuccess, + ErrorMessage: errMsg, + } + database.DB.Create(&logEntry) + } + + // 检查可用次数 + if user.SignCount <= 0 { + slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id) + sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"}) + return + } + + // 限流:同一用户同一任务 + rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID) + rules := []ratelimit.Rule{ + {Limit: 3, Window: 2 * time.Minute, Label: "2m"}, + {Limit: 6, Window: 1 * time.Hour, Label: "1h"}, + {Limit: 10, Window: 24 * time.Hour, Label: "1d"}, + } + if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok { + slog.Warn("触发限流", "userId", userID, "taskId", id, "msg", msg) + sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true}) + return + } + + // 所有前置检查通过,更新状态为执行中 + database.DB.Model(&task).Updates(map[string]interface{}{ + "task_status": "executing", + "error_message": "", + }) + + // 1. 连接成功 + sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."}) + + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + // 2. 获取表单详情 + sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."}) + detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title) + if err != nil && isCookieExpired(err) { + slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id) + if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { + cookieMap = cpd.ParseCookieJSON(user.Cookie) + detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title) + } else if errors.Is(refreshErr, ErrPasswordChanged) { + err = ErrPasswordChanged + } + } + if err != nil { + errMsg := "获取签到表单失败: " + err.Error() + if errors.Is(err, ErrPasswordChanged) { + errMsg = "登录凭证已过期,请重新登录" + } + slog.Error("获取表单失败", "taskId", id, "error", err) + database.DB.Model(&task).Updates(map[string]interface{}{ + "task_status": "fail", + "error_message": errMsg, + }) + saveLog(false, errMsg, taskTitle(task)) + sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)}) + return + } + + // 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称) + if task.RealTitle == "" { + todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType)) + for _, t := range todayTasks { + if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" { + task.RealTitle = t.TaskName + database.DB.Model(&task).Update("real_title", task.RealTitle) + break + } + } + } + + // 3. 反序列化用户配置的表单 + var formItems []model.CpTaskFormItem + if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil { + errMsg := "表单数据解析失败" + database.DB.Model(&task).Updates(map[string]interface{}{ + "task_status": "fail", + "error_message": errMsg, + }) + saveLog(false, errMsg, taskTitle(task)) + sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg}) + return + } + + // 4. 填充表单并提交 + sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."}) + + submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode) + err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID) + if err != nil && isCookieExpired(err) { + slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id) + if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { + cookieMap = cpd.ParseCookieJSON(user.Cookie) + err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID) + } else if errors.Is(refreshErr, ErrPasswordChanged) { + err = ErrPasswordChanged + } + } + if err != nil { + errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error()) + if errors.Is(err, ErrPasswordChanged) { + errMsg = "登录凭证已过期,请重新登录" + } + slog.Error("签到失败", "taskId", id, "error", err) + database.DB.Model(&task).Updates(map[string]interface{}{ + "task_status": "fail", + "error_message": errMsg, + }) + saveLog(false, errMsg, taskTitle(task)) + sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)}) + return + } + + // 5. 成功 + database.DB.Model(&task).Updates(map[string]interface{}{ + "task_status": "success", + }) + saveLog(true, "", taskTitle(task)) + + // 扣减可用次数(原子操作,防止并发超卖) + database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID). + Update("sign_count", gorm.Expr("sign_count - 1")) + sendEvent("success", map[string]interface{}{ + "status": "success", + "message": "签到成功!", + }) + + slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title) + }) + + return nil +} + +// taskTitle 获取任务的实际显示标题(优先使用 cpdaily 返回的真实标题) +func taskTitle(task model.CpTask) string { + if task.RealTitle != "" { + return task.RealTitle + } + return task.Title +} + +// ListTaskLogs 获取任务执行日志列表 +func ListTaskLogs(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var logs []model.CpTaskLog + database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(10). + Find(&logs) + + return utils.Success(c, logs) +} + +// DeleteTaskLog 删除任务执行日志 +func DeleteTaskLog(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + var log model.CpTaskLog + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "日志不存在") + } + return utils.FailServer(c, "查询失败") + } + + database.DB.Delete(&log) + return utils.Success(c, nil) +} + +// 确保 task 包导入 +var _ = time.Now + +// UpdateTaskRequest 更新任务请求 +type UpdateTaskRequest struct { + Title *string `json:"title"` + FormData *[]model.CpTaskFormItem `json:"formData"` +} + +// UpdateTask 更新任务(标题、表单数据) +func UpdateTask(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + id := c.Params("id") + + var req UpdateTaskRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + + var task model.CpTask + if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "任务不存在") + } + return utils.FailServer(c, "查询失败") + } + + updates := map[string]interface{}{} + if req.Title != nil { + updates["title"] = *req.Title + } + if req.FormData != nil { + formData, _ := json.Marshal(req.FormData) + updates["form_data"] = string(formData) + } + + if len(updates) > 0 { + database.DB.Model(&task).Updates(updates) + } + + // 重新查询返回最新数据 + database.DB.First(&task, task.ID) + return utils.Success(c, task) +} + +// GetUploadPolicy 获取图片上传策略(前端直接上传到今日校园 OBS) +func GetUploadPolicy(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + if user.Cookie == "" { + return utils.Fail(c, 400, "请先登录") + } + + var req struct { + TaskType string `json:"taskType"` + } + if err := c.BodyParser(&req); err != nil || req.TaskType == "" { + return utils.FailParam(c, "参数不完整") + } + + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType)) + if err != nil && isCookieExpired(err) { + slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID) + if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { + cookieMap = cpd.ParseCookieJSON(user.Cookie) + policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType)) + } else if errors.Is(refreshErr, ErrPasswordChanged) { + err = ErrPasswordChanged + } + } + if err != nil { + slog.Error("获取上传策略失败", "error", err) + if errors.Is(err, ErrPasswordChanged) { + return utils.Fail(c, 401, "登录凭证已过期,请重新登录") + } + return utils.Fail(c, 503, "获取上传策略失败: "+err.Error()) + } + + return utils.Success(c, policy) +} + +// GetPreviewUrl 获取图片预览 URL +func GetPreviewUrl(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.FailAuth(c, "用户不存在") + } + if user.Cookie == "" { + return utils.Fail(c, 400, "请先登录") + } + + var req struct { + TaskType string `json:"taskType"` + FileKey string `json:"fileKey"` + } + if err := c.BodyParser(&req); err != nil || req.TaskType == "" || req.FileKey == "" { + return utils.FailParam(c, "参数不完整") + } + + cookieMap := cpd.ParseCookieJSON(user.Cookie) + host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID) + + previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey) + if err != nil && isCookieExpired(err) { + slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID) + if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { + cookieMap = cpd.ParseCookieJSON(user.Cookie) + previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey) + } else if errors.Is(refreshErr, ErrPasswordChanged) { + err = ErrPasswordChanged + } + } + if err != nil { + slog.Error("获取预览 URL 失败", "error", err) + if errors.Is(err, ErrPasswordChanged) { + return utils.Fail(c, 401, "登录凭证已过期,请重新登录") + } + return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error()) + } + + return utils.Success(c, fiber.Map{ + "previewUrl": previewUrl, + }) +} diff --git a/backed/internal/handler/ticket.go b/backed/internal/handler/ticket.go new file mode 100644 index 0000000..8481074 --- /dev/null +++ b/backed/internal/handler/ticket.go @@ -0,0 +1,548 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "fmt" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// ---------- 请求/响应结构体 ---------- + +type CreateTicketRequest struct { + Title string `json:"title"` + Content string `json:"content"` +} + +type AddMessageRequest struct { + Content string `json:"content"` +} + +type AdminReplyRequest struct { + Content string `json:"content"` +} + +type UpdateTicketStatusRequest struct { + Status string `json:"status"` +} + +type TicketListItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + MessageCount int64 `json:"messageCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type AdminTicketListItem struct { + TicketListItem + Username string `json:"username"` + RealName string `json:"realName"` + SchoolName string `json:"schoolName"` +} + +type TicketDetail struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Status string `json:"status"` + UserID uint `json:"userId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Messages []TicketMessageRes `json:"messages"` +} + +type TicketMessageRes struct { + ID uint `json:"id"` + TicketID uint `json:"ticketId"` + SenderType string `json:"senderType"` + SenderID uint `json:"senderId"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` +} + +type TicketNotificationRes struct { + ID uint `json:"id"` + TicketID uint `json:"ticketId"` + Title string `json:"title"` + Content string `json:"content"` + IsRead bool `json:"isRead"` + CreatedAt string `json:"createdAt"` +} + +// ---------- 通知辅助函数 ---------- + +func createTicketNotification(ticketID, userID uint, title, content string) { + notif := model.TicketNotification{ + UserID: userID, + TicketID: ticketID, + Title: title, + Content: content, + } + database.DB.Create(¬if) +} + +// ---------- 用户端 API ---------- + +// CreateTicket 创建工单 +func CreateTicket(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var req CreateTicketRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Title == "" || req.Content == "" { + return utils.FailParam(c, "标题和内容不能为空") + } + + // 限制最多 2 个非关闭状态的工单 + var activeCount int64 + database.DB.Model(&model.Ticket{}).Where("user_id = ? AND status != ?", userID, "closed").Count(&activeCount) + if activeCount >= 2 { + return utils.Fail(c, 400, "您最多只能有 2 个未关闭的工单,请先关闭或删除已有工单") + } + + ticket := model.Ticket{ + UserID: userID, + Title: req.Title, + Content: req.Content, + Status: "pending", + } + if err := database.DB.Create(&ticket).Error; err != nil { + return utils.FailServer(c, "创建工单失败") + } + + return utils.Success(c, fiber.Map{ + "id": ticket.ID, + }) +} + +// ListMyTickets 获取我的工单列表 +func ListMyTickets(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var tickets []model.Ticket + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tickets) + + items := make([]TicketListItem, 0, len(tickets)) + for _, t := range tickets { + var count int64 + database.DB.Model(&model.TicketMessage{}).Where("ticket_id = ?", t.ID).Count(&count) + + items = append(items, TicketListItem{ + ID: t.ID, + Title: t.Title, + Status: t.Status, + MessageCount: count, + CreatedAt: t.CreatedAt.Format("2006-01-02 15:04"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02 15:04"), + }) + } + + return utils.Success(c, items) +} + +// GetTicketDetail 获取工单详情(含消息) +func GetTicketDetail(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + var messages []model.TicketMessage + database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages) + + msgRes := make([]TicketMessageRes, 0, len(messages)) + for _, m := range messages { + msgRes = append(msgRes, TicketMessageRes{ + ID: m.ID, + TicketID: m.TicketID, + SenderType: m.SenderType, + SenderID: m.SenderID, + Content: m.Content, + CreatedAt: m.CreatedAt.Format("2006-01-02 15:04"), + }) + } + + return utils.Success(c, TicketDetail{ + ID: ticket.ID, + Title: ticket.Title, + Content: ticket.Content, + Status: ticket.Status, + UserID: ticket.UserID, + CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04"), + UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04"), + Messages: msgRes, + }) +} + +// AddTicketMessage 用户回复工单 +func AddTicketMessage(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var req AddMessageRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Content == "" { + return utils.FailParam(c, "内容不能为空") + } + + // 验证工单归属 + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + // 已关闭的工单不能回复 + if ticket.Status == "closed" { + return utils.Fail(c, 400, "工单已关闭,无法回复") + } + + msg := model.TicketMessage{ + TicketID: ticket.ID, + SenderType: "user", + SenderID: userID, + Content: req.Content, + } + if err := database.DB.Create(&msg).Error; err != nil { + return utils.FailServer(c, "回复失败") + } + + // 如果工单是已解决状态,回复后重新开启 + if ticket.Status == "resolved" { + database.DB.Model(&ticket).Update("status", "pending") + } + + return utils.Success(c, fiber.Map{ + "id": msg.ID, + }) +} + +// CloseTicket 用户关闭工单 +func CloseTicket(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + if ticket.Status == "closed" { + return utils.Fail(c, 400, "工单已关闭,无需重复关闭") + } + + database.DB.Model(&ticket).Update("status", "closed") + + return utils.Success(c, fiber.Map{"status": "closed"}) +} + +// DeleteTicket 用户删除工单 +func DeleteTicket(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + // 级联删除:消息、通知 + database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{}) + database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{}) + database.DB.Delete(&ticket) + + return utils.Success(c, fiber.Map{"deleted": true}) +} + +// ---------- 工单通知 API ---------- + +// ListTicketNotifications 获取我的工单通知 +func ListTicketNotifications(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var notifs []model.TicketNotification + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs) + + items := make([]TicketNotificationRes, 0, len(notifs)) + for _, n := range notifs { + items = append(items, TicketNotificationRes{ + ID: n.ID, + TicketID: n.TicketID, + Title: n.Title, + Content: n.Content, + IsRead: n.IsRead, + CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"), + }) + } + + return utils.Success(c, items) +} + +// GetTicketUnreadCount 获取工单通知未读数 +func GetTicketUnreadCount(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + + var count int64 + database.DB.Model(&model.TicketNotification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count) + + return utils.Success(c, fiber.Map{"unread": count}) +} + +// MarkTicketNotificationRead 标记工单通知已读 +func MarkTicketNotificationRead(c *fiber.Ctx) error { + userID := c.Locals("userId").(uint) + notifID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的通知ID") + } + + database.DB.Model(&model.TicketNotification{}).Where("id = ? AND user_id = ?", notifID, userID).Update("is_read", true) + + return utils.Success(c, fiber.Map{"success": true}) +} + +// ---------- 管理端 API ---------- + +// AdminListTickets 管理端工单列表 +func AdminListTickets(c *fiber.Ctx) error { + status := c.Query("status") + keyword := c.Query("keyword") + + query := database.DB.Table("tickets"). + Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id"). + Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id"). + Order("tickets.created_at DESC") + + if status != "" { + query = query.Where("tickets.status = ?", status) + } + if keyword != "" { + query = query.Where("tickets.title LIKE ? OR tickets.content LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + var results []struct { + model.Ticket + Username string `json:"username"` + RealName string `json:"real_name"` + SchoolID string `json:"school_id"` + } + if err := query.Find(&results).Error; err != nil { + return utils.FailServer(c, "查询失败") + } + + items := make([]AdminTicketListItem, 0, len(results)) + for _, r := range results { + var count int64 + database.DB.Model(&model.TicketMessage{}).Where("ticket_id = ?", r.ID).Count(&count) + + items = append(items, AdminTicketListItem{ + TicketListItem: TicketListItem{ + ID: r.ID, + Title: r.Title, + Status: r.Status, + MessageCount: count, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"), + UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"), + }, + Username: r.Username, + RealName: r.RealName, + SchoolName: r.SchoolID, + }) + } + + return utils.Success(c, items) +} + +// AdminGetTicket 管理端获取工单详情 +func AdminGetTicket(c *fiber.Ctx) error { + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var ticket model.Ticket + if err := database.DB.First(&ticket, ticketID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + // 获取用户信息 + var user model.SysUser + database.DB.Select("username, real_name, school_id").First(&user, ticket.UserID) + + // 获取消息 + var messages []model.TicketMessage + database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages) + + msgRes := make([]TicketMessageRes, 0, len(messages)) + for _, m := range messages { + msgRes = append(msgRes, TicketMessageRes{ + ID: m.ID, + TicketID: m.TicketID, + SenderType: m.SenderType, + SenderID: m.SenderID, + Content: m.Content, + CreatedAt: m.CreatedAt.Format("2006-01-02 15:04"), + }) + } + + return utils.Success(c, fiber.Map{ + "id": ticket.ID, + "title": ticket.Title, + "content": ticket.Content, + "status": ticket.Status, + "userId": ticket.UserID, + "username": user.Username, + "realName": user.RealName, + "schoolName": user.SchoolID, + "createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"), + "updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"), + "messages": msgRes, + }) +} + +// AdminReplyTicket 管理员回复工单 +func AdminReplyTicket(c *fiber.Ctx) error { + adminID := c.Locals("adminId").(uint) + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var req AdminReplyRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Content == "" { + return utils.FailParam(c, "内容不能为空") + } + + var ticket model.Ticket + if err := database.DB.First(&ticket, ticketID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + if ticket.Status == "closed" { + return utils.Fail(c, 400, "工单已关闭") + } + + msg := model.TicketMessage{ + TicketID: ticket.ID, + SenderType: "admin", + SenderID: adminID, + Content: req.Content, + } + if err := database.DB.Create(&msg).Error; err != nil { + return utils.FailServer(c, "回复失败") + } + + // 管理员回复后自动将状态改为处理中 + if ticket.Status == "pending" { + database.DB.Model(&ticket).Update("status", "processing") + } + + // 发送通知给用户 + title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID) + content := truncateString(req.Content, 100) + createTicketNotification(ticket.ID, ticket.UserID, title, content) + + return utils.Success(c, fiber.Map{ + "id": msg.ID, + }) +} + +// AdminUpdateTicketStatus 更新工单状态 +func AdminUpdateTicketStatus(c *fiber.Ctx) error { + ticketID, err := c.ParamsInt("id") + if err != nil { + return utils.FailParam(c, "无效的工单ID") + } + + var req UpdateTicketStatusRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + + validStatus := map[string]bool{ + "pending": true, + "processing": true, + "resolved": true, + "closed": true, + } + if !validStatus[req.Status] { + return utils.FailParam(c, "无效的状态值") + } + + var ticket model.Ticket + if err := database.DB.First(&ticket, ticketID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return utils.Fail(c, 404, "工单不存在") + } + return utils.FailServer(c, "查询失败") + } + + database.DB.Model(&ticket).Update("status", req.Status) + + // 发送通知给用户 + statusLabels := map[string]string{ + "pending": "待处理", + "processing": "处理中", + "resolved": "已解决", + "closed": "已关闭", + } + title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID) + content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status]) + createTicketNotification(ticket.ID, ticket.UserID, title, content) + + return utils.Success(c, fiber.Map{ + "status": req.Status, + }) +} + +func truncateString(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} diff --git a/backed/internal/handler/user_announcement.go b/backed/internal/handler/user_announcement.go new file mode 100644 index 0000000..06e2f91 --- /dev/null +++ b/backed/internal/handler/user_announcement.go @@ -0,0 +1,211 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "fmt" + "time" + + "github.com/gofiber/fiber/v2" +) + +// getCurrentUser 从上下文中获取当前用户信息(支持普通用户和代理/管理员) +func getCurrentUser(c *fiber.Ctx) (userID uint, userType string, err error) { + if adminID, ok := c.Locals("adminId").(uint); ok { + return adminID, "agent", nil + } + if userID, ok := c.Locals("userId").(uint); ok { + return userID, "user", nil + } + return 0, "", fmt.Errorf("未认证") +} + +// UserAnnouncementItem 用户视角的公告项 +type UserAnnouncementItem struct { + model.Announcement + IsRead bool `json:"isRead"` + ReadAt *time.Time `json:"readAt,omitempty"` +} + +// ListUserAnnouncements 获取当前用户的公告列表(含已读状态) +func ListUserAnnouncements(c *fiber.Ctx) error { + userID, userType, err := getCurrentUser(c) + if err != nil { + return utils.FailAuth(c, "未认证") + } + + var announcements []model.Announcement + database.DB.Where("target_type = ?", userType). + Order("created_at DESC"). + Find(&announcements) + + // 查询该用户的已读记录 + readIDs := make(map[uint]time.Time) + var reads []model.AnnouncementRead + database.DB.Where("user_id = ? AND user_type = ?", userID, userType).Find(&reads) + for _, r := range reads { + readIDs[r.AnnouncementID] = r.ReadAt + } + + items := make([]UserAnnouncementItem, 0, len(announcements)) + for _, a := range announcements { + readAt, isRead := readIDs[a.ID] + item := UserAnnouncementItem{ + Announcement: a, + IsRead: isRead, + } + if isRead { + item.ReadAt = &readAt + } + items = append(items, item) + } + + return utils.Success(c, items) +} + +// GetUnreadCount 获取未读公告数 +func GetUnreadCount(c *fiber.Ctx) error { + userID, userType, err := getCurrentUser(c) + if err != nil { + return utils.FailAuth(c, "未认证") + } + + var totalCount int64 + database.DB.Model(&model.Announcement{}). + Where("target_type = ?", userType). + Count(&totalCount) + + var readCount int64 + database.DB.Model(&model.AnnouncementRead{}). + Where("user_id = ? AND user_type = ?", userID, userType). + Count(&readCount) + + unread := int(totalCount) - int(readCount) + if unread < 0 { + unread = 0 + } + + return utils.Success(c, fiber.Map{ + "total": totalCount, + "read": readCount, + "unread": unread, + }) +} + +// PendingPopupItem 待弹窗公告项 +type PendingPopupItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + ForcePopup bool `json:"forcePopup"` + LoopPopup bool `json:"loopPopup"` + CreatedAt string `json:"createdAt"` +} + +// MyAnnouncementInfo 我的公告信息聚合 +type MyAnnouncementInfo struct { + UnreadCount int `json:"unreadCount"` + PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出) +} + +// GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列) +func GetMyAnnouncementInfo(c *fiber.Ctx) error { + userID, userType, err := getCurrentUser(c) + if err != nil { + return utils.FailAuth(c, "未认证") + } + + // 1. 总公告数 + var totalCount int64 + database.DB.Model(&model.Announcement{}). + Where("target_type = ?", userType). + Count(&totalCount) + + // 2. 已读数 + var readCount int64 + database.DB.Model(&model.AnnouncementRead{}). + Where("user_id = ? AND user_type = ?", userID, userType). + Count(&readCount) + + unread := int(totalCount) - int(readCount) + if unread < 0 { + unread = 0 + } + + // 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读) + var forceAnnouncements []model.Announcement + database.DB.Where("target_type = ? AND force_popup = 1", userType). + Order("created_at ASC"). + Find(&forceAnnouncements) + + // 查已读记录 + readIDs := make(map[uint]bool) + var reads []model.AnnouncementRead + database.DB.Where("user_id = ? AND user_type = ?", userID, userType).Find(&reads) + for _, r := range reads { + readIDs[r.AnnouncementID] = true + } + + popupQueue := make([]PendingPopupItem, 0, len(forceAnnouncements)) + for _, a := range forceAnnouncements { + if a.LoopPopup || !readIDs[a.ID] { + popupQueue = append(popupQueue, PendingPopupItem{ + ID: a.ID, + Title: a.Title, + Content: a.Content, + ForcePopup: a.ForcePopup, + LoopPopup: a.LoopPopup, + CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + } + + return utils.Success(c, MyAnnouncementInfo{ + UnreadCount: unread, + PopupQueue: popupQueue, + }) +} + +// MarkAnnouncementRead 标记公告为已读 +func MarkAnnouncementRead(c *fiber.Ctx) error { + userID, userType, err := getCurrentUser(c) + if err != nil { + return utils.FailAuth(c, "未认证") + } + + announcementID := c.Params("id") + + // 验证公告存在 + var announcement model.Announcement + if err := database.DB.First(&announcement, announcementID).Error; err != nil { + return utils.Fail(c, 404, "公告不存在") + } + + // 验证公告目标类型匹配 + if announcement.TargetType != userType { + return utils.Fail(c, 403, "无权操作此公告") + } + + // 检查是否已读 + var existing model.AnnouncementRead + result := database.DB.Where("announcement_id = ? AND user_id = ? AND user_type = ?", + announcementID, userID, userType).First(&existing) + if result.Error == nil { + // 已读过了 + return utils.Success(c, fiber.Map{"alreadyRead": true}) + } + + // 创建已读记录 + read := model.AnnouncementRead{ + AnnouncementID: announcement.ID, + UserID: userID, + UserType: userType, + ReadAt: time.Now(), + } + if err := database.DB.Create(&read).Error; err != nil { + return utils.FailServer(c, "标记失败") + } + + return utils.Success(c, fiber.Map{"alreadyRead": false}) +} diff --git a/backed/internal/middleware/admin_jwt.go b/backed/internal/middleware/admin_jwt.go new file mode 100644 index 0000000..27376c5 --- /dev/null +++ b/backed/internal/middleware/admin_jwt.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "cpdaily-new/internal/service" + "cpdaily-new/internal/utils" + + "github.com/gofiber/fiber/v2" +) + +// AdminAuthRequired 管理员/代理认证中间件 +func AdminAuthRequired() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" || len(authHeader) < 7 || authHeader[:7] != "Bearer " { + return utils.FailAuth(c, "缺少认证信息") + } + + tokenStr := authHeader[7:] + claims, err := service.ValidateAdminToken(tokenStr) + if err != nil { + return utils.FailAuth(c, "token无效或已过期") + } + + // 将管理员信息存入 context + c.Locals("adminId", claims.AgentID) + c.Locals("adminUsername", claims.Username) + c.Locals("adminRole", claims.Role) + return c.Next() + } +} + +// AdminOrAgentRequired 管理员或代理均可访问 +func AdminOrAgentRequired() fiber.Handler { + return AdminAuthRequired() +} + +// AdminOnly 仅管理员可访问 +func AdminOnly() fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("adminRole").(string) + if !ok || role != "admin" { + return utils.Fail(c, 403, "权限不足,仅管理员可执行此操作") + } + return c.Next() + } +} diff --git a/backed/internal/middleware/jwt.go b/backed/internal/middleware/jwt.go new file mode 100644 index 0000000..1e634a6 --- /dev/null +++ b/backed/internal/middleware/jwt.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "cpdaily-new/internal/service" + "cpdaily-new/internal/utils" + "strings" + + "github.com/gofiber/fiber/v2" +) + +func AuthRequired() fiber.Handler { + return func(c *fiber.Ctx) error { + tokenStr := "" + + // 优先从 Authorization header 取 + authHeader := c.Get("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + tokenStr = parts[1] + } + } + + // 降级:从 query param ?token= 取(用于 SSE EventSource) + if tokenStr == "" { + tokenStr = c.Query("token") + } + + if tokenStr == "" { + return utils.FailAuth(c, "缺少认证信息") + } + + claims, err := service.ValidateToken(tokenStr) + if err != nil { + return utils.FailAuth(c, "token无效或已过期") + } + // 将用户信息存入 context + c.Locals("userId", claims.UserID) + c.Locals("username", claims.Username) + c.Locals("schoolId", claims.SchoolID) + return c.Next() + } +} + +func CORS() fiber.Handler { + return func(c *fiber.Ctx) error { + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + c.Set("Access-Control-Allow-Credentials", "true") + if c.Method() == "OPTIONS" { + return c.SendStatus(204) + } + return c.Next() + } +} diff --git a/backed/internal/model/agent_user.go b/backed/internal/model/agent_user.go new file mode 100644 index 0000000..d8fc41d --- /dev/null +++ b/backed/internal/model/agent_user.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// AgentUser 代理/管理员 +type AgentUser struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"` + Password string `gorm:"type:varchar(255);not null" json:"-"` + Role string `gorm:"type:varchar(16);not null;default:agent" json:"role"` // admin / agent + ParentID *uint `gorm:"index" json:"parentId"` // 上级代理ID(admin为null) + Rate float64 `gorm:"type:decimal(5,2);not null;default:1.00" json:"rate"` // 费率倍率 1.00=100% + Balance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"balance"` + Status string `gorm:"type:varchar(16);not null;default:active" json:"status"` // active / disabled + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (AgentUser) TableName() string { + return "agent_users" +} diff --git a/backed/internal/model/announcement.go b/backed/internal/model/announcement.go new file mode 100644 index 0000000..c3128ad --- /dev/null +++ b/backed/internal/model/announcement.go @@ -0,0 +1,16 @@ +package model + +import "time" + +// Announcement 公告 +type Announcement struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Title string `gorm:"type:varchar(255);not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user + ForcePopup bool `gorm:"default:false" json:"forcePopup"` + LoopPopup bool `gorm:"default:false" json:"loopPopup"` + CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backed/internal/model/announcement_read.go b/backed/internal/model/announcement_read.go new file mode 100644 index 0000000..a6e1513 --- /dev/null +++ b/backed/internal/model/announcement_read.go @@ -0,0 +1,12 @@ +package model + +import "time" + +// AnnouncementRead 公告已读记录 +type AnnouncementRead struct { + ID uint `gorm:"primaryKey;autoIncrement"` + AnnouncementID uint `gorm:"not null;index;uniqueIndex:idx_announce_user"` + UserID uint `gorm:"not null;index;uniqueIndex:idx_announce_user"` // 接收方用户ID + UserType string `gorm:"type:varchar(16);not null;uniqueIndex:idx_announce_user"` // agent / user + ReadAt time.Time +} diff --git a/backed/internal/model/cdk_code.go b/backed/internal/model/cdk_code.go new file mode 100644 index 0000000..1dbac27 --- /dev/null +++ b/backed/internal/model/cdk_code.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// CDKCode CDK码 +type CDKCode struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"` + Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格) + CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价) + AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理 + MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数 + Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled + UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户 + UsedAt *time.Time `json:"usedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (CDKCode) TableName() string { + return "cdk_codes" +} diff --git a/backed/internal/model/cp_task.go b/backed/internal/model/cp_task.go new file mode 100644 index 0000000..43a3e8a --- /dev/null +++ b/backed/internal/model/cp_task.go @@ -0,0 +1,56 @@ +package model + +import "time" + +// CpTask 签到/查寝任务 +type CpTask struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint `gorm:"not null;index" json:"userId"` + TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check + Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符) + RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题 + SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识 + SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识 + FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem + TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail + ErrorMessage string `gorm:"type:text" json:"errorMessage"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (CpTask) TableName() string { + return "cp_tasks" +} + +// CpTaskFormItem 签到表单字段 +type CpTaskFormItem struct { + Title string `json:"title"` // 字段标题 + ColName string `json:"colName"` // 列名(用于提交映射) + Type CpTaskItemType `json:"type"` // 字段类型 + Content string `json:"content"` // 用户填入的值 + Required bool `json:"required"` + Options []CpTaskFormOption `json:"options,omitempty"` // 单选/多选选项 + Default string `json:"default,omitempty"` // 历史默认值 + IsOther bool `json:"isOther,omitempty"` + OtherValue string `json:"otherValue,omitempty"` + Number uint `json:"number,omitempty"` // 额外字段序号 + Show bool `json:"show"` // 是否显示 + Tip string `json:"tip,omitempty"` // 提示文字 +} + +// CpTaskItemType 表单字段类型 +type CpTaskItemType int + +const ( + ItemTypeInput CpTaskItemType = 1 // 输入框 + ItemTypeSingle CpTaskItemType = 2 // 单选 + ItemTypeMulti CpTaskItemType = 3 // 多选 + ItemTypeImage CpTaskItemType = 4 // 图片 + ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度 +) + +// CpTaskFormOption 表单选项 +type CpTaskFormOption struct { + Content string `json:"content"` + IsOtherItems bool `json:"isOtherItems"` +} diff --git a/backed/internal/model/cp_task_log.go b/backed/internal/model/cp_task_log.go new file mode 100644 index 0000000..508cefa --- /dev/null +++ b/backed/internal/model/cp_task_log.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// CpTaskLog 任务执行日志 +type CpTaskLog struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + CpTaskID uint `gorm:"not null;index" json:"cpTaskId"` + UserID uint `gorm:"not null;index" json:"userId"` + TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check + Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符) + RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题 + IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功 + ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息 + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (CpTaskLog) TableName() string { + return "cp_task_logs" +} diff --git a/backed/internal/model/pending_school.go b/backed/internal/model/pending_school.go new file mode 100644 index 0000000..38e652b --- /dev/null +++ b/backed/internal/model/pending_school.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" +) + +// PendingSchool 待适配学校(用户提交的测试账号) +type PendingSchool struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + SchoolID string `gorm:"size:50;not null;index" json:"schoolId"` + SchoolName string `gorm:"size:200;not null" json:"schoolName"` + Username string `gorm:"size:100;not null" json:"username"` + Password string `gorm:"size:200;not null" json:"password"` + IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"` + Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + +func (PendingSchool) TableName() string { + return "pending_schools" +} diff --git a/backed/internal/model/sys_user.go b/backed/internal/model/sys_user.go new file mode 100644 index 0000000..050fba0 --- /dev/null +++ b/backed/internal/model/sys_user.go @@ -0,0 +1,72 @@ +package model + +import "time" + +type SysUser struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"` + Password string `gorm:"type:varchar(255);not null" json:"-"` + SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"` + SchoolName string `gorm:"type:varchar(255)" json:"schoolName"` + StudentID string `gorm:"type:varchar(255);not null" json:"studentId"` + Cookie string `gorm:"type:longtext" json:"-"` + // RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录 + RawPassword string `gorm:"type:longtext" json:"-"` + + // 今日校园真实姓名 + RealName string `gorm:"type:varchar(255)" json:"realName"` + // 性别 MALE/FEMALE + Gender string `gorm:"type:varchar(32)" json:"gender"` + // 头像 URL + Avatar string `gorm:"type:varchar(512)" json:"avatar"` + // 学院 + Academy string `gorm:"type:varchar(255)" json:"academy"` + // 专业 + Major string `gorm:"type:varchar(255)" json:"major"` + // 班级 + ClassName string `gorm:"type:varchar(255)" json:"className"` + // 年级 + Grade string `gorm:"type:varchar(64)" json:"grade"` + // 手机号 + MobilePhone string `gorm:"type:varchar(64)" json:"mobilePhone"` + // 组织全路径 + OrgFullName string `gorm:"type:varchar(512)" json:"orgFullName"` + // 设备码 + MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"` + // 设备码类型 random/authorize/manual + MobileType string `gorm:"type:varchar(32)" json:"mobileType"` + // 可签到次数 + SignCount int `gorm:"type:int;default:0" json:"signCount"` + // 邀请码 + InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"` + // 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到) + InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"` + // 累计邀请人数 + InvitedCount int `gorm:"type:int;default:0" json:"invitedCount"` + + // 最后登录时间 + LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SysUser) TableName() string { + return "sys_user" +} + +// DeviceBindToken 设备绑定令牌 +type DeviceBindToken struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint `gorm:"not null;index" json:"userId"` + Token string `gorm:"type:varchar(64);uniqueIndex;not null" json:"token"` + DeviceCode string `gorm:"type:varchar(255)" json:"deviceCode"` + Status string `gorm:"type:varchar(20);default:pending;index" json:"status"` // pending/bound/expired + ExpiresAt time.Time `gorm:"not null" json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (DeviceBindToken) TableName() string { + return "device_bind_tokens" +} diff --git a/backed/internal/model/system_config.go b/backed/internal/model/system_config.go new file mode 100644 index 0000000..686b832 --- /dev/null +++ b/backed/internal/model/system_config.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// SystemConfig 系统配置(key-value) +type SystemConfig struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Key string `gorm:"type:varchar(64);uniqueIndex;not null" json:"key"` + Value string `gorm:"type:varchar(255);not null;default:''" json:"value"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SystemConfig) TableName() string { + return "system_configs" +} diff --git a/backed/internal/model/ticket.go b/backed/internal/model/ticket.go new file mode 100644 index 0000000..acc5225 --- /dev/null +++ b/backed/internal/model/ticket.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" +) + +// Ticket 工单 +type Ticket struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint `gorm:"not null;index" json:"userId"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Status string `gorm:"size:20;not null;default:pending;index" json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (Ticket) TableName() string { + return "tickets" +} + +// TicketMessage 工单消息 +type TicketMessage struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TicketID uint `gorm:"not null;index" json:"ticketId"` + SenderType string `gorm:"size:10;not null" json:"senderType"` // user / admin + SenderID uint `gorm:"not null" json:"senderId"` + Content string `gorm:"type:text;not null" json:"content"` + CreatedAt time.Time `json:"createdAt"` +} + +func (TicketMessage) TableName() string { + return "ticket_messages" +} diff --git a/backed/internal/model/ticket_notification.go b/backed/internal/model/ticket_notification.go new file mode 100644 index 0000000..087993c --- /dev/null +++ b/backed/internal/model/ticket_notification.go @@ -0,0 +1,20 @@ +package model + +import ( + "time" +) + +// TicketNotification 工单通知(管理员操作后通知用户) +type TicketNotification struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint `gorm:"not null;index" json:"userId"` + TicketID uint `gorm:"not null;index" json:"ticketId"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"size:500;not null" json:"content"` + IsRead bool `gorm:"not null;default:false;index" json:"isRead"` + CreatedAt time.Time `json:"createdAt"` +} + +func (TicketNotification) TableName() string { + return "ticket_notifications" +} diff --git a/backed/internal/ratelimit/ratelimit.go b/backed/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..7d0d818 --- /dev/null +++ b/backed/internal/ratelimit/ratelimit.go @@ -0,0 +1,115 @@ +package ratelimit + +import ( + "fmt" + "sync" + "time" +) + +// key 为 "userID:taskID" +type limiter struct { + mu sync.Mutex + records map[string][]time.Time +} + +var instance = &limiter{ + records: make(map[string][]time.Time), +} + +// Allow 检查是否允许执行,limit 次 / window 时间内 +// 返回 (true, 剩余秒数) 表示允许;返回 (false, 等待秒数) 表示被限流 +func Allow(key string, limit int, window time.Duration) (bool, int) { + instance.mu.Lock() + defer instance.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-window) + + // 清理过期记录 + records := instance.records[key] + var valid []time.Time + for _, t := range records { + if t.After(cutoff) { + valid = append(valid, t) + } + } + + if len(valid) >= limit { + // 最早那次什么时候过期(转换为秒) + oldest := valid[0] + wait := int((window - now.Sub(oldest)) / time.Second) + if wait < 1 { + wait = 1 + } + instance.records[key] = valid + return false, wait + } + + valid = append(valid, now) + instance.records[key] = valid + return true, 0 +} + +// Rule 定义一条限流规则 +type Rule struct { + Limit int + Window time.Duration + Label string // 用于提示文案 +} + +// AllowMulti 同时检查多条限流规则(短路,任意一条触发即拒绝) +// 返回 (true, "") 表示允许;返回 (false, "提示文案") 表示被限流 +func AllowMulti(key string, rules []Rule) (bool, string) { + maxWait := 0 + for _, rule := range rules { + ok, wait := Allow(key+"|"+rule.Label, rule.Limit, rule.Window) + if !ok { + if wait > maxWait { + maxWait = wait + } + // 一旦任一规则触发,不再检查后续规则(避免后续规则继续累加计数) + break + } + } + if maxWait > 0 { + return false, formatWait(maxWait) + } + return true, "" +} + +func formatWait(seconds int) string { + switch { + case seconds < 60: + return fmt.Sprintf("操作频繁,请 %d 秒后重试", seconds) + case seconds < 3600: + return fmt.Sprintf("操作频繁,请 %d 分钟后重试", seconds/60) + default: + return fmt.Sprintf("操作频繁,请 %d 小时后重试", seconds/3600) + } +} + +// Cleanup 定期清理过期记录(后台 goroutine 调用) +func Cleanup(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + instance.mu.Lock() + cutoff := time.Now().Add(-interval * 2) + for k, records := range instance.records { + var valid []time.Time + for _, t := range records { + if t.After(cutoff) { + valid = append(valid, t) + } + } + if len(valid) == 0 { + delete(instance.records, k) + } else { + instance.records[k] = valid + } + } + instance.mu.Unlock() + } + }() +} diff --git a/backed/internal/service/admin_jwt.go b/backed/internal/service/admin_jwt.go new file mode 100644 index 0000000..b43360e --- /dev/null +++ b/backed/internal/service/admin_jwt.go @@ -0,0 +1,63 @@ +package service + +import ( + "cpdaily-new/internal/config" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// AdminClaims 管理员/代理 JWT 声明 +type AdminClaims struct { + AgentID uint `json:"agentId"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateAdminToken 生成管理员/代理 token +func GenerateAdminToken(agentID uint, username, role string) (string, int64, error) { + now := time.Now() + jwtCfg := config.Global.Jwt + expiresIn := int64(86400 * 7) // 7天有效期 + + claims := AdminClaims{ + AgentID: agentID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expiresIn) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "cpdaily-new-admin", + }, + } + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtCfg.Secret)) + if err != nil { + return "", 0, err + } + + return token, expiresIn, nil +} + +// ValidateAdminToken 验证管理员/代理 token +func ValidateAdminToken(tokenString string) (*AdminClaims, error) { + claims := &AdminClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(config.Global.Jwt.Secret), nil + }) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("invalid token") + } + if claims.Issuer != "cpdaily-new-admin" { + return nil, errors.New("invalid token type") + } + return claims, nil +} diff --git a/backed/internal/service/captcha.go b/backed/internal/service/captcha.go new file mode 100644 index 0000000..4bce636 --- /dev/null +++ b/backed/internal/service/captcha.go @@ -0,0 +1,445 @@ +package service + +import ( + "bytes" + "cpdaily-new/internal/config" + "encoding/base64" + "image" + "image/color" + "image/draw" + "image/png" + "math/rand" + "strings" + "sync" + "time" +) + +var ( + captchaStore = sync.Map{} + rng = rand.New(rand.NewSource(time.Now().UnixNano())) +) + +type CaptchaResult struct { + CaptchaID string `json:"captchaId"` + ImageBase64 string `json:"imageBase64"` +} + +// 二次元风格配色 — 亮色系 +var animeColors = []color.RGBA{ + {0xFF, 0x14, 0x93, 0xFF}, // 深粉红 + {0x00, 0xCE, 0xD1, 0xFF}, // 青色 + {0xFF, 0xD7, 0x00, 0xFF}, // 金色 + {0x93, 0x70, 0xDB, 0xFF}, // 中紫 + {0xFF, 0x8C, 0x00, 0xFF}, // 橙色 + {0x00, 0xBF, 0xFF, 0xFF}, // 天蓝 + {0xBA, 0x55, 0xD3, 0xFF}, // 紫红 + {0x32, 0xCD, 0x32, 0xFF}, // 酸橙 + {0xFF, 0x69, 0xB4, 0xFF}, // 亮粉 + {0xFF, 0x63, 0x47, 0xFF}, // 番茄 +} + +// 马卡龙色系背景 +var pastelBG = []color.RGBA{ + {0xFF, 0xE4, 0xE1, 0xFF}, // 淡粉 + {0xE6, 0xE6, 0xFA, 0xFF}, // 淡紫 + {0xF0, 0xFF, 0xF0, 0xFF}, // 薄荷 + {0xFF, 0xDA, 0xB9, 0xFF}, // 桃色 + {0xE0, 0xF0, 0xFF, 0xFF}, // 天蓝 + {0xFF, 0xF0, 0xF5, 0xFF}, // 薰衣草 + {0xF5, 0xFF, 0xFA, 0xFF}, // 薄荷奶油 + {0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝 +} + +// 生成随机验证码 +func generateCode(length int) string { + digits := "0123456789" + var sb strings.Builder + for i := 0; i < length; i++ { + sb.WriteByte(digits[rng.Intn(len(digits))]) + } + return sb.String() +} + +// 生成图片验证码 +func GenerateCaptcha() (*CaptchaResult, error) { + cfg := config.Global.Captcha + code := generateCode(cfg.Length) + + // 生成图片 + img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height)) + + // 随机马卡龙色背景 + bg := pastelBG[rng.Intn(len(pastelBG))] + draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) + + // ── 干扰线(粗线,高不透明度)── + for i := 0; i < 10; i++ { + col := animeColors[rng.Intn(len(animeColors))] + col.A = uint8(rng.Intn(80) + 150) // alpha 150~230 + + x1 := rng.Intn(cfg.Width) + y1 := rng.Intn(cfg.Height) + x2 := rng.Intn(cfg.Width) + y2 := rng.Intn(cfg.Height) + + thick := rng.Intn(2) + 2 // 2~3px 粗 + drawThickLine(img, x1, y1, x2, y2, col, thick) + } + + // ── 二次元风格彩色折线 ── + for i := 0; i < 6; i++ { + col := animeColors[rng.Intn(len(animeColors))] + col.A = uint8(rng.Intn(60) + 130) + + x1 := rng.Intn(cfg.Width) + y1 := rng.Intn(cfg.Height) + xm := rng.Intn(cfg.Width) + ym := rng.Intn(cfg.Height) + x2 := rng.Intn(cfg.Width) + y2 := rng.Intn(cfg.Height) + drawThickLine(img, x1, y1, xm, ym, col, 2) + drawThickLine(img, xm, ym, x2, y2, col, 2) + } + + // ── 装饰圆圈(半透明彩色气泡)── + for i := 0; i < 4; i++ { + cx := rng.Intn(cfg.Width) + cy := rng.Intn(cfg.Height) + radius := rng.Intn(5) + 3 + col := animeColors[rng.Intn(len(animeColors))] + col.A = 40 + drawCircle(img, cx, cy, radius, col) + } + + // 小星星/闪光装饰点 + for i := 0; i < 6; i++ { + x := rng.Intn(cfg.Width) + y := rng.Intn(cfg.Height) + starCol := animeColors[rng.Intn(len(animeColors))] + starCol.A = 100 + drawStarDot(img, x, y, starCol) + } + + // 彩色噪点 + for i := 0; i < 100; i++ { + x := rng.Intn(cfg.Width) + y := rng.Intn(cfg.Height) + col := animeColors[rng.Intn(len(animeColors))] + col.A = uint8(rng.Intn(100) + 80) + img.Set(x, y, col) + } + + // ── 绘制文字 — 4px 像素块,带倾斜效果 ── + charBlockW := cfg.Width / cfg.Length + charPixelSize := 4 // 4px 像素块 + charMapW := 7 // 字符矩阵宽度 + charMapH := 9 // 字符矩阵高度 + charW := charMapW * charPixelSize + charH := charMapH * charPixelSize + + for i, ch := range code { + // 每个字符在其块中居中 + blockCenterX := i*charBlockW + charBlockW/2 + charX := blockCenterX - charW/2 + charY := (cfg.Height - charH) / 2 + + // 微调位置 + charX += rng.Intn(5) - 2 + charY += rng.Intn(3) - 1 + + // 每个字符选择鲜艳的二次元颜色 + charColor := animeColors[rng.Intn(len(animeColors))] + + // 随机倾斜程度 (shear: 每行偏移像素数) + shear := rng.Intn(2) + 1 // 1~2 + + // 1/3 概率向右倾斜,2/3 向左倾斜 + if rng.Intn(3) != 0 { + shear = -shear + } + + drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear) + } + + // 编码为 PNG base64 + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + base64Str := base64.StdEncoding.EncodeToString(buf.Bytes()) + + // 生成唯一 ID 并存储验证码 + captchaID := generateCode(16) + captchaStore.Store(captchaID, CaptchaValue{ + Code: code, + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + + return &CaptchaResult{ + CaptchaID: captchaID, + ImageBase64: "data:image/png;base64," + base64Str, + }, nil +} + +type CaptchaValue struct { + Code string + ExpiresAt time.Time +} + +func VerifyCaptcha(captchaID, code string) bool { + if captchaID == "" || code == "" { + return false + } + val, ok := captchaStore.Load(captchaID) + if !ok { + return false + } + captchaStore.Delete(captchaID) + cv := val.(CaptchaValue) + if time.Now().After(cv.ExpiresAt) { + return false + } + return strings.EqualFold(cv.Code, code) +} + +// GetCaptchaDebug 读取验证码(调试用) +func GetCaptchaDebug(captchaID string) (string, bool) { + val, ok := captchaStore.Load(captchaID) + if !ok { + return "", false + } + cv := val.(CaptchaValue) + return cv.Code, true +} + +// 画粗线(绘制多条平行线模拟粗线) +func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) { + dx, dy := x2-x1, y2-y1 + steps := max(abs(dx), abs(dy)) + if steps == 0 { + return + } + for i := 0; i <= steps; i++ { + cx := x1 + dx*i/steps + cy := y1 + dy*i/steps + // 在垂直方向偏移绘制粗线效果 + for t := -(thickness - 1); t < thickness; t++ { + // 根据线的方向决定偏移方向 + var px, py int + if abs(dx) > abs(dy) { + px = cx + py = cy + t + } else { + px = cx + t + py = cy + } + if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y { + img.Set(px, py, col) + } + } + } +} + +// 划线(单像素) +func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) { + dx, dy := x2-x1, y2-y1 + steps := max(abs(dx), abs(dy)) + if steps == 0 { + return + } + for i := 0; i <= steps; i++ { + x := x1 + dx*i/steps + y := y1 + dy*i/steps + if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y { + img.Set(x, y, col) + } + } +} + +// 绘制半透明圆圈 +func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) { + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy <= radius*radius { + x := cx + dx + y := cy + dy + if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y { + img.Set(x, y, col) + } + } + } + } +} + +// 绘制小星星闪光点 +func drawStarDot(img *image.RGBA, x, y int, col color.Color) { + offsets := [][2]int{ + {0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1}, + {1, 1}, {-1, -1}, {1, -1}, {-1, 1}, + } + for _, off := range offsets { + px := x + off[0] + py := y + off[1] + if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y { + img.Set(px, py, col) + } + } +} + +// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果) +func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) { + // 7x9 数字点阵映射 + charMaps := map[string][][]int{ + "0": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "1": { + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + {0, 1, 1, 1, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "2": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "3": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "4": { + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 1, 1, 1, 0}, + {0, 0, 1, 1, 1, 1, 0}, + {0, 1, 1, 0, 1, 1, 0}, + {1, 1, 0, 0, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 0, 1, 1, 0}, + }, + "5": { + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {0, 1, 1, 1, 1, 1, 0}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "6": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 1, 1, 1, 1, 1, 0}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "7": { + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 1, 1, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + }, + "8": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + "9": { + {0, 1, 1, 1, 1, 1, 0}, + {1, 1, 1, 1, 1, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 1, 1}, + {0, 1, 1, 1, 1, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 1, 1}, + {1, 1, 1, 1, 1, 1, 1}, + {0, 1, 1, 1, 1, 1, 0}, + }, + } + + pattern, ok := charMaps[char] + if !ok { + return + } + + // shear: 每行相对中心行(4)的水平偏移 + // shear=1: 行0偏移-4, 行8偏移+4 → 右倾 + // shear=-1: 行0偏移+4, 行8偏移-4 → 左倾 + for py, row := range pattern { + // 行偏移量 = (py - 4) * shear + rowShear := (py - 4) * shear + + for px, v := range row { + if v == 1 { + ix := x + px*pixelSize + rowShear + iy := y + py*pixelSize + for dx := 0; dx < pixelSize; dx++ { + for dy := 0; dy < pixelSize; dy++ { + if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y { + img.Set(ix+dx, iy+dy, col) + } + } + } + } + } + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/backed/internal/service/cpdaily/cookiejar.go b/backed/internal/service/cpdaily/cookiejar.go new file mode 100644 index 0000000..b59c4e3 --- /dev/null +++ b/backed/internal/service/cpdaily/cookiejar.go @@ -0,0 +1,69 @@ +package cpdaily + +import ( + "net/http" + "net/url" + "sync" +) + +// simpleCookieJar 简化的 CookieJar 实现 +type simpleCookieJar struct { + mu sync.RWMutex + cookies map[string][]*http.Cookie // key: host +} + +func newCookieJar() *simpleCookieJar { + return &simpleCookieJar{ + cookies: make(map[string][]*http.Cookie), + } +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + + host := u.Host + existing := j.cookies[host] + cookieMap := make(map[string]*http.Cookie) + for _, c := range existing { + cookieMap[c.Name] = c + } + for _, c := range cookies { + cookieMap[c.Name] = c + } + + var merged []*http.Cookie + for _, c := range cookieMap { + merged = append(merged, c) + } + j.cookies[host] = merged +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + j.mu.RLock() + defer j.mu.RUnlock() + + host := u.Host + var result []*http.Cookie + result = append(result, j.cookies[host]...) + // 也返回全局 cookie + result = append(result, j.cookies[""]...) + return result +} + +// AllCookies 返回所有域名下的所有 cookie +func (j *simpleCookieJar) AllCookies() []*http.Cookie { + j.mu.RLock() + defer j.mu.RUnlock() + var result []*http.Cookie + seen := make(map[string]bool) + for _, cookies := range j.cookies { + for _, c := range cookies { + if !seen[c.Name] { + result = append(result, c) + seen[c.Name] = true + } + } + } + return result +} diff --git a/backed/internal/service/cpdaily/crypto.go b/backed/internal/service/cpdaily/crypto.go new file mode 100644 index 0000000..c3fc633 --- /dev/null +++ b/backed/internal/service/cpdaily/crypto.go @@ -0,0 +1,240 @@ +package cpdaily + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/des" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "strings" +) + +// ========== AES-128-CBC-PKCS7 ========== + +var ( + AesKey = []byte("fafUY9659kyengTR") + AesKeyForCat = []byte("hru89rwr87Nthifa") + AesIv = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7} +) + +func AesEncrypt(data []byte) []byte { + return aesEncrypt(data, AesKey, AesIv) +} + +func AesDecrypt(data []byte) []byte { + return aesDecrypt(data, AesKey, AesIv) +} + +func AesEncryptForCat(data []byte) []byte { + return aesEncrypt(data, AesKeyForCat, AesIv) +} + +func AesDecryptForCat(data []byte) []byte { + return aesDecrypt(data, AesKeyForCat, AesIv) +} + +func aesEncrypt(data, key, iv []byte) []byte { + block, _ := aes.NewCipher(key) + padded := pkcs7Pad(data, block.BlockSize()) + ciphertext := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, padded) + return ciphertext +} + +func aesDecrypt(data, key, iv []byte) []byte { + block, _ := aes.NewCipher(key) + plaintext := make([]byte, len(data)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(plaintext, data) + return pkcs7Unpad(plaintext) +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +func pkcs7Unpad(data []byte) []byte { + if len(data) == 0 { + return data + } + padLen := int(data[len(data)-1]) + if padLen > len(data) || padLen == 0 { + return data + } + return data[:len(data)-padLen] +} + +// ========== CAS 登录密码加密 ========== + +// EncryptCasPassword AES-128-CBC 加密密码 +// key=salt(从页面提取), iv=16位随机, 明文=64位随机字符串+密码, 输出base64 +func EncryptCasPassword(password, salt string) string { + const randStrLen = 64 + const randIvLen = 16 + + randomIv := randomString(randIvLen) + randomStr := randomString(randStrLen) + plaintext := []byte(randomStr + password) + key := []byte(salt) + iv := []byte(randomIv) + + encrypted := aesEncrypt(plaintext, key, iv) + return base64.StdEncoding.EncodeToString(encrypted) +} + +// ========== RSA (IAP 登录用) ========== + +// 硬编码 IAP RSA 公钥 +const IapRsaPubKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCpbRy8ZoyQvRPpUDIXycglTwVDcYrLcv7P9HI4E7/TAPZiI3GN0ckTUVVWRvoo/re3uCOOJK+F+Ufh19XiRIdoPYCULsSyEjcLLiyAS09mVOzhAQco84E/lM24T7rRTVID7LIgWPewyN8Nd5vzHET4Q3qTthoL8n82BwC1iXuVQIDAQAB" + +type RSAKeyPair struct { + n *big.Int + e *big.Int +} + +func NewRSAKeyPairFromPublicKey(base64Key string) (*RSAKeyPair, error) { + der, err := base64.StdEncoding.DecodeString(base64Key) + if err != nil { + return nil, fmt.Errorf("base64 decode error: %w", err) + } + // 简单的 DER 解析 (PKCS#1 Public Key) + // 跳过 ASN.1 头部,提取 n 和 e + return parsePKCS1PublicKey(der) +} + +func parsePKCS1PublicKey(der []byte) (*RSAKeyPair, error) { + // 简化的 PKCS#1 解析 + // 实际上应该用 crypto/x509,但为了轻量级,手动解析 + // 格式: SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } } + // 或者直接 PKCS#1: SEQUENCE { INTEGER n, INTEGER e } + + // 找最后的两个 INTEGER + data := string(der) + nParts := strings.Split(data, "\x02") + if len(nParts) < 3 { + return nil, fmt.Errorf("invalid RSA key format") + } + + // 提取 n (倒数第二个 INTEGER) + nBytes := extractIntegerBytes(nParts[len(nParts)-2]) + // 提取 e (最后一个 INTEGER) + eBytes := extractIntegerBytes(nParts[len(nParts)-1]) + + r := &RSAKeyPair{ + n: new(big.Int).SetBytes(nBytes), + e: new(big.Int).SetBytes(eBytes), + } + return r, nil +} + +func extractIntegerBytes(s string) []byte { + if len(s) == 0 { + return nil + } + // 第一个字节是长度 + data := []byte(s) + if len(data) < 2 { + return data + } + length := int(data[0]) + if length > 128 { + // 长格式 + numLenBytes := length & 0x7F + length = 0 + for i := 1; i <= numLenBytes; i++ { + length = length*256 + int(data[i]) + } + data = data[1+numLenBytes:] + } else { + data = data[1:] + } + if length > len(data) { + length = len(data) + } + return data[:length] +} + +func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) { + // PKCS#1 v1.5 加密 + k := (r.n.BitLen() + 7) / 8 + if k < 12 { + return "", fmt.Errorf("key too short") + } + + // 填充: 0x00 || 0x02 || PS || 0x00 || M + // PS 是随机非零字节 + plainBytes := []byte(plaintext) + if len(plainBytes) > k-11 { + return "", fmt.Errorf("plaintext too long") + } + + padded := make([]byte, k) + padded[0] = 0x00 + padded[1] = 0x02 + + // PS: 随机非零字节 + for i := 2; i < k-len(plainBytes)-1; i++ { + bt := byte(0) + for bt == 0 { + b := make([]byte, 1) + rand.Read(b) + bt = b[0] + } + padded[i] = bt + } + + padded[k-len(plainBytes)-1] = 0x00 + copy(padded[k-len(plainBytes):], plainBytes) + + // c = m^e mod n + m := new(big.Int).SetBytes(padded) + c := new(big.Int).Exp(m, r.e, r.n) + + result := c.Text(16) + if len(result)%2 != 0 { + result = "0" + result + } + + return result, nil +} + +// ========== 工具函数 ========== + +func randomString(length int) string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + result := make([]byte, length) + for i := 0; i < length; i++ { + n, _ := rand.Read(make([]byte, 1)) + result[i] = chars[int(n)%len(chars)] + } + return string(result) +} + +func randomStringV2(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b)[:length] +} + +// ========== DES-CBC-PKCS7 (用于 Cpdaily-Extension) ========== + +var ( + DesKeyForCat = []byte("b3L26XNL") + DesIv = []byte{1, 2, 3, 4, 5, 6, 7, 8} +) + +func DesEncryptForCat(data []byte) []byte { + block, _ := des.NewCipher(DesKeyForCat) + padded := pkcs7Pad(data, block.BlockSize()) + ciphertext := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, DesIv) + mode.CryptBlocks(ciphertext, padded) + return ciphertext +} diff --git a/backed/internal/service/cpdaily/image.go b/backed/internal/service/cpdaily/image.go new file mode 100644 index 0000000..87f03ec --- /dev/null +++ b/backed/internal/service/cpdaily/image.go @@ -0,0 +1,190 @@ +package cpdaily + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "time" + + "github.com/go-resty/resty/v2" +) + +// CpImageUploadPolicy 今日校园 OBS 上传策略 +type CpImageUploadPolicy struct { + AccessId string `json:"accessid"` + FileName string `json:"fileName"` + Policy string `json:"policy"` + Host string `json:"host"` + Signature string `json:"signature"` +} + +// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀 +func getImageUrlPrefix(taskType TaskType) string { + switch taskType { + case TaskTypeSleepCheck: + return "/wec-counselor-attendance-apps/student/attendance" + case TaskTypeSign: + return "/wec-counselor-sign-apps/stu" + default: + return "/wec-counselor-sign-apps/stu" + } +} + +// GetUploadPolicy 从今日校园获取 OBS 上传策略(前端用,浏览器直传到 OBS) +func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType) (*CpImageUploadPolicy, error) { + client := newImgClient() + cookieStr := cookiesToStr(cookieMap) + urlPrefix := getImageUrlPrefix(taskType) + + var apiPath string + if taskType == TaskTypeSleepCheck { + apiPath = "/getUploadPolicy" + } else { + apiPath = "/obs/getUploadPolicy" + } + fullUrl := host + urlPrefix + apiPath + + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", cookieStr). + SetBody(map[string]interface{}{"fileType": 1}). + Post(fullUrl) + if err != nil { + return nil, fmt.Errorf("获取上传策略失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Datas *CpImageUploadPolicy `json:"datas"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析上传策略失败: %w", err) + } + if result.ErrCode != 0 || result.Datas == nil { + return nil, fmt.Errorf("获取上传策略错误(%d): %s", result.ErrCode, result.ErrMsg) + } + + return result.Datas, nil +} + +// uploadToOBS 将文件上传到今日校园的 OBS +func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error { + client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) + + if contentType == "" { + contentType = "image/jpeg" + } + + // 手动构造 multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + writer.WriteField("x-obs-acl", "public-read") + writer.WriteField("AccessKeyId", policy.AccessId) + writer.WriteField("key", policy.FileName) + writer.WriteField("signature", policy.Signature) + writer.WriteField("policy", policy.Policy) + writer.WriteField("content-type", contentType) + + part, _ := writer.CreateFormFile("file", "file") + part.Write(fileData) + writer.Close() + + resp, err := client.R(). + SetHeader("Content-Type", writer.FormDataContentType()). + SetBody(body.Bytes()). + Post(policy.Host) + if err != nil { + return fmt.Errorf("上传文件到 OBS 失败: %w", err) + } + + if resp.StatusCode() > 299 { + return fmt.Errorf("上传文件到 OBS 返回错误(%d): %s", resp.StatusCode(), string(resp.Body())) + } + + return nil +} + +// GetPreviewUrl 从今日校园获取图片预览 URL +func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType, fileKey string) (string, error) { + client := newImgClient() + cookieStr := cookiesToStr(cookieMap) + urlPrefix := getImageUrlPrefix(taskType) + + var apiPath string + switch taskType { + case TaskTypeSleepCheck: + apiPath = "/previewAttachment" + case TaskTypeSign: + apiPath = "/sign/previewAttachment" + default: + apiPath = "/sign/previewAttachment" + } + fullUrl := host + urlPrefix + apiPath + + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", cookieStr). + SetBody(map[string]interface{}{"ossKey": fileKey}). + Post(fullUrl) + if err != nil { + return "", fmt.Errorf("获取预览 URL 失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Datas json.RawMessage `json:"datas"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析预览 URL 响应失败: %w", err) + } + if result.ErrCode != 0 { + return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg) + } + + var previewUrl string + if err := json.Unmarshal(result.Datas, &previewUrl); err != nil { + return "", fmt.Errorf("解析预览 URL 数据失败: %w", err) + } + + return previewUrl, nil +} + +// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL +func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) { + policy, err := GetUploadPolicy(cookieMap, host, taskType) + if err != nil { + return "", err + } + + if err := uploadToOBS(policy, fileData, contentType); err != nil { + return "", err + } + + previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName) + if err != nil { + return "", err + } + + return previewUrl, nil +} + +func newImgClient() *resty.Client { + return resty.New(). + SetTimeout(30*time.Second). + SetDoNotParseResponse(true). + SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) +} diff --git a/backed/internal/service/cpdaily/login_cas.go b/backed/internal/service/cpdaily/login_cas.go new file mode 100644 index 0000000..8692f8d --- /dev/null +++ b/backed/internal/service/cpdaily/login_cas.go @@ -0,0 +1,329 @@ +package cpdaily + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/go-resty/resty/v2" +) + +// ========== CAS 登录 ========== + +// sessionClient 包装 resty.Client 并保存 cookie jar 引用 +type sessionClient struct { + *resty.Client + jar *simpleCookieJar +} + +func newSessionClient() *sessionClient { + jar := newCookieJar() + client := resty.New(). + SetTimeout(30*time.Second). + SetTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS10, + }). + SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1") + client.SetCookieJar(jar) + client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)) + + return &sessionClient{ + Client: client, + jar: jar, + } +} + +func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) { + sc := newSessionClient() + + // 1. 获取合法的登录 URL + loginUrl := getCasValidLoginUrl(school) + slog.Info("CAS登录", "loginUrl", loginUrl, "school", school.Name, "idsUrl", school.IdsUrl) + + // 2. GET 登录页面 (使用带60s timeout的独立context) + loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl) + if err != nil { + return nil, fmt.Errorf("请求登录页面失败: %w", err) + } + + loginPageBytes := loginPageResp.Body() + loginResponseUrl := loginPageResp.RawResponse.Request.URL + slog.Info("CAS登录页", "finalUrl", loginResponseUrl.String(), "bodyLen", len(loginPageBytes)) + + // 3. 禁用自动重定向 + sc.SetRedirectPolicy(resty.NoRedirectPolicy()) + + // 4. 解析表单 + allInputs := parseLoginForm(loginPageBytes, username, password) + slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs)) + + // 5. 判断是否需要验证码 + needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client) + slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType) + if needCaptcha { + captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client) + slog.Info("CAS验证码OCR", "code", captchaCode) + switch loginType { + case 0: + allInputs["captchaResponse"] = captchaCode + default: + allInputs["captcha"] = captchaCode + } + } + + // 6. POST 提交登录 + loginResp, err := sc.R(). + SetFormData(allInputs). + SetContext(loginCtx). + Post(loginResponseUrl.String()) + if err != nil { + // NoRedirectPolicy 下 302 会返回 http.ErrUseLastResponse,此时 loginResp 仍然有效 + if loginResp != nil && loginResp.StatusCode() == 302 { + slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location")) + } else { + return nil, fmt.Errorf("提交登录失败: %w", err) + } + } + + slog.Info("CAS登录响应", "statusCode", loginResp.StatusCode()) + + // 7. 处理响应 + switch loginResp.StatusCode() { + case 302: + slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location")) + return handleCasLoginSuccess(ctx, loginResp, school, sc) + case 200, 401: + body := loginResp.Body() + slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)])) + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body)) + if err == nil && isCasNeedKickOut(doc) { + slog.Info("CAS需要踢下线") + kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client) + return handleCasLoginSuccess(ctx, kickResp, school, sc) + } + msg := getCasErrorMessage(doc) + if msg == "" { + msg = "登录失败,请检查账号密码" + } + return nil, fmt.Errorf("%s", msg) + default: + return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode()) + } +} + +// 获取 CAS 合法登录 URL +func getCasValidLoginUrl(school *SchoolDetailInfo) string { + if school.IdsUrl != "" { + if strings.Contains(school.IdsUrl, "campusphere") || strings.Contains(school.IdsUrl, "wec-portal-mobile") { + return school.IdsUrl + } + } + for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} { + if u != "" { + return u + } + } + return "" +} + +// 解析登录表单 +func parseLoginForm(page []byte, username, password string) map[string]string { + inputs := make(map[string]string) + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(page)) + if err != nil { + return inputs + } + + // 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID + selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"} + var formSel *goquery.Selection + for _, sel := range selectors { + formSel = doc.Find(sel) + if formSel.Length() > 0 { + slog.Info("CAS表单选择", "selector", sel, "count", formSel.Length()) + break + } + } + + formSel.Find("input").Each(func(_ int, s *goquery.Selection) { + name, _ := s.Attr("name") + value, _ := s.Attr("value") + if name != "" { + inputs[name] = value + } + }) + + inputs["username"] = username + + // 清除表单中不必要或可能干扰的字段 + delete(inputs, "userPassword") + delete(inputs, "passwordText") + + salt := inputs["encryptsalt"] + if salt == "" { + salt, _ = doc.Find("#pwdEncryptSalt").Attr("value") + } + if salt == "" { + matches := saltRegexp.FindStringSubmatch(string(page)) + if len(matches) > 1 { + salt = matches[1] + } + } + + if salt != "" { + slog.Info("CAS密码加密", "salt", salt) + inputs["password"] = EncryptCasPassword(password, salt) + } else { + slog.Info("CAS密码未加密") + inputs["password"] = password + } + + if _, exists := inputs["rememberMe"]; exists { + inputs["rememberMe"] = "on" + } + + return inputs +} + +// 检测是否需要验证码 +func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) { + checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s", + loginUrl.Scheme, loginUrl.Host, username) + resp, err := client.R().SetContext(ctx).Get(checkUrl) + if err == nil { + bodyStr := strings.TrimSpace(string(resp.Body())) + // 只当响应体是简单文本(不含HTML标签)时才判断,否则可能是非法请求页面 + if !strings.Contains(bodyStr, " 0 || strings.Contains(doc.Text(), "踢下线") +} + +func submitCasKickOut(ctx context.Context, loginUrl string, doc *goquery.Document, client *resty.Client) *resty.Response { + inputs := map[string]string{} + doc.Find("#continue input").Each(func(_ int, s *goquery.Selection) { + name, _ := s.Attr("name") + value, _ := s.Attr("value") + if name != "" { + inputs[name] = value + } + }) + if _, exists := inputs["continue"]; !exists { + inputs["continue"] = "确认继续" + } + resp, _ := client.R().SetContext(ctx).SetFormData(inputs).Post(loginUrl) + return resp +} + +func getCasErrorMessage(doc *goquery.Document) string { + if doc == nil { + return "登录失败" + } + selectors := []string{ + "#msg", "#loginError", ".login-error", + ".error", "#error", "p.error", + } + for _, sel := range selectors { + msg := strings.TrimSpace(doc.Find(sel).Text()) + if msg != "" { + return msg + } + } + return "" +} diff --git a/backed/internal/service/cpdaily/login_iap.go b/backed/internal/service/cpdaily/login_iap.go new file mode 100644 index 0000000..3ee4771 --- /dev/null +++ b/backed/internal/service/cpdaily/login_iap.go @@ -0,0 +1,265 @@ +package cpdaily + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "cpdaily-new/internal/config" + + "github.com/go-resty/resty/v2" +) + +// ========== IAP 登录 ========== + +func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) { + sc := newSessionClient() + loginUrl := getIapValidLoginUrl(school) + + // 1. 获取 LT (Login Ticket) + lt, err := getIapLT(ctx, loginUrl, sc) + if err != nil { + return nil, fmt.Errorf("获取LT失败: %w", err) + } + + // 2. 获取真实 _lt + realLT, err := getIapRealLT(ctx, loginUrl, lt, sc.Client) + if err != nil { + return nil, fmt.Errorf("获取真实LT失败: %w", err) + } + + // 3. RSA 加密密码 + rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey) + if err != nil { + return nil, fmt.Errorf("RSA密钥解析失败: %w", err) + } + encPwd, err := rsa.Encrypt(password) + if err != nil { + return nil, fmt.Errorf("RSA加密失败: %w", err) + } + + // 4. 检测验证码 + captchaCode := "" + if needIapCaptcha(ctx, loginUrl, realLT, sc.Client) { + captchaCode = resolveIapCaptcha(ctx, loginUrl, realLT, sc.Client) + } + + // 5. 提交登录 + submitData := map[string]string{ + "lt": realLT, + "rememberMe": "true", + "dllt": "", + "mobile": "", + "captcha": captchaCode, + "username": username, + "password": "{rsa}" + encPwd, + } + + submitUrls := []string{ + loginUrl + "/iap/doLogin", + loginUrl + "/doLogin", + } + + var loginResp *resty.Response + var lastErr error + for _, submitUrl := range submitUrls { + loginResp, lastErr = sc.R(). + SetFormData(submitData). + SetContext(ctx). + Post(submitUrl) + if lastErr == nil { + break + } + } + if lastErr != nil { + return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr) + } + defer loginResp.RawBody().Close() + + // 6. 处理响应 + switch loginResp.StatusCode() { + case 302: + return handleIapLoginSuccess(ctx, loginResp, school, sc) + case 200: + body, _ := io.ReadAll(loginResp.RawBody()) + var result struct { + ResultCode int `json:"resultCode"` + ResultMsg string `json:"resultMsg"` + } + if err := json.Unmarshal(body, &result); err == nil { + switch result.ResultCode { + case 1: + return sc.jar.AllCookies(), nil + case -4: + return nil, fmt.Errorf("验证码错误") + case -3: + return nil, fmt.Errorf("账号或密码错误") + } + if result.ResultMsg != "" { + return nil, fmt.Errorf("%s", result.ResultMsg) + } + } + return nil, fmt.Errorf("IAP登录失败,未知错误") + default: + return nil, fmt.Errorf("IAP登录返回异常状态码: %d", loginResp.StatusCode()) + } +} + +func getIapValidLoginUrl(school *SchoolDetailInfo) string { + if school.WecloudUrl != "" { + return school.WecloudUrl + } + return school.AmpUrl +} + +func getIapLT(ctx context.Context, baseUrl string, sc *sessionClient) (string, error) { + sc.SetRedirectPolicy(resty.NoRedirectPolicy()) + resp, err := sc.R().SetContext(ctx).Get(baseUrl + "/portal/login") + if err != nil { + resp, err = sc.R().SetContext(ctx).Get(baseUrl + "/iap/login/mobile.html") + } + sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)) + + if err != nil { + return "", err + } + defer resp.RawBody().Close() + + if resp.StatusCode() == 302 { + loc, _ := resp.RawResponse.Location() + if loc != nil { + lt := loc.Query().Get("lt") + if lt != "" { + return lt, nil + } + } + } + + body, _ := io.ReadAll(resp.RawBody()) + return extractLTFromPage(string(body)), nil +} + +func getIapRealLT(ctx context.Context, baseUrl, lt string, client *resty.Client) (string, error) { + resp, err := client.R(). + SetFormData(map[string]string{"lt": lt}). + SetContext(ctx). + Post(baseUrl + "/iap/security/lt") + if err != nil { + return "", err + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + Result struct { + LT string `json:"_lt"` + } `json:"result"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析LT响应失败: %w", err) + } + if result.Result.LT == "" { + return "", fmt.Errorf("获取真实LT为空") + } + return result.Result.LT, nil +} + +func needIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) bool { + resp, err := client.R(). + SetFormData(map[string]string{"ltId": lt}). + SetContext(ctx). + Post(baseUrl + "/iap/checkNeedCaptcha") + if err != nil { + return false + } + defer resp.RawBody().Close() + body, _ := io.ReadAll(resp.RawBody()) + return strings.Contains(string(body), `"needCaptcha":true`) +} + +func resolveIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) string { + captchaUrl := fmt.Sprintf("%s/iap/generateCaptcha?ltId=%s", baseUrl, lt) + imgResp, err := client.R().SetContext(ctx).Get(captchaUrl) + if err != nil { + return "" + } + defer imgResp.RawBody().Close() + imgData, _ := io.ReadAll(imgResp.RawBody()) + return captchaOCR(imgData) +} + +func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) { + jumpUrl, err := resp.RawResponse.Location() + if err != nil { + return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err) + } + + sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)) + verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String()) + if err != nil { + return nil, fmt.Errorf("IAP跟随重定向失败: %w", err) + } + defer verifyResp.RawBody().Close() + + activateSession(ctx, school.WecloudUrl, sc.Client) + return sc.jar.AllCookies(), nil +} + +func extractLTFromPage(page string) string { + idx := strings.Index(page, `"lt":"`) + if idx > 0 { + end := strings.Index(page[idx+5:], `"`) + if end > 0 { + return page[idx+5 : idx+5+end] + } + } + return "" +} + +func getHost(rawUrl string) string { + u, err := url.Parse(rawUrl) + if err != nil { + return rawUrl + } + return u.Host +} + +// ========== OCR 验证码识别 ========== + +func captchaOCR(imgData []byte) string { + // 创建专用客户端(不继承 DoNotParseResponse) + cfg := config.Global.Cpdaily + resp, err := resty.New(). + SetTimeout(15 * time.Second). + R(). + SetHeader("X-Api-Key", cfg.CaptchaApiKey). + SetFileReader("file", "captcha.jpg", bytes.NewReader(imgData)). + Post(cfg.CaptchaBaseUrl + "/ocr") + if err != nil { + slog.Warn("OCR请求失败", "err", err) + return "" + } + + var result struct { + Code int `json:"code"` + Data string `json:"data"` + } + bodyStr := resp.String() + if err := json.Unmarshal([]byte(bodyStr), &result); err != nil { + slog.Warn("OCR响应解析失败", "err", err, "body", bodyStr) + return "" + } + if result.Code != 200 { + slog.Warn("OCR识别失败", "code", result.Code, "data", result.Data) + return "" + } + slog.Info("OCR识别结果", "data", result.Data) + return result.Data +} diff --git a/backed/internal/service/cpdaily/profile.go b/backed/internal/service/cpdaily/profile.go new file mode 100644 index 0000000..8559de9 --- /dev/null +++ b/backed/internal/service/cpdaily/profile.go @@ -0,0 +1,88 @@ +package cpdaily + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +// 创建 Resty 客户端 +func newMobileClient() *resty.Client { + return resty.New(). + SetTimeout(15*time.Second). + SetDoNotParseResponse(true). + SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) +} + +// FetchUserProfile 从 cpdaily 获取用户主页信息 +// 使用 wecloud getCurrentUserId API 获取姓名+头像,不再尝试获取手机号 +func FetchUserProfile(cookieMap map[string]string, wecloudUrl string) (*CpdailyUserProfile, error) { + if wecloudUrl != "" { + if p, err := fetchFromWecloud(wecloudUrl, cookieMap); err == nil && p.Name != "" { + return p, nil + } + } + + return nil, fmt.Errorf("无法获取用户信息") +} + +// fetchFromWecloud 通过 campusphere.net 获取用户基本信息 +func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyUserProfile, error) { + host := strings.TrimSuffix(wecloudUrl, "/") + cookieStr := cookiesToStr(cookieMap) + + client := newMobileClient() + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Cookie", cookieStr). + Get(host + "/wec-im-group/group/groupMember/getCurrentUserId") + if err != nil { + return nil, fmt.Errorf("请求 wecloud 用户信息失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Data *struct { + UserName string `json:"userName"` + AvatarUrl string `json:"avatarUrl"` + MobilePhone string `json:"mobilePhone"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err) + } + if result.ErrCode != 0 || result.Data == nil { + return nil, fmt.Errorf("wecloud 用户信息接口错误(%d): %s", result.ErrCode, result.ErrMsg) + } + + return &CpdailyUserProfile{ + Name: result.Data.UserName, + Img: result.Data.AvatarUrl, + MobilePhone: result.Data.MobilePhone, + }, nil +} + +// cookiesToStr 将 cookie map 转为 cookie 字符串 +func cookiesToStr(cookieMap map[string]string) string { + var parts []string + for k, v := range cookieMap { + parts = append(parts, k+"="+v) + } + return strings.Join(parts, "; ") +} + +// ParseCookieJSON 将 JSON cookie 字符串解析为 map +func ParseCookieJSON(cookieJSON string) map[string]string { + cookieMap := make(map[string]string) + json.Unmarshal([]byte(cookieJSON), &cookieMap) + return cookieMap +} diff --git a/backed/internal/service/cpdaily/service.go b/backed/internal/service/cpdaily/service.go new file mode 100644 index 0000000..6ed1c2b --- /dev/null +++ b/backed/internal/service/cpdaily/service.go @@ -0,0 +1,215 @@ +package cpdaily + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +// ========== HTTP 客户端 ========== + +var httpClient *resty.Client + +func init() { + httpClient = resty.New(). + SetTimeout(30*time.Second). + SetDoNotParseResponse(true). + SetTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS10, + }). + SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("deviceType", "2") +} + +// ========== 公开 API ========== + +// SearchSchools 搜索学校 +func SearchSchools(name string) ([]SchoolBriefInfo, error) { + client := httpClient + resp, err := client.R().Get("https://static.campushoy.com/apicache/tenantListSort") + if err != nil { + return nil, fmt.Errorf("请求学校列表失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + + var result struct { + ErrMsg string `json:"errMsg"` + ErrCode int `json:"errCode"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析学校列表失败: %w", err) + } + + // data 是一个数组,每项包含 datas 字段 + var datasets []struct { + Datas []SchoolBriefInfo `json:"datas"` + } + if err := json.Unmarshal(result.Data, &datasets); err != nil { + return nil, fmt.Errorf("解析学校数据失败: %w", err) + } + + var schools []SchoolBriefInfo + for _, ds := range datasets { + for _, s := range ds.Datas { + if name == "" || strings.Contains(s.Name, name) || strings.Contains(s.NamePinYin, name) { + schools = append(schools, s) + } + } + } + return schools, nil +} + +// GetSchoolDetail 获取学校详情 +func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) { + // AES 加密 tenantID + encrypted := AesEncrypt([]byte(tenantID)) + aParam := base64.StdEncoding.EncodeToString(encrypted) + + resp, err := httpClient.R(). + SetQueryParam("a", aParam). + SetQueryParam("b", "first_v4"). + Get("https://mobile.campushoy.com/v6/config/guest/tenant/info/v-8222") + if err != nil { + return nil, fmt.Errorf("请求学校详情失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + + var result struct { + ErrMsg string `json:"errMsg"` + ErrCode int `json:"errCode"` + Data string `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析学校详情失败: %w", err) + } + + if result.ErrCode != 0 { + return nil, fmt.Errorf("学校详情接口错误: %s", result.ErrMsg) + } + + // 数据是 base64(AES_Encrypted(JSON)),需要解码+解密 + encData, err := base64.StdEncoding.DecodeString(result.Data) + if err != nil { + return nil, fmt.Errorf("base64解码失败: %w", err) + } + decData := AesDecrypt(encData) + + // 有时候需要解密两次 + if !json.Valid(decData) { + // 再解密一次 + encData2, _ := base64.StdEncoding.DecodeString(string(decData)) + if encData2 != nil { + decData = AesDecrypt(encData2) + } + } + + // 注意: API 返回的可能是一个数组(部分学校返回单对象,部分返回数组) + var detail SchoolDetailInfo + if decData[0] == '[' { + var details []SchoolDetailInfo + if err := json.Unmarshal(decData, &details); err != nil { + return nil, fmt.Errorf("解析学校详情数组JSON失败: %w, data: %s", err, string(decData)) + } + if len(details) == 0 { + return nil, fmt.Errorf("学校详情数组为空") + } + detail = details[0] + } else { + if err := json.Unmarshal(decData, &detail); err != nil { + return nil, fmt.Errorf("解析学校详情JSON失败: %w, data: %s", err, string(decData)) + } + } + + // 填充额外的字段 + detail.NamePinYin = getPinYinFromID(tenantID) + detail.NameAbbr = getAbbrFromName(detail.Name) + + return &detail, nil +} + +// CpdailyLogin 执行 cpdaily 登录 +// 返回登录成功后的所有 cookies +func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) { + if school.IsIap() { + return iapLogin(ctx, school, username, password) + } + return casLogin(ctx, school, username, password) +} + +// SerializeCookies 将 cookies 序列化为字符串 +func SerializeCookies(cookies []*http.Cookie) string { + // 转为 map[string]string + cookieMap := make(map[string]string) + for _, c := range cookies { + cookieMap[c.Name] = c.Value + } + jsonBytes, _ := json.Marshal(cookieMap) + return string(jsonBytes) +} + +// ========== 学校辅助函数 ========== + +// 从学校名取简称 (拼音首字母或截取) +func getAbbrFromName(name string) string { + // 简化: 取前几个字符 + name = strings.TrimSpace(name) + if len(name) <= 4 { + return name + } + // 取前4个字符作为简称 + runes := []rune(name) + if len(runes) > 4 { + return string(runes[:4]) + } + return name +} + +func getPinYinFromID(id string) string { + // 这是个简化函数,实际应该从学校列表获取 + return "" +} + +// ========== 工具函数 ========== + +// ParseCookieString 解析 Set-Cookie 头 +func parseCookies(resp *resty.Response) []*http.Cookie { + return resp.RawResponse.Cookies() +} + +// CombineCookies 从多个响应中收集 cookies +func combineCookies(responses ...*resty.Response) []*http.Cookie { + seen := make(map[string]*http.Cookie) + for _, resp := range responses { + if resp == nil || resp.RawResponse == nil { + continue + } + for _, c := range resp.RawResponse.Cookies() { + // 同名 cookie 用最新的 + seen[c.Name] = c + } + } + var result []*http.Cookie + for _, c := range seen { + result = append(result, c) + } + return result +} + +// 从 URL 中提取值 +var saltRegexp = regexp.MustCompile(`var\s+pwdDefaultEncryptSalt\s*=\s*["']([^"']+)["']`) diff --git a/backed/internal/service/cpdaily/submit_captcha.go b/backed/internal/service/cpdaily/submit_captcha.go new file mode 100644 index 0000000..d51f7de --- /dev/null +++ b/backed/internal/service/cpdaily/submit_captcha.go @@ -0,0 +1,314 @@ +package cpdaily + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + "time" + + "cpdaily-new/internal/config" + + "github.com/go-resty/resty/v2" +) + +// ======== 数据结构 ======== + +// ValidationInfo 验证信息结构体 +type ValidationInfo struct { + Validation bool `json:"validation"` + SceneCode string `json:"sceneCode,omitempty"` + AccountKey string `json:"accountKey,omitempty"` + TenantId string `json:"tenantId,omitempty"` + UserId string `json:"userId,omitempty"` +} + +// CpSubmitCaptcha 今日校园提交验证码结构体 +type CpSubmitCaptcha struct { + Code string `json:"code,omitempty"` + RawResponseJson json.RawMessage `json:"-"` +} + +// apiResponse 通用API响应结构体 +type apiResponse struct { + Code json.Number `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` + Datas json.RawMessage `json:"datas,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// itStarResponse ITStar验证码识别响应结构体 +type itStarResponse struct { + Code json.Number `json:"code"` + Msg string `json:"msg"` + Data []string `json:"data"` +} + +// ======== 验证码解析器 ======== + +type submitCaptchaResolver struct { + cookieStr string + host string + deviceId string + taskType TaskType +} + +func newSubmitCaptchaResolver(cookieStr, host, deviceId string, taskType TaskType) *submitCaptchaResolver { + return &submitCaptchaResolver{ + cookieStr: cookieStr, + host: host, + deviceId: deviceId, + taskType: taskType, + } +} + +// getValidateUrl 根据任务类型获取验证URL +func (r *submitCaptchaResolver) getValidateUrl() string { + baseURL := strings.TrimSuffix(r.host, "/") + switch r.taskType { + case TaskTypeSign: + return baseURL + "/wec-counselor-sign-apps/stu/sign/checkValidation" + case TaskTypeSleepCheck: + return baseURL + "/wec-counselor-attendance-apps/student/attendance/checkValidation" + default: + return baseURL + "/wec-counselor-sign-apps/stu/sign/checkValidation" + } +} + +// GetValidationInfo 获取验证信息 +func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) { + client := newTaskClient() + + reqData := map[string]string{ + "deviceId": r.deviceId, + } + + resp, err := client.R(). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", r.cookieStr). + SetBody(reqData). + Post(r.getValidateUrl()) + if err != nil { + return nil, fmt.Errorf("获取验证信息失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + + var apiResp apiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析验证信息响应失败: %w", err) + } + + if apiResp.Datas == nil { + msg := "获取验证信息失败" + if apiResp.Message != "" { + msg = apiResp.Message + } + return nil, fmt.Errorf("%s", msg) + } + + var info ValidationInfo + if err := json.Unmarshal(apiResp.Datas, &info); err != nil { + return nil, fmt.Errorf("解析验证信息失败: %w", err) + } + + return &info, nil +} + +// GetCaptchaTicket 获取验证码票据 +func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string, error) { + // Step 1: 创建验证码 + captchaInfo, err := r.createCaptcha(info) + if err != nil { + return "", fmt.Errorf("创建验证码失败: %w", err) + } + + // Step 2: AI 识别 + codes, err := r.getResultFromItStar(captchaInfo.RawResponseJson) + if err != nil { + return "", fmt.Errorf("AI识别验证码失败: %w", err) + } + + // Step 3: 提交验证结果 + ticket, err := r.submitCaptcha(info, captchaInfo, codes) + if err != nil { + return "", fmt.Errorf("提交验证码结果失败: %w", err) + } + + return ticket, nil +} + +// createCaptcha 创建验证码(JSON body) +func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) { + baseURL := strings.TrimSuffix(r.host, "/") + url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage" + + client := newTaskClient() + + reqData := map[string]string{ + "accountKey": info.AccountKey, + "sceneCode": info.SceneCode, + "tenantId": info.TenantId, + "userId": info.UserId, + } + + resp, err := client.R(). + SetHeader("Cookie", r.cookieStr). + SetHeader("deviceId", r.deviceId). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetBody(reqData). + Post(url) + if err != nil { + return nil, fmt.Errorf("创建验证码请求失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + + var apiResp apiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析创建验证码响应失败: %w", err) + } + + if apiResp.Result == nil { + msg := "创建验证码失败" + if apiResp.Message != "" { + msg = apiResp.Message + } + return nil, fmt.Errorf("%s", msg) + } + + var result CpSubmitCaptcha + if err := json.Unmarshal(apiResp.Result, &result); err != nil { + return nil, fmt.Errorf("解析验证码数据失败: %w", err) + } + + result.RawResponseJson = apiResp.Result + return &result, nil +} + +// getResultFromItStar 从ITStar获取识别结果 +func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage) ([]string, error) { + var responseMap map[string]interface{} + if err := json.Unmarshal(rawResponse, &responseMap); err != nil { + return nil, fmt.Errorf("解析验证码响应失败: %w", err) + } + + cfg := config.Global.Cpdaily + url := cfg.CaptchaBaseUrl + "/classifier" + + client := resty.New().SetTimeout(30 * time.Second) + + resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("X-Api-Key", cfg.CaptchaApiKey). + SetBody(responseMap). + Post(url) + if err != nil { + return nil, fmt.Errorf("请求ITStar识别服务失败: %w", err) + } + + var itResp itStarResponse + if err := json.Unmarshal(resp.Body(), &itResp); err != nil { + return nil, fmt.Errorf("解析ITStar响应失败: %w", err) + } + + codeStr, _ := itResp.Code.Float64() + if int64(codeStr) != 200 { + return nil, fmt.Errorf("验证码识别出错: %s", itResp.Msg) + } + + return itResp.Data, nil +} + +// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded) +func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) { + baseURL := strings.TrimSuffix(r.host, "/") + targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage" + + // 构建 URL-encoded 表单数据 + form := url.Values{} + form.Set("accountKey", info.AccountKey) + form.Set("sceneCode", info.SceneCode) + form.Set("tenantId", info.TenantId) + form.Set("userId", info.UserId) + form.Set("scenesImageCode", captchaInfo.Code) + for _, code := range codes { + form.Add("scenesImageCodes", code) + } + + client := newTaskClient() + resp, err := client.R(). + SetHeader("Cookie", r.cookieStr). + SetHeader("deviceId", r.deviceId). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetBody(form.Encode()). + Post(targetURL) + if err != nil { + return "", fmt.Errorf("提交验证码请求失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + + var apiResp apiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return "", fmt.Errorf("解析提交验证码响应失败: %w", err) + } + + codeStr, _ := apiResp.Code.Float64() + if int64(codeStr) != 200 { + msg := "提交验证码失败" + if apiResp.Message != "" { + msg = apiResp.Message + } + return "", fmt.Errorf("%s", msg) + } + + if apiResp.Result == nil { + return "", fmt.Errorf("今日校园返回空验证码结果") + } + + var ticket string + if err := json.Unmarshal(apiResp.Result, &ticket); err != nil { + return "", fmt.Errorf("解析验证码票据失败: %w", err) + } + + return ticket, nil +} + +// ResolveSubmitCaptcha 一站式解决提交验证码:检查 → 识别 → 获取票据 +// 返回 ticket(空字符串表示不需要验证码) +func ResolveSubmitCaptcha(cookieStr, host, deviceId string, taskType TaskType) (string, error) { + resolver := newSubmitCaptchaResolver(cookieStr, host, deviceId, taskType) + + // 1. 检查是否需要验证码 + info, err := resolver.GetValidationInfo() + if err != nil { + return "", fmt.Errorf("检查验证码状态失败: %w", err) + } + + // 不需要验证码 + if !info.Validation { + return "", nil + } + + // 2. 需要验证码,重试获取 ticket(最多5次) + var lastErr error + for i := 0; i < 5; i++ { + if i > 0 { + time.Sleep(time.Duration(10+i*2) * time.Second) + } + + ticket, err := resolver.GetCaptchaTicket(info) + if err == nil { + return ticket, nil + } + lastErr = err + } + + return "", fmt.Errorf("验证码识别失败(已重试5次): %w", lastErr) +} diff --git a/backed/internal/service/cpdaily/task.go b/backed/internal/service/cpdaily/task.go new file mode 100644 index 0000000..5fcb4bd --- /dev/null +++ b/backed/internal/service/cpdaily/task.go @@ -0,0 +1,853 @@ +package cpdaily + +import ( + "crypto/md5" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + "time" + + cpdmodel "cpdaily-new/internal/model" + + "github.com/go-resty/resty/v2" +) + +// TaskType 任务类型 +type TaskType string + +const ( + TaskTypeSign TaskType = "sign" + TaskTypeSleepCheck TaskType = "sleep_check" +) + +// 各任务类型的 API 基础路径 +func taskBasePath(host string, taskType TaskType) string { + switch taskType { + case TaskTypeSign: + return host + "/wec-counselor-sign-apps/stu/sign" + case TaskTypeSleepCheck: + return host + "/wec-counselor-attendance-apps/student/attendance" + default: + return host + "/wec-counselor-sign-apps/stu/sign" + } +} + +// TodayTaskInfo 任务信息 +type TodayTaskInfo struct { + TaskName string `json:"taskName"` + SignWid string `json:"signWid"` + SignInstanceWid string `json:"signInstanceWid"` + IsSigned bool `json:"isSigned"` + TaskType string `json:"taskType,omitempty"` // sign / sleep_check +} + +// TaskFormDetail 签到任务表单详情 +type TaskFormDetail struct { + Rows map[string]interface{} `json:"rows"` + RealTitle string `json:"realTitle"` + SignWid string `json:"signWid"` + SignInstanceWid string `json:"signInstanceWid"` + IsPhoto bool `json:"isPhoto"` + IsNeedExtra bool `json:"isNeedExtra"` + ExtraField []interface{} `json:"extraField,omitempty"` + HistoryRows map[string]interface{} `json:"historyRows,omitempty"` +} + +// newRestyClient 创建 resty 客户端 +func newTaskClient() *resty.Client { + return resty.New(). + SetTimeout(30*time.Second). + SetDoNotParseResponse(true). + SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) +} + +// FetchTodayTasks 获取今日所有签到/查寝任务 +// 如果今日没有任务,自动降级查询本月任务作为近期任务供用户选择 +func FetchTodayTasks(cookieMap map[string]string, host string, taskType TaskType) ([]TodayTaskInfo, error) { + tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "") + if err != nil { + return nil, err + } + + // 今日有任务,直接返回 + if len(tasks) > 0 { + return tasks, nil + } + + // 今日无任务,降级查询本月任务 + now := time.Now() + year := fmt.Sprintf("%d", now.Year()) + month := fmt.Sprintf("%02d", int(now.Month())) + monthTasks, monthErr := fetchTasksByYearMonth(cookieMap, host, taskType, year, month) + if monthErr == nil && len(monthTasks) > 0 { + return monthTasks, nil + } + return []TodayTaskInfo{}, nil +} + +// fetchTasksByEndpoint 通过指定路径(InOneDay / ByWeekMonth)获取任务列表 +func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType TaskType, yearMonth string) ([]TodayTaskInfo, error) { + client := newTaskClient() + cookieStr := cookiesToStr(cookieMap) + baseURL := taskBasePath(host, taskType) + + var endpoint string + var bodyMap map[string]interface{} + isMonthQuery := yearMonth != "" + + switch taskType { + case TaskTypeSign: + if isMonthQuery { + endpoint = "/getStuSignInfosByWeekMonth" + bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth} + } else { + endpoint = "/getStuSignInfosInOneDay" + bodyMap = map[string]interface{}{} + } + case TaskTypeSleepCheck: + if isMonthQuery { + endpoint = "/getStuSignInfosByWeekMonth" + bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth} + } else { + endpoint = "/getStuAttendacesInOneDay" + bodyMap = map[string]interface{}{} + } + default: + endpoint = "/getStuSignInfosInOneDay" + bodyMap = map[string]interface{}{} + } + + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", cookieStr). + SetBody(bodyMap). + Post(baseURL + endpoint) + if err != nil { + return nil, fmt.Errorf("请求任务列表失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Datas map[string]interface{} `json:"datas"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析任务列表响应失败: %w", err) + } + if result.ErrCode != 0 { + return nil, fmt.Errorf("任务列表接口错误(%d): %s", result.ErrCode, result.ErrMsg) + } + + return parseTasksFromDatas(result.Datas), nil +} + +// parseTasksFromDatas 从 datas map 中解析任务列表(兼容 InOneDay 和 ByWeekMonth 两种响应) +func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo { + seen := map[string]bool{} + tasks := make([]TodayTaskInfo, 0) + + addTasks := func(arr []interface{}, signed bool) { + for _, item := range arr { + if m, ok := item.(map[string]interface{}); ok { + name, _ := m["taskName"].(string) + wid, _ := m["signWid"].(string) + iwid, _ := m["signInstanceWid"].(string) + if name != "" && !seen[name] { + seen[name] = true + tasks = append(tasks, TodayTaskInfo{ + TaskName: name, + SignWid: wid, + SignInstanceWid: iwid, + IsSigned: signed, + }) + } + } + } + } + + parseGroup := func(d map[string]interface{}) { + if v, ok := d["unSignedTasks"]; ok { + if arr, ok := v.([]interface{}); ok { + addTasks(arr, false) + } + } + if v, ok := d["leaveTasks"]; ok { + if arr, ok := v.([]interface{}); ok { + addTasks(arr, false) + } + } + if v, ok := d["signedTasks"]; ok { + if arr, ok := v.([]interface{}); ok { + addTasks(arr, true) + } + } + if v, ok := d["codeRcvdTasks"]; ok { + if arr, ok := v.([]interface{}); ok { + addTasks(arr, false) + } + } + } + + // InOneDay 格式:datas 直接包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks + if _, ok := datas["unSignedTasks"]; ok { + parseGroup(datas) + return tasks + } + + // ByWeekMonth 格式:datas.rows 是数组,每行包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks + if rows, ok := datas["rows"].([]interface{}); ok { + for _, row := range rows { + if rowMap, ok := row.(map[string]interface{}); ok { + parseGroup(rowMap) + } + } + } + + return tasks +} + +// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回 +func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo { + type result struct { + tasks []TodayTaskInfo + err error + } + ch := make(chan result, 2) + + types := []TaskType{TaskTypeSign, TaskTypeSleepCheck} + for _, tt := range types { + go func(t TaskType) { + tasks, err := FetchTodayTasks(cookieMap, host, t) + if err != nil { + ch <- result{nil, err} + return + } + // 标记任务类型 + for i := range tasks { + tasks[i].TaskType = string(t) + } + ch <- result{tasks, nil} + }(tt) + } + + seen := map[string]bool{} + var all []TodayTaskInfo + + for i := 0; i < 2; i++ { + r := <-ch + for _, t := range r.tasks { + key := t.TaskName + "|" + t.TaskType + if !seen[key] { + seen[key] = true + all = append(all, t) + } + } + } + + if len(all) == 0 { + return []TodayTaskInfo{} + } + return all +} +func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) { + yearMonth := fmt.Sprintf("%s-%s", year, month) + return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth) +} + +// globMatch 简单通配符匹配(仅支持 *),用于标题匹配 +func globMatch(str, pattern string) bool { + if pattern == "" { + return str == "" + } + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return str == pattern + } + if parts[0] != "" && !strings.HasPrefix(str, parts[0]) { + return false + } + str = str[len(parts[0]):] + for i := 1; i < len(parts)-1; i++ { + part := parts[i] + if part == "" { + continue + } + idx := strings.Index(str, part) + if idx < 0 { + return false + } + str = str[idx+len(part):] + } + last := parts[len(parts)-1] + if last != "" && !strings.HasSuffix(str, last) { + return false + } + return true +} + +// FetchHistoryFormData 从今日校园获取历史表单数据用于预填充 +// 先查今天已签到的同名任务,没有再查本月历史 +func FetchHistoryFormData(cookieMap map[string]string, host string, taskType TaskType, realTitle, excludeSignInstanceWid string) map[string]interface{} { + // 1. 查今天已签到的同名任务 + todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType) + for _, t := range todayTasks { + if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid { + detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName) + if err == nil && detail != nil { + return detail.Rows + } + break + } + } + + // 2. 降级:查本月已签到的同名任务 + now := time.Now() + year := fmt.Sprintf("%d", now.Year()) + month := fmt.Sprintf("%02d", int(now.Month())) + monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month) + for _, t := range monthTasks { + if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid { + detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName) + if err == nil && detail != nil { + return detail.Rows + } + break + } + } + + return nil +} + +// FetchTaskDetail 获取签到任务详情(表单字段) +func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) { + client := newTaskClient() + cookieStr := cookiesToStr(cookieMap) + baseURL := taskBasePath(host, taskType) + + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", cookieStr). + SetBody(map[string]interface{}{ + "taskName": title, + "signWid": signWid, + "signInstanceWid": signInstanceWid, + }). + Post(baseURL + "/detailSignInstance") + if err != nil { + return nil, fmt.Errorf("请求任务详情失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Datas map[string]interface{} `json:"datas"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析任务详情响应失败: %w", err) + } + if result.ErrCode != 0 { + return nil, fmt.Errorf("任务详情接口错误(%d): %s", result.ErrCode, result.ErrMsg) + } + + rows := result.Datas + + detail := &TaskFormDetail{ + Rows: rows, + RealTitle: title, + SignWid: signWid, + SignInstanceWid: signInstanceWid, + } + + if v, ok := rows["isPhoto"]; ok { + if f, ok := v.(float64); ok && f == 1 { + detail.IsPhoto = true + } + } + if v, ok := rows["isNeedExtra"]; ok { + if f, ok := v.(float64); ok && f == 1 { + detail.IsNeedExtra = true + } + } + if v, ok := rows["extraField"]; ok { + if arr, ok := v.([]interface{}); ok { + detail.ExtraField = arr + } + } + + return detail, nil +} + +// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表 +func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem { + rows := detail.Rows + histRows := rows + if historyRows != nil { + histRows = historyRows + } + + // 提取历史值(与旧版系统格式一致:lon/lat/address 分开存储) + histLon := "" + histLat := "" + histAddress := "" + histSignPhotoUrl := "" + histIsMalposition := "" + + // 优先从 signPlaceSelected 提取(由 cpdaily 返回的历史签到地点 JSON 数组) + if sps, ok := histRows["signPlaceSelected"]; ok { + if spsStr, ok := sps.(string); ok && spsStr != "" && spsStr != "[]" { + var places []map[string]interface{} + if json.Unmarshal([]byte(spsStr), &places) == nil && len(places) > 0 { + lng, _ := places[0]["longitude"].(string) + lat, _ := places[0]["latitude"].(string) + addr, _ := places[0]["address"].(string) + if lng != "" { + histLon = lng + } + if lat != "" { + histLat = lat + } + if addr != "" { + histAddress = addr + } + } + } + } + // 降级到 histRows 中的 longitude/latitude + if histLon == "" || histLat == "" { + if lon, ok := histRows["longitude"]; ok && lon != nil { + histLon = fmt.Sprintf("%v", lon) + } + if lat, ok := histRows["latitude"]; ok && lat != nil { + histLat = fmt.Sprintf("%v", lat) + } + } + // 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标) + if histLon == "" || histLat == "" { + if lon, ok := rows["longitude"]; ok && lon != nil { + histLon = fmt.Sprintf("%v", lon) + } + if lat, ok := rows["latitude"]; ok && lat != nil { + histLat = fmt.Sprintf("%v", lat) + } + } + // 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress + if histAddress == "" { + if pos, ok := histRows["position"]; ok && pos != nil { + histAddress = fmt.Sprintf("%v", pos) + } + } + if histAddress == "" { + if addr, ok := histRows["signAddress"]; ok && addr != nil { + histAddress = fmt.Sprintf("%v", addr) + } + } + if histAddress == "" { + if pos, ok := rows["position"]; ok && pos != nil { + histAddress = fmt.Sprintf("%v", pos) + } + } + if histAddress == "" { + if addr, ok := rows["signAddress"]; ok && addr != nil { + histAddress = fmt.Sprintf("%v", addr) + } + } + // 签到照片 + if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil { + histSignPhotoUrl = fmt.Sprintf("%v", photo) + } + // 具体照片 URL 降级到 rows 中的 signPhotoUrl + if histSignPhotoUrl == "" { + if photo, ok := rows["signPhotoUrl"]; ok && photo != nil { + histSignPhotoUrl = fmt.Sprintf("%v", photo) + } + } + // 是否范围外签到 + if mal, ok := histRows["isMalposition"]; ok { + switch v := mal.(type) { + case float64: + if v == 1 { + histIsMalposition = "是" + } else { + histIsMalposition = "否" + } + case string: + if v == "1" || v == "是" { + histIsMalposition = "是" + } else { + histIsMalposition = "否" + } + } + } + + var list []cpdmodel.CpTaskFormItem + + // 经度(与旧版系统一致,colName="lon") + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "经度", + ColName: "lon", + Type: cpdmodel.ItemTypeInput, + Required: true, + Content: histLon, + Default: histLon, + Show: true, + Tip: "签到地点经度坐标", + }) + // 纬度(与旧版系统一致,colName="lat") + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "纬度", + ColName: "lat", + Type: cpdmodel.ItemTypeInput, + Required: true, + Content: histLat, + Default: histLat, + Show: true, + Tip: "签到地点纬度坐标", + }) + // 签到具体地址 + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "签到具体地址", + ColName: "address", + Type: cpdmodel.ItemTypeInput, + Required: true, + Content: histAddress, + Default: histAddress, + Show: true, + Tip: "签到完毕后查看时最底部那个最详细的地址信息", + }) + + // 签到照片(如果需要) + if detail.IsPhoto { + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "签到照片", + ColName: "signImg", + Type: cpdmodel.ItemTypeImage, + Content: histSignPhotoUrl, + Default: histSignPhotoUrl, + Show: true, + Tip: "您可以上传多张图片,会顺序轮换更新", + }) + } + + // 是否范围外签到 + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "是否范围外签到", + ColName: "isMalposition", + Type: cpdmodel.ItemTypeSingle, + Required: true, + Content: histIsMalposition, + Default: histIsMalposition, + Options: []cpdmodel.CpTaskFormOption{ + {Content: "是", IsOtherItems: true}, + {Content: "否"}, + }, + Show: true, + Tip: "一般在学校就是选否,如果在外实习、请假之类的才选是", + }) + + // 范围外签到原因 + list = append(list, cpdmodel.CpTaskFormItem{ + Title: "范围外签到原因", + ColName: "abnormalReason", + Type: cpdmodel.ItemTypeInput, + Show: histIsMalposition == "是", + }) + + // 额外字段 + if detail.IsNeedExtra { + for i, field := range detail.ExtraField { + fm, ok := field.(map[string]interface{}) + if !ok { + continue + } + extraTitle, _ := fm["title"].(string) + isReq, _ := fm["isRequired"].(bool) + hasOther, _ := fm["hasOtherItems"].(bool) + + item := cpdmodel.CpTaskFormItem{ + Title: extraTitle, + ColName: extraTitle, + Type: cpdmodel.ItemTypeSingle, + Number: uint(i + 1), + Required: isReq, + IsOther: hasOther, + Show: true, + } + + if items, ok := fm["extraFieldItems"].([]interface{}); ok { + for _, it := range items { + if im, ok := it.(map[string]interface{}); ok { + content, _ := im["content"].(string) + otherItems, _ := im["isOtherItems"].(bool) + item.Options = append(item.Options, cpdmodel.CpTaskFormOption{ + Content: content, + IsOtherItems: otherItems, + }) + } + } + } + list = append(list, item) + } + } + + return list +} + +// FillSubmitForm 根据用户配置的表单项填充提交表单 +func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) map[string]interface{} { + result := make(map[string]interface{}) + + // 提取经纬度和地址(兼容旧版 lon/lat 和新版 latAndLon 两种格式) + var lat, lon, address string + for _, v := range formItems { + switch v.ColName { + case "lon": + lon = v.Content + case "lat": + lat = v.Content + case "latAndLon": + parts := strings.Split(v.Content, ",") + if len(parts) == 2 { + if lon == "" { + lon = parts[0] + } + if lat == "" { + lat = parts[1] + } + } + case "address": + address = v.Content + } + } + + result["latitude"] = lat + result["longitude"] = lon + result["position"] = address + result["uaIsCpadaily"] = true + + // signInstanceWid + if wid, ok := rows["signInstanceWid"]; ok { + result["signInstanceWid"] = wid + } + + // 签到照片 + isPhoto := false + if v, ok := rows["isPhoto"]; ok { + if f, ok := v.(float64); ok && f == 1 { + isPhoto = true + } + } + if isPhoto { + for _, v := range formItems { + if v.ColName == "signImg" && v.Content != "" { + result["signPhotoUrl"] = v.Content + break + } + } + } + + // 是否范围外签到 + for _, v := range formItems { + if v.ColName == "isMalposition" { + if v.Content == "是" { + result["isMalposition"] = 1 + // 找原因 + for _, v2 := range formItems { + if v2.ColName == "abnormalReason" { + result["abnormalReason"] = v2.Content + break + } + } + } else { + result["isMalposition"] = 0 + result["abnormalReason"] = "" + } + break + } + } + + // 额外字段 + isNeedExtra := false + if v, ok := rows["isNeedExtra"]; ok { + if f, ok := v.(float64); ok && f == 1 { + isNeedExtra = true + } + } + if isNeedExtra { + result["isNeedExtra"] = 1 + var extraValues []map[string]interface{} + if extraField, ok := rows["extraField"].([]interface{}); ok { + for i, field := range extraField { + fm, ok := field.(map[string]interface{}) + if !ok { + continue + } + num := uint(i + 1) + title, _ := fm["title"].(string) + // 找到用户输入的对应值 + for _, v := range formItems { + if v.Number == num && v.Title == title { + ev := map[string]interface{}{ + "extraFieldItemValue": v.Content, + } + if items, ok := fm["extraFieldItems"].([]interface{}); ok { + for _, it := range items { + if im, ok := it.(map[string]interface{}); ok { + if cnt, _ := im["content"].(string); cnt == v.Content { + ev["extraFieldItemWid"] = im["wid"] + } + } + } + } + extraValues = append(extraValues, ev) + break + } + } + } + } + result["extraFieldItems"] = extraValues + } else { + result["isNeedExtra"] = 0 + } + + return result +} + +// GetBodyString 加密提交表单为 bodyString +func GetBodyString(form map[string]interface{}) string { + data, _ := json.Marshal(form) + return base64.StdEncoding.EncodeToString(AesEncryptForCat(data)) +} + +// GetExtensionString 生成 Cpdaily-Extension 头 +func GetExtensionString(lat, lon, deviceId, username string) string { + ext := map[string]interface{}{ + "lon": lon, + "lat": lat, + "model": "cpdaily", + "appVersion": "9.9.6", + "systemVersion": "15", + "userId": username, + "systemName": "android", + "deviceId": deviceId, + "cpdailyVersion": "9.9.6", + } + data, _ := json.Marshal(ext) + return base64.StdEncoding.EncodeToString(DesEncryptForCat(data)) +} + +// GetSubmitSign 生成提交签名 +func GetSubmitSign(form map[string]interface{}) string { + const salt = "hru89rwr87Nthifa" + keys := []string{"appVersion", "bodyString", "deviceId", "lat", "lon", "model", "systemName", "systemVersion", "userId"} + + var sb strings.Builder + for _, key := range keys { + val := "" + if v, ok := form[key]; ok && v != nil { + val = fmt.Sprintf("%v", v) + } + sb.WriteString(key) + sb.WriteString("=") + sb.WriteString(url.QueryEscape(val)) + sb.WriteString("&") + } + sb.WriteString(salt) + + hash := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(hash[:]) +} + +// SubmitSign 提交签到(含9选5验证码处理) +func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, submitForm map[string]interface{}, deviceId, username string) error { + client := newTaskClient() + cookieStr := cookiesToStr(cookieMap) + baseURL := taskBasePath(host, taskType) + + // 处理 9选5 图片验证码 + ticket, err := ResolveSubmitCaptcha(cookieStr, host, deviceId, taskType) + if err != nil { + return fmt.Errorf("验证码处理失败: %w", err) + } + if ticket != "" { + submitForm["ticket"] = ticket + } else { + delete(submitForm, "ticket") + } + + bodyString := GetBodyString(submitForm) + + // 构造提交数据 + lat := "" + lon := "" + if v, ok := submitForm["latitude"]; ok { + lat = fmt.Sprintf("%v", v) + } + if v, ok := submitForm["longitude"]; ok { + lon = fmt.Sprintf("%v", v) + } + + extensionString := GetExtensionString(lat, lon, deviceId, username) + + submitData := map[string]interface{}{ + "lon": lon, + "version": "first_v4", + "calVersion": "first_v4", + "deviceId": deviceId, + "systemName": "android", + "bodyString": bodyString, + "lat": lat, + "systemVersion": "15", + "appVersion": "9.9.6", + "model": "cpdaily", + } + // 添加 userId + submitData["userId"] = username + + sign := GetSubmitSign(submitData) + submitData["sign"] = sign + + resp, err := client.R(). + SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6"). + SetHeader("CpdailyClientType", "CPDAILY"). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Cookie", cookieStr). + SetHeader("Cpdaily-Extension", extensionString). + SetBody(submitData). + Post(baseURL + "/submitSign") + if err != nil { + return fmt.Errorf("提交签到请求失败: %w", err) + } + defer resp.RawBody().Close() + + body, _ := io.ReadAll(resp.RawBody()) + var result struct { + Code json.Number `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body)) + } + code, _ := result.Code.Int64() + if code != 0 { + msg := result.Message + if msg == "" { + msg = "未知错误" + } + return fmt.Errorf("%s", msg) + } + + return nil +} diff --git a/backed/internal/service/cpdaily/types.go b/backed/internal/service/cpdaily/types.go new file mode 100644 index 0000000..6284b5a --- /dev/null +++ b/backed/internal/service/cpdaily/types.go @@ -0,0 +1,79 @@ +package cpdaily + +// SchoolBriefInfo 学校简要信息 +type SchoolBriefInfo struct { + ID string `json:"id"` + Name string `json:"name"` + NamePinYin string `json:"namePinYin"` + Img string `json:"img"` +} + +// SchoolDetailInfo 学校详情 +type SchoolDetailInfo struct { + AmpUrl string `json:"ampUrl"` + AmpUrl2 string `json:"ampUrl2"` + AmpUrl3 string `json:"ampUrl3"` + IdsUrl string `json:"idsUrl"` + Img string `json:"img"` + JoinType string `json:"joinType"` + Name string `json:"name"` + AppId string `json:"appId"` + ID string `json:"id"` + WecloudUrl string `json:"wecloudUrl"` + NameAbbr string `json:"nameAbbr,omitempty"` + NamePinYin string `json:"namePinYin,omitempty"` +} + +// IsIap 判断是否为 IAP 登录方式 +func (s SchoolDetailInfo) IsIap() bool { + return s.JoinType == "cloud" || s.JoinType == "CLOUD" +} + +// LoginUrl 获取可访问的学校登录地址(用于用户找回密码等场景) +func (s SchoolDetailInfo) LoginUrl() string { + if s.IdsUrl != "" { + return s.IdsUrl + } + if s.WecloudUrl != "" { + return s.WecloudUrl + } + if s.AmpUrl != "" { + return s.AmpUrl + } + return "" +} + +// CpdailyUserProfile 今日校园用户主页信息 +type CpdailyUserProfile struct { + Name string `json:"name"` + Alias string `json:"alias"` + Img string `json:"img"` + Gender string `json:"gender"` + StudentNo string `json:"studentNo"` + MobilePhone string `json:"mobilePhone"` + Tenant string `json:"tenant"` + TenantImg string `json:"tenantImg"` + Academy string `json:"academy"` + Major string `json:"major"` + ClassName string `json:"className"` + Grade string `json:"grade"` + OrgFullName string `json:"orgFullName"` + Degree string `json:"degree"` + CloudUserId string `json:"cloudUserId"` + TenantId string `json:"tenantId,omitempty"` + OpenId string `json:"openId,omitempty"` +} + +// CpdailyMyMainPageResponse 用户主页 API 响应 +type CpdailyMyMainPageResponse struct { + ErrCode int `json:"errCode"` + ErrMsg string `json:"errMsg"` + Data CpdailyUserProfile `json:"data"` +} + +// BaseResponse cpdaily API 通用响应 +type BaseResponse struct { + ErrMsg string `json:"errMsg"` + ErrCode int `json:"errCode"` + Data interface{} `json:"data"` +} diff --git a/backed/internal/service/jwt.go b/backed/internal/service/jwt.go new file mode 100644 index 0000000..e3a4a2f --- /dev/null +++ b/backed/internal/service/jwt.go @@ -0,0 +1,91 @@ +package service + +import ( + "cpdaily-new/internal/config" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID uint `json:"userId"` + Username string `json:"username"` + SchoolID string `json:"schoolId"` + jwt.RegisteredClaims +} + +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` +} + +func GenerateTokens(userID uint, username, schoolID string) (*TokenPair, error) { + now := time.Now() + jwtCfg := config.Global.Jwt + + accessClaims := Claims{ + UserID: userID, + Username: username, + SchoolID: schoolID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtCfg.AccessExpire) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "cpdaily-new", + }, + } + + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(jwtCfg.Secret)) + if err != nil { + return nil, err + } + + refreshClaims := Claims{ + UserID: userID, + Username: username, + SchoolID: schoolID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtCfg.RefreshExpire) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "cpdaily-new-refresh", + }, + } + + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(jwtCfg.Secret)) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int64(jwtCfg.AccessExpire), + }, nil +} + +func ValidateToken(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(config.Global.Jwt.Secret), nil + }) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} + +func RefreshTokens(refreshToken string) (*TokenPair, error) { + claims, err := ValidateToken(refreshToken) + if err != nil { + return nil, errors.New("refresh token invalid or expired") + } + // 刷新时生成全新 token 对 + return GenerateTokens(claims.UserID, claims.Username, claims.SchoolID) +} diff --git a/backed/internal/utils/response.go b/backed/internal/utils/response.go new file mode 100644 index 0000000..117acd7 --- /dev/null +++ b/backed/internal/utils/response.go @@ -0,0 +1,45 @@ +package utils + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" +) + +// FormatFloat 格式化浮点数为字符串保留2位小数 +func FormatFloat(f float64) string { + return fmt.Sprintf("%.2f", f) +} + +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +func Success(c *fiber.Ctx, data interface{}) error { + return c.JSON(Response{ + Code: 0, + Message: "success", + Data: data, + }) +} + +func Fail(c *fiber.Ctx, code int, msg string) error { + return c.Status(code).JSON(Response{ + Code: code, + Message: msg, + }) +} + +func FailParam(c *fiber.Ctx, msg string) error { + return Fail(c, 400, msg) +} + +func FailAuth(c *fiber.Ctx, msg string) error { + return Fail(c, 401, msg) +} + +func FailServer(c *fiber.Ctx, msg string) error { + return Fail(c, 500, msg) +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..e8155d2 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,15 @@ +services: + cpdaily-xiaozhushou: + image: cpdaily-xiaozhushou:latest + container_name: cpdaily-xiaozhushou + restart: unless-stopped + ports: + - "8884:8093" + volumes: + - /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro + networks: + - likedream-net + +networks: + likedream-net: + external: true diff --git a/frondend/.env.development b/frondend/.env.development new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/frondend/.env.development @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/frondend/index.html b/frondend/index.html new file mode 100644 index 0000000..df0cc5b --- /dev/null +++ b/frondend/index.html @@ -0,0 +1,13 @@ + + + + + + + 小助手 + + +
+ + + diff --git a/frondend/package-lock.json b/frondend/package-lock.json new file mode 100644 index 0000000..7b04335 --- /dev/null +++ b/frondend/package-lock.json @@ -0,0 +1,2574 @@ +{ + "name": "tianshen-web-new", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tianshen-web-new", + "version": "1.0.0", + "dependencies": { + "axios": "^1.13.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-vue-next": "^1.0.0", + "qrcode": "^1.5.4", + "tailwind-merge": "^3.5.0", + "vue": "^3.5.30", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^26.0.1", + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^6.0.5", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.5" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", + "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", + "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", + "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "devOptional": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==" + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "dev": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", + "dependencies": { + "@vue/devtools-kit": "^8.1.5" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", + "dependencies": { + "@vue/devtools-shared": "^8.1.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==" + }, + "node_modules/@vue/language-core": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.6.tgz", + "integrity": "sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ] + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "devOptional": true, + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "devOptional": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", + "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==", + "dependencies": { + "@babel/generator": "^8.0.0-rc.4", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.1.2", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34", + "pinia": "^3.0.4", + "vite": "^7.0.0 || ^8.0.0", + "vue": "^3.5.34" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.6.tgz", + "integrity": "sha512-ERXGgbKSBGFUkavrJ1Iwj0ZVxKqB/5UOx65IXy7fPf2UsoI21n3ssQLwdvx8xyUGgJe9PvSZTM/FYzIdwUDPFA==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/frondend/package.json b/frondend/package.json new file mode 100644 index 0000000..6e87e18 --- /dev/null +++ b/frondend/package.json @@ -0,0 +1,31 @@ +{ + "name": "tianshen-web-new", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-vue-next": "^1.0.0", + "qrcode": "^1.5.4", + "tailwind-merge": "^3.5.0", + "vue": "^3.5.30", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^26.0.1", + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^6.0.5", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.5" + } +} diff --git a/frondend/public/vite.svg b/frondend/public/vite.svg new file mode 100644 index 0000000..5192d98 --- /dev/null +++ b/frondend/public/vite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frondend/src/App.vue b/frondend/src/App.vue new file mode 100644 index 0000000..83b3c06 --- /dev/null +++ b/frondend/src/App.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frondend/src/api/admin.ts b/frondend/src/api/admin.ts new file mode 100644 index 0000000..c6c9869 --- /dev/null +++ b/frondend/src/api/admin.ts @@ -0,0 +1,243 @@ +import api from './index' + +// ======== 设置 ======== + +export interface SetupStatus { + installed: boolean +} + +export interface AdminTokenResult { + token: { + accessToken: string + expiresIn: number + tokenType: string + } + agent: AgentInfo +} + +/** 检查是否已安装(是否有admin账号) */ +export function getSetupStatus() { + return api.get<{ data: SetupStatus }>('/admin/setup/status') +} + +/** 初始化创建管理员账号 */ +export function setupInit(params: { username: string; password: string }) { + return api.post<{ data: AdminTokenResult }>('/admin/setup/init', params) +} + +// ======== 管理员认证 ======== + +export interface AdminLoginParams { + username: string + password: string +} + +export interface AgentInfo { + id: number + username: string + role: string + rate: number + balance: number + status: string + parentId?: number | null + subCount?: number + cdkCount?: number + createdAt?: string +} + +export interface AdminLoginResult { + token: { + accessToken: string + expiresIn: number + tokenType: string + } + agent: AgentInfo +} + +/** admin 登录 */ +export function adminLogin(params: AdminLoginParams) { + return api.post<{ data: AdminLoginResult }>('/admin/login', params) +} + +/** 获取当前账号信息 */ +export function getAdminMe() { + return api.get<{ data: AgentInfo }>('/admin/me') +} + +// ======== 代理管理 ======== + +export interface AgentUser { + id: number + username: string + role: string + parentId: number | null + parentUsername?: string + rate: number + balance: number + status: string + createdAt: string + updatedAt: string +} + +export interface CreateAgentParams { + username: string + password: string + rate: number + balance: number +} + +export interface UpdateAgentParams { + balance?: number + rate?: number +} + +/** 获取下级代理列表 */ +export function getSubAgents() { + return api.get<{ data: AgentUser[] }>('/admin/agents') +} + +/** 获取所有代理(admin only) */ +export function getAllAgents() { + return api.get<{ data: AgentUser[] }>('/admin/all-agents') +} + +/** 创建下级代理 */ +export function createSubAgent(params: CreateAgentParams) { + return api.post<{ data: { username: string; role: string; rate: number; balance: number } }>( + '/admin/agents', + params + ) +} + +/** 更新代理(充值/修改费率) */ +export function updateSubAgent(id: number, params: UpdateAgentParams) { + return api.put<{ data: AgentUser }>(`/admin/agents/${id}`, params) +} + +/** 获取代理详情 */ +export function getAgentDetail(id: number) { + return api.get(`/admin/agents/${id}`) +} + +/** 修改代理密码(admin only) */ +export function updateAgentPassword(id: number, password: string) { + return api.put(`/admin/agents/${id}/password`, { password }) +} + +/** 启用/禁用代理(admin only) */ +export function updateAgentStatus(id: number, status: 'active' | 'disabled') { + return api.put(`/admin/agents/${id}/status`, { status }) +} + +// ======== CDK管理 ======== + +export interface CDKCode { + id: number + code: string + price: number + costPrice: number + agentId: number + status: string + userId: number | null + usedAt: string | null + createdAt: string + updatedAt: string +} + +export interface GenerateCDKResult { + count: number + price: number + maxRuns: number + costTotal: number + cdkCodes: CDKCode[] +} + +/** 生成CDK */ +export function generateCDK(count: number = 1, maxRuns: number = 1) { + return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns }) +} + +/** 获取自己生成的CDK列表 */ +export function getMyCDKs() { + return api.get<{ data: CDKCode[] }>('/admin/cdks') +} + +/** 获取所有CDK列表(admin only) */ +export function getAllCDKs() { + return api.get<{ data: CDKCode[] }>('/admin/all-cdks') +} + +// ======== 系统设置(admin only) ======== + +export interface SystemSettings { + [key: string]: string +} + +/** 获取系统设置 */ +export function getSettings() { + return api.get<{ data: SystemSettings }>('/admin/settings') +} + +/** 更新系统设置 */ +export function updateSetting(key: string, value: string) { + return api.put('/admin/settings', { key, value }) +} + +// ======== 公告管理(admin only) ======== + +export interface Announcement { + id: number + title: string + content: string + targetType: 'agent' | 'user' + forcePopup: boolean + loopPopup: boolean + createdBy: number + createdAt: string + updatedAt: string +} + +export interface AnnouncementListItem extends Announcement { + readCount: number + unreadCount: number + totalTargets: number +} + +export interface CreateAnnouncementParams { + title: string + content: string + targetType: 'agent' | 'user' + forcePopup: boolean + loopPopup: boolean +} + +export interface ReadUser { + userId: number + username: string + readAt: string +} + +export interface AnnouncementDetail { + announcement: Announcement + readUsers: ReadUser[] +} + +/** 创建公告 */ +export function createAnnouncement(params: CreateAnnouncementParams) { + return api.post<{ data: Announcement }>('/admin/announcements', params) +} + +/** 获取公告列表(含统计) */ +export function getAnnouncements() { + return api.get<{ data: AnnouncementListItem[] }>('/admin/announcements') +} + +/** 获取公告详情(含已读用户列表) */ +export function getAnnouncementDetail(id: number) { + return api.get<{ data: AnnouncementDetail }>(`/admin/announcements/${id}`) +} + +/** 删除公告 */ +export function deleteAnnouncement(id: number) { + return api.delete(`/admin/announcements/${id}`) +} diff --git a/frondend/src/api/announcement.ts b/frondend/src/api/announcement.ts new file mode 100644 index 0000000..e4a0851 --- /dev/null +++ b/frondend/src/api/announcement.ts @@ -0,0 +1,82 @@ +import api from './index' +import adminApi from './index' + +// ======== 普通用户公告(access_token) ======== + +export interface AnnouncementItem { + id: number + title: string + content: string + targetType: 'agent' | 'user' + forcePopup: boolean + loopPopup: boolean + createdBy: number + createdAt: string + updatedAt: string + isRead: boolean + readAt?: string +} + +export interface UnreadCount { + total: number + read: number + unread: number +} + +/** 获取当前用户的公告列表(含已读状态) */ +export function getUserAnnouncements() { + return api.get<{ data: AnnouncementItem[] }>('/announcements') +} + +/** 获取未读公告数 */ +export function getUnreadCount() { + return api.get<{ data: UnreadCount }>('/announcements/unread-count') +} + +// ======== 公告聚合信息 ======== + +export interface PopupAnnouncement { + id: number + title: string + content: string + forcePopup: boolean + loopPopup: boolean + createdAt: string +} + +export interface AnnouncementInfo { + unreadCount: number + popupQueue: PopupAnnouncement[] +} + +/** 获取当前用户的公告聚合信息(未读数 + 待弹窗队列,由后端计算) */ +export function getMyAnnouncementInfo() { + return api.get<{ data: AnnouncementInfo }>('/announcements/my-info') +} + +/** 代理端获取公告聚合信息 */ +export function getAgentAnnouncementInfo() { + return adminApi.get<{ data: AnnouncementInfo }>('/admin/my-announcements/my-info') +} + +/** 标记公告为已读 */ +export function markAsRead(id: number) { + return api.post<{ data: { alreadyRead: boolean } }>(`/announcements/${id}/read`) +} + +// ======== 代理公告(admin_token) ======== + +/** 获取当前代理的公告列表 */ +export function getAgentAnnouncements() { + return adminApi.get<{ data: AnnouncementItem[] }>('/admin/my-announcements') +} + +/** 获取代理未读公告数 */ +export function getAgentUnreadCount() { + return adminApi.get<{ data: UnreadCount }>('/admin/my-announcements/unread-count') +} + +/** 代理标记公告为已读 */ +export function agentMarkAsRead(id: number) { + return adminApi.post<{ data: { alreadyRead: boolean } }>(`/admin/my-announcements/${id}/read`) +} diff --git a/frondend/src/api/auth.ts b/frondend/src/api/auth.ts new file mode 100644 index 0000000..15feaa8 --- /dev/null +++ b/frondend/src/api/auth.ts @@ -0,0 +1,272 @@ +import api from './index' + +export interface SchoolBrief { + id: string + name: string + namePinYin: string + img: string +} + +export interface CaptchaResult { + captchaId: string + imageBase64: string // base64 +} + +export interface LoginParams { + schoolId: string + username: string + password: string + captchaId: string + captchaCode: string + inviteCode?: string +} + +export interface LoginResult { + token: { + accessToken: string + refreshToken: string + expiresIn: number + } + user: { + id: number + username: string + nickname: string + schoolName: string + studentId: string + } +} + +export interface UserInfo { + id: number + username: string + schoolName: string + studentId: string + createdAt: string + // 以下字段来自 cpdaily 同步 + realName: string + gender: string + avatar: string + academy: string + major: string + className: string + grade: string + mobilePhone: string + orgFullName: string + mobileCode: string + mobileType: string + signCount: number + inviteCode: string + inviteReward: number + invitedCount: number +} + +/** 搜索学校 */ +export function searchSchools(keyword: string) { + return api.get<{ data: SchoolBrief[] }>('/auth/schools', { + params: { keyword }, + }) +} + +/** 获取验证码 */ +export function getCaptcha() { + return api.get<{ data: CaptchaResult }>('/auth/captcha') +} + +/** 登录 */ +export function login(params: LoginParams) { + return api.post<{ data: LoginResult }>('/auth/login', params) +} + +/** 刷新 token */ +export function refreshToken(refreshToken: string) { + return api.post<{ data: { accessToken: string; refreshToken: string } }>( + '/auth/refresh', + { refreshToken } + ) +} + +/** 获取用户信息 */ +export function getUserInfo() { + return api.get<{ data: UserInfo }>('/auth/userinfo') +} + +// ---- 设备绑定 API ---- + +/** 生成设备绑定二维码 */ +export function generateDeviceQr() { + return api.post<{ data: { token: string; expiresIn: number } }>('/device/generate') +} + +/** 查询设备绑定状态(长轮询) */ +export function getDeviceStatus(token: string) { + return api.get<{ data: { status: string; deviceCode?: string } }>('/device/status', { + params: { token }, + }) +} + +/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */ +export function redeemCDK(code: string, captchaId: string, captchaCode: string) { + return api.post<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode }) +} + +// ======== 签到任务相关 ======== + +export interface TaskInfo { + id: number + userId: number + taskType: string + title: string + realTitle: string + signWid: string + signInstanceWid: string + formData: string + taskStatus: string + errorMessage: string + createdAt: string + updatedAt: string + matchStatus: string +} + +export interface TaskTitleItem { + taskName: string + signWid: string + signInstanceWid: string + isSigned: boolean + taskType: string +} + +export interface FormItem { + title: string + colName: string + type: number + content: string + required: boolean + options?: { content: string; isOtherItems: boolean }[] + default?: string + isOther?: boolean + otherValue?: string + number?: number + show: boolean + tip?: string +} + +/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */ +export function getTaskTitles() { + return api.get<{ data: TaskTitleItem[] }>('/task/titles') +} + +/** 创建任务 */ +export function createTask(params: { taskType: string; title: string; realTitle: string; signWid: string; signInstanceWid: string }) { + return api.post<{ data: TaskInfo }>('/task/create', params) +} + +/** 获取任务表单 */ +export function getTaskForm(id: number) { + return api.get<{ data: { formItems: FormItem[]; realTitle: string; isPhoto: boolean } }>(`/task/form/${id}`) +} + +/** 保存任务表单 */ +export function saveTaskForm(id: number, formData: FormItem[]) { + return api.put(`/task/form/${id}`, { formData }) +} + +/** 获取任务列表 */ +export function getTaskList() { + return api.get<{ data: TaskInfo[] }>('/task/list') +} + +/** 删除任务 */ +export function deleteTask(id: number) { + return api.delete(`/task/${id}`) +} + +/** 执行签到(SSE) */ +export function executeTaskSSE(id: number, callbacks: { + onConnecting?: () => void + onFetchingForm?: () => void + onSubmitting?: () => void + onSuccess?: (data: any) => void + onError?: (message: string, data?: any) => void +}) { + const token = localStorage.getItem('access_token') + const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api' + const url = `${baseUrl}/task/execute/${id}?token=${encodeURIComponent(token || '')}` + const es = new EventSource(url) + + es.addEventListener('connecting', () => { + callbacks.onConnecting?.() + }) + es.addEventListener('fetching_form', () => { + callbacks.onFetchingForm?.() + }) + es.addEventListener('submitting', () => { + callbacks.onSubmitting?.() + }) + es.addEventListener('success', (e) => { + const data = JSON.parse(e.data) + callbacks.onSuccess?.(data) + es.close() + }) + let handled = false + es.addEventListener('task_error', (e: MessageEvent) => { + let data: any + try { + data = JSON.parse(e.data) + } catch { + data = { message: e.data || '执行失败' } + } + handled = true + callbacks.onError?.(data.message || '执行失败', data) + es.close() + }) + + es.onerror = () => { + if (handled) return + handled = true + // EventSource 连接失败(HTTP 错误或网络错误),释放执行锁 + callbacks.onError?.('连接失败,请检查网络', { message: '连接失败' }) + es.close() + } + + return es +} + +// ======== 任务执行日志 ======== + +export interface TaskLog { + id: number + cpTaskId: number + userId: number + taskType: string + title: string + realTitle: string + isSuccess: boolean + errorMessage: string + createdAt: string + updatedAt: string +} + +/** 获取任务执行日志 */ +export function getTaskLogs() { + return api.get<{ data: TaskLog[] }>('/task/logs') +} + +/** 删除任务执行日志 */ +export function deleteTaskLog(id: number) { + return api.delete(`/task/logs/${id}`) +} + +/** 获取/生成邀请码 */ +export function getMyInvite() { + return api.post<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/generate-invite') +} + +/** 查询邀请信息 */ +export function getMyInviteInfo() { + return api.get<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/my-invite') +} + +/** 兑换邀请奖励 */ +export function redeemInviteReward() { + return api.post<{ data: { quota: number } }>('/auth/redeem-invite') +} diff --git a/frondend/src/api/index.ts b/frondend/src/api/index.ts new file mode 100644 index 0000000..e9c2bea --- /dev/null +++ b/frondend/src/api/index.ts @@ -0,0 +1,119 @@ +import axios from 'axios' + +// 自定义事件:cpdaily 密码错误,通知组件弹窗 +export const CPD_PASSWORD_ERROR = 'cpd-password-error' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// 请求拦截器:添加 Authorization header +api.interceptors.request.use( + (config) => { + // admin API 使用 admin_token,其他使用 access_token + if (config.url?.startsWith('/admin/')) { + const adminToken = localStorage.getItem('admin_token') + if (adminToken) { + config.headers.Authorization = `Bearer ${adminToken}` + } + } else { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + } + return config + }, + (error) => Promise.reject(error) +) + +// 响应拦截器:处理 token 过期自动刷新 +api.interceptors.response.use( + (response) => { + // 检查响应体中是否包含 cpdaily 密码错误信息 + const data = response.data + if (data && data.code === 401 && typeof data.message === 'string' && data.message.includes('请重新登录')) { + handleCpdailyPasswordError() + } + return response + }, + async (error) => { + const originalRequest = error.config + + // 检查是否是 cpdaily 密码错误(非 SSE 请求) + if (error.response?.status === 401) { + const message = error.response?.data?.message || '' + if (message.includes('请重新登录') || message.includes('登录凭证已过期')) { + handleCpdailyPasswordError() + return Promise.reject(error) + } + } + + // 401 且不是刷新/登录的请求,尝试刷新 token + if ( + error.response?.status === 401 && + !originalRequest._retry && + !originalRequest.url?.includes('/auth/refresh') && + !originalRequest.url?.includes('/auth/login') && + !originalRequest.url?.includes('/report-failed-login') + ) { + originalRequest._retry = true + try { + const refreshToken = localStorage.getItem('refresh_token') + if (!refreshToken) { + throw new Error('no refresh token') + } + + const res = await api.post('/auth/refresh', { + refreshToken, + }) + + const { accessToken, refreshToken: newRefreshToken } = res.data.data + localStorage.setItem('access_token', accessToken) + localStorage.setItem('refresh_token', newRefreshToken) + + originalRequest.headers.Authorization = `Bearer ${accessToken}` + return api(originalRequest) + } catch { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + // 判断是 admin API 则跳 admin 登录页 + if (originalRequest.url?.startsWith('/admin/')) { + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_info') + window.location.href = '/admin/login' + } else { + window.location.href = '/login' + } + return Promise.reject(error) + } + } + + return Promise.reject(error) + } +) + +// handleCpdailyPasswordError 处理 cpdaily 密码错误:清除 token 并通知用户重新登录 +function handleCpdailyPasswordError() { + // 避免重复处理 + if ((window as any).__cpdPasswordErrorHandled) return + ;(window as any).__cpdPasswordErrorHandled = true + + // 清除 token + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + + // 发送自定义事件,让 Vue 组件弹出确认框 + window.dispatchEvent(new CustomEvent(CPD_PASSWORD_ERROR)) + + // 延迟跳转到登录页(给 Dialog 留时间) + setTimeout(() => { + window.location.href = '/login' + }, 500) +} + +export default api diff --git a/frondend/src/api/ticket.ts b/frondend/src/api/ticket.ts new file mode 100644 index 0000000..6c5a8a7 --- /dev/null +++ b/frondend/src/api/ticket.ts @@ -0,0 +1,150 @@ +import api from './index' + +// ======== 类型定义 ======== + +export interface TicketListItem { + id: number + title: string + status: string + messageCount: number + createdAt: string + updatedAt: string +} + +export interface AdminTicketListItem extends TicketListItem { + username: string + realName: string + schoolName: string +} + +export interface TicketMessageRes { + id: number + ticketId: number + senderType: string + senderId: number + content: string + createdAt: string +} + +export interface TicketDetail { + id: number + title: string + content: string + status: string + userId: number + username?: string + realName?: string + schoolName?: string + createdAt: string + updatedAt: string + messages: TicketMessageRes[] +} + +export interface TicketNotificationItem { + id: number + ticketId: number + title: string + content: string + isRead: boolean + createdAt: string +} + +// ======== 用户端 API ======== + +/** 创建工单 */ +export function createTicket(title: string, content: string) { + return api.post<{ data: { id: number } }>('/ticket/create', { title, content }) +} + +/** 获取我的工单列表 */ +export function getMyTickets() { + return api.get<{ data: TicketListItem[] }>('/ticket/list') +} + +/** 获取工单详情 */ +export function getTicketDetail(id: number) { + return api.get<{ data: TicketDetail }>(`/ticket/${id}`) +} + +/** 回复工单 */ +export function replyTicket(ticketId: number, content: string) { + return api.post<{ data: { id: number } }>(`/ticket/${ticketId}/message`, { content }) +} + +/** 关闭工单 */ +export function closeTicket(ticketId: number) { + return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/close`) +} + +/** 删除工单 */ +export function deleteTicket(ticketId: number) { + return api.delete<{ data: { deleted: boolean } }>(`/ticket/${ticketId}`) +} + +/** 获取工单通知列表 */ +export function getTicketNotifications() { + return api.get<{ data: TicketNotificationItem[] }>('/ticket/notifications/list') +} + +/** 获取工单通知未读数 */ +export function getTicketUnreadCount() { + return api.get<{ data: { unread: number } }>('/ticket/notifications/unread-count') +} + +/** 标记工单通知已读 */ +export function markTicketNotificationRead(id: number) { + return api.post<{ data: { success: boolean } }>(`/ticket/notifications/${id}/read`) +} + +/** 报告登录失败(提交待适配学校测试账号) */ +export function reportFailedLogin(params: { + schoolId: string + schoolName: string + username: string + password: string + captchaId: string + captchaCode: string +}) { + return api.post<{ data: { accepted: boolean } }>('/report-failed-login', params) +} + +// ======== 管理端 API ======== + +/** 获取所有工单列表 */ +export function getAdminTicketList(status?: string, keyword?: string) { + const params: Record = {} + if (status) params.status = status + if (keyword) params.keyword = keyword + return api.get<{ data: AdminTicketListItem[] }>('/admin/ticket/list', { params }) +} + +/** 管理员获取工单详情 */ +export function getAdminTicketDetail(id: number) { + return api.get<{ data: TicketDetail }>(`/admin/ticket/${id}`) +} + +/** 管理员回复工单 */ +export function adminReplyTicket(ticketId: number, content: string) { + return api.post<{ data: { id: number } }>(`/admin/ticket/reply/${ticketId}`, { content }) +} + +/** 更新工单状态 */ +export function updateTicketStatus(ticketId: number, status: string) { + return api.put<{ data: { status: string } }>(`/admin/ticket/status/${ticketId}`, { status }) +} + +/** 工单状态映射 */ +export const TICKET_STATUS_MAP: Record = { + pending: '待处理', + processing: '处理中', + resolved: '已解决', + closed: '已关闭', +} + +/** 工单状态颜色映射 */ +export const TICKET_STATUS_COLOR: Record = { + pending: 'bg-amber-100 text-amber-700', + processing: 'bg-blue-100 text-blue-700', + resolved: 'bg-green-100 text-green-700', + closed: 'bg-gray-100 text-gray-500', +} diff --git a/frondend/src/assets/styles/globals.css b/frondend/src/assets/styles/globals.css new file mode 100644 index 0000000..6d2ebe9 --- /dev/null +++ b/frondend/src/assets/styles/globals.css @@ -0,0 +1,59 @@ +@import "tailwindcss"; + +@theme { + --color-background: #f5f5f5; + --color-foreground: #1a1a1a; + --color-card: #ffffff; + --color-card-foreground: #1a1a1a; + --color-primary: #2563eb; + --color-primary-foreground: #ffffff; + --color-secondary: #f3f4f6; + --color-secondary-foreground: #1f2937; + --color-muted: #f3f4f6; + --color-muted-foreground: #6b7280; + --color-accent: #f3f4f6; + --color-accent-foreground: #1f2937; + --color-destructive: #ef4444; + --color-destructive-foreground: #ffffff; + --color-border: #e5e7eb; + --color-input: #e5e7eb; + --color-ring: #2563eb; + --radius: 0.5rem; +} + +* { + border-color: var(--color-border); +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #e8e8e8; + color: var(--color-foreground); +} + +/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */ +#app { + max-width: 430px; + width: 100%; + height: 100vh; + margin: 0 auto; + background-color: #ffffff; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.08); +} + +/* 管理后台模式 — 全屏宽度 */ +#app.admin-mode { + max-width: none; + box-shadow: none; + background-color: transparent; +} diff --git a/frondend/src/components/ui/button.ts b/frondend/src/components/ui/button.ts new file mode 100644 index 0000000..ea9013a --- /dev/null +++ b/frondend/src/components/ui/button.ts @@ -0,0 +1,34 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export { default as Button } from './button.vue' + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export type ButtonVariants = VariantProps diff --git a/frondend/src/components/ui/button.vue b/frondend/src/components/ui/button.vue new file mode 100644 index 0000000..a5669de --- /dev/null +++ b/frondend/src/components/ui/button.vue @@ -0,0 +1,27 @@ + + + diff --git a/frondend/src/components/ui/card.ts b/frondend/src/components/ui/card.ts new file mode 100644 index 0000000..d455855 --- /dev/null +++ b/frondend/src/components/ui/card.ts @@ -0,0 +1,8 @@ +import { cva } from 'class-variance-authority' + +export { default as Card } from './card.vue' + +export const cardVariants = cva('', { + variants: {}, + defaultVariants: {}, +}) diff --git a/frondend/src/components/ui/card.vue b/frondend/src/components/ui/card.vue new file mode 100644 index 0000000..643a6d7 --- /dev/null +++ b/frondend/src/components/ui/card.vue @@ -0,0 +1,15 @@ + + + diff --git a/frondend/src/components/ui/input.ts b/frondend/src/components/ui/input.ts new file mode 100644 index 0000000..e1b1075 --- /dev/null +++ b/frondend/src/components/ui/input.ts @@ -0,0 +1 @@ +export { default as Input } from './input.vue' diff --git a/frondend/src/components/ui/input.vue b/frondend/src/components/ui/input.vue new file mode 100644 index 0000000..b2e1253 --- /dev/null +++ b/frondend/src/components/ui/input.vue @@ -0,0 +1,39 @@ + + + diff --git a/frondend/src/components/ui/label.ts b/frondend/src/components/ui/label.ts new file mode 100644 index 0000000..ce4209a --- /dev/null +++ b/frondend/src/components/ui/label.ts @@ -0,0 +1 @@ +export { default as Label } from './label.vue' diff --git a/frondend/src/components/ui/label.vue b/frondend/src/components/ui/label.vue new file mode 100644 index 0000000..f1868ee --- /dev/null +++ b/frondend/src/components/ui/label.vue @@ -0,0 +1,15 @@ + + + diff --git a/frondend/src/composables/useMessage.ts b/frondend/src/composables/useMessage.ts new file mode 100644 index 0000000..0ec912a --- /dev/null +++ b/frondend/src/composables/useMessage.ts @@ -0,0 +1,35 @@ +import { ref } from 'vue' + +export interface Toast { + id: number + type: 'success' | 'error' | 'info' + message: string +} + +const toasts = ref([]) +let nextId = 0 + +export function useMessage() { + function addToast(type: Toast['type'], message: string, duration = 3000) { + const id = nextId++ + toasts.value.push({ id, type, message }) + setTimeout(() => { + removeToast(id) + }, duration) + } + + function removeToast(id: number) { + const idx = toasts.value.findIndex((t) => t.id === id) + if (idx !== -1) { + toasts.value.splice(idx, 1) + } + } + + return { + toasts, + success: (msg: string) => addToast('success', msg), + error: (msg: string) => addToast('error', msg), + info: (msg: string) => addToast('info', msg), + removeToast, + } +} diff --git a/frondend/src/layouts/MobileLayout.vue b/frondend/src/layouts/MobileLayout.vue new file mode 100644 index 0000000..cec8bf9 --- /dev/null +++ b/frondend/src/layouts/MobileLayout.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/frondend/src/lib/utils.ts b/frondend/src/lib/utils.ts new file mode 100644 index 0000000..d32b0fe --- /dev/null +++ b/frondend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frondend/src/main.ts b/frondend/src/main.ts new file mode 100644 index 0000000..3bf2858 --- /dev/null +++ b/frondend/src/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import './assets/styles/globals.css' + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/frondend/src/router/index.ts b/frondend/src/router/index.ts new file mode 100644 index 0000000..c5a9ca8 --- /dev/null +++ b/frondend/src/router/index.ts @@ -0,0 +1,224 @@ +import { createRouter, createWebHistory } from 'vue-router' +import LoginView from '@/views/login/LoginView.vue' +import HomeView from '@/views/home/HomeView.vue' +import ProfileView from '@/views/profile/ProfileView.vue' +import BindDeviceView from '@/views/bind/BindDeviceView.vue' +import MobileLayout from '@/layouts/MobileLayout.vue' + +import TicketList from '@/views/ticket/TicketList.vue' +import TicketDetail from '@/views/ticket/TicketDetail.vue' + +// Admin views +import AdminSetup from '@/views/admin/AdminSetup.vue' +import AdminLogin from '@/views/admin/AdminLogin.vue' +import AdminLayout from '@/views/admin/AdminLayout.vue' +import AdminDashboard from '@/views/admin/AdminDashboard.vue' +import AdminAgents from '@/views/admin/AdminAgents.vue' +import AdminCdks from '@/views/admin/AdminCdks.vue' +import AdminSettings from '@/views/admin/AdminSettings.vue' +import AdminAnnouncements from '@/views/admin/AdminAnnouncements.vue' +import AdminTicketList from '@/views/admin/AdminTicketList.vue' +import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue' +import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + // ======== 管理后台路由(放在最前面确保优先级)======== + { + path: '/admin/setup', + name: 'admin-setup', + component: AdminSetup, + meta: { admin: true, noAuth: true }, + }, + { + path: '/admin/login', + name: 'admin-login', + component: AdminLogin, + meta: { admin: true, noAuth: true }, + }, + { + path: '/admin', + component: AdminLayout, + meta: { admin: true, requiresAdminAuth: true }, + children: [ + { + path: '', + redirect: { name: 'admin-dashboard' }, + }, + { + path: 'dashboard', + name: 'admin-dashboard', + component: AdminDashboard, + }, + { + path: 'agents', + name: 'admin-agents', + component: AdminAgents, + }, + { + path: 'cdks', + name: 'admin-cdks', + component: AdminCdks, + }, + { + path: 'settings', + name: 'admin-settings', + component: AdminSettings, + }, + { + path: 'announcements', + name: 'admin-announcements', + component: AdminAnnouncements, + }, + { + path: 'tickets', + name: 'admin-tickets', + component: AdminTicketList, + }, + { + path: 'tickets/:id', + name: 'admin-ticket-detail', + component: AdminTicketDetail, + }, + { + path: 'pending-schools', + name: 'admin-pending-schools', + component: AdminPendingSchools, + }, + ], + }, + // ======== 普通用户路由 ======== + { + path: '/', + redirect: '/login', + }, + { + path: '/login', + name: 'login', + component: LoginView, + }, + { + path: '/bind-device', + name: 'bind-device', + component: BindDeviceView, + meta: { requiresAuth: true }, + }, + { + path: '/ticket', + name: 'ticket-list', + component: TicketList, + meta: { requiresAuth: true }, + }, + { + path: '/ticket/:id', + name: 'ticket-detail', + component: TicketDetail, + meta: { requiresAuth: true }, + }, + { + path: '', + component: MobileLayout, + meta: { requiresAuth: true }, + children: [ + { + path: 'home', + name: 'home', + component: HomeView, + }, + { + path: 'profile', + name: 'profile', + component: ProfileView, + }, + ], + }, + ], +}) + +router.beforeEach(async (to, _from) => { + // 调试:记录当前路径 + try { + await fetch(`/api/admin/setup/debug?path=${encodeURIComponent(to.path)}`) + } catch {} + + const token = localStorage.getItem('access_token') + const adminToken = localStorage.getItem('admin_token') + const path = to.path + + // ======== 管理后台路由控制(基于路径前缀,不依赖 meta 继承)======== + if (path.startsWith('/admin')) { + // 把 /admin 精确转发到 /admin/dashboard + if (path === '/admin') { + return '/admin/dashboard' + } + + // admin-setup 和 admin-login 不需要登录 + if (path === '/admin/setup' || path === '/admin/login') { + // 已登录直接进后台 + if (adminToken) { + return '/admin/dashboard' + } + // 检测安装状态做自动跳转 + try { + const res = await fetch('/api/admin/setup/status') + const data = await res.json() + const installed = data?.data?.installed + if (path === '/admin/login' && !installed) { + return '/admin/setup' + } + if (path === '/admin/setup' && installed) { + return '/admin/login' + } + } catch { + // 请求失败留在当前页 + } + return + } + + // 其他 /admin/* 路径需要管理员认证 + if (!adminToken) { + try { + const res = await fetch('/api/admin/setup/status') + const data = await res.json() + if (data?.data?.installed) { + return '/admin/login' + } else { + return '/admin/setup' + } + } catch { + return '/admin/login' + } + } + + // adminToken 存在但可能已过期,尝试验证 + try { + const meRes = await fetch('/api/admin/me', { + headers: { Authorization: `Bearer ${adminToken}` }, + }) + if (!meRes.ok) { + // token 失效,清除并重定向 + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_info') + return '/admin/login' + } + } catch { + // 网络错误,放行让组件自己处理 + } + return + } + + // ======== 普通用户认证 ======== + if (to.meta.requiresAuth && !token) { + return '/login' + } + if (to.name === 'login' && token) { + return '/home' + } + // 已登录用户访问根路径直接跳到 /home,避免路由级 redirect 链导致空白 + if (to.path === '/' && token) { + return '/home' + } +}) + +export default router diff --git a/frondend/src/views/admin/AdminAgents.vue b/frondend/src/views/admin/AdminAgents.vue new file mode 100644 index 0000000..06244b5 --- /dev/null +++ b/frondend/src/views/admin/AdminAgents.vue @@ -0,0 +1,442 @@ + + + diff --git a/frondend/src/views/admin/AdminAnnouncements.vue b/frondend/src/views/admin/AdminAnnouncements.vue new file mode 100644 index 0000000..d6e6fec --- /dev/null +++ b/frondend/src/views/admin/AdminAnnouncements.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frondend/src/views/admin/AdminTicketList.vue b/frondend/src/views/admin/AdminTicketList.vue new file mode 100644 index 0000000..b270d20 --- /dev/null +++ b/frondend/src/views/admin/AdminTicketList.vue @@ -0,0 +1,129 @@ + + + diff --git a/frondend/src/views/bind/BindDeviceView.vue b/frondend/src/views/bind/BindDeviceView.vue new file mode 100644 index 0000000..1d4a15b --- /dev/null +++ b/frondend/src/views/bind/BindDeviceView.vue @@ -0,0 +1,260 @@ + + + diff --git a/frondend/src/views/home/HomeView.vue b/frondend/src/views/home/HomeView.vue new file mode 100644 index 0000000..9511a4c --- /dev/null +++ b/frondend/src/views/home/HomeView.vue @@ -0,0 +1,1333 @@ + + + diff --git a/frondend/src/views/login/LoginView.vue b/frondend/src/views/login/LoginView.vue new file mode 100644 index 0000000..2c4aeb3 --- /dev/null +++ b/frondend/src/views/login/LoginView.vue @@ -0,0 +1,533 @@ + + + diff --git a/frondend/src/views/profile/ProfileView.vue b/frondend/src/views/profile/ProfileView.vue new file mode 100644 index 0000000..d419e64 --- /dev/null +++ b/frondend/src/views/profile/ProfileView.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/frondend/src/views/ticket/TicketDetail.vue b/frondend/src/views/ticket/TicketDetail.vue new file mode 100644 index 0000000..1858357 --- /dev/null +++ b/frondend/src/views/ticket/TicketDetail.vue @@ -0,0 +1,170 @@ + + + diff --git a/frondend/src/views/ticket/TicketList.vue b/frondend/src/views/ticket/TicketList.vue new file mode 100644 index 0000000..e179bd1 --- /dev/null +++ b/frondend/src/views/ticket/TicketList.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frondend/src/vite-env.d.ts b/frondend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frondend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frondend/tsconfig.app.json b/frondend/tsconfig.app.json new file mode 100644 index 0000000..672b68d --- /dev/null +++ b/frondend/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frondend/tsconfig.json b/frondend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frondend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frondend/tsconfig.node.json b/frondend/tsconfig.node.json new file mode 100644 index 0000000..f9e513b --- /dev/null +++ b/frondend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/frondend/vite.config.ts b/frondend/vite.config.ts new file mode 100644 index 0000000..0ce16a9 --- /dev/null +++ b/frondend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 3300, + proxy: { + '/api': { + target: 'http://localhost:8093', + changeOrigin: true, + }, + }, + }, +}) diff --git a/scripts/init-1panel.sh b/scripts/init-1panel.sh new file mode 100644 index 0000000..c520c80 --- /dev/null +++ b/scripts/init-1panel.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# ============================================================ +# cpdaily-xiaozhushou 首次部署初始化脚本 +# 仅需在首次部署时执行一次 +# ============================================================ +set -e + +SITE_NAME="xiaozhushou" +DOMAIN="xiaozhushou.eu.cc" +ACTIVE_PORT=8884 + +echo "=== 1. 创建生产配置目录 ===" +mkdir -p /opt/cpdaily-xiaozhushou + +echo "=== 2. 创建 1Panel 站点配置 ===" +docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/$SITE_NAME/proxy /www/sites/$SITE_NAME/log + +cat <