Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 || '登录已过期,请重新登录');
|
||||
|
||||
@@ -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 页,或回退 / 首页 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user