Compare commits

...

9 Commits

Author SHA1 Message Date
jacy ac38979d5c Merge pull request 'Dev' (#73) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/73
2026-09-03 23:31:38 +08:00
jacy 5ae1816414 Merge pull request 'v4.0.17 小程序版本优化' (#72) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/72
2026-09-03 23:31:22 +08:00
jacy 0e711be6c6 v4.0.17 小程序版本优化
CI / verify (pull_request) Waiting to run
2026-09-03 23:28:37 +08:00
jacy 800932249b Merge pull request 'Dev v4.0.16' (#71) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/71
2026-09-03 22:25:37 +08:00
jacy 9e0db27326 Merge pull request 'Dev jacy' (#70) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/70
2026-09-03 21:32:14 +08:00
jacy 8a196c377e Merge commit '7fa69c2521eefbfc1b78cca0e8a45264291c967d' into dev_jacy
CI / verify (pull_request) Waiting to run
2026-09-03 21:29:29 +08:00
jacy 418e13a5e3 企业微信智能机器人API插件 2026-09-03 21:29:10 +08:00
developer_liu 7fa69c2521 merge dev into main
CI / verify (push) Waiting to run
2026-09-03 16:42:58 +08:00
developer_liu b0874f07f6 merge dev-v4.0.15 into dev 2026-09-03 16:42:41 +08:00
40 changed files with 1408 additions and 117 deletions
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Checkbox, Checkbox,
@@ -25,6 +26,8 @@ import {
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS, WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS, WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES, WECOM_BOT_ROLES,
WECOM_PLUGIN_API_KEY_HEADER,
resolveWecomPluginPublicUrl,
type LlmApiConfigOptionDto, type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto, type KnowledgeBaseOptionDto,
type WecomAibotRuntimeDto, type WecomAibotRuntimeDto,
@@ -403,6 +406,34 @@ export default function WecomBotsPage() {
</Space> </Space>
</Space> </Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="企微 API 插件(与上方长连接机器人独立)"
description={
<div>
<div>
URL
<Typography.Text copyable>
{resolveWecomPluginPublicUrl(window.location.hostname)}
</Typography.Text>
</div>
<div>
OpenAPI
<Typography.Text copyable>
{`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`}
</Typography.Text>
</div>
<Typography.Text type="secondary">
Service token / API keyHeader {WECOM_PLUGIN_API_KEY_HEADER}
.env WECOM_PLUGIN_API_KEY
WECOM_PLUGIN_ENABLED=true
</Typography.Text>
</div>
}
/>
<Form <Form
form={filterForm} form={filterForm}
layout="inline" layout="inline"
+4
View File
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
<p className="label-md text-muted">: {entry.totalStores} </p> <p className="label-md text-muted">: {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <div className="partner-leaderboard-stat">
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p> <p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p> <p className="label-md text-muted"></p>
</>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
<p className="label-md text-muted"> {entry.totalStores} </p> <p className="label-md text-muted"> {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <div className="partner-leaderboard-stat">
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p> <p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted">{periodSubLabel(period)}</p> <p className="label-md text-muted">{periodSubLabel(period)}</p>
</>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -1,28 +1,33 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { View, Text, Swiper, SwiperItem } from '@tarojs/components'; import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import { formatRedeemRelativeTime } from '../lib/datetime';
export type StoreRedeemMarqueeItem = { export type StoreRedeemMarqueeItem = {
userLabel: string; userLabel: string;
amount: string; amount: string;
createdAt?: string | null;
}; };
type StoreRedeemMarqueeProps = { type StoreRedeemMarqueeProps = {
items: StoreRedeemMarqueeItem[]; items: StoreRedeemMarqueeItem[];
}; };
const STAY_MS = 20000; const STAY_MS = 5000;
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) { function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
const timeLabel = formatRedeemRelativeTime(item.createdAt);
return ( return (
<View className="store-detail-marquee-inner"> <View className="store-detail-marquee-inner">
<View className="store-detail-marquee-dot" /> <View className="store-detail-marquee-dot" />
<Text className="store-detail-marquee-text">{item.userLabel} </Text> <Text className="store-detail-marquee-text">
{item.userLabel} {timeLabel}
</Text>
<Text className="store-detail-marquee-amount">{item.amount}</Text> <Text className="store-detail-marquee-amount">{item.amount}</Text>
</View> </View>
); );
} }
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */ /** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) { export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
const list = useMemo( const list = useMemo(
() => () =>
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
.map((row) => ({ .map((row) => ({
userLabel: String(row.userLabel || '用户***').trim() || '用户***', userLabel: String(row.userLabel || '用户***').trim() || '用户***',
amount: String(row.amount || '').trim(), amount: String(row.amount || '').trim(),
createdAt: row.createdAt ?? null,
})) }))
.filter((row) => row.amount), .filter((row) => row.amount),
[items], [items],
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
indicatorDots={false} indicatorDots={false}
> >
{list.map((row, index) => ( {list.map((row, index) => (
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}> <SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
<MarqueeRow item={row} /> <MarqueeRow item={row} />
</SwiperItem> </SwiperItem>
))} ))}
+37
View File
@@ -1,3 +1,40 @@
const MS_MINUTE = 60_000;
const MS_HOUR = 60 * MS_MINUTE;
const MS_DAY = 24 * MS_HOUR;
function shanghaiParts(input: string | Date) {
const d = input instanceof Date ? input : new Date(input);
if (Number.isNaN(d.getTime())) return null;
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return {
year: sh.getUTCFullYear(),
month: p(sh.getUTCMonth() + 1),
day: p(sh.getUTCDate()),
hour: p(sh.getUTCHours()),
minute: p(sh.getUTCMinutes()),
};
}
/** 核销走马灯时间:24 小时内相对时间,超过则显示具体时间 */
export function formatRedeemRelativeTime(input?: string | Date | null): string {
if (input == null || input === '') return '刚刚';
const at = input instanceof Date ? input : new Date(input);
if (Number.isNaN(at.getTime())) return '刚刚';
const diffMs = Date.now() - at.getTime();
if (diffMs < MS_MINUTE) return '刚刚';
if (diffMs < MS_HOUR) return `${Math.floor(diffMs / MS_MINUTE)}分钟前`;
if (diffMs < MS_DAY) return `${Math.floor(diffMs / MS_HOUR)}小时前`;
const parts = shanghaiParts(at);
if (!parts) return '刚刚';
const nowParts = shanghaiParts(new Date());
const datePrefix =
nowParts && parts.year === nowParts.year
? `${parts.month}-${parts.day}`
: `${parts.year}-${parts.month}-${parts.day}`;
return `${datePrefix} ${parts.hour}:${parts.minute}`;
}
/** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */ /** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
export function formatShanghaiDateTime(input?: string | Date | null): string { export function formatShanghaiDateTime(input?: string | Date | null): string {
if (input == null || input === '') return '—'; if (input == null || input === '') return '—';
+1 -5
View File
@@ -22,7 +22,7 @@ import {
isHomeCatalogBootstrapped, isHomeCatalogBootstrapped,
setHomeCatalogCache, setHomeCatalogCache,
} from '../../lib/home-catalog-session'; } from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import { import {
@@ -202,8 +202,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -213,8 +211,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
+27 -26
View File
@@ -102,7 +102,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey); const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true); const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP); const [showSmsForm, setShowSmsForm] = useState(true);
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl); const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
useEffect(() => { useEffect(() => {
@@ -393,7 +393,7 @@ export default function LoginPage() {
? '绑定手机号' ? '绑定手机号'
: completeMode === 'wechat' : completeMode === 'wechat'
? '授权登录' ? '授权登录'
: '手机号快捷登录'; : '验证码登录';
return ( return (
<PageShell variant="plain" className="login-page"> <PageShell variant="plain" className="login-page">
@@ -451,32 +451,9 @@ export default function LoginPage() {
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} /> <AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{showPhoneQuick ? (
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
) : null}
{showPhoneQuick ? (
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text
className="login-divider-text"
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
<View className="login-divider-line" />
</View>
) : null}
{(showSmsForm || !showPhoneQuick) && ( {(showSmsForm || !showPhoneQuick) && (
<> <>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}> <View className="login-field">
<Text className="login-field-prefix">+86</Text> <Text className="login-field-prefix">+86</Text>
<Input <Input
className="login-field-input" className="login-field-input"
@@ -526,6 +503,30 @@ export default function LoginPage() {
</> </>
)} )}
{showPhoneQuick ? (
<>
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text className="login-divider-text"></Text>
<View className="login-divider-line" />
</View>
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
<Text
className="login-divider-text"
style={{ display: 'block', textAlign: 'center', marginTop: 12 }}
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
</>
) : null}
{displayMsg ? ( {displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}> <Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg} {displayMsg}
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
@@ -150,9 +149,6 @@ export default function OrderConfirmPickupPage() {
} }
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
const { confirm } = await Taro.showModal({ const { confirm } = await Taro.showModal({
title: '确认提交订单', title: '确认提交订单',
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`, content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
@@ -8,7 +8,6 @@ import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav'; import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location'; import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import DeliveryHintHtml from '../../components/DeliveryHintHtml'; import DeliveryHintHtml from '../../components/DeliveryHintHtml';
@@ -291,9 +290,6 @@ export default function OrderConfirmPage() {
} }
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import BenefitIntroCard from '../../components/BenefitIntroCard'; import BenefitIntroCard from '../../components/BenefitIntroCard';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
import { import {
getProductCarouselImages, getProductCarouselImages,
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
export default function RedeemSuccessPage() { export default function RedeemSuccessPage() {
const router = useRouter(); const router = useRouter();
const [fromHistory, setFromHistory] = useState(false); const [fromHistory, setFromHistory] = useState(false);
const [fromStore, setFromStore] = useState(false);
const [record, setRecord] = useState<RedeemRecord | null>(null); const [record, setRecord] = useState<RedeemRecord | null>(null);
const [score, setScore] = useState(5); const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']); const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [imageUrls, setImageUrls] = useState<string[]>([]); const [imageUrls, setImageUrls] = useState<string[]>([]);
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
const rated = Boolean(record?.rating); const rated = Boolean(record?.rating);
const applyRating = useCallback((rating: StoreRatingDto) => { const applyRating = useCallback((rating: StoreRatingDto) => {
const nextScore = Number(rating.serviceScore || rating.envScore || 5); const nextService = Number(rating.serviceScore || 5);
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5); const nextEnv = Number(rating.envScore || 5);
setServiceScore(
Number.isFinite(nextService) && nextService > 0 ? Math.min(5, Math.round(nextService)) : 5,
);
setEnvScore(Number.isFinite(nextEnv) && nextEnv > 0 ? Math.min(5, Math.round(nextEnv)) : 5);
setTags(Array.isArray(rating.tags) ? rating.tags : []); setTags(Array.isArray(rating.tags) ? rating.tags : []);
setComment(String(rating.comment || '')); setComment(String(rating.comment || ''));
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []); setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
@@ -105,6 +111,7 @@ export default function RedeemSuccessPage() {
const id = String(options?.id || router.params.id || '').trim(); const id = String(options?.id || router.params.id || '').trim();
const from = String(options?.from || router.params.from || ''); const from = String(options?.from || router.params.from || '');
setFromHistory(from === 'history'); setFromHistory(from === 'history');
setFromStore(from === 'store');
if (id) { if (id) {
void loadRecord(id); void loadRecord(id);
return; return;
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
function leave() { function leave() {
clearCache(); clearCache();
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
if (fromHistory && pages.length > 1) { if ((fromHistory || fromStore) && pages.length > 1) {
Taro.navigateBack(); Taro.navigateBack();
return; return;
} }
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
method: 'POST', method: 'POST',
data: { data: {
redeemRecordId: record.id, redeemRecordId: record.id,
serviceScore: score, serviceScore,
envScore: score, envScore,
comment: comment.trim(), comment: comment.trim(),
tags, tags,
imageUrls, imageUrls,
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
prev prev
? { ? {
...prev, ...prev,
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls }, rating: { serviceScore, envScore, comment, tags, imageUrls },
} }
: prev, : prev,
); );
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
</View> </View>
<View className="review-card"> <View className="review-card">
<Text className="review-card-title"></Text> <Text className="review-card-title"></Text>
<View className="review-star-row"> <View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => ( {[1, 2, 3, 4, 5].map((value) => (
<Text <Text
key={value} key={`service-${value}`}
className={`review-star${value <= score ? ' review-star--active' : ''}`} className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
onClick={() => { onClick={() => {
if (!rated) setScore(value); if (!rated) setServiceScore(value);
}} }}
> >
</Text> </Text>
))} ))}
</View> </View>
<Text className="review-score-label">{SCORE_LABELS[score]}</Text> <Text className="review-score-label">{SCORE_LABELS[serviceScore]}</Text>
</View>
<View className="review-card">
<Text className="review-card-title"></Text>
<View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => (
<Text
key={`env-${value}`}
className={`review-star${value <= envScore ? ' review-star--active' : ''}`}
onClick={() => {
if (!rated) setEnvScore(value);
}}
>
</Text>
))}
</View>
<Text className="review-score-label">{SCORE_LABELS[envScore]}</Text>
</View> </View>
<View className="review-card"> <View className="review-card">
@@ -16,7 +16,7 @@ import ProductCarousel from '../../components/ProductCarousel';
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard'; import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast, isLoggedIn } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money'; import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone'; import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics'; import { track } from '../../lib/analytics';
@@ -158,7 +158,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
const userLabel = String(row.userLabel || '用户***').trim() || '用户***'; const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
const amount = formatRedeemAmountYuan(row.amount); const amount = formatRedeemAmountYuan(row.amount);
if (!amount) return null; if (!amount) return null;
return { userLabel, amount }; return { userLabel, amount, createdAt: row.createdAt };
} catch { } catch {
return null; return null;
} }
@@ -189,6 +189,7 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
const storeRef = useRef<Store | null>(null); const storeRef = useRef<Store | null>(null);
storeRef.current = store; storeRef.current = store;
@@ -274,6 +275,13 @@ export default function StoreDetailPage() {
if (!id) return; if (!id) return;
void loadStore(id); void loadStore(id);
void loadRecentRedeems(id); void loadRecentRedeems(id);
if (isLoggedIn()) {
void request<{ id?: string | null }>(`/redeem/records/pending-rating?storeId=${id}`)
.then((data) => setPendingRatingId(data?.id ? String(data.id) : null))
.catch(() => setPendingRatingId(null));
} else {
setPendingRatingId(null);
}
}); });
const sharePayload = useMemo(() => { const sharePayload = useMemo(() => {
@@ -530,8 +538,20 @@ export default function StoreDetailPage() {
) : null} ) : null}
<View className="store-detail-bar"> <View className="store-detail-bar">
{pendingRatingId ? (
<View <View
className="u-btn u-btn--block" className="store-detail-bar-btn store-detail-bar-btn--ghost"
onClick={() =>
Taro.navigateTo({
url: `/pages/redeem-success/index?id=${pendingRatingId}&from=store`,
})
}
>
<Text></Text>
</View>
) : null}
<View
className={`store-detail-bar-btn store-detail-bar-btn--primary${pendingRatingId ? '' : ' store-detail-bar-btn--full'}`}
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })} onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
> >
<Text></Text> <Text></Text>
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '套餐详情', navigationBarTitleText: '套餐详情',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import '../../styles/store-detail.css'; import '../../styles/store-detail.css';
import '../../styles/product-detail.css'; import '../../styles/product-detail.css';
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
type StorePackage = { type StorePackage = {
name: string; name: string;
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
export default function StorePackageDetailPage() { export default function StorePackageDetailPage() {
const router = useRouter(); const router = useRouter();
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId)); const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
const [pkg, setPkg] = useState<StorePackage | null>(null); const [pkg, setPkg] = useState<StorePackage | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
const sid = pickStoreId(options?.storeId || router.params.storeId); const sid = pickStoreId(options?.storeId || router.params.storeId);
const index = parsePackageIndex(options?.index ?? router.params.index); const index = parsePackageIndex(options?.index ?? router.params.index);
setStoreId(sid); setStoreId(sid);
setPackageIndex(index);
void loadPackage(sid, index); void loadPackage(sid, index);
}); });
function goBack() { const imageUrls = useMemo(
() => (pkg ? normalizeStorePackageImageUrls(pkg) : []),
[pkg],
);
const sharePayload = useMemo(
() =>
buildSceneSharePayload('storeDetail', {
path: `/pages/store-package-detail/index?storeId=${storeId}&index=${packageIndex}`,
dynamicTitle: pkg
? storeName
? `${pkg.name} · ${storeName}`
: pkg.name
: undefined,
dynamicDesc: pkg?.dishes?.trim() || undefined,
dynamicImageUrl: imageUrls[0] || undefined,
}),
[imageUrls, pkg, packageIndex, storeId, storeName],
);
const sharePayloadRef = useRef(sharePayload);
sharePayloadRef.current = sharePayload;
const shareQuery = storeId ? `storeId=${storeId}&index=${packageIndex}` : '';
const goBack = useCallback(() => {
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
if (pages.length > 1) Taro.navigateBack(); if (pages.length > 1) Taro.navigateBack();
else if (storeId) { else if (storeId) {
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
} else { } else {
Taro.switchTab({ url: '/pages/stores/index' }); Taro.switchTab({ url: '/pages/stores/index' });
} }
} }, [storeId]);
if (loading) { // 不用 useShare* HookTaro 编译会把它们折进 return,触发 React #310
return ( useEffect(() => {
<PageShell variant="scroll" className="store-package-detail-page"> const page = Taro.getCurrentInstance().page as
<PageNavBar title="套餐详情" solid onBack={goBack} /> | {
<View className="page-with-nav-bar u-empty"></View> onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
</PageShell> onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
);
} }
| undefined;
if (!page) return;
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
}, [shareQuery]);
if (!pkg) { if (!pkg) {
return ( return (
<PageShell variant="scroll" className="store-package-detail-page"> <PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<PageNavBar title="套餐详情" solid onBack={goBack} /> <PageNavBar title="套餐详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View> <View className="page-with-nav-bar u-empty">
{loading ? '加载中…' : loadError || '套餐不存在'}
</View>
</PageShell> </PageShell>
); );
} }
const imageUrls = normalizeStorePackageImageUrls(pkg);
return ( return (
<PageShell variant="scroll" className="store-package-detail-page"> <PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} /> <PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
<View className="store-package-detail-body"> <View className="store-package-detail-body">
+3 -1
View File
@@ -492,7 +492,9 @@ export default function StoresPage() {
</Text> </Text>
))} ))}
</View> </View>
<Text className="store-card-redeem">{s.redeemCount ?? 0}</Text> {Number(s.redeemCount) > 0 ? (
<Text className="store-card-redeem">{s.redeemCount}</Text>
) : null}
</View> </View>
<View className="store-card-row store-card-row--mid"> <View className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}> <Text className="store-card-address" numberOfLines={2}>
+2
View File
@@ -68,9 +68,11 @@
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 | | 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 | | 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 | | 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`X-Api-Key);与长连接 Bot 独立 |
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls` | | 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls` |
| 2026-08-26 | v3.5.14`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` | | 2026-08-26 | v3.5.14`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 | | 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
+123
View File
@@ -0,0 +1,123 @@
# 杜康好客 · v3.5.16 开发文档
> **2026-09-03** · integrations/wecom · admin-web · domain · shared-types
> **主题**:企微智能机器人 **API 插件**只读数据面
---
## 1. 版本目标
企微后台「添加 API 插件」对接本系统:企微托管大模型 HTTP 调公网接口拉数。与 HQ「企微机器人」长连接 Bot **独立**,不改指令/审批/短信验身。
**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 接口暴露给企微。
---
## 2. 与长连接 Bot 的分工
| | 长连接智能机器人 | API 插件 |
|--|------------------|----------|
| 对话 | 我们收消息并回复 | 企微自带模型组织回复 |
| 入口 | HQ 企微机器人 + `@wecom/aibot-node-sdk` | 企微「添加 API 插件」 |
| 鉴权 | BotID / Secret | Header `X-Api-Key` |
| 能力 | 指令 + 工单/审批等 | 第一期只读查询 |
建议:客服/技术支持继续走长连接;另建一只「运营查询」机器人只挂本插件。
---
## 3. 环境变量
```
WECOM_PLUGIN_ENABLED=true
WECOM_PLUGIN_API_KEY=<随机长密钥>
```
只放 `.env` / `.env.staging` / `.env.production`,不进 HQ 系统设置、不进 Git。
---
## 4. 接口
前缀:`/api/v1/wecom/plugin`
鉴权:Header `X-Api-Key`(未启用 / 无 Key / 错 Key 一律 401
响应:`{ code, message, data }``GET .../openapi.json` **除外**,原样 OpenAPI 3.0
列表 `pageSize` 默认 5、最大 10。手机号 `maskContactPhone`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 插件说明 |
| GET | `/openapi.json` | 供第 2 步导入工具 |
| GET | `/orders?q=` | 订单号 |
| GET | `/users?q=` | 用户号或 11 位手机 |
| GET | `/stores?q=` | 门店名 |
| GET | `/redeems?q=` | 核销单号或门店名 |
| GET | `/promo-codes?q=` | 推广码 / 名称 |
| GET | `/promo-codes/:code/stats` | 推广码统计 |
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord``today` 期末为当前时刻。
审计:`log_wecom_bot.botKey=plugin`
---
## 5. 企微表单(上线后)
| 字段 | 值 |
|------|-----|
| 插件 URL | 生产 `https://api.dukanghaoke.com/api/v1/wecom/plugin`;测试 `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` |
| 授权 | Service token / API key |
| 位置 | Header |
| Parameter name | `X-Api-Key` |
| Service token | 与 `WECOM_PLUGIN_API_KEY` 相同 |
| 第 2 步导入 | `…/wecom/plugin/openapi.json`(同一把 Key |
---
## 6. 联调清单(staging curl
先在 `api-test``.env.staging` 打开开关并写入 Key,重启 `dukang-api`
```bash
BASE=https://api-test.dukanghaoke.com/api/v1/wecom/plugin
KEY='<WECOM_PLUGIN_API_KEY>'
# 无 Key → 401
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/metrics"
# 错 Key → 401
curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: wrong" "$BASE/metrics"
# 经营指标 / OpenAPI / 订单 / 门店 / 推广码
curl -sS -H "X-Api-Key: $KEY" "$BASE/metrics?kind=today"
curl -sS -H "X-Api-Key: $KEY" "$BASE/openapi.json" | head -c 200
curl -sS -H "X-Api-Key: $KEY" "$BASE/orders?q=DK"
curl -sS -H "X-Api-Key: $KEY" "$BASE/stores?q=店"
curl -sS -H "X-Api-Key: $KEY" "$BASE/promo-codes?q=DK"
```
企微:导入 OpenAPI → 白名单会话用自然语言问订单/门店 → HQ「企微机器人 → 日志」出现 `plugin`
---
## 7. 变更面
| 层 | 路径 |
|----|------|
| domain | `wecom-plugin.ts` |
| shared-types | `wecom-plugin.ts` |
| API | `integrations/wecom/wecom-plugin.*``WecomModule` 注册 Controller |
| HQ | `WecomBotsPage.tsx` 插件 URL / Header 提示(不展示 Key |
| env | `.env*.example``WECOM_PLUGIN_ENABLED` / `WECOM_PLUGIN_API_KEY` |
---
## 8. 验收
- [ ] 无 Key / 错 Key → 401
- [ ] curl 带 Key 查订单 / 门店 / 推广码 / 今日指标,`{ code:0, data }` 且手机脱敏
- [ ] `openapi.json` 为 OpenAPI 文档(无信封)
- [ ] 企微第 2 步可导入工具;白名单会话能问到真实数据
- [ ] HQ 企微机器人日志可见 `plugin`
- [ ] 现有长连接 Bot 行为不变
+2
View File
@@ -48,6 +48,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 企微报告(v3.5.15**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord` **HQ 企微报告(v3.5.15**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord`
**企微 API 插件(v3.5.16**`GET /api/v1/wecom/plugin/*`Header `X-Api-Key`;只读订单/用户/门店/核销/推广码/经营指标。与长连接 Bot 独立。OpenAPI`GET /api/v1/wecom/plugin/openapi.json`
**HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 **HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
**C 端(v3.5.10**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。 **C 端(v3.5.10**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html``GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat``CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
+1
View File
@@ -150,6 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
| 能力 | 入口 | | 能力 | 入口 |
|------|------| |------|------|
| 智能机器人 | `/wecom/bots` 长连接指令 | | 智能机器人 | `/wecom/bots` 长连接指令 |
| API 插件 | `/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
| 消息推送 | `/wecom/pushes` Webhook+eventKey | | 消息推送 | `/wecom/pushes` Webhook+eventKey |
| 日志 | `/logs/wecom-bots` | | 日志 | `/logs/wecom-bots` |
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 | | C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
+1
View File
@@ -413,4 +413,5 @@ export * from './shanghai-date';
export * from './dashboard-period'; export * from './dashboard-period';
export * from './dashboard-series'; export * from './dashboard-series';
export * from './wecom-report'; export * from './wecom-report';
export * from './wecom-plugin';
export * from './shipping-address'; export * from './shipping-address';
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { maskContactPhone } from './phone';
import {
clampWecomPluginPageSize,
isWecomPluginEnabled,
parseWecomPluginMetricsKind,
toWecomPluginUserView,
verifyWecomPluginApiKey,
wecomPluginMetricsPeriod,
} from './wecom-plugin';
describe('verifyWecomPluginApiKey', () => {
it('accepts an exact match', () => {
expect(verifyWecomPluginApiKey('secret-token', 'secret-token')).toBe(true);
});
it('rejects wrong, empty, or missing keys', () => {
expect(verifyWecomPluginApiKey('secret-token', 'other-token')).toBe(false);
expect(verifyWecomPluginApiKey('secret-token', 'secret-toke')).toBe(false);
expect(verifyWecomPluginApiKey('', 'secret-token')).toBe(false);
expect(verifyWecomPluginApiKey('secret-token', '')).toBe(false);
expect(verifyWecomPluginApiKey('secret-token', null)).toBe(false);
});
});
describe('isWecomPluginEnabled', () => {
it('requires both the switch and a non-empty key', () => {
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
true,
);
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: '' })).toBe(
false,
);
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'false', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
false,
);
});
});
describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => {
it('defaults and caps pageSize at 10', () => {
expect(clampWecomPluginPageSize(undefined)).toBe(5);
expect(clampWecomPluginPageSize('3')).toBe(3);
expect(clampWecomPluginPageSize(99)).toBe(10);
expect(clampWecomPluginPageSize(0)).toBe(5);
});
it('parses metrics kind, defaulting empty to today', () => {
expect(parseWecomPluginMetricsKind(undefined)).toBe('today');
expect(parseWecomPluginMetricsKind('weekly')).toBe('weekly');
expect(parseWecomPluginMetricsKind('nope')).toBeNull();
});
});
describe('toWecomPluginUserView', () => {
it('masks phone and fills empty nickname', () => {
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
userNo: 'DK1',
nickname: '—',
phone: '138****8000',
});
expect(maskContactPhone('13800138000')).toBe('138****8000');
});
});
describe('wecomPluginMetricsPeriod', () => {
it('today starts at Shanghai midnight and ends at now', () => {
const now = new Date('2026-09-03T21:15:00+08:00');
const p = wecomPluginMetricsPeriod('today', now);
expect(p.kind).toBe('today');
expect(p.periodKey).toBe('2026-09-03');
expect(p.start.toISOString()).toBe(new Date('2026-09-03T00:00:00+08:00').toISOString());
expect(p.endExclusive.getTime()).toBe(now.getTime());
});
});
+87
View File
@@ -0,0 +1,87 @@
import { maskContactPhone } from './phone';
import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report';
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const;
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
export type WecomPluginMetricsPeriod = Omit<WecomReportPeriod, 'kind'> & {
kind: WecomPluginMetricsKind;
};
export function isWecomPluginMetricsKind(v: string): v is WecomPluginMetricsKind {
return (WECOM_PLUGIN_METRICS_KINDS as readonly string[]).includes(v);
}
export function parseWecomPluginMetricsKind(raw?: string | null): WecomPluginMetricsKind | null {
const v = String(raw ?? '').trim().toLowerCase();
if (!v) return 'today';
return isWecomPluginMetricsKind(v) ? v : null;
}
export function clampWecomPluginPageSize(raw?: string | number | null): number {
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(n) || n <= 0) return WECOM_PLUGIN_PAGE_SIZE_DEFAULT;
return Math.min(WECOM_PLUGIN_PAGE_SIZE_MAX, Math.max(1, Math.floor(n)));
}
export function clampWecomPluginPage(raw?: string | number | null): number {
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(n) || n <= 0) return 1;
return Math.min(100, Math.floor(n));
}
/** 长度必须相同后再逐字符 XOR,避免短密码走快速失败路径时的明显差异(仍非密码学级) */
export function verifyWecomPluginApiKey(provided: string, expected?: string | null): boolean {
const exp = String(expected ?? '');
const got = String(provided ?? '');
if (!exp || !got || exp.length !== got.length) return false;
let diff = 0;
for (let i = 0; i < exp.length; i++) {
diff |= exp.charCodeAt(i) ^ got.charCodeAt(i);
}
return diff === 0;
}
export function isWecomPluginEnabled(env: {
WECOM_PLUGIN_ENABLED?: string;
WECOM_PLUGIN_API_KEY?: string;
}): boolean {
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
}
export function wecomPluginMetricsPeriod(
kind: WecomPluginMetricsKind,
now = new Date(),
): WecomPluginMetricsPeriod {
if (kind === 'today') {
const start = startOfShanghaiDay(now);
const ymd = shanghaiYmd(start);
return {
kind,
start,
endExclusive: now,
periodKey: ymd,
title: `今日(${ymd}`,
rangeLabel: ymd,
incrementLabel: '今日新增',
};
}
const period = wecomReportPeriod(kind as WecomReportKind, now);
return { ...period, kind };
}
export function toWecomPluginUserView(user: {
userNo: string;
nickname?: string | null;
phone?: string | null;
}): { userNo: string; nickname: string; phone: string } {
return {
userNo: user.userNo,
nickname: user.nickname?.trim() || '—',
phone: maskContactPhone(user.phone),
};
}
+1
View File
@@ -29,6 +29,7 @@ export * from './city-warehouse';
export * from './fulfillment-provider'; export * from './fulfillment-provider';
export * from './system-config'; export * from './system-config';
export * from './wecom-bot'; export * from './wecom-bot';
export * from './wecom-plugin';
export * from './wecom-message-push'; export * from './wecom-message-push';
export * from './wecom-report'; export * from './wecom-report';
export * from './llm-config'; export * from './llm-config';
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_BASE_PATH,
resolveWecomPluginPublicUrl,
} from './wecom-plugin';
describe('resolveWecomPluginPublicUrl', () => {
it('maps HQ production host to api.dukanghaoke.com', () => {
expect(resolveWecomPluginPublicUrl('admin.dukanghaoke.com')).toBe(
`https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('maps staging hosts to api-test', () => {
expect(resolveWecomPluginPublicUrl('admin-test.dukanghaoke.com')).toBe(
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
expect(resolveWecomPluginPublicUrl('api-test.dukanghaoke.com')).toBe(
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('falls back to local API for unknown hosts', () => {
expect(resolveWecomPluginPublicUrl('localhost')).toBe(
`http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`,
);
});
it('uses X-Api-Key as the plugin auth header', () => {
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
});
});
+25
View File
@@ -0,0 +1,25 @@
/** 企微智能机器人 API 插件(只读数据面,与长连接 Bot 独立) */
export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key';
export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin';
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const;
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
export function resolveWecomPluginPublicUrl(hostname: string): string {
const host = String(hostname || '').toLowerCase();
if (host === 'admin.dukanghaoke.com' || host === 'api.dukanghaoke.com') {
return `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
}
if (host.includes('dukanghaoke.com') && host.includes('test')) {
return `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
}
return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`;
}
+4
View File
@@ -67,6 +67,10 @@ WX_MINI_MSG_AES_KEY=
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建) # 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false WECOM_AIBOT_ENABLED=false
# 企微「API 插件」只读数据面(与长连接 Bot 独立;密钥勿提交)
WECOM_PLUGIN_ENABLED=false
# WECOM_PLUGIN_API_KEY=
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」) # 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults # 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
# WECOM_ALERT_ENABLED=false # WECOM_ALERT_ENABLED=false
@@ -54,6 +54,10 @@ WX_MINI_MSG_AES_KEY=
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护) # 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
WECOM_AIBOT_ENABLED=false WECOM_AIBOT_ENABLED=false
# 企微 API 插件只读数据面(与长连接 Bot 独立)
WECOM_PLUGIN_ENABLED=false
# WECOM_PLUGIN_API_KEY=
# 运营告警:企业微信群机器人 Webhook # 运营告警:企业微信群机器人 Webhook
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」) # 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
# WECOM_ALERT_ENABLED=false # WECOM_ALERT_ENABLED=false
+4
View File
@@ -54,6 +54,10 @@ WX_MINI_MSG_AES_KEY=
WECOM_AIBOT_ENABLED=false WECOM_AIBOT_ENABLED=false
# 企微 API 插件(测试环境单独一把 Key)
WECOM_PLUGIN_ENABLED=false
# WECOM_PLUGIN_API_KEY=
# 运营告警:企业微信群机器人 Webhook # 运营告警:企业微信群机器人 Webhook
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」) # 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
# WECOM_ALERT_ENABLED=false # WECOM_ALERT_ENABLED=false
@@ -51,6 +51,7 @@ const fixed = {
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay', WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
OSS_UPLOAD_PREFIX: 'staging/uploads', OSS_UPLOAD_PREFIX: 'staging/uploads',
WECOM_AIBOT_ENABLED: 'false', WECOM_AIBOT_ENABLED: 'false',
WECOM_PLUGIN_ENABLED: 'false',
}; };
const preferFromProd = [ const preferFromProd = [
@@ -6,10 +6,19 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, map } from 'rxjs'; import { Observable, map } from 'rxjs';
function skipResponseWrap(url?: string): boolean {
const path = (url || '').split('?')[0];
return path.endsWith('/wecom/plugin/openapi.json');
}
@Injectable() @Injectable()
export class ResponseInterceptor implements NestInterceptor { export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>();
const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>(); const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>();
if (skipResponseWrap(req.originalUrl || req.url)) {
return next.handle();
}
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
if (res.headersSent) return data; if (res.headersSent) return data;
@@ -22,3 +31,4 @@ export class ResponseInterceptor implements NestInterceptor {
); );
} }
} }
@@ -0,0 +1,418 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
clampWecomPluginPage,
clampWecomPluginPageSize,
maskContactPhone,
MOBILE_PHONE_RE,
parseWecomPluginMetricsKind,
toWecomPluginUserView,
wecomPluginMetricsPeriod,
type WecomReportStats,
} from '@dukang/domain';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { PromoCodeService } from '../../modules/promo/promo-code.service';
import { WecomBotAuditService } from './wecom-bot-audit.service';
import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants';
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
if (v == null) return 0;
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
return Number(v);
}
function requireQuery(q?: string): string {
const v = String(q ?? '').trim();
if (!v) throw new BadRequestException('请提供查询关键词 q');
return v;
}
@Injectable()
export class WecomPluginQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly promo: PromoCodeService,
private readonly audit: WecomBotAuditService,
) {}
info() {
return {
name: '杜康好客运营查询',
description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。',
auth: { header: 'X-Api-Key' },
tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'],
};
}
queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.order.read',
permission: 'order.read',
inputSummary: keyword,
},
async () => {
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where: { orderNo: { contains: keyword } },
orderBy: { createdAt: 'desc' },
skip,
take,
include: {
user: { select: { userNo: true, nickname: true, phone: true } },
delivery: { select: { trackingNo: true, provider: true } },
},
}),
this.prisma.order.count({ where: { orderNo: { contains: keyword } } }),
]);
return {
total,
items: items.map((o) => ({
orderNo: o.orderNo,
status: o.status,
payStatus: o.payStatus,
deliveryType: o.deliveryType,
productName: o.productName,
quantity: o.quantity,
payAmount: asNumber(o.payAmount),
user: toWecomPluginUserView({
userNo: o.user?.userNo || '—',
nickname: o.user?.nickname,
phone: o.user?.phone,
}),
receiverName: o.receiverName,
receiverPhone: maskContactPhone(o.receiverPhone),
receiverCity: o.receiverCity,
trackingNo: o.delivery?.trackingNo || null,
createdAt: o.createdAt.toISOString(),
})),
};
},
);
}
queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.user.read',
permission: 'user.read',
inputSummary: MOBILE_PHONE_RE.test(keyword) ? maskContactPhone(keyword) : keyword,
},
async () => {
const where = MOBILE_PHONE_RE.test(keyword)
? { phone: keyword, mergedIntoUserId: null }
: { userNo: { contains: keyword }, mergedIntoUserId: null };
const [rows, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
id: true,
userNo: true,
nickname: true,
phone: true,
status: true,
createdAt: true,
_count: { select: { orders: true } },
},
}),
this.prisma.user.count({ where }),
]);
const balances = await Promise.all(
rows.map((u) =>
this.prisma.benefitCoupon.aggregate({
where: { userId: u.id, status: 'ACTIVE' },
_sum: { balance: true },
}),
),
);
return {
total,
items: rows.map((u, i) => ({
...toWecomPluginUserView(u),
status: u.status,
orderCount: u._count.orders,
benefitBalance: asNumber(balances[i]?._sum.balance),
createdAt: u.createdAt.toISOString(),
})),
};
},
);
}
queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.store.read',
permission: 'store.read',
inputSummary: keyword,
},
async () => {
const where = { name: { contains: keyword } };
const [rows, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip,
take,
select: {
name: true,
status: true,
cityName: true,
district: true,
address: true,
contactPhone: true,
phone: true,
},
}),
this.prisma.store.count({ where }),
]);
return {
total,
items: rows.map((s) => ({
name: s.name,
status: s.status,
cityName: s.cityName,
district: s.district,
address: s.address,
contactPhone: maskContactPhone(s.contactPhone || s.phone),
})),
};
},
);
}
queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.redeem.read',
permission: 'redeem.read',
inputSummary: keyword,
},
async () => {
const byNo = await this.prisma.redeemRecord.findMany({
where: { redeemNo: { contains: keyword } },
orderBy: { createdAt: 'desc' },
skip,
take,
include: { store: { select: { name: true } } },
});
const rows =
byNo.length > 0
? byNo
: await this.prisma.redeemRecord.findMany({
where: { store: { name: { contains: keyword } } },
orderBy: { createdAt: 'desc' },
skip,
take,
include: { store: { select: { name: true } } },
});
const total =
byNo.length > 0
? await this.prisma.redeemRecord.count({ where: { redeemNo: { contains: keyword } } })
: await this.prisma.redeemRecord.count({
where: { store: { name: { contains: keyword } } },
});
return {
total,
items: rows.map((r) => ({
redeemNo: r.redeemNo,
amount: asNumber(r.amount),
channel: r.channel,
storeName: r.store?.name || '—',
createdAt: r.createdAt.toISOString(),
})),
};
},
);
}
queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.promo.read',
inputSummary: keyword,
},
async () => {
const where = {
OR: [
{ code: { contains: keyword.toUpperCase() } },
{ name: { contains: keyword } },
],
};
const [rows, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
code: true,
name: true,
scene: true,
status: true,
scanCount: true,
orderCount: true,
},
}),
this.prisma.commonPromoCode.count({ where }),
]);
return { total, items: rows };
},
);
}
queryPromoCodeStats(wecomUserId: string, code?: string) {
const keyword = requireQuery(code);
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.promo.stats',
inputSummary: keyword,
},
async () => {
const row =
(await this.prisma.commonPromoCode.findUnique({
where: { code: keyword.toUpperCase() },
select: { id: true, code: true, name: true, status: true },
})) ||
(await this.prisma.commonPromoCode.findFirst({
where: { code: { contains: keyword.toUpperCase() } },
select: { id: true, code: true, name: true, status: true },
}));
if (!row) throw new NotFoundException(`未找到推广码:${keyword}`);
const stats = await this.promo.stats(row.id);
return { code: row.code, name: row.name, status: row.status, stats };
},
);
}
queryMetrics(wecomUserId: string, kindRaw?: string) {
const kind = parseWecomPluginMetricsKind(kindRaw);
if (!kind) {
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
}
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
wecomUserId,
action: 'plugin.metrics.read',
inputSummary: kind,
},
async () => {
const period = wecomPluginMetricsPeriod(kind);
const stats = await this.loadStats(period.start, period.endExclusive);
return {
kind: period.kind,
title: period.title,
rangeLabel: period.rangeLabel,
incrementLabel: period.incrementLabel,
periodKey: period.periodKey,
stats,
};
},
);
}
/** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
const userBase = { status: 1, mergedIntoUserId: null } as const;
const partnerBase = { isPrimary: 1 } as const;
const paid = { payStatus: 'PAID' as const };
const [
usersTotal,
usersIncrement,
partnersTotal,
partnersIncrement,
storesTotal,
storesIncrement,
ordersTotal,
ordersIncrement,
orderAmountTotal,
orderAmountIncrement,
redeemsTotal,
redeemsIncrement,
redeemAmountTotal,
redeemAmountIncrement,
] = await Promise.all([
this.prisma.user.count({ where: { ...userBase, createdAt: { lt: cutoff } } }),
this.prisma.user.count({
where: { ...userBase, createdAt: { gte: start, lt: cutoff } },
}),
this.prisma.partnerAccount.count({
where: { ...partnerBase, createdAt: { lt: cutoff } },
}),
this.prisma.partnerAccount.count({
where: { ...partnerBase, createdAt: { gte: start, lt: cutoff } },
}),
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { lt: cutoff } },
}),
this.prisma.order.aggregate({
_sum: { payAmount: true },
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
}),
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
this.prisma.redeemRecord.aggregate({
_sum: { amount: true },
where: { createdAt: { lt: cutoff } },
}),
this.prisma.redeemRecord.aggregate({
_sum: { amount: true },
where: { createdAt: { gte: start, lt: cutoff } },
}),
]);
return {
usersTotal,
usersIncrement,
partnersTotal,
partnersIncrement,
storesTotal,
storesIncrement,
ordersTotal,
ordersIncrement,
orderAmountTotal: asNumber(orderAmountTotal._sum.payAmount),
orderAmountIncrement: asNumber(orderAmountIncrement._sum.payAmount),
redeemsTotal,
redeemsIncrement,
redeemAmountTotal: asNumber(redeemAmountTotal._sum.amount),
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
};
}
}
@@ -0,0 +1,19 @@
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
/** 审计占位:插件无长连接 Bot 行,botKey 固定为 plugin */
export const WECOM_PLUGIN_AUDIT_BOT: WecomBotRuntimeConfig = {
id: '',
key: 'plugin',
role: 'OPERATIONS',
name: '企微 API 插件',
enabled: true,
botId: '',
secret: '',
welcome: '',
avatarUrl: null,
permissions: [],
reviewSuperAdminWecomUserIds: [],
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
};
@@ -0,0 +1,87 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
import { WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
@Controller('wecom/plugin')
@UseGuards(WecomPluginGuard)
export class WecomPluginController {
constructor(private readonly query: WecomPluginQueryService) {}
@Get()
info() {
return this.query.info();
}
@Get('openapi.json')
openapi() {
return WECOM_PLUGIN_OPENAPI;
}
@Get('orders')
orders(
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryOrders(pluginCaller(req), q, page, pageSize);
}
@Get('users')
users(
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryUsers(pluginCaller(req), q, page, pageSize);
}
@Get('stores')
stores(
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStores(pluginCaller(req), q, page, pageSize);
}
@Get('redeems')
redeems(
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryRedeems(pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes')
promoCodes(
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPromoCodes(pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes/:code/stats')
promoStats(@Req() req: Request, @Param('code') code: string) {
return this.query.queryPromoCodeStats(pluginCaller(req), code);
}
@Get('metrics')
metrics(@Req() req: Request, @Query('kind') kind?: string) {
return this.query.queryMetrics(pluginCaller(req), kind);
}
}
function pluginCaller(req: Request): string {
const raw = req.headers['x-wecom-userid'] ?? req.headers['userid'];
const v = Array.isArray(raw) ? raw[0] : raw;
return String(v || 'plugin').trim() || 'plugin';
}
@@ -0,0 +1,28 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain';
@Injectable()
export class WecomPluginGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (!isWecomPluginEnabled(process.env)) {
throw new UnauthorizedException('Unauthorized');
}
const req = context.switchToHttp().getRequest<{ headers: Record<string, unknown> }>();
const provided = headerValue(req.headers, 'x-api-key');
if (!verifyWecomPluginApiKey(provided, process.env.WECOM_PLUGIN_API_KEY)) {
throw new UnauthorizedException('Unauthorized');
}
return true;
}
}
function headerValue(headers: Record<string, unknown>, name: string): string {
const raw = headers[name];
if (Array.isArray(raw)) return String(raw[0] ?? '').trim();
return String(raw ?? '').trim();
}
@@ -0,0 +1,157 @@
const envelope = (dataSchema: Record<string, unknown>) => ({
type: 'object',
properties: {
code: { type: 'integer', example: 0 },
message: { type: 'string', example: 'ok' },
data: dataSchema,
},
required: ['code', 'message', 'data'],
});
const qParam = {
name: 'q',
in: 'query',
required: true,
schema: { type: 'string' },
description: '查询关键词',
};
const pageParams = [
{
name: 'page',
in: 'query',
required: false,
schema: { type: 'integer', default: 1, minimum: 1 },
},
{
name: 'pageSize',
in: 'query',
required: false,
schema: { type: 'integer', default: 5, minimum: 1, maximum: 10 },
description: '默认 5,最大 10',
},
];
const unauthorized = {
description: '缺少或错误的 X-Api-Key,或插件未启用',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: { type: 'integer' },
message: { type: 'string' },
},
},
},
},
};
function listPath(summary: string, description: string, qDescription: string) {
return {
get: {
summary,
description,
operationId: summary,
parameters: [{ ...qParam, description: qDescription }, ...pageParams],
responses: {
200: {
description: '查询结果',
content: {
'application/json': {
schema: envelope({
type: 'object',
properties: {
total: { type: 'integer' },
items: { type: 'array', items: { type: 'object' } },
},
}),
},
},
},
401: unauthorized,
},
},
};
}
/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */
export const WECOM_PLUGIN_OPENAPI = {
openapi: '3.0.3',
info: {
title: '杜康好客运营查询',
description:
'企业内部只读查询。手机号已脱敏。鉴权:Header X-Api-Key。响应除本文件外均为 { code, message, data }。',
version: '1.0.0',
},
servers: [
{ url: 'https://api.dukanghaoke.com/api/v1/wecom/plugin', description: '生产' },
{ url: 'https://api-test.dukanghaoke.com/api/v1/wecom/plugin', description: '测试' },
],
security: [{ ApiKeyAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-Api-Key',
},
},
},
paths: {
'/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'),
'/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'),
'/stores': listPath('查询门店', '按门店名称模糊查询', '门店名称关键词'),
'/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'),
'/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'),
'/promo-codes/{code}/stats': {
get: {
summary: '推广码统计',
operationId: '查询推广码统计',
parameters: [
{
name: 'code',
in: 'path',
required: true,
schema: { type: 'string' },
description: '推广码 code',
},
],
responses: {
200: {
description: '扫码/成交统计',
content: { 'application/json': { schema: envelope({ type: 'object' }) } },
},
401: unauthorized,
},
},
},
'/metrics': {
get: {
summary: '经营指标',
description:
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。',
operationId: '查询经营指标',
parameters: [
{
name: 'kind',
in: 'query',
required: false,
schema: {
type: 'string',
enum: ['today', 'daily', 'weekly', 'monthly'],
default: 'today',
},
},
],
responses: {
200: {
description: '存量与新增',
content: { 'application/json': { schema: envelope({ type: 'object' }) } },
},
401: unauthorized,
},
},
},
},
} as const;
@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { CommonModule } from '../../modules/common/common.module'; import { CommonModule } from '../../modules/common/common.module';
import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module'; import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module';
import { PromoModule } from '../../modules/promo/promo.module';
import { SettlementModule } from '../../modules/settlement/settlement.module'; import { SettlementModule } from '../../modules/settlement/settlement.module';
import { IntegrationsModule } from '../integrations.module'; import { IntegrationsModule } from '../integrations.module';
import { LlmModule } from '../llm/llm.module'; import { LlmModule } from '../llm/llm.module';
@@ -10,16 +11,21 @@ import { WecomBotAiService } from './wecom-bot-ai.service';
import { WecomBotAuditService } from './wecom-bot-audit.service'; import { WecomBotAuditService } from './wecom-bot-audit.service';
import { WecomBotCapabilityService } from './wecom-bot-capability.service'; import { WecomBotCapabilityService } from './wecom-bot-capability.service';
import { WecomBotSessionService } from './wecom-bot-session.service'; import { WecomBotSessionService } from './wecom-bot-session.service';
import { WecomPluginController } from './wecom-plugin.controller';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Promo + Integrations(短信)+ Llm */
@Module({ @Module({
imports: [ imports: [
forwardRef(() => CommonModule), forwardRef(() => CommonModule),
forwardRef(() => IntegrationsModule), forwardRef(() => IntegrationsModule),
DevPlanModule, DevPlanModule,
SettlementModule, SettlementModule,
PromoModule,
LlmModule, LlmModule,
], ],
controllers: [WecomPluginController],
providers: [ providers: [
WecomBotSessionService, WecomBotSessionService,
WecomBotAuditService, WecomBotAuditService,
@@ -27,6 +33,8 @@ import { WecomBotSessionService } from './wecom-bot-session.service';
WecomBotActionsService, WecomBotActionsService,
WecomBotAiService, WecomBotAiService,
WecomAibotService, WecomAibotService,
WecomPluginGuard,
WecomPluginQueryService,
], ],
exports: [WecomAibotService, WecomBotAuditService], exports: [WecomAibotService, WecomBotAuditService],
}) })
@@ -51,6 +51,11 @@ export class UserRedeemController {
); );
} }
@Get('records/pending-rating')
pendingRating(@CurrentUser() user: AuthUser, @Query('storeId') storeId: string) {
return this.redeemService.getPendingRatingRecord(user.actorId, storeId);
}
@Get('records/:id') @Get('records/:id')
record(@CurrentUser() user: AuthUser, @Param('id') id: string) { record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.getUserRecord(user.actorId, id); return this.redeemService.getUserRecord(user.actorId, id);
@@ -3,11 +3,18 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module'; import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module'; import { SettlementModule } from '../settlement/settlement.module';
import { StoreModule } from '../store/store.module';
import { RedeemService } from './redeem.service'; import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller'; import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)], imports: [
IamModule,
AnalyticsModule,
BenefitModule,
forwardRef(() => SettlementModule),
forwardRef(() => StoreModule),
],
controllers: [UserRedeemController, ShopRedeemController], controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService], providers: [RedeemService],
exports: [RedeemService], exports: [RedeemService],
@@ -1,7 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { import {
@@ -33,6 +35,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service'; import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { StoreService } from '../store/store.service';
type TokenPayload = { type TokenPayload = {
userId: string; userId: string;
@@ -123,6 +126,8 @@ export class RedeemService {
private readonly payRedeemAnomaly: PayRedeemAnomalyService, private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly wecomPush: WecomMessagePushService, private readonly wecomPush: WecomMessagePushService,
private readonly systemConfig: SystemConfigService, private readonly systemConfig: SystemConfigService,
@Inject(forwardRef(() => StoreService))
private readonly storeService: StoreService,
) {} ) {}
private maskPhoneForStore(phone: string) { private maskPhoneForStore(phone: string) {
@@ -1388,6 +1393,22 @@ export class RedeemService {
}); });
} }
async getPendingRatingRecord(userId: bigint, storeId: string) {
const sid = String(storeId || '').trim();
if (!/^\d+$/.test(sid)) return null;
const record = await this.prisma.redeemRecord.findFirst({
where: {
userId,
storeId: BigInt(sid),
rating: null,
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (!record) return null;
return serializeBigInt({ id: record.id });
}
async submitRating( async submitRating(
userId: bigint, userId: bigint,
body: { body: {
@@ -1421,6 +1442,7 @@ export class RedeemService {
imageUrls, imageUrls,
}, },
}); });
await this.storeService.refreshStoreRatingFromReviews(record.storeId);
return serializeBigInt(mapStoreRating(rating)); return serializeBigInt(mapStoreRating(rating));
} }
} }
@@ -1668,4 +1668,19 @@ export class StoreService {
await this.partnerGetStore(partnerAccountId, storeId); await this.partnerGetStore(partnerAccountId, storeId);
return this.replaceStoreCategories(storeId, categoryIds, priorities); return this.replaceStoreCategories(storeId, categoryIds, priorities);
} }
/** 评价提交后回写门店综合评分(服务+环境均值) */
async refreshStoreRatingFromReviews(storeId: bigint) {
const agg = await this.prisma.storeRating.aggregate({
where: { storeId },
_avg: { serviceScore: true, envScore: true },
});
const avgService = Number(agg._avg.serviceScore ?? 0);
const avgEnv = Number(agg._avg.envScore ?? 0);
const rating = Math.round(((avgService + avgEnv) / 2) * 100) / 100;
await this.prisma.store.update({
where: { id: storeId },
data: { rating },
});
}
} }