Compare commits
8 Commits
d1b207422c
...
935ab0d1d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 935ab0d1d4 | |||
| 350a086a73 | |||
| c5f526d51d | |||
| e1f5130c18 | |||
| bc0b0cafd6 | |||
| ade68972a9 | |||
| 47b39e3ba0 | |||
| cf73bbae23 |
@@ -52,8 +52,7 @@ scroll-view::-webkit-scrollbar,
|
||||
.taro-scroll::-webkit-scrollbar,
|
||||
.taro-scroll-view::-webkit-scrollbar,
|
||||
.taro-scroll-view__scroll-x::-webkit-scrollbar,
|
||||
.taro-scroll-view__scroll-y::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
.taro-scroll-view__scroll-y::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
pnpm --filter @dukang/mini-user dev
|
||||
# → http://localhost:5177
|
||||
|
||||
# 微信小程序
|
||||
# 微信小程序(本地联调,development)
|
||||
pnpm --filter @dukang/mini-user dev:weapp
|
||||
|
||||
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com)
|
||||
pnpm build:mini-user:weapp
|
||||
# 用微信开发者工具打开 apps/mini-user(miniprogramRoot = dist/)
|
||||
```
|
||||
|
||||
@@ -18,7 +21,7 @@ pnpm --filter @dukang/mini-user dev:weapp
|
||||
|
||||
| 环节 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000`;`NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||
|
||||
### 微信登录 `invalid code`
|
||||
@@ -45,10 +48,10 @@ pnpm --filter @dukang/mini-user dev:weapp
|
||||
**连远程 API**:
|
||||
|
||||
```bash
|
||||
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
|
||||
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
|
||||
```
|
||||
|
||||
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||
|
||||
## 页面结构(18 页)
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from '@tarojs/cli';
|
||||
|
||||
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
|
||||
const isDevMode =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.argv.includes('--watch') ||
|
||||
process.argv.includes('development');
|
||||
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||
const API_ORIGIN =
|
||||
process.env.VITE_API_TARGET ??
|
||||
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
|
||||
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||
|
||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||
|
||||
@@ -91,7 +97,7 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
mini: {
|
||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||
debugReact: process.env.NODE_ENV !== 'production',
|
||||
debugReact: isDevMode,
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: { enable: false },
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
||||
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
||||
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"postcss": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
"compileType": "miniprogram",
|
||||
"preloadBackgroundData": false
|
||||
}
|
||||
|
||||
@@ -39,19 +39,13 @@ body::-webkit-scrollbar,
|
||||
#app::-webkit-scrollbar,
|
||||
.taro_page::-webkit-scrollbar,
|
||||
.taro_router::-webkit-scrollbar,
|
||||
.taro-tabbar__panel::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
.taro-tabbar__panel::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
|
||||
.taro-tabbar__tabbar,
|
||||
.taro-tabbar__border {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
|
||||
type PhoneQuickLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
|
||||
agreed: boolean;
|
||||
onRequireAgree: () => void;
|
||||
onGetPhoneNumber: (phoneCode: string) => void;
|
||||
onFail?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
|
||||
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
|
||||
*/
|
||||
export default function PhoneQuickLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
agreed,
|
||||
onRequireAgree,
|
||||
onGetPhoneNumber,
|
||||
onFail,
|
||||
}: PhoneQuickLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
|
||||
const label = loading ? '登录中...' : '手机号快捷登录';
|
||||
|
||||
if (!agreed) {
|
||||
return (
|
||||
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
openType={inactive ? undefined : 'getPhoneNumber'}
|
||||
hoverClass="none"
|
||||
onGetPhoneNumber={(e) => {
|
||||
if (inactive) return;
|
||||
const detail = e.detail as {
|
||||
errMsg?: string;
|
||||
code?: string;
|
||||
errno?: number;
|
||||
};
|
||||
if (!detail?.code) {
|
||||
const denied =
|
||||
detail?.errMsg?.includes('deny') ||
|
||||
detail?.errMsg?.includes('cancel') ||
|
||||
detail?.errno === 103;
|
||||
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
|
||||
return;
|
||||
}
|
||||
onGetPhoneNumber(detail.code);
|
||||
}}
|
||||
>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -7,25 +7,24 @@ type TabMainHeaderProps = {
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
|
||||
/**
|
||||
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
|
||||
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
|
||||
*/
|
||||
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
||||
const metrics = useNavBarMetrics();
|
||||
|
||||
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
|
||||
if (isH5 && !extra) {
|
||||
if (!extra) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
|
||||
{!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
|
||||
<View
|
||||
className="tab-main-header__content"
|
||||
style={tabNavContentStyle(metrics)}
|
||||
>
|
||||
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
|
||||
{process.env.TARO_ENV !== 'h5' ? (
|
||||
<Text className="tab-main-header__title">{title}</Text>
|
||||
) : null}
|
||||
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
|
||||
<View className="tab-main-header__extra">{extra}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,22 +3,16 @@ import { View, Text } from '@tarojs/components';
|
||||
type WechatLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function WechatIcon() {
|
||||
return (
|
||||
<View className="wechat-login-icon" aria-hidden>
|
||||
<View className="wechat-login-icon__big" />
|
||||
<View className="wechat-login-icon__small" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */
|
||||
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
|
||||
export default function WechatLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
label = '授权登录',
|
||||
onClick,
|
||||
}: WechatLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
@@ -28,10 +22,7 @@ export default function WechatLoginButton({
|
||||
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
||||
onClick={inactive ? undefined : onClick}
|
||||
>
|
||||
<WechatIcon />
|
||||
<Text className="login-wechat-btn__text">
|
||||
{loading ? '授权中...' : '微信一键授权'}
|
||||
</Text>
|
||||
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
|
||||
import type { UserProfile } from './api';
|
||||
|
||||
export type MiniWechatProfile = {
|
||||
@@ -32,77 +30,100 @@ export function getCachedWxProfile(): MiniWechatProfile | null {
|
||||
}
|
||||
|
||||
export function isDefaultMiniNickname(nickname?: string | null): boolean {
|
||||
if (!nickname || nickname === '访客') return true;
|
||||
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
|
||||
return /^用户\d{4}$/.test(nickname);
|
||||
}
|
||||
|
||||
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
|
||||
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
|
||||
if (!profile) return true;
|
||||
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
|
||||
}
|
||||
|
||||
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
||||
if (!profile.hasWechat) return profile;
|
||||
const cached = getCachedWxProfile();
|
||||
if (!cached) return profile;
|
||||
if (!cached && !profile.hasWechat) return profile;
|
||||
const nickname =
|
||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||
cached?.nickname ||
|
||||
profile.nickname ||
|
||||
'微信用户';
|
||||
return {
|
||||
...profile,
|
||||
nickname:
|
||||
cached.nickname ||
|
||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||
profile.nickname ||
|
||||
'微信用户',
|
||||
avatarUrl: profile.avatarUrl || cached.avatarUrl || null,
|
||||
nickname,
|
||||
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户点击触发:拉取微信昵称/头像 */
|
||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||
if (process.env.TARO_ENV !== 'weapp') {
|
||||
throw new Error('请在微信小程序中授权');
|
||||
}
|
||||
const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
|
||||
const info: MiniWechatProfile = {
|
||||
nickname: res.userInfo?.nickName?.trim(),
|
||||
avatarUrl: res.userInfo?.avatarUrl?.trim(),
|
||||
};
|
||||
if (!info.nickname && !info.avatarUrl) {
|
||||
throw new Error('未获取到微信头像或昵称');
|
||||
}
|
||||
cacheWxProfile(info);
|
||||
return info;
|
||||
}
|
||||
/** 上传 chooseAvatar 临时文件到 OSS,返回永久 URL */
|
||||
export async function uploadAvatarTempFile(tempFilePath: string): Promise<string> {
|
||||
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<MiniWechatProfile | null> {
|
||||
if (!info.nickname && !info.avatarUrl) return null;
|
||||
const res = await Taro.uploadFile({
|
||||
url: `${API_BASE}/common/resources/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
},
|
||||
header: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': CLIENT_APP,
|
||||
},
|
||||
});
|
||||
|
||||
let body: { code?: number; message?: string; data?: { url?: string } } = {};
|
||||
try {
|
||||
const updated = await request<{
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}>('/auth/wechat/mini-profile', {
|
||||
method: 'POST',
|
||||
data: info,
|
||||
});
|
||||
if (updated?.nickname || updated?.avatarUrl) {
|
||||
cacheWxProfile({
|
||||
nickname: updated.nickname ?? info.nickname,
|
||||
avatarUrl: updated.avatarUrl ?? info.avatarUrl,
|
||||
});
|
||||
}
|
||||
return info;
|
||||
body = JSON.parse(String(res.data || '{}')) as typeof body;
|
||||
} catch {
|
||||
return null;
|
||||
throw new Error('头像上传响应异常');
|
||||
}
|
||||
if (res.statusCode === 401 || body.code === 401) {
|
||||
throw new Error(body.message || '登录已过期,请重新登录');
|
||||
}
|
||||
if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) {
|
||||
throw new Error(body.message || '头像上传失败');
|
||||
}
|
||||
return body.data.url;
|
||||
}
|
||||
|
||||
/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */
|
||||
export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise<MiniWechatProfile | null> {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<UserProfile | null> {
|
||||
if (!info.nickname && !info.avatarUrl) return null;
|
||||
const { request } = await import('./api');
|
||||
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
|
||||
method: 'POST',
|
||||
data: info,
|
||||
});
|
||||
cacheWxProfile({
|
||||
nickname: updated?.nickname ?? info.nickname,
|
||||
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
let info = prefetched ?? null;
|
||||
if (!info) {
|
||||
try {
|
||||
info = await fetchMiniWechatUserInfo();
|
||||
} catch {
|
||||
return getCachedWxProfile();
|
||||
}
|
||||
/**
|
||||
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
|
||||
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
|
||||
*/
|
||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||
const cached = getCachedWxProfile();
|
||||
if (cached?.nickname || cached?.avatarUrl) {
|
||||
return cached;
|
||||
}
|
||||
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
|
||||
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
|
||||
}
|
||||
|
||||
/** 绑定后上报微信资料(优先使用已拉取的信息) */
|
||||
export async function syncMiniWechatProfile(
|
||||
prefetched?: MiniWechatProfile | null,
|
||||
): Promise<MiniWechatProfile | null> {
|
||||
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);
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,32 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { saveAuth } from './api';
|
||||
import { syncMiniWechatProfile } from './mini-wechat-profile';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
syncMiniWechatProfile,
|
||||
} from './mini-wechat-profile';
|
||||
|
||||
/**
|
||||
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
|
||||
*(开发者工具热更新时常见旧页 + 新 common 混用)
|
||||
*/
|
||||
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
|
||||
|
||||
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
|
||||
const _wxProfileCompat = {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
};
|
||||
if (
|
||||
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
|
||||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
|
||||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
|
||||
) {
|
||||
throw new Error('mini-wechat-profile helpers missing');
|
||||
}
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
|
||||
@@ -106,14 +106,6 @@ export default function BenefitPage() {
|
||||
<Text>康</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-actions">
|
||||
<Text
|
||||
className="benefit-hero-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
|
||||
>
|
||||
查看权益明细 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '杜康好客',
|
||||
});
|
||||
|
||||
@@ -65,10 +65,7 @@ export default function HomePage() {
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="home-page no-tab-header">
|
||||
<TabMainHeader title="杜康好客" />
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||
import {
|
||||
@@ -18,13 +19,14 @@ import {
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
getCachedWxProfile,
|
||||
syncMiniWechatProfile,
|
||||
type MiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
@@ -33,6 +35,44 @@ function isValidPhone(phone: string) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
function AgreementRow({
|
||||
agreed,
|
||||
onToggle,
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="login-agreement" onClick={onToggle}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
请阅读并勾选同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.return || '';
|
||||
@@ -45,8 +85,10 @@ export default function LoginPage() {
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
/** 须用户主动勾选,禁止默认同意 */
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
@@ -54,6 +96,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);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
@@ -66,7 +109,6 @@ export default function LoginPage() {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
|
||||
if (!needPhone && !needWechat) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
@@ -77,6 +119,7 @@ export default function LoginPage() {
|
||||
if (cancelled) return;
|
||||
if (needPhone && !me.phoneVerified) {
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
return;
|
||||
}
|
||||
if (needWechat && !me.hasWechat) {
|
||||
@@ -101,7 +144,7 @@ export default function LoginPage() {
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -109,18 +152,18 @@ export default function LoginPage() {
|
||||
|
||||
function applySessionAndLeave(
|
||||
data: SessionPayload | WechatLoginResult,
|
||||
phone?: string,
|
||||
phoneValue?: string,
|
||||
wxInfo?: MiniWechatProfile | null,
|
||||
successToast = '登录成功',
|
||||
) {
|
||||
if (!data.accessToken) return;
|
||||
if (phone) saveUserPhone(phone);
|
||||
if (phoneValue) saveUserPhone(phoneValue);
|
||||
saveAuth({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||
if (!phone) {
|
||||
if (!phoneValue) {
|
||||
void fetchUserProfile()
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
.catch(() => {});
|
||||
@@ -134,7 +177,6 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
||||
// 微信授权成功即登录;手机号改为下单页可选绑定
|
||||
if (result.accessToken) {
|
||||
applySessionAndLeave(result, undefined, wxInfo);
|
||||
return;
|
||||
@@ -142,11 +184,49 @@ export default function LoginPage() {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
||||
setShowSmsForm(true);
|
||||
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
|
||||
setSentHint('');
|
||||
return;
|
||||
}
|
||||
setMsg('微信登录未完成,请重试或使用手机号登录');
|
||||
setMsg('登录未完成,请重试或使用手机号登录');
|
||||
}
|
||||
|
||||
async function onPhoneQuickLogin(phoneCode: string) {
|
||||
if (!ensureAgreed()) return;
|
||||
setPhoneQuickLoading(true);
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
try {
|
||||
let loginCode: string | undefined;
|
||||
try {
|
||||
const loginRes = await Taro.login();
|
||||
loginCode = loginRes.code || undefined;
|
||||
} catch {
|
||||
/* openId 绑定失败不阻断手机号登录 */
|
||||
}
|
||||
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
phoneCode,
|
||||
...(loginCode ? { loginCode } : {}),
|
||||
platform: 'mini',
|
||||
},
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('登录成功但未返回令牌,请重试');
|
||||
return;
|
||||
}
|
||||
const profilePhone =
|
||||
typeof data.user === 'object' && data.user && 'phone' in data.user
|
||||
? String((data.user as { phone?: string }).phone || '')
|
||||
: '';
|
||||
applySessionAndLeave(data, profilePhone || undefined);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
|
||||
} finally {
|
||||
setPhoneQuickLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendCode() {
|
||||
@@ -201,7 +281,6 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (completeMode === 'phone' && isLoggedIn()) {
|
||||
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
|
||||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
@@ -235,15 +314,7 @@ export default function LoginPage() {
|
||||
setSentHint('');
|
||||
setWxLoading(true);
|
||||
try {
|
||||
let wxInfo: MiniWechatProfile | null = null;
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
try {
|
||||
wxInfo = await fetchMiniWechatUserInfo();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const wxInfo = getCachedWxProfile();
|
||||
|
||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||
const result = await bindWechatForUser(wxInfo);
|
||||
@@ -254,12 +325,13 @@ export default function LoginPage() {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
setMsg('请绑定手机号完成认证');
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
await syncMiniWechatProfile(wxInfo);
|
||||
toast('微信授权成功', 'success');
|
||||
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||||
toast('授权成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
@@ -268,11 +340,11 @@ export default function LoginPage() {
|
||||
const result = await loginWithWechat();
|
||||
if (result) handleWechatLoginResult(result, wxInfo);
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : '微信登录失败';
|
||||
const raw = e instanceof Error ? e.message : '授权登录失败';
|
||||
const hint = /invalid code/i.test(raw)
|
||||
? process.env.TARO_ENV === 'weapp'
|
||||
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
|
||||
: '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
|
||||
? '授权失败:请确认后端小程序 AppID 配置正确'
|
||||
: '授权失败:请确认公众号网页授权域名配置正确'
|
||||
: raw;
|
||||
setMsg(hint);
|
||||
} finally {
|
||||
@@ -282,18 +354,19 @@ export default function LoginPage() {
|
||||
|
||||
const displayMsg = msg || sentHint;
|
||||
const codeDisabled = cooldown > 0 || sending;
|
||||
const showWechatLogin =
|
||||
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
|
||||
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
||||
const showSmsForm = completeMode !== 'wechat';
|
||||
const showAuthLogin =
|
||||
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
|
||||
(IS_WEAPP || wxAuthorize);
|
||||
const showPhoneQuick =
|
||||
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
|
||||
const cardTitle =
|
||||
completeMode === 'phone'
|
||||
? '验证手机号'
|
||||
: bindMode
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '微信授权'
|
||||
: '手机验证码登录';
|
||||
? '授权登录'
|
||||
: '手机号快捷登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
@@ -309,7 +382,7 @@ export default function LoginPage() {
|
||||
{completeMode === 'phone'
|
||||
? '建议绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '完成微信授权'
|
||||
? '完成授权登录'
|
||||
: '欢迎来到杜康好客'}
|
||||
</Text>
|
||||
<Text className="login-welcome-sub">
|
||||
@@ -325,77 +398,100 @@ export default function LoginPage() {
|
||||
<View className="login-main">
|
||||
{completeMode === 'wechat' ? (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">微信一键授权</Text>
|
||||
<Text className="login-card-title">授权登录</Text>
|
||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||
使用微信支付前需授权微信账号
|
||||
使用支付功能前需完成授权登录
|
||||
</Text>
|
||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
</View>
|
||||
{showWechatLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
{showAuthLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">{cardTitle}</Text>
|
||||
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
{showPhoneQuick ? (
|
||||
<PhoneQuickLoginButton
|
||||
loading={phoneQuickLoading}
|
||||
agreed={agreed}
|
||||
onRequireAgree={() => ensureAgreed()}
|
||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||
onFail={(message) => setMsg(message)}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
) : 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}>
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
>
|
||||
<Text className="login-sms-btn__text">
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '验证码登录'}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
@@ -403,48 +499,6 @@ export default function LoginPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
>
|
||||
<Text className="login-sms-btn__text">
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '登录'}
|
||||
</Text>
|
||||
</View>
|
||||
{completeMode === 'phone' ? (
|
||||
<View
|
||||
className="login-skip-bind"
|
||||
@@ -459,14 +513,14 @@ export default function LoginPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showWechatLogin && completeMode !== 'wechat' ? (
|
||||
{showAuthLogin && completeMode !== 'wechat' ? (
|
||||
<>
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的',
|
||||
});
|
||||
|
||||
@@ -8,10 +8,10 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
|
||||
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
@@ -29,6 +29,8 @@ const SERVICES = [
|
||||
{ icon: '关', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -47,6 +49,10 @@ export default function MinePage() {
|
||||
setOrderCounts({});
|
||||
}
|
||||
|
||||
function applyProfile(me: UserProfile) {
|
||||
setProfile(mergeWxDisplayProfile(me));
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
if (!isLoggedIn()) return;
|
||||
Promise.all([
|
||||
@@ -57,7 +63,7 @@ export default function MinePage() {
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
setProfile(mergeWxDisplayProfile(me));
|
||||
applyProfile(me);
|
||||
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
@@ -89,93 +95,64 @@ export default function MinePage() {
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
async function handleAvatarTap() {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (bindingWx) return;
|
||||
|
||||
let current = profile;
|
||||
if (!current) {
|
||||
try {
|
||||
current = await fetchUserProfile();
|
||||
setProfile(current);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (current?.hasWechat) return;
|
||||
|
||||
async function ensureWechatBound(): Promise<boolean> {
|
||||
if (profile?.hasWechat) return true;
|
||||
if (!wxAuthorize) {
|
||||
toast('当前环境未开启微信授权');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setBindingWx(true);
|
||||
try {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
if (!isWeapp) {
|
||||
if (!isWechatEnv()) {
|
||||
toast('请在微信内打开后授权');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const result = await bindWechatForUser();
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', {
|
||||
bindMode: '1',
|
||||
wxSessionKey: result.wxSessionKey,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.ok) {
|
||||
setProfile(
|
||||
mergeWxDisplayProfile({
|
||||
...(result.profile ?? {}),
|
||||
id: result.profile?.id ?? profile?.id ?? '',
|
||||
hasWechat: true,
|
||||
}),
|
||||
);
|
||||
loadProfile();
|
||||
toast('微信授权成功', 'success');
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let wxInfo = null;
|
||||
try {
|
||||
wxInfo = await fetchMiniWechatUserInfo();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bindWechatForUser(wxInfo);
|
||||
const result = await bindWechatForUser(null);
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.ok) {
|
||||
const merged = mergeWxDisplayProfile({
|
||||
...(result.profile ?? {}),
|
||||
id: result.profile?.id ?? profile?.id ?? '',
|
||||
hasWechat: true,
|
||||
nickname: result.profile?.nickname || wxInfo.nickname || profile?.nickname,
|
||||
avatarUrl: result.profile?.avatarUrl || wxInfo.avatarUrl || profile?.avatarUrl,
|
||||
});
|
||||
setProfile(merged);
|
||||
loadProfile();
|
||||
toast('微信授权成功', 'success');
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '微信授权失败');
|
||||
return false;
|
||||
} finally {
|
||||
setBindingWx(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 未绑定微信时点击头像走授权(小程序 openId / H5 公众号 OAuth) */
|
||||
async function handleAvatarTap() {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (profile?.hasWechat) return;
|
||||
await ensureWechatBound();
|
||||
loadProfile();
|
||||
}
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('url' in item && item.url) {
|
||||
Taro.navigateTo({ url: item.url });
|
||||
@@ -190,17 +167,6 @@ export default function MinePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDisplayProfile(profile: UserProfile | null, hasWechat: boolean) {
|
||||
if (!profile) {
|
||||
return { nickname: '用户', avatarUrl: null as string | null };
|
||||
}
|
||||
const merged = hasWechat ? mergeWxDisplayProfile(profile) : profile;
|
||||
return {
|
||||
nickname: merged.nickname || '用户',
|
||||
avatarUrl: merged.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAvatarContent(displayAvatarUrl: string | null) {
|
||||
if (displayAvatarUrl) {
|
||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||
@@ -210,10 +176,7 @@ export default function MinePage() {
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
@@ -231,10 +194,7 @@ export default function MinePage() {
|
||||
</View>
|
||||
<View className="mine-login-gate">
|
||||
<View className="mine-login-gate-hint">登录后管理订单与个人信息</View>
|
||||
<View
|
||||
className="mine-login-btn"
|
||||
onClick={() => goLogin('/pages/mine/index')}
|
||||
>
|
||||
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -244,43 +204,45 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
const hasWechat = !!profile?.hasWechat;
|
||||
const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp';
|
||||
const display = resolveDisplayProfile(profile, hasWechat);
|
||||
const nickname = display.nickname;
|
||||
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
|
||||
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
|
||||
const display = mergeWxDisplayProfile(
|
||||
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||
);
|
||||
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
|
||||
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
|
||||
throw new Error('wx profile helpers missing');
|
||||
}
|
||||
const nickname = display.nickname || '用户';
|
||||
const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '未完成授权' : '未完成授权';
|
||||
void needsWxProfileFill(display);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View
|
||||
className={`mine-avatar-wrap${!hasWechat && canWxBind ? ' mine-avatar-wrap--action' : ''}`}
|
||||
onClick={() => void handleAvatarTap()}
|
||||
className={`mine-avatar-wrap${!hasWechat && canWxAuth ? ' mine-avatar-wrap--action' : ''}`}
|
||||
onClick={!hasWechat && canWxAuth ? () => void handleAvatarTap() : undefined}
|
||||
>
|
||||
<View
|
||||
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : canWxBind ? ' mine-avatar--wx-pending' : ''}`}
|
||||
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'}`}
|
||||
>
|
||||
{renderAvatarContent(display.avatarUrl)}
|
||||
</View>
|
||||
{!hasWechat && canWxBind ? (
|
||||
{!hasWechat && canWxAuth ? (
|
||||
<View className="mine-avatar-status mine-avatar-status--pending">
|
||||
<Text>{bindingWx ? '授权中' : '去授权'}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View>
|
||||
|
||||
<View className="mine-profile-meta">
|
||||
<Text className="mine-profile-name">{nickname}</Text>
|
||||
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
|
||||
{memberLabel}
|
||||
</Text>
|
||||
{!hasWechat && canWxBind ? (
|
||||
<Text className="mine-wechat-hint">点击头像完成微信授权</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -8,21 +8,25 @@ export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="legal-page">
|
||||
<SubPageHeader title={doc.title} />
|
||||
<View className="sub-page-body inset-page">
|
||||
<ScrollView scrollY className="legal-scroll">
|
||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
||||
<Text className="legal-intro">{doc.intro}</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading">{section.heading}</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View className="sub-page-body inset-page legal-body">
|
||||
<Text className="legal-updated" selectable>
|
||||
更新日期:{doc.updatedAt}
|
||||
</Text>
|
||||
<Text className="legal-intro" selectable>
|
||||
{doc.intro}
|
||||
</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading" selectable>
|
||||
{section.heading}
|
||||
</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -101,8 +101,10 @@ export default function RedeemPage() {
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
placeholderClass="redeem-input-placeholder"
|
||||
value={amount}
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</View>
|
||||
<View className="redeem-amount-foot">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '门店',
|
||||
});
|
||||
|
||||
@@ -73,10 +73,7 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="store-page no-tab-header">
|
||||
<TabMainHeader title="门店" />
|
||||
<View className="store-toolbar">
|
||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '用户协议',
|
||||
navigationBarTitleText: '用户服务协议',
|
||||
navigationStyle: 'custom',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -8,21 +8,25 @@ export default function UserAgreementPage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="legal-page">
|
||||
<SubPageHeader title={doc.title} />
|
||||
<View className="sub-page-body inset-page">
|
||||
<ScrollView scrollY className="legal-scroll">
|
||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
||||
<Text className="legal-intro">{doc.intro}</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading">{section.heading}</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View className="sub-page-body inset-page legal-body">
|
||||
<Text className="legal-updated" selectable>
|
||||
更新日期:{doc.updatedAt}
|
||||
</Text>
|
||||
<Text className="legal-intro" selectable>
|
||||
{doc.intro}
|
||||
</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading" selectable>
|
||||
{section.heading}
|
||||
</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -120,19 +120,7 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.benefit-hero-actions {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.benefit-hero-link {
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.benefit-hero-cta {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-heritage-red);
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
.legal-page .legal-scroll {
|
||||
height: 100%;
|
||||
.legal-page .legal-body {
|
||||
min-height: calc(100vh - var(--nav-bar-height, 88px));
|
||||
padding-top: 8px;
|
||||
padding-bottom: 40px;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 32px;
|
||||
background: var(--color-background, #f7f4ef);
|
||||
}
|
||||
|
||||
.legal-updated {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant, #8d706e);
|
||||
color: #8d706e;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.legal-intro {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-on-surface, #1f1a17);
|
||||
line-height: 1.75;
|
||||
color: #1f1a17;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -27,14 +30,15 @@
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface, #1f1a17);
|
||||
color: #1f1a17;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.legal-paragraph {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-on-surface-variant, #5c504c);
|
||||
line-height: 1.75;
|
||||
color: #3d3530;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-wechat-btn {
|
||||
.login-wechat-btn,
|
||||
.login-phone-quick-btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: none;
|
||||
@@ -250,52 +251,33 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
||||
}
|
||||
|
||||
.login-wechat-btn:active {
|
||||
.login-wechat-btn:active,
|
||||
.login-phone-quick-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-wechat-btn--disabled {
|
||||
.login-wechat-btn--disabled,
|
||||
.login-phone-quick-btn--disabled {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-wechat-btn__text {
|
||||
.login-wechat-btn__text,
|
||||
.login-phone-quick-btn__text {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.wechat-login-icon {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wechat-login-icon__big,
|
||||
.wechat-login-icon__small {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-login-icon__big {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.wechat-login-icon__small {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
right: 0;
|
||||
bottom: 3px;
|
||||
/* 重置小程序 Button 默认样式,避免绿边/微信绿 */
|
||||
.login-phone-quick-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mine-profile-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mine-avatar-status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -86,13 +91,6 @@
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.mine-wechat-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.mine-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
|
||||
/* 无自定义 Tab 顶栏:贴顶(标题走小程序/H5 系统导航栏) */
|
||||
.page-shell.no-tab-header,
|
||||
.page-shell.h5-no-tab-header {
|
||||
--nav-bar-height: 0px;
|
||||
--nav-status-bar-height: 0px;
|
||||
--nav-content-height: 0px;
|
||||
}
|
||||
|
||||
.page-shell.no-tab-header .home-aroma-nav,
|
||||
.page-shell.h5-no-tab-header .home-aroma-nav {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@@ -47,16 +47,20 @@
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.redeem-input-placeholder {
|
||||
color: var(--color-subtle-gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 小程序原生 input:text-align 需落到组件自身与内部节点 */
|
||||
.redeem-input,
|
||||
.redeem-input input,
|
||||
.redeem-input .taro-input,
|
||||
.redeem-input .weui-input {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
height: 52px !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
@@ -65,8 +69,8 @@
|
||||
box-sizing: border-box !important;
|
||||
font-size: 24px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: normal !important;
|
||||
color: inherit;
|
||||
line-height: 52px !important;
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
|
||||
|
||||
function stripQuotes(s) {
|
||||
const t = s.trim();
|
||||
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
||||
return t.slice(1, -1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function parseEnvValue(text, key) {
|
||||
const re = new RegExp(`^${key}=(.*)$`, 'm');
|
||||
const m = text.match(re);
|
||||
if (!m) return null;
|
||||
return stripQuotes(m[1]);
|
||||
}
|
||||
|
||||
function tryKey(name, key) {
|
||||
const literalN = (key.match(/\\n/g) || []).length;
|
||||
const realN = (key.match(/\n/g) || []).length;
|
||||
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
|
||||
try {
|
||||
crypto.createPrivateKey(key);
|
||||
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(path, 'utf8');
|
||||
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
|
||||
if (!raw) {
|
||||
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('file=', path);
|
||||
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
|
||||
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
|
||||
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
|
||||
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
|
||||
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
|
||||
|
||||
const variants = {
|
||||
as_is: raw,
|
||||
unescape_n: raw.replace(/\\n/g, '\n'),
|
||||
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
|
||||
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
|
||||
// dotenv style sometimes leaves surrounding quotes in process.env
|
||||
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
|
||||
};
|
||||
|
||||
let ok = false;
|
||||
for (const [name, key] of Object.entries(variants)) {
|
||||
if (tryKey(name, key)) ok = true;
|
||||
}
|
||||
|
||||
// also simulate dotenv load
|
||||
try {
|
||||
const dotenv = require('dotenv');
|
||||
const parsed = dotenv.parse(text);
|
||||
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
|
||||
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
|
||||
tryKey('dotenv_as_is', fromDotenv);
|
||||
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
|
||||
} catch (e) {
|
||||
console.log('dotenv skip', e.message);
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 2);
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# 启用 api.dukanghaoke.com:扩证书 + 装 nginx 配置
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN_API=api.dukanghaoke.com
|
||||
CERT_NAME=user.dukanghaoke.com
|
||||
EMAIL="${CERTBOT_EMAIL:-admin@dukanghaoke.com}"
|
||||
APP_ROOT="${APP_ROOT:-/opt/dukang}"
|
||||
NGINX_SRC="$APP_ROOT/deploy/nginx-dukanghaoke.conf"
|
||||
NGINX_DST="/etc/nginx/sites-available/dukang"
|
||||
|
||||
mkdir -p /var/www/certbot/.well-known/acme-challenge
|
||||
mkdir -p /var/log/nginx/dukang
|
||||
|
||||
echo "==> 1. 临时 HTTP 放行 api 域名(便于 http-01)"
|
||||
# 先写入含 api 的 80 server,若证书尚未含 api,443 块可暂用现有证书
|
||||
if [[ -f "$NGINX_SRC" ]]; then
|
||||
install -m 644 "$NGINX_SRC" "$NGINX_DST"
|
||||
ln -sfn "$NGINX_DST" /etc/nginx/sites-enabled/dukang
|
||||
fi
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> 2. 扩展证书加入 $DOMAIN_API"
|
||||
certbot certonly --nginx \
|
||||
--cert-name "$CERT_NAME" \
|
||||
--expand \
|
||||
-d user.dukanghaoke.com \
|
||||
-d shop.dukanghaoke.com \
|
||||
-d partner.dukanghaoke.com \
|
||||
-d admin.dukanghaoke.com \
|
||||
-d api.dukanghaoke.com \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--non-interactive \
|
||||
--keep-until-expiring
|
||||
|
||||
echo "==> 3. 重载 nginx"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> 4. 健康检查"
|
||||
sleep 1
|
||||
curl -sf -o /dev/null -w "api-https:%{http_code} content-type:%{content_type}\n" \
|
||||
"https://${DOMAIN_API}/api/v1/health"
|
||||
curl -sf "https://${DOMAIN_API}/api/v1/health"; echo
|
||||
echo "==> 完成"
|
||||
@@ -1,4 +1,4 @@
|
||||
# 杜康好客 — CodeUp Webhook 反代(挂到 dkapi.runxian.top 443/80 server 块内)
|
||||
# 杜康好客 — CodeUp Webhook 反代(挂到 api.dukanghaoke.com 443/80 server 块内)
|
||||
# setup-webhook.sh 会自动 include 此文件
|
||||
|
||||
location = /hooks/deploy {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 杜康好客 — dukanghaoke.com
|
||||
# HTTP → HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name user.dukanghaoke.com shop.dukanghaoke.com partner.dukanghaoke.com admin.dukanghaoke.com api.dukanghaoke.com;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# api.dukanghaoke.com → 8090
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name api.dukanghaoke.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
include /opt/dukang/deploy/nginx-deploy-webhook.conf;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
}
|
||||
|
||||
# user.dukanghaoke.com → 8091
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name user.dukanghaoke.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# shop.dukanghaoke.com → 8092
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name shop.dukanghaoke.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8092;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# partner.dukanghaoke.com → 8093
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name partner.dukanghaoke.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8093;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# admin.dukanghaoke.com → 8094
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name admin.dukanghaoke.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8094;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
||||
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
||||
# C 端 H5 编译期注入的 API origin(勿落到 localhost)
|
||||
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
|
||||
export VITE_API_TARGET="${VITE_API_TARGET:-https://api.dukanghaoke.com}"
|
||||
pnpm approve-builds --all 2>/dev/null || true
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
|
||||
|
||||
+19
-7
@@ -30,13 +30,25 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
|
||||
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
|
||||
|
||||
MARKER="include $HOOK_CONF;"
|
||||
for conf in /etc/nginx/conf.d/dukang-runxian.conf /etc/nginx/conf.d/dukang-runxian-ssl.conf; do
|
||||
if [[ -f "$conf" ]] && grep -q 'server_name dkapi.runxian.top' "$conf"; then
|
||||
# 清理旧错误 include
|
||||
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
|
||||
for conf in \
|
||||
/etc/nginx/sites-available/dukang \
|
||||
/etc/nginx/conf.d/dukang-runxian.conf \
|
||||
/etc/nginx/conf.d/dukang-runxian-ssl.conf
|
||||
do
|
||||
if [[ ! -f "$conf" ]]; then
|
||||
continue
|
||||
fi
|
||||
# 清理旧错误 include
|
||||
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
|
||||
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
|
||||
if ! grep -qF "$MARKER" "$conf"; then
|
||||
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
|
||||
echo " 已 patch $conf (api.dukanghaoke.com)"
|
||||
fi
|
||||
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
|
||||
if ! grep -qF "$MARKER" "$conf"; then
|
||||
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
|
||||
echo " 已 patch $conf"
|
||||
echo " 已 patch $conf (dkapi.runxian.top)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -62,14 +74,14 @@ SECRET="$(grep DEPLOY_WEBHOOK_SECRET "$ENV_FILE" | cut -d= -f2- | tr -d '\"')"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Webhook 已就绪"
|
||||
echo " URL: https://dkapi.runxian.top/hooks/deploy"
|
||||
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
|
||||
echo " Secret: $SECRET"
|
||||
echo " Branch: dev (refs/heads/dev)"
|
||||
echo " Log: $LOG_DIR/deploy.log"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "CodeUp 配置:仓库 → 设置 → Webhooks → 添加"
|
||||
echo " URL: https://dkapi.runxian.top/hooks/deploy"
|
||||
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
|
||||
echo " Secret Token: (与上方 Secret 相同)"
|
||||
echo " 触发事件: Push events"
|
||||
echo " 分支过滤: dev"
|
||||
|
||||
@@ -11,7 +11,7 @@ DEPLOY_HOST=""
|
||||
DEPLOY_USER="root"
|
||||
DEPLOY_PORT="22"
|
||||
DEPLOY_SSH_KEY=""
|
||||
APP_ROOT="/opt/dukang-haoke"
|
||||
APP_ROOT="/opt/dukang"
|
||||
|
||||
TARGET="${1:-production}"
|
||||
case "$TARGET" in
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dev:hq": "pnpm --filter @dukang/mini-hq dev",
|
||||
"dev:mini-user": "pnpm --filter @dukang/mini-user dev",
|
||||
"dev:mini-user:weapp": "pnpm --filter @dukang/mini-user dev:weapp",
|
||||
"build:mini-user:weapp": "pnpm --filter @dukang/mini-user build:weapp",
|
||||
"preview:hq": "pnpm --filter @dukang/mini-hq build && pnpm --filter @dukang/mini-hq preview",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -21,10 +21,10 @@ export type LegalDocument = {
|
||||
*/
|
||||
export const USER_AGREEMENT: LegalDocument = {
|
||||
id: 'user-agreement',
|
||||
title: '用户协议',
|
||||
updatedAt: '2026-07-15',
|
||||
title: '用户服务协议',
|
||||
updatedAt: '2026-07-21',
|
||||
intro:
|
||||
'欢迎使用「杜康好客」平台(含微信小程序、微信内置浏览器 H5 及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您勾选同意并继续使用,即视为已阅读并接受本协议全部内容。',
|
||||
'欢迎使用「杜康好客」平台(含小程序、移动网页及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您须主动勾选同意后继续使用,不得默认强制同意;勾选即视为已阅读并接受本协议全部内容。',
|
||||
sections: [
|
||||
{
|
||||
heading: '一、服务说明',
|
||||
@@ -36,7 +36,7 @@ export const USER_AGREEMENT: LegalDocument = {
|
||||
{
|
||||
heading: '二、账号注册与安全',
|
||||
paragraphs: [
|
||||
'您可通过手机号验证码、微信授权等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
|
||||
'您可通过手机号快捷登录、手机号验证码等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
|
||||
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
|
||||
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
|
||||
],
|
||||
@@ -88,20 +88,21 @@ export const USER_AGREEMENT: LegalDocument = {
|
||||
export const PRIVACY_POLICY: LegalDocument = {
|
||||
id: 'privacy-policy',
|
||||
title: '隐私政策',
|
||||
updatedAt: '2026-07-15',
|
||||
updatedAt: '2026-07-21',
|
||||
intro:
|
||||
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您勾选同意,即表示您已充分理解并同意我们按本政策处理相关个人信息。',
|
||||
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您须主动勾选同意后,我们才会按本政策处理相关个人信息;我们不会默认勾选或强制同意。',
|
||||
sections: [
|
||||
{
|
||||
heading: '一、我们如何收集与使用个人信息',
|
||||
paragraphs: [
|
||||
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能收集并使用下列信息:',
|
||||
'1)账号信息:手机号码、验证码、微信 OpenID/UnionID、昵称与头像(若您授权微信);用于注册登录、账号绑定与安全保障。',
|
||||
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能在取得您授权同意后收集并使用下列信息:',
|
||||
'1)账号信息:手机号码、验证码、开放平台账号标识(OpenID/UnionID,若您授权)、昵称与头像(若您主动授权);用于注册登录、账号绑定与安全保障。',
|
||||
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
|
||||
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
|
||||
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
|
||||
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
|
||||
'我们不会以默认勾选等方式强制您同意本政策;未征得同意前,我们不会超范围收集与实现业务功能无关的个人信息。',
|
||||
'收集目的与方式:仅在实现上述业务功能所必需的范围内,通过您主动填写、授权组件或系统必要日志收集;未征得同意前,我们不会超范围收集与业务无关的个人信息。',
|
||||
'我们不会以默认勾选等方式强制您同意本政策。',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -109,7 +110,7 @@ export const PRIVACY_POLICY: LegalDocument = {
|
||||
paragraphs: [
|
||||
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
|
||||
'1)获得您的明确同意;',
|
||||
'2)为实现支付、短信、配送、地图/定位、微信登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
|
||||
'2)为实现支付、短信、配送、地图/定位、账号登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
|
||||
'3)根据法律法规、行政或司法机关要求;',
|
||||
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
|
||||
],
|
||||
|
||||
@@ -50,7 +50,7 @@ WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||
WX_PLATFORM_CERT=
|
||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||
|
||||
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
||||
TENCENT_LBS_KEY=
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
NODE_ENV=production
|
||||
|
||||
DATABASE_URL="mysql://dukang:CHANGE_ME@localhost:3306/dukang_haoke"
|
||||
# 生产:阿里云 RDS(密码中的 @ ! 需 URL 编码为 %40 %21)
|
||||
DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_prod?charset=utf8mb4"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET="CHANGE_ME-strong-random-secret"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
@@ -36,7 +37,7 @@ WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
WX_PLATFORM_CERT=
|
||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { resolve } from 'path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
|
||||
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
|
||||
|
||||
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
|
||||
|
||||
function apiRoot() {
|
||||
return resolve(__dirname, '..', '..');
|
||||
}
|
||||
@@ -11,6 +14,11 @@ export function resolveEnvFilePath() {
|
||||
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
|
||||
}
|
||||
|
||||
function normalizeConfigValue(key: string, value: string): string {
|
||||
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
|
||||
export async function preloadSystemConfigEnv(): Promise<number> {
|
||||
const prisma = new PrismaClient();
|
||||
@@ -18,7 +26,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
|
||||
const rows = await prisma.systemConfig.findMany();
|
||||
for (const row of rows) {
|
||||
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
|
||||
process.env[row.configKey] = row.value;
|
||||
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
|
||||
}
|
||||
}
|
||||
return rows.length;
|
||||
@@ -41,7 +49,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
|
||||
export function applyEnvOverlay(values: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
|
||||
process.env[key] = value;
|
||||
process.env[key] = normalizeConfigValue(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,17 +205,31 @@ export class SystemConfigService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeByMeta(meta: { type: string }, raw: string): string {
|
||||
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
|
||||
if (meta.type === 'boolean') {
|
||||
return raw === 'true' || raw === '1' ? 'true' : 'false';
|
||||
}
|
||||
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
|
||||
let value = raw.trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvLine(key: string, value: string): string {
|
||||
if (/[\s#"'\\]/.test(value)) {
|
||||
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return `${key}=${value}`;
|
||||
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
|
||||
if (!needsQuote) return `${key}=${value}`;
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r\n/g, '\\n')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/"/g, '\\"');
|
||||
return `${key}="${escaped}"`;
|
||||
}
|
||||
|
||||
@@ -67,3 +67,22 @@ export function safeEqual(a: string, b: string): boolean {
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 .env / system_config 中的 PEM:
|
||||
* - 去掉外层引号(DB/表单常把整段含引号写入)
|
||||
* - 把字面量 \\n 转成真实换行
|
||||
* OpenSSL 报 1E08010C DECODER unsupported 时多半是这两类污染。
|
||||
*/
|
||||
export function normalizePemEnv(raw: string | undefined | null): string {
|
||||
if (!raw) return '';
|
||||
let value = String(raw).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './w
|
||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||
import {
|
||||
decryptPayResource,
|
||||
normalizePemEnv,
|
||||
verifyPaySignature,
|
||||
type WechatPayNotifyEnvelope,
|
||||
} from './wechat-pay.util';
|
||||
@@ -15,6 +16,7 @@ type TokenCache = { accessToken: string; expiresAt: number };
|
||||
type TicketCache = { ticket: string; expiresAt: number };
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
||||
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
|
||||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,10 +29,10 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
||||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||||
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
||||
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
||||
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
@@ -254,7 +256,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
if (platform === 'h5') {
|
||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||
}
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||
const data = await this.fetchJson<{
|
||||
errcode?: number;
|
||||
@@ -432,6 +434,37 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(): Promise<string> {
|
||||
const appId = this.miniAppId;
|
||||
const appSecret = this.miniAppSecret;
|
||||
if (!appId || !appSecret) {
|
||||
throw new InternalServerErrorException(
|
||||
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||||
);
|
||||
}
|
||||
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||
|
||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
||||
url.searchParams.set('grant_type', 'client_credential');
|
||||
url.searchParams.set('appid', appId);
|
||||
url.searchParams.set('secret', appSecret);
|
||||
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||||
url.toString(),
|
||||
);
|
||||
if (!data.access_token) {
|
||||
throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败');
|
||||
}
|
||||
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||
await this.redis.setJson(
|
||||
MINI_ACCESS_TOKEN_KEY,
|
||||
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||||
ttl,
|
||||
);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async getJsapiTicket(): Promise<string> {
|
||||
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CheckPartnerPhoneDto,
|
||||
LoginSmsDto,
|
||||
LoginWechatDto,
|
||||
LoginWechatPhoneDto,
|
||||
RefreshTokenDto,
|
||||
SendSmsDto,
|
||||
} from './dto/auth.dto';
|
||||
@@ -74,6 +75,23 @@ export class UserAuthController {
|
||||
return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId);
|
||||
}
|
||||
|
||||
/** 小程序手机号快捷登录(getPhoneNumber) */
|
||||
@Post('auth/login/wechat-phone')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
wechatPhoneLogin(@Req() req: Request, @Body() dto: LoginWechatPhoneDto) {
|
||||
const guest = (req as Request & { user?: AuthUser }).user;
|
||||
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||
const clientApp = resolveUserClientApp(req);
|
||||
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
|
||||
return this.authService.loginUserWechatPhone(
|
||||
dto.phoneCode,
|
||||
clientApp,
|
||||
platform,
|
||||
guestId,
|
||||
dto.loginCode,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('auth/wechat/bind-phone')
|
||||
bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) {
|
||||
return this.authService.bindWechatPhone(
|
||||
|
||||
@@ -751,19 +751,13 @@ export class AuthService {
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
select: { id: true },
|
||||
});
|
||||
await this.verifySmsForUser(
|
||||
normalizedPhone,
|
||||
code,
|
||||
SmsScene.USER_LOGIN,
|
||||
clientApp,
|
||||
guestId ?? existingUser?.id,
|
||||
);
|
||||
/** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */
|
||||
private async issueUserSessionByVerifiedPhone(
|
||||
normalizedPhone: string,
|
||||
clientApp: ClientApp,
|
||||
guestId: bigint | undefined,
|
||||
method: 'sms' | 'wechat_phone',
|
||||
) {
|
||||
let user: UserRow | null = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { avatar: true },
|
||||
@@ -816,12 +810,12 @@ export class AuthService {
|
||||
if (guestId && guestId !== user.id) {
|
||||
user = await this.mergeUsers(guestId, user.id);
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'sms_login',
|
||||
extraJson: { method: 'sms', accountMerged: true },
|
||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||
extraJson: { method, accountMerged: true },
|
||||
});
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'login_success',
|
||||
extraJson: { method: 'sms', accountMerged: true },
|
||||
extraJson: { method, accountMerged: true },
|
||||
});
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true });
|
||||
} else {
|
||||
@@ -832,17 +826,70 @@ export class AuthService {
|
||||
if (!user) throw new BadRequestException('登录失败');
|
||||
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'sms_login',
|
||||
extraJson: { method: 'sms' },
|
||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||
extraJson: { method },
|
||||
});
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: 'login_success',
|
||||
extraJson: { method: 'sms' },
|
||||
extraJson: { method },
|
||||
});
|
||||
|
||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
select: { id: true },
|
||||
});
|
||||
await this.verifySmsForUser(
|
||||
normalizedPhone,
|
||||
code,
|
||||
SmsScene.USER_LOGIN,
|
||||
clientApp,
|
||||
guestId ?? existingUser?.id,
|
||||
);
|
||||
return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms');
|
||||
}
|
||||
|
||||
/** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */
|
||||
async loginUserWechatPhone(
|
||||
phoneCode: string,
|
||||
clientApp: ClientApp,
|
||||
platform: 'h5' | 'mini' = 'mini',
|
||||
guestId?: bigint,
|
||||
loginCode?: string,
|
||||
) {
|
||||
this.assertWechatEnabled();
|
||||
if (platform !== 'mini') {
|
||||
throw new BadRequestException('仅小程序支持手机号快捷登录');
|
||||
}
|
||||
const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform);
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const session = await this.issueUserSessionByVerifiedPhone(
|
||||
normalizedPhone,
|
||||
clientApp,
|
||||
guestId,
|
||||
'wechat_phone',
|
||||
);
|
||||
|
||||
if (loginCode?.trim() && session.actorId) {
|
||||
try {
|
||||
await this.bindUserWechat(
|
||||
BigInt(session.actorId),
|
||||
{ code: loginCode.trim() },
|
||||
clientApp,
|
||||
'mini',
|
||||
);
|
||||
} catch {
|
||||
/* 绑定 openId 失败不阻断已成功的手机号登录 */
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
||||
|
||||
@@ -55,6 +55,22 @@ export class LoginWechatDto {
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
/** 小程序 getPhoneNumber 返回的 phoneCode,可选附带 wx.login code 绑定 openId */
|
||||
export class LoginWechatPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phoneCode: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
loginCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['h5', 'mini'])
|
||||
@IsOptional()
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
export class BindWechatPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
Reference in New Issue
Block a user