Compare commits
2 Commits
320f21442c
...
44f93a0ae9
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f93a0ae9 | |||
| 8926c0b109 |
@@ -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)
|
||||
|
||||
// 签到任务相关
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user