diff --git a/docs/快手小店应用申请MRD_PRD.md b/docs/快手小店应用申请MRD_PRD.md new file mode 100644 index 0000000..9a8301f --- /dev/null +++ b/docs/快手小店应用申请MRD_PRD.md @@ -0,0 +1,187 @@ +# 快手小店应用服务 MRD/PRD + +## 1. 开发者公司介绍 + +### a) 公司体量、人员数量 + +本应用由自研技术团队开发和维护,团队具备前后端开发、产品运营、客服处理和系统运维能力。系统已完成商品发布、订单履约、商家交接、买家确认、售后争议、后台管理等核心能力建设。 + +公司主体、人员数量、联系方式等信息请按实际情况补充。 + +### b) 公司提供的所有服务应用 + +目前主要提供虚拟商品/服务类订单履约系统,包含商家商品管理、订单管理、商家发码、买家确认、核销记录、客服介入、证据上传和后台审计等服务。 + +## 2. 应用服务在其他平台表现 + +### a) 使用用户数量、活跃用户量、留存率等 + +当前应用处于自研和业务验证阶段,已完成核心业务流程搭建。 + +### b) 销售价格报表 + +- 目前为商家自研自用,无需价格相关 + +## 3. 应用服务功能简介 + +### a) 功能介绍 + +本应用面向快手小店商家,主要解决虚拟服务、数字权益、预约服务等订单的发码和核销问题。 + +核心功能包括: + +- 同步快手小店订单。 +- 商家在订单支付后发码或提交交接说明。 +- 买家查看发码内容并确认收到。 +- 商家根据订单或核销码完成核销。 +- 保存发码、确认、核销和售后处理记录。 +- 支持客服查看订单、交接记录、证据附件和操作日志。 + +### b) 功能使用流程 + +1. 买家在快手小店下单并完成支付。 +2. 应用同步订单信息,生成待发码订单。 +3. 商家进入后台,对订单填写服务码、交接说明或履约信息。 +4. 买家查看发码内容并确认收到。 +5. 买家使用服务时出示订单或核销码。 +6. 商家核验后完成核销。 +7. 如出现售后问题,客服可查看全链路记录并处理。 + +## 4. 应用服务功能详述 + +### a) 详细介绍功能点 + +1. 订单管理 + 商家可查看快手订单状态,筛选待发码、待核销、已完成、售后中订单。 + +2. 商家发码 + 商家可对已支付订单填写服务码、预约码、账号交接说明或其他履约内容,并保存发码记录。 + +3. 买家确认 + 买家收到服务信息后,可确认已收到,系统记录确认时间和订单状态。 + +4. 商家核销 + 商家输入或扫描核销码,系统校验订单是否已支付、是否已发码、是否重复核销、是否存在售后。校验通过后完成核销。 + +5. 履约留痕 + 系统保存发码记录、核销记录、聊天记录、证据附件和客服处理记录,方便后续查询。 + +6. 售后处理 + 如订单出现异常,客服可查看订单详情、发码内容、核销状态和证据材料,并进行处理。 + +### b) 系统主要功能截图 + +提交前建议补充以下全景截图: + +- 商家订单列表页。 +- 订单详情页。 +- 商家发码页面。 +- 商家核销页面。 +- 售后/客服处理页面。 +- 后台审计日志页面。 + +### c) 系统子功能截图 + +提交前建议补充以下全景截图: + +- 订单筛选功能。 +- 发码内容填写功能。 +- 买家确认功能。 +- 核销成功/失败提示。 +- 证据上传功能。 +- 操作日志详情。 + +## 5. 应用服务实现机制 + +### a) 服务数据层 + +系统后端使用 Go + Gin,数据库使用 MySQL,文件存储使用对象存储,前端使用 Vue 3 + TypeScript。 + +主要数据包括: + +- 商家/买家用户信息。 +- 快手订单映射信息。 +- 发码记录。 +- 核销记录。 +- 售后和争议记录。 +- 文件证据。 +- 后台操作审计日志。 + +### b) 服务业务流层 + +业务流程为: + +快手订单支付成功 -> 应用同步订单 -> 商家发码 -> 买家确认 -> 商家核销 -> 订单完成。 + +异常流程为: + +订单售后中或退款中 -> 暂停核销 -> 客服查看证据 -> 处理售后结果 -> 恢复或关闭订单。 + +### c) 服务接口调用 + +系统会调用快手开放平台接口完成订单同步、订单状态回调、履约状态反馈和核销状态反馈。内部接口用于商家发码、买家确认、商家核销、记录查询和后台审核。 + +## 6. 应用服务对接接口和反馈字段 + +### a) 具体接口调用和字段反馈 + +拟对接能力: + +- 店铺授权接口:获取商家店铺授权。 +- 订单查询接口:同步订单号、商品、SKU、支付状态、售后状态。 +- 订单回调接口:接收支付成功、关闭、退款、售后变更。 +- 履约反馈接口:商家发码后反馈发码状态。 +- 核销反馈接口:商家核销后反馈核销结果。 + +主要字段: + +- `shop_id`:店铺 ID。 +- `order_id`:快手订单 ID。 +- `item_id`:商品 ID。 +- `sku_id`:SKU ID。 +- `order_status`:订单状态。 +- `pay_time`:支付时间。 +- `delivery_status`:发码状态。 +- `verify_code`:核销码。 +- `verify_status`:核销状态。 +- `verify_time`:核销时间。 + +### b) 回调地址、回调原因 + +正式域名待补充,建议配置: + +- `POST https://{正式域名}/api/open/kuaishou/callbacks/order`:订单支付、关闭、状态变化回调。 +- `POST https://{正式域名}/api/open/kuaishou/callbacks/after-sale`:退款、售后状态变化回调。 +- `POST https://{正式域名}/api/open/kuaishou/callbacks/verify`:核销状态变化回调。 + +回调处理会进行签名校验、幂等判断、状态更新和日志记录。 + +## 7. 应用服务服务市场信息 + +### a) 应用 icon 图 + +待补充。建议图标突出“发码、核销、订单履约”含义,不使用快手官方标识。 + +### b) 应用服务介绍 + +快手小店发码核销助手是一款面向商家的订单履约工具。商家可在订单支付后快速发码,买家确认收到后,商家可根据订单或核销码完成核销。系统会保存发码、确认、核销、售后和客服处理记录,帮助商家提升履约效率,减少漏发、错发、重复核销和售后纠纷。 + +### c) 价格表 + +价格待补充,可参考: + +| 版本 | 功能 | 价格 | +| --- | --- | --- | +| 基础版 | 订单同步、手动发码、手动核销 | 待补充 | +| 专业版 | 批量发码、核销统计、证据上传 | 待补充 | +| 企业版 | 多店铺、多角色、审计报表、接口日志 | 待补充 | + +## 8. 自研发软件著作权 + +待补充。 + +如已有软件著作权,请补充证书截图、证书编号、软件名称、著作权人信息,并确保著作权人与店铺或开发者实名认证主体一致。 + +如暂未取得,可备注: + +本系统为自研系统,相关软件著作权材料正在准备中。系统代码、业务流程、前后端页面和数据库设计均由开发团队自主研发。 diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts index 319ad7b..1459b94 100644 --- a/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts @@ -1,12 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { LocationQuery } from 'vue-router' import { + clearStoredHomeQuery, decodeHomeQueryJson, encodeHomeQueryJson, + hasHomeQueryValues, isSameQuery, mergeHomeQuery, + readStoredHomeQuery, readQueryNumber, readQueryString, + storeHomeQuery, } from '@/features/listings/composables/useHomeFilterQuery' describe('useHomeFilterQuery', () => { @@ -51,4 +55,27 @@ describe('useHomeFilterQuery', () => { server: [], }) }) + + it('应该判断并缓存首页筛选参数', () => { + const storage = new Map() + vi.stubGlobal('window', { + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }) + const storageKey = 'hfb.home.filters.test' + const query = { keyword: '测试', zone: 'gift' } + + try { + expect(hasHomeQueryValues(query, ['keyword', 'zone'])).toBe(true) + storeHomeQuery(storageKey, query) + expect(readStoredHomeQuery(storageKey)).toEqual(query) + clearStoredHomeQuery(storageKey) + expect(readStoredHomeQuery(storageKey)).toEqual({}) + } finally { + vi.unstubAllGlobals() + } + }) }) diff --git a/frontend/src/features/listings/composables/useHomeFilterQuery.ts b/frontend/src/features/listings/composables/useHomeFilterQuery.ts index 0ccfb46..09656d5 100644 --- a/frontend/src/features/listings/composables/useHomeFilterQuery.ts +++ b/frontend/src/features/listings/composables/useHomeFilterQuery.ts @@ -29,13 +29,16 @@ export const mobileHomeFilterQueryKeys = ['keyword', 'sort', 'zone', 'filters', export type HomeQueryValue = string | number | undefined -export function readQueryString(query: LocationQuery, key: string) { +export const desktopHomeFilterStorageKey = 'hfb.home.filters.desktop' +export const mobileHomeFilterStorageKey = 'hfb.home.filters.mobile' + +export function readQueryString(query: LocationQuery | LocationQueryRaw, key: string) { const value = query[key] if (Array.isArray(value)) return String(value[0] || '') - return typeof value === 'string' ? value : '' + return value === undefined || value === null ? '' : String(value) } -export function readQueryNumber(query: LocationQuery, key: string) { +export function readQueryNumber(query: LocationQuery | LocationQueryRaw, key: string) { const value = readQueryString(query, key) if (value === '') return undefined const number = Number(value) @@ -66,12 +69,62 @@ export function isSameQuery(left: LocationQuery, right: LocationQueryRaw) { return stableQuerySignature(left) === stableQuerySignature(right) } +export function hasHomeQueryValues( + query: LocationQuery | LocationQueryRaw, + keys: readonly string[] +) { + return keys.some(key => { + const value = query[key] + if (Array.isArray(value)) return value.some(item => item !== undefined && item !== '') + return value !== undefined && value !== '' + }) +} + +export function readStoredHomeQuery(storageKey: string): LocationQueryRaw { + if (typeof window === 'undefined') return {} + try { + const raw = window.sessionStorage.getItem(storageKey) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const query: LocationQueryRaw = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string' && value !== '') query[key] = value + } + return query + } catch { + return {} + } +} + +export function storeHomeQuery(storageKey: string, query: LocationQueryRaw) { + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(storageKey, JSON.stringify(query)) + } catch { + // 浏览器禁用 sessionStorage 时忽略,URL 仍然可用。 + } +} + +export function clearStoredHomeQuery(storageKey: string) { + if (typeof window === 'undefined') return + try { + window.sessionStorage.removeItem(storageKey) + } catch { + // 浏览器禁用 sessionStorage 时忽略。 + } +} + export function encodeHomeQueryJson(value: unknown) { if (!hasMeaningfulValue(value)) return undefined return JSON.stringify(value) } -export function decodeHomeQueryJson(query: LocationQuery, key: string, fallback: T): T { +export function decodeHomeQueryJson( + query: LocationQuery | LocationQueryRaw, + key: string, + fallback: T +): T { const raw = readQueryString(query, key) if (!raw) return fallback try { diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index ba58be6..d27b7fd 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -13,11 +13,16 @@ import { } from '@/features/listings/api/listingOptions' import { useHomeFilters } from '@/features/listings/composables/useHomeFilters' import { + clearStoredHomeQuery, + desktopHomeFilterStorageKey, desktopHomeFilterQueryKeys, + hasHomeQueryValues, isSameQuery, mergeHomeQuery, + readStoredHomeQuery, readQueryNumber, readQueryString, + storeHomeQuery, type HomeQueryValue, } from '@/features/listings/composables/useHomeFilterQuery' import { useListingQuery } from '@/features/listings/composables/useListingQuery' @@ -151,6 +156,7 @@ function updateFilters(partial: Partial) { } function handleResetFilters() { + clearStoredHomeQuery(desktopHomeFilterStorageKey) resetFilters() activeZone.value = 'all' sortBy.value = 'recommended' @@ -158,7 +164,10 @@ function handleResetFilters() { } function applyRouteQueryToHomeState() { - const query = route.query + const storedQuery = readStoredHomeQuery(desktopHomeFilterStorageKey) + const query = hasHomeQueryValues(route.query, desktopHomeFilterQueryKeys) + ? route.query + : storedQuery filters.keyword = readQueryString(query, 'keyword') filters.server = readQueryString(query, 'server') filters.region = readQueryString(query, 'region') @@ -193,6 +202,9 @@ function syncHomeQuery() { desktopHomeFilterQueryKeys, buildDesktopHomeQueryValues() ) + if (hasHomeQueryValues(query, desktopHomeFilterQueryKeys)) { + storeHomeQuery(desktopHomeFilterStorageKey, query) + } if (isSameQuery(route.query, query)) return router.replace({ path: '/', query }) } @@ -224,6 +236,7 @@ function buildDesktopHomeQueryValues(): Record { } } +syncHomeQuery() loadHome() diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index c7a4310..ba72c3f 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -23,12 +23,17 @@ import { } from '@/features/listings/api/homeConfig' import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue' import { + clearStoredHomeQuery, decodeHomeQueryJson, encodeHomeQueryJson, + hasHomeQueryValues, isSameQuery, mergeHomeQuery, + mobileHomeFilterStorageKey, mobileHomeFilterQueryKeys, + readStoredHomeQuery, readQueryString, + storeHomeQuery, type HomeQueryValue, } from '@/features/listings/composables/useHomeFilterQuery' import { @@ -384,6 +389,7 @@ function selectZone(zoneKey: string) { } function clearFilters() { + clearStoredHomeQuery(mobileHomeFilterStorageKey) selectedFilters.value = {} rangeFilters.value = {} searchValue.value = '' @@ -466,7 +472,10 @@ function parseOptionalNumber(value: string) { } function applyRouteQueryToMobileState() { - const query = route.query + const storedQuery = readStoredHomeQuery(mobileHomeFilterStorageKey) + const query = hasHomeQueryValues(route.query, mobileHomeFilterQueryKeys) + ? route.query + : storedQuery searchValue.value = readQueryString(query, 'keyword') activeSort.value = readQueryString(query, 'sort') || 'recommended' activeZone.value = readQueryString(query, 'zone') || 'all' @@ -480,6 +489,9 @@ function applyRouteQueryToMobileState() { function syncMobileHomeQuery() { const query = mergeHomeQuery(route.query, mobileHomeFilterQueryKeys, buildMobileHomeQueryValues()) + if (hasHomeQueryValues(query, mobileHomeFilterQueryKeys)) { + storeHomeQuery(mobileHomeFilterStorageKey, query) + } if (isSameQuery(route.query, query)) return router.replace({ path: route.path, query }) } @@ -572,6 +584,8 @@ function chipTone(label: string) { if (label.includes('方便')) return 'time' return 'default' } + +syncMobileHomeQuery()