Merge pull request #7 from qianfree/dev

完善验证
This commit is contained in:
Qian authored and GitHub committed 2026-06-13 20:51:58 +08:00
commit d4f4c549cb
22 files changed
+625 -106

No files matched your search

+3
View File
@@ -172,6 +172,9 @@ var (
}
return nil
})
cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error {
return task.ExpirePendingOrders(ctx)
})
cs.StartBackground(ctx)
// Initialize update manager
+18 -2
View File
@@ -1,6 +1,8 @@
package public
import (
"time"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/payment"
@@ -31,10 +33,24 @@ func HandlePaymentCallback(r *ghttp.Request) {
// 仅查询订单状态决定跳转目标,履约由异步回调完成。
func HandlePaymentEpayReturn(r *ghttp.Request) {
orderNo := r.GetQuery("out_trade_no").String()
paid := payment.QueryOrderPaid(r.Context(), orderNo)
if orderNo == "" {
r.Response.RedirectTo("/console/wallet?pay=fail")
return
}
ctx := r.Context()
// 异步 notify 可能晚于浏览器回跳到达:最多轮询约 4 秒等待回调入账。
// 仍未到账不一定是失败(回调可能仍在途),引导到"处理中"页由前端继续刷新,
// 避免给用户展示误导性的"失败"。
paid := payment.QueryOrderPaid(ctx, orderNo)
for i := 0; !paid && i < 8; i++ {
time.Sleep(500 * time.Millisecond)
paid = payment.QueryOrderPaid(ctx, orderNo)
}
if paid {
r.Response.RedirectTo("/console/wallet?pay=success")
return
}
r.Response.RedirectTo("/console/wallet?pay=fail")
r.Response.RedirectTo("/console/wallet?pay=processing")
}
+10 -1
View File
@@ -197,9 +197,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
}
if count > 0 {
data := do.SysOptions{Value: value}
// Sync metadata from registry so that changes to IsPublic/Category/Description
// are reflected in the DB row on the next write (e.g. a setting newly marked
// as IsPublic will become visible to GetPublicOptions).
if def := GetSettingDef(key); def != nil {
data.Category = def.Category
data.Description = def.Label
data.IsPublic = def.IsPublic
}
_, err = dao.SysOptions.Ctx(ctx).
Where("key", key).
Data(do.SysOptions{Value: value}).
Data(data).
Update()
} else {
def := GetSettingDef(key)
+2 -2
View File
@@ -120,9 +120,9 @@ var Registry = []SettingDef{
{Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security",
Label: "密码最小长度", Validation: "min:6,max:32"},
{Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security",
Label: "启用 Turnstile 人机验证"},
Label: "启用 Turnstile 人机验证", IsPublic: true},
{Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security",
Label: "Turnstile Site Key", Sensitive: false},
Label: "Turnstile Site Key", Sensitive: false, IsPublic: true},
{Key: "turnstile_secret_key", Type: SettingTypeString, Default: "", Category: "security",
Label: "Turnstile Secret Key", Sensitive: true},
{Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security",
+33
View File
@@ -57,9 +57,42 @@ func LoadChannelConfig(ctx context.Context, channelType string) (jsonStr string,
// SaveChannelConfig 将渠道配置 JSON 保存到 sys_options。
func SaveChannelConfig(ctx context.Context, channelType string, configJSON string) error {
// 启用线上支付渠道前,必须先配置全局回调地址,否则网关异步通知无法回传,
// 支付成功也无法入账(发起支付时还有 RequireCallbackBaseURL 二次校验)。
if err := validateChannelEnable(ctx, channelType, configJSON); err != nil {
return err
}
return lcommon.Config().SetOption(ctx, channelConfigKey(channelType), configJSON)
}
// validateChannelEnable 校验"启用渠道"的前置条件:启用易支付要求回调地址已配置。
func validateChannelEnable(ctx context.Context, channelType string, configJSON string) error {
if channelType != "epay" {
return nil
}
cfg, err := ParseChannelConfig(channelType, configJSON)
if err != nil || cfg == nil {
return nil // JSON 合法性由调用方校验;此处只关心启用前置条件
}
ec, ok := cfg.(*EpayConfig)
if !ok || !ec.IsEnabled {
return nil
}
if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" {
return lcommon.NewBadRequestError("请先在「支付设置」中配置回调基础URL,再启用支付渠道;否则支付成功后无法回调入账")
}
return nil
}
// RequireCallbackBaseURL 发起支付前的二次防护:回调地址未配置则拒绝发起。
// 渠道启用时已拦截一次,这里防止"启用后被清空回调地址"等场景下仍能发起支付。
func RequireCallbackBaseURL(ctx context.Context) error {
if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" {
return lcommon.NewBusinessError(422, "支付回调地址未配置,请联系管理员在「支付设置」中配置回调基础URL")
}
return nil
}
// ListAllChannels 返回所有已注册渠道的配置(含未配置的,返回默认值)。
func ListAllChannels(ctx context.Context) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(channelTypes))
+6 -23
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
@@ -43,10 +42,14 @@ func (p *EpayProvider) CreatePayment(ctx context.Context, order *PaymentOrder, c
params["sign"] = sign
params["sign_type"] = "MD5"
redirectURL := fmt.Sprintf("%s/submit.php?%s", strings.TrimRight(cfg.PayAddress, "/"), buildQuery(params))
// 采用 POST 表单提交:这里只返回 submit.php 的 action 地址,签名后的全部
// 参数放入 Params,由前端构造 <form method="POST"> 隐藏域提交。
// 相比拼 GET 查询串更安全:sign 与订单参数不进入 URL(不落浏览器历史/服务器
// 日志),也规避 URL 长度限制和中文/特殊字符的编码边界问题。
actionURL := fmt.Sprintf("%s/submit.php", strings.TrimRight(cfg.PayAddress, "/"))
return &PaymentResult{
PaymentURL: redirectURL,
PaymentURL: actionURL,
Params: params,
IsRedirect: true,
}, nil
@@ -128,26 +131,6 @@ func epaySign(params map[string]string, key string) string {
return hex.EncodeToString(hash[:])
}
// buildQuery 构建 URL 查询字符串。
func buildQuery(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var buf strings.Builder
for i, k := range keys {
if i > 0 {
buf.WriteByte('&')
}
buf.WriteString(url.QueryEscape(k))
buf.WriteByte('=')
buf.WriteString(url.QueryEscape(params[k]))
}
return buf.String()
}
// ReadBody 读取 HTTP 请求体并返回内容(用于 Stripe Webhook 等需要 raw body 的场景)。
func ReadBody(r *http.Request) ([]byte, error) {
defer r.Body.Close()
+28 -22
View File
@@ -34,14 +34,10 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
tenantCode := strings.TrimSpace(strings.ToLower(req.TenantCode))
username := strings.TrimSpace(req.Username)
// Validate username format
if err := common.ValidateUsername(username); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
}
// Validate forbidden words in username, tenant name, tenant code
tenantName := strings.TrimSpace(req.TenantName)
// 先校验禁用词:命中禁用词时优先返回更准确的提示,
// 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因
if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
@@ -52,6 +48,11 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
// Validate username format
if err := common.ValidateUsername(username); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
}
// Check register rate limit (IP + global)
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil {
@@ -70,14 +71,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
return nil, err
}
} else {
// 人机验证:优先 Turnstile,否则滑块验证
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
// Turnstile 未启用时,使用滑块验证
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
// 人机验证:Turnstile 启用时只验 Turnstile,否则用滑块验证
turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled")
if turnstileEnabled {
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
} else {
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
}
}
@@ -240,13 +243,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) {
account := strings.TrimSpace(req.Account)
// Check captcha if required
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
// Check captcha: Turnstile takes priority; slider captcha is fallback
turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled")
if turnstileEnabled {
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
return nil, err
}
} else {
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
return nil, err
}
}
// Get client info early for login history recording
+25 -3
View File
@@ -187,9 +187,12 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
if req.PaymentChannel == "" {
return nil, lcommon.NewBusinessError(422, "请选择支付渠道")
}
if err := payment.RequireCallbackBaseURL(ctx); err != nil {
return nil, err
}
//
orderNo, finalAmount, currency, orderType, description, err := getOrderForPay(ctx, tenantID, orderID)
orderNo, finalAmount, currency, orderType, _, err := getOrderForPay(ctx, tenantID, orderID)
if err != nil {
return nil, err
}
@@ -222,7 +225,7 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
Amount: finalAmount,
Currency: currency,
OrderType: orderType,
Description: description,
Description: gatewayProductName(orderType),
PaymentMethod: req.PaymentMethod,
NotifyURL: notifyURL,
ReturnURL: returnURL,
@@ -263,6 +266,22 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq)
return res, nil
}
// gatewayProductName 返回发送给支付网关(易支付)的商品名称。
//
// 易支付上游(支付宝/微信商户)的风控会扫描 name 参数中的关键词,
// 出现"充值/代充/代付/钱包/余额/额度/API/套现"等字样时会直接拦截,
// 网关返回"该商品禁止出售"。因此传给网关的 name 必须使用中性、安全的名称。
// 数据库 ord_orders.description 仍保留可读文案供租户订单列表展示,
// 二者解耦:此处只决定网关看到的商品名(参考 new-api 使用 "TUC{id}" 的做法)。
func gatewayProductName(orderType string) string {
switch orderType {
case "new_plan", "renew", "upgrade":
return "会员订阅"
default: // recharge 等其他类型
return "会员服务"
}
}
// getOrderForPay 获取待支付订单信息(供 OrderPay 内部调用)
func getOrderForPay(ctx context.Context, tenantID int64, orderID int64) (orderNo string, finalAmount float64, currency string, orderType string, description string, err error) {
var order *struct {
@@ -326,6 +345,9 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
}
// 2. 从 sys_options 加载渠道配置
if err := payment.RequireCallbackBaseURL(ctx); err != nil {
return nil, err
}
cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel)
if err != nil {
return nil, lcommon.NewBusinessError(422, err.Error())
@@ -387,7 +409,7 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
Amount: finalAmount,
Currency: "CNY",
OrderType: "recharge",
Description: description,
Description: gatewayProductName("recharge"),
PaymentMethod: req.PaymentMethod,
NotifyURL: notifyURL,
ReturnURL: returnURL,
@@ -0,0 +1,27 @@
-- +goose Up
-- Sync is_public flag from code registry to DB rows.
-- Settings like turnstile_enabled/turnstile_site_key were marked IsPublic in the
-- registry but the DB rows still had is_public=false, causing GetPublicOptions
-- (WHERE is_public=true) to skip them and return stale defaults to the tenant frontend.
UPDATE sys_options SET is_public = true, updated_at = NOW()
WHERE key IN (
'site_name',
'site_description',
'register_enabled',
'register_email_verification',
'maintenance_message',
'maintenance_duration',
'api_maintenance_enabled',
'oauth_auto_register',
'oauth_github_enabled',
'oauth_google_enabled',
'turnstile_enabled',
'turnstile_site_key',
'agreement_enabled'
) AND is_public = false;
-- +goose Down
-- No-op: reverting is_public to false would break public settings again,
-- so we leave the corrected values in place.
SELECT 1;
+2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import TopProgress from '@/components/TopProgress.vue'
</script>
<template>
<TopProgress />
<AConfigProvider>
<router-view />
</AConfigProvider>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useTopProgress } from '@/composables/useTopProgress'
const { progress, visible, smooth } = useTopProgress()
</script>
<template>
<div class="top-progress-container" :class="{ 'is-visible': visible }">
<div class="top-progress-bar" :class="{ 'no-anim': !smooth }" :style="{ width: progress + '%' }" />
</div>
</template>
<style scoped>
.top-progress-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.top-progress-container.is-visible {
opacity: 1;
}
.top-progress-bar {
height: 100%;
background: var(--ta-primary-gradient);
transition: width 0.2s ease;
}
.top-progress-bar.no-anim {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.top-progress-container,
.top-progress-bar {
transition-duration: 1ms;
}
}
</style>
@@ -0,0 +1,94 @@
import { ref, nextTick } from 'vue'
/**
* 顶部加载进度条(模块级单例)
*
* 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%,
* 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。
*
* 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。
*/
const TICK_MS = 100
/** 指数收敛时间常数:越大爬升越慢 */
const TIME_CONSTANT = 1500
/** 运行中进度上限,永远到不了 100% */
const MAX_PROGRESS = 90
/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */
const HIDE_DELAY_MS = 300
/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */
const TIMEOUT_MS = 15_000
const progress = ref(0)
const visible = ref(false)
/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */
const smooth = ref(true)
let tickTimer: ReturnType<typeof setInterval> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let startTime = 0
function clearTickTimer(): void {
if (tickTimer !== null) {
clearInterval(tickTimer)
tickTimer = null
}
}
function clearHideTimer(): void {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
}
function start(): void {
// 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」
clearHideTimer()
// 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start)
if (visible.value) return
// 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见
smooth.value = false
progress.value = 0
visible.value = true
// 下一帧恢复过渡并起步爬升
nextTick(() => {
smooth.value = true
progress.value = 8 // 即时起步反馈,避免前 100ms 空白
startTime = Date.now()
clearTickTimer()
tickTimer = setInterval(() => {
const elapsed = Date.now() - startTime
// 绝对超时兜底
if (elapsed > TIMEOUT_MS) {
done()
return
}
// 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移
const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT))
progress.value = Math.min(next, 99)
}, TICK_MS)
})
}
function done(): void {
clearTickTimer()
// 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用)
if (!visible.value) return
progress.value = 100
clearHideTimer()
hideTimer = setTimeout(() => {
visible.value = false
// 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置
hideTimer = null
}, HIDE_DELAY_MS)
}
export function useTopProgress() {
return { progress, visible, smooth, start, done }
}
+12
View File
@@ -4,6 +4,7 @@ import adminRoutes from './admin'
import { useAuthStore } from '@/stores/auth'
import { shouldRefresh, getRefreshToken } from '@/utils/request'
import { useSiteName } from '@/composables/useSiteName'
import { useTopProgress } from '@/composables/useTopProgress'
import axios from 'axios'
const routes: RouteRecordRaw[] = [
@@ -44,8 +45,11 @@ async function checkSetupStatus(): Promise<boolean> {
}
const { siteName, fetchSiteName } = useSiteName()
const { start, done } = useTopProgress()
router.beforeEach(async (to) => {
start()
const authStore = useAuthStore()
authStore.loadFromStorage()
@@ -82,4 +86,12 @@ router.beforeEach(async (to) => {
return true
})
router.afterEach(() => {
done()
})
router.onError(() => {
done()
})
export default router
+10 -11
View File
@@ -199,8 +199,10 @@ const availableModelOptions = computed(() => {
})
const allModelOptions = computed(() => {
return modelsList.value
.map(m => ({ label: m.model_name ? `${m.model_name} (${m.model_id})` : m.model_id, value: m.model_id }))
// 测试模型下拉列表只显示该渠道已配置的能力模型
return abilitiesData.value
.filter(a => a.enabled)
.map(a => ({ label: a.model_name, value: a.model_name }))
})
function openAddAbilityModal() {
@@ -343,7 +345,7 @@ onBeforeUnmount(() => {
function onTabChange(key: string) {
if (key === 'abilities' && abilitiesData.value.length === 0) fetchAbilities()
if (key === 'test' && modelsList.value.length === 0) fetchModels()
if (key === 'test' && abilitiesData.value.length === 0) fetchAbilities()
if (key === 'health_trend' && trendData.value.length === 0) fetchHealthTrend()
}
@@ -517,13 +519,11 @@ function formatHeaders(headers: Record<string, string>): string {
<ASelect
v-model="testModelName"
:options="allModelOptions"
:loading="modelsLoading"
:loading="abilitiesLoading"
allow-search
allow-clear
:filter-option="false"
placeholder="输入关键词搜索模型"
placeholder="选择渠道支持的模型"
:disabled="testLoading"
@search="handleModelSearch"
@keydown.enter="handleTest"
/>
</AFormItem>
@@ -698,13 +698,12 @@ function formatHeaders(headers: Record<string, string>): string {
<ASelect
v-model="addAbilityForm.model_name"
:options="availableModelOptions"
:loading="modelsLoading"
:loading="abilitiesLoading"
allow-search
allow-clear
:filter-option="false"
placeholder="输入关键词搜索模型"
placeholder="选择渠道支持的模型"
:fallback-option="false"
@search="handleModelSearch"
/>
</AFormItem>
<AFormItem label="上游模型名"><AInput v-model="addAbilityForm.upstream_model" placeholder="留空则与平台模型名相同" /></AFormItem>
+2
View File
@@ -1,10 +1,12 @@
<template>
<TopProgress />
<router-view />
<Toast />
<ConfirmDialog />
</template>
<script setup lang="ts">
import TopProgress from '@/components/common/TopProgress.vue'
import Toast from '@/components/common/Toast.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
</script>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useTopProgress } from '@/composables/useTopProgress'
const { progress, visible, smooth } = useTopProgress()
</script>
<template>
<div
class="top-progress-container fixed top-0 left-0 z-[9999] h-[3px] w-full pointer-events-none"
:class="{ 'is-visible': visible }"
>
<div
class="top-progress-bar h-full bg-gradient-to-r from-primary-500 to-primary-600"
:class="{ 'no-anim': !smooth }"
:style="{ width: progress + '%' }"
/>
</div>
</template>
<style scoped>
.top-progress-container {
opacity: 0;
transition: opacity 0.3s ease;
}
.top-progress-container.is-visible {
opacity: 1;
}
.top-progress-bar {
transition: width 0.2s ease;
}
.top-progress-bar.no-anim {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.top-progress-container,
.top-progress-bar {
transition-duration: 1ms;
}
}
</style>
@@ -0,0 +1,94 @@
import { ref, nextTick } from 'vue'
/**
* 顶部加载进度条(模块级单例)
*
* 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%,
* 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。
*
* 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。
*/
const TICK_MS = 100
/** 指数收敛时间常数:越大爬升越慢 */
const TIME_CONSTANT = 1500
/** 运行中进度上限,永远到不了 100% */
const MAX_PROGRESS = 90
/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */
const HIDE_DELAY_MS = 300
/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */
const TIMEOUT_MS = 15_000
const progress = ref(0)
const visible = ref(false)
/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */
const smooth = ref(true)
let tickTimer: ReturnType<typeof setInterval> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let startTime = 0
function clearTickTimer(): void {
if (tickTimer !== null) {
clearInterval(tickTimer)
tickTimer = null
}
}
function clearHideTimer(): void {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
}
function start(): void {
// 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」
clearHideTimer()
// 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start)
if (visible.value) return
// 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见
smooth.value = false
progress.value = 0
visible.value = true
// 下一帧恢复过渡并起步爬升
nextTick(() => {
smooth.value = true
progress.value = 8 // 即时起步反馈,避免前 100ms 空白
startTime = Date.now()
clearTickTimer()
tickTimer = setInterval(() => {
const elapsed = Date.now() - startTime
// 绝对超时兜底
if (elapsed > TIMEOUT_MS) {
done()
return
}
// 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移
const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT))
progress.value = Math.min(next, 99)
}, TICK_MS)
})
}
function done(): void {
clearTickTimer()
// 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用)
if (!visible.value) return
progress.value = 100
clearHideTimer()
hideTimer = setTimeout(() => {
visible.value = false
// 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置
hideTimer = null
}, HIDE_DELAY_MS)
}
export function useTopProgress() {
return { progress, visible, smooth, start, done }
}
+13
View File
@@ -4,6 +4,7 @@ import tenantRoutes from './tenant'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import { shouldRefresh, getRefreshToken } from '@/utils/request'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useTopProgress } from '@/composables/useTopProgress'
const routes: RouteRecordRaw[] = [
{
@@ -29,7 +30,11 @@ const router = createRouter({
routes,
})
const { start, done } = useTopProgress()
router.beforeEach((to) => {
start()
const tenantAuthStore = useTenantAuthStore()
tenantAuthStore.loadFromStorage()
@@ -72,4 +77,12 @@ router.beforeEach((to) => {
return true
})
router.afterEach(() => {
done()
})
router.onError(() => {
done()
})
export default router
+3 -2
View File
@@ -114,14 +114,15 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
persist()
}
async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number }): Promise<any> {
async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }): Promise<any> {
const { data } = await request.post('/tenant/auth/login', {
account,
password,
type,
captcha_key: captcha?.captchaKey,
captcha_x: captcha?.captchaX,
})
turnstile_token: captcha?.turnstileToken,
}, { _suppressErrorMsg: true } as any)
if (data.data?.totp_required) {
return data.data
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 是否为 Safari 浏览器。
* Safari 下"axios 异步请求完成后再提交 target=_blank 表单"常被静默拦截
* (新标签页打不开,用户卡住),故 Safari 上沿用当前页提交,其它浏览器在新标签页打开。
*/
function isSafariBrowser(): boolean {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
}
/**
* 以 POST 表单方式把支付参数提交到支付网关。
*
* 易支付 submit.php 的「页面跳转支付」标准做法:后端只返回 submit.php 的 action
* 地址 + 签名后的参数,前端构造一个隐藏表单 POST 提交。
* 相比把 sign/订单参数拼进 GET URL,更安全(不落浏览器历史与服务器日志),
* 也规避 URL 长度限制和中文/特殊字符的编码边界问题。
*
* @param url 表单 action 地址(submit.php)
* @param params 表单隐藏域参数(含 sign / sign_type)
*/
export function submitPaymentForm(url: string, params: Record<string, string>): void {
if (!url) return
const form = document.createElement('form')
form.action = url
form.method = 'POST'
// 在新标签页打开支付页,保留当前控制台不被覆盖;支付完成后由 return_url
// 在新标签页回到 /console/wallet 展示结果。Safari 例外(见 isSafariBrowser)。
if (!isSafariBrowser()) {
form.target = '_blank'
}
Object.entries(params || {}).forEach(([key, value]) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = value
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
document.body.removeChild(form)
}
/**
* 处理后端返回的支付结果:有 form 参数时走 POST 表单提交,否则普通跳转。
* 兼容未来返回纯跳转 URL 的支付渠道(如 Stripe Checkout)。
*/
export function dispatchPayment(result: {
payment_url?: string
params?: Record<string, string>
}): void {
const { payment_url, params } = result || {}
if (!payment_url) return
if (params && Object.keys(params).length > 0) {
submitPaymentForm(payment_url, params)
} else {
window.location.href = payment_url
}
}
+67 -36
View File
@@ -36,10 +36,13 @@ const provisionalToken = ref('')
const totpCode = ref('')
const totpLoading = ref(false)
// Captcha state — always required
// Captcha state
const captcha = reactive({ captchaKey: '', captchaX: 0 })
const turnstileToken = ref('')
const turnstileToken = ref('')
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
const turnstileRef = ref<InstanceType<typeof Turnstile> | null>(null)
const useTurnstile = computed(() => settings.value['turnstile_enabled'] === true)
const emailInput = ref<HTMLInputElement | null>(null)
const accountInput = ref<HTMLInputElement | null>(null)
@@ -124,15 +127,24 @@ function validateRam(): boolean {
async function handleAdminLogin() {
if (!validateAdmin()) return
if (!captcha.captchaKey || !captcha.captchaX) {
errorMsg.value = '请先完成滑块验证'
return
if (useTurnstile.value) {
if (!turnstileToken.value) {
errorMsg.value = '请先完成人机验证'
return
}
} else {
if (!captcha.captchaKey || !captcha.captchaX) {
errorMsg.value = '请先完成滑块验证'
return
}
}
loading.value = true
errorMsg.value = ''
try {
const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
const captchaPayload = useTurnstile.value
? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value }
: { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload)
if (res?.totp_required) {
provisionalToken.value = res.provisional_token
@@ -142,12 +154,16 @@ async function handleAdminLogin() {
proceedAfterLogin()
} catch (err) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
if (useTurnstile.value) {
turnstileRef.value?.reset()
errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码'
} else if (apiErr?.code === 10058) {
errorMsg.value = '滑块验证失败,请重新拖动'
captchaRef.value?.resetCaptcha()
} else {
errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码'
captchaRef.value?.resetCaptcha()
}
captchaRef.value?.resetCaptcha()
} finally {
loading.value = false
}
@@ -155,15 +171,24 @@ async function handleAdminLogin() {
async function handleRamLogin() {
if (!validateRam()) return
if (!captcha.captchaKey || !captcha.captchaX) {
errorMsg.value = '请先完成滑块验证'
return
if (useTurnstile.value) {
if (!turnstileToken.value) {
errorMsg.value = '请先完成人机验证'
return
}
} else {
if (!captcha.captchaKey || !captcha.captchaX) {
errorMsg.value = '请先完成滑块验证'
return
}
}
loading.value = true
errorMsg.value = ''
try {
const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
const captchaPayload = useTurnstile.value
? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value }
: { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload)
if (res?.totp_required) {
provisionalToken.value = res.provisional_token
@@ -173,12 +198,16 @@ async function handleRamLogin() {
proceedAfterLogin()
} catch (err) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
if (useTurnstile.value) {
turnstileRef.value?.reset()
errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码'
} else if (apiErr?.code === 10058) {
errorMsg.value = '滑块验证失败,请重新拖动'
captchaRef.value?.resetCaptcha()
} else {
errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码'
captchaRef.value?.resetCaptcha()
}
captchaRef.value?.resetCaptcha()
} finally {
loading.value = false
}
@@ -335,17 +364,8 @@ async function handleOAuthLogin(provider: string) {
</div>
<!-- Captcha -->
<SlideCaptcha ref="captchaRef" v-model="captcha" />
<!-- Error Message -->
<transition name="slide-fade">
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
<div class="flex items-start gap-2">
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
</div>
</transition>
<Turnstile v-if="useTurnstile" ref="turnstileRef" v-model="turnstileToken" :site-key="settings['turnstile_site_key']" />
<SlideCaptcha v-else ref="captchaRef" v-model="captcha" />
<!-- Remember & Forgot -->
<div class="flex items-center justify-between">
@@ -370,6 +390,16 @@ async function handleOAuthLogin(provider: string) {
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '登录中...' : '登录' }}
</button>
<!-- Error Message -->
<transition name="slide-fade">
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
<div class="flex items-start gap-2">
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
</div>
</transition>
<p class="mt-4 text-center text-xs text-gray-400">
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
<span class="mx-1">·</span>
@@ -432,17 +462,8 @@ async function handleOAuthLogin(provider: string) {
</div>
<!-- Captcha -->
<SlideCaptcha ref="captchaRef" v-model="captcha" />
<!-- Error Message -->
<transition name="slide-fade">
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
<div class="flex items-start gap-2">
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
</div>
</transition>
<Turnstile v-if="useTurnstile" ref="turnstileRef" v-model="turnstileToken" :site-key="settings['turnstile_site_key']" />
<SlideCaptcha v-else ref="captchaRef" v-model="captcha" />
<!-- Remember & Forgot -->
<div class="flex items-center justify-between">
@@ -467,6 +488,16 @@ async function handleOAuthLogin(provider: string) {
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '登录中...' : '登录' }}
</button>
<!-- Error Message -->
<transition name="slide-fade">
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
<div class="flex items-start gap-2">
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
</div>
</transition>
<p class="mt-4 text-center text-xs text-gray-400">
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
<span class="mx-1">·</span>
+36 -4
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { dispatchPayment } from '@/utils/payment'
const route = useRoute()
const wallet = ref<any>(null)
@@ -16,7 +17,7 @@ const selectedPaymentMethod = ref('')
const paymentInfo = ref<any>(null)
// Pay result notification
const payResult = ref<'success' | 'fail' | ''>('')
const payResult = ref<'success' | 'fail' | 'processing' | ''>('')
// Frozen items
const showFrozenModal = ref(false)
@@ -140,7 +141,7 @@ async function handleRecharge() {
})
const data = res.data?.data
if (data?.payment_url) {
window.location.href = data.payment_url
dispatchPayment(data)
return
}
} catch {
@@ -238,11 +239,19 @@ onMounted(() => {
// Handle pay result from return URL
const pay = route.query.pay as string
if (pay === 'success' || pay === 'fail') {
if (pay === 'success' || pay === 'fail' || pay === 'processing') {
payResult.value = pay
setTimeout(() => { payResult.value = '' }, 5000)
if (pay === 'success') {
setTimeout(() => { payResult.value = '' }, 5000)
fetchWallet()
} else if (pay === 'processing') {
// 异步回调可能仍在途:延迟刷新余额,到账后自动更新;超时后提示联系客服
fetchWallet()
setTimeout(fetchWallet, 3000)
setTimeout(fetchWallet, 8000)
setTimeout(() => { payResult.value = '' }, 12000)
} else {
setTimeout(() => { payResult.value = '' }, 5000)
}
}
})
@@ -294,6 +303,18 @@ onBeforeUnmount(() => {
<Icon name="x" size="sm" />
</button>
</div>
<div v-else-if="payResult === 'processing'" class="pay-banner pay-banner-processing">
<div class="pay-banner-icon">
<Icon name="infoCircle" size="sm" />
</div>
<div class="flex-1">
<p class="text-sm font-semibold">支付结果确认中</p>
<p class="text-xs opacity-80 mt-0.5">正在等待到账,余额会在确认后自动更新</p>
</div>
<button class="opacity-60 hover:opacity-100 transition-opacity p-1" @click="payResult = ''">
<Icon name="x" size="sm" />
</button>
</div>
</transition>
<!-- ============================================ -->
@@ -712,6 +733,17 @@ onBeforeUnmount(() => {
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #dc2626;
}
.pay-banner-processing {
background-color: #fffbeb;
border: 1px solid rgba(252, 211, 77, 0.6);
color: #92400e;
}
.pay-banner-processing .pay-banner-icon {
height: 2rem; width: 2rem; border-radius: 9999px;
background-color: #fef3c7;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #d97706;
}
/* ==========================================
Balance Hero Card