Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aab416125 | |||
| 6ad0db6e3a | |||
| dab1004db0 | |||
| 48023f43af | |||
| dcf5b312de | |||
| f28e8e2aa7 | |||
| f32a16f788 | |||
| 14884a0037 | |||
| f3ce1428e5 | |||
| 81ee9b5036 | |||
| d23189f72a | |||
| e6820b3ecb | |||
| c99e878328 | |||
| 048d0d6820 | |||
| 00d1b8ee5c | |||
| d43d3cfb46 | |||
| 9c97fe770d | |||
| 566f460e05 | |||
| 040b1aef18 | |||
| a5bb9843c0 | |||
| 19d8c392c3 | |||
| 93df73a179 | |||
| e7e8b86b42 | |||
| 30baaa6507 | |||
| 64ccac1042 | |||
| e5b57203fb | |||
| a51501909a | |||
| 672214c06e | |||
| 4cc36658c0 | |||
| c75e55c6e7 | |||
| 315942cfce | |||
| fd214d06eb | |||
| 48c3948c56 | |||
| 0d3280fe7b | |||
| 0d639ea485 | |||
| 3b4a8df8fd | |||
| 5a33afa213 | |||
| eaafc50739 | |||
| 44f93a0ae9 | |||
| 8926c0b109 |
+61
-75
@@ -1,6 +1,6 @@
|
||||
when:
|
||||
- event: tag
|
||||
tag: v*
|
||||
ref: refs/tags/v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
@@ -10,7 +10,6 @@ clone:
|
||||
|
||||
steps:
|
||||
build-image:
|
||||
name: 构建 Docker 镜像
|
||||
image: docker
|
||||
commands: |
|
||||
docker build -t cpdaily-xiaozhushou:latest .
|
||||
@@ -19,7 +18,7 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
deploy:
|
||||
name: 蓝绿部署
|
||||
depends_on: [build-image]
|
||||
image: docker
|
||||
commands: |
|
||||
set -e
|
||||
@@ -35,28 +34,27 @@ steps:
|
||||
SLOT_GREEN=8885
|
||||
|
||||
# ============================================================
|
||||
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
|
||||
# 第一步:确定本次要更新的槽位
|
||||
# 本机 OpenResty 暂未配置 cpdaily 站点,没有可供切换的反代入口,
|
||||
# 因此只做「容器级蓝绿」:两个槽保持一旧一新,每次只重建其中一个,
|
||||
# 另一个继续运行上一版本,便于手工回滚。槽位记录在宿主机文件里交替。
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
SLOT_FILE=/opt/cpdaily-xiaozhushou/current-slot
|
||||
if [ -f "$SLOT_FILE" ]; then
|
||||
CURRENT_SLOT=$(cat "$SLOT_FILE")
|
||||
else
|
||||
# 首次:当前 8884 为较新版本,本次更新另一个槽
|
||||
CURRENT_SLOT=$SLOT_GREEN
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
|
||||
if [ "$CURRENT_SLOT" = "$SLOT_BLUE" ]; then
|
||||
PORT_ACTIVE=$SLOT_BLUE
|
||||
PORT_STANDBY=$SLOT_GREEN
|
||||
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
|
||||
else
|
||||
PORT_ACTIVE=$SLOT_GREEN
|
||||
PORT_STANDBY=$SLOT_BLUE
|
||||
else
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
|
||||
exit 1
|
||||
fi
|
||||
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
|
||||
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
|
||||
echo "=== 当前版本槽: $PORT_ACTIVE | 本次更新槽: $PORT_STANDBY ==="
|
||||
|
||||
# ============================================================
|
||||
# 第一步附:一次性「收养」旧命名容器
|
||||
@@ -91,14 +89,14 @@ steps:
|
||||
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
--network 1panel-network \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-p $PORT_STANDBY:8093 \
|
||||
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
|
||||
# 第三步:健康检查(新容器必须就绪,否则直接失败)
|
||||
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
|
||||
# ============================================================
|
||||
HEALTHY=false
|
||||
@@ -113,62 +111,15 @@ steps:
|
||||
done
|
||||
|
||||
if [ "$HEALTHY" = false ]; then
|
||||
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
|
||||
echo "ERROR: 新容器未通过健康检查,中止部署(旧槽 :$PORT_ACTIVE 未受影响)"
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
|
||||
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
|
||||
# 确保旧连接不再按旧配置转发。
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
cp $PROXY_CONF $PROXY_CONF.bak
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
|
||||
|
||||
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
|
||||
echo "ERROR: nginx 配置校验失败,回滚反代配置"
|
||||
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
|
||||
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
|
||||
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成,等待旧 worker 退出..."
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
STILL=0
|
||||
for p in $OLD_WORKERS; do
|
||||
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
|
||||
STILL=$((STILL + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$STILL" = "0" ]; then
|
||||
echo " 旧 worker 已全部退出 ✓"
|
||||
break
|
||||
fi
|
||||
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
|
||||
sleep 2
|
||||
done
|
||||
echo " 反代已指向 :$PORT_STANDBY ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:验证线上确实由新槽服务
|
||||
# ============================================================
|
||||
if ! docker exec 1Panel-openresty-ESmn \
|
||||
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
|
||||
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
|
||||
else
|
||||
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
|
||||
fi
|
||||
# 记录本次更新成功的槽,下次部署自动切到另一个槽
|
||||
echo "$PORT_STANDBY" > "$SLOT_FILE"
|
||||
echo " 已记录当前版本槽: $PORT_STANDBY"
|
||||
|
||||
# ============================================================
|
||||
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
|
||||
@@ -178,12 +129,47 @@ steps:
|
||||
echo "=== 当前容器 ==="
|
||||
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
|
||||
|
||||
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
|
||||
# 清理历史镜像:只保留最近的 5 个版本。
|
||||
# latest 与当前正在部署的 IMAGE_TAG 永不删除 —— deploy-162 步骤与此步骤并行,
|
||||
# 如果误删当前 tag,它会 docker save 失败。因此先排除这两个,再按新旧顺序裁掉尾部。
|
||||
echo "=== 清理历史镜像(保留最近 5 个版本)==="
|
||||
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
|
||||
| grep -v '^latest$' | head -n -2 | while read tag; do
|
||||
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
| grep -v '^latest$' | grep -v "^$IMAGE_TAG\$" \
|
||||
| tail -n +6 | while read tag; do
|
||||
[ -n "$tag" ] && docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
|
||||
echo "=== 部署完成 ✓ 本次更新槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/cpdaily-xiaozhushou:/opt/cpdaily-xiaozhushou
|
||||
|
||||
# ============================================================
|
||||
# 新增:同步部署到 162(跨机蓝绿)
|
||||
# 本机部署步骤保持不变,这里只做「镜像推送 + 远程触发脚本」
|
||||
# ============================================================
|
||||
deploy-162:
|
||||
# 只依赖构建产物,不受上面 deploy 步骤成败影响
|
||||
depends_on: [build-image]
|
||||
when:
|
||||
- status: [success, failure]
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 宿主机 ssh 密钥(config 内含 do-162-14-81-139 别名与 IdentityFile)
|
||||
- /root/.ssh:/root/.ssh:ro
|
||||
commands: |
|
||||
set -e
|
||||
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||
# alpine 官方源在本机不可达,改用国内镜像
|
||||
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
apk add --no-cache openssh-client
|
||||
|
||||
echo "=== 推送镜像 $IMAGE_TAG 到 162 ==="
|
||||
docker save cpdaily-xiaozhushou:$IMAGE_TAG | gzip -1 | \
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
|
||||
"gunzip | docker load"
|
||||
|
||||
echo "=== 触发 162 蓝绿部署 ==="
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
|
||||
"/opt/cpdaily-xiaozhushou/deploy-bluegreen.sh $IMAGE_TAG"
|
||||
|
||||
@@ -96,6 +96,7 @@ func main() {
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
@@ -210,6 +211,8 @@ func main() {
|
||||
ticket.Get("/:id", handler.GetTicketDetail)
|
||||
ticket.Post("/:id/message", handler.AddTicketMessage)
|
||||
ticket.Put("/:id/close", handler.CloseTicket)
|
||||
// 打回:仅 resolved → processing,放在 /:id/close 同层,不与 /:id 冲突
|
||||
ticket.Put("/:id/reopen", handler.UserReopenTicket)
|
||||
ticket.Delete("/:id", handler.DeleteTicket)
|
||||
ticket.Get("/notifications/list", handler.ListTicketNotifications)
|
||||
ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -585,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
|
||||
if code == "" {
|
||||
return utils.FailParam(c, "请输入邀请码")
|
||||
}
|
||||
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
|
||||
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
|
||||
// 用统一文案,不泄露「码存在但格式对」这类信息。
|
||||
if !isValidInviteCodeShape(code) {
|
||||
return utils.Fail(c, 400, "邀请码无效")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
|
||||
@@ -10,16 +10,19 @@ import (
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeLength = 8
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
@@ -137,3 +140,92 @@ func BackfillInviteCodes() {
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
|
||||
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
|
||||
//
|
||||
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
|
||||
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
|
||||
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
|
||||
func resetInviteCode(user *model.SysUser) (string, error) {
|
||||
if user == nil {
|
||||
return "", errors.New("用户不存在")
|
||||
}
|
||||
oldCode := strings.TrimSpace(user.InviteCode)
|
||||
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
|
||||
if oldCode != "" {
|
||||
// 乐观锁:旧码未被他人改动才允许替换
|
||||
q = q.Where("invite_code = ?", oldCode)
|
||||
}
|
||||
res := q.Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.InviteCode = code
|
||||
return code, nil
|
||||
}
|
||||
|
||||
var errInviteCodeChanged = errors.New("invite code changed concurrently")
|
||||
|
||||
// ResetInviteCode 用户主动重置自己的邀请码。
|
||||
//
|
||||
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
|
||||
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
|
||||
func ResetInviteCode(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, "用户不存在")
|
||||
}
|
||||
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
|
||||
if strings.TrimSpace(user.InviteCode) == "" {
|
||||
if err := ensureInviteCode(&user); err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
oldCode := user.InviteCode
|
||||
code, err := resetInviteCode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInviteCodeChanged) {
|
||||
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
|
||||
}
|
||||
slog.Error("重置邀请码失败", "userId", userID, "error", err)
|
||||
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
|
||||
}
|
||||
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
|
||||
return utils.Success(c, fiber.Map{"inviteCode": code})
|
||||
}
|
||||
|
||||
// inviteCodeLegacyLength 旧版邀请码长度。
|
||||
//
|
||||
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
|
||||
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
|
||||
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
|
||||
const inviteCodeLegacyLength = 6
|
||||
|
||||
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
|
||||
//
|
||||
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
|
||||
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
|
||||
// 也避免把用户的任意长字符串当码去查。
|
||||
func isValidInviteCodeShape(code string) bool {
|
||||
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(code); i++ {
|
||||
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -111,3 +111,94 @@ func TestGenerateRandomCode(t *testing.T) {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||||
old, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{old: true}
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("生成重复码 %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||||
if inviteCodeLength != 8 {
|
||||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||||
}
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||||
if dup := n - len(seen); dup > 0 {
|
||||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||||
t.Fatal("两个哨兵错误不应互相匹配")
|
||||
}
|
||||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||||
t.Fatal("errors.Is 自反性失败")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
|
||||
func TestIsValidInviteCodeShape(t *testing.T) {
|
||||
valid, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"正常 8 位", valid, true},
|
||||
{"空串", "", false},
|
||||
{"过短", "ABC", false},
|
||||
{"过长", "ABCDEFGHJ", false},
|
||||
{"含易混淆字符 0", "ABCD0EFG", false},
|
||||
{"含易混淆字符 O", "ABCDOEFG", false},
|
||||
{"含易混淆字符 1", "ABCD1EFG", false},
|
||||
{"含易混淆字符 I", "ABCDIEFG", false},
|
||||
{"含易混淆字符 L", "ABCDLEFG", false},
|
||||
{"含小写", "abcdefgh", false},
|
||||
{"含符号", "ABCD-EFG", false},
|
||||
{"含中文", "邀请码邀请码邀请", false},
|
||||
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
|
||||
{"存量 6 位码", "ABCDEF", true},
|
||||
{"存量 6 位含易混淆字符", "ABCD0F", false},
|
||||
{"5 位", "ABCDE", false},
|
||||
{"7 位", "ABCDEFG", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isValidInviteCodeShape(c.code); got != c.want {
|
||||
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
@@ -194,6 +195,16 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户销假串行执行,避免并发请求重复提交学校端。
|
||||
// 销假是同步阻塞流程,TTL 取 5 分钟已足够覆盖单次执行。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave:%d", userID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("销假正在执行中,拒绝重复提交", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
// 1. 连接成功
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
@@ -341,6 +342,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户请假串行执行,避免并发请求重复提交学校端。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave-apply:%d", userID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("请假正在执行中,拒绝重复提交", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
@@ -479,10 +489,10 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
|
||||
submit := func(cm map[string]string) error {
|
||||
if req.IsExtend {
|
||||
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
|
||||
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, cpd.AndroidVersion, cpd.AppVersion, req.ExtendLeaveID)
|
||||
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, cpd.AndroidVersion, cpd.AppVersion)
|
||||
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
err = submit(cookieMap)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 销假(POST /api/leave/execute)端到端契约测试。
|
||||
//
|
||||
// 覆盖 ExecuteLeave 在真正触达学校端之前的全部前置校验分支:
|
||||
// 参数校验 → 用户存在 → Cookie 登录态 → 设备码 → 学校 flavor 分流。
|
||||
// 这些分支不需要真实 sdau 账号,也不产生任何外网请求。
|
||||
//
|
||||
// 需要测试库(同 INVITE_IT_DSN 约定,请指向一次性库,会建表/清表):
|
||||
//
|
||||
// LEAVE_IT_DSN='root:test@tcp(127.0.0.1:13306)/leavetest?charset=utf8mb4&parseTime=True&loc=Local' \
|
||||
// go test ./internal/handler/ -run ExecuteLeave -count=1 -v
|
||||
//
|
||||
// 未覆盖:扣额度与学校端真实提交(依赖真实 cookie、待销假记录与学校业务校验)。
|
||||
|
||||
const testCookieJSON = `{"MOD_AUTH_CAS":"ST-test"}`
|
||||
|
||||
// setupLeaveIT 用测试库接管 database.DB,并重建 SysUser 表。
|
||||
func setupLeaveIT(t *testing.T) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("LEAVE_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 LEAVE_IT_DSN,跳过销假数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = prev })
|
||||
}
|
||||
|
||||
// newLeaveApp 构造只挂 ExecuteLeave 的最小 app,模拟 AuthRequired 已放行并写入 userId。
|
||||
func newLeaveApp(userID uint) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/leave/execute", func(c *fiber.Ctx) error {
|
||||
c.Locals("userId", userID)
|
||||
return ExecuteLeave(c)
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
// doLeave 发起一次销假请求,返回状态码与响应体。
|
||||
func doLeave(t *testing.T, app *fiber.App, body string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/api/leave/execute", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取响应失败: %v", err)
|
||||
}
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
// createLeaveUser 建一个带 Cookie / 设备码 / 学校的用户(默认 0 次、非 VIP)。
|
||||
func createLeaveUser(t *testing.T, username, schoolID, cookie, mobileCode string) model.SysUser {
|
||||
t.Helper()
|
||||
return createLeaveUserFull(t, username, schoolID, cookie, mobileCode, 0, nil)
|
||||
}
|
||||
|
||||
// createLeaveUserFull 额外指定可执行次数与 VIP 到期时间(nil 表示非 VIP)。
|
||||
func createLeaveUserFull(t *testing.T, username, schoolID, cookie, mobileCode string, signCount float64, vipExpire *time.Time) model.SysUser {
|
||||
t.Helper()
|
||||
u := model.SysUser{
|
||||
Username: username,
|
||||
Password: "x",
|
||||
SchoolID: schoolID,
|
||||
SchoolName: "测试学校",
|
||||
StudentID: username,
|
||||
Cookie: cookie,
|
||||
MobileCode: mobileCode,
|
||||
SignCount: signCount,
|
||||
VipExpireAt: vipExpire,
|
||||
}
|
||||
if err := database.DB.Create(&u).Error; err != nil {
|
||||
t.Fatalf("插入用户失败: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// setDeduct 写入销假扣减额度系统设置。
|
||||
func setDeduct(t *testing.T, reportValue string) {
|
||||
t.Helper()
|
||||
if err := database.DB.AutoMigrate(&model.SystemConfig{}); err != nil {
|
||||
t.Fatalf("建 SystemConfig 表失败: %v", err)
|
||||
}
|
||||
cfg := model.SystemConfig{Key: settingDeductReport, Value: reportValue}
|
||||
if err := database.DB.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("写入扣减额度失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺 LeaveID / 经纬度 时必须 400 且不查库、不发外部请求。
|
||||
func TestExecuteLeaveRejectsMissingParams(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p1", "ynu", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"全空", `{}`, "销假经纬度不能为空"},
|
||||
{"缺经纬度", `{"leaveId":"L1"}`, "销假经纬度不能为空"},
|
||||
{"缺经度", `{"leaveId":"L1","latitude":"25.0"}`, "销假经纬度不能为空"},
|
||||
{"缺纬度", `{"leaveId":"L1","longitude":"102.7"}`, "销假经纬度不能为空"},
|
||||
{"只有空白字符", `{"leaveId":" ","longitude":" ","latitude":" "}`, "销假经纬度不能为空"},
|
||||
{"非法 JSON", `{`, "请求参数错误"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, body := doLeave(t, app, c.body)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, c.want) {
|
||||
t.Fatalf("响应缺少 %q: %s", c.want, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 用户不存在时必须 401,而不是 500。
|
||||
func TestExecuteLeaveRejectsUnknownUser(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
app := newLeaveApp(999999)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 401 {
|
||||
t.Fatalf("状态码 = %d, want 401; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "用户不存在") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie 为空时必须提示重新登录,不得继续走学校端。
|
||||
func TestExecuteLeaveRejectsEmptyCookie(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p2", "ynu", "", "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "请先登录") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 设备码为空时必须拦截,避免学校端风控异常。
|
||||
func TestExecuteLeaveRejectsEmptyMobileCode(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p3", "ynu", testCookieJSON, "")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "绑定设备码") {
|
||||
t.Fatalf("响应缺少提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// sdau 属于独立学工系统:销假必须分流到学工系统,不得落到辅导猫分支。
|
||||
// 这是 sdau 销假最关键的分流契约——走错分支会让用户看到辅导猫的 420「产品已到期」。
|
||||
//
|
||||
// 学工销假同样是 SSE 流:先发 connecting,会话失败再发 task_error。
|
||||
// 测试环境无真实学校网络,故断言「流内出现学工系统文案且不出现辅导猫」。
|
||||
func TestExecuteLeaveRoutesXsgzSchoolAwayFromCounselor(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p4", "sdau", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
|
||||
if code != 200 {
|
||||
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("应发出 connecting 事件: %s", body)
|
||||
}
|
||||
// 没有真实 cookie,会话必失败 → 必须收到 task_error 且文案指向学工系统。
|
||||
if !strings.Contains(body, "event: task_error") {
|
||||
t.Fatalf("无有效会话时应发 task_error: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "学工系统") {
|
||||
t.Fatalf("错误文案应指向学工系统而非辅导猫: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "辅导猫") {
|
||||
t.Fatalf("sdau 不应走辅导猫分支: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 校验顺序:参数错误必须优先于查库,避免无效请求打到数据库。
|
||||
func TestExecuteLeaveParamCheckedBeforeDatabase(t *testing.T) {
|
||||
// 不建表:若先查库会因表不存在而 500。
|
||||
dsn := os.Getenv("LEAVE_IT_DSN_NO_MIGRATE")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 LEAVE_IT_DSN_NO_MIGRATE,跳过校验顺序测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
_ = db.Migrator().DropTable(&model.SysUser{})
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() {
|
||||
database.DB = prev
|
||||
_ = db.Migrator().CreateTable(&model.SysUser{})
|
||||
})
|
||||
|
||||
app := newLeaveApp(1)
|
||||
code, body := doLeave(t, app, `{}`)
|
||||
if code != 400 {
|
||||
t.Fatalf("参数校验应先于查库,状态码 = %d, want 400; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "销假经纬度不能为空") {
|
||||
t.Fatalf("响应缺少参数校验提示: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 响应必须是统一 JSON 结构(code/message),便于前端统一处理。
|
||||
func TestExecuteLeaveErrorResponseShape(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p5", "ynu", testCookieJSON, "")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
_, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
|
||||
}
|
||||
if resp.Code != 400 || resp.Message == "" {
|
||||
t.Fatalf("响应结构不符合契约: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 VIP 且次数不足时必须拒绝,且不得触达学校端。
|
||||
// 销假默认扣 1 次,用户 0 次 → 流内应发 task_error「可用次数不足」。
|
||||
func TestExecuteLeaveRejectsInsufficientSignCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
u := createLeaveUser(t, "p6", "ynu", testCookieJSON, "dev-1") // 0 次、非 VIP
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "event: task_error") {
|
||||
t.Fatalf("次数不足应发 task_error: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("错误文案应提示次数不足: %s", body)
|
||||
}
|
||||
// 不得进入学校端连接阶段
|
||||
if strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("次数不足时不应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 额度为 0 时(后台可配)即使 0 次也应放行,验证「0 = 不扣费」语义。
|
||||
func TestExecuteLeaveZeroDeductAllowsZeroCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
setDeduct(t, "0")
|
||||
u := createLeaveUser(t, "p7", "ynu", testCookieJSON, "dev-1")
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("额度 0 时不应拦截: %s", body)
|
||||
}
|
||||
// 0 次但额度 0 → 放行,进入连接学校端阶段
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("额度 0 时应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// VIP(未过期)免扣次数:0 次也应放行。
|
||||
func TestExecuteLeaveVipBypassesSignCount(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
u := createLeaveUserFull(t, "p8", "ynu", testCookieJSON, "dev-1", 0, &future)
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("VIP 应免扣次数: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: connecting") {
|
||||
t.Fatalf("VIP 应继续连接学校端: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// VIP 已过期则不免扣:0 次时仍应被拦截。
|
||||
func TestExecuteLeaveExpiredVipStillDeducts(t *testing.T) {
|
||||
setupLeaveIT(t)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
u := createLeaveUserFull(t, "p9", "ynu", testCookieJSON, "dev-1", 0, &past)
|
||||
app := newLeaveApp(u.ID)
|
||||
|
||||
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "可用次数不足") {
|
||||
t.Fatalf("VIP 过期应照常扣次数: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -48,14 +48,27 @@ func SmsSend(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
// 手机号维度 60 秒冷却:今日校园对同一号码有服务端频率限制,超频时接口
|
||||
// 仍返回 errCode=0 但短信不会下发(前端显示成功、手机收不到),故本地拦截。
|
||||
if ok, wait := service.AllowSmsCodeSend(phone); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
// 上游发送失败不占用冷却,允许用户立即重试
|
||||
service.ReleaseSmsCodeCooldown(phone)
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
countdown := result.Countdown
|
||||
if countdown < service.SmsCodeCooldownSeconds {
|
||||
countdown = service.SmsCodeCooldownSeconds
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"countdown": countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
@@ -647,6 +648,18 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户同一任务串行执行。
|
||||
// 限流只约束频率,挡不住两个并发请求同时通过限流后向学校端重复提交
|
||||
// (多实例部署时尤其明显,各实例各自放行)。这里用跨实例互斥锁覆盖整个执行流程。
|
||||
// TTL 须大于最长执行耗时(含人脸复核轮询),否则锁提前过期会失去互斥意义。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:task:%d:%d", userID, task.ID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("同一任务正在执行中,拒绝重复执行", "userId", userID, "taskId", id)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
// 所有前置检查通过,更新状态为执行中
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/lock"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
@@ -87,6 +88,15 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
return
|
||||
}
|
||||
|
||||
// 分布式锁:同一用户同一返校登记任务串行执行,避免并发重复提交。
|
||||
execLock, locked := lock.TryLock(fmt.Sprintf("exec:return:%d:%d", userID, task.ID), 5*time.Minute)
|
||||
if !locked {
|
||||
slog.Warn("返校登记正在执行中,拒绝重复提交", "userId", userID, "taskId", task.ID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
|
||||
return
|
||||
}
|
||||
defer execLock.Unlock()
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
"error_message": "",
|
||||
|
||||
@@ -104,13 +104,18 @@ func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
return
|
||||
}
|
||||
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
|
||||
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
|
||||
place, matched := cpd.WorklogMatchedScope(*f, lat, lon)
|
||||
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !matched {
|
||||
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, "", lon, lat); serr != nil {
|
||||
// signPlace 必须是命中的签到点名称(学校配置的 address),空串会导致学校端
|
||||
// 校验「信息收集信息」不通过并返回 2210010000。
|
||||
signPlace := place.Address
|
||||
// deviceId 用 MobileCode、username 用 StudentID,与普通签到提交保持一致
|
||||
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, signPlace, lon, lat, user.MobileCode, user.StudentID); serr != nil {
|
||||
msg := "签到失败: " + serr.Error()
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
|
||||
@@ -37,6 +37,8 @@ type TicketListItem struct {
|
||||
MessageCount int64 `json:"messageCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
// 管理员最近一次操作后用户是否已读;false 时用户端列表显示未读红点
|
||||
UserRead bool `json:"userRead"`
|
||||
}
|
||||
|
||||
type AdminTicketListItem struct {
|
||||
@@ -46,6 +48,10 @@ type AdminTicketListItem struct {
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
// 管理员最近一次操作后用户是否已读;false 表示用户还没看这条回复/状态变更
|
||||
UserRead bool `json:"userRead"`
|
||||
// 用户最近一次回复后管理员是否已读;false 表示后台该工单显示未读红点
|
||||
AdminRead bool `json:"adminRead"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -57,6 +63,10 @@ type TicketDetail struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Messages []TicketMessageRes `json:"messages"`
|
||||
// 管理员最近一次操作(回复/状态变更)后,用户是否已读
|
||||
UserRead bool `json:"userRead"`
|
||||
// 用户最近一次回复后,管理员是否已读
|
||||
AdminRead bool `json:"adminRead"`
|
||||
}
|
||||
|
||||
type TicketMessageRes struct {
|
||||
@@ -161,6 +171,7 @@ func ListMyTickets(c *fiber.Ctx) error {
|
||||
MessageCount: count,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04"),
|
||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
UserRead: t.UserRead,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,6 +194,13 @@ func GetTicketDetail(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 用户打开工单即视为已读:清掉工单级未读红点。
|
||||
// 逐条通知的 is_read 由通知列表单独维护,两者互不影响。
|
||||
if !ticket.UserRead {
|
||||
database.DB.Model(&ticket).Update("user_read", true)
|
||||
ticket.UserRead = true
|
||||
}
|
||||
|
||||
var messages []model.TicketMessage
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||
|
||||
@@ -207,9 +225,46 @@ func GetTicketDetail(c *fiber.Ctx) error {
|
||||
CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
Messages: msgRes,
|
||||
UserRead: ticket.UserRead,
|
||||
AdminRead: ticket.AdminRead,
|
||||
})
|
||||
}
|
||||
|
||||
// UserReopenTicket 用户把「已完成」工单打回「处理中」。
|
||||
// 仅 resolved 可打回:closed 是管理员终结状态,pending 本就是待处理。
|
||||
// 打回即视为用户表达「问题没解决」,因此管理员侧置为未读。
|
||||
func UserReopenTicket(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, "工单已关闭,无法打回")
|
||||
}
|
||||
if ticket.Status != "resolved" {
|
||||
return utils.Fail(c, 400, "只有已完成的工单可以打回")
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&ticket).Updates(map[string]interface{}{
|
||||
"status": "processing",
|
||||
"admin_read": false,
|
||||
}).Error; err != nil {
|
||||
return utils.FailServer(c, "打回失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"status": "processing"})
|
||||
}
|
||||
|
||||
// AddTicketMessage 用户回复工单
|
||||
func AddTicketMessage(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -250,10 +305,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "回复失败")
|
||||
}
|
||||
|
||||
// 如果工单是已解决状态,回复后重新开启
|
||||
// 用户回复后:管理员侧重新变为未读(后台列表显示红点)。
|
||||
// 同时把「已完成」打回「处理中」——用户回复即表示问题未真正解决,需管理员继续跟进。
|
||||
// pending/processing 状态保持不变;closed 已在上方拦截。
|
||||
updates := map[string]interface{}{"admin_read": false}
|
||||
if ticket.Status == "resolved" {
|
||||
database.DB.Model(&ticket).Update("status", "pending")
|
||||
updates["status"] = "processing"
|
||||
}
|
||||
database.DB.Model(&ticket).Updates(updates)
|
||||
|
||||
// 异步邮件提醒管理员:工单对话有新回复
|
||||
go func() {
|
||||
@@ -436,6 +495,8 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
UserRead: r.UserRead,
|
||||
AdminRead: r.AdminRead,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -462,6 +523,13 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
var user model.SysUser
|
||||
database.DB.Select("username, real_name, school_id").First(&user, ticket.UserID)
|
||||
|
||||
// 管理员打开工单即视为已读:清掉后台「用户有新回复」红点。
|
||||
// 用户的未读(user_read)不受影响——管理员看没看,不应替用户决定。
|
||||
if !ticket.AdminRead {
|
||||
database.DB.Unscoped().Model(&model.Ticket{}).Where("id = ?", ticket.ID).Update("admin_read", true)
|
||||
ticket.AdminRead = true
|
||||
}
|
||||
|
||||
// 获取消息
|
||||
var messages []model.TicketMessage
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||
@@ -490,6 +558,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
"deleted": ticket.DeletedAt.Valid,
|
||||
"userRead": ticket.UserRead,
|
||||
"adminRead": ticket.AdminRead,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -533,10 +603,14 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "回复失败")
|
||||
}
|
||||
|
||||
// 管理员回复后自动将状态改为处理中
|
||||
// 管理员回复后:对用户置为未读(用户需要看到这条回复),
|
||||
// 并把待处理推进到处理中。admin_read 置 true——这是管理员自己的动作,
|
||||
// 后台不应因此把自己标成未读。
|
||||
adminUpdates := map[string]interface{}{"user_read": false, "admin_read": true}
|
||||
if ticket.Status == "pending" {
|
||||
database.DB.Model(&ticket).Update("status", "processing")
|
||||
adminUpdates["status"] = "processing"
|
||||
}
|
||||
database.DB.Model(&ticket).Updates(adminUpdates)
|
||||
|
||||
// 发送通知给用户
|
||||
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
|
||||
@@ -579,7 +653,13 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
database.DB.Model(&ticket).Update("status", req.Status)
|
||||
// 管理员改状态:对用户置为未读(用户需要知道状态变了)。
|
||||
// admin_read 置 true——这是管理员自己的操作,后台不显示未读。
|
||||
database.DB.Model(&ticket).Updates(map[string]interface{}{
|
||||
"status": req.Status,
|
||||
"user_read": false,
|
||||
"admin_read": true,
|
||||
})
|
||||
|
||||
// 发送通知给用户
|
||||
statusLabels := map[string]string{
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 工单双向未读契约测试。
|
||||
//
|
||||
// 覆盖三个方向的状态迁移:
|
||||
// 管理员回复/改状态 → 用户侧未读(user_read=false),管理员侧已读(admin_read=true)
|
||||
// 用户回复 → 管理员侧未读(admin_read=false)
|
||||
// 打开详情 → 对应一侧置为已读
|
||||
// 以及用户打回的边界(仅 resolved → processing)。
|
||||
//
|
||||
// 需要测试库(同 LEAVE_IT_DSN 约定,指向一次性库,会建表/清表):
|
||||
//
|
||||
// TICKET_IT_DSN='root:test@tcp(127.0.0.1:13306)/tickettest?charset=utf8mb4&parseTime=True&loc=Local' \
|
||||
// go test ./internal/handler/ -run Ticket -count=1 -v
|
||||
|
||||
func setupTicketIT(t *testing.T) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TICKET_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 TICKET_IT_DSN,跳过工单数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
for _, m := range []interface{}{&model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}} {
|
||||
if err := db.Migrator().DropTable(m); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
prev := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = prev })
|
||||
}
|
||||
|
||||
// newTicketApp 构造最小 app,按需注入 userId / adminId。
|
||||
func newTicketApp(userID, adminID uint) *fiber.App {
|
||||
app := fiber.New()
|
||||
withUser := func(c *fiber.Ctx) error {
|
||||
c.Locals("userId", userID)
|
||||
return c.Next()
|
||||
}
|
||||
withAdmin := func(c *fiber.Ctx) error {
|
||||
c.Locals("adminId", adminID)
|
||||
return c.Next()
|
||||
}
|
||||
app.Get("/ticket/:id", withUser, GetTicketDetail)
|
||||
app.Post("/ticket/:id/message", withUser, AddTicketMessage)
|
||||
app.Put("/ticket/:id/reopen", withUser, UserReopenTicket)
|
||||
app.Get("/admin/ticket/:id", withAdmin, AdminGetTicket)
|
||||
app.Post("/admin/ticket/reply/:id", withAdmin, AdminReplyTicket)
|
||||
app.Put("/admin/ticket/status/:id", withAdmin, AdminUpdateTicketStatus)
|
||||
return app
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func createTicket(t *testing.T, userID uint, status string) model.Ticket {
|
||||
t.Helper()
|
||||
tk := model.Ticket{
|
||||
UserID: userID,
|
||||
Title: "测试工单",
|
||||
Content: "内容",
|
||||
Status: status,
|
||||
UserRead: true,
|
||||
AdminRead: true,
|
||||
}
|
||||
if err := database.DB.Create(&tk).Error; err != nil {
|
||||
t.Fatalf("建工单失败: %v", err)
|
||||
}
|
||||
return tk
|
||||
}
|
||||
|
||||
// reloadTicket 从库里重读工单,确认落库结果。
|
||||
func reloadTicket(t *testing.T, id uint) model.Ticket {
|
||||
t.Helper()
|
||||
var tk model.Ticket
|
||||
if err := database.DB.Unscoped().First(&tk, id).Error; err != nil {
|
||||
t.Fatalf("重读工单失败: %v", err)
|
||||
}
|
||||
return tk
|
||||
}
|
||||
|
||||
// 管理员回复:用户侧变未读,管理员侧保持已读。
|
||||
func TestTicketAdminReplyMarksUserUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/admin/ticket/reply/"+itoa(tk.ID), `{"content":"已处理,请查收"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.UserRead {
|
||||
t.Fatalf("管理员回复后用户侧应为未读")
|
||||
}
|
||||
if !got.AdminRead {
|
||||
t.Fatalf("管理员自己的回复不应把后台标为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员改状态:用户侧变未读,管理员侧已读。
|
||||
func TestTicketAdminStatusChangeMarksUserUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/admin/ticket/status/"+itoa(tk.ID), `{"status":"resolved"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("改状态失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.UserRead {
|
||||
t.Fatalf("状态变更后用户侧应为未读")
|
||||
}
|
||||
if !got.AdminRead {
|
||||
t.Fatalf("管理员自己的操作不应把后台标为未读")
|
||||
}
|
||||
if got.Status != "resolved" {
|
||||
t.Fatalf("状态应为 resolved,实际 %s", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户回复:管理员侧变未读;user_read 不受影响(管理员没做新动作)。
|
||||
func TestTicketUserReplyMarksAdminUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"还是不行"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.AdminRead {
|
||||
t.Fatalf("用户回复后管理员侧应为未读")
|
||||
}
|
||||
if !got.UserRead {
|
||||
t.Fatalf("用户自己的回复不应把用户侧标为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 用户对已完成工单回复:自动打回处理中 + 管理员未读。
|
||||
func TestTicketUserReplyReopensResolved(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "resolved")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"问题还在"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("回复失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
got := reloadTicket(t, tk.ID)
|
||||
if got.Status != "processing" {
|
||||
t.Fatalf("已完成工单被回复后应打回 processing,实际 %s", got.Status)
|
||||
}
|
||||
if got.AdminRead {
|
||||
t.Fatalf("用户回复后管理员侧应为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 用户打开详情:user_read 置真。
|
||||
func TestTicketUserOpenMarksUserRead(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("user_read", false)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
|
||||
if code != 200 {
|
||||
t.Fatalf("查询失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
if !reloadTicket(t, tk.ID).UserRead {
|
||||
t.Fatalf("用户打开详情后应标记为已读")
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员打开详情:admin_read 置真。
|
||||
func TestTicketAdminOpenMarksAdminRead(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("admin_read", false)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "GET", "/admin/ticket/"+itoa(tk.ID), "")
|
||||
if code != 200 {
|
||||
t.Fatalf("查询失败 code=%d body=%s", code, body)
|
||||
}
|
||||
|
||||
if !reloadTicket(t, tk.ID).AdminRead {
|
||||
t.Fatalf("管理员打开详情后应标记为已读")
|
||||
}
|
||||
}
|
||||
|
||||
// 打回:仅 resolved 允许,且有次数与状态边界。
|
||||
func TestTicketReopenBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fromStatus string
|
||||
wantCode int
|
||||
wantStatus string
|
||||
}{
|
||||
{"已完成可打回", "resolved", 200, "processing"},
|
||||
{"处理中不可打回", "processing", 400, "processing"},
|
||||
{"待处理不可打回", "pending", 400, "pending"},
|
||||
{"已关闭不可打回", "closed", 400, "closed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, c.fromStatus)
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
|
||||
if code != c.wantCode {
|
||||
t.Fatalf("状态码 = %d, want %d; body=%s", code, c.wantCode, body)
|
||||
}
|
||||
if got := reloadTicket(t, tk.ID).Status; got != c.wantStatus {
|
||||
t.Fatalf("状态 = %s, want %s", got, c.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打回后管理员侧应为未读(用户表达了「问题没解决」)。
|
||||
func TestTicketReopenMarksAdminUnread(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "resolved")
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
|
||||
if code != 200 {
|
||||
t.Fatalf("打回失败 code=%d body=%s", code, body)
|
||||
}
|
||||
if reloadTicket(t, tk.ID).AdminRead {
|
||||
t.Fatalf("打回后管理员侧应为未读")
|
||||
}
|
||||
}
|
||||
|
||||
// 详情接口要把两个已读标记返回给前端。
|
||||
func TestTicketDetailExposesReadFlags(t *testing.T) {
|
||||
setupTicketIT(t)
|
||||
tk := createTicket(t, 1, "processing")
|
||||
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Updates(map[string]interface{}{
|
||||
"user_read": false, "admin_read": false,
|
||||
})
|
||||
app := newTicketApp(1, 99)
|
||||
|
||||
// 用户详情:调用后 user_read 会被置真
|
||||
_, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
|
||||
// 响应外层是统一结构 {code,message,data},字段在 data 里
|
||||
var resp struct {
|
||||
Data struct {
|
||||
UserRead bool `json:"userRead"`
|
||||
AdminRead bool `json:"adminRead"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
|
||||
}
|
||||
if !resp.Data.UserRead {
|
||||
t.Fatalf("用户打开详情后返回的 userRead 应为 true,body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v uint) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for v > 0 {
|
||||
b = append([]byte{byte('0' + v%10)}, b...)
|
||||
v /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package lock 提供基于 Redis 的分布式互斥锁。
|
||||
//
|
||||
// 用途:阻止同一用户在不同实例 / 不同浏览器上并发触发同一个不可重入的操作
|
||||
// (签到提交、请假申请、销假等)。单机时代这类并发靠进程内 mutex 就够,
|
||||
// 多实例部署后必须靠 Redis 才能跨进程互斥。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 锁值用随机 token,释放时必须校验 token 一致(Lua 原子比较删除),
|
||||
// 否则 A 的锁过期后 B 拿到锁,A 执行完把 B 的锁删了。
|
||||
// - 默认不自动续期:这些业务操作都是有界的 HTTP/爬虫请求,耗时已知且较短,
|
||||
// 给足 TTL 即可。自动续期会引入后台 goroutine 与泄漏风险,不值得。
|
||||
// - Redis 不可用时降级为进程内锁:单机能保证幂等,多实例降级期间不保证。
|
||||
// 这比让 Redis 抖动直接拒绝所有请求更合理。
|
||||
package lock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// releaseScript 仅当锁值匹配时才删除(原子),防止误删他人锁
|
||||
var releaseScript = redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// extendScript 仅当锁值匹配时才续期
|
||||
var extendScript = redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// 前缀隔离,避免与其他业务的 Redis key 冲突
|
||||
const keyPrefix = "lock:"
|
||||
|
||||
// Lock 表示一把已获取的锁
|
||||
type Lock struct {
|
||||
key string
|
||||
token string
|
||||
// 内存降级模式下持有的互斥键
|
||||
localKey string
|
||||
}
|
||||
|
||||
// TryLock 尝试获取锁,不等待。
|
||||
// 返回 (lock, true) 表示获取成功;返回 (nil, false) 表示已被他人持有。
|
||||
// ttl 是锁的自动过期时间,必须大于业务操作的最长耗时,否则会出现"重复执行"。
|
||||
func TryLock(key string, ttl time.Duration) (*Lock, bool) {
|
||||
fullKey := keyPrefix + key
|
||||
token := randomToken()
|
||||
|
||||
if service.Redis != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
ok, err := service.Redis.SetNX(ctx, fullKey, token, ttl).Result()
|
||||
if err == nil {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &Lock{key: fullKey, token: token}, true
|
||||
}
|
||||
// Redis 异常,落到内存锁
|
||||
}
|
||||
|
||||
if !localMutex.tryAcquire(key) {
|
||||
return nil, false
|
||||
}
|
||||
return &Lock{localKey: key}, true
|
||||
}
|
||||
|
||||
// Acquire 获取锁,最多等待 wait 时间。
|
||||
// 返回 (lock, true) 表示获取成功;超时返回 (nil, false)。
|
||||
func Acquire(key string, ttl, wait time.Duration) (*Lock, bool) {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
if l, ok := TryLock(key, ttl); ok {
|
||||
return l, true
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, false
|
||||
}
|
||||
// 短间隔轮询:业务锁争抢是低频事件,不需要指数退避
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock 释放锁。必须由获取锁的一方调用,且调用后不应再使用该锁对象。
|
||||
func (l *Lock) Unlock() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if l.localKey != "" {
|
||||
localMutex.release(l.localKey)
|
||||
return
|
||||
}
|
||||
if service.Redis == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := releaseScript.Run(ctx, service.Redis, []string{l.key}, l.token).Err(); err != nil {
|
||||
// 释放失败不致命:TTL 到期会自动删除
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Extend 续期。仅当仍持有该锁时生效,返回是否续期成功。
|
||||
// 长耗时操作可在中途调用;普通短操作不需要。
|
||||
func (l *Lock) Extend(ttl time.Duration) bool {
|
||||
if l == nil || l.localKey != "" || service.Redis == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
n, err := extendScript.Run(ctx, service.Redis, []string{l.key}, l.token, ttl.Milliseconds()).Int64()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// randomToken 生成锁持有者标识
|
||||
func randomToken() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// 极端情况下退化为时间戳,仍可用(只是碰撞概率略高)
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ---------- 内存降级锁 ----------
|
||||
|
||||
type localLocks struct {
|
||||
mu sync.Mutex
|
||||
held map[string]bool
|
||||
}
|
||||
|
||||
var localMutex = &localLocks{held: make(map[string]bool)}
|
||||
|
||||
func (m *localLocks) tryAcquire(key string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.held[key] {
|
||||
return false
|
||||
}
|
||||
m.held[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *localLocks) release(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.held, key)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package lock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func testRedisAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func withRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
addr := testRedisAddr(t)
|
||||
c := redis.NewClient(&redis.Options{Addr: addr})
|
||||
if err := c.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("连接测试 Redis 失败: %v", err)
|
||||
}
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
t.Cleanup(func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func withoutRedis(t *testing.T) {
|
||||
t.Helper()
|
||||
old := service.Redis
|
||||
service.Redis = nil // 强制走内存降级
|
||||
t.Cleanup(func() { service.Redis = old })
|
||||
}
|
||||
|
||||
func uniqKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ---------- 内存降级模式 ----------
|
||||
|
||||
func TestTryLock_Local_MutualExclusion(t *testing.T) {
|
||||
withoutRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("已持有时第二次应失败")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
if _, ok := TryLock(key, time.Minute); !ok {
|
||||
t.Fatal("释放后应可再次获取")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Redis 模式 ----------
|
||||
|
||||
func TestTryLock_Redis_MutualExclusion(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("已持有时第二次应失败")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
b, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("释放后应可再次获取")
|
||||
}
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
// 锁 TTL 到期后应自动可被他人获取(防止进程崩溃后锁永久卡死)
|
||||
func TestTryLock_Redis_TTLExpiry(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
if _, ok := TryLock(key, 200*time.Millisecond); !ok {
|
||||
t.Fatal("首次应获取成功")
|
||||
}
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
if _, ok := TryLock(key, time.Minute); !ok {
|
||||
t.Fatal("TTL 到期后应可被获取")
|
||||
}
|
||||
}
|
||||
|
||||
// 关键用例:A 的锁过期后 B 拿到锁,A 迟到的 Unlock 绝不能删掉 B 的锁,
|
||||
// 否则会出现「两个请求同时认为自己持有锁」的重复执行。
|
||||
func TestUnlock_DoesNotDeleteOthersLock(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, 200*time.Millisecond)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
|
||||
// 等 A 的锁过期
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
b, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 过期后 B 应获取成功")
|
||||
}
|
||||
|
||||
// A 迟到的释放:token 不匹配,必须什么都不做
|
||||
a.Unlock()
|
||||
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("B 仍持有锁,第三次获取应失败(A 的 Unlock 误删了 B 的锁)")
|
||||
}
|
||||
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func TestAcquire_WaitsForRelease(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
|
||||
// 150ms 后释放,B 在 3s 内等待应能成功拿到
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
a.Unlock()
|
||||
}()
|
||||
|
||||
b, ok := Acquire(key, time.Minute, 3*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("等待期间锁释放后应获取成功")
|
||||
}
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func TestAcquire_Timeout(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
defer a.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
if _, ok := Acquire(key, time.Minute, 300*time.Millisecond); ok {
|
||||
t.Fatal("锁被长期持有时等待应超时失败")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("等待时长异常,耗时 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_OnlyHolderCanExtend(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
a, ok := TryLock(key, 200*time.Millisecond)
|
||||
if !ok {
|
||||
t.Fatal("A 应获取成功")
|
||||
}
|
||||
if !a.Extend(time.Minute) {
|
||||
t.Fatal("持有者续期应成功")
|
||||
}
|
||||
|
||||
// 续期后存活时间应超过原 TTL
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
if _, ok := TryLock(key, time.Minute); ok {
|
||||
t.Fatal("续期后锁不应已过期")
|
||||
}
|
||||
|
||||
a.Unlock()
|
||||
}
|
||||
|
||||
// 并发争抢下必须恰好只有一个赢家
|
||||
func TestTryLock_ConcurrentSingleWinner(t *testing.T) {
|
||||
withRedis(t)
|
||||
key := uniqKey(t)
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
winners := 0
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if l, ok := TryLock(key, 30*time.Second); ok {
|
||||
mu.Lock()
|
||||
winners++
|
||||
mu.Unlock()
|
||||
_ = l // 不释放,保持持有直到测试结束
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if winners != 1 {
|
||||
t.Fatalf("并发争抢应恰好 1 个赢家,实际 %d", winners)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ type Ticket struct {
|
||||
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"`
|
||||
// 双向已读标记:工单是用户与管理员之间的对话,两侧各自维护一个未读状态。
|
||||
// UserRead :用户是否已读「管理员最近一次操作」(回复或状态变更)之后的内容。
|
||||
// 管理员回复/改状态时置 false,用户打开工单时置 true。
|
||||
// AdminRead :管理员是否已读「用户最近一次回复」。
|
||||
// 用户回复时置 false,管理员打开工单时置 true。
|
||||
// 两者独立:管理员刚回复完,对用户是未读,对管理员自己仍是已读。
|
||||
UserRead bool `gorm:"not null;default:true;index" json:"userRead"`
|
||||
AdminRead bool `gorm:"not null;default:true;index" json:"adminRead"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
|
||||
@@ -1,12 +1,115 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// key 为 "userID:taskID"
|
||||
// 限流实现。
|
||||
//
|
||||
// 双轨:
|
||||
// - Redis 可用时走滑动窗口(Lua 原子脚本),多实例共享同一份计数,
|
||||
// 这是多机部署下限流正确性的前提——否则每个实例各算各的,
|
||||
// 用户把 N 个实例各点一次就绕过了「10 次/天」。
|
||||
// - Redis 未配置或调用失败时降级为进程内内存计数(单机语义与历史行为一致)。
|
||||
// 降级只影响多实例一致性,不会让功能不可用。
|
||||
//
|
||||
// 对外 API(Allow / AllowMulti)签名保持与历史一致,调用方无需改动。
|
||||
|
||||
// slidingWindowScript 滑动窗口计数(原子)。
|
||||
//
|
||||
// KEYS[1] 计数键;ARGV[1] 窗口毫秒;ARGV[2] 当前毫秒;ARGV[3] 上限
|
||||
// 返回 {允许(1/0), 需等待秒数}
|
||||
//
|
||||
// 用 ZSET 存每次请求的时间戳,先剔除窗口外的,再按剩余数量判断。
|
||||
// 相比固定窗口计数,避免了窗口边界双倍突刺。
|
||||
var slidingWindowScript = redis.NewScript(`
|
||||
local windowMs = tonumber(ARGV[1])
|
||||
local now = tonumber(ARGV[2])
|
||||
local limit = tonumber(ARGV[3])
|
||||
local member = ARGV[4]
|
||||
|
||||
local cutoff = now - windowMs
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
|
||||
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
if count < limit then
|
||||
redis.call('ZADD', KEYS[1], now, member)
|
||||
redis.call('PEXPIRE', KEYS[1], windowMs)
|
||||
return { 1, 0 }
|
||||
end
|
||||
|
||||
-- 被限流:最早一条过期时即可再次放行
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
local waitMs = windowMs
|
||||
if oldest and oldest[2] then
|
||||
waitMs = tonumber(oldest[2]) + windowMs - now
|
||||
end
|
||||
if waitMs < 1000 then waitMs = 1000 end
|
||||
local waitSec = math.ceil(waitMs / 1000)
|
||||
return { 0, waitSec }
|
||||
`)
|
||||
|
||||
// redisOpTimeout 单次限流判断的 Redis 超时。
|
||||
// 取短值:限流是执行路径上的旁路判断,不能因为 Redis 抖动把主流程拖死。
|
||||
const redisOpTimeout = 1500 * time.Millisecond
|
||||
|
||||
// seq 用于给 ZSET member 加序号,避免同一纳秒内多次请求成员相同而互相覆盖
|
||||
var seq atomic.Uint64
|
||||
|
||||
func localSeq() uint64 { return seq.Add(1) }
|
||||
|
||||
// redisAvailable 判断是否可以使用 Redis
|
||||
func redisAvailable() bool {
|
||||
return service.Redis != nil
|
||||
}
|
||||
|
||||
// Allow 检查是否允许执行,limit 次 / window 时间内
|
||||
// 返回 (true, 0) 表示允许;返回 (false, 等待秒数) 表示被限流
|
||||
func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||
if limit <= 0 {
|
||||
return true, 0
|
||||
}
|
||||
if redisAvailable() {
|
||||
if ok, wait, err := allowRedis(key, limit, window); err == nil {
|
||||
return ok, wait
|
||||
}
|
||||
// Redis 异常:降级到内存,保证功能不被限流组件拖垮
|
||||
}
|
||||
return allowLocal(key, limit, window)
|
||||
}
|
||||
|
||||
// allowRedis 走 Redis 滑动窗口
|
||||
func allowRedis(key string, limit int, window time.Duration) (bool, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
// member 用 纳秒时间戳+序号 保证同毫秒内多次请求不互相覆盖
|
||||
member := fmt.Sprintf("%d-%d", now.UnixNano(), localSeq())
|
||||
|
||||
res, err := slidingWindowScript.Run(ctx, service.Redis,
|
||||
[]string{"ratelimit:" + key},
|
||||
window.Milliseconds(), now.UnixMilli(), limit, member,
|
||||
).Int64Slice()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if len(res) < 2 {
|
||||
return false, 0, fmt.Errorf("限流脚本返回值异常: %v", res)
|
||||
}
|
||||
return res[0] == 1, int(res[1]), nil
|
||||
}
|
||||
|
||||
// ---------- 内存降级实现(与原实现语义一致) ----------
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
records map[string][]time.Time
|
||||
@@ -16,16 +119,14 @@ 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) {
|
||||
// allowLocal 进程内滑动窗口(Redis 不可用时使用)
|
||||
func allowLocal(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 {
|
||||
@@ -35,7 +136,6 @@ func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||
}
|
||||
|
||||
if len(valid) >= limit {
|
||||
// 最早那次什么时候过期(转换为秒)
|
||||
oldest := valid[0]
|
||||
wait := int((window - now.Sub(oldest)) / time.Second)
|
||||
if wait < 1 {
|
||||
@@ -88,7 +188,7 @@ func formatWait(seconds int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 定期清理过期记录(后台 goroutine 调用)
|
||||
// Cleanup 定期清理过期记录(仅内存降级模式需要;Redis 侧靠 PEXPIRE 自动回收)
|
||||
func Cleanup(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// testRedisAddr 从环境变量读取测试 Redis 地址;未设置时相关用例跳过,
|
||||
// 保证没有 Redis 的环境(如纯单元测试机)仍能跑其余用例。
|
||||
func testRedisAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// withRedis 把全局 service.Redis 指向 addr 并返回清理函数
|
||||
func withRedis(t *testing.T, addr string) *redis.Client {
|
||||
t.Helper()
|
||||
c := redis.NewClient(&redis.Options{Addr: addr})
|
||||
if err := c.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("连接测试 Redis 失败: %v", err)
|
||||
}
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
t.Cleanup(func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func uniqKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// ---------- 内存降级模式 ----------
|
||||
|
||||
func TestAllowLocal_LimitAndExceed(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil // 强制走内存降级
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
|
||||
ok, _ := Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 1 次应放行")
|
||||
}
|
||||
ok, _ = Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 2 次应放行")
|
||||
}
|
||||
ok, _ = Allow(key, 3, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("第 3 次应放行")
|
||||
}
|
||||
|
||||
ok, wait := Allow(key, 3, time.Minute)
|
||||
if ok {
|
||||
t.Fatal("第 4 次应被限流")
|
||||
}
|
||||
if wait <= 0 {
|
||||
t.Fatalf("被限流时应返回正数等待秒数,实际 %d", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowLocal_WindowExpiry(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
|
||||
t.Fatal("首次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); ok {
|
||||
t.Fatal("窗口内第二次应被限流")
|
||||
}
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
|
||||
t.Fatal("窗口过期后应放行")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Redis 模式:跨实例一致性 ----------
|
||||
|
||||
// 关键回归用例:多实例部署时,两个实例(两个 redis 客户端)必须共享同一份计数,
|
||||
// 否则每个实例各算各的,用户把 N 个实例各点一次就绕过了限流。
|
||||
func TestAllowRedis_SharedAcrossClients(t *testing.T) {
|
||||
addr := testRedisAddr(t)
|
||||
|
||||
clientA := redis.NewClient(&redis.Options{Addr: addr})
|
||||
clientB := redis.NewClient(&redis.Options{Addr: addr})
|
||||
defer clientA.Close()
|
||||
defer clientB.Close()
|
||||
|
||||
old := service.Redis
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
|
||||
// 实例 A 用掉 2 次配额(上限 2)
|
||||
service.Redis = clientA
|
||||
if ok, _ := Allow(key, 2, time.Minute); !ok {
|
||||
t.Fatal("实例A 第 1 次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 2, time.Minute); !ok {
|
||||
t.Fatal("实例A 第 2 次应放行")
|
||||
}
|
||||
|
||||
// 实例 B 共享同一计数,第 3 次必须被拒
|
||||
service.Redis = clientB
|
||||
if ok, wait := Allow(key, 2, time.Minute); ok {
|
||||
t.Fatalf("实例B 第 3 次应被跨实例限流;wait=%d", wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowRedis_WindowExpiry(t *testing.T) {
|
||||
addr := testRedisAddr(t)
|
||||
c := withRedis(t, addr)
|
||||
_ = c
|
||||
|
||||
key := uniqKey(t)
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
|
||||
t.Fatal("首次应放行")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); ok {
|
||||
t.Fatal("窗口内第二次应被限流")
|
||||
}
|
||||
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
|
||||
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
|
||||
t.Fatal("窗口过期后应放行")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 不可达时必须降级到内存而不是报错或阻塞主流程
|
||||
func TestAllow_RedisDownFallsBackToLocal(t *testing.T) {
|
||||
// 指向一个必然拒绝连接的地址,快速失败
|
||||
c := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:1",
|
||||
MaxRetries: 0,
|
||||
DialTimeout: 200 * time.Millisecond,
|
||||
})
|
||||
old := service.Redis
|
||||
service.Redis = c
|
||||
defer func() {
|
||||
service.Redis = old
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
key := uniqKey(t)
|
||||
ok, _ := Allow(key, 1, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("Redis 不可用时应降级放行,而非拒绝")
|
||||
}
|
||||
if ok, _ := Allow(key, 1, time.Minute); ok {
|
||||
t.Fatal("降级到内存后仍应正常执行限流")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowMulti_ShortCircuit(t *testing.T) {
|
||||
old := service.Redis
|
||||
service.Redis = nil
|
||||
defer func() { service.Redis = old }()
|
||||
|
||||
key := uniqKey(t)
|
||||
rules := []Rule{
|
||||
{Limit: 2, Window: time.Minute, Label: "2m"},
|
||||
{Limit: 100, Window: time.Hour, Label: "1h"},
|
||||
}
|
||||
|
||||
if ok, _ := AllowMulti(key, rules); !ok {
|
||||
t.Fatal("第 1 次应放行")
|
||||
}
|
||||
if ok, _ := AllowMulti(key, rules); !ok {
|
||||
t.Fatal("第 2 次应放行")
|
||||
}
|
||||
ok, msg := AllowMulti(key, rules)
|
||||
if ok {
|
||||
t.Fatal("第 3 次应被短规则拒绝")
|
||||
}
|
||||
if msg == "" {
|
||||
t.Fatal("被限流时应返回提示文案")
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -157,7 +157,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
|
||||
@@ -156,7 +156,7 @@ func leaveTimeWithSeconds(t string) string {
|
||||
func leavePost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
req := 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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
@@ -462,8 +462,8 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
|
||||
"systemName": "android",
|
||||
"bodyString": bodyString,
|
||||
"lat": lat,
|
||||
"systemVersion": "15",
|
||||
"appVersion": "9.9.6",
|
||||
"systemVersion": AndroidVersion,
|
||||
"appVersion": AppVersion,
|
||||
"model": "cpdaily",
|
||||
}
|
||||
submitData["userId"] = username
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestBuildLeaveApplyForm(t *testing.T) {
|
||||
ApproverId: "20220109",
|
||||
ApproverName: "罗亭",
|
||||
}
|
||||
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
|
||||
form := BuildLeaveApplyForm(p, "device-1", AndroidVersion, AppVersion)
|
||||
|
||||
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
|
||||
t.Errorf("基础字段错误: %+v", form)
|
||||
@@ -156,7 +156,7 @@ func TestBuildLeaveApplyForm(t *testing.T) {
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "校内",
|
||||
}
|
||||
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
|
||||
form2 := BuildLeaveApplyForm(p2, "device-1", AndroidVersion, AppVersion)
|
||||
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
|
||||
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func TestBuildLeaveCcInfo(t *testing.T) {
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
CcUsers: users,
|
||||
}, "device-1", "15", "9.9.6")
|
||||
}, "device-1", AndroidVersion, AppVersion)
|
||||
fcc, _ := form["ccInfo"].(map[string]interface{})
|
||||
if fcc == nil || fcc["totalSize"] != 2 {
|
||||
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
|
||||
@@ -228,7 +228,7 @@ func TestSubmitLeaveApply(t *testing.T) {
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
}, "device-1", "15", "9.9.6")
|
||||
}, "device-1", AndroidVersion, AppVersion)
|
||||
|
||||
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
|
||||
t.Errorf("SubmitLeaveApply 应成功: %v", err)
|
||||
@@ -260,7 +260,7 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", "https://obs.example.com/only.jpg", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
}, "device-1", AndroidVersion, AppVersion)
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/only.jpg" {
|
||||
t.Errorf("空串应被剔除: %v", form["applyAttach"])
|
||||
@@ -270,7 +270,7 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
}, "device-1", AndroidVersion, AppVersion)
|
||||
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
|
||||
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
|
||||
LeaveReason: "续假三天", OutStatus: false,
|
||||
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
|
||||
}
|
||||
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
|
||||
form := BuildLeaveExtendForm(p, "dev-uuid", AndroidVersion, AppVersion, "3d917d58abcd")
|
||||
|
||||
if _, has := form["location"]; has {
|
||||
t.Fatal("续假提交体不应包含 location 子对象")
|
||||
|
||||
@@ -48,7 +48,7 @@ type LeaveReturnTask struct {
|
||||
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap)).
|
||||
|
||||
@@ -909,6 +909,19 @@ func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
|
||||
if r.DK == "" || r.M == "" || r.E == "" {
|
||||
return nil, fmt.Errorf("解析公钥参数失败")
|
||||
}
|
||||
// 形态校验:/api/sec_js 每次返回都带新盐,若响应被截断或格式变化,
|
||||
// 正则会匹配到残缺值,加密出的密文服务端必然解不开(表现为
|
||||
// 「参数解码错误,刷新页面后尝试」)。这里提前拦住,报错更贴近真因,
|
||||
// 也避免把无效密文发出去。
|
||||
if len(r.DK) < 16 {
|
||||
return nil, fmt.Errorf("DK 盐长度异常(len=%d),sec_js 响应可能不完整", len(r.DK))
|
||||
}
|
||||
if len(r.M) < 64 {
|
||||
return nil, fmt.Errorf("模数长度异常(hex len=%d),sec_js 响应可能不完整", len(r.M))
|
||||
}
|
||||
if r.E != "010001" && r.E != "10001" {
|
||||
return nil, fmt.Errorf("指数形态异常(%q),sec_js 响应可能不完整", r.E)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// BzuuSchoolId 亳州学院(pending_schools id=27)
|
||||
const BzuuSchoolId = "1021882682406205"
|
||||
|
||||
// 亳州学院 zs 主题 CAS 的固定密码加密密钥(来自 themes/zs/js/casLoginView.js)
|
||||
const (
|
||||
bzuuAesKey = "1234567890adbcde"
|
||||
bzuuAesIv = "1234567890hjlkew"
|
||||
)
|
||||
|
||||
// bzuuEncryptPassword 对齐 casLoginView.js 的 encryption():
|
||||
// AES-128-CBC + ZeroPadding,输出 base64。
|
||||
func bzuuEncryptPassword(password string) string {
|
||||
block, err := aes.NewCipher([]byte(bzuuAesKey))
|
||||
if err != nil {
|
||||
return password
|
||||
}
|
||||
data := []byte(password)
|
||||
if rem := len(data) % block.BlockSize(); rem != 0 {
|
||||
data = append(data, bytes.Repeat([]byte{0}, block.BlockSize()-rem)...)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
cipher.NewCBCEncrypter(block, []byte(bzuuAesIv)).CryptBlocks(out, data)
|
||||
return base64.StdEncoding.EncodeToString(out)
|
||||
}
|
||||
|
||||
var bzuuSessionValueRe = regexp.MustCompile(`id\s*=\s*"sessionValue"[^>]*>([^<]+)<`)
|
||||
|
||||
// bzuuCasLogin 亳州学院登录适配。
|
||||
//
|
||||
// 平台侧配置 joinType=CLOUD(IsIap=true)、idsUrl=https://bzuu.campusphere.net/iap,
|
||||
// 但真实链路是学校统一身份认证(auth.bzuu.edu.cn/authserver,zs 主题定制 CAS),
|
||||
// 且该校**不校验表单明文密码**:账号密码必须先 POST /authserver/userLogin 完成校验,
|
||||
// 密码用固定 key/iv 做 AES-CBC(ZeroPadding)+base64;校验通过后再提交登录表单换取票据。
|
||||
// 只按通用 CAS 提交明文密码会稳定返回 401「登录失败,请检查用户名和密码!」。
|
||||
func bzuuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 进入网关,跟随重定向到学校 CAS 登录页
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(loginUrl)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 访问登录页失败: %w", err)
|
||||
}
|
||||
finalURL := pageResp.RawResponse.Request.URL
|
||||
pageBody := readBody(pageResp)
|
||||
|
||||
// 2. 页面内嵌的 sessionValue 必须以 SESSION cookie 形式带上,否则表单提交被判无效
|
||||
if m := bzuuSessionValueRe.FindSubmatch(pageBody); len(m) > 1 {
|
||||
sc.SetCookie(&http.Cookie{
|
||||
Name: "SESSION", Value: strings.TrimSpace(string(m[1])),
|
||||
Path: "/", Domain: finalURL.Host,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 解析表单隐藏域(execution/_eventId/geolocation)
|
||||
inputs := parseLoginForm(pageBody, username, password)
|
||||
execution := inputs["execution"]
|
||||
eventId := inputs["_eventId"]
|
||||
geolocation := inputs["geolocation"]
|
||||
|
||||
// 4. 加密密码并调用 userLogin 完成账号密码校验
|
||||
encPwd := bzuuEncryptPassword(password)
|
||||
base := fmt.Sprintf("%s://%s", finalURL.Scheme, finalURL.Host)
|
||||
ulResp, err := sc.R().
|
||||
SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"serviceUrl": finalURL.String(),
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"loginCaptcha": "",
|
||||
}).
|
||||
Post(base + "/authserver/userLogin")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交账号密码失败: %w", err)
|
||||
}
|
||||
ulBody := readBody(ulResp)
|
||||
|
||||
var ul struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
IsFirstLogin bool `json:"isFirstLogin"`
|
||||
IsVerification bool `json:"is_Verification"`
|
||||
}
|
||||
if err := json.Unmarshal(ulBody, &ul); err != nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 无法解析登录响应")
|
||||
}
|
||||
if !ul.Success {
|
||||
msg := ul.Message
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查用户名和密码!"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if ul.IsFirstLogin {
|
||||
return nil, nil, fmt.Errorf("该校要求首次登录在网页端完善个人信息后才能自动登录")
|
||||
}
|
||||
|
||||
// 5. 校验通过后提交登录表单换取 CAS 票据(密码同样为密文)。
|
||||
// 禁跳转以直接从 302 Location 拿到 ticket。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
formResp, err := sc.R().
|
||||
SetContext(ctx).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"execution": execution,
|
||||
"_eventId": eventId,
|
||||
"geolocation": geolocation,
|
||||
"key": randomString(32),
|
||||
"captcha": "",
|
||||
"submit": "登录",
|
||||
}).
|
||||
Post(finalURL.String())
|
||||
if err != nil {
|
||||
if formResp == nil || formResp.StatusCode() != 302 {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交登录表单失败: %w", err)
|
||||
}
|
||||
}
|
||||
if formResp == nil {
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 登录表单无响应")
|
||||
}
|
||||
|
||||
switch formResp.StatusCode() {
|
||||
case 302:
|
||||
slog.Info("亳州学院 CAS 登录成功", "redirect", formResp.Header().Get("Location"))
|
||||
cookies, err := handleCasLoginSuccess(ctx, formResp, school, sc)
|
||||
return cookies, nil, err
|
||||
case 200, 401:
|
||||
doc, derr := goquery.NewDocumentFromReader(bytes.NewReader(readBody(formResp)))
|
||||
msg := ""
|
||||
if derr == nil {
|
||||
msg = getCasErrorMessage(doc)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查用户名和密码!"
|
||||
}
|
||||
// 学校端对频繁失败会下发 MORETHANLIMIT,此时即使账号密码正确也会报错
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(formResp.Header())), "morethanlimit") {
|
||||
msg += "(登录尝试过于频繁,已被学校限制,请稍后再试)"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
rawBody := readBody(formResp)
|
||||
if d, derr := goquery.NewDocumentFromReader(bytes.NewReader(rawBody)); derr == nil {
|
||||
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "text", truncateForLog(strings.Join(strings.Fields(d.Text()), " "), 600))
|
||||
} else {
|
||||
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "body", truncateForLog(string(rawBody), 500))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("亳州学院 CAS 登录返回异常状态码: %d", formResp.StatusCode())
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,14 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底选择器 "form" 常命中多个表单(移动端账密、手机号、二维码等)。全部 input
|
||||
// 合并会把 phone/valiCode/key 等无关字段一起提交,学校端可能因此按手机号或验证码
|
||||
// 分支校验而拒绝账密登录(亳州学院实测 401)。此处优先锁定纯账密表单。
|
||||
if formSel.Length() > 1 {
|
||||
formSel = pickCredentialForm(formSel)
|
||||
slog.Info("CAS表单锁定账密表单", "count", formSel.Length())
|
||||
}
|
||||
|
||||
formSel.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
value, _ := s.Attr("value")
|
||||
@@ -282,6 +290,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// pickCredentialForm 在多个表单中挑出最"纯"的账密表单。
|
||||
//
|
||||
// 评分:必须含密码框;不含手机号/短信码字段加分;不含 key/captcha(滑块、图片验证码
|
||||
// 专用隐藏域)再加分。页面同时存在移动端账密(带 key/captcha)与 PC 账密表单时,
|
||||
// 选后者——把空 key/captcha 一起提交会给学校端造成"要求验证码"的误判。
|
||||
func pickCredentialForm(forms *goquery.Selection) *goquery.Selection {
|
||||
var best *goquery.Selection
|
||||
bestScore := -1
|
||||
forms.Each(func(_ int, s *goquery.Selection) {
|
||||
hasPassword := s.Find("input[type=password]").Length() > 0 ||
|
||||
s.Find("input[name=password]").Length() > 0
|
||||
if !hasPassword {
|
||||
return
|
||||
}
|
||||
score := 10
|
||||
if s.Find("input[name=phone], input[name=valiCode], input[name=phoneCode]").Length() == 0 {
|
||||
score += 2
|
||||
}
|
||||
if s.Find("input[name=key]").Length() == 0 {
|
||||
score++
|
||||
}
|
||||
if s.Find("input[name=captcha]").Length() == 0 {
|
||||
score++
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestScore = s, score
|
||||
}
|
||||
})
|
||||
if best != nil {
|
||||
return best
|
||||
}
|
||||
return forms.First()
|
||||
}
|
||||
|
||||
// CAS 验证码形态
|
||||
type casCaptchaKind int
|
||||
|
||||
@@ -437,6 +479,9 @@ func getCasErrorMessage(doc *goquery.Document) string {
|
||||
selectors := []string{
|
||||
"#msg", "#loginError", ".login-error",
|
||||
".error", "#error", "p.error",
|
||||
// zs 主题(亳州学院等)把错误提示放在 .warn-text 里,如
|
||||
// <div class="warn-text"><span style="color:#ff0000">登录失败,请检查用户名和密码!</span>
|
||||
".warn-text", ".warn-text span", "#showErrorTip",
|
||||
}
|
||||
for _, sel := range selectors {
|
||||
msg := strings.TrimSpace(doc.Find(sel).Text())
|
||||
|
||||
@@ -38,7 +38,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
Get(host + "/wec-im-group/group/groupMember/getCurrentUserId")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
|
||||
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
|
||||
func TestXsgzFetchRSAValidation(t *testing.T) {
|
||||
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
|
||||
goodDK := strings.Repeat("a", 32)
|
||||
goodM := "00" + strings.Repeat("b", 256)
|
||||
mk := func(dk, m, e string) string {
|
||||
var sb strings.Builder
|
||||
if dk != "" {
|
||||
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
|
||||
}
|
||||
if m != "" {
|
||||
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
|
||||
}
|
||||
if e != "" {
|
||||
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{"正常", mk(goodDK, goodM, "010001"), ""},
|
||||
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
|
||||
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
|
||||
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
|
||||
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
|
||||
{"空响应", "", "解析公钥参数失败"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := xsgzFetchRSA(resty.New(), srv.URL)
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("应报错但未报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -180,9 +180,26 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
|
||||
detail.NamePinYin = getPinYinFromID(tenantID)
|
||||
detail.NameAbbr = getAbbrFromName(detail.Name)
|
||||
|
||||
// 部分学校返回的域名不带协议(如 "jatc.campusphere.net"),
|
||||
// 直接拼接口路径会得到 "host/path",net/http 报 unsupported protocol scheme ""。
|
||||
detail.AmpUrl = ensureURLScheme(detail.AmpUrl)
|
||||
detail.AmpUrl2 = ensureURLScheme(detail.AmpUrl2)
|
||||
detail.AmpUrl3 = ensureURLScheme(detail.AmpUrl3)
|
||||
detail.IdsUrl = ensureURLScheme(detail.IdsUrl)
|
||||
detail.WecloudUrl = ensureURLScheme(detail.WecloudUrl)
|
||||
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
// ensureURLScheme 为缺省协议的地址补 https://;已有 scheme(含 http://)的原样返回。
|
||||
func ensureURLScheme(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || strings.Contains(u, "://") {
|
||||
return u
|
||||
}
|
||||
return "https://" + u
|
||||
}
|
||||
|
||||
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
|
||||
var iapSchoolIds = map[string]bool{
|
||||
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
|
||||
@@ -251,6 +268,12 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == HfutSchoolId {
|
||||
return hfutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 亳州学院:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/login 实际 302 到
|
||||
// 学校 CAS(auth.bzuu.edu.cn/authserver),IAP 账密接口要求验证码不可用;
|
||||
// 真实链路为标准 CAS 表单(密码明文、当前无验证码),单独强制走 CAS(详见 login_bzuu.go)
|
||||
if school.ID == BzuuSchoolId {
|
||||
return bzuuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
|
||||
@@ -148,6 +148,17 @@ func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
// 只有 status=200 才代表短信真正下发。上游在「号码当日验证次数已达上限」等
|
||||
// 情况下仍返回 errCode=0,但 status=2、tipMsg="验证次数已达上限,将于次日清零"
|
||||
// 且 countdown 只有 10 秒,此时短信不会发出——若按成功处理,就会出现
|
||||
// 「前端提示已发送、手机收不到」。
|
||||
if st := result.Data.Status; st != 0 && st != 200 {
|
||||
msg := result.Data.TipMsg
|
||||
if msg == "" {
|
||||
msg = "短信发送失败,请稍后重试"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
@@ -208,7 +219,7 @@ func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
host := ensureURLScheme(strings.TrimSuffix(school.WecloudUrl, "/"))
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
@@ -309,6 +320,8 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
// 防御:传入的 host 可能是裸域名(无协议),直接拼接会导致 unsupported protocol scheme ""。
|
||||
host = ensureURLScheme(strings.TrimSuffix(host, "/"))
|
||||
|
||||
client := newSmsClient()
|
||||
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
|
||||
|
||||
@@ -149,7 +149,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
|
||||
}
|
||||
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -474,7 +474,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -650,7 +650,7 @@ func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
@@ -1559,12 +1559,12 @@ func GetExtensionString(lat, lon, deviceId, username string) string {
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"model": "cpdaily",
|
||||
"appVersion": "9.9.6",
|
||||
"systemVersion": "15",
|
||||
"appVersion": AppVersion,
|
||||
"systemVersion": AndroidVersion,
|
||||
"userId": username,
|
||||
"systemName": "android",
|
||||
"deviceId": deviceId,
|
||||
"cpdailyVersion": "9.9.6",
|
||||
"cpdailyVersion": AppVersion,
|
||||
}
|
||||
data, _ := json.Marshal(ext)
|
||||
return base64.StdEncoding.EncodeToString(DesEncryptForCat(data))
|
||||
@@ -1631,8 +1631,8 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
"systemName": "android",
|
||||
"bodyString": bodyString,
|
||||
"lat": lat,
|
||||
"systemVersion": "15",
|
||||
"appVersion": "9.9.6",
|
||||
"systemVersion": AndroidVersion,
|
||||
"appVersion": AppVersion,
|
||||
"model": "cpdaily",
|
||||
}
|
||||
// 添加 userId
|
||||
@@ -1642,7 +1642,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
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("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookieStr).
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cpdaily
|
||||
|
||||
// 今日校园客户端版本标识。
|
||||
//
|
||||
// 这些值会出现在两个地方,必须保持一致:
|
||||
// - User-Agent 尾部:cpdaily/<ver> wisedu/<ver>
|
||||
// - Cpdaily-Extension 明文 JSON 的 appVersion / cpdailyVersion 字段
|
||||
// 以及 submitSign 请求体的 appVersion
|
||||
//
|
||||
// 集中定义避免各处字面量漂移:改版本只改这里。
|
||||
const (
|
||||
// AppVersion 今日校园客户端版本
|
||||
AppVersion = "9.9.26"
|
||||
|
||||
// AndroidVersion 上报的系统版本(假设备信息,与 UA 中的 Android 15 对应)
|
||||
AndroidVersion = "15"
|
||||
|
||||
// UserAgent 通用请求 UA(Chrome 内核 + 今日校园客户端标识)
|
||||
UserAgent = "Mozilla/5.0 (Linux; Android " + AndroidVersion +
|
||||
"; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/" +
|
||||
AppVersion + " wisedu/" + AppVersion
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package cpdaily
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,6 +22,41 @@ import (
|
||||
// 5. update(operationType=1) 提交。
|
||||
const worklogAPIBase = "/wec-counselor-worklog-apps"
|
||||
|
||||
// worklogPost 工作日志专用 POST。
|
||||
//
|
||||
// 与 leavePost 的差异:固定携带 CpdailyStandAlone: 0,且在 extension 非空时携带
|
||||
// Cpdaily-Extension。依据逆向文档(worklog_api.md):定位签到接口 worklog/sign/submitSign
|
||||
// 必须带 Cpdaily-Extension(DES-CBC)与 CpdailyStandAlone: 0,缺失会被学校端拒绝。
|
||||
// 此前该接口走 leavePost 且 extension 传空,等于两个头都没发。
|
||||
func worklogPost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
req := client.R().
|
||||
SetHeader("User-Agent", UserAgent).
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("CpdailyStandAlone", "0").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
if extension != "" {
|
||||
req = req.SetHeader("Cpdaily-Extension", extension)
|
||||
}
|
||||
resp, err := req.SetBody(body).Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
|
||||
type WorklogSubTemplate struct {
|
||||
Wid int64 `json:"wid"`
|
||||
@@ -215,24 +251,33 @@ func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]
|
||||
return datas.Form, nil
|
||||
}
|
||||
|
||||
// SubmitWorklogSign 定位签到项提交
|
||||
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude string) error {
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
|
||||
// SubmitWorklogSign 定位签到项提交。
|
||||
// 需要 Cpdaily-Extension 头(含当前定位与设备信息),故必须传入 deviceId 与 username。
|
||||
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude, deviceId, username string) error {
|
||||
// 与普通打卡一致:Cpdaily-Extension = base64(DES-CBC({lon,lat,deviceId,userId,...}))
|
||||
ext := GetExtensionString(latitude, longitude, deviceId, username)
|
||||
body, err := worklogPost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
|
||||
"fieldWid": fieldWid,
|
||||
"worklogWid": worklogWid,
|
||||
"signPlace": signPlace,
|
||||
"longitude": longitude,
|
||||
"latitude": latitude,
|
||||
}, "")
|
||||
}, ext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("工作日志定位签到失败: %w", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析定位签到响应失败: %w", err)
|
||||
return fmt.Errorf("解析定位签到响应失败: %w (body: %.300s)", err, string(body))
|
||||
}
|
||||
// 与普通打卡的 submitSign 保持一致,记录完整响应,便于排查学校端业务拒绝原因
|
||||
// (此前只取 message、不打日志,导致「请提交正确的信息收集信息」这类错误无从定位)。
|
||||
slog.Info("worklog/submitSign 响应",
|
||||
"fieldWid", fieldWid, "worklogWid", worklogWid,
|
||||
"code", envelope.Code, "message", envelope.Message, "body", string(body))
|
||||
if envelope.Message != "SUCCESS" {
|
||||
msg := strings.TrimSpace(envelope.Message)
|
||||
if msg == "" {
|
||||
@@ -256,7 +301,7 @@ func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64,
|
||||
formOut = append(formOut, map[string]interface{}{"wid": form[i].Wid, "value": form[i].Value})
|
||||
}
|
||||
}
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
|
||||
body, err := worklogPost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
|
||||
"formWid": formWid,
|
||||
"wid": wid,
|
||||
"form": formOut,
|
||||
@@ -405,3 +450,21 @@ func WorklogScopePoints(f WorklogField) []SignPlace {
|
||||
func WorklogWithinRange(f WorklogField, lat, lon string) bool {
|
||||
return coordsWithinSignRange(lat, lon, WorklogScopePoints(f))
|
||||
}
|
||||
|
||||
// WorklogMatchedScope 返回坐标落入的那个签到范围点(含 address)。
|
||||
//
|
||||
// submitSign 的 signPlace 必须是学校配置的签到点名称本身,而不是空串或任意地址;
|
||||
// 此前 handler 恒传空串,学校端校验「信息收集信息」不通过并返回 2210010000。
|
||||
func WorklogMatchedScope(f WorklogField, lat, lon string) (SignPlace, bool) {
|
||||
latF, e1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
|
||||
lonF, e2 := strconv.ParseFloat(strings.TrimSpace(lon), 64)
|
||||
if e1 != nil || e2 != nil {
|
||||
return SignPlace{}, false
|
||||
}
|
||||
for _, p := range WorklogScopePoints(f) {
|
||||
if haversineMeters(latF, lonF, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return SignPlace{}, false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"crypto/des"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -81,3 +89,78 @@ func TestWorklogSignWidRoundTrip(t *testing.T) {
|
||||
t.Fatal("非法标识应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:定位签到接口必须携带 Cpdaily-Extension 与 CpdailyStandAlone: 0。
|
||||
// 此前走 leavePost 且 extension 传空,两个头都没发,学校端会拒绝。
|
||||
func TestSubmitWorklogSign_SendsExtensionHeaders(t *testing.T) {
|
||||
var gotExt, gotStandAlone, gotBody string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotExt = r.Header.Get("Cpdaily-Extension")
|
||||
gotStandAlone = r.Header.Get("CpdailyStandAlone")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := SubmitWorklogSign(
|
||||
map[string]string{"SESSION": "abc"}, srv.URL,
|
||||
428, 2234, "唐槐校区", "112.55", "37.87", "13800000000", "20230001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("提交不应失败: %v", err)
|
||||
}
|
||||
|
||||
if gotStandAlone != "0" {
|
||||
t.Errorf("CpdailyStandAlone 应为 0,实际 %q", gotStandAlone)
|
||||
}
|
||||
if gotExt == "" {
|
||||
t.Fatal("缺少 Cpdaily-Extension 头")
|
||||
}
|
||||
|
||||
// 解密校验:头里应包含本次定位与用户标识
|
||||
raw, err := base64.StdEncoding.DecodeString(gotExt)
|
||||
if err != nil {
|
||||
t.Fatalf("Cpdaily-Extension 非法 base64: %v", err)
|
||||
}
|
||||
plain, err := desDecryptForTest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Cpdaily-Extension 解密失败: %v", err)
|
||||
}
|
||||
var ext map[string]interface{}
|
||||
if err := json.Unmarshal(plain, &ext); err != nil {
|
||||
t.Fatalf("Cpdaily-Extension 非 JSON: %v", err)
|
||||
}
|
||||
if ext["lat"] != "37.87" || ext["lon"] != "112.55" {
|
||||
t.Errorf("签名坐标不符: lat=%v lon=%v", ext["lat"], ext["lon"])
|
||||
}
|
||||
if ext["userId"] != "20230001" {
|
||||
t.Errorf("签名 userId 不符: %v", ext["userId"])
|
||||
}
|
||||
|
||||
// 请求体仍应是明文定位参数
|
||||
if !strings.Contains(gotBody, "112.55") || !strings.Contains(gotBody, "2234") {
|
||||
t.Errorf("请求体异常: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// desDecryptForTest 用生产同款 key/iv 解密,验证签名内容
|
||||
func desDecryptForTest(data []byte) ([]byte, error) {
|
||||
block, err := des.NewCipher(DesKeyForCat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data)%block.BlockSize() != 0 {
|
||||
return nil, fmt.Errorf("密文长度非法: %d", len(data))
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
cipher.NewCBCDecrypter(block, DesIv).CryptBlocks(out, data)
|
||||
// 去 PKCS7
|
||||
n := int(out[len(out)-1])
|
||||
if n <= 0 || n > len(out) {
|
||||
return nil, fmt.Errorf("padding 非法")
|
||||
}
|
||||
return out[:len(out)-n], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 短信验证码发送冷却:同一手机号 60 秒内只允许触发一次下发。
|
||||
//
|
||||
// 今日校园 messageCode 接口对同一手机号有服务端频率限制:超出时接口仍返回
|
||||
// errCode=0,但短信不会真正下发,表现为「前端提示发送成功,手机却收不到」。
|
||||
// 因此在调用上游之前用 Redis 做手机号维度的冷却,既能挡住重复点击,
|
||||
// 又能保证每次真正下发的间隔满足上游要求。
|
||||
const SmsCodeCooldownSeconds = 60
|
||||
|
||||
const smsCodeCooldown = SmsCodeCooldownSeconds * time.Second
|
||||
|
||||
// smsCodeCooldownKeyFmt 冷却标记(值为触发时间戳,便于排查)
|
||||
const smsCodeCooldownKeyFmt = "sms:code:cd:%s"
|
||||
|
||||
// AllowSmsCodeSend 判断该手机号当前是否允许发送验证码。
|
||||
// 放行时立即写入冷却标记并返回 (true, 0);
|
||||
// 冷却中返回 (false, 剩余时长)。
|
||||
func AllowSmsCodeSend(phone string) (bool, time.Duration) {
|
||||
if phone == "" || Redis == nil {
|
||||
return true, 0
|
||||
}
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
|
||||
key := fmt.Sprintf(smsCodeCooldownKeyFmt, phone)
|
||||
ok, err := Redis.SetNX(ctx, key, time.Now().Unix(), smsCodeCooldown).Result()
|
||||
if err != nil {
|
||||
// Redis 故障时放行,避免把发码功能整体打挂
|
||||
slog.Warn("短信冷却 Redis 操作失败,放行", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
if ok {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
ttl, err := Redis.TTL(ctx, key).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
return false, smsCodeCooldown
|
||||
}
|
||||
return false, ttl
|
||||
}
|
||||
|
||||
// ReleaseSmsCodeCooldown 释放冷却(上游发送失败时调用,允许用户立即重试)
|
||||
func ReleaseSmsCodeCooldown(phone string) {
|
||||
if phone == "" || Redis == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := redisCaptchaCtx()
|
||||
defer cancel()
|
||||
if err := Redis.Del(ctx, fmt.Sprintf(smsCodeCooldownKeyFmt, phone)).Err(); err != nil {
|
||||
slog.Warn("释放短信冷却失败", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func smsTestRedis(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
|
||||
}
|
||||
c := redis.NewClient(&redis.Options{Addr: addr})
|
||||
if err := c.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("连接测试 Redis 失败: %v", err)
|
||||
}
|
||||
old := Redis
|
||||
Redis = c
|
||||
t.Cleanup(func() {
|
||||
Redis = old
|
||||
_ = c.Close()
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
// Redis 不可用时不应阻断发码(避免把功能整体打挂)
|
||||
func TestAllowSmsCodeSendNoRedis(t *testing.T) {
|
||||
old := Redis
|
||||
Redis = nil
|
||||
t.Cleanup(func() { Redis = old })
|
||||
|
||||
if ok, wait := AllowSmsCodeSend("13800000000"); !ok || wait != 0 {
|
||||
t.Fatalf("Redis 不可用时应放行, got ok=%v wait=%v", ok, wait)
|
||||
}
|
||||
ReleaseSmsCodeCooldown("13800000000") // 不应 panic
|
||||
}
|
||||
|
||||
// 同一手机号 60 秒内第二次发送必须被拒绝,并返回剩余冷却时间
|
||||
func TestAllowSmsCodeSendCooldown(t *testing.T) {
|
||||
c := smsTestRedis(t)
|
||||
phone := smsTestPhone()
|
||||
t.Cleanup(func() {
|
||||
c.Del(context.Background(), "sms:code:cd:"+phone)
|
||||
})
|
||||
|
||||
if ok, wait := AllowSmsCodeSend(phone); !ok || wait != 0 {
|
||||
t.Fatalf("首次发送应放行, got ok=%v wait=%v", ok, wait)
|
||||
}
|
||||
ok, wait := AllowSmsCodeSend(phone)
|
||||
if ok {
|
||||
t.Fatal("冷却期内第二次发送应被拒绝")
|
||||
}
|
||||
if wait <= 0 || wait > SmsCodeCooldownSeconds*time.Second {
|
||||
t.Fatalf("剩余冷却时间应在 (0, %ds], got %v", SmsCodeCooldownSeconds, wait)
|
||||
}
|
||||
|
||||
// 不同手机号互不影响
|
||||
other := smsTestPhone()
|
||||
t.Cleanup(func() { c.Del(context.Background(), "sms:code:cd:"+other) })
|
||||
if ok, _ := AllowSmsCodeSend(other); !ok {
|
||||
t.Fatal("其他手机号不应被冷却拦截")
|
||||
}
|
||||
}
|
||||
|
||||
// 上游发送失败后释放冷却,应允许立即重试
|
||||
func TestReleaseSmsCodeCooldown(t *testing.T) {
|
||||
c := smsTestRedis(t)
|
||||
phone := smsTestPhone()
|
||||
t.Cleanup(func() {
|
||||
c.Del(context.Background(), "sms:code:cd:"+phone)
|
||||
})
|
||||
|
||||
if ok, _ := AllowSmsCodeSend(phone); !ok {
|
||||
t.Fatal("首次发送应放行")
|
||||
}
|
||||
if ok, _ := AllowSmsCodeSend(phone); ok {
|
||||
t.Fatal("冷却期内应被拒绝")
|
||||
}
|
||||
ReleaseSmsCodeCooldown(phone)
|
||||
if ok, _ := AllowSmsCodeSend(phone); !ok {
|
||||
t.Fatal("释放冷却后应允许重试")
|
||||
}
|
||||
}
|
||||
|
||||
func smsTestPhone() string {
|
||||
return fmt.Sprintf("199%08d", time.Now().UnixNano()%1e8)
|
||||
}
|
||||
@@ -400,6 +400,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
|
||||
export function resetInviteCode() {
|
||||
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface TicketListItem {
|
||||
messageCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 管理员最近一次操作后用户是否已读;false 时列表显示未读红点 */
|
||||
userRead?: boolean
|
||||
}
|
||||
|
||||
export interface AdminTicketListItem extends TicketListItem {
|
||||
@@ -17,6 +19,10 @@ export interface AdminTicketListItem extends TicketListItem {
|
||||
schoolName: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可见 */
|
||||
deleted?: boolean
|
||||
/** 管理员最近一次操作后用户是否已读;false 表示用户还没看 */
|
||||
userRead?: boolean
|
||||
/** 用户最近一次回复后管理员是否已读;false 表示后台显示未读红点 */
|
||||
adminRead?: boolean
|
||||
}
|
||||
|
||||
export interface TicketMessageRes {
|
||||
@@ -41,6 +47,10 @@ export interface TicketDetail {
|
||||
updatedAt: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
|
||||
deleted?: boolean
|
||||
/** 管理员最近一次操作后用户是否已读 */
|
||||
userRead?: boolean
|
||||
/** 用户最近一次回复后管理员是否已读 */
|
||||
adminRead?: boolean
|
||||
messages: TicketMessageRes[]
|
||||
}
|
||||
|
||||
@@ -84,6 +94,11 @@ export function closeTicket(ticketId: number) {
|
||||
return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/close`)
|
||||
}
|
||||
|
||||
/** 打回工单:仅「已完成」可打回为「处理中」 */
|
||||
export function reopenTicket(ticketId: number) {
|
||||
return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/reopen`)
|
||||
}
|
||||
|
||||
/** 删除工单 */
|
||||
export function deleteTicket(ticketId: number) {
|
||||
return api.delete<{ data: { deleted: boolean } }>(`/ticket/${ticketId}`)
|
||||
|
||||
@@ -74,8 +74,14 @@ watch(
|
||||
)
|
||||
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') schedule()
|
||||
else clearTimer()
|
||||
if (document.visibilityState === 'visible') {
|
||||
// 回到前台时重新测量:后台期间可能发生过尺寸变化(横竖屏切换、
|
||||
// 系统字体缩放等),若沿用旧的 overflow,长文本的滚动距离会算错,
|
||||
// 出现「还没滚完就翻页」或「滚过头留白」。
|
||||
void measure().then(schedule)
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function openAll() {
|
||||
|
||||
@@ -91,10 +91,13 @@ watch(
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
|
||||
// 路由变化时同步底部 tab 高亮;进入需要弹窗的页面时尝试加载公告强制弹窗(若引导已完成)。
|
||||
// 工单弹窗与页面无关(任意页面都要弹),但切页时顺手查一次,
|
||||
// 让用户在两个入口之间切换时能更快看到新回复,而不是等下一个 30s 轮询。
|
||||
watch(() => route.name, (name) => {
|
||||
if (name) activeTab.value = name as string
|
||||
if (name === 'home') maybeLoadAnnouncementPopups()
|
||||
if (POPUP_ROUTES.has(String(name))) maybeLoadAnnouncementPopups()
|
||||
if (!showGuide.value) checkTicketReplyPopups()
|
||||
})
|
||||
|
||||
// 公告相关
|
||||
@@ -116,6 +119,10 @@ const totalUnread = computed(() => unreadCount.value + ticketUnreadCount.value)
|
||||
const forcePopupQueue = ref<AnnouncementItem[]>([])
|
||||
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||
|
||||
// 需要加载公告强制弹窗的页面:打卡页(home)与请假页(leave)。
|
||||
// 两页均为高频主入口,公告(含后台标记的强制弹出)在此保持一致行为。
|
||||
const POPUP_ROUTES = new Set(['home', 'leave'])
|
||||
|
||||
// 工单回复强制弹窗(任意页面均强制弹出)
|
||||
// 仅对「本设备本账号新出现的未读回复通知」弹窗:以 localStorage 记录基线 id,
|
||||
// 首次加载只记基线不弹历史(既避免历史未读轰炸,也避免 kind 列新增默认值
|
||||
@@ -189,7 +196,10 @@ function needGuide(user: UserInfo): boolean {
|
||||
}
|
||||
|
||||
async function maybeLoadAnnouncementPopups() {
|
||||
if (announcementLoaded.value || showGuide.value || route.name !== 'home') return
|
||||
if (announcementLoaded.value || showGuide.value) return
|
||||
// 强制弹窗在打卡页与请假页均需生效(与打卡页一致)。
|
||||
// 其他页面不主动加载:避免进入任意页就打扰,语义仍保持「这两个主入口必弹」。
|
||||
if (!POPUP_ROUTES.has(String(route.name))) return
|
||||
announcementLoaded.value = true
|
||||
try {
|
||||
const res = await announceApi.getMyAnnouncementInfo()
|
||||
@@ -240,9 +250,11 @@ async function checkTicketReplyPopups() {
|
||||
ticketNotifSeenId = stored
|
||||
}
|
||||
|
||||
// 新出现(id > 基线)且未读的回复通知 → 入队弹窗(仅 kind=reply;状态变更不强制弹窗)
|
||||
// 新出现(id > 基线)且未读的通知 → 入队弹窗。
|
||||
// 回复与状态变更都要弹(管理员改状态同样是用户需要知道的事);
|
||||
// 用户在详情页里的回复会推进基线,不会重复弹。
|
||||
const fresh = list
|
||||
.filter((n) => n.id > ticketNotifSeenId && !n.isRead && n.kind === 'reply')
|
||||
.filter((n) => n.id > ticketNotifSeenId && !n.isRead)
|
||||
.sort((a, b) => a.id - b.id)
|
||||
if (fresh.length > 0) {
|
||||
const queued = new Set(ticketPopupQueue.value.map((n) => n.id))
|
||||
|
||||
@@ -112,6 +112,18 @@ async function changeStatus(status: string) {
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<!-- 用户是否已读管理员最近一次操作(回复或状态变更) -->
|
||||
<span
|
||||
class="adm-badge no-dot"
|
||||
:class="ticket.userRead === false ? 'adm-badge-amber' : 'adm-badge-green'"
|
||||
:title="
|
||||
ticket.userRead === false
|
||||
? '管理员最近一次回复或状态变更,用户尚未查看'
|
||||
: '用户已查看管理员最近一次操作'
|
||||
"
|
||||
>
|
||||
{{ ticket.userRead === false ? '用户未读' : '用户已读' }}
|
||||
</span>
|
||||
<span
|
||||
class="adm-badge"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
|
||||
|
||||
@@ -109,6 +109,12 @@ function goDetail(id: number) {
|
||||
@click="goDetail(t.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<!-- 用户有新回复未读:最需要管理员处理,放最前用一个实心点强调 -->
|
||||
<span
|
||||
v-if="t.adminRead === false"
|
||||
class="mr-2 w-2 h-2 rounded-full bg-rose-500 flex-shrink-0"
|
||||
title="用户有新回复,管理员尚未查看"
|
||||
/>
|
||||
<h3 class="text-sm font-semibold text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<span
|
||||
v-if="t.deleted"
|
||||
@@ -117,6 +123,14 @@ function goDetail(id: number) {
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<!-- 用户是否已读最近一次管理员操作 -->
|
||||
<span
|
||||
v-if="t.userRead === false"
|
||||
class="ml-2 adm-badge no-dot adm-badge-amber flex-shrink-0"
|
||||
title="管理员最近一次回复或状态变更,用户尚未查看"
|
||||
>
|
||||
用户未读
|
||||
</span>
|
||||
<span
|
||||
class="ml-2 adm-badge flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CheckCircle, Clock, Plus, ScanLine, ImagePlus, Play, Trash2, ListTodo,
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import NoRunsDialog from '@/components/NoRunsDialog.vue'
|
||||
import QrScanner from '@/components/QrScanner.vue'
|
||||
import jsQR from 'jsqr'
|
||||
import { resolveUploadFileName } from '@/utils/obsUpload'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -38,6 +39,7 @@ const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
const resettingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
@@ -92,6 +94,18 @@ function onCdkBuyClick() {
|
||||
if (!cdkBuyUrl.value) return
|
||||
showCdkBuyConfirm.value = true
|
||||
}
|
||||
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
|
||||
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
|
||||
// 避免两个弹窗叠加。
|
||||
function onBuyFromRedeemModal() {
|
||||
closeRedeemModal()
|
||||
if (!cdkBuyVisible.value) {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
return
|
||||
}
|
||||
onCdkBuyClick()
|
||||
}
|
||||
|
||||
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
|
||||
// 避免手机浏览器把它当弹窗强制当前页打开。
|
||||
function confirmCdkBuy() {
|
||||
@@ -149,13 +163,6 @@ function maskDeviceCode(code: string): string {
|
||||
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
|
||||
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
|
||||
|
||||
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
|
||||
const profileTags = computed(() => {
|
||||
const u = userInfo.value
|
||||
if (!u) return []
|
||||
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
|
||||
})
|
||||
|
||||
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
|
||||
const vipDaysLeft = computed(() => {
|
||||
if (!isVip.value || !vipExpireAt.value) return null
|
||||
@@ -368,6 +375,50 @@ function fallbackCopy(text: string) {
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 重置邀请码 ----
|
||||
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
|
||||
async function onResetInviteTap() {
|
||||
if (resettingInvite.value) return
|
||||
const ok = await confirmDialog({
|
||||
title: '重置邀请码',
|
||||
content:
|
||||
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
|
||||
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
|
||||
confirmText: '确认重置',
|
||||
cancelText: '再想想',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
resettingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.resetInviteCode()
|
||||
const code = res.data.data?.inviteCode || ''
|
||||
if (!code) {
|
||||
// 接口成功但未返回新码:不能静默当作成功,否则页面仍显示旧码
|
||||
message.error('重置失败:未获取到新的邀请码,请重试')
|
||||
return
|
||||
}
|
||||
inviteCode.value = code
|
||||
message.success('邀请码已重置,请使用新的邀请码分享')
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
|
||||
message.error(msg)
|
||||
// 409:并发重置冲突,说明服务端已是新码。重新拉取,避免页面继续显示旧码
|
||||
if (status === 409) {
|
||||
try {
|
||||
const info = await authApi.getMyInviteInfo()
|
||||
applyInviteInfo(info.data.data)
|
||||
} catch {
|
||||
// 拉取失败不覆盖原提示,用户可手动刷新
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resettingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
@@ -419,54 +470,52 @@ async function handleRedeemInvite() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<div class="flex flex-col min-h-full profile-page">
|
||||
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
|
||||
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
|
||||
<!-- 头图绿色背景:高度需让渐变边界切到「购买 CDK 兑换码」卡片中线(390px 宽实测)。
|
||||
几何(相对 hero 顶部):兑换 CDK 按钮中线 99、购买卡 top 152、购买卡中线 196。
|
||||
hero 底边 = 上内边距 14 + 内容高 + 下内边距 pb;
|
||||
卡片用等量负上边距抵消,故 pb 增加多少,绿边界就下移多少、卡片位置不动。
|
||||
pb-22(88px)时边界在 102,只到「兑换 CDK」按钮;需 182px 才到购买卡中线 196。
|
||||
购买卡高 88,其上移量取 138 = 182 - 44(原 -mt-11 的偏移),保持 VIP 卡 top=58 不变。
|
||||
注:卡片不透明,绿色只在其左右留白处可见。 -->
|
||||
<div class="profile-hero relative overflow-hidden hero-clip bg-brand-gradient px-5 text-white flex-shrink-0">
|
||||
<!-- 装饰:柔光圆 + 细网格纹理 -->
|
||||
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
|
||||
|
||||
<!-- 头像 + 姓名 + 学校 -->
|
||||
<div class="relative flex items-center gap-4 rise-in">
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
|
||||
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
|
||||
</p>
|
||||
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 会员卡(压在头图下沿) ======== -->
|
||||
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
|
||||
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
|
||||
<!-- ======== 会员卡(压在头图下沿) ========
|
||||
负上边距 = hero pb - 44,抵消 pb 增加带来的整体下移,
|
||||
使 VIP 卡 top 恒为 58px(相对 hero 顶部),只有绿色边界在动。
|
||||
当前 pb 182 → -mt 138。改 pb 时必须同步改这里,否则整块内容会跟着平移。 -->
|
||||
<div class="profile-card-wrap relative px-4 rise-in" style="animation-delay: 40ms">
|
||||
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
|
||||
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||||
<Crown class="w-5 h-5" :stroke-width="2.4" />
|
||||
<!-- 头像(原顶部头图内容整合至此处) -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
|
||||
<!-- 姓名 + 会员身份 -->
|
||||
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
|
||||
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
|
||||
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
|
||||
</p>
|
||||
<p class="member-sub text-xs mt-0.5 truncate">
|
||||
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
|
||||
<template v-else>开通后执行任务不限次数</template>
|
||||
<template v-else>开通 VIP 即可不限次数</template>
|
||||
</p>
|
||||
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
|
||||
<p class="member-sub text-xs mt-1 flex items-center gap-1">
|
||||
<span class="opacity-75">可执行次数</span>
|
||||
<span class="font-bold tabular-nums">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
|
||||
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
|
||||
@@ -541,6 +590,16 @@ async function handleRedeemInvite() {
|
||||
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
|
||||
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-reset"
|
||||
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
|
||||
title="重置邀请码"
|
||||
aria-label="重置邀请码"
|
||||
:disabled="resettingInvite"
|
||||
@click="onResetInviteTap"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
|
||||
@@ -732,16 +791,26 @@ async function handleRedeemInvite() {
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
|
||||
<div class="flex gap-2">
|
||||
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
|
||||
<button
|
||||
type="button"
|
||||
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
|
||||
@click="onBuyFromRedeemModal"
|
||||
>
|
||||
没有 CDK?立即购买
|
||||
</button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -950,7 +1019,25 @@ async function handleRedeemInvite() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 头图细网格纹理(极淡,增加质感) */
|
||||
/* 头图:绿底高度与卡片上浮量联动,改一处即可。
|
||||
--hero-pb:绿底下内边距(决定绿色边界落在哪)
|
||||
--card-lift:卡片负上边距(= --hero-pb - 44,抵消整体下移,卡片位置不动) */
|
||||
.profile-page {
|
||||
--hero-pb: 182px;
|
||||
--card-lift: 138px;
|
||||
}
|
||||
|
||||
.profile-hero {
|
||||
padding-top: calc(var(--safe-top) + 14px);
|
||||
padding-bottom: var(--hero-pb);
|
||||
}
|
||||
|
||||
.profile-card-wrap {
|
||||
margin-top: calc(var(--card-lift) * -1);
|
||||
}
|
||||
|
||||
/* 头图细网格纹理(极淡,增加质感)。
|
||||
mask 用百分比随绿底高度等比缩放,改 --hero-pb 不需要动这里。 */
|
||||
.profile-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px),
|
||||
@@ -960,7 +1047,7 @@ async function handleRedeemInvite() {
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
|
||||
}
|
||||
|
||||
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
|
||||
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
|
||||
.member-card.is-vip {
|
||||
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
|
||||
border: 1px solid rgb(246 196 69 / 0.35);
|
||||
@@ -982,13 +1069,24 @@ async function handleRedeemInvite() {
|
||||
color: #8a5a00;
|
||||
background: linear-gradient(135deg, #fff1c7, #ffe08a);
|
||||
}
|
||||
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
|
||||
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
|
||||
|
||||
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
|
||||
.invite-btn {
|
||||
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
|
||||
}
|
||||
|
||||
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
|
||||
与外层购买入口同为珊瑚渐变,形成视觉呼应。
|
||||
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
|
||||
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
|
||||
.cdk-goto-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
|
||||
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
|
||||
.cdk-buy-btn {
|
||||
@@ -1066,12 +1164,14 @@ async function handleRedeemInvite() {
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
|
||||
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.vip-btn::after {
|
||||
content: '';
|
||||
@@ -1094,6 +1194,7 @@ async function handleRedeemInvite() {
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
|
||||
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
|
||||
html.dark .member-card.is-free {
|
||||
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Send } from 'lucide-vue-next'
|
||||
import { ArrowLeft, Send, RotateCcw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
@@ -61,6 +61,23 @@ async function handleReply() {
|
||||
replying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打回:仅「已完成」可操作。用户认为问题没解决时,把工单退回处理中等待管理员跟进。
|
||||
const reopening = ref(false)
|
||||
|
||||
async function handleReopen() {
|
||||
if (!ticket.value) return
|
||||
reopening.value = true
|
||||
try {
|
||||
await ticketApi.reopenTicket(ticket.value.id)
|
||||
message.success('工单已打回为处理中')
|
||||
await loadTicket(ticket.value.id)
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.message || '打回失败')
|
||||
} finally {
|
||||
reopening.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,6 +113,19 @@ async function handleReply() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 已完成:允许打回处理中(问题没解决时) -->
|
||||
<div v-if="ticket.status === 'resolved'" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="press inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-amber-600 bg-amber-50 border border-amber-200"
|
||||
:disabled="reopening"
|
||||
@click="handleReopen"
|
||||
>
|
||||
<RotateCcw class="w-3.5 h-3.5" />
|
||||
{{ reopening ? '打回中...' : '问题未解决,打回处理中' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 初始工单内容(用户发起的,靠右) -->
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[85%]">
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# cpdaily-xiaozhushou 蓝绿部署脚本(在 162 上执行)
|
||||
# 用法: ./deploy-bluegreen.sh <image-tag> 例如 ./deploy-bluegreen.sh v1.2.194
|
||||
# 流程: 空闲槽部署 -> 健康检查 -> 切 Nginx -> 公网验证 -> 失败自动回滚
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_TAG="${1:?用法: $0 <image-tag>,例如 $0 v1.2.194}"
|
||||
IMAGE="cpdaily-xiaozhushou:${IMAGE_TAG}"
|
||||
CONF=/opt/1panel/apps/openresty/openresty/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
OPENRESTY=1Panel-openresty-VZIf
|
||||
NETWORK=1panel-network
|
||||
CONFIG_VOL=/opt/cpdaily-xiaozhushou/config.toml
|
||||
DOMAIN=https://jinrixiaoyuan.top/
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "镜像不存在: $IMAGE"; exit 1; }
|
||||
|
||||
# 读取当前反代端口,目标槽取另一个
|
||||
CUR=$(grep -oP 'proxy_pass http://127\.0\.0\.1:\K[0-9]+' "$CONF" | head -1)
|
||||
[ -n "$CUR" ] || { echo "无法从 $CONF 解析当前反代端口"; exit 1; }
|
||||
if [ "$CUR" = "8884" ]; then TARGET=8885; else TARGET=8884; fi
|
||||
CUR_NAME="cpdaily-xiaozhushou-${CUR}"
|
||||
TARGET_NAME="cpdaily-xiaozhushou-${TARGET}"
|
||||
|
||||
echo "[1/6] 当前 active=${CUR}($(docker inspect -f '{{.Config.Image}}' "$CUR_NAME" 2>/dev/null || echo 未知)) -> 目标=${TARGET} 镜像=${IMAGE}"
|
||||
|
||||
echo "[2/6] 备份目标槽容器定义"
|
||||
docker inspect "$TARGET_NAME" > "/opt/cpdaily-xiaozhushou/${TARGET_NAME}-backup-$(date +%Y%m%d-%H%M%S).json" 2>/dev/null || true
|
||||
|
||||
echo "[3/6] 在 ${TARGET} 部署新版本"
|
||||
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$TARGET_NAME" \
|
||||
--restart unless-stopped \
|
||||
--network "$NETWORK" \
|
||||
-p "${TARGET}:8093" \
|
||||
-v "$CONFIG_VOL:/app/config.toml:ro" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
echo "[4/6] 健康检查 http://127.0.0.1:${TARGET}/health"
|
||||
HEALTHY=0
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -m 3 "http://127.0.0.1:${TARGET}/health" >/dev/null 2>&1; then HEALTHY=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$HEALTHY" != "1" ]; then
|
||||
echo "健康检查失败,保持 ${CUR} 不变,清理新容器"
|
||||
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
echo " 健康检查通过"
|
||||
|
||||
echo "[5/6] 切换 Nginx: ${CUR} -> ${TARGET}"
|
||||
cp -a "$CONF" "$CONF.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
sed -i "s|proxy_pass http://127.0.0.1:${CUR};|proxy_pass http://127.0.0.1:${TARGET};|" "$CONF"
|
||||
if ! docker exec "$OPENRESTY" openresty -t >/dev/null 2>&1; then
|
||||
echo "Nginx 配置校验失败,回滚配置"
|
||||
sed -i "s|proxy_pass http://127.0.0.1:${TARGET};|proxy_pass http://127.0.0.1:${CUR};|" "$CONF"
|
||||
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$OPENRESTY" openresty -s reload >/dev/null 2>&1
|
||||
|
||||
echo "[6/6] 公网验证 ${DOMAIN}"
|
||||
sleep 2
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 15 "$DOMAIN" || echo 000)
|
||||
if [ "$CODE" = "200" ]; then
|
||||
echo "部署成功:线上 ${TARGET} = ${IMAGE};${CUR} 保留为回滚位"
|
||||
echo "回滚:sed -i 's|:${TARGET};|:${CUR};|' ${CONF} && docker exec ${OPENRESTY} openresty -s reload"
|
||||
else
|
||||
echo "公网验证失败 ($CODE),回滚到 ${CUR}"
|
||||
sed -i "s|proxy_pass http://127.0.0.1:${TARGET};|proxy_pass http://127.0.0.1:${CUR};|" "$CONF"
|
||||
docker exec "$OPENRESTY" openresty -s reload >/dev/null 2>&1 || true
|
||||
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user