From 23ba639e9bfd8be79f2770c7a8ebe7b7663ecc0f Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 8 Sep 2026 10:07:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?v4.0.18=E7=89=88=E6=9C=AC=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/dukang-coding/reference-backend.md | 2 +- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 2 + apps/admin-web/src/lib/hq-log.ts | 2 + apps/admin-web/src/pages/BankAccountsPage.tsx | 389 ++++++++++++++++++ .../admin-web/src/pages/HqPermissionsPage.tsx | 2 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 243 ++++++++--- apps/mini-user/src/lib/store-display.ts | 32 +- .../src/pages/store-detail/index.tsx | 7 +- apps/mini-user/src/styles/store-detail.css | 1 + docs/企微API插件-配置手册.md | 4 +- docs/杜康好客-v3-现状对照.md | 3 +- docs/杜康好客-v3.5.15-开发文档.md | 4 +- docs/杜康好客-v3.5.16-开发文档.md | 2 +- docs/杜康好客-v3编码手册.md | 4 +- docs/杜康好客-v4-PRD.md | 25 +- docs/杜康好客-v4-现状对照.md | 6 +- docs/杜康好客-v4.0.18-开发文档.md | 80 +++- packages/domain/src/index.ts | 1 + packages/domain/src/store-address.test.ts | 31 ++ packages/domain/src/store-address.ts | 29 ++ packages/domain/src/wecom-report.test.ts | 15 + packages/domain/src/wecom-report.ts | 14 + packages/shared-types/src/hq-list-columns.ts | 1 + packages/shared-types/src/settlement.ts | 39 ++ packages/shared-types/src/shop.ts | 25 ++ .../shared-types/src/wecom-message-push.ts | 4 +- .../migrate-finance-bank-account-v4018.sql | 26 ++ server/dukang-api/prisma/schema.prisma | 36 ++ .../hq-operation/hq-operation.constants.ts | 4 + .../wecom/wecom-message-push.service.ts | 40 +- .../wecom/wecom-plugin-query.service.ts | 14 +- .../wecom/wecom-plugin.openapi.ts | 2 +- .../wecom/wecom-push-template.defaults.ts | 19 +- .../modules/ops/admin-stores.controller.ts | 33 ++ .../src/modules/ops/admin-stores.service.ts | 182 +++++++- .../ops/admin-wecom-reports.service.ts | 10 +- .../src/modules/ops/dto/admin-mutate.dto.ts | 57 +++ .../finance-bank-account.service.ts | 354 ++++++++++++++++ .../settlement/finance-bank-export.util.ts | 83 ++++ .../settlement/settlement.controller.ts | 88 +++- .../modules/settlement/settlement.module.ts | 5 +- .../modules/settlement/settlement.service.ts | 34 +- .../settlement/wecom-bill-digest.test.ts | 68 +++ .../modules/settlement/wecom-bill-digest.ts | 51 ++- .../src/modules/store/store.service.ts | 9 +- 46 files changed, 1922 insertions(+), 162 deletions(-) create mode 100644 apps/admin-web/src/pages/BankAccountsPage.tsx create mode 100644 packages/domain/src/store-address.test.ts create mode 100644 packages/domain/src/store-address.ts create mode 100644 server/dukang-api/prisma/migrate-finance-bank-account-v4018.sql create mode 100644 server/dukang-api/src/modules/settlement/finance-bank-account.service.ts create mode 100644 server/dukang-api/src/modules/settlement/finance-bank-export.util.ts create mode 100644 server/dukang-api/src/modules/settlement/wecom-bill-digest.test.ts diff --git a/.cursor/skills/dukang-coding/reference-backend.md b/.cursor/skills/dukang-coding/reference-backend.md index 2bf3cc1..98c2006 100644 --- a/.cursor/skills/dukang-coding/reference-backend.md +++ b/.cursor/skills/dukang-coding/reference-backend.md @@ -102,7 +102,7 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。 | `/admin/users` | C 端用户 | | `/admin/orders` | 订单 | | `/admin/stores` | 门店 | -| `/admin/store-accounts` | 门店账号 | +| `/admin/store-accounts` | 门店账号(含子账号 CRUD:`/:id/staff`) | | `/admin/store-media` | 门店媒体 | | `/admin/partners` | 合伙人 | | `/admin/partner-accounts` | 合伙人账号 | diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 8573032..204977c 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -36,6 +36,7 @@ import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import WineryBillsPage from './pages/WineryBillsPage'; import LogisticsBillsPage from './pages/LogisticsBillsPage'; +import BankAccountsPage from './pages/BankAccountsPage'; import TicketsPage from './pages/TicketsPage'; import SupportTicketsPage from './pages/SupportTicketsPage'; import InvoicesPage from './pages/InvoicesPage'; @@ -133,6 +134,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> = { + STORE: 'blue', + WINERY: 'gold', + PARTNER: 'purple', + LOGISTICS: 'cyan', + OTHER: 'default', +}; + +function otherNumericId(id: string) { + return id.startsWith('OTHER:') ? id.slice('OTHER:'.length) : id; +} + +export default function BankAccountsPage() { + const [filterForm] = Form.useForm<{ + type?: FinanceBankAccountType; + cityId?: string; + keyword?: string; + }>(); + const [otherForm] = Form.useForm(); + const [remarkForm] = Form.useForm<{ remark?: string }>(); + const [filters, setFilters] = useState({ type: '', cityId: '', keyword: '' }); + const [cities, setCities] = useState([]); + const [otherOpen, setOtherOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [remarkTarget, setRemarkTarget] = useState(null); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState<'xlsx' | 'pdf' | null>(null); + + useEffect(() => { + void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items ?? [])) + .catch(() => setCities([])); + }, []); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/finance/bank-accounts', + () => { + const qs = new URLSearchParams(); + if (filters.type) qs.set('type', filters.type); + if (filters.cityId) qs.set('cityId', filters.cityId); + if (filters.keyword) qs.set('keyword', filters.keyword); + return qs; + }, + [filters.type, filters.cityId, filters.keyword], + ); + + function openCreate() { + setEditing(null); + otherForm.resetFields(); + setOtherOpen(true); + } + + function openEdit(row: FinanceBankAccountDto) { + setEditing(row); + otherForm.setFieldsValue({ + name: row.ownerName === row.bankAccountName ? undefined : row.ownerName, + bankAccountName: row.bankAccountName, + bankAccountNo: row.bankAccountNo, + bankBranch: row.bankBranch ?? undefined, + remark: row.remark ?? undefined, + }); + setOtherOpen(true); + } + + function openRemark(row: FinanceBankAccountDto) { + setRemarkTarget(row); + remarkForm.setFieldsValue({ remark: row.remark ?? undefined }); + } + + async function saveOther() { + const values = await otherForm.validateFields(); + setSaving(true); + try { + if (editing) { + await request(`/admin/finance/bank-accounts/other/${otherNumericId(editing.id)}`, { + method: 'PUT', + body: JSON.stringify(values), + }); + message.success('已保存'); + } else { + await request('/admin/finance/bank-accounts', { + method: 'POST', + body: JSON.stringify(values), + }); + message.success('已新增'); + } + setOtherOpen(false); + await reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function saveRemark() { + if (!remarkTarget) return; + const values = await remarkForm.validateFields(); + setSaving(true); + try { + await request(`/admin/finance/bank-accounts/${encodeURIComponent(remarkTarget.id)}/remark`, { + method: 'PUT', + body: JSON.stringify({ remark: values.remark ?? '' }), + }); + message.success('备注已保存'); + setRemarkTarget(null); + await reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function removeOther(row: FinanceBankAccountDto) { + try { + await request(`/admin/finance/bank-accounts/other/${otherNumericId(row.id)}`, { method: 'DELETE' }); + message.success('已删除'); + await reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + } + + async function exportFile(format: 'xlsx' | 'pdf') { + setExporting(format); + try { + const qs = new URLSearchParams(); + qs.set('format', format); + if (filters.type) qs.set('type', filters.type); + if (filters.cityId) qs.set('cityId', filters.cityId); + if (filters.keyword) qs.set('keyword', filters.keyword); + const result = await request(`/admin/finance/bank-accounts/export?${qs}`); + downloadBase64File(result.contentBase64, result.filename, result.mimeType); + message.success(`已导出 ${result.count} 条`); + } catch (e) { + message.error(e instanceof Error ? e.message : '导出失败'); + } finally { + setExporting(null); + } + } + + const typeOptions = useMemo( + () => FINANCE_BANK_ACCOUNT_TYPES.map((value) => ({ value, label: FINANCE_BANK_ACCOUNT_TYPE_LABELS[value] })), + [], + ); + + const baseColumns: ColumnsType = [ + { + title: '类型', + dataIndex: 'type', + width: 88, + render: (type: FinanceBankAccountType) => ( + {FINANCE_BANK_ACCOUNT_TYPE_LABELS[type]} + ), + }, + { title: '归属', dataIndex: 'ownerName', width: 180 }, + { title: '城市', dataIndex: 'cityName', width: 100, render: (v?: string | null) => v || '—' }, + { title: '户名', dataIndex: 'bankAccountName', width: 140 }, + { title: '银行账号', dataIndex: 'bankAccountNo', width: 180 }, + { title: '开户行', dataIndex: 'bankBranch', width: 180, render: (v?: string | null) => v || '—' }, + { + title: '默认', + dataIndex: 'isDefault', + width: 72, + render: (v: boolean | undefined, row) => (row.type === 'STORE' ? (v ? '是' : '否') : '—'), + }, + { title: '备注', dataIndex: 'remark', width: 200, render: (v?: string | null) => v || '—' }, + { + title: '操作', + key: 'actions', + width: 160, + fixed: 'right', + render: (_, row) => ( + + + {row.editable ? ( + <> + + void removeOther(row)} + > + + + + ) : null} + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, { + page, + pageSize, + }); + + return ( +
+ {settingsModal} + + 新增账户 + + } + /> + +
{ + setFilters({ + type: v.type || '', + cityId: v.cityId || '', + keyword: v.keyword?.trim() || '', + }); + setPage(1); + }} + > + + ({ value: c.id, label: c.name }))} + /> + + + + + + + + + + + + + + + + +
+ + { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setOtherOpen(false)} + onOk={() => void saveOther()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + +
+ + setRemarkTarget(null)} + onOk={() => void saveRemark()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx index 4b03d93..b4d087e 100644 --- a/apps/admin-web/src/pages/HqPermissionsPage.tsx +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -246,7 +246,7 @@ export default function HqPermissionsPage() { 按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。 运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。 - 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 + 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单、物流对账与银行账户。 ; + staff?: AdminStoreStaffItem[]; }; type StoreOption = { id: string; name: string }; +const STAFF_ROLE_OPTIONS = Object.entries(STORE_STAFF_ROLE_LABELS).map(([value, label]) => ({ value, label })); +const STAFF_PERMISSION_OPTIONS = STORE_STAFF_DEFAULT_PERMISSIONS.map((value) => ({ + value, + label: STORE_STAFF_PERMISSION_LABELS[value], +})); + export default function StoreAccountsPage() { const [form] = Form.useForm(); const [createForm] = Form.useForm(); + const [staffForm] = Form.useForm(); + const [staffEditForm] = Form.useForm(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/store-accounts', @@ -50,9 +64,14 @@ export default function StoreAccountsPage() { const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [staffOpen, setStaffOpen] = useState(false); + const [staffEditOpen, setStaffEditOpen] = useState(false); + const [editingStaffId, setEditingStaffId] = useState(null); const [stores, setStores] = useState([]); const [deletingStaffId, setDeletingStaffId] = useState(null); + const parentStoreOptions = (detail?.stores ?? []).map((s) => ({ value: s.id, label: s.name })); + async function loadStores() { const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); @@ -64,6 +83,34 @@ export default function StoreAccountsPage() { void reload(); } + function openAddStaff() { + if (!detail) return; + if (!detail.stores?.length) { + message.warning('请先为该主账号绑定门店'); + return; + } + staffForm.resetFields(); + staffForm.setFieldsValue({ + staffRole: StoreStaffRole.CASHIER, + permissions: [...STORE_STAFF_DEFAULT_PERMISSIONS], + storeIds: detail.stores.map((s) => s.id), + }); + setStaffOpen(true); + } + + function openEditStaff(staff: AdminStoreStaffItem) { + setEditingStaffId(staff.id); + staffEditForm.setFieldsValue({ + name: staff.name, + phone: staff.phone, + staffRole: staff.staffRole ?? StoreStaffRole.CASHIER, + status: staff.status, + permissions: staff.permissions?.length ? staff.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS], + storeIds: staff.storeIds?.length ? staff.storeIds : (staff.stores ?? []).map((s) => s.id), + }); + setStaffEditOpen(true); + } + async function deleteStaff(staffId: string) { if (!detail) return; setDeletingStaffId(staffId); @@ -151,13 +198,58 @@ export default function StoreAccountsPage() { const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize }); + const staffColumns: ColumnsType = [ + { title: '姓名', dataIndex: 'name', width: 90 }, + { title: '手机', dataIndex: 'phone', width: 120 }, + { + title: '角色', + dataIndex: 'staffRole', + width: 80, + render: (role) => STORE_STAFF_ROLE_LABELS[role as StoreStaffRole] || role || '—', + }, + { + title: '门店', + render: (_, staff) => + staff.stores?.length ? staff.stores.map((s) => s.name).join('、') : '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 70, + render: (s) => {ACCOUNT_STATUS_LABELS[s] || s}, + }, + { + title: '操作', + width: 120, + render: (_, staff) => ( + + + void deleteStaff(staff.id)} + > + + + + ), + }, + ]; + return (
{settingsModal} setDrawerOpen(false)} extra={ detail && (
{ACCOUNT_STATUS_LABELS[s] || s}, - }, - { - title: '操作', - width: 80, - render: (_, staff) => ( - void deleteStaff(staff.id)} - > - - - ), - }, - ]} - /> - - ) : ( - - 暂无子账号 - - )} +
+ + 子账号({detail.staff?.length ?? 0})· 仅主账号可添加,不可多级 + + +
+
)} @@ -321,6 +387,87 @@ export default function StoreAccountsPage() { + setStaffOpen(false)} + onOk={async () => { + if (!detail) return; + const v = await staffForm.validateFields(); + await request(`/admin/store-accounts/${detail.id}/staff`, { + method: 'POST', + body: JSON.stringify(v), + }); + message.success('子账号已创建'); + setStaffOpen(false); + await refreshDetail(detail.id); + }} + > +
+ + + + + + + + + + + +
+ setStaffEditOpen(false)} + onOk={async () => { + if (!detail || !editingStaffId) return; + const v = await staffEditForm.validateFields(); + await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, { + method: 'PUT', + body: JSON.stringify(v), + }); + message.success('子账号已更新'); + setStaffEditOpen(false); + await refreshDetail(detail.id); + }} + > +
+ + + + + + ({ value, label }))} /> + + +
0; +} diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index ba3f393..9a8f1f2 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -17,11 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component import BenefitIntroCard from '../../components/BenefitIntroCard'; import WechatShareReady from '../../components/WechatShareReady'; import { request, toast, isLoggedIn } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { toMoneyNumber } from '../../lib/money'; import { maskPhone, toDialablePhone } from '../../lib/phone'; import { track } from '../../lib/analytics'; import { fullStoreAddress, + shouldShowStoreRedeemCount, storeCategoryTags, storeStarCount, type StoreCategoryTreeNode, @@ -190,6 +192,7 @@ export default function StoreDetailPage() { const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); const [pendingRatingId, setPendingRatingId] = useState(null); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const storeRef = useRef(null); storeRef.current = store; @@ -197,6 +200,12 @@ export default function StoreDetailPage() { setHeaderSolid(scrollTop > 100); }); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false)) + .catch(() => undefined); + }, []); + const loadStore = useCallback(async (id: string) => { if (!id) { setLoading(false); @@ -440,7 +449,7 @@ export default function StoreDetailPage() { ))} - {Number(store.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? ( 核销{store.redeemCount}次 ) : null} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 2a5b162..73ec286 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -28,6 +28,7 @@ import { import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; import { getToken, request, toast } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { getStoresListCache, isStoresSessionBootstrapped, @@ -42,7 +43,7 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import BenefitSloganBar from '../../components/BenefitSloganBar'; -import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; +import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; import openBadgeImg from '../../assets/icons/store-open-badge.png'; type Store = { @@ -123,13 +124,21 @@ export default function StoresPage() { const [categoryTree, setCategoryTree] = useState([]); const [sort, setSort] = useState(() => cached?.sort ?? 'nearby'); const [sortOpen, setSortOpen] = useState(false); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const fetchCityKeyRef = useRef(cached?.cityKey ?? null); const fetchSeqRef = useRef(0); const regionRef = useRef(region); regionRef.current = region; const regionLabel = formatRegionLabel(region); const categoryLabel = formatCategoryLabel(category); - const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先'; + const sortOptions = useMemo( + () => + showStoreRedeemCount + ? STORE_SORT_OPTIONS + : STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'), + [showStoreRedeemCount], + ); + const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先'; const showBootLoading = loading && stores.length === 0; const childIdsByParent = useMemo(() => { @@ -143,6 +152,22 @@ export default function StoresPage() { return map; }, [categoryTree]); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => { + const enabled = cfg.showStoreRedeemCount !== false; + setShowStoreRedeemCount(enabled); + if (!enabled) { + setSort((prev) => { + if (prev !== 'redeem') return prev; + patchStoresFilterCache({ sort: 'nearby' }); + return 'nearby'; + }); + } + }) + .catch(() => undefined); + }, []); + async function fetchStores( nextCode: string, coords: UserCoords | null, @@ -329,7 +354,7 @@ export default function StoresPage() { if (sort === 'rating') { const diff = storeStarCount(b.rating) - storeStarCount(a.rating); if (diff !== 0) return diff; - } else if (sort === 'redeem') { + } else if (sort === 'redeem' && showStoreRedeemCount) { const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0); if (diff !== 0) return diff; } @@ -338,7 +363,7 @@ export default function StoresPage() { return da - db; }); return next; - }, [stores, region, category, keyword, sort, childIdsByParent]); + }, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]); function applySearch() { const next = keywordInput.trim(); @@ -497,7 +522,7 @@ export default function StoresPage() { ))} - {Number(s.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? ( 核销{s.redeemCount}次 ) : null} @@ -551,7 +576,7 @@ export default function StoresPage() { - {STORE_SORT_OPTIONS.map((opt) => ( + {sortOptions.map((opt) => ( return code || MOCK_SMS_FIXED_CODE; } +/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */ +export function isShowStoreRedeemCountEnabled(env?: Record): boolean { + const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase(); + if (!raw) return true; + return raw === 'true' || raw === '1'; +} + /** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */ export const DEFAULT_SHARE_TITLE = '你吃饭,我买单'; export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 7f8796e..6f5827d 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -74,6 +74,8 @@ export type ClientRuntimeConfig = { partnerOnboardCsHint?: string | null; /** 小程序各场景分享文案/图 */ share?: MiniShareRuntime; + /** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */ + showStoreRedeemCount?: boolean; }; /** 是否展示微信授权入口 */ diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index 900b9fb..5a18e59 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ requiresRestart: false, description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)', }, + { + key: 'SHOW_STORE_REDEEM_COUNT', + label: 'C 端展示门店核销次数', + group: G.feature, + type: 'boolean', + requiresRestart: false, + description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数', + }, { key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false }, { key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false }, @@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k /** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ export const SYSTEM_CONFIG_DEFAULTS: Record = { + SHOW_STORE_REDEEM_COUNT: 'true', USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''), BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 8dbe02d..2dd51f1 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get } from '@nestjs/common'; import { + isShowStoreRedeemCountEnabled, parseMiniHomeBanners, resolveClientBrandRuntime, resolveMiniShareRuntime, @@ -44,6 +45,7 @@ export class ClientConfigController { (env.PARTNER_ONBOARD_CS_HINT ?? '').trim() || '使用问题、提现问题等随时可联系【杜康好客】客服', share, + showStoreRedeemCount: isShowStoreRedeemCountEnabled(env), }; } } diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts index a49798b..ace49de 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqPermissionGuard, @@ -80,6 +81,17 @@ export class AdminPartnersController { return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20); } + @Get(':id/assoc/qrcode') + async assocQrcode(@Param('id') id: string, @Res() res: Response) { + const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id)); + res.setHeader('Content-Type', 'image/png'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + res.send(buffer); + } + @Post(':id/assoc/qrcode') regenQrcode(@Param('id') id: string) { return this.assoc.ensureQrcode(BigInt(id), true); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index aad112b..114a7cd 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -5,7 +5,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; +import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; import { @@ -36,6 +36,7 @@ import { } from '../../common/test-whitelist/test-whitelist.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { formatPartnerWecomLabel } from './wecom-submitter-label'; +import { SystemConfigService } from '../../common/system-config/system-config.service'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; @@ -86,6 +87,7 @@ export class StoreService { private readonly tencentLbs: TencentLbsProvider, private readonly testWhitelist: TestWhitelistService, private readonly wecomPush: WecomMessagePushService, + private readonly systemConfig: SystemConfigService, ) {} /** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */ @@ -218,11 +220,12 @@ export class StoreService { latitude?: unknown; longitude?: unknown; sortOrder?: number; - redeemCount: number; + redeemCount?: number; }; + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const redeemGroups = - visible.length === 0 + !showRedeemCount || visible.length === 0 ? [] : await this.prisma.redeemRecord.groupBy({ by: ['storeId'], @@ -252,7 +255,9 @@ export class StoreService { items.push({ ...mapped, distanceMeters, - redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0, + ...(showRedeemCount + ? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 } + : {}), }); } @@ -286,6 +291,7 @@ export class StoreService { throw new NotFoundException('门店不存在'); } const coords = await this.ensureStoreCoordinates(store); + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const [media, packageRows, redeemCount] = await Promise.all([ this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, @@ -295,7 +301,7 @@ export class StoreService { where: { storeId: id }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }), - this.prisma.redeemRecord.count({ where: { storeId: id } }), + showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null), ]); const { visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( @@ -304,7 +310,7 @@ export class StoreService { ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, - redeemCount, + ...(showRedeemCount && redeemCount != null ? { redeemCount } : {}), media, packages: packageRows.map((p) => { const imageUrls = normalizeStorePackageImageUrls({