diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index ba9f0ff..c72b8f1 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -32,6 +32,11 @@ type OrderAgreementsDTO struct { RenterAgreement AgreementContentDTO `json:"renter_agreement"` } +type PostRentalNoticeDTO struct { + Title string `json:"title"` + Content string `json:"content"` +} + type AgreementContentDTO struct { Title string `json:"title"` Content string `json:"content"` diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index 1a451df..2952258 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -54,6 +54,15 @@ func (h *Handler) OrderAgreements(c *gin.Context) { response.OK(c, agreements) } +func (h *Handler) PostRentalNotice(c *gin.Context) { + notice, err := h.service.PostRentalNotice() + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, notice) +} + func (h *Handler) HomeAnnouncements(c *gin.Context) { announcements, err := h.service.HomeAnnouncements() if err != nil { diff --git a/backend/internal/modules/systemconfig/post_rental_notice.go b/backend/internal/modules/systemconfig/post_rental_notice.go new file mode 100644 index 0000000..9929716 --- /dev/null +++ b/backend/internal/modules/systemconfig/post_rental_notice.go @@ -0,0 +1,52 @@ +package systemconfig + +import "encoding/json" + +const postRentalNoticeConfigKey = "profile.post_rental_notice" + +func defaultPostRentalNoticeConfigValue() string { + raw, err := json.Marshal(DefaultPostRentalNotice()) + if err != nil { + return "{}" + } + return string(raw) +} + +func DefaultPostRentalNotice() PostRentalNoticeDTO { + return PostRentalNoticeDTO{ + Title: "租后须知(重要!)", + Content: `一、上号前须知 + +1. 租期说明 +租期内无法退租(除非发现不可抗力),如遇游戏的风控策略调整导致账号异常,如账号主动提交过人脸识别认证(按交易协议比例赔偿)。 + +2. 账号信息不齐 +租客流需在首次上号后10分钟完成验号,超时则单次以租期已开始计算,此后中断与异常问题由租客自行承担。 + +仅以在首次上号10分钟之前完成查验; + +未上号马号主与关联等,如果出现异常抢拍后经过时,除押金全额返还后,可能会影响结账比例规则判定。 + +二、账号使用规范 + +3. 切勿使用规制 +账号内绑"绑币"外,其它物品谨慎消耗,送意开货时用品、子弹包、资源包、全能包、合金包。 + +活动赠送或抽奖券及二次充值。 + +若账号因租客违规操作、使用外挂、恶意消耗、转移资产或违反游戏规则造成损失,可能从押金中扣除或进入争议处理,根本扣除额按按违规严重程度而定。 + +4. 禁止转租 + +不可转租、转借或用于任何非订单目的。 + +号主无法输入人脸验证;按交接规则卫按级号号不得修改账号绑定信息或任何影响号主取回账号的内容。 + +三、封禁处理规则 + +5. 账号封禁 +账号封禁判责规则按"按照订单记录、截图证据、聊天记录和双方说明"进行判断,若租客操作符合规则但仍封禁,须按封禁情况审核后按实际损失或押金比例约40%补偿号主。 + +不可转按换现金或按时归还后不予退租,按照平台规则处理退款和赔付。`, + } +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index c6bbd1f..751146f 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -32,6 +32,7 @@ var defaultConfigs = []defaultConfig{ {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, + {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, {Key: "chat.default_support_admin_id", Value: "1", Description: "订单群聊回退客服 ID(仅当无可用客服时使用)"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, @@ -54,6 +55,7 @@ var adminVisibleConfigKeys = []string{ "order.agreements", "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", + "profile.post_rental_notice", } func NewRepository(db *gorm.DB) *Repository { diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index 46afb4f..1df39a6 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -59,6 +59,17 @@ func (s *Service) OrderAgreements() (*OrderAgreementsDTO, error) { return &agreements, nil } +func (s *Service) PostRentalNotice() (*PostRentalNoticeDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + notice, err := s.postRentalNotice() + if err != nil { + return nil, err + } + return ¬ice, nil +} + func (s *Service) publishOptions() (PublishOptionsDTO, error) { value, err := s.repo.FindValue(publishOptionsConfigKey) if err != nil { @@ -98,6 +109,19 @@ func (s *Service) orderAgreements() (OrderAgreementsDTO, error) { return agreements, nil } +func (s *Service) postRentalNotice() (PostRentalNoticeDTO, error) { + value, err := s.repo.FindValue(postRentalNoticeConfigKey) + if err != nil { + return PostRentalNoticeDTO{}, err + } + notice := DefaultPostRentalNotice() + if err := json.Unmarshal([]byte(value), ¬ice); err != nil { + notice = DefaultPostRentalNotice() + } + normalizePostRentalNotice(¬ice) + return notice, nil +} + func (s *Service) HomeAnnouncements() (*HomeAnnouncementsDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -223,6 +247,18 @@ func normalizeOrderAgreements(agreements *OrderAgreementsDTO) { } } +func normalizePostRentalNotice(notice *PostRentalNoticeDTO) { + defaults := DefaultPostRentalNotice() + notice.Title = strings.TrimSpace(notice.Title) + notice.Content = strings.TrimSpace(notice.Content) + if notice.Title == "" { + notice.Title = defaults.Title + } + if notice.Content == "" { + notice.Content = defaults.Content + } +} + func normalizePublishOptions(options *PublishOptionsDTO) { defaults := DefaultPublishOptions() if len(options.ServerOptions) == 0 { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 45f202d..49a8efd 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -196,6 +196,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig) api.GET("/order-agreements", systemConfigHandler.OrderAgreements) + api.GET("/post-rental-notice", systemConfigHandler.PostRentalNotice) api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/public/files/object", fileHandler.PublicObject) diff --git a/backend/migrations/test_index_performance.sql b/backend/migrations/test_index_performance.sql deleted file mode 100644 index 24fbd73..0000000 --- a/backend/migrations/test_index_performance.sql +++ /dev/null @@ -1,200 +0,0 @@ --- ============================================ --- 数据库索引性能测试脚本 --- 用途: 对比索引创建前后的查询性能 --- 使用方法: mysql -u root -p hfb_sys < test_index_performance.sql --- ============================================ - -SET NAMES utf8mb4; - --- 关闭查询缓存,确保测试准确性 -SET SESSION query_cache_type = OFF; - --- 开启性能分析 -SET profiling = 1; - --- ============================================ --- 测试 1: 订单状态+创建时间查询 --- ============================================ - --- 清空缓冲区 -RESET QUERY CACHE; - --- 查看执行计划 -EXPLAIN SELECT * FROM rental_orders -WHERE status = 'active' -ORDER BY created_at DESC -LIMIT 20\G - --- 执行查询并记录时间 -SELECT '测试 1: 订单状态+创建时间查询' AS test_name; -SELECT * FROM rental_orders -WHERE status = 'active' -ORDER BY created_at DESC -LIMIT 20; - --- ============================================ --- 测试 2: 钱包流水按用户+业务类型查询 --- ============================================ - -RESET QUERY CACHE; - -EXPLAIN SELECT * FROM wallet_ledger -WHERE user_id = 1 AND biz_type = 'order_payment' -ORDER BY created_at DESC -LIMIT 20\G - -SELECT '测试 2: 钱包流水按用户+业务类型查询' AS test_name; -SELECT * FROM wallet_ledger -WHERE user_id = 1 AND biz_type = 'order_payment' -ORDER BY created_at DESC -LIMIT 20; - --- ============================================ --- 测试 3: 订单结算状态查询 --- ============================================ - -RESET QUERY CACHE; - -EXPLAIN SELECT * FROM rental_orders -WHERE settlement_status = 'unsettled' AND owner_id = 1 -ORDER BY created_at -LIMIT 20\G - -SELECT '测试 3: 订单结算状态查询' AS test_name; -SELECT * FROM rental_orders -WHERE settlement_status = 'unsettled' AND owner_id = 1 -ORDER BY created_at -LIMIT 20; - --- ============================================ --- 测试 4: 商品发布时间查询 --- ============================================ - -RESET QUERY CACHE; - -EXPLAIN SELECT * FROM rental_listings -WHERE status = 'active' AND review_status = 'approved' -ORDER BY published_at DESC -LIMIT 20\G - -SELECT '测试 4: 商品发布时间查询' AS test_name; -SELECT * FROM rental_listings -WHERE status = 'active' AND review_status = 'approved' -ORDER BY published_at DESC -LIMIT 20; - --- ============================================ --- 测试 5: 用户实名认证状态查询 --- ============================================ - -RESET QUERY CACHE; - -EXPLAIN SELECT * FROM users -WHERE realname_status = 'verified' AND status = 'active' -LIMIT 100\G - -SELECT '测试 5: 用户实名认证状态查询' AS test_name; -SELECT COUNT(*) FROM users -WHERE realname_status = 'verified' AND status = 'active'; - --- ============================================ --- 显示性能分析结果 --- ============================================ - --- 显示所有查询的性能概览 -SELECT '性能测试结果' AS title; -SHOW PROFILES; - --- ============================================ --- 索引使用情况检查 --- ============================================ - -SELECT '索引使用情况检查' AS title; - --- 检查新增索引是否存在 -SELECT - TABLE_NAME, - INDEX_NAME, - GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns -FROM information_schema.STATISTICS -WHERE TABLE_SCHEMA = DATABASE() - AND INDEX_NAME IN ( - 'idx_rental_orders_status_created_at', - 'idx_wallet_ledger_user_biz_created', - 'idx_rental_orders_settlement', - 'idx_rental_listings_published', - 'idx_users_realname_status' - ) -GROUP BY TABLE_NAME, INDEX_NAME -ORDER BY TABLE_NAME, INDEX_NAME; - --- ============================================ --- 慢查询统计 --- ============================================ - -SELECT '慢查询统计' AS title; -SHOW GLOBAL STATUS LIKE 'Slow_queries'; - --- ============================================ --- 索引基数检查(检查索引选择性) --- ============================================ - -SELECT '索引基数检查' AS title; - --- rental_orders 表索引基数 -SELECT - INDEX_NAME, - SEQ_IN_INDEX, - COLUMN_NAME, - CARDINALITY, - SUB_PART, - NULLABLE -FROM information_schema.STATISTICS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'rental_orders' - AND INDEX_NAME LIKE 'idx_%' -ORDER BY INDEX_NAME, SEQ_IN_INDEX; - --- wallet_ledger 表索引基数 -SELECT - INDEX_NAME, - SEQ_IN_INDEX, - COLUMN_NAME, - CARDINALITY, - SUB_PART, - NULLABLE -FROM information_schema.STATISTICS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'wallet_ledger' - AND INDEX_NAME LIKE 'idx_%' -ORDER BY INDEX_NAME, SEQ_IN_INDEX; - --- ============================================ --- 表统计信息 --- ============================================ - -SELECT '表统计信息' AS title; - -SELECT - TABLE_NAME, - TABLE_ROWS, - AVG_ROW_LENGTH, - DATA_LENGTH, - INDEX_LENGTH, - ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb, - ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb, - ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb -FROM information_schema.TABLES -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME IN ('rental_orders', 'wallet_ledger', 'rental_listings', 'users') -ORDER BY TABLE_NAME; - --- ============================================ --- 测试完成 --- ============================================ - -SELECT '索引性能测试完成!' AS message; -SELECT '请查看 SHOW PROFILES 的结果,对比优化前后的查询时间' AS tips; - --- 关闭性能分析 -SET profiling = 0; diff --git a/frontend/src/features/admin/components/PostRentalNoticeDialog.vue b/frontend/src/features/admin/components/PostRentalNoticeDialog.vue new file mode 100644 index 0000000..2d887bb --- /dev/null +++ b/frontend/src/features/admin/components/PostRentalNoticeDialog.vue @@ -0,0 +1,195 @@ + + + + + + + {{ config.key }} + 恢复默认文本 + + + + + + + + + + + + + + + + + 取消 + 保存 + + + + + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 0e1c564..a25348c 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -27,6 +27,7 @@ import SalePriceDialog from '../components/SalePriceDialog.vue' import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue' import HomeBannersDialog from '../components/HomeBannersDialog.vue' import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue' +import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' import GeneralConfigDialog from '../components/GeneralConfigDialog.vue' import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue' @@ -51,6 +52,7 @@ const salePriceVisible = ref(false) const announcementsVisible = ref(false) const bannersVisible = ref(false) const agreementsVisible = ref(false) +const postRentalNoticeVisible = ref(false) const generalVisible = ref(false) const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null) @@ -58,6 +60,7 @@ const salePriceConfig = computed(() => configs.value.find((item) => item.key === const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null) const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null) const orderAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'order.agreements') || null) +const postRentalNoticeConfig = computed(() => configs.value.find((item) => item.key === 'profile.post_rental_notice') || null) const regularConfigs = computed(() => configs.value.filter( @@ -66,7 +69,8 @@ const regularConfigs = computed(() => item.key !== 'listing.sale_price_config' && item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_banners' && - item.key !== 'order.agreements', + item.key !== 'order.agreements' && + item.key !== 'profile.post_rental_notice', ), ) @@ -136,6 +140,8 @@ function openEdit(row: SystemConfig) { bannersVisible.value = true } else if (row.key === 'order.agreements') { agreementsVisible.value = true + } else if (row.key === 'profile.post_rental_notice') { + postRentalNoticeVisible.value = true } else { generalVisible.value = true } @@ -356,6 +362,39 @@ function formatConfigValue(row: SystemConfig) { + + + + Post Rental Notice + 租后须知配置 + 管理移动端个人中心展示的租后须知内容,用户在实名认证下方可查看。 + + 编辑租后须知 + + + + 1 + 须知文档 + + + 移动端 + 个人中心展示 + + + 实名认证 + 下方位置 + + + 接口 + /api/post-rental-notice + + + 更新 + {{ formatHomeConfigStatus(postRentalNoticeConfig, '未初始化') }} + + + + @@ -414,6 +453,13 @@ function formatConfigValue(row: SystemConfig) { @saved="loadConfigs" /> + + ([]); +// 租后须知 +const showPostRentalNotice = ref(false); +const loadingPostRentalNotice = ref(false); +const postRentalNotice = ref(null); + const settingsGroups = [ { title: "账号与安全", @@ -108,6 +114,20 @@ async function openLedgers() { } } +async function openPostRentalNotice() { + showPostRentalNotice.value = true; + if (!postRentalNotice.value) { + loadingPostRentalNotice.value = true; + try { + postRentalNotice.value = await fetchPostRentalNotice(); + } catch { + showToast({ message: "无法获取租后须知", icon: "cross" }); + } finally { + loadingPostRentalNotice.value = false; + } + } +} + function handleWithdraw() { showDialog({ title: "提现提示", @@ -348,6 +368,7 @@ function resolveAvatarURL(url: string | undefined | null) { + @@ -382,6 +403,27 @@ function resolveAvatarURL(url: string | undefined | null) { + + + + {{ postRentalNotice?.title || '租后须知' }} + ✕ + + + 加载中... + + {{ postRentalNotice.content }} + + + + + +import { InfoFilled } from '@element-plus/icons-vue' +import { onMounted, ref } from 'vue' +import { useRouter } from 'vue-router' + +import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders' +import { useSessionStore } from '@/stores/session' + +const session = useSessionStore() +const router = useRouter() +const loading = ref(false) +const notice = ref(null) + +onMounted(async () => { + if (!session.isLoggedIn) { + router.replace({ path: '/login', query: { redirect: '/post-rental-notice' } }) + return + } + await loadNotice() +}) + +async function loadNotice() { + loading.value = true + try { + notice.value = await fetchPostRentalNotice() + } catch (error) { + console.error('Failed to load post rental notice:', error) + } finally { + loading.value = false + } +} + + + + + + + + + + Post Rental Notice + {{ notice?.title || '租后须知' }} + 租赁账号后的重要须知事项,请仔细阅读 + + + + + + + {{ notice.content }} + + + + + + + + diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 80378a7..3837fed 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -108,6 +108,11 @@ export interface OrderAgreements { renter_agreement: OrderAgreementContent } +export interface PostRentalNotice { + title: string + content: string +} + export interface SubmitCheckoutPayload { content: string consumable_amount: number @@ -137,6 +142,11 @@ export async function fetchOrderAgreements() { return data.data } +export async function fetchPostRentalNotice() { + const { data } = await apiClient.get>('/post-rental-notice') + return data.data +} + export async function payOrder(id: number) { const { data } = await apiClient.post>(`/orders/${id}/pay`) return data.data diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts index b159460..a851d32 100644 --- a/frontend/src/features/seller/composables/usePublishDraft.ts +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -88,6 +88,10 @@ function normalizeDraftForm(value: unknown): PublishForm { next.common_regions = Array.isArray(value.common_regions) ? value.common_regions.filter((region): region is string => typeof region === 'string') : [] + // 默认使用参考比例:如果草稿中的 accelerated_sale_ratio 是 0 或未设置,重置为空字符串 + if (!next.accelerated_sale_ratio || next.accelerated_sale_ratio === 0) { + next.accelerated_sale_ratio = '' + } return next } diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 5bd8f9f..039e263 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -11,6 +11,7 @@ import { EditPen, Finished, House, + InfoFilled, Search, Service, Shop, @@ -203,6 +204,14 @@ async function handleSupportClick() { {{ realnameBadge.text }} + + + 租后须知 + diff --git a/frontend/src/router/accountRoutes.ts b/frontend/src/router/accountRoutes.ts index df00939..33b97ad 100644 --- a/frontend/src/router/accountRoutes.ts +++ b/frontend/src/router/accountRoutes.ts @@ -48,6 +48,12 @@ export const accountRoutes: RouteRecordRaw[] = [ component: () => import('@/features/auth/views/RealnameView.vue'), meta: { requiresAuth: true }, }, + { + path: '/post-rental-notice', + name: 'post-rental-notice', + component: () => import('@/features/auth/views/PostRentalNoticeView.vue'), + meta: { requiresAuth: true }, + }, { path: '/messages', name: 'messages',
{{ config.key }}
Post Rental Notice
租赁账号后的重要须知事项,请仔细阅读