merge(dev_jacy): sync to dev

This commit is contained in:
2026-09-17 09:07:35 +08:00
36 changed files with 12138 additions and 112 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/admin-web", "name": "@dukang/admin-web",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+93 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message, Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -9,25 +9,37 @@ import {
PROMO_CODE_STATUS_LABELS, PROMO_CODE_STATUS_LABELS,
promoConversion, promoConversion,
type PromoCodeItem, type PromoCodeItem,
type PromoCodePartnerBrief,
type PromoCodeScene, type PromoCodeScene,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader'; import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem; type Row = PromoCodeItem;
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
type SceneOption = { value: PromoCodeScene; label: string }; type SceneOption = { value: PromoCodeScene; label: string };
function formatPartnerLabel(p?: PromoCodePartnerBrief | PartnerOption | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return p.phone ? `${title} · ${p.phone}` : title;
}
function formatPartnerNames(partners?: PromoCodePartnerBrief[] | null) {
if (!partners?.length) return '—';
return partners.map((p) => p.companyName || p.name || p.id).join('、');
}
export default function PromoCodesPage() { export default function PromoCodesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [scenes, setScenes] = useState<SceneOption[]>( const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene, value: value as PromoCodeScene,
@@ -37,6 +49,11 @@ export default function PromoCodesPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const partnerSelectOptions = useMemo(
() => partners.map((p) => ({ value: p.id, label: formatPartnerLabel(p) })),
[partners],
);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes', '/admin/promo-codes',
() => { () => {
@@ -45,6 +62,8 @@ export default function PromoCodesPage() {
if (filters.code) qs.set('code', filters.code); if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene); if (filters.scene) qs.set('scene', filters.scene);
if (filters.channelOwnerPartnerId) qs.set('channelOwnerPartnerId', filters.channelOwnerPartnerId);
if (filters.assocPartnerAccountId) qs.set('assocPartnerAccountId', filters.assocPartnerAccountId);
return qs; return qs;
}, },
[filters], [filters],
@@ -63,6 +82,12 @@ export default function PromoCodesPage() {
void loadScenes(); void loadScenes();
}, []); }, []);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
const baseColumns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '名称', title: '名称',
@@ -116,8 +141,13 @@ export default function PromoCodesPage() {
}, },
{ {
title: '渠道负责人', title: '渠道负责人',
width: 120, width: 160,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', render: (_, row) => formatPartnerNames(row.channelOwners),
},
{
title: '关联合伙人',
width: 140,
render: (_, row) => row.assocPartner?.companyName || row.assocPartner?.name || '—',
}, },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -137,7 +167,15 @@ export default function PromoCodesPage() {
}, },
]; ];
async function handleCreate(values: Record<string, string>) { async function handleCreate(values: {
name: string;
code?: string;
scene: PromoCodeScene;
page?: string;
remark?: string;
channelOwnerPartnerIds?: string[];
assocPartnerAccountId?: string;
}) {
setCreating(true); setCreating(true);
try { try {
const created = await request<PromoCodeItem>('/admin/promo-codes', { const created = await request<PromoCodeItem>('/admin/promo-codes', {
@@ -146,7 +184,8 @@ export default function PromoCodesPage() {
name: values.name, name: values.name,
code: values.code?.trim() || undefined, code: values.code?.trim() || undefined,
scene: values.scene, scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined, channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
assocPartnerAccountId: values.assocPartnerAccountId || null,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined, page: values.page?.trim() || undefined,
}), }),
@@ -192,6 +231,24 @@ export default function PromoCodesPage() {
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="channelOwnerPartnerId" label="渠道负责人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 200 }}
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item name="assocPartnerAccountId" label="关联合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 200 }}
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
@@ -235,8 +292,32 @@ export default function PromoCodesPage() {
> >
<Input placeholder="pages/home/index" /> <Input placeholder="pages/home/index" />
</Form.Item> </Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)"> <Form.Item
<Input placeholder="渠道负责人,填写用户数据库 ID" /> name="channelOwnerPartnerIds"
label="渠道负责人"
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量,看不到用户或订单明细。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
extra="可空。登录用户扫该码且尚未关联任何人时,将绑定到该主合伙人;已关联他人不换绑。"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" /> <Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
import { QuestionCircleOutlined } from '@ant-design/icons'; import { QuestionCircleOutlined } from '@ant-design/icons';
import { import {
@@ -23,8 +23,8 @@ import {
PROMO_CODE_STATUS_LABELS, PROMO_CODE_STATUS_LABELS,
promoConversion, promoConversion,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../../lib/api'; import { request, type Paginated } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel'; import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
@@ -51,6 +51,19 @@ const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成
const CONVERSION_HINT = const CONVERSION_HINT =
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。'; '已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
function formatPartnerLabel(p?: { companyName?: string | null; name?: string | null; phone?: string | null; id: string } | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return p.phone ? `${title} · ${p.phone}` : title;
}
function formatPartnerNames(partners?: Array<{ companyName?: string | null; name?: string | null; id: string }> | null) {
if (!partners?.length) return '—';
return partners.map((p) => p.companyName || p.name || p.id).join('、');
}
function StatHint({ label, hint }: { label: string; hint: string }) { function StatHint({ label, hint }: { label: string; hint: string }) {
return ( return (
<span> <span>
@@ -86,10 +99,30 @@ export default function PromoCodeDetailPage() {
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const stats = detail.stats; const stats = detail.stats;
async function handleEdit(values: Record<string, string>) { const partnerSelectOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
for (const p of detail.channelOwners ?? []) map.set(p.id, p);
if (detail.assocPartner) map.set(detail.assocPartner.id, detail.assocPartner);
return [...map.values()].map((p) => ({ value: p.id, label: formatPartnerLabel(p) }));
}, [partners, detail.channelOwners, detail.assocPartner]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
async function handleEdit(values: {
name: string;
scene: string;
remark?: string;
channelOwnerPartnerIds?: string[];
assocPartnerAccountId?: string;
}) {
setSaving(true); setSaving(true);
try { try {
await request(`/admin/promo-codes/${detail.id}`, { await request(`/admin/promo-codes/${detail.id}`, {
@@ -97,7 +130,8 @@ export default function PromoCodeDetailPage() {
body: JSON.stringify({ body: JSON.stringify({
name: values.name, name: values.name,
scene: values.scene, scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || null, channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
assocPartnerAccountId: values.assocPartnerAccountId || null,
remark: values.remark?.trim() || null, remark: values.remark?.trim() || null,
}), }),
}); });
@@ -128,7 +162,8 @@ export default function PromoCodeDetailPage() {
name: detail.name, name: detail.name,
scene: detail.scene, scene: detail.scene,
remark: detail.remark, remark: detail.remark,
ownerUserId: detail.ownerUser?.id, channelOwnerPartnerIds: (detail.channelOwners ?? []).map((p) => p.id),
assocPartnerAccountId: detail.assocPartner?.id,
}); });
setEditOpen(true); setEditOpen(true);
}} }}
@@ -194,7 +229,12 @@ export default function PromoCodeDetailPage() {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="渠道负责人"> <Descriptions.Item label="渠道负责人">
<span style={{ whiteSpace: 'nowrap' }}> <span style={{ whiteSpace: 'nowrap' }}>
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'} {formatPartnerNames(detail.channelOwners)}
</span>
</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
<span style={{ whiteSpace: 'nowrap' }}>
{formatPartnerLabel(detail.assocPartner)}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
@@ -309,8 +349,32 @@ export default function PromoCodeDetailPage() {
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID"> <Form.Item
<Input placeholder="留空表示解除关联" /> name="channelOwnerPartnerIds"
label="渠道负责人"
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
extra="可空。扫该码且尚未关联的用户将绑定该合伙人;已关联他人不换绑,不回刷历史。"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010'; const apiTarget = process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/h5-partner", "name": "@dukang/h5-partner",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -26,6 +26,7 @@ import UsersManagePage from './pages/UsersManagePage';
import AssocOrdersPage from './pages/AssocOrdersPage'; import AssocOrdersPage from './pages/AssocOrdersPage';
import CommissionOrdersPage from './pages/CommissionOrdersPage'; import CommissionOrdersPage from './pages/CommissionOrdersPage';
import ActivityPostersPage from './pages/ActivityPostersPage'; import ActivityPostersPage from './pages/ActivityPostersPage';
import PromoCodesPage from './pages/PromoCodesPage';
import BankAccountPage from './pages/BankAccountPage'; import BankAccountPage from './pages/BankAccountPage';
function PrimaryRoutes() { function PrimaryRoutes() {
@@ -45,6 +46,7 @@ function PrimaryRoutes() {
<Route path="/center/staff/new" element={<StaffCreatePage />} /> <Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/bank" element={<BankAccountPage />} /> <Route path="/center/bank" element={<BankAccountPage />} />
<Route path="/center/activity-posters" element={<ActivityPostersPage />} /> <Route path="/center/activity-posters" element={<ActivityPostersPage />} />
<Route path="/center/promo-codes" element={<PromoCodesPage />} />
<Route path="/center/assoc" element={<Navigate to="/users" replace />} /> <Route path="/center/assoc" element={<Navigate to="/users" replace />} />
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} /> <Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
<Route path="/users/orders" element={<AssocOrdersPage />} /> <Route path="/users/orders" element={<AssocOrdersPage />} />
+9
View File
@@ -258,6 +258,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
</div> </div>
<span className="material-symbols-outlined text-muted">chevron_right</span> <span className="material-symbols-outlined text-muted">chevron_right</span>
</Link> </Link>
<Link to="/center/promo-codes" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">qr_code_2</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}>广</span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/users" className="partner-menu-item"> <Link to="/users" className="partner-menu-item">
<div className="partner-menu-item-left"> <div className="partner-menu-item-left">
<div className="partner-menu-icon"> <div className="partner-menu-icon">
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { PROMO_CODE_STATUS_LABELS, type PartnerPromoCodeItem } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
type ListRes = { items: PartnerPromoCodeItem[] };
export default function PromoCodesPage() {
usePartnerPageView('partner_promo_codes_view');
const navigate = useNavigate();
const [items, setItems] = useState<PartnerPromoCodeItem[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<ListRes>('PARTNER_H5', '/partner/promo-codes');
setItems(res.items ?? []);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
document.title = '推广码数据';
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<PullToRefresh onRefresh={load}>
<PageHeader title="推广码数据" onBack={() => navigate(-1)} />
<div style={{ padding: '0 20px 24px' }}>
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
{loading ? '加载中…' : `仅展示扫码人数、归因人数、订单数量`}
</p>
{!loading && items.length === 0 && <div className="empty">广</div>}
{items.map((row) => (
<div key={row.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header">
<div>
<p className="body-md">{row.name}</p>
<p className="label-md text-muted">
{row.code}
{row.status !== 'ACTIVE' ? ` · ${PROMO_CODE_STATUS_LABELS[row.status] || row.status}` : ''}
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.scanCount}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.attributionCount}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.orderCount}</p>
</div>
</div>
</div>
))}
</div>
</PullToRefresh>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
host: true, host: true,
port: 5175, port: 5175,
proxy: { proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010', '/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010',
}, },
}, },
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/h5-shop", "name": "@dukang/h5-shop",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -14,6 +14,6 @@ export default defineConfig({
server: { server: {
host: true, host: true,
port: 5174, port: 5174,
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' }, proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010' },
}, },
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+1 -1
View File
@@ -3,7 +3,7 @@ import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */ /** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */
export const APP_VERSION = export const APP_VERSION =
(typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.19'; (typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.20';
export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`; export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`;
+3 -1
View File
@@ -1,7 +1,7 @@
# 杜康好客 · V3 编码手册(交付业务版) # 杜康好客 · V3 编码手册(交付业务版)
> **事实源**[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) > **事实源**[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15**[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。 > **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15· 推广码渠道负责人/关联合伙人(v4.0.20)**[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。 > V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
## 1. 交付目标(六条) ## 1. 交付目标(六条)
@@ -68,6 +68,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**合伙人关联与订单佣金(v4.0.1 / v4.0.9**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc``scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword``assocPartnerAccountId``none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。 **合伙人关联与订单佣金(v4.0.1 / v4.0.9**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc``scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword``assocPartnerAccountId``none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
**推广码渠道负责人 / 关联合伙人(v4.0.20)**:规则见 v4-PRD §2.1 与 [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md)。表 `promo_code_channel_owner`(多对多主合伙人)+ `common_promo_code.assoc_partner_account_id`。HQ `POST/PUT /admin/promo-codes` 字段 `channelOwnerPartnerIds``assocPartnerAccountId`(须主账号 ACTIVE)。`POST /promo/touch` 登录后对关联合伙人 `PartnerCityService.tryBindIfUnbound`(已绑他人静默跳过,不回刷历史;Promo 不 import StoreModule)。合伙人主账号 `GET /partner/promo-codes` 仅返回 `scanCount` / `attributionCount` / `orderCount`(已完成订单),禁止用户/事件/订单明细。H5 入口:合伙人中心 → 运营管理 → 推广码数据。验收:只配渠道负责人不绑用户;只配关联合伙人会进「关联用户」且能看三项汇总;已关联他人扫码流程不中断。
**合伙人周结算(v4.0.9**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。 **合伙人周结算(v4.0.9**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
## 5. 验收用例(必过) ## 5. 验收用例(必过)
+15 -5
View File
@@ -1,9 +1,9 @@
# 杜康好客 · V4 PRD # 杜康好客 · V4 PRD
> **v4.0**2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录 > **v4.0**2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录**v4.0.20** 推广码渠道负责人/关联合伙人
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。 > 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。 > 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) > 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
## 0. 版本 ## 0. 版本
@@ -17,6 +17,7 @@
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) | | 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) | | 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) | | 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
| 4.0.20 | 09-16 / 09-17 | 推广码渠道负责人多选主合伙人(H5 只看三项汇总);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 | [`v4.0.20`](./杜康好客-v4.0.20-开发文档.md) |
## 1. 锚点(沿用 V3,佣金归属改写) ## 1. 锚点(沿用 V3,佣金归属改写)
@@ -43,6 +44,15 @@
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。 - 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
- HQ:用户列表综合搜索 + 关联合伙人筛选(`none` 未关联 / `any` 已关联全部 / 指定主合伙人),筛选默认展开;订单列表按本单佣金快照筛关联合伙人;开城城市合伙人提供「关联用户」快链与「全部关联用户」快链,进入用户列表并带上筛选。 - HQ:用户列表综合搜索 + 关联合伙人筛选(`none` 未关联 / `any` 已关联全部 / 指定主合伙人),筛选默认展开;订单列表按本单佣金快照筛关联合伙人;开城城市合伙人提供「关联用户」快链与「全部关联用户」快链,进入用户列表并带上筛选。
### 2.1 推广码 × 合伙人(v4.0.20
推广码仍是独立小程序码(数字 scene),**不复用**关联码 `pa_` / `sa_`
- **渠道负责人**(可空、多选主合伙人):HQ 创建/编辑推广码可指定。被指定的**主账号**可在合伙人 H5「推广码数据」查看该码的**扫码人数、归因人数、订单数量**;不得查看用户明细、订单列表、核销或手机号等。
- **关联合伙人**(可空、单选主合伙人):登录用户扫该码且尚未关联任何人时,**first-lock** 到该主合伙人(写入 `assoc_partner_account_id` + `assocBoundAt`,不写子账号)。已关联他人不换绑、不报错。只绑以后扫进来的用户,**不回刷**历史归因用户。不增加关联码 `assoc_scan_count`。用户来源仍按推广码规则(ORGANIC 才标 `PROMO_CODE`)。
- 两字段独立:只配渠道负责人不会绑用户;只配关联合伙人也会把用户写入该合伙人「关联用户」,并允许该合伙人看上述三项汇总。
- 子账号不展示推广码数据入口。HQ 原 `ownerUserId`C 端用户)不再编辑。
## 3. 订单佣金 ## 3. 订单佣金
支付快照字段:`user_order.partner_account_id_at_pay``order_commission_rate_at_pay` 支付快照字段:`user_order.partner_account_id_at_pay``order_commission_rate_at_pay`
@@ -125,4 +135,4 @@ HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查
## 10. 不做 ## 10. 不做
推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。 不把推广码改成关联码、不回刷历史绑定;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
+5 -3
View File
@@ -3,11 +3,11 @@
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) > 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。 > V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
## 0. 总览(2026-09-07 ## 0. 总览(2026-09-17
| 维度 | 结论 | | 维度 | 结论 |
|------|------| |------|------|
| 版本线 | **v4.0.18** 子账号继承二维码 + HQ 财务全部银行账户(门店行读结算资质;含 v4.0.15 HQ 概览折线图 | | 版本线 | **v4.0.20** 推广码渠道负责人 + 关联合伙人(含 v4.0.18 子账号继承码 / 财务全部银行账户 |
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) | | 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 | | 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
@@ -25,6 +25,7 @@
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 | | 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 | | 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 | | 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
| 4.0.20 | [`推广码渠道负责人 + 关联合伙人`](./杜康好客-v4.0.20-开发文档.md) | ✅ 已实现 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
@@ -42,4 +43,5 @@
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 | | 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
| 2026-09-08 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) | | 2026-09-08 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
| 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) | | 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 | | 2026-09-16 | v4.0.20:推广码渠道负责人改为可多选主合伙人(H5 只看扫码/归因/订单数);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 |
| 2026-09-17 | v4.0.20PromoModule 改走 CityScope 避免 Nest 循环依赖;本地 Vite 代理默认 `127.0.0.1:3010` |
+98
View File
@@ -0,0 +1,98 @@
# 杜康好客 · v4.0.20 开发文档
> **2026-09-16 / 09-17** · promo / city-scope / store / admin-web / h5-partner / shared-types
> **主题**:推广码渠道负责人(多选主合伙人)+ 关联合伙人(扫码 first-lock
---
## 1. 版本目标
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
| 1 | 渠道负责人 | 需求 | HQ 创建/编辑推广码可多选主合伙人;被指定主账号在合伙人 H5 只看扫码人数、归因人数、订单数量 |
| 2 | 关联合伙人 | 需求 | HQ 可单选绑定主合伙人;登录用户扫该码且尚未关联任何人时 first-lock;已关联他人静默跳过;不回刷历史 |
**不做**:把推广码改成关联码(仍用数字 scene,不复用 `pa_` / `sa_`);回刷历史归因用户;已关联他人换绑;合伙人 H5 开放用户/订单/核销/手机号明细;历史 `ownerUserId`(C 端用户)迁成合伙人;子账号推广码数据入口。
---
## 2. 规则
规则事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §2.1。
```mermaid
flowchart TD
scan[C端扫推广码] --> touch["POST /promo/touch"]
touch --> scanInc[scanCount++ 未登录也计]
touch --> login{已登录?}
login -->|否| done[结束]
login -->|是| attr[首次归因 UserPromoAttribution]
attr --> src[ORGANIC 才标 PROMO_CODE 来源]
src --> hasAssoc{码上有关联合伙人?}
hasAssoc -->|否| done
hasAssoc -->|是| bound{用户已有 assoc?}
bound -->|无| lock["PartnerCityService.tryBindIfUnbound"]
bound -->|已是同一人| skipSame[noop]
bound -->|已是他人| skipOther[静默跳过]
lock --> users[合伙人H5 关联用户可见]
```
- **渠道负责人**:可空、多选 **ACTIVE 主合伙人**`isPrimary=1`)。只授权看三项汇总,不绑用户。
- **关联合伙人**:可空、单选 ACTIVE 主合伙人。登录 touch 后 `tryBindIfUnbound`:无关联则写 `assoc_partner_account_id` + `assoc_bound_at`(不写 `assoc_sub_account_id`、不改 `sourceType`、不增加关联码 `assoc_scan_count`);已是同一人 noop;已是他人不抛错。只绑以后扫进来的用户。
- 两字段独立:只配渠道负责人 ≠ 绑用户;只配关联合伙人也会把用户写入该合伙人「关联用户」,并允许看三项汇总。
- HQ 原 `ownerUserId` 列保留、创建/编辑不再暴露。
---
## 3. API
### 3.1 HQpromo 模块)
`POST /admin/promo-codes``PUT /admin/promo-codes/:id` 增加:
- `channelOwnerPartnerIds: string[]`(可空)
- `assocPartnerAccountId: string | null`(可空;空串/null 解绑)
列表/详情返回 `channelOwners[]``assocPartner``id` / `companyName` / `name` / `phone`)。列表筛:`channelOwnerPartnerId``assocPartnerAccountId`
### 3.2 C 端扫码
`POST /promo/touch`:登录后、归因与来源标记之后,若码上有 `assocPartnerAccountId`,调用 `PartnerCityService.tryBindIfUnbound`**不** import `StoreModule`,避免 Nest 循环依赖)。
### 3.3 合伙人 H5(仅主账号,`PartnerPrimaryGuard`
| 方法 | 路径 | 返回 |
|------|------|------|
| GET | `/partner/promo-codes` | `{ items: [{ id, name, code, status, scanCount, attributionCount, orderCount }] }` |
可见范围:当前主账号是渠道负责人 **或** 关联合伙人。订单数 = `status=COMPLETED`。禁止 HQ 的 users / metrics / timeline / 订单快链。
---
## 4. 变更面
| 层 | 路径 |
|----|------|
| Prisma | `CommonPromoCode.assocPartnerAccountId``PromoCodeChannelOwner``promo_code_channel_owner` |
| 迁移 | `server/dukang-api/prisma/migrate-promo-partner-fields.sql`**Review 后生产执行** |
| shared-types | `promo.ts``PromoCodePartnerBrief``PromoCodeItem.channelOwners/assocPartner``PartnerPromoCodeItem` |
| API promo | `promo-code.service.ts``dto/promo-code.dto.ts``partner-promo-code.controller.ts``PromoModule` import `CityScopeModule` |
| API city-scope | `PartnerCityService.tryBindIfUnbound` |
| API store | `PartnerAssocService.tryBindIfUnbound` 转调 city-scope |
| admin-web | `PromoCodesPage.tsx``promo/PromoCodeDetailPage.tsx`:渠道负责人多选 / 关联合伙人单选;去掉关联用户 ID |
| h5-partner | `CenterPage.tsx` 运营管理「推广码数据」;`/center/promo-codes``PromoCodesPage.tsx` |
| 本地代理 | admin/partner/shop Vite 默认 `VITE_API_TARGET``http://127.0.0.1:3010`Windows 上 `localhost` 可能打到占用 `::3010` 的其他进程) |
---
## 5. 验收
- [ ] HQ 创建/编辑推广码可多选渠道负责人、可清空单选关联合伙人;列表/详情展示公司名;可按两字段筛选
- [ ] 只配渠道负责人:扫码不写用户关联;该主账号 H5「推广码数据」能看到三项数字,点不开用户/订单/核销
- [ ] 只配关联合伙人:未关联的登录用户扫码后出现在该合伙人「关联用户」;该主账号也能看三项汇总
- [ ] 用户已关联他人:扫带关联合伙人的码不换绑、流程不中断、不报「无法更换」
- [ ] 为已有码补关联合伙人:**不**回刷历史归因用户
- [ ] 子账号中心无「推广码数据」入口
- [ ] 关联码 `pa_` / `sa_` 行为不变;已付佣金快照不回刷
- [ ] 执行迁移后 API 可启动(`PromoModule` 不得再 import `StoreModule`
- [ ] shared-types 构建通过;相关 lint 过
+1 -1
View File
@@ -103,7 +103,7 @@ HQ 创建商品:名称/价格/权益额/箱规/香型/城市上架;详情模
## 7. 活动 / 推广码 ## 7. 活动 / 推广码
HQ 推广码:场景/合伙人绑定/上下线;touch 归因metrics 四指标+事件日志(v3.4.13)。 HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下线;touch 归因 + 关联合伙人 first-lockmetrics 四指标+事件日志(v3.4.13)。
## 8. 开城 ## 8. 开城
+7876 -6
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"globals": "^15.14.0", "globals": "^15.14.0",
"prisma": "^8.0.0-rc.15",
"typescript-eslint": "^8.18.0" "typescript-eslint": "^8.18.0"
}, },
"scripts": { "scripts": {
+23
View File
@@ -27,6 +27,13 @@ export type PromoCodeOwnerUser = {
phone?: string | null; phone?: string | null;
}; };
export type PromoCodePartnerBrief = {
id: string;
companyName?: string | null;
name?: string | null;
phone?: string | null;
};
export type PromoCodeItem = { export type PromoCodeItem = {
id: string; id: string;
code: string; code: string;
@@ -40,11 +47,27 @@ export type PromoCodeItem = {
landingUrl: string; landingUrl: string;
qrcodeUrl?: string | null; qrcodeUrl?: string | null;
remark?: string | null; remark?: string | null;
/** @deprecated HQ 不再编辑;历史 C 端用户渠道负责人 */
ownerUser?: PromoCodeOwnerUser | null; ownerUser?: PromoCodeOwnerUser | null;
/** 渠道负责人:主合伙人,可多选 */
channelOwners?: PromoCodePartnerBrief[];
/** 关联合伙人:扫码 first-lock 绑定 */
assocPartner?: PromoCodePartnerBrief | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
/** 合伙人 H5:仅三项汇总,不含用户/订单明细 */
export type PartnerPromoCodeItem = {
id: string;
name: string;
code: string;
status: PromoCodeStatus;
scanCount: number;
attributionCount: number;
orderCount: number;
};
export type PromoCodeStats = { export type PromoCodeStats = {
/** 扫码进入次数 */ /** 扫码进入次数 */
scanCount: number; scanCount: number;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/shared-ui", "name": "@dukang/shared-ui",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
+3555 -28
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,3 +17,4 @@ allowBuilds:
msgpackr-extract: true msgpackr-extract: true
prisma: true prisma: true
sharp: true sharp: true
workerd: true
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/api", "name": "@dukang/api",
"version": "4.0.19", "version": "4.0.20",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "pnpm --dir ../../packages/domain build", "predev": "pnpm --dir ../../packages/domain build",
@@ -0,0 +1,18 @@
-- v4.0.20:推广码关联合伙人 + 渠道负责人(主合伙人多选)
-- Review 后再在生产执行。
ALTER TABLE `common_promo_code`
ADD COLUMN `assoc_partner_account_id` BIGINT UNSIGNED NULL AFTER `owner_user_id`,
ADD KEY `idx_common_promo_code_assoc_partner` (`assoc_partner_account_id`),
ADD CONSTRAINT `fk_common_promo_code_assoc_partner`
FOREIGN KEY (`assoc_partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS `promo_code_channel_owner` (
`promo_code_id` BIGINT UNSIGNED NOT NULL,
`partner_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`promo_code_id`, `partner_account_id`),
KEY `idx_promo_channel_owner_partner` (`partner_account_id`),
CONSTRAINT `fk_promo_channel_owner_promo` FOREIGN KEY (`promo_code_id`) REFERENCES `common_promo_code`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_promo_channel_owner_partner` FOREIGN KEY (`partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码渠道负责人(主合伙人)';
+35 -15
View File
@@ -1069,31 +1069,49 @@ model StoreCategoryLink {
} }
model CommonPromoCode { model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32) code String @unique @db.VarChar(32)
name String @db.VarChar(128) name String @db.VarChar(128)
scene PromoCodeScene @default(ONLINE_LINK) scene PromoCodeScene @default(ONLINE_LINK)
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64) qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE) status PromoCodeStatus @default(ACTIVE)
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
remark String? @db.VarChar(256) assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt remark String? @db.VarChar(256)
scanCount Int @default(0) @map("scan_count") qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
orderCount Int @default(0) @map("order_count") scanCount Int @default(0) @map("scan_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) orderCount Int @default(0) @map("order_count")
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull) assocPartner PartnerAccount? @relation("PromoAssocPartner", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
channelOwners PromoCodeChannelOwner[]
attributions UserPromoAttribution[] attributions UserPromoAttribution[]
orders Order[] orders Order[]
metricEvents LogPromoEvent[] metricEvents LogPromoEvent[]
@@index([ownerUserId]) @@index([ownerUserId])
@@index([assocPartnerAccountId])
@@index([scene, status]) @@index([scene, status])
@@map("common_promo_code") @@map("common_promo_code")
} }
/// 推广码渠道负责人(主合伙人,可多选)
model PromoCodeChannelOwner {
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount @relation("PromoChannelOwner", fields: [partnerAccountId], references: [id], onDelete: Cascade)
@@id([promoCodeId, partnerAccountId])
@@index([partnerAccountId])
@@map("promo_code_channel_owner")
}
model CommonCity { model CommonCity {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(16) code String @unique @db.VarChar(16)
@@ -1287,6 +1305,8 @@ model PartnerAccount {
assocUsers User[] @relation("UserPartnerAssoc") assocUsers User[] @relation("UserPartnerAssoc")
assocSubUsers User[] @relation("UserSubAccountAssoc") assocSubUsers User[] @relation("UserSubAccountAssoc")
userNotes PartnerUserNote[] userNotes PartnerUserNote[]
promoAssocCodes CommonPromoCode[] @relation("PromoAssocPartner")
promoChannelOwnerLinks PromoCodeChannelOwner[] @relation("PromoChannelOwner")
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull) assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull) activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
@@ -152,7 +152,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
group: G.wechat_mini, group: G.wechat_mini,
type: 'string', type: 'string',
requiresRestart: false, requiresRestart: false,
placeholder: '4.0.19', placeholder: '4.0.20',
description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新', description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新',
}, },
{ {
@@ -7,7 +7,7 @@ import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guar
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Module({ @Module({
imports: [forwardRef(() => IamModule), PromoModule], imports: [forwardRef(() => IamModule), forwardRef(() => PromoModule)],
controllers: [AnalyticsController, PromoController], controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard], providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
exports: [AnalyticsService], exports: [AnalyticsService],
@@ -89,6 +89,47 @@ export class PartnerCityService {
return snapshot; return snapshot;
} }
/**
* 推广码关联合伙人:未绑定则 first-lock;已绑同一人 noop;已绑他人静默跳过。
* 不改 sourceType、不写 assocSubAccountId、不增加关联码扫码计数。
*/
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint): Promise<{
bound: boolean;
alreadyBound: boolean;
skipped: boolean;
}> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, assocPartnerAccountId: true },
});
if (!user) {
return { bound: false, alreadyBound: false, skipped: true };
}
if (user.assocPartnerAccountId) {
if (user.assocPartnerAccountId === partnerAccountId) {
return { bound: true, alreadyBound: true, skipped: false };
}
return { bound: false, alreadyBound: false, skipped: true };
}
const primary = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
select: { id: true, isPrimary: true, status: true },
});
if (!primary || primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
return { bound: false, alreadyBound: false, skipped: true };
}
await this.prisma.user.update({
where: { id: userId },
data: {
assocPartnerAccountId: primary.id,
assocBoundAt: new Date(),
},
});
return { bound: true, alreadyBound: false, skipped: false };
}
async validatePrimaryBinding( async validatePrimaryBinding(
cityId: bigint, cityId: bigint,
input: { input: {
@@ -28,7 +28,7 @@ export class HealthController {
return { return {
status, status,
service: 'dukang-api', service: 'dukang-api',
version: '4.0.19', version: '4.0.20',
checks: { db, redis }, checks: { db, redis },
}; };
} }
@@ -1,7 +1,14 @@
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator';
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types'; import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
function toOptionalNullableString(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
const t = String(value).trim();
return t.length ? t : null;
}
export class PromoCodeListQueryDto { export class PromoCodeListQueryDto {
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -30,6 +37,14 @@ export class PromoCodeListQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
ownerUserId?: string; ownerUserId?: string;
@IsOptional()
@IsString()
channelOwnerPartnerId?: string;
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
} }
export class CreatePromoCodeDto { export class CreatePromoCodeDto {
@@ -51,6 +66,17 @@ export class CreatePromoCodeDto {
@IsString() @IsString()
ownerUserId?: string; ownerUserId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
channelOwnerPartnerIds?: string[];
@IsOptional()
@Transform(({ value }) => toOptionalNullableString(value))
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
assocPartnerAccountId?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(256) @MaxLength(256)
@@ -77,6 +103,17 @@ export class UpdatePromoCodeDto {
@IsString() @IsString()
ownerUserId?: string | null; ownerUserId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
channelOwnerPartnerIds?: string[];
@IsOptional()
@Transform(({ value }) => toOptionalNullableString(value))
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
assocPartnerAccountId?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(256) @MaxLength(256)
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoCodeService } from './promo-code.service';
@Controller('partner/promo-codes')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerPromoCodeController {
constructor(private readonly service: PromoCodeService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.service.listForPartner(user.actorId);
}
}
@@ -5,6 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { import {
PROMO_CODE_SCENE_LABELS, PROMO_CODE_SCENE_LABELS,
PromoCodeScene, PromoCodeScene,
@@ -17,6 +18,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface'; import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { PromoMetricLogService } from './promo-metric-log.service'; import { PromoMetricLogService } from './promo-metric-log.service';
import { import {
computePromoMetricPeak, computePromoMetricPeak,
@@ -41,6 +43,13 @@ type PromoTouchMeta = {
sessionId?: string; sessionId?: string;
}; };
type PromoPartnerBrief = {
id: bigint;
companyName: string | null;
name: string;
phone: string;
};
type PromoRow = { type PromoRow = {
id: bigint; id: bigint;
code: string; code: string;
@@ -59,6 +68,8 @@ type PromoRow = {
nickname: string | null; nickname: string | null;
phone: string | null; phone: string | null;
} | null; } | null;
assocPartner?: PromoPartnerBrief | null;
channelOwners?: Array<{ partnerAccount: PromoPartnerBrief }>;
qrcodeResource?: { url: string } | null; qrcodeResource?: { url: string } | null;
}; };
@@ -89,6 +100,7 @@ export class PromoCodeService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly promoMetricLog: PromoMetricLogService, private readonly promoMetricLog: PromoMetricLogService,
private readonly partnerCity: PartnerCityService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider, @Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {} ) {}
@@ -151,6 +163,16 @@ export class PromoCodeService {
}); });
} }
private mapPartnerBrief(partner?: PromoPartnerBrief | null) {
if (!partner) return null;
return serializeBigInt({
id: partner.id,
companyName: partner.companyName,
name: partner.name,
phone: partner.phone,
});
}
private mapRow(row: PromoRow, orderCount?: number) { private mapRow(row: PromoRow, orderCount?: number) {
return serializeBigInt({ return serializeBigInt({
id: row.id, id: row.id,
@@ -165,15 +187,30 @@ export class PromoCodeService {
landingUrl: buildLandingUrl(row.code, row.qrcodeId), landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null, qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser), ownerUser: this.mapOwnerUser(row.ownerUser),
channelOwners: (row.channelOwners ?? []).map((link) => this.mapPartnerBrief(link.partnerAccount)),
assocPartner: this.mapPartnerBrief(row.assocPartner),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}); });
} }
private partnerSelect = {
id: true,
companyName: true,
name: true,
phone: true,
} as const;
private includeRelations = { private includeRelations = {
ownerUser: { ownerUser: {
select: { id: true, userNo: true, nickname: true, phone: true }, select: { id: true, userNo: true, nickname: true, phone: true },
}, },
assocPartner: {
select: this.partnerSelect,
},
channelOwners: {
include: { partnerAccount: { select: this.partnerSelect } },
},
qrcodeResource: { qrcodeResource: {
select: { url: true }, select: { url: true },
}, },
@@ -182,13 +219,7 @@ export class PromoCodeService {
async list(query: PromoCodeListQueryDto) { async list(query: PromoCodeListQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: { const where: Prisma.CommonPromoCodeWhereInput = {};
status?: 'ACTIVE' | 'DISABLED';
scene?: PromoCodeScene;
name?: { contains: string };
code?: { contains: string };
ownerUserId?: bigint;
} = {};
if (query.status) where.status = query.status; if (query.status) where.status = query.status;
if (query.scene) where.scene = query.scene; if (query.scene) where.scene = query.scene;
if (query.name) where.name = { contains: query.name }; if (query.name) where.name = { contains: query.name };
@@ -196,6 +227,14 @@ export class PromoCodeService {
if (query.ownerUserId?.trim()) { if (query.ownerUserId?.trim()) {
where.ownerUserId = BigInt(query.ownerUserId.trim()); where.ownerUserId = BigInt(query.ownerUserId.trim());
} }
if (query.channelOwnerPartnerId?.trim()) {
where.channelOwners = {
some: { partnerAccountId: BigInt(query.channelOwnerPartnerId.trim()) },
};
}
if (query.assocPartnerAccountId?.trim()) {
where.assocPartnerAccountId = BigInt(query.assocPartnerAccountId.trim());
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({ this.prisma.commonPromoCode.findMany({
@@ -237,6 +276,30 @@ export class PromoCodeService {
return user.id; return user.id;
} }
private async resolvePrimaryPartnerId(partnerId?: string | null) {
if (partnerId === undefined) return undefined;
if (partnerId === null || !partnerId.trim()) return null;
const partner = await this.prisma.partnerAccount.findFirst({
where: { id: BigInt(partnerId.trim()), isPrimary: 1, status: 'ACTIVE' },
select: { id: true },
});
if (!partner) throw new BadRequestException('关联合伙人必须是有效的主合伙人');
return partner.id;
}
private async resolveChannelOwnerIds(ids?: string[]) {
const unique = [...new Set((ids ?? []).map((s) => s.trim()).filter(Boolean))];
if (!unique.length) return [] as bigint[];
const rows = await this.prisma.partnerAccount.findMany({
where: { id: { in: unique.map((id) => BigInt(id)) }, isPrimary: 1, status: 'ACTIVE' },
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('渠道负责人必须是有效的主合伙人');
}
return unique.map((id) => BigInt(id));
}
private async generateUniqueCode(custom?: string) { private async generateUniqueCode(custom?: string) {
let code = custom?.trim().toUpperCase(); let code = custom?.trim().toUpperCase();
if (code) { if (code) {
@@ -309,6 +372,8 @@ export class PromoCodeService {
const code = await this.generateUniqueCode(dto.code); const code = await this.generateUniqueCode(dto.code);
const qrcodeId = await this.generateUniqueQrcodeId(); const qrcodeId = await this.generateUniqueQrcodeId();
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId); const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
const assocPartnerAccountId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene; const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
const row = await this.prisma.commonPromoCode.create({ const row = await this.prisma.commonPromoCode.create({
@@ -319,7 +384,11 @@ export class PromoCodeService {
qrcodeId, qrcodeId,
status: 'ACTIVE', status: 'ACTIVE',
ownerUserId, ownerUserId,
assocPartnerAccountId: assocPartnerAccountId ?? undefined,
remark: dto.remark?.trim() || null, remark: dto.remark?.trim() || null,
channelOwners: channelOwnerIds.length
? { create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })) }
: undefined,
}, },
}); });
@@ -339,22 +408,29 @@ export class PromoCodeService {
async update(id: bigint, dto: UpdatePromoCodeDto) { async update(id: bigint, dto: UpdatePromoCodeDto) {
await this.detail(id); await this.detail(id);
const data: { const data: Prisma.CommonPromoCodeUpdateInput = {};
name?: string;
scene?: PromoCodeScene;
remark?: string | null;
ownerUserId?: bigint | null;
} = {};
if (dto.name !== undefined) data.name = dto.name.trim(); if (dto.name !== undefined) data.name = dto.name.trim();
if (dto.scene !== undefined) data.scene = dto.scene; if (dto.scene !== undefined) data.scene = dto.scene;
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null; if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
if (dto.ownerUserId !== undefined) { if (dto.ownerUserId !== undefined) {
if (dto.ownerUserId === null || dto.ownerUserId === '') { if (dto.ownerUserId === null || dto.ownerUserId === '') {
data.ownerUserId = null; data.ownerUser = { disconnect: true };
} else { } else {
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId); const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
data.ownerUser = { connect: { id: ownerUserId } };
} }
} }
if (dto.assocPartnerAccountId !== undefined) {
const assocId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
data.assocPartner = assocId ? { connect: { id: assocId } } : { disconnect: true };
}
if (dto.channelOwnerPartnerIds !== undefined) {
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
data.channelOwners = {
deleteMany: {},
create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })),
};
}
const row = await this.prisma.commonPromoCode.update({ const row = await this.prisma.commonPromoCode.update({
where: { id }, where: { id },
@@ -464,6 +540,10 @@ export class PromoCodeService {
} }
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta); sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
if (promo.assocPartnerAccountId) {
await this.partnerCity.tryBindIfUnbound(userId, promo.assocPartnerAccountId);
}
} }
if (shouldCountScan) { if (shouldCountScan) {
@@ -783,4 +863,43 @@ export class PromoCodeService {
pageSize, pageSize,
}); });
} }
/** 合伙人 H5:仅扫码/归因/订单三项汇总,不含用户或订单明细 */
async listForPartner(partnerAccountId: bigint) {
const where: Prisma.CommonPromoCodeWhereInput = {
OR: [
{ assocPartnerAccountId: partnerAccountId },
{ channelOwners: { some: { partnerAccountId } } },
],
};
const rows = await this.prisma.commonPromoCode.findMany({
where,
select: { id: true, name: true, code: true, status: true, scanCount: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
const ids = rows.map((r) => r.id);
const [attributionRows, orderMap] = await Promise.all([
ids.length
? this.prisma.userPromoAttribution.groupBy({
by: ['promoCodeId'],
where: { promoCodeId: { in: ids } },
_count: { _all: true },
})
: Promise.resolve([] as Array<{ promoCodeId: bigint; _count: { _all: number } }>),
this.completedOrderCounts(ids),
]);
const attributionMap = new Map(attributionRows.map((r) => [r.promoCodeId.toString(), r._count._all]));
return serializeBigInt({
items: rows.map((row) => ({
id: row.id,
name: row.name,
code: row.code,
status: row.status,
scanCount: row.scanCount,
attributionCount: attributionMap.get(row.id.toString()) ?? 0,
orderCount: orderMap.get(row.id.toString()) ?? 0,
})),
});
}
} }
@@ -4,7 +4,10 @@ import { GeoModule } from '../../common/geo/geo.module';
import { IntegrationsModule } from '../../integrations/integrations.module'; import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { AdminPromoCodeController } from './admin-promo-code.controller'; import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PartnerPromoCodeController } from './partner-promo-code.controller';
import { PromoCodeService } from './promo-code.service'; import { PromoCodeService } from './promo-code.service';
import { PromoMetricLogService } from './promo-metric-log.service'; import { PromoMetricLogService } from './promo-metric-log.service';
@@ -12,13 +15,14 @@ import { PromoMetricLogService } from './promo-metric-log.service';
imports: [ imports: [
GeoModule, GeoModule,
IntegrationsModule, IntegrationsModule,
CityScopeModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret', secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' }, signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}), }),
], ],
controllers: [AdminPromoCodeController], controllers: [AdminPromoCodeController, PartnerPromoCodeController],
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard], providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
exports: [PromoCodeService], exports: [PromoCodeService],
}) })
export class PromoModule {} export class PromoModule {}
@@ -131,6 +131,10 @@ export class PartnerAssocService {
}; };
} }
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint) {
return this.partnerCityService.tryBindIfUnbound(userId, partnerAccountId);
}
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) { async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
const { primary, sub } = await this.resolveAssocTarget(input); const { primary, sub } = await this.resolveAssocTarget(input);
const shouldCountScan = input.countScan !== false; const shouldCountScan = input.countScan !== false;