Compare commits

...

15 Commits

Author SHA1 Message Date
becomer ff3593f057 fix: VIP 展示与复制按钮等 7 项体验修复
ci/woodpecker/tag/build Pipeline was successful
- VIP 徽标:修复 position 覆盖导致徽标挤变形头像,加 z-10 浮于头像上层
- VIP 用户「可签到次数」显示无限次
- VIP 兑换弹窗标题统一「续费 VIP」,去掉提示文案;按钮统一「续费 VIP」
- 新增 utils/clipboard:兼容 HTTP/移动端的复制(Clipboard API + execCommand 回退),
  修复待适配学校密码复制、CDK 结果列表单个复制按钮不生效
- 新手引导新增一步:VIP 会员与普通签到次数的区别
2026-09-08 13:25:13 +08:00
becomer 74422b1848 fix: VIP 卡默认价改为周卡 30 元 / 月卡 100 元(对齐单次扣费 6 元的定价逻辑)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:40:20 +08:00
becomer 84f87cd168 feat: VIP CDK 改为周卡/月卡两档(7天/30天),价格分开配置
ci/woodpecker/tag/build Pipeline was successful
- 后端:vip_price_week(周卡,默认5元)/ vip_price_month(月卡,默认15元),
  VIP CDK 仅支持 7/30 天,vip-info 返回双价格
- 后台:生成弹窗改为周卡/月卡选择,设置页拆分周卡/月卡售价两项
- 前台:开通按钮展示 周¥x · 月¥y 双价格
2026-09-08 12:29:59 +08:00
becomer 386e9269bf fix: VIP 价格设置前端兜底值与后端默认一致(15 元)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:23:31 +08:00
becomer 17a71b1e2a feat: 新增 VIP 功能(CDK 兑换开通、徽标展示、免扣次数、后台管理)
ci/woodpecker/tag/build Pipeline was successful
- 数据库:sys_user.vip_expire_at、cdk_codes type/vip_days、系统设置 vip_price
- 后端:CDK 生成支持 count/vip 类型(VIP 默认 30 天),兑换延长 VIP 到期时间,/cdk/vip-info 查询售价
- 任务执行:VIP 有效期内的用户跳过次数扣减
- 前台:个人中心 VIP 徽标(反光动画)+ 到期时间 + CDK 兑换弹窗
- 后台:CDK 生成弹窗类型选择 + 系统设置 VIP 月卡售价项
2026-09-08 12:19:23 +08:00
becomer 1b4f029602 feat: 后台用户列表增加学校列;待适配学校表格小屏横向滚动
ci/woodpecker/tag/build Pipeline was successful
用户列表在真实姓名后新增学校列(数据接口本已返回 schoolName),
各列宽度相应调整;待适配学校账号表格外层增加 overflow-x-auto
并设置最小宽度,修复手机端表格溢出被裁剪无法查看完整信息的问题。
2026-09-08 07:40:59 +08:00
becomer 2246752724 fix: 引导页手指改指右上角菜单,浏览器打开后自动跳回原页面
ci/woodpecker/tag/build Pipeline was successful
1. 手指图标按业界通用引导样式移至右上角,指向模拟的 ···
   菜单按钮并带点动动画
2. 修复微信右上角"在浏览器打开"仍停留在引导页的问题:
   - 引导页在正常浏览器 UA 下由守卫自动 replace 回 redirect 原地址
   - 放行纯 QQ 浏览器 App(MQQBrowser),避免安卓微信唤起 QQ
     浏览器后再次被拦造成死循环;仍拦微信与手机QQ内置浏览器
2026-09-07 22:16:38 +08:00
becomer a303782ab4 feat: 微信/QQ 内置浏览器访问时跳转浏览器打开引导页
ci/woodpecker/tag/build Pipeline was successful
检测 MicroMessenger/QQ/MQQBrowser UA 时路由守卫优先拦截,
跳转 /open-in-browser 引导页(手指动画指向"用浏览器打开"
按钮),点击复制原地址含全部 query;系统浏览器打开后自动
回到原页面。
2026-09-07 22:07:02 +08:00
becomer 2cac0cdfee fix: 查寝历史位置超出签到范围时自动改用最近签到点提交
ci/woodpecker/tag/build Pipeline was successful
学校老师频繁调整查寝组签到点(小半径500~1000m),历史记录位置
卡在边缘(如距边角修正1仅540m),配置一变即触发"位置不在签到
范围内"。提交前校验历史位置与 signPlaceSelected 各点距离,全部
出界时自动改用最近签到点坐标,位置在范围内则继续沿用历史记录。
2026-09-07 21:51:18 +08:00
becomer 75d334680b feat: 用户新建工单时邮件提醒管理员
ci/woodpecker/tag/build Pipeline was successful
复用待适配学校通知模式: GetAdminEmails + SendMailAsync, 邮件含标题/
用户/学校/内容摘要(HTML转义防注入, 500字截断), 仅新建工单触发。
2026-09-06 18:37:53 +08:00
becomer 35fa5a6560 feat: 后台用户管理展示邀请人
ci/woodpecker/tag/build Pipeline was successful
- sys_user 新增 inviter_id 字段, 注册时记录邀请关系
- ListUsers/GetUserDetail 返回 inviterId/inviterName(姓名优先)
- 前端用户列表加邀请人列, 详情弹窗加邀请人卡片
- 历史数据通过系统公告(邀请奖励到账/新人注册奖励 同刻配对)回填
2026-09-05 22:06:52 +08:00
becomer dbd3c88d32 fix: 修复芜湖职业技术大学等CAS学校登录入口错选问题
ci/woodpecker/tag/build Pipeline was successful
getCasValidLoginUrl 原来按 AmpUrl/AmpUrl2 顺序取第一个可用URL, 部分学校
AmpUrl 是自研门户(ehall), 登录后cookie落在门户域, 与后续签到/查寝任务
接口所在campusphere网关域不匹配, 导致姓名同步失败且任务拉取返回302页面。
芜湖与安徽师范结构同构(网关+AmpUrl2), 修改为先取campusphere/wec网关入口。
2026-09-04 21:33:05 +08:00
becomer 33e29573ca fix: 验证码限流改令牌桶(同IP 5分钟最多10次), 前端显示限流提示不再静默失败
ci/woodpecker/tag/build Pipeline was successful
原 SetNX 固定 10 秒/次:共享出口 IP 下易互相挤占且失败时前端静默,
表现为"验证码不显示且无提示"。改为 Redis Lua 原子令牌桶(容量10、速率
每30s回补1个),429 返回动态等待文案;登录主验证码、失败工单弹窗、
兑换弹窗三处加载失败均展示错误提示并支持点击重试。
2026-09-04 18:46:02 +08:00
becomer 4a80382b60 fix: 全局时间统一北京时间(Asia/Shanghai), 修复记录与展示偏移8小时
ci/woodpecker/tag/build Pipeline was successful
根因: 运行容器默认 UTC 且后端未设时区, time.Now()/gorm 时间戳/DSN
loc=Local/手动 Format 全部按 UTC, 导致展示为"美国时间"、月度与每日
边界错位。修复:
- main.go 在 database.Init 前固定 time.Local=Asia/Shanghai
  (DSN loc=Local 随之一致, 一处生效覆盖全部业务点)
- pending_school.go 今日判断改北京时间当日0点(原 Truncate 按UTC午夜偏移8h)
- Dockerfile/build.yml/compose 加 TZ=Asia/Shanghai 双保险
配套: 存量 datetime 列已 +8h 迁移(备份 /root/cpdaily-backup-pre-tz-*.sql)
2026-09-03 21:06:59 +08:00
becomer 8eaaf5781a fix: 引导强播标记改为一次性消费, 杜绝看完引导后刷新又弹且不上报
ci/woodpecker/tag/build Pipeline was successful
?guide=1 强播标记此前写入 sessionStorage 后在整页加载时只置位不清除,
同一标签页后续普通刷新被残留标记误判为强播: 关闭引导不写库、刷新又弹。
现 MobileLayout 读取标记后立即消费(removeItem), 仅当次加载内强播生效,
刷新后回到数据库判定(未看过 v2 才弹, 关闭即上报写库)。
2026-09-03 16:51:13 +08:00
31 changed files with 1284 additions and 175 deletions
+2
View File
@@ -77,6 +77,7 @@ steps:
docker run -d --name cpdaily-xiaozhushou-new \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_STANDBY:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
@@ -126,6 +127,7 @@ steps:
docker run -d --name cpdaily-xiaozhushou \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_ACTIVE:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
+2
View File
@@ -24,6 +24,8 @@ RUN go build -ldflags="-s -w" -o server ./cmd/server/
# ============================================================
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata
# 时区固定为北京时间(tzdata 已安装;进程层 main.go 另有 time.Local 兜底)
ENV TZ=Asia/Shanghai
COPY --from=backend-builder /build/server /app/server
COPY --from=frontend-builder /build/dist /app/dist
WORKDIR /app
+11
View File
@@ -23,6 +23,16 @@ func main() {
log.Fatalf("加载配置失败: %v", err)
}
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
}
time.Local = loc
// 连接数据库
database.Init()
@@ -79,6 +89,7 @@ func main() {
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
cdk.Post("/redeem", handler.UserRedeemCDK)
cdk.Get("/vip-info", handler.GetUserVipInfo)
// 设备绑定
device := api.Group("/device")
+46 -5
View File
@@ -25,6 +25,8 @@ type SysUserListItem struct {
SignCount int `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
TaskCount int64 `json:"taskCount"`
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
TodayStatus string `json:"todayStatus"` // today: success / fail / none
@@ -33,6 +35,14 @@ type SysUserListItem struct {
LastLoginAt string `json:"lastLoginAt"`
}
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
func inviterDisplay(username, realName string) string {
if realName != "" {
return realName
}
return username
}
// ListUsers 获取用户列表(仅admin)
func ListUsers(c *fiber.Ctx) error {
// 分页
@@ -130,6 +140,26 @@ func ListUsers(c *fiber.Ctx) error {
}
}
// 邀请人信息(本页用户的 inviter_id 去重后一次查询)
inviterNameMap := make(map[uint]string)
if len(users) > 0 {
inviterIDs := make([]uint, 0, len(users))
seen := make(map[uint]bool)
for _, u := range users {
if u.InviterID != nil && !seen[*u.InviterID] {
seen[*u.InviterID] = true
inviterIDs = append(inviterIDs, *u.InviterID)
}
}
if len(inviterIDs) > 0 {
var inviters []model.SysUser
database.DB.Select("id, username, real_name").Where("id IN ?", inviterIDs).Find(&inviters)
for _, iv := range inviters {
inviterNameMap[iv.ID] = inviterDisplay(iv.Username, iv.RealName)
}
}
}
items := make([]SysUserListItem, 0, len(users))
for _, u := range users {
lastLogin := ""
@@ -140,6 +170,10 @@ func ListUsers(c *fiber.Ctx) error {
if todayStatus == "" {
todayStatus = "none"
}
inviterName := ""
if u.InviterID != nil {
inviterName = inviterNameMap[*u.InviterID]
}
items = append(items, SysUserListItem{
ID: u.ID,
Username: u.Username,
@@ -152,6 +186,8 @@ func ListUsers(c *fiber.Ctx) error {
SignCount: u.SignCount,
InviteReward: u.InviteReward,
InvitedCount: u.InvitedCount,
InviterID: u.InviterID,
InviterName: inviterName,
TaskCount: taskCountMap[u.ID],
ExecutedCount: executedMap[u.ID],
TodayStatus: todayStatus,
@@ -197,11 +233,14 @@ func GetUserDetail(c *fiber.Ctx) error {
var taskCount int64
database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount)
// 统计被邀请人(通过邀请码查找)
var invitees []model.SysUser
database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的
// 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段
// 可以通过统计该用户的 invited_count 来知道邀请人数
// 邀请人信息(被谁邀请)
var inviterName string
if user.InviterID != nil {
var inviter model.SysUser
if err := database.DB.Select("id, username, real_name").First(&inviter, *user.InviterID).Error; err == nil {
inviterName = inviterDisplay(inviter.Username, inviter.RealName)
}
}
lastLogin := ""
if user.LastLoginAt != nil {
@@ -228,6 +267,8 @@ func GetUserDetail(c *fiber.Ctx) error {
"signCount": user.SignCount,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"inviterId": user.InviterID,
"inviterName": inviterName,
"status": user.Status,
"disabledReason": user.DisabledReason,
"taskCount": taskCount,
+8 -3
View File
@@ -7,6 +7,7 @@ import (
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
@@ -17,15 +18,16 @@ import (
"gorm.io/gorm"
)
// GetCaptcha 获取图片验证码(同 IP 10 秒内最多生成一次,防刷屏打爆资源)
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
func GetCaptcha(c *fiber.Ctx) error {
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
if !service.AllowCaptchaGen(ip) {
return utils.Fail(c, 429, "验证码获取过于频繁,请 10 秒后重试")
ok, wait := service.AllowCaptchaToken(ip)
if !ok {
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
}
result, err := service.GenerateCaptcha()
if err != nil {
@@ -407,6 +409,9 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return nil
}
// 记录邀请关系(后台用户管理可展示"被谁邀请")
tx.Model(&user).Update("inviter_id", inviter.ID)
// 给邀请人发放奖励积分 + 增加邀请计数
if inviterReward > 0 {
tx.Model(&inviter).Updates(map[string]interface{}{
+151 -26
View File
@@ -19,8 +19,10 @@ import (
// GenerateCDKRequest 生成CDK请求
type GenerateCDKRequest struct {
Count int `json:"count"` // 生成数量,默认1
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
Count int `json:"count"` // 生成数量,默认1
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数(type=count),默认1
Type string `json:"type"` // CDK类型:count=执行次数(默认)/ vip=VIP时长
VipDays int `json:"vipDays"` // VIP天数(type=vip),默认30(一个月)
}
// 获取基础扣费(从系统设置或配置)
@@ -36,6 +38,22 @@ func getBaseDeduction() float64 {
return config.Global.Agent.SystemCdkPrice
}
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
func getVipCardPrice(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
return v
}
}
return def
}
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -44,32 +62,70 @@ func GenerateCDK(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
// CDK 类型校验(默认次数)
if req.Type == "" {
req.Type = "count"
}
if req.Type != "count" && req.Type != "vip" {
return utils.Fail(c, 400, "CDK类型无效,必须为 count 或 vip")
}
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)
var maxRuns int
var vipDays int
var unitPrice float64 // 单张CDK代理扣费
var costPerCDK float64
if req.Type == "vip" {
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
if req.VipDays <= 0 {
req.VipDays = 30
}
if req.VipDays != 7 && req.VipDays != 30 {
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
}
maxRuns = 0
vipDays = req.VipDays
var vipPrice float64
if req.VipDays == 7 {
vipPrice = getVipWeekPrice()
} else {
vipPrice = getVipMonthPrice()
}
unitPrice = vipPrice * agent.Rate
costPerCDK = vipPrice
} else {
// 次数 CDK
if req.MaxRuns <= 0 {
req.MaxRuns = 1
}
if req.MaxRuns > 999 {
return utils.Fail(c, 400, "每张CDK最多999次")
}
maxRuns = req.MaxRuns
vipDays = 0
unitPrice = baseDeduction * float64(req.MaxRuns) * agent.Rate
costPerCDK = baseDeduction * float64(req.MaxRuns)
}
cdkPrice := unitPrice
costTotal := unitPrice * float64(req.Count)
// 事务:扣余额 + 生成CDK
cdkCodes := make([]model.CDKCode, 0, req.Count)
@@ -90,9 +146,11 @@ func GenerateCDK(c *fiber.Ctx) error {
cdk := model.CDKCode{
Code: code,
Price: cdkPrice,
CostPrice: baseDeduction * float64(req.MaxRuns),
CostPrice: costPerCDK,
AgentID: agentID,
MaxRuns: req.MaxRuns,
MaxRuns: maxRuns,
Type: req.Type,
VipDays: vipDays,
Status: "unused",
CreatedAt: now,
UpdatedAt: now,
@@ -311,6 +369,9 @@ func UserRedeemCDK(c *fiber.Ctx) error {
}
// 使用行锁(悲观锁)确保并发安全
var vipExpireAt time.Time
redeemType := cdk.Type
redeemRuns := cdk.MaxRuns
err = database.DB.Transaction(func(tx *gorm.DB) error {
// 重新查询并锁定该行
var lockedCDK model.CDKCode
@@ -320,28 +381,54 @@ func UserRedeemCDK(c *fiber.Ctx) error {
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 lockedCDK.Type == "vip" {
// ===== VIP CDK:延长 VIP 到期时间 =====
if lockedCDK.VipDays <= 0 {
return errors.New("VIP CDK配置无效")
}
// 锁定用户行,防止并发兑换导致到期时间计算错误
var lockedUser model.SysUser
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedUser, userID).Error; err != nil {
return err
}
// 已开通且未过期:在原到期时间上顺延;否则从当前时间起算
base := now
if lockedUser.VipExpireAt != nil && lockedUser.VipExpireAt.After(base) {
base = *lockedUser.VipExpireAt
}
vipExpireAt = base.AddDate(0, 0, lockedCDK.VipDays)
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
Update("vip_expire_at", vipExpireAt).Error; err != nil {
return err
}
} else {
// ===== 次数 CDK:一次性消耗全部次数 =====
if lockedCDK.MaxRuns <= 0 {
return errors.New("CDK码已无可运行次数")
}
redeemRuns = lockedCDK.MaxRuns
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 + ?", redeemRuns)).Error; err != nil {
return err
}
return nil
}
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
})
@@ -352,15 +439,30 @@ func UserRedeemCDK(c *fiber.Ctx) error {
if err.Error() == "CDK码已无可运行次数" {
return utils.Fail(c, 400, "CDK码已无可运行次数")
}
if err.Error() == "VIP CDK配置无效" {
return utils.Fail(c, 400, "VIP CDK配置无效,请联系管理员")
}
slog.Error("兑换CDK失败", "error", err)
return utils.FailServer(c, "兑换失败")
}
// 按 CDK 类型返回不同提示
if redeemType == "vip" {
slog.Info("VIP CDK兑换成功", "userId", userID, "code", req.Code, "vipDays", cdk.VipDays, "newExpireAt", vipExpireAt.Format("2006-01-02 15:04:05"))
return utils.Success(c, fiber.Map{
"message": "VIP兑换成功",
"type": "vip",
"vipDays": cdk.VipDays,
"vipExpireAt": vipExpireAt.Format("2006-01-02 15:04:05"),
})
}
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
return utils.Success(c, fiber.Map{
"message": "兑换成功",
"totalRuns": cdk.MaxRuns,
"type": "count",
"totalRuns": redeemRuns,
})
}
@@ -379,3 +481,26 @@ func generateCDKCode() string {
func formatFloat(f float64) string {
return utils.FormatFloat(f)
}
// GetUserVipInfo 获取当前用户 VIP 信息(到期时间 + 售价,供个人中心展示)
func GetUserVipInfo(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.Select("id, vip_expire_at").First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
vipExpireAt := ""
if user.VipExpireAt != nil {
vipExpireAt = user.VipExpireAt.Format("2006-01-02 15:04:05")
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
return utils.Success(c, fiber.Map{
"isVip": isVip,
"vipExpireAt": vipExpireAt,
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
})
}
+3 -1
View File
@@ -196,7 +196,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
}
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
today := time.Now().Truncate(24 * time.Hour)
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todayLoginCount int64
database.DB.Model(&model.SysUser{}).
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
+15 -7
View File
@@ -34,6 +34,12 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["register_reward"]; !ok {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
return utils.Success(c, settings)
}
@@ -60,15 +66,17 @@ func UpdateSetting(c *fiber.Ctx) error {
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
"register_reward": true,
"vip_price_week": true,
"vip_price_month": true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
"smtp_user": true,
"smtp_password": true,
"smtp_from": true,
"smtp_host": true,
"smtp_port": true,
"smtp_user": true,
"smtp_password": true,
"smtp_from": true,
"smtp_from_name": true,
"smtp_secure": true,
"admin_emails": true,
"smtp_secure": true,
"admin_emails": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
+23 -11
View File
@@ -393,8 +393,11 @@ func ExecuteTask(c *fiber.Ctx) error {
database.DB.Create(&logEntry)
}
// 检查可用次数
if user.SignCount <= 0 {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
@@ -481,6 +484,8 @@ func ExecuteTask(c *fiber.Ctx) error {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
@@ -512,15 +517,22 @@ func ExecuteTask(c *fiber.Ctx) error {
})
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": "签到成功!",
})
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
if !isVip {
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": "签到成功!",
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!(VIP 免扣次数)",
})
}
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title, "isVip", isVip)
})
return nil
@@ -569,7 +581,7 @@ var _ = time.Now
// UpdateTaskRequest 更新任务请求
type UpdateTaskRequest struct {
Title *string `json:"title"`
Title *string `json:"title"`
FormData *[]model.CpTaskFormItem `json:"formData"`
}
+7
View File
@@ -3,6 +3,7 @@ package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"fmt"
@@ -117,6 +118,12 @@ func CreateTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "创建工单失败")
}
// 异步邮件提醒管理员处理新工单
var u model.SysUser
if err := database.DB.Select("id, username, real_name, school_name").First(&u, userID).Error; err == nil {
service.NotifyNewTicket(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, ticket.Content, ticket.CreatedAt.Format("2006-01-02 15:04"))
}
return utils.Success(c, fiber.Map{
"id": ticket.ID,
})
+9 -5
View File
@@ -6,11 +6,15 @@ import "time"
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的代理
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
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的代理
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数(type=count 有效)
// CDK 类型:count=执行次数 / vip=VIP 时长
Type string `gorm:"type:varchar(16);not null;default:count;index" json:"type"`
// VIP 时长天数(type=vip 有效,默认 30 天/一个月)
VipDays int `gorm:"default:0" json:"vipDays"`
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"`
+12 -8
View File
@@ -3,15 +3,15 @@ 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:"-"`
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:"-"`
RawPassword string `gorm:"type:longtext" json:"-"`
// 今日校园真实姓名
RealName string `gorm:"type:varchar(255)" json:"realName"`
@@ -37,8 +37,12 @@ type SysUser struct {
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
// 可签到次数
SignCount int `gorm:"type:int;default:0" json:"signCount"`
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
// 邀请码
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
// 邀请人用户ID(null=非邀请注册)
InviterID *uint `gorm:"type:int;index" json:"inviterId"`
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
// 累计邀请人数
+89 -16
View File
@@ -24,12 +24,54 @@ var (
)
const (
captchaTTL = 5 * time.Minute
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKeyFmt = "captcha:rl:%s" // 生成频率限制
captchaRLWindow = 10 * time.Second // 每 IP 生成间隔
captchaTTL = 5 * time.Minute
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKey = "captcha:rl:%s"
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
captchaBucketCapacity = 10
captchaBucketWindow = 5 * time.Minute
)
// 令牌桶 Lua 脚本(原子):状态存 "剩余tokens:上次补桶毫秒时间戳"
// 返回 {1,0} 放行(已扣 1 个 token);{0, waitMs} 被限流(需等待 waitMs 毫秒后才有新 token)
var captchaTokenScript = redis.NewScript(`
local cap = tonumber(ARGV[1])
local rateMs = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowMs = tonumber(ARGV[4])
local tokens = cap
local last = now
local data = redis.call('GET', KEYS[1])
if data then
local colon = string.find(data, ':')
if colon then
tokens = tonumber(string.sub(data, 1, colon - 1))
last = tonumber(string.sub(data, colon + 1))
if not tokens or not last then tokens = cap end
end
end
local elapsedMs = now - last
if elapsedMs > 0 then
tokens = tokens + elapsedMs * rateMs
end
if tokens > cap then tokens = cap end
if tokens < 1 then
local waitMs = math.ceil((1 - tokens) / rateMs)
if waitMs < 1000 then waitMs = 1000 end
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
return { 0, waitMs }
end
tokens = tokens - 1
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
return { 1, 0 }
`)
// redisCaptchaOp 短超时上下文(不阻塞请求)
func redisCaptchaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 2*time.Second)
@@ -248,23 +290,54 @@ func VerifyCaptcha(captchaID, code string) bool {
return strings.EqualFold(cv.Code, code)
}
// AllowCaptchaGen 验证码生成频率限制:同 IP 在窗口期内最多生成一次
// 返回 true 表示允许生成。Redis 不可用时放行(生成接口的存储兜底同样受限)。
func AllowCaptchaGen(ip string) bool {
if ip == "" {
return true
}
if Redis == nil {
return true
// AllowCaptchaToken 验证码生成令牌桶限流:同 IP 5 分钟内最多获取 10 次。
// 返回 (true, 0) 表示允许生成并已扣减 token;
// 返回 (false, wait) 表示被限流,wait 为最快可再次获取的等待时长(已向上取整到秒)。
// 空 IP 或 Redis 不可用/出错时放行(存储兜底同样受限,避免误伤正常用户)。
func AllowCaptchaToken(ip string) (bool, time.Duration) {
if ip == "" || Redis == nil {
return true, 0
}
ctx, cancel := redisCaptchaCtx()
defer cancel()
ok, err := Redis.SetNX(ctx, fmt.Sprintf(captchaRLKeyFmt, ip), "1", captchaRLWindow).Result()
rateMs := float64(captchaBucketCapacity) / float64(captchaBucketWindow.Milliseconds())
res, err := captchaTokenScript.Run(ctx, Redis,
[]string{fmt.Sprintf(captchaRLKey, ip)},
captchaBucketCapacity,
fmt.Sprintf("%.12f", rateMs),
time.Now().UnixMilli(),
captchaBucketWindow.Milliseconds(),
).Int64Slice()
if err != nil {
slog.Warn("验证码限流 Redis 操作失败", "err", err)
return true
slog.Warn("验证码限流 Redis 操作失败,放行", "err", err)
return true, 0
}
if len(res) != 2 {
return true, 0
}
if res[0] == 1 {
return true, 0
}
// waitMs 向上取整到秒(至少 1 秒)
wait := time.Duration(((res[1]+999)/1000)*1000) * time.Millisecond
if wait < time.Second {
wait = time.Second
}
return false, wait
}
// FormatWait 将等待时长格式化为提示文案片段:如 "5 秒"、"3 分钟"
func FormatWait(d time.Duration) string {
secs := int((d + 999*time.Millisecond) / time.Second)
switch {
case secs < 60:
return fmt.Sprintf("%d 秒", secs)
case secs < 3600:
return fmt.Sprintf("%d 分钟", (secs+59)/60)
default:
return fmt.Sprintf("%d 小时", (secs+3599)/3600)
}
return ok
}
// 画粗线(绘制多条平行线模拟粗线)
@@ -140,6 +140,14 @@ func getCasValidLoginUrl(school *SchoolDetailInfo) string {
return school.IdsUrl
}
}
// 优先选择 campusphere / wec 网关入口:登录后 cookie 落在网关域,
// 后续签到/查寝等任务请求(wec-counselor-* 均在网关域)才能携带有效会话。
// 部分学校 AmpUrl 为学校自研门户(如 ehall),若登录到该域,cookie 与任务接口域不匹配。
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
if u != "" && (strings.Contains(u, "campusphere") || strings.Contains(u, "wec-portal-mobile")) {
return u
}
}
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
if u != "" {
return u
+137 -9
View File
@@ -9,7 +9,9 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net/url"
"strconv"
"strings"
"time"
@@ -49,20 +51,20 @@ type TodayTaskInfo struct {
// 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"`
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).
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
@@ -851,6 +853,132 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
return result
}
// SignPlace 查寝组/签到任务配置的签到范围点
type SignPlace struct {
Address string
Longitude string // 原始字符串,保持精度
Latitude string // 原始字符串,保持精度
LongitudeF float64 // 用于距离计算
LatitudeF float64
RadiusMeters float64
}
// parseSignPlaces 解析任务详情中的 signPlaceSelected(兼容 JSON 字符串与数组两种格式)
func parseSignPlaces(rows map[string]interface{}) []SignPlace {
if rows == nil {
return nil
}
var places []SignPlace
appendPlace := func(m map[string]interface{}) {
lon := mapValueStr(m, "longitude")
lat := mapValueStr(m, "latitude")
if lon == "" || lat == "" {
return
}
lonF, err1 := strconv.ParseFloat(lon, 64)
latF, err2 := strconv.ParseFloat(lat, 64)
if err1 != nil || err2 != nil {
return
}
radius := 0.0
if r, ok := m["radius"]; ok && r != nil {
if f, ok := r.(float64); ok {
radius = f
} else {
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
}
}
if radius <= 0 {
return
}
places = append(places, SignPlace{
Address: mapValueStr(m, "address"),
Longitude: lon,
Latitude: lat,
LongitudeF: lonF,
LatitudeF: latF,
RadiusMeters: radius,
})
}
if sps, ok := rows["signPlaceSelected"]; ok {
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
var arr []map[string]interface{}
if json.Unmarshal([]byte(v), &arr) == nil {
for _, m := range arr {
appendPlace(m)
}
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
appendPlace(m)
}
}
}
}
return places
}
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadius * math.Asin(math.Sqrt(a))
}
// AdjustSubmitFormToSignRange 提交前校验历史位置是否落在查寝组签到范围内,
// 若超出所有签到点范围则自动改用距离最近的签到点坐标(避免服务端拒绝"位置不在签到范围内")。
// 返回 true 表示发生了坐标调整。
func AdjustSubmitFormToSignRange(submitForm map[string]interface{}, detailRows map[string]interface{}) bool {
places := parseSignPlaces(detailRows)
if len(places) == 0 {
return false
}
latStr, _ := submitForm["latitude"].(string)
lonStr, _ := submitForm["longitude"].(string)
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
best := -1
bestDist := math.MaxFloat64
for i, p := range places {
d := haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF)
if d <= p.RadiusMeters {
// 历史位置在某个签到点范围内,正常沿用历史记录
return false
}
if d < bestDist {
bestDist = d
best = i
}
}
if best < 0 {
return false
}
p := places[best]
submitForm["latitude"] = p.Latitude
submitForm["longitude"] = p.Longitude
if p.Address != "" {
submitForm["position"] = p.Address
}
slog.Info("历史位置超出查寝组所有签到范围,自动改用最近签到点",
"origLat", latStr, "origLon", lonStr,
"newLat", p.Latitude, "newLon", p.Longitude,
"place", p.Address, "distM", int(bestDist), "radiusM", int(p.RadiusMeters))
return true
}
// GetBodyString 加密提交表单为 bodyString
func GetBodyString(form map[string]interface{}) string {
data, _ := json.Marshal(form)
@@ -0,0 +1,82 @@
package cpdaily
import (
"testing"
)
// 模拟阜阳职业技术学院查寝组真实返回的签到点配置
func mockSignPlacesRows() map[string]interface{} {
return map[string]interface{}{
"signPlaceSelected": `[{"address":"七渔河东区","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":500},
{"address":"新建的签到地点","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":790},
{"address":"新校区1000米","longitude":"115.77058016419615","latitude":"32.904624876025075","radius":1000},
{"address":"新校区边角修正2","longitude":"115.77471236954204","latitude":"32.9053826914217","radius":500},
{"address":"新校区边角修正1","longitude":"115.76709473881743","latitude":"32.90250295808209","radius":500}]`,
}
}
// 历史位置在"新校区1000米"点范围内(约932m)→ 不调整,沿用历史记录
func TestAdjustInKeepHistory(t *testing.T) {
form := map[string]interface{}{
"latitude": "32.898615",
"longitude": "115.763603",
"position": "七渔河东区",
}
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
if adjusted {
t.Fatalf("历史位置在范围内不应调整, form=%v", form)
}
if form["latitude"] != "32.898615" || form["longitude"] != "115.763603" {
t.Fatalf("范围内历史坐标不应被修改: %v", form)
}
}
// 历史位置远离所有签到点(2km+)→ 调整为最近签到点坐标
func TestAdjustOutOfRange(t *testing.T) {
form := map[string]interface{}{
"latitude": "32.880000",
"longitude": "115.740000",
"position": "旧位置",
}
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
if !adjusted {
t.Fatal("完全出界的历史位置应被调整")
}
if form["longitude"] != "115.76709473881743" || form["latitude"] != "32.90250295808209" {
t.Fatalf("应调整为最近的'边角修正1'坐标, got %v", form)
}
if form["position"] != "新校区边角修正1" {
t.Fatalf("地址应同步为最近签到点: %v", form)
}
}
// 无 signPlaceSelected / 无 radius 时保持不动
func TestAdjustNoPlaces(t *testing.T) {
form := map[string]interface{}{"latitude": "32.88", "longitude": "115.74"}
if AdjustSubmitFormToSignRange(form, map[string]interface{}{}) {
t.Fatal("无签到点配置不应调整")
}
if AdjustSubmitFormToSignRange(form, map[string]interface{}{"signPlaceSelected": "[]"}) {
t.Fatal("空签到点不应调整")
}
}
// 坐标解析异常时保持不动
func TestAdjustBadCoord(t *testing.T) {
form := map[string]interface{}{"latitude": "", "longitude": "abc"}
if AdjustSubmitFormToSignRange(form, mockSignPlacesRows()) {
t.Fatal("坐标异常不应调整")
}
}
// haversine 距离合理性:边角修正1距历史位置约540m,七渔河东区约2.26km
func TestHaversineMeters(t *testing.T) {
d1 := haversineMeters(32.898615, 115.763603, 32.90250295808209, 115.76709473881743)
if d1 < 480 || d1 > 600 {
t.Fatalf("距边角修正1应约540m, got %.0f", d1)
}
d2 := haversineMeters(32.898615, 115.763603, 32.90506452710031, 115.78725271446127)
if d2 < 2000 || d2 > 2500 {
t.Fatalf("距七渔河东区应约2.26km, got %.0f", d2)
}
}
+31
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"html"
"log/slog"
"net"
"net/smtp"
@@ -294,3 +295,33 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
<p style="font-size:14px;color:#555">若仍有问题,欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifyNewTicket 新工单提醒(发给管理员)
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过新工单通知", "ticketId", ticketID)
return
}
// 内容摘要:HTML 转义防注入,换行转 <br>,超长截断
escaped := html.EscapeString(content)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := "【小助手】新的工单:" + title
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的工单反馈</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">标题</td><td><b>` + html.EscapeString(title) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + html.EscapeString(createdAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
+2
View File
@@ -5,6 +5,8 @@ services:
restart: unless-stopped
ports:
- "8884:8093"
environment:
- TZ=Asia/Shanghai
volumes:
- /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro
networks:
+12 -3
View File
@@ -175,9 +175,14 @@ export interface GenerateCDKResult {
cdkCodes: CDKCode[]
}
/** 生成CDK */
export function generateCDK(count: number = 1, maxRuns: number = 1) {
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns })
/** 生成CDK(type: count=次数 / vip=VIP时长,vipDays 默认 30 天) */
export function generateCDK(
count: number = 1,
maxRuns: number = 1,
type: 'count' | 'vip' = 'count',
vipDays: number = 30
) {
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns, type, vipDays })
}
/** 获取自己生成的CDK列表 */
@@ -308,6 +313,8 @@ export interface UserListItem {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
taskCount: number
status: string
createdAt: string
@@ -332,6 +339,8 @@ export interface UserDetail {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
status: string
disabledReason: string
taskCount: number
+18 -2
View File
@@ -56,6 +56,8 @@ export interface UserInfo {
mobileCode: string
mobileType: string
signCount: number
/** VIP 到期时间(null=从未开通) */
vipExpireAt: string | null
inviteCode: string
inviteReward: number
invitedCount: number
@@ -119,9 +121,23 @@ export function getDeviceStatus(token: string) {
})
}
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
return api.post<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode })
return api.post<{
data: {
type: 'count' | 'vip'
totalRuns?: number
vipDays?: number
vipExpireAt?: string
}
}>('/cdk/redeem', { code, captchaId, captchaCode })
}
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
}
// ======== 签到任务相关 ========
+7
View File
@@ -57,6 +57,13 @@ const steps: GuideStep[] = [
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
{
tab: 'home',
title: '新建每日签到任务',
+9 -4
View File
@@ -55,12 +55,13 @@ const showDetail = ref(false)
const detailItem = ref<AnnouncementItem | null>(null)
// 新手引导:是否展示由后端数据库记录判定(用户 guideVersion 未达当前版本即展示,
// 完成/关闭后上报写库,跨设备不再重复打扰)。仅保留会话级测试强播入口。
// 完成/关闭后上报写库,跨设备不再重复打扰)。测试强播入口 ?guide=1 保留:
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
const GUIDE_FORCE_KEY = 'xz_guide_force'
/** 与后端 handler.GuideCompleteVersion 一致 */
const GUIDE_VERSION = 'v2'
const showGuide = ref(false)
// 本次展示是否来自测试强播(?guide=1):强播关闭时不写库,便于反复测试
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
const guideForced = ref(false)
// 邀请奖励规则(供引导第 4 步动态展示;接口失败保持 null,气泡用泛指文案)
const inviterRewardPerInvite = ref<number | null>(null)
@@ -201,6 +202,11 @@ async function handleForcePopupDismiss() {
}
onMounted(async () => {
// 强播标记一次性消费:仅本次整页加载(URL 带 ?guide=1)内优先展示且关闭不写库;
// 读取后立即清除,避免会话残留把后续普通访问(刷新/重开)误判为强播——
// 否则会出现"看完引导后刷新又弹、且永不上报完成"的假象
guideForced.value = sessionStorage.getItem(GUIDE_FORCE_KEY) === '1'
sessionStorage.removeItem(GUIDE_FORCE_KEY)
try {
const res = await authApi.getUserInfo()
const data = res.data.data
@@ -211,8 +217,7 @@ onMounted(async () => {
return
}
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
// 测试强播(?guide=1 会话标记)优先且不写库,可反复重播
guideForced.value = sessionStorage.getItem(GUIDE_FORCE_KEY) === '1'
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
if (guideForced.value || needGuide(data)) {
showGuide.value = true
// 引导气泡中展示邀请奖励数值(第 4 步)
+32
View File
@@ -4,6 +4,7 @@ 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 BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
import TicketList from '@/views/ticket/TicketList.vue'
import TicketDetail from '@/views/ticket/TicketDetail.vue'
@@ -99,6 +100,12 @@ const router = createRouter({
path: '/',
redirect: '/login',
},
// 微信/QQ 内置浏览器引导页:提示复制链接用系统浏览器打开
{
path: '/open-in-browser',
name: 'open-in-browser',
component: BrowserGuideView,
},
{
path: '/login',
name: 'login',
@@ -142,7 +149,32 @@ const router = createRouter({
],
})
// 微信/QQ 系内置浏览器检测:
// - MicroMessenger: 微信内置浏览器
// - QQ/: 手机 QQ 内置浏览器
// 注意:不拦纯 QQ 浏览器 App(MQQBrowser),因为安卓微信右上角
// "在浏览器打开"唤起的就是它,拦截会造成引导页死循环
function isBlockedBrowser(): boolean {
const ua = navigator.userAgent
return /MicroMessenger|QQ\//i.test(ua)
}
router.beforeEach(async (to, _from) => {
// ======== 微信/QQ 浏览器拦截(优先于一切认证逻辑)========
// 引导页自身放行;但若当前已是正常浏览器(如从微信右上角
// "在浏览器打开"跳转过来),自动回到 redirect 指向的原页面
if (to.name === 'open-in-browser') {
if (!isBlockedBrowser()) {
const r = to.query.redirect
const target = typeof r === 'string' && r.startsWith('/') && r !== to.fullPath ? r : '/'
return target
}
return true
}
if (isBlockedBrowser()) {
return { name: 'open-in-browser', query: { redirect: to.fullPath } }
}
const token = localStorage.getItem('access_token')
const adminToken = localStorage.getItem('admin_token')
const path = to.path
+38
View File
@@ -0,0 +1,38 @@
/**
* 复制文本到剪贴板(兼容 HTTP 环境与移动端)
* 优先 Clipboard API(需 HTTPS/localhost),失败回退 execCommand
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) Clipboard API(仅安全上下文可用)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 继续走 fallback
}
}
// 2) 隐藏 textarea + execCommand(HTTP / 老浏览器)
const ta = document.createElement('textarea')
ta.value = text
// 必须可编辑且不在屏幕外,否则 iOS Safari 会复制失败
ta.style.position = 'fixed'
ta.style.top = '0'
ta.style.left = '0'
ta.style.opacity = '0'
ta.setAttribute('readonly', '')
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
document.body.removeChild(ta)
return ok
}
+74 -42
View File
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -49,6 +50,8 @@ const showGenerateModal = ref(false)
const modalView = ref<'form' | 'result'>('form')
const generateCount = ref(1)
const generateMaxRuns = ref(1)
const generateType = ref<'count' | 'vip'>('count')
const generateVipDays = ref(30)
const generateLoading = ref(false)
const resultCdks = ref<adminApi.CDKCode[]>([])
const resultCost = ref(0)
@@ -118,13 +121,23 @@ async function handleGenerate() {
message.info('数量范围为 1-100')
return
}
if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
if (generateType.value === 'vip') {
if (generateVipDays.value !== 7 && generateVipDays.value !== 30) {
message.info('VIP CDK 仅支持周卡(7 天)或月卡(30 天)')
return
}
} else if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
message.info('运行次数范围为 1-999')
return
}
generateLoading.value = true
try {
const res = await adminApi.generateCDK(generateCount.value, generateMaxRuns.value)
const res = await adminApi.generateCDK(
generateCount.value,
generateMaxRuns.value,
generateType.value,
generateVipDays.value
)
const data = res.data?.data
resultCdks.value = data?.cdkCodes || []
resultCost.value = data?.costTotal || 0
@@ -142,6 +155,8 @@ function openGenerateModal() {
modalView.value = 'form'
generateCount.value = 1
generateMaxRuns.value = 1
generateType.value = 'count'
generateVipDays.value = 30
showGenerateModal.value = true
}
@@ -156,57 +171,32 @@ function closeGenerateModal() {
}
async function copyCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
async function copyResultCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
async function copyAllCodes() {
if (resultCdks.value.length === 0) return
const all = resultCdks.value.map((c) => c.code).join('\n')
try {
await navigator.clipboard.writeText(all)
if (await copyToClipboard(all)) {
copiedAll.value = true
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = all
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copiedAll.value = true
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按手动复制')
}
}
@@ -445,16 +435,55 @@ function statusClass(s: string) {
</div>
<div class="space-y-3">
<div>
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
@click="generateType = 'count'"
>次数 CDK</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateType = 'vip'"
>⭐ VIP CDK</button>
</div>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
</div>
<template v-if="generateType === 'count'">
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
</div>
</template>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 7"
>周卡 · 7天</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 30"
>月卡 · 30天</button>
</div>
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
</div>
</template>
</div>
<div class="flex gap-2 mt-6">
@@ -475,7 +504,10 @@ function statusClass(s: string) {
生成成功
</h2>
<p class="text-xs text-gray-400 mt-0.5">
共 {{ resultCdks.length }} 个CDK · 每张 {{ generateMaxRuns }} 次 · 扣费
共 {{ resultCdks.length }} 个CDK ·
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
扣费
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
</p>
</div>
@@ -4,6 +4,7 @@ import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucid
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
interface PendingSchoolItem {
@@ -102,10 +103,13 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
message.success('已复制')
})
} else {
message.error('复制失败,请长按手动复制')
}
}
</script>
@@ -223,8 +227,9 @@ function copyText(text: string) {
</Button>
</div>
<!-- 账号列表 -->
<table class="w-full text-sm">
<!-- 账号列表(小屏横向滚动) -->
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[760px]">
<thead>
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
@@ -304,7 +309,8 @@ function copyText(text: string) {
</td>
</tr>
</tbody>
</table>
</table>
</div>
</div>
</div>
</div>
@@ -13,6 +13,8 @@ const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const baseDeduction = ref('')
const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
@@ -39,6 +41,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
@@ -123,10 +127,22 @@ async function handleSave() {
message.info('直接注册奖励积分请输入有效的非负数')
return
}
const vipWeek = parseFloat(vipWeekPrice.value)
if (isNaN(vipWeek) || vipWeek <= 0) {
message.info('VIP 周卡售价请输入有效的正数')
return
}
const vipMonth = parseFloat(vipMonthPrice.value)
if (isNaN(vipMonth) || vipMonth <= 0) {
message.info('VIP 月卡售价请输入有效的正数')
return
}
saving.value = true
try {
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
await adminApi.updateSetting('vip_price_week', vipWeek.toFixed(2))
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
@@ -168,6 +184,34 @@ async function handleSave() {
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
<Input
v-model="vipWeekPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Input
v-model="vipMonthPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="saving" @click="handleSave">
<Save class="w-4 h-4 mr-1" />
+26 -9
View File
@@ -47,6 +47,8 @@ interface UserItem {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
taskCount: number
executedCount: number
todayStatus: string
@@ -73,6 +75,8 @@ interface UserDetail {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
status: string
disabledReason: string
taskCount: number
@@ -319,17 +323,19 @@ function todayStatusLabel(s: string) {
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1080px]">
<table class="w-full text-sm table-fixed min-w-[1260px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium w-[11%]">学工号</th>
<th class="px-4 py-3 font-medium w-[8%]">真实姓名</th>
<th class="px-4 py-3 font-medium w-[15%]">设备码</th>
<th class="px-4 py-3 font-medium w-[10%]">可执行/已执行</th>
<th class="px-4 py-3 font-medium w-[10%]">积分/邀请数</th>
<th class="px-4 py-3 font-medium w-[10%]">今天执行状态</th>
<th class="px-4 py-3 font-medium w-[12%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[24%]">操作</th>
<th class="px-4 py-3 font-medium w-[9%]">学工号</th>
<th class="px-4 py-3 font-medium w-[6%]">真实姓名</th>
<th class="px-4 py-3 font-medium w-[13%]">学校</th>
<th class="px-4 py-3 font-medium w-[11%]">设备码</th>
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
<th class="px-4 py-3 font-medium w-[8%]">积分/邀请数</th>
<th class="px-4 py-3 font-medium w-[7%]">邀请人</th>
<th class="px-4 py-3 font-medium w-[8%]">今天执行状态</th>
<th class="px-4 py-3 font-medium w-[10%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[20%]">操作</th>
</tr>
</thead>
<tbody>
@@ -340,6 +346,7 @@ function todayStatusLabel(s: string) {
>
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
<td class="px-4 py-3 truncate text-xs text-gray-600" :title="user.schoolName">{{ user.schoolName || '--' }}</td>
<td class="px-4 py-3 truncate font-mono text-xs">
<div class="flex items-center gap-1.5 min-w-0">
<span v-if="user.mobileCode" class="text-gray-700 truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
@@ -364,6 +371,10 @@ function todayStatusLabel(s: string) {
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.invitedCount }}</span>
</td>
<td class="px-4 py-3 truncate">
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
<span v-else class="text-gray-300">--</span>
</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
@@ -523,6 +534,12 @@ function todayStatusLabel(s: string) {
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人
</div>
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Globe, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useMessage } from '@/composables/useMessage'
const route = useRoute()
const message = useMessage()
// 原始地址(含参数),在系统浏览器中打开后由路由守卫自动回到原页面
const targetUrl = computed(() => {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
const path = redirect.startsWith('/') ? redirect : `/${redirect}`
return `${window.location.origin}${path}`
})
function copyLink() {
const text = targetUrl.value
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => message.success('链接已复制,请在浏览器中打开'))
.catch(() => fallbackCopy(text))
return
}
fallbackCopy(text)
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
message.success('链接已复制,请在浏览器中打开')
} catch {
message.error('复制失败,请手动复制地址栏链接')
}
document.body.removeChild(ta)
}
</script>
<template>
<div class="guide-wrap">
<div class="guide-card">
<!-- 右上角:模拟浏览器菜单 + 手指指向(业界通用引导样式)-->
<div class="top-hint" aria-hidden="true">
<div class="hand">👆</div>
<div class="fake-menu">···</div>
</div>
<div class="icon-circle">
<Globe :size="34" :stroke-width="1.8" />
</div>
<h2 class="title">请在浏览器中打开</h2>
<p class="desc">
请点击<b>右上角 ···</b>(或 <b>⋮</b>)<br />
选择「<b>在浏览器中打开</b>」
</p>
<Button class="open-btn" size="lg" @click="copyLink">
<Copy :size="18" />
复制链接去浏览器打开
</Button>
<p class="tip">如果上方按钮无效,也可以手动复制地址栏链接</p>
</div>
</div>
</template>
<style scoped>
.guide-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(160deg, #eef4ff 0%, #f7f9fc 60%, #eefaf3 100%);
}
.guide-card {
position: relative;
width: 100%;
max-width: 360px;
background: #fff;
border-radius: 20px;
padding: 56px 24px 32px;
text-align: center;
box-shadow: 0 12px 40px rgba(31, 58, 122, 0.12);
}
/* 右上角菜单图标 + 手指:手指位于菜单左下方、指向右上角 */
.top-hint {
position: absolute;
top: 16px;
right: 20px;
display: flex;
align-items: flex-end;
}
.fake-menu {
width: 38px;
height: 38px;
border-radius: 10px;
background: #f3f4f6;
border: 1.5px solid #d1d5db;
color: #6b7280;
font-size: 17px;
font-weight: 700;
letter-spacing: 1px;
line-height: 34px;
text-align: center;
}
.hand {
font-size: 30px;
line-height: 1;
margin-right: 6px;
margin-bottom: -2px;
transform: rotate(38deg);
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.18));
animation: hand-tap 1.1s ease-in-out infinite;
transform-origin: 30% 90%;
}
@keyframes hand-tap {
0%,
100% {
transform: rotate(38deg) translate(0, 0);
}
50% {
transform: rotate(38deg) translate(5px, -5px);
}
}
.icon-circle {
width: 68px;
height: 68px;
margin: 0 auto 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
background: #eff6ff;
}
.title {
font-size: 19px;
font-weight: 600;
color: #1f2937;
margin: 0 0 10px;
}
.desc {
font-size: 14px;
line-height: 1.8;
color: #6b7280;
margin: 0 0 24px;
}
.desc b {
color: #1f2937;
font-weight: 600;
}
.open-btn {
width: 100%;
height: 46px;
font-size: 15px;
border-radius: 12px;
}
.tip {
margin: 14px 0 0;
font-size: 12.5px;
color: #9ca3af;
line-height: 1.6;
}
</style>
+26 -2
View File
@@ -27,6 +27,7 @@ const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const loadingCaptcha = ref(false)
const captchaError = ref('')
const logging = ref(false)
@@ -42,6 +43,7 @@ const reportCaptchaImage = ref('')
const reportCaptchaCode = ref('')
const reportLoadingCaptcha = ref(false)
const reportSubmitting = ref(false)
const reportCaptchaError = ref('')
// 联系邮箱:前缀 + 下拉选择国内邮箱后缀
const reportEmailPrefix = ref('')
@@ -112,14 +114,25 @@ function handleBlur() {
}, 200)
}
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
return err?.response?.data?.message || '验证码加载失败,请点击重试'
}
async function loadCaptcha() {
loadingCaptcha.value = true
captchaError.value = ''
try {
const res = await authApi.getCaptcha()
captchaId.value = res.data?.data?.captchaId || ''
captchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
} catch (e) {
captchaId.value = ''
captchaImage.value = ''
const msg = captchaFailMsg(e)
captchaError.value = msg
message.error(msg)
} finally {
loadingCaptcha.value = false
}
@@ -217,12 +230,17 @@ function handleSubmitTicket() {
async function loadReportCaptcha() {
reportLoadingCaptcha.value = true
reportCaptchaError.value = ''
try {
const res = await authApi.getCaptcha()
reportCaptchaId.value = res.data?.data?.captchaId || ''
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
} catch (e) {
reportCaptchaId.value = ''
reportCaptchaImage.value = ''
const msg = captchaFailMsg(e)
reportCaptchaError.value = msg
message.error(msg)
} finally {
reportLoadingCaptcha.value = false
}
@@ -381,6 +399,9 @@ async function handleReportSubmit() {
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
@@ -564,6 +585,9 @@ async function handleReportSubmit() {
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
+166 -16
View File
@@ -56,18 +56,41 @@ const showRedeemModal = ref(false)
const redeemCodeText = ref('')
const redeeming = ref(false)
const redeemError = ref('')
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const captchaError = ref('')
// VIP 到期时间短格式(2026-10-08)
const vipExpireShort = computed(() => {
if (!vipExpireAt.value) return ''
return vipExpireAt.value.slice(0, 10)
})
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
return err?.response?.data?.message || '验证码加载失败,请点击重试'
}
async function refreshCaptcha() {
captchaError.value = ''
try {
const res = await authApi.getCaptcha()
captchaId.value = res.data?.data?.captchaId || ''
captchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
// ignore
} catch (e) {
captchaId.value = ''
captchaImage.value = ''
const msg = captchaFailMsg(e)
captchaError.value = msg
message.error(msg)
}
}
@@ -92,6 +115,14 @@ onMounted(async () => {
} catch {
message.error('获取用户信息失败')
}
// 加载 VIP 信息
try {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
} catch {
// VIP 信息加载失败不阻塞页面
}
})
function handleLogout() {
@@ -105,7 +136,8 @@ function goRebind() {
}
// ---- 兑换弹窗 ----
function openRedeemModal() {
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemKind.value = kind
showRedeemModal.value = true
redeemCodeText.value = ''
redeemError.value = ''
@@ -117,6 +149,17 @@ function closeRedeemModal() {
showRedeemModal.value = false
}
// 刷新 VIP 信息(兑换 VIP CDK 后调用)
async function reloadVipInfo() {
try {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
} catch {
// ignore
}
}
async function handleRedeem() {
if (!redeemCodeText.value.trim()) {
redeemError.value = '请输入兑换码'
@@ -131,12 +174,19 @@ async function handleRedeem() {
redeeming.value = true
try {
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
// 一次性消耗全部剩余次数
const totalRuns = res.data.data.totalRuns
if (userInfo.value) {
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
const data = res.data.data
if (data.type === 'vip') {
// VIP CDK:延长 VIP 到期时间
await reloadVipInfo()
message.success(`VIP 兑换成功!已延长至 ${data.vipExpireAt || ''}`)
} else {
// 次数 CDK:一次性消耗全部剩余次数
const totalRuns = data.totalRuns ?? 0
if (userInfo.value) {
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
}
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
}
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
closeRedeemModal()
} catch (err: any) {
redeemError.value = err.response?.data?.message || '兑换失败'
@@ -209,12 +259,34 @@ async function handleRedeemInvite() {
<div class="flex flex-col min-h-full">
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
<div class="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-hidden ring-4 ring-white/30">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30">
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="mt-3 flex items-center justify-center gap-2">
<span
v-if="isVip"
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
>
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
续费 VIP
</button>
</div>
</div>
<!-- 详情列表 -->
@@ -227,13 +299,13 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">可签到次数</p>
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
</div>
</div>
<button
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
@click="openRedeemModal"
@click="openRedeemModal('count')"
>
<Gift class="w-3 h-3" />
兑换
@@ -392,7 +464,9 @@ async function handleRedeemInvite() {
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">兑换签到次数</h3>
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
</button>
@@ -402,7 +476,7 @@ async function handleRedeemInvite() {
<div class="px-5 pb-5 space-y-4">
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">兑换码</label>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
@@ -428,6 +502,14 @@ async function handleRedeemInvite() {
@click="refreshCaptcha"
title="点击刷新验证码"
/>
<div
v-else-if="captchaError"
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
title="点击重试"
@click="refreshCaptcha"
>
{{ captchaError }}
</div>
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
加载中
</div>
@@ -461,4 +543,72 @@ async function handleRedeemInvite() {
.fade-leave-to {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
.vip-badge {
overflow: hidden;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
font-style: italic;
letter-spacing: 0.5px;
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow: 0 2px 6px rgba(160, 100, 0, 0.35);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
.vip-badge::after {
content: '';
position: absolute;
top: -60%;
left: -45%;
width: 38%;
height: 220%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.85) 50%,
transparent 100%
);
transform: rotate(18deg);
animation: vip-shine 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes vip-shine {
0% {
left: -45%;
}
55%,
100% {
left: 135%;
}
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
.vip-btn {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
}
.vip-btn::after {
content: '';
position: absolute;
top: -60%;
left: -45%;
width: 30%;
height: 220%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.7) 50%,
transparent 100%
);
transform: rotate(18deg);
animation: vip-shine 3s ease-in-out infinite;
pointer-events: none;
}
</style>