diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index f3e626e..a740911 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -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(path: string, options: ReqOptions = {}): Promise { const header: Record = { @@ -115,8 +100,6 @@ export async function request(path: string, options: ReqOptions = { if (/账号已合并/.test(mergedMsg)) { // 合并后旧 JWT 失效:强制刷新,不引导「重新登录」 forceReloadAfterAccountMerge(); - } else if (!isOnLoginPage()) { - redirectToLogin(); } } throw new Error(body?.message || '登录已过期,请重新登录'); diff --git a/apps/mini-user/src/lib/auth-nav.ts b/apps/mini-user/src/lib/auth-nav.ts index dc2ed6c..4216ef6 100644 --- a/apps/mini-user/src/lib/auth-nav.ts +++ b/apps/mini-user/src/lib/auth-nav.ts @@ -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) { + 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) { } } 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 页,或回退 / 首页 */ diff --git a/apps/mini-user/src/lib/mini-wechat-profile.ts b/apps/mini-user/src/lib/mini-wechat-profile.ts index 4a92353..3ccbbdf 100644 --- a/apps/mini-user/src/lib/mini-wechat-profile.ts +++ b/apps/mini-user/src/lib/mini-wechat-profile.ts @@ -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 { +/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */ +export async function uploadAvatarTempFile(tempFilePath: string): Promise { 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= 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 { - if (!info.nickname && !info.avatarUrl) return null; +export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise { + if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null; const { request } = await import('./api'); const updated = await request('/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; } diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index 9baca05..3ec53cf 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -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 ( @@ -70,11 +76,11 @@ export default function HomePage() { - {AROMA_TABS.map((t) => ( + {availableAromas.map((t) => ( onAromaTabClick(t.key, t.open)} + className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`} + onClick={() => setTab(t.key)} > {t.label} @@ -85,12 +91,10 @@ export default function HomePage() { {loading ? 加载中… : null} - {!loading && !onSale ? 该香型暂未上线,敬请期待 : null} - {!loading && onSale && filtered.length === 0 ? ( - 暂无商品 + {!loading && products.length === 0 ? ( + 当前城市暂无在售商品 ) : null} {!loading && - onSale && filtered.map((p) => { const images = getProductImages(p); return ( diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 5a0418b..ca9a2ab 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -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 ( + + + + 返回 + + @@ -523,6 +542,11 @@ export default function LoginPage() { void wechatLogin()} /> ) : null} + + + 暂不登录,继续浏览 + + 无需登录也可浏览商品和门店 ); diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index c9b96a1..7f17058 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -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('/auth/me'), request>>('/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() { - 登录后管理订单与个人信息 + + 登录后管理订单与个人信息;无需登录也可浏览商品和门店 + goLogin('/pages/mine/index')}> 去登录 @@ -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() { > {renderAvatarContent(display.avatarUrl)} @@ -313,13 +328,11 @@ export default function MinePage() { {avatarClickable ? ( - {bindingWx ? '授权中' : needProfileFill || !hasWechat ? '去完善' : '更换'} + {bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'} ) : null} @@ -330,9 +343,20 @@ export default function MinePage() { onClick={avatarClickable ? () => void handleAvatarTap() : undefined} > {nickname} - + {memberLabel} + {profileLoadError ? ( + { + event.stopPropagation(); + loadProfile(); + }} + > + 资料加载失败,点击重试 + + ) : null} diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css index af12005..5d2eaa4 100644 --- a/apps/mini-user/src/styles/home.css +++ b/apps/mini-user/src/styles/home.css @@ -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; diff --git a/apps/mini-user/src/styles/login.css b/apps/mini-user/src/styles/login.css index 4b96cf5..528cdbc 100644 --- a/apps/mini-user/src/styles/login.css +++ b/apps/mini-user/src/styles/login.css @@ -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; +} diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css index f564d77..7a285ad 100644 --- a/apps/mini-user/src/styles/mine.css +++ b/apps/mini-user/src/styles/mine.css @@ -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; diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts index b6d91ad..d87c5cc 100644 --- a/server/dukang-api/src/modules/common/common.module.ts +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -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, diff --git a/server/dukang-api/src/modules/common/resource.service.ts b/server/dukang-api/src/modules/common/resource.service.ts index b56e3a1..23d01cb 100644 --- a/server/dukang-api/src/modules/common/resource.service.ts +++ b/server/dukang-api/src/modules/common/resource.service.ts @@ -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: { diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 88f4c4c..cc84ee0 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -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); } diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index db55a3b..d6ccd83 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -118,6 +118,10 @@ export class MiniWechatProfileDto { @IsString() @IsOptional() avatarUrl?: string; + + @IsString() + @IsOptional() + avatarResourceId?: string; } export class CheckPartnerPhoneDto { diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index 45ecd70..880a6a2 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -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',