Compare commits

...

2 Commits

Author SHA1 Message Date
becomer 44f93a0ae9 sdau sec_js 解析增加密钥形态校验
ci/woodpecker/tag/build Pipeline was successful
xsgzFetchRSA 此前只判断 DK/M/E 是否非空。若 /api/sec_js 响应被截断
或格式变化,正则可能匹配到残缺值,用它加密出的密文服务端必然解不开,
最终表现为「参数解码错误,刷新页面后尝试」,难以定位真因。

新增形态校验,提前拦住并把错误指向真实原因:
- DK 盐长度 < 16 → 报「DK 盐长度异常(len=N),sec_js 响应可能不完整」
- 模数 hex 长度 < 64 → 报「模数长度异常」
- 指数非 010001/10001 → 报「指数形态异常」

阈值取保守下限(真实响应对应 32 / 258 / "010001"),
已用线上真实 /api/sec_js 验证不会误判。
测试覆盖正常与四类残缺情形。
2026-10-21 16:29:38 +08:00
becomer 8926c0b109 邀请码改为 8 位 + 支持用户重置
1. 增至 8 位(backed/internal/handler/invite_code.go)
   字符集 31 个不变(已去掉易混淆的 0/O/1/L),
   空间从 31^6≈8.9 亿提升到 31^8≈8.5 万亿。

2. 新增重置接口 POST /api/auth/reset-invite
   - resetInviteCode():复用 withUniqueInviteCode,唯一性由
     invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑,
     不做「先查后写」,并发下依然正确)
   - 乐观锁:仅当库中旧码仍是本次读到的值时才替换,
     避免两个并发重置互相覆盖导致其中一个返回的码实际已失效;
     冲突时返回 409 提示刷新重试
   - 用户当前无码时先补齐再重置,否则乐观锁条件会失效
   - 记录 oldCode/newCode 便于排查

3. 前端(ProfileView)
   - 邀请码行内新增重置按钮(环形箭头图标,重置中转圈)
   - 点击先弹确认框,用 danger 样式并明确告知影响:
     「原来的邀请码会立即失效,别人再用旧码填写将无法绑定;
       已经通过旧码完成的邀请关系不受影响,积分也不会被收回」
   - 成功后直接更新页面上的邀请码并提示

测试:8 位长度与字符集校验、5000 次生成无重复、
重置生成的新码格式合规、两个哨兵错误互不混淆。
2026-10-21 16:29:31 +08:00
7 changed files with 249 additions and 2 deletions
+1
View File
@@ -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)
// 签到任务相关
+70 -2
View File
@@ -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,68 @@ 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})
}
@@ -111,3 +111,55 @@ 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 自反性失败")
}
}
@@ -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,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)
}
})
}
}
+5
View File
@@ -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 })
@@ -3,6 +3,7 @@ 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 { 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('')
@@ -368,6 +370,35 @@ 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) inviteCode.value = code
message.success('邀请码已重置,请使用新的邀请码分享')
} catch (e: any) {
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
message.error(msg)
} finally {
resettingInvite.value = false
}
}
// ---- 填写他人邀请码 ----
function openFillInviteModal() {
fillInviteCode.value = ''
@@ -541,6 +572,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"