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
This commit was merged in pull request #73.
This commit is contained in:
2026-09-03 23:31:38 +08:00
18 changed files with 254 additions and 84 deletions
+6 -2
View File
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
<p className="label-md text-muted">: {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</>
) : null}
</div>
</div>
))
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted">{periodSubLabel(period)}</p>
{entry.periodStores > 0 ? (
<>
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted">{periodSubLabel(period)}</p>
</>
) : null}
</div>
</div>
))
@@ -1,28 +1,33 @@
import { useMemo } from 'react';
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import { formatRedeemRelativeTime } from '../lib/datetime';
export type StoreRedeemMarqueeItem = {
userLabel: string;
amount: string;
createdAt?: string | null;
};
type StoreRedeemMarqueeProps = {
items: StoreRedeemMarqueeItem[];
};
const STAY_MS = 20000;
const STAY_MS = 5000;
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
const timeLabel = formatRedeemRelativeTime(item.createdAt);
return (
<View className="store-detail-marquee-inner">
<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>
</View>
);
}
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
/** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
const list = useMemo(
() =>
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
.map((row) => ({
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
amount: String(row.amount || '').trim(),
createdAt: row.createdAt ?? null,
}))
.filter((row) => row.amount),
[items],
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
indicatorDots={false}
>
{list.map((row, index) => (
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
<SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
<MarqueeRow item={row} />
</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,兼容微信小程序) */
export function formatShanghaiDateTime(input?: string | Date | null): string {
if (input == null || input === '') return '—';
+1 -5
View File
@@ -22,7 +22,7 @@ import {
isHomeCatalogBootstrapped,
setHomeCatalogCache,
} from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images';
import {
@@ -202,8 +202,6 @@ export default function HomePage() {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
@@ -213,8 +211,6 @@ export default function HomePage() {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
+27 -26
View File
@@ -102,7 +102,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true);
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);
useEffect(() => {
@@ -393,7 +393,7 @@ export default function LoginPage() {
? '绑定手机号'
: completeMode === 'wechat'
? '授权登录'
: '手机号快捷登录';
: '验证码登录';
return (
<PageShell variant="plain" className="login-page">
@@ -451,32 +451,9 @@ export default function LoginPage() {
<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) && (
<>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
<View className="login-field">
<Text className="login-field-prefix">+86</Text>
<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 ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
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({
title: '确认提交订单',
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
@@ -8,7 +8,6 @@ import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
@@ -291,9 +290,6 @@ export default function OrderConfirmPage() {
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
import {
getProductCarouselImages,
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
export default function RedeemSuccessPage() {
const router = useRouter();
const [fromHistory, setFromHistory] = useState(false);
const [fromStore, setFromStore] = useState(false);
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 [comment, setComment] = useState('');
const [imageUrls, setImageUrls] = useState<string[]>([]);
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
const rated = Boolean(record?.rating);
const applyRating = useCallback((rating: StoreRatingDto) => {
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
const nextService = Number(rating.serviceScore || 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 : []);
setComment(String(rating.comment || ''));
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 from = String(options?.from || router.params.from || '');
setFromHistory(from === 'history');
setFromStore(from === 'store');
if (id) {
void loadRecord(id);
return;
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
function leave() {
clearCache();
const pages = Taro.getCurrentPages();
if (fromHistory && pages.length > 1) {
if ((fromHistory || fromStore) && pages.length > 1) {
Taro.navigateBack();
return;
}
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore: score,
envScore: score,
serviceScore,
envScore,
comment: comment.trim(),
tags,
imageUrls,
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
prev
? {
...prev,
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
rating: { serviceScore, envScore, comment, tags, imageUrls },
}
: prev,
);
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
</View>
<View className="review-card">
<Text className="review-card-title"></Text>
<Text className="review-card-title"></Text>
<View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => (
<Text
key={value}
className={`review-star${value <= score ? ' review-star--active' : ''}`}
key={`service-${value}`}
className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
onClick={() => {
if (!rated) setScore(value);
if (!rated) setServiceScore(value);
}}
>
</Text>
))}
</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 className="review-card">
@@ -16,7 +16,7 @@ import ProductCarousel from '../../components/ProductCarousel';
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { request, toast, isLoggedIn } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
@@ -158,7 +158,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
const amount = formatRedeemAmountYuan(row.amount);
if (!amount) return null;
return { userLabel, amount };
return { userLabel, amount, createdAt: row.createdAt };
} catch {
return null;
}
@@ -189,6 +189,7 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false);
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
const storeRef = useRef<Store | null>(null);
storeRef.current = store;
@@ -274,6 +275,13 @@ export default function StoreDetailPage() {
if (!id) return;
void loadStore(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(() => {
@@ -530,8 +538,20 @@ export default function StoreDetailPage() {
) : null}
<View className="store-detail-bar">
{pendingRatingId ? (
<View
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="u-btn u-btn--block"
className={`store-detail-bar-btn store-detail-bar-btn--primary${pendingRatingId ? '' : ' store-detail-bar-btn--full'}`}
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text></Text>
@@ -1,4 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
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 '../../styles/store-detail.css';
import '../../styles/product-detail.css';
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
type StorePackage = {
name: string;
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
export default function StorePackageDetailPage() {
const router = useRouter();
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
const [storeName, setStoreName] = useState('');
const [pkg, setPkg] = useState<StorePackage | null>(null);
const [loading, setLoading] = useState(true);
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
const sid = pickStoreId(options?.storeId || router.params.storeId);
const index = parsePackageIndex(options?.index ?? router.params.index);
setStoreId(sid);
setPackageIndex(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();
if (pages.length > 1) Taro.navigateBack();
else if (storeId) {
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
} else {
Taro.switchTab({ url: '/pages/stores/index' });
}
}
}, [storeId]);
if (loading) {
return (
<PageShell variant="scroll" className="store-package-detail-page">
<PageNavBar title="套餐详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty"></View>
</PageShell>
);
}
// 不用 useShare* HookTaro 编译会把它们折进 return,触发 React #310
useEffect(() => {
const page = Taro.getCurrentInstance().page as
| {
onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
}
| undefined;
if (!page) return;
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
}, [shareQuery]);
if (!pkg) {
return (
<PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<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>
);
}
const imageUrls = normalizeStorePackageImageUrls(pkg);
return (
<PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
<View className="store-package-detail-body">
+3 -1
View File
@@ -492,7 +492,9 @@ export default function StoresPage() {
</Text>
))}
</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 className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}>
@@ -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')
record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.getUserRecord(user.actorId, id);
@@ -3,11 +3,18 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module';
import { StoreModule } from '../store/store.module';
import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
imports: [
IamModule,
AnalyticsModule,
BenefitModule,
forwardRef(() => SettlementModule),
forwardRef(() => StoreModule),
],
controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService],
exports: [RedeemService],
@@ -1,7 +1,9 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import {
@@ -33,6 +35,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { StoreService } from '../store/store.service';
type TokenPayload = {
userId: string;
@@ -123,6 +126,8 @@ export class RedeemService {
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly wecomPush: WecomMessagePushService,
private readonly systemConfig: SystemConfigService,
@Inject(forwardRef(() => StoreService))
private readonly storeService: StoreService,
) {}
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(
userId: bigint,
body: {
@@ -1421,6 +1442,7 @@ export class RedeemService {
imageUrls,
},
});
await this.storeService.refreshStoreRatingFromReviews(record.storeId);
return serializeBigInt(mapStoreRating(rating));
}
}
@@ -1668,4 +1668,19 @@ export class StoreService {
await this.partnerGetStore(partnerAccountId, storeId);
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 },
});
}
}