fix(mini-user): address review blockers
CI / verify (pull_request) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 13:03:02 +08:00
parent eb96b36d0b
commit bdf80e577b
14 changed files with 280 additions and 106 deletions
+1 -18
View File
@@ -1,6 +1,6 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
import { forceReloadAfterAccountMerge } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
return !!getToken();
}
export function redirectToLogin() {
goLogin();
}
export function logout() {
clearAuth();
Taro.reLaunch({ url: '/pages/home/index' });
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
return {};
}
function isOnLoginPage(): boolean {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
return route.includes('pages/login');
} catch {
return false;
}
}
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
} else if (!isOnLoginPage()) {
redirectToLogin();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
+18 -3
View File
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
'/pages/mine/index',
]);
let loginNavigationPending = false;
function isLoginPageActive(): boolean {
const pages = Taro.getCurrentPages();
const current = pages[pages.length - 1] as { route?: string } | undefined;
return !!current?.route?.includes('pages/login/');
}
function currentPagePath(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
@@ -25,6 +33,7 @@ function currentPagePath(): string {
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath();
const parts: string[] = [];
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
@@ -34,9 +43,15 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
}
}
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
Taro.navigateTo({ url }).catch(() => {
Taro.redirectTo({ url });
});
loginNavigationPending = true;
void Taro.navigateTo({ url })
.catch(() => Taro.redirectTo({ url }))
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
+36 -8
View File
@@ -6,6 +6,17 @@ export type MiniWechatProfile = {
avatarUrl?: string;
};
export type MiniWechatProfileUpdate = MiniWechatProfile & {
avatarResourceId?: string;
};
export type UploadedAvatarResource = {
resourceId: string;
url: string;
bucket: string;
ossKey: string;
};
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
export function cacheWxProfile(info: MiniWechatProfile) {
@@ -55,8 +66,8 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
};
}
/** 上传 chooseAvatar 临时文件到 OSS,返回永久 URL */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<string> {
/** 上传 chooseAvatar 临时文件到 OSS返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken();
if (!token) throw new Error('请先登录');
@@ -75,7 +86,11 @@ export async function uploadAvatarTempFile(tempFilePath: string): Promise<string
},
});
let body: { code?: number; message?: string; data?: { url?: string } } = {};
let body: {
code?: number;
message?: string;
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
} = {};
try {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
@@ -84,14 +99,26 @@ export async function uploadAvatarTempFile(tempFilePath: string): Promise<string
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) {
if (
res.statusCode >= 400 ||
body.code !== 0 ||
!body.data?.resourceId ||
!body.data.url ||
!body.data.bucket ||
!body.data.ossKey
) {
throw new Error(body.message || '头像上传失败');
}
return body.data.url;
return {
resourceId: body.data.resourceId,
url: body.data.url,
bucket: body.data.bucket,
ossKey: body.data.ossKey,
};
}
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl) return null;
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST',
@@ -124,6 +151,7 @@ export async function syncMiniWechatProfile(
if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null;
await uploadMiniWechatProfile(info);
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info;
}
+22 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -21,9 +21,9 @@ type Product = {
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型', open: true },
{ key: 'JIANGXIANG', label: '酱香型', open: false },
{ key: 'NONGXIANG', label: '浓香型', open: false },
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
export default function HomePage() {
@@ -49,20 +49,26 @@ export default function HomePage() {
.finally(() => setLoading(false));
}, [cityCode]);
function onAromaTabClick(key: string, open: boolean) {
if (!open) {
toast('暂未开放');
return;
const availableAromas = useMemo(
() =>
AROMA_TABS.filter((item) =>
products.some((product) => product.aromaType === item.key),
),
[products],
);
useEffect(() => {
if (loading || availableAromas.length === 0) return;
if (!availableAromas.some((item) => item.key === tab)) {
setTab(availableAromas[0].key);
}
setTab(key);
}
}, [availableAromas, loading, tab]);
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
return (
<PageShell variant="tab" className="home-page no-tab-header">
@@ -70,11 +76,11 @@ export default function HomePage() {
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{AROMA_TABS.map((t) => (
{availableAromas.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => onAromaTabClick(t.key, t.open)}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</Text>
@@ -85,12 +91,10 @@ export default function HomePage() {
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && !onSale ? <View className="home-empty">线</View> : null}
{!loading && onSale && filtered.length === 0 ? (
<View className="home-empty"></View>
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
onSale &&
filtered.map((p) => {
const images = getProductImages(p);
return (
+24
View File
@@ -150,6 +150,19 @@ export default function LoginPage() {
return true;
}
function cancelLogin() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack().catch(() => {
Taro.switchTab({ url: '/pages/home/index' });
});
return;
}
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
}
function applySessionAndLeave(
data: SessionPayload | WechatLoginResult,
phoneValue?: string,
@@ -370,6 +383,12 @@ export default function LoginPage() {
return (
<PageShell variant="plain" className="login-page">
<View className="login-nav">
<View className="login-nav-back" onClick={cancelLogin}>
<Text className="login-nav-back-icon"></Text>
<Text></Text>
</View>
</View>
<View className="login-header">
<View className="login-logo-wrap">
<View className="login-logo">
@@ -523,6 +542,11 @@ export default function LoginPage() {
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
</>
) : null}
<View className="login-cancel-btn" onClick={cancelLogin}>
<Text></Text>
</View>
<Text className="login-cancel-hint"></Text>
</View>
</PageShell>
);
+47 -23
View File
@@ -50,11 +50,13 @@ export default function MinePage() {
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
function resetGuestState() {
setProfile(null);
setBenefitBalance(0);
setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
@@ -63,6 +65,7 @@ export default function MinePage() {
function loadProfile() {
if (!isLoggedIn()) return;
setProfileLoadError('');
Promise.all([
request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
@@ -83,7 +86,16 @@ export default function MinePage() {
});
setOrderCounts(counts);
})
.catch(() => {});
.catch((error) => {
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
}
useDidShow(() => {
@@ -162,22 +174,20 @@ export default function MinePage() {
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) return;
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (!isWeapp) {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
if (isWeapp) {
openProfileSheet();
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
if (ok) loadProfile();
}
openProfileSheet();
return;
}
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
@@ -202,16 +212,18 @@ export default function MinePage() {
}
setSavingProfile(true);
try {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
}
let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) {
avatarUrl = await uploadAvatarTempFile(draftAvatarTemp);
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
}
const updated = await uploadMiniWechatProfile({ nickname, avatarUrl });
if (updated) applyProfile({ ...updated, hasWechat: true });
const updated = await uploadMiniWechatProfile({
nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false);
toast('头像昵称已更新', 'success');
loadProfile();
@@ -262,7 +274,9 @@ export default function MinePage() {
</View>
</View>
<View className="mine-login-gate">
<View className="mine-login-gate-hint"></View>
<View className="mine-login-gate-hint">
</View>
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
<Text></Text>
</View>
@@ -283,9 +297,10 @@ export default function MinePage() {
}
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const memberLabel = needProfileFill
? '点击头像完善资料'
: hasWechat
: isWeapp || hasWechat
? '好客会员'
: canWxAuth
? '点击头像授权'
@@ -305,7 +320,7 @@ export default function MinePage() {
>
<View
className={`mine-avatar${
!needProfileFill && hasWechat ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
>
{renderAvatarContent(display.avatarUrl)}
@@ -313,13 +328,11 @@ export default function MinePage() {
{avatarClickable ? (
<View
className={`mine-avatar-status${
needProfileFill || !hasWechat
? ' mine-avatar-status--pending'
: ' mine-avatar-status--ok'
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : needProfileFill || !hasWechat ? '去完善' : '更换'}
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View>
) : null}
@@ -330,9 +343,20 @@ export default function MinePage() {
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<Text className="mine-profile-name">{nickname}</Text>
<Text className={`mine-member-tag${hasWechat && !needProfileFill ? ' mine-member-tag--wechat' : ''}`}>
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel}
</Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null}
</View>
</View>
</View>
-4
View File
@@ -81,10 +81,6 @@
border-bottom-color: var(--color-heritage-red);
}
.home-aroma-tab--muted {
opacity: 0.65;
}
.home-product-list {
display: flex;
flex-direction: column;
+48
View File
@@ -326,3 +326,51 @@
.login-agreement-link {
color: var(--color-heritage-red);
}
.login-nav {
min-height: 44px;
padding: 8px var(--space-page) 0;
display: flex;
align-items: center;
box-sizing: border-box;
}
.login-nav-back {
min-width: 72px;
min-height: 40px;
display: flex;
align-items: center;
gap: 4px;
color: var(--color-on-surface);
font-size: 16px;
font-weight: 600;
}
.login-nav-back-icon {
font-size: 30px;
line-height: 1;
}
.login-cancel-btn {
width: 100%;
min-height: 48px;
margin-top: 20px;
border: 1px solid var(--color-heritage-red);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: var(--color-heritage-red);
font-size: 16px;
font-weight: 700;
background: #fff;
}
.login-cancel-hint {
display: block;
margin-top: 10px;
text-align: center;
color: var(--color-on-surface-variant);
font-size: 13px;
}
+8
View File
@@ -149,6 +149,14 @@
color: rgba(255, 255, 255, 0.9);
}
.mine-profile-retry {
display: block;
margin-top: 6px;
font-size: 11px;
color: #fff;
text-decoration: underline;
}
.mine-main {
margin-top: -28px;
position: relative;
@@ -16,7 +16,7 @@ import { ClientConfigController } from './client-config.controller';
import { WechatLocationService } from './wechat-location.service';
@Module({
imports: [IamModule, IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
controllers: [
ResourceController,
EventController,
@@ -102,6 +102,9 @@ export class ResourceService {
});
throw new BadRequestException(message);
}
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
throw new BadRequestException('头像仅支持图片文件');
}
try {
const result = await this.oss.putObject({
@@ -125,6 +128,24 @@ export class ResourceService {
externalNo: result.ossKey,
status: 'SUCCESS',
});
if (actor?.refType === 'USER' && dto.bizType === 'AVATAR' && dto.mediaType === 'IMAGE') {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: actor.refId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: result.bucket,
ossKey: result.ossKey,
url: result.url,
fileName: file.originalname || 'avatar',
fileSize: BigInt(file.size),
mimeType: file.mimetype,
status: 'ACTIVE',
},
});
return serializeBigInt({ ...result, resourceId: resource.id });
}
return result;
} catch (err) {
await logOssUpload(this.prisma, {
@@ -138,6 +159,37 @@ export class ResourceService {
}
}
async getOwnedActiveAvatar(resourceId: bigint, userId: bigint) {
const resource = await this.prisma.commonResource.findFirst({
where: {
id: resourceId,
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
status: 'ACTIVE',
},
});
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
return resource;
}
async getOwnedActiveAvatarByUrl(url: string, userId: bigint) {
const resource = await this.prisma.commonResource.findFirst({
where: {
url,
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
status: 'ACTIVE',
},
orderBy: { createdAt: 'desc' },
});
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
return resource;
}
async register(dto: RegisterResourceDto) {
const resource = await this.prisma.commonResource.create({
data: {
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
@@ -22,6 +23,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { verifyPassword } from '../../common/crypto/password.util';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import type { User } from '@prisma/client';
@@ -62,6 +64,7 @@ export class AuthService {
private readonly analyticsService: AnalyticsService,
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
) {}
private assertMobilePhone(phone: string) {
@@ -1407,12 +1410,9 @@ export class AuthService {
async updateMiniWechatProfile(
userId: bigint,
input: { nickname?: string; avatarUrl?: string },
input: { nickname?: string; avatarUrl?: string; avatarResourceId?: string },
) {
const user = await this.assertActiveUser(userId);
if (!user.wxOpenId) {
throw new BadRequestException('请先完成微信授权');
}
const data: {
nickname?: string;
@@ -1425,37 +1425,23 @@ export class AuthService {
}
const avatarUrl = input.avatarUrl?.trim();
if (avatarUrl) {
if (user.avatarResourceId) {
await this.prisma.commonResource.update({
where: { id: user.avatarResourceId },
data: { url: avatarUrl },
});
} else {
const avatar = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: 'wechat',
ossKey: `wx-avatar/${user.wxOpenId}`,
url: avatarUrl,
status: 'ACTIVE',
},
});
data.avatarResourceId = avatar.id;
const avatarResourceId = input.avatarResourceId?.trim();
if (avatarResourceId) {
let resourceId: bigint;
try {
resourceId = BigInt(avatarResourceId);
} catch {
throw new BadRequestException('头像资源编号无效');
}
const avatar = await this.resourceService.getOwnedActiveAvatar(resourceId, userId);
data.avatarResourceId = avatar.id;
} else if (avatarUrl) {
// 兼容已发布旧客户端:只接受刚由当前用户上传并登记过的真实资源 URL。
const avatar = await this.resourceService.getOwnedActiveAvatarByUrl(avatarUrl, userId);
data.avatarResourceId = avatar.id;
}
if (!data.nickname && !data.avatarResourceId) {
if (avatarUrl && user.avatarResourceId) {
const refreshed = await this.prisma.user.findUnique({
where: { id: userId },
include: { avatar: true },
});
return this.formatUserProfile(refreshed ?? user);
}
return this.formatUserProfile(user);
}
@@ -118,6 +118,10 @@ export class MiniWechatProfileDto {
@IsString()
@IsOptional()
avatarUrl?: string;
@IsString()
@IsOptional()
avatarResourceId?: string;
}
export class CheckPartnerPhoneDto {
@@ -24,10 +24,12 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service';
import { CommonModule } from '../common/common.module';
@Module({
imports: [
IntegrationsModule,
forwardRef(() => CommonModule),
forwardRef(() => AnalyticsModule),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',