From eac6b2fd1f6175cc3bbc3a6baeddfc3b0a7ccc88 Mon Sep 17 00:00:00 2001 From: wangwuww111 <2816108629@qq.com> Date: Wed, 11 Mar 2026 12:10:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=99=BB=E5=BD=95=E5=92=8C=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=98=B5=E7=A7=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/misc.xml | 4 - client/js/data/UserManager.js | 129 ++++++-- client/js/main.js | 44 ++- client/js/scenes/LoginScene.js | 257 ++++++++++++++++ client/js/scenes/ProfileScene.js | 276 +++++++++++++++++- client/js/scenes/SceneManager.js | 4 +- client/js/utils/http.js | 89 +++++- server/app/config.py | 7 + server/app/main.py | 9 +- .../models/__pycache__/story.cpython-310.pyc | Bin 3691 -> 3691 bytes .../models/__pycache__/user.cpython-310.pyc | Bin 2287 -> 2917 bytes .../__pycache__/drafts.cpython-310.pyc | Bin 7889 -> 12704 bytes .../routers/__pycache__/user.cpython-310.pyc | Bin 9569 -> 12713 bytes server/app/routers/upload.py | 108 +++++++ server/app/routers/user.py | 77 ++++- .../services/__pycache__/ai.cpython-310.pyc | Bin 18003 -> 22522 bytes server/app/utils/__init__.py | 6 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 347 bytes .../__pycache__/jwt_utils.cpython-312.pyc | Bin 0 -> 3065 bytes server/app/utils/jwt_utils.py | 78 +++++ 20 files changed, 1021 insertions(+), 67 deletions(-) create mode 100644 client/js/scenes/LoginScene.js create mode 100644 server/app/routers/upload.py create mode 100644 server/app/utils/__init__.py create mode 100644 server/app/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 server/app/utils/__pycache__/jwt_utils.cpython-312.pyc create mode 100644 server/app/utils/jwt_utils.py diff --git a/.idea/misc.xml b/.idea/misc.xml index 1a8319f..e6be3f1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,5 @@ - - diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js index ba9ce14..e3f3cd3 100644 --- a/client/js/data/UserManager.js +++ b/client/js/data/UserManager.js @@ -9,50 +9,125 @@ export default class UserManager { this.openid = null; this.nickname = ''; this.avatarUrl = ''; + this.token = ''; this.isLoggedIn = false; } /** - * 初始化用户 + * 检查是否已登录(只检查本地缓存) + * @returns {boolean} 是否已登录 + */ + checkLogin() { + const cached = wx.getStorageSync('userInfo'); + if (cached && cached.userId && cached.token) { + this.userId = cached.userId; + this.openid = cached.openid; + this.nickname = cached.nickname || '游客'; + this.avatarUrl = cached.avatarUrl || ''; + this.token = cached.token; + this.isLoggedIn = true; + return true; + } + return false; + } + + /** + * 初始化用户(只恢复缓存,不自动登录) */ async init() { - try { - // 尝试从本地存储恢复用户信息 - const cached = wx.getStorageSync('userInfo'); - if (cached) { - this.userId = cached.userId; - this.openid = cached.openid; - this.nickname = cached.nickname; - this.avatarUrl = cached.avatarUrl; - this.isLoggedIn = true; - return; - } + // 只检查本地缓存,不自动登录 + this.checkLogin(); + } + /** + * 执行登录(供登录按钮调用) + * @param {Object} userInfo - 微信用户信息(头像、昵称等),可选 + */ + async doLogin(userInfo = null) { + try { // 获取登录code const { code } = await this.wxLogin(); - // 调用后端登录接口 - const result = await post('/user/login', { code }); + // 调用后端登录接口,传入用户信息 + const result = await post('/user/login', { + code, + userInfo: userInfo ? { + nickname: userInfo.nickName, + avatarUrl: userInfo.avatarUrl, + gender: userInfo.gender || 0 + } : null + }); this.userId = result.userId; this.openid = result.openid; - this.nickname = result.nickname || '游客'; - this.avatarUrl = result.avatarUrl || ''; + // 优先使用后端返回的,其次用授权获取的 + this.nickname = result.nickname || (userInfo?.nickName) || '游客'; + this.avatarUrl = result.avatarUrl || (userInfo?.avatarUrl) || ''; + this.token = result.token || ''; this.isLoggedIn = true; - // 缓存用户信息 + // 缓存用户信息(包含 token) wx.setStorageSync('userInfo', { userId: this.userId, openid: this.openid, nickname: this.nickname, - avatarUrl: this.avatarUrl + avatarUrl: this.avatarUrl, + token: this.token }); + + return { + userId: this.userId, + nickname: this.nickname, + avatarUrl: this.avatarUrl + }; } catch (error) { - console.error('用户初始化失败:', error); - // 使用临时身份 - this.userId = 0; - this.nickname = '游客'; - this.isLoggedIn = false; + console.error('登录失败:', error); + throw error; + } + } + + /** + * 退出登录 + */ + logout() { + this.userId = null; + this.openid = null; + this.nickname = ''; + this.avatarUrl = ''; + this.token = ''; + this.isLoggedIn = false; + wx.removeStorageSync('userInfo'); + } + + /** + * 更新用户资料 + */ + async updateProfile(nickname, avatarUrl) { + if (!this.isLoggedIn) return false; + try { + await post('/user/profile', { + nickname, + avatarUrl, + gender: 0 + }, { params: { userId: this.userId } }); + + // 更新本地数据 + this.nickname = nickname; + this.avatarUrl = avatarUrl; + + // 更新缓存(保留 token) + wx.setStorageSync('userInfo', { + userId: this.userId, + openid: this.openid, + nickname: this.nickname, + avatarUrl: this.avatarUrl, + token: this.token + }); + + return true; + } catch (e) { + console.error('更新资料失败:', e); + return false; } } @@ -63,12 +138,16 @@ export default class UserManager { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('登录超时')); - }, 3000); + }, 5000); wx.login({ success: (res) => { clearTimeout(timeout); - resolve(res); + if (res.code) { + resolve(res); + } else { + reject(new Error('获取code失败')); + } }, fail: (err) => { clearTimeout(timeout); diff --git a/client/js/main.js b/client/js/main.js index 2e1b594..62a03f2 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -46,13 +46,37 @@ export default class Main { }); console.log('[Main] 云环境初始化完成'); - // 用户初始化(失败不阻塞) - console.log('[Main] 初始化用户...'); - await this.userManager.init().catch(e => { - console.warn('[Main] 用户初始化失败,使用游客模式:', e); - }); - console.log('[Main] 用户初始化完成'); + // 检查用户是否已登录(只检查缓存,不自动登录) + console.log('[Main] 检查登录状态...'); + const isLoggedIn = this.userManager.checkLogin(); + console.log('[Main] 登录状态:', isLoggedIn ? '已登录' : '未登录'); + // 隐藏加载界面 + this.hideLoading(); + + if (!isLoggedIn) { + // 未登录,显示登录场景 + console.log('[Main] 未登录,显示登录页面'); + this.sceneManager.switchScene('login'); + } else { + // 已登录,加载数据并进入首页 + await this.loadAndEnterHome(); + } + + // 设置分享 + this.setupShare(); + } catch (error) { + console.error('[Main] 初始化失败:', error); + this.hideLoading(); + this.showError('初始化失败,请重试'); + } + } + + // 加载数据并进入首页 + async loadAndEnterHome() { + this.showLoading('正在加载...'); + + try { // 加载故事列表 console.log('[Main] 加载故事列表...'); await this.storyManager.loadStoryList(); @@ -65,17 +89,15 @@ export default class Main { this.sceneManager.switchScene('home'); console.log('[Main] 初始化完成,进入首页'); - // 设置分享 - this.setupShare(); - // 启动草稿检查(仅登录用户) if (this.userManager.isLoggedIn) { this.startDraftChecker(); } } catch (error) { - console.error('[Main] 初始化失败:', error); this.hideLoading(); - this.showError('初始化失败,请重试'); + console.error('[Main] 加载失败:', error); + // 加载失败也进入首页,让用户可以重试 + this.sceneManager.switchScene('home'); } } diff --git a/client/js/scenes/LoginScene.js b/client/js/scenes/LoginScene.js new file mode 100644 index 0000000..4828165 --- /dev/null +++ b/client/js/scenes/LoginScene.js @@ -0,0 +1,257 @@ +/** + * 登录场景 - 微信授权登录 + */ +import BaseScene from './BaseScene'; + +export default class LoginScene extends BaseScene { + constructor(main, params = {}) { + super(main, params); + this.isLoading = false; + this.userInfoButton = null; + } + + loadAssets() { + // 不加载外部图片,使用 Canvas 绘制 + } + + init() { + console.log('[LoginScene] 初始化登录场景'); + // 创建微信授权按钮 + this.createUserInfoButton(); + } + + // 创建微信用户信息授权按钮 + createUserInfoButton() { + const { screenWidth, screenHeight } = this; + const btnWidth = 280; + const btnHeight = 50; + const btnX = (screenWidth - btnWidth) / 2; + const btnY = screenHeight * 0.55; + + // 创建透明的用户信息按钮,覆盖在登录按钮上 + this.userInfoButton = wx.createUserInfoButton({ + type: 'text', + text: '', + style: { + left: btnX, + top: btnY, + width: btnWidth, + height: btnHeight, + backgroundColor: 'transparent', + borderColor: 'transparent', + borderWidth: 0, + borderRadius: 25, + color: 'transparent', + textAlign: 'center', + fontSize: 18, + lineHeight: 50 + } + }); + + // 监听点击事件 + this.userInfoButton.onTap(async (res) => { + console.log('[LoginScene] 用户信息授权回调:', res); + + if (res.userInfo) { + // 用户同意授权,获取到了头像昵称 + await this.doLoginWithUserInfo(res.userInfo); + } else { + // 未获取到用户信息,静默登录 + console.log('[LoginScene] 未获取到用户信息,使用静默登录'); + await this.doLoginWithUserInfo(null); + } + }); + } + + render(ctx) { + const { screenWidth, screenHeight } = this; + + // 绘制背景渐变 + const gradient = ctx.createLinearGradient(0, 0, 0, screenHeight); + gradient.addColorStop(0, '#1a1a2e'); + gradient.addColorStop(0.5, '#16213e'); + gradient.addColorStop(1, '#0f0f23'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, screenWidth, screenHeight); + + // 绘制装饰性光效 + this.renderGlow(ctx); + + // 绘制Logo + this.renderLogo(ctx); + + // 绘制应用名称 + this.renderTitle(ctx); + + // 绘制登录按钮 + this.renderLoginButton(ctx); + + // 绘制底部提示 + this.renderFooter(ctx); + } + + renderGlow(ctx) { + const { screenWidth, screenHeight } = this; + + // 顶部光晕 + const topGlow = ctx.createRadialGradient( + screenWidth / 2, -100, 0, + screenWidth / 2, -100, 400 + ); + topGlow.addColorStop(0, 'rgba(106, 90, 205, 0.3)'); + topGlow.addColorStop(1, 'rgba(106, 90, 205, 0)'); + ctx.fillStyle = topGlow; + ctx.fillRect(0, 0, screenWidth, 400); + } + + renderLogo(ctx) { + const { screenWidth, screenHeight } = this; + const logoSize = 120; + const logoX = (screenWidth - logoSize) / 2; + const logoY = screenHeight * 0.2; + + // 绘制Logo背景圆 + ctx.beginPath(); + ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10, 0, Math.PI * 2); + const logoGradient = ctx.createRadialGradient( + screenWidth / 2, logoY + logoSize / 2, 0, + screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10 + ); + logoGradient.addColorStop(0, 'rgba(106, 90, 205, 0.4)'); + logoGradient.addColorStop(1, 'rgba(106, 90, 205, 0.1)'); + ctx.fillStyle = logoGradient; + ctx.fill(); + + // 绘制内圆 + ctx.fillStyle = '#6a5acd'; + ctx.beginPath(); + ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 - 10, 0, Math.PI * 2); + ctx.fill(); + + // 绘制书本图标 + ctx.fillStyle = '#fff'; + ctx.font = `${logoSize * 0.5}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('📖', screenWidth / 2, logoY + logoSize / 2); + } + + renderTitle(ctx) { + const { screenWidth, screenHeight } = this; + const titleY = screenHeight * 0.2 + 160; + + // 应用名称 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 32px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('AI互动故事', screenWidth / 2, titleY); + + // 副标题 + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.font = '16px sans-serif'; + ctx.fillText('探索无限可能的剧情世界', screenWidth / 2, titleY + 40); + } + + renderLoginButton(ctx) { + const { screenWidth, screenHeight } = this; + + // 按钮位置和尺寸 + const btnWidth = 280; + const btnHeight = 50; + const btnX = (screenWidth - btnWidth) / 2; + const btnY = screenHeight * 0.55; + + // 绘制按钮背景 + const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY); + if (this.isLoading) { + btnGradient.addColorStop(0, '#666666'); + btnGradient.addColorStop(1, '#888888'); + } else { + btnGradient.addColorStop(0, '#07c160'); + btnGradient.addColorStop(1, '#06ae56'); + } + + this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25); + ctx.fillStyle = btnGradient; + ctx.fill(); + + // 绘制按钮阴影效果 + if (!this.isLoading) { + ctx.shadowColor = 'rgba(7, 193, 96, 0.4)'; + ctx.shadowBlur = 20; + ctx.shadowOffsetY = 5; + this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25); + ctx.fill(); + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + } + + // 绘制按钮文字 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + if (this.isLoading) { + ctx.fillText('登录中...', screenWidth / 2, btnY + btnHeight / 2); + } else { + ctx.fillText('微信一键登录', screenWidth / 2, btnY + btnHeight / 2); + } + } + + renderFooter(ctx) { + const { screenWidth, screenHeight } = this; + const footerY = screenHeight - 60; + + // 用户协议提示 + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('登录即表示同意《用户协议》和《隐私政策》', screenWidth / 2, footerY); + } + + // 不再需要手动处理点击事件,由 userInfoButton 处理 + onTouchEnd(e) {} + + async doLoginWithUserInfo(userInfo) { + if (this.isLoading) return; + + this.isLoading = true; + console.log('[LoginScene] 开始登录,用户信息:', userInfo); + + try { + // 调用 UserManager 的登录方法,传入用户信息 + await this.main.userManager.doLogin(userInfo); + + console.log('[LoginScene] 登录成功,加载数据并进入首页'); + + // 隐藏授权按钮 + if (this.userInfoButton) { + this.userInfoButton.hide(); + } + + // 登录成功,加载数据并进入首页 + await this.main.loadAndEnterHome(); + } catch (error) { + console.error('[LoginScene] 登录失败:', error); + this.isLoading = false; + + wx.showToast({ + title: error.message || '登录失败', + icon: 'none' + }); + } + } + + destroy() { + console.log('[LoginScene] 销毁登录场景'); + // 销毁授权按钮 + if (this.userInfoButton) { + this.userInfoButton.destroy(); + this.userInfoButton = null; + } + } +} diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 926f105..1b5ef3b 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -21,6 +21,10 @@ export default class ProfileScene extends BaseScene { this.selectedStoryRecords = []; // 选中故事的记录列表 this.selectedStoryInfo = {}; // 选中故事的信息 + // 头像相关 + this.avatarImage = null; + this.avatarImageLoaded = false; + // 统计 this.stats = { works: 0, @@ -40,6 +44,29 @@ export default class ProfileScene extends BaseScene { async init() { await this.loadData(); + this.loadAvatarImage(); + } + + // 加载头像图片 + loadAvatarImage() { + let avatarUrl = this.main.userManager.avatarUrl; + if (!avatarUrl) return; + + // 如果是相对路径,拼接完整 URL + if (avatarUrl.startsWith('/uploads')) { + avatarUrl = 'http://172.20.10.8:8000' + avatarUrl; + } + + if (avatarUrl.startsWith('http')) { + this.avatarImage = wx.createImage(); + this.avatarImage.onload = () => { + this.avatarImageLoaded = true; + }; + this.avatarImage.onerror = () => { + this.avatarImageLoaded = false; + }; + this.avatarImage.src = avatarUrl; + } } async loadData() { @@ -155,18 +182,42 @@ export default class ProfileScene extends BaseScene { const avatarSize = 50; const avatarX = 30; const avatarY = cardY + 18; - const avatarGradient = ctx.createLinearGradient(avatarX, avatarY, avatarX + avatarSize, avatarY + avatarSize); - avatarGradient.addColorStop(0, '#ff6b6b'); - avatarGradient.addColorStop(1, '#ffd700'); - ctx.fillStyle = avatarGradient; - ctx.beginPath(); - ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); - ctx.fill(); + + // 保存头像区域用于点击检测 + this.avatarRect = { x: avatarX, y: avatarY, width: avatarSize, height: avatarSize }; + + // 如果有头像图片则绘制图片,否则绘制默认渐变头像 + if (this.avatarImage && this.avatarImageLoaded) { + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(this.avatarImage, avatarX, avatarY, avatarSize, avatarSize); + ctx.restore(); + } else { + const avatarGradient = ctx.createLinearGradient(avatarX, avatarY, avatarX + avatarSize, avatarY + avatarSize); + avatarGradient.addColorStop(0, '#ff6b6b'); + avatarGradient.addColorStop(1, '#ffd700'); + ctx.fillStyle = avatarGradient; + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 20px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(user.nickname ? user.nickname[0] : '游', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 7); + } + + // 编辑图标(头像右下角) + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.beginPath(); + ctx.arc(avatarX + avatarSize - 8, avatarY + avatarSize - 8, 10, 0, Math.PI * 2); + ctx.fill(); ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 20px sans-serif'; + ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(user.nickname ? user.nickname[0] : '游', avatarX + avatarSize / 2, avatarY + avatarSize / 2 + 7); + ctx.fillText('✎', avatarX + avatarSize - 8, avatarY + avatarSize - 5); // 昵称和ID ctx.textAlign = 'left'; @@ -713,6 +764,21 @@ export default class ProfileScene extends BaseScene { return; } + // 设置按钮(右上角) + if (y < 50 && x > this.screenWidth - 50) { + this.showSettingsMenu(); + return; + } + + // 头像点击 + if (this.avatarRect) { + const rect = this.avatarRect; + if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) { + this.showAvatarOptions(); + return; + } + } + // Tab切换 if (this.tabRects) { for (const rect of this.tabRects) { @@ -946,4 +1012,196 @@ export default class ProfileScene extends BaseScene { } }); } + + // 显示设置菜单 + showSettingsMenu() { + wx.showActionSheet({ + itemList: ['修改头像', '修改昵称', '退出登录'], + success: (res) => { + switch (res.tapIndex) { + case 0: + this.chooseAndUploadAvatar(); + break; + case 1: + this.showEditNicknameDialog(); + break; + case 2: + this.confirmLogout(); + break; + } + } + }); + } + + // 显示头像选项 + showAvatarOptions() { + wx.showActionSheet({ + itemList: ['从相册选择', '取消'], + success: (res) => { + if (res.tapIndex === 0) { + this.chooseAndUploadAvatar(); + } + } + }); + } + + // 选择并上传头像 + chooseAndUploadAvatar() { + console.log('[ProfileScene] 开始选择头像'); + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'], + success: async (res) => { + console.log('[ProfileScene] 选择图片成功:', res); + const tempFilePath = res.tempFiles[0].tempFilePath; + + wx.showLoading({ title: '上传中...' }); + + try { + // 上传图片到服务器 + const uploadRes = await this.uploadAvatar(tempFilePath); + console.log('[ProfileScene] 上传结果:', uploadRes); + + if (uploadRes && uploadRes.url) { + // 更新用户头像 + const success = await this.main.userManager.updateProfile( + this.main.userManager.nickname, + uploadRes.url + ); + + wx.hideLoading(); + + if (success) { + // 重新加载头像 + this.loadAvatarImage(); + wx.showToast({ title: '头像更新成功', icon: 'success' }); + } else { + wx.showToast({ title: '更新失败', icon: 'none' }); + } + } else { + wx.hideLoading(); + wx.showToast({ title: '上传失败', icon: 'none' }); + } + } catch (error) { + wx.hideLoading(); + console.error('[ProfileScene] 上传头像失败:', error); + wx.showToast({ title: '上传失败', icon: 'none' }); + } + }, + fail: (err) => { + console.error('[ProfileScene] 选择图片失败:', err); + if (err.errMsg && err.errMsg.indexOf('cancel') === -1) { + wx.showToast({ title: '选择图片失败', icon: 'none' }); + } + } + }); + } + + // 上传头像到服务器 + uploadAvatar(filePath) { + return new Promise((resolve, reject) => { + const token = this.main.userManager.token || ''; + const baseUrl = 'http://172.20.10.8:8000'; // 与 http.js 保持一致 + + wx.uploadFile({ + url: `${baseUrl}/api/upload/avatar`, + filePath: filePath, + name: 'file', + header: { + 'Authorization': token ? `Bearer ${token}` : '' + }, + success: (res) => { + try { + const data = JSON.parse(res.data); + if (data.code === 0) { + resolve(data.data); + } else { + reject(new Error(data.message || '上传失败')); + } + } catch (e) { + reject(e); + } + }, + fail: reject + }); + }); + } + + // 修改昵称弹窗 + showEditNicknameDialog() { + console.log('[ProfileScene] 显示修改昵称弹窗'); + const currentNickname = this.main.userManager.nickname || ''; + + // 微信小游戏使用 wx.showModal 的 editable 参数 + wx.showModal({ + title: '修改昵称', + editable: true, + placeholderText: '请输入新昵称', + success: async (res) => { + console.log('[ProfileScene] showModal 回调:', res); + if (res.confirm) { + const newNickname = (res.content || '').trim(); + console.log('[ProfileScene] 新昵称:', newNickname); + + if (!newNickname) { + wx.showToast({ title: '昵称不能为空', icon: 'none' }); + return; + } + + if (newNickname === currentNickname) { + wx.showToast({ title: '昵称未变更', icon: 'none' }); + return; + } + + wx.showLoading({ title: '保存中...' }); + try { + const success = await this.main.userManager.updateProfile( + newNickname, + this.main.userManager.avatarUrl || '' + ); + wx.hideLoading(); + + if (success) { + wx.showToast({ title: '修改成功', icon: 'success' }); + } else { + wx.showToast({ title: '修改失败', icon: 'none' }); + } + } catch (e) { + wx.hideLoading(); + console.error('[ProfileScene] 修改昵称失败:', e); + wx.showToast({ title: '修改失败', icon: 'none' }); + } + } + }, + fail: (err) => { + console.error('[ProfileScene] showModal 失败:', err); + } + }); + } + + // 确认退出登录 + confirmLogout() { + wx.showModal({ + title: '确认退出', + content: '退出登录后需要重新授权登录,确定退出吗?', + confirmText: '退出', + confirmColor: '#e74c3c', + success: (res) => { + if (res.confirm) { + // 执行退出登录 + this.main.userManager.logout(); + + // 停止草稿检查 + this.main.stopDraftChecker(); + + // 跳转到登录页 + this.main.sceneManager.switchScene('login'); + + wx.showToast({ title: '已退出登录', icon: 'success' }); + } + } + }); + } } diff --git a/client/js/scenes/SceneManager.js b/client/js/scenes/SceneManager.js index 9edb636..e16cda1 100644 --- a/client/js/scenes/SceneManager.js +++ b/client/js/scenes/SceneManager.js @@ -7,6 +7,7 @@ import EndingScene from './EndingScene'; import ProfileScene from './ProfileScene'; import ChapterScene from './ChapterScene'; import AICreateScene from './AICreateScene'; +import LoginScene from './LoginScene'; export default class SceneManager { constructor(main) { @@ -18,7 +19,8 @@ export default class SceneManager { ending: EndingScene, profile: ProfileScene, chapter: ChapterScene, - aiCreate: AICreateScene + aiCreate: AICreateScene, + login: LoginScene }; } diff --git a/client/js/utils/http.js b/client/js/utils/http.js index 1bce1d3..27c4cd1 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -9,7 +9,7 @@ const ENV = 'cloud'; // 'local' = 本地后端, 'cloud' = 微信云托管 const CONFIG = { local: { - baseUrl: 'http://localhost:8000/api' + baseUrl: 'http://172.20.10.8:8000/api' // 局域网IP,真机测试用 }, cloud: { env: 'prod-6gjx1rd4c40f5884', @@ -17,6 +17,18 @@ const CONFIG = { } }; +/** + * 获取存储的 Token + */ +function getToken() { + try { + const userInfo = wx.getStorageSync('userInfo'); + return userInfo?.token || ''; + } catch (e) { + return ''; + } +} + /** * 发送HTTP请求 */ @@ -33,16 +45,43 @@ export function request(options) { */ function requestLocal(options) { return new Promise((resolve, reject) => { + const timeoutMs = options.timeout || 30000; + + // 自动添加 Token 到请求头 + const token = getToken(); + const header = { + 'Content-Type': 'application/json', + ...options.header + }; + + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + + // 处理 URL 查询参数 + let url = CONFIG.local.baseUrl + options.url; + if (options.params) { + const queryString = Object.entries(options.params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + url += (url.includes('?') ? '&' : '?') + queryString; + } + wx.request({ - url: CONFIG.local.baseUrl + options.url, + url, method: options.method || 'GET', data: options.data || {}, - timeout: options.timeout || 30000, - header: { - 'Content-Type': 'application/json', - ...options.header - }, + timeout: timeoutMs, + header, success(res) { + // 处理 401 未授权错误 + if (res.statusCode === 401) { + // Token 过期或无效,清除本地存储 + wx.removeStorageSync('userInfo'); + reject(new Error('登录已过期,请重新登录')); + return; + } + if (res.data && res.data.code === 0) { resolve(res.data.data); } else { @@ -62,19 +101,43 @@ function requestLocal(options) { */ function requestCloud(options) { return new Promise((resolve, reject) => { + // 自动添加 Token 到请求头 + const token = getToken(); + const header = { + 'X-WX-SERVICE': CONFIG.cloud.serviceName, + 'Content-Type': 'application/json', + ...options.header + }; + + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + + // 处理 URL 查询参数 + let path = '/api' + options.url; + if (options.params) { + const queryString = Object.entries(options.params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + path += (path.includes('?') ? '&' : '?') + queryString; + } + wx.cloud.callContainer({ config: { env: CONFIG.cloud.env }, - path: '/api' + options.url, + path, method: options.method || 'GET', data: options.data || {}, - header: { - 'X-WX-SERVICE': CONFIG.cloud.serviceName, - 'Content-Type': 'application/json', - ...options.header - }, + header, success(res) { + // 处理 401 未授权错误 + if (res.statusCode === 401) { + wx.removeStorageSync('userInfo'); + reject(new Error('登录已过期,请重新登录')); + return; + } + if (res.data && res.data.code === 0) { resolve(res.data.data); } else if (res.data) { diff --git a/server/app/config.py b/server/app/config.py index 752c75f..66184a3 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -36,6 +36,13 @@ class Settings(BaseSettings): wx_appid: str = "" wx_secret: str = "" + # JWT 配置 + jwt_secret_key: str = "your-super-secret-key-change-in-production" + jwt_expire_hours: int = 168 # 7天 + + # 文件上传配置 + upload_path: str = "./uploads" + @property def database_url(self) -> str: return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" diff --git a/server/app/main.py b/server/app/main.py index f3c95ea..c237ab6 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,12 +1,14 @@ """ 星域故事汇 - Python后端服务 """ +import os import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.config import get_settings -from app.routers import story, user, drafts +from app.routers import story, user, drafts, upload settings = get_settings() @@ -30,6 +32,11 @@ app.add_middleware( app.include_router(story.router, prefix="/api/stories", tags=["故事"]) app.include_router(user.router, prefix="/api/user", tags=["用户"]) app.include_router(drafts.router, prefix="/api", tags=["草稿箱"]) +app.include_router(upload.router, prefix="/api", tags=["上传"]) + +# 静态文件服务(用于访问上传的图片) +os.makedirs(settings.upload_path, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads") @app.get("/") diff --git a/server/app/models/__pycache__/story.cpython-310.pyc b/server/app/models/__pycache__/story.cpython-310.pyc index ef618f18449c93c39982f5dddca5370a8b20a471..b1f2f3781ad0f72cabfea0924c7f4f0312456699 100644 GIT binary patch delta 20 acmaDY^IC>GpO=@50SGi+tlP+)zy|<5j|G(g delta 20 acmaDY^IC>GpO=@50SMMlSi6xsfe!#bD+R*< diff --git a/server/app/models/__pycache__/user.cpython-310.pyc b/server/app/models/__pycache__/user.cpython-310.pyc index 3e9a23150e4b0a5807e46799d2c924b90c18eb24..6eef585d7761710eb08b8e75bc5868915ede339e 100644 GIT binary patch delta 1314 zcma)+-%Aux6vyW}yX)-i%s8v-TB2q7t2Gt02aQyEDk@q_gfN--2}`;ux(Gc+Px7O0;F<^>wCwK>tK z0>6?<;7i=s!4Faye3|=U@IzDqU*Uc=RY!%Gmal`eowK$T^${cd;TkFYR_y`lQ<#AU8`c1gj`K*4z$8**6Q|&eMEILVk=QMmc$N1 zH1~_It*-{6KV>JEVF}ymPSD+681Vc~&SE)Mi%Y|Z2w<0>bqrDOHu`%88qjP+oIspJ zG$ASwBH|RH648Ue&6yA2xexreheGI{Ml>T@5E8)COfzSVCvDSWla6V!at#>fRvTLF zZd;&>w7X-0VnM@!Gl&?X18|9TqS=MuCGhNdMZAQ)e7umIrit{VnYL24q2;H|ykj%7 zJjl+$`gknD4z0Ctc(Lp}Y~m?@j&UY3%w_@1Yp;GIlusu!6Vo;|tsK7s)`t@gs=FTo zSd|k6NP>^}h}$1fS~OAm#V|Ml-@XUWx(J{1+8rb{=S8)wE_#yhDsAjetyRJ^4n`njNvBJetRA zD{XCUKYFvX_OAGGX=iz*sjKPx=NHAdPfP2M3#HZNa(MgMd~t1|wDf*wbG`U!-cYiz ziOKjb&KNsFV|P)TT%cvW?y1z!5_G2AOw{-6KMBSQvAV-MdX$F#)lF_TYqKCE;VD@w zH)T#G_;H5HOQCsY~k|2~!W~Qy;Q3%L5n(K@pU|E6@fQ7y=G>3*Lc@CQ=vK?Sh9m3`W4HD%t(r zPvwx!G={07*6cS_K}+?^zIo5_avV&6NzF(38sii&l}%f715^3weD&KIS)I{q)~j9e z1^wlsdUSdh|6d>qPL9(0-eDqk;-+*_VjetqnFaOPEqe>5L_wC+l|P$CVdG00r+fQJ z?d!B2C3e2!@$vDrh@0_l+(;z;85w{Izy{Clf9EdO>f$6#R`3NHp(bOEF;1KctdQ^O F{s*?zavcBw diff --git a/server/app/routers/__pycache__/drafts.cpython-310.pyc b/server/app/routers/__pycache__/drafts.cpython-310.pyc index ce96bfa7dd9ffe672e30c60654eb8974afd43020..013d0ff21d9e88a13ac4aba20df901de1f63ee94 100644 GIT binary patch delta 5994 zcmc&&YmijM6~2Av&Yjnt$IkAu?>7rO%RWF{Fo=ftE3hE)&{Z6V?Yl4o%q%^3B;Xwf zT~MirK-#wucMX(oQU(cHF=Eh&u_`G`ld4pu>i$ToEEhDDDt{RMNC`Qo@7)K8uV2}z z``y#sr_br`)BW|i`?p&LdqbIE&@aK?m6uK?|Fil?s9NrqkF@=@uvDTYv~-_DOZD)8 ztEU#qlV}-~%S5@3mh1I>GOeJM`;?vrr&L9&q15P{174%xJ zay(717fu!0M%#r>Oz3n>u;u6!`V`tprwgT4p)_NH63pE(bOW6!bf$q$fL^OJ>QJRO z(k`Lb272vu)^%;orZ)+l4$zs}PJc8(Y1EKH=g^ylQYW2zyA-?SbcuY2tMJ7fh@dX@ z5GwV^>m^EhT)F})EYFq1+XZhG%8K)LJ8yNaL_J`}Yn3l#dcx2br)e_1Wf4pC8Ef^O zIX!FSqG0;M89s7q@A)TwKl1RipPu~P`BM+E2B>C@0G3~0BzkoxcIZ}67xKbP+R)Pm zn*s{m!yZ zfwe#jjJOhgB-6QXAM=GXuUu!$tQBmqX$WG3tPQ90|_@IjaD8 zB`;xf_}|smz)jd5M3{p;RW=>uEo{jc4_2HknDs`x3oI zhV35aRsKeDk>Bb+1o)x3^VhXv zvV{Lx+g-l{?DfKY#{Nn`3czy?o@|SYXFSzwDWS@CCGQe%cV02cfOMm@T%yV{=`eXN znRg#1dqSprP&GV;cYqA~3~$PBO55ZiGDK{>ylSe0fgvgHF+JG@e3iHSUPDU-O-~#< zhKC$;L~_hTgqaAK@uZ5bGUPSA!jRAOWe31U6m3v9V6jm$SUMzuGzo3;h2ExuCso$} zNK(rCP5)pyRjFr4&Ig3-r9O}~Q%+TwzEmYVRsBb)-wc=<_^hUZgKlT&K{I%munIE> z7PKK^%T%k7@*y+uyhMY?J5DyiBbS3Y7!*gp!UYp~9Y4Au`xLnL2h z7J-Sd83xB;A!}v`^ zEKnq4>S3}wL!^9^NPD`B7Fupb!IC^c2rwFKm{`8pES^5tIs~KKQ#ESZm}W)?pqWd0h~Yjs|zoja8w12EX4K&0K;y!2<629R={S%cp9lO

@xZd`_HWdJ$k zy3gNwbpG=5PZW@1KY#1Mp0>O1zw4g+^P5^5C)j9osvHwe@$Nk92Max;4jeAXsZe?T z=?5=7`-gRw$El3Dg-gqm?bj3AbY}UI*|_aN7%7a6+1TB$vz1`tOVZA|;hmcda92Oe z02O89f&^^NvAb`oA9?GckvHC$Q_og|Vyum=ftO7(x1lr-z*17#Oxlu}&emd`2cZbz zc7z#H0dO6Pcusf$*xo4<0U<=&o2(5zK(Z(*5SzZG6_`|9pRdT)m6iy6x>D>UCQSaNd??no@N zI9j?FA%TDk#Wo}KB2WY!p&B4o!senpM!0Mj7JrJ+hp+`RyJ9~%uadG225(dtzKf$s(W!q6*ko5G5Z;ahRP|6+$koRh`CT`rQ ztZ++fDriI_iVN>y8GZ`UE=9dw;wQ2z0;LeNhKL{ffY#n9)W}!7iY%k9{ZlTfS5#k! zln}41f@YLN`SB+B>clLkKG~<@wBJS zU7L69d5^!~`Je!7j2u46VPl_=Z8r7;8w2;G%HgT#=fK83({H*{l@r)FUXbVyxRw&hoQdqE_J6;QRYs!K4BBl?p=$Ha3DB{5IdrfcAhUhz#C> z$mV>F$Vvr~l>w1q=JQ>2#?r8`b|y4NVJw4n%Mn%p^!Sl(Fw=_9u=n|WP%GHQ_F=O_ zGgMAsheCX;*Y){f^7Z)P8(0BG@Su@H#=FQL56vd?`KFR;{xL|8^U83myn^s0;Tz_zffx5lV%r|DZSTHswJef+0vZKq?x~d7u=~i&e1KdK zpxsuryA8|ZUqF`XT#!FUeb@z3ZVEpet|^><>Q%c>M@I*H5*wceh?R;@CZ=G;_XwuA z-0e7Gk5&YcF^LQQIV?VpATIc$C>=u>km_K&soxw|5z<-3PDt%3*pb}^bNJ#$Fe2|& zJsN3rd5HoSlnj@c0@n>l{NIu4)xQLdJ(14Q)7JhSTY$X)`VfSPF_=iP7=w)>206U5 z;4AslvwUPfB#B7F=O26IlQYM^Eiew14nkixgfJeCCJ0A)!M5|{{!iZg+5aOTs^E+l z@gTKSJF7mrcB4s%I9SiX^0VVOgA=DcMa87B2;^?Du|9()iDAsY3^m``-n^r|nbtQi zo723C;pG$aK>`R0f(dRwNze<1(oOmA*sO0agPb#y zOxt`V>Q3^hP~ag zeR|%S&rjdjfn%=}dWq@E1$l$eX7>D$SBkB!!D%fKLw| zxbX7ZpS*E!Fj#UI>n$c&^?-t>GCT0WJqk$?clwH1~A|g!dP|f1VtE!QK_&IQ49)aZMplB zENj>VbP}5cb%;TV^nMA}J%O+kL4^2TloALl5ybF*gOd1$_%%weB8W|(+SmH%CJ0LG zx7fB7A%)Q|j*LY=PIV)Z-0qS2>*Z;3mQSu23<%;qgW3o9M-|oUpF!~{fSCWPuHKi-xzVV;U5Eerh)voh^Ualwmb6Iw2UwLo% OT20&6Yxw=O8~z9FOvow# delta 2053 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 jsWGmo1Re)!J89Eu-D@$((x z3cav%a1GsZU!bcfH;Zx?JLFm}C7Q=5^b%b|`W(&NbmT6J^U7_ah$l)){8||&F7b&n zDhAv?NyKFl@qh$Nq2-df=itMzGA#b!KHDFYL-;d168sJyi4z_ZANo?_yl3-lKM)51 z1_2TPNq|uamaAMfo4Q~PwWeT{79V^1NgCDkd4F)BOHBqis0I~If-YrU*EN2$rN=fA zrl@gD{MDNzW2kY`w^Yo+&Nv8q%f{96T}H3%dH8eB$vsc+wA`4Ge}DH$0QY^Qm+)zM zPMRDOANdCIhbINf#syu|Sjm*9@#D`^IVU$jEuRIL+dt_iQcoN-WK#f*}wN$q--6?I;$%Ktnr{uaniqUw_%GzxGE;IxFCho6A)0>GkJ>P-)H z-i+ne^v!a0QB-@Eq8B9Vfp4zH^^LN|W_eaT?fqe(O}BV0m~PmXR+wR^n=I$&FMw`{ zKN`$mgi&6C<+-P7b!N2t4SOpRaxUAhRaijdy37jIYcepb*tF&Al4n36INzR7Tydh< zzyI?8JLL5fep&83=mEnwxwPvVAK%Y}o{&ghu5&!&3XHCb--g+G7>3cS0NIs zoa@}aTC^bwTW+&#YD`{(y3RB-!BW1hm#Z8t&P$3N(N%FOIywlkwp^;F<$P^^EeI`| zJbn)L6lt2l&w^N2M0+=x3E{*DZHS*o&ydpoC(%EV8(l7gqd9k*`p$iMb*+e60am5u z>SnR3R#=W;-pPNbMJ7JfxCnZN?EHsPw-W_38{Nl5X{q8%K*2Q1!)Og)0s#7p-C4jg z{t`e0fB`^}*!kWgN0z&!*Q=%#Fqn86kEJlz{B1RGc-}ejm-u3K1H`)`6*Sa)n0Xkg z?~9fGZE{!qvi~13$cA?R`K#i?ffKf;;s43g8L$Ig1CwPhJS^KblYvwQ=&(5KumYyO zxv8M-@Nv~R)2w*YQSu)KDgHF#0Z4k}>*wz0&#hN<(i>}y?B zHh_H~3}x2zwX9c-A@S)@hTIoNhJUYoQR)!y=Y~XIYQ=UNtzqI;Dzlu21ME4BwgDah zz$4popM945YvO~{n(e+H8xDtlX?Gf|ltX{<0PNWJfPx-&v=Uf-4L}6|hYMc2$e(c- z{f&da^{S=^OY#*jHmM6@H$6?>*#98CKpJ__gcr-JyafQUvIP(FWsAo8~y`}TJpB*u5#Fg#hmK!KH#nsU)c_?b5w_kjY! z>Swy$Twc++oCP-F1{$<7FC#ENo zp6^LoZeA4UN)5|nZr9{HYJcYYYTHz;n&lGjl@zO2t<`4XD{);l7!Sfy7?vtJWtuTt zmy7l*h>?YQw<2x(XfWQg<>5MfT=JOY@HD^}z;pv}PJ*S>bi?HE9ccHF%P?97z{)DY z=q^ANpayUs;0=I>0FMFQ0oVlqC);9yQNauHEld0B8e5T}oOBr9kvzqbaP^Xe{DPOi a&bUO6GonP?5l_Sy@%VfqaAL!GjQj^J>TpK@ diff --git a/server/app/routers/upload.py b/server/app/routers/upload.py new file mode 100644 index 0000000..25ca7ca --- /dev/null +++ b/server/app/routers/upload.py @@ -0,0 +1,108 @@ +""" +文件上传路由 +""" +import os +import uuid +from datetime import datetime +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException +from ..config import get_settings +from ..utils.jwt_utils import get_current_user_id + +router = APIRouter(prefix="/upload", tags=["上传"]) + +# 允许的图片格式 +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +# 最大文件大小 (5MB) +MAX_FILE_SIZE = 5 * 1024 * 1024 + +def allowed_file(filename: str) -> bool: + """检查文件扩展名是否允许""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@router.post("/avatar") +async def upload_avatar( + file: UploadFile = File(...), + user_id: int = Depends(get_current_user_id) +): + """上传用户头像""" + # 检查文件类型 + if not file.filename or not allowed_file(file.filename): + raise HTTPException(status_code=400, detail="不支持的文件格式") + + # 读取文件内容 + content = await file.read() + + # 检查文件大小 + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)") + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f"{user_id}_{uuid.uuid4().hex[:8]}.{ext}" + + # 创建上传目录 + settings = get_settings() + upload_dir = os.path.join(settings.upload_path, "avatars") + os.makedirs(upload_dir, exist_ok=True) + + # 保存文件 + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(content) + + # 返回访问URL + # 使用相对路径,前端拼接 baseUrl + avatar_url = f"/uploads/avatars/{filename}" + + return { + "code": 0, + "data": { + "url": avatar_url, + "filename": filename + }, + "message": "上传成功" + } + +@router.post("/image") +async def upload_image( + file: UploadFile = File(...), + user_id: int = Depends(get_current_user_id) +): + """上传通用图片""" + # 检查文件类型 + if not file.filename or not allowed_file(file.filename): + raise HTTPException(status_code=400, detail="不支持的文件格式") + + # 读取文件内容 + content = await file.read() + + # 检查文件大小 + if len(content) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)") + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + date_str = datetime.now().strftime("%Y%m%d") + filename = f"{date_str}_{uuid.uuid4().hex[:8]}.{ext}" + + # 创建上传目录 + settings = get_settings() + upload_dir = os.path.join(settings.upload_path, "images") + os.makedirs(upload_dir, exist_ok=True) + + # 保存文件 + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(content) + + # 返回访问URL + image_url = f"/uploads/images/{filename}" + + return { + "code": 0, + "data": { + "url": image_url, + "filename": filename + }, + "message": "上传成功" + } diff --git a/server/app/routers/user.py b/server/app/routers/user.py index da8ce0c..d1b206f 100644 --- a/server/app/routers/user.py +++ b/server/app/routers/user.py @@ -6,10 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, func, text, delete from typing import Optional from pydantic import BaseModel +import httpx from app.database import get_db from app.models.user import User, UserProgress, UserEnding, PlayRecord from app.models.story import Story +from app.config import get_settings +from app.utils.jwt_utils import create_token, get_current_user_id, get_optional_user_id router = APIRouter() @@ -59,9 +62,28 @@ class PlayRecordRequest(BaseModel): @router.post("/login") async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): """微信登录""" - # 实际部署时需要调用微信API获取openid - # 这里简化处理:用code作为openid - openid = request.code + settings = get_settings() + + # 调用微信API获取openid + if settings.wx_appid and settings.wx_secret: + try: + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={settings.wx_appid}&secret={settings.wx_secret}&js_code={request.code}&grant_type=authorization_code" + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10.0) + data = resp.json() + + if "errcode" in data and data["errcode"] != 0: + # 微信API返回错误,使用code作为openid(开发模式) + print(f"[Login] 微信API错误: {data}") + openid = request.code + else: + openid = data.get("openid", request.code) + except Exception as e: + print(f"[Login] 调用微信API失败: {e}") + openid = request.code + else: + # 未配置微信密钥,开发模式:用code作为openid + openid = request.code # 查找或创建用户 result = await db.execute(select(User).where(User.openid == openid)) @@ -79,6 +101,55 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): await db.commit() await db.refresh(user) + # 生成 JWT Token + token = create_token(user.id, user.openid) + + return { + "code": 0, + "data": { + "userId": user.id, + "openid": user.openid, + "nickname": user.nickname, + "avatarUrl": user.avatar_url, + "gender": user.gender, + "total_play_count": user.total_play_count, + "total_endings": user.total_endings, + "token": token + } + } + + +@router.post("/refresh-token") +async def refresh_token(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)): + """刷新 Token""" + # 查找用户 + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 生成新 Token + new_token = create_token(user.id, user.openid) + + return { + "code": 0, + "data": { + "token": new_token, + "userId": user.id + } + } + + +@router.get("/me") +async def get_current_user(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)): + """获取当前用户信息(通过 Token 验证)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + return { "code": 0, "data": { diff --git a/server/app/services/__pycache__/ai.cpython-310.pyc b/server/app/services/__pycache__/ai.cpython-310.pyc index 800729bce9e13ef6dc2d1c7840f55b797dbf9566..b4ea878779e076a7d7fdb83d3c0332e540ac828b 100644 GIT binary patch delta 4013 zcmb7GeNa@_6@T~b+hzAH%g6E|%a;p^x)>KR60M3#MI#!`j3m)e2|?bP5^=GY-Kb>A zq6nfGz+6aGMA(T5(HVneGk#(8kIuByNoP!_{flO%$;jJXo0+DaX{Qr3Y0r6YSw39b z`k4K_d(ZEjbMN`M@A8LJWMY`a)tgKr2hXC=C$*0nN8(}#X=ka#!}7_3+IT79m{4Jq z5*>o1cW50t3%6I0%u*6aW2NL7(iABbq$X(&NMoikIw?)E023F5NtZH!iI?Uu6L0Z8 zZka931wK)-PV*;7IZ`f2%~AZ#ljZ}H6otu?766khEiC73i^dEkW#ta~R&9-=hG6i@ zPMDQO>19Ug8_ z>(O~)e0&)=-pn=Wol6xq8Q4R>>O4AdlH!ShEopotqLrm+rGnOR(2AMmBTixG0NV?! zUSYkFC15vc9=&96W1LDxJbL=IM?YwgEIvZN20q;-s&gsB2O1GQZqNvp77OPiO$N}* z^cZING0?9RyDTZ&#{-wO2D3dEycxXU-dfAeaA(-bm7_uq=4PjoT7h%Lc9X+K!ZnGI zuS{TbT_%s%Lf(vnOw99$lf{v;B|}|&lhI=wOz?4N|0;NB65kP(5-FpD+2AOXN1DI8eQJEPQyt5gnMsSFNkgQA}5YWn&a2)kT%F zdcUJM8t-m!(5eHpe*Xbiabx=9i#VLsd+HogMlt;ftl(Y?Fk6Y8OeoN}H;K%tpU3hO zQ@rx^PlKafa{JZLojZZv*6~{%5O83id;G@bNNR$eSLO2`2U`50kB&f^LU+$b*x=|8 zWJ)!ZyH3j^z4x1sisJp|?(xx4`K0fD^Jy_Oe0`Rcnqo-0>}~HG|NMMpVR`f#xC0lE z_;A+mwm$HU20t4Lbbc7FPg|?}$xWqVcgH{P3$K0iTHsS(;QR^P=#`708R#DXolx_s zz?or?kKbww7jwog*)DTYLFYguRJo-K^wsJ3%?`P{Gj#lN@YpqoE!Z;vseuHso91p| zw{y3XZB4qOf&H?FNgrsyJG+p)O?iKt{129x`W(ABr&5eGNFnL(d? zFKzSGB+IhMILTBkt41TX&*GX=kS(QClY;c6QD$hnYG*PN^q*N8H}?S%d#`AHOm`uQkW|?QEV`}7+WnLK&d*k z6E;$NH>65+iX9Mk-~DcI=<0X3&$$y&efpXpthH*uQO2aBV9&>a_Umf40;i6Tk6r{v z3q~PSacEE&#eiMP&6!Kr14Hq0b1#Ud^S9hoFD*fOUKG00 zx~2TZ?V+I$0vEdFizA`WKFo2aN7a*WAC+(1SY@RffVL&l6nIfH!bXHS2+0VW5$p(M z2=J-&a`br|mLg!%DMmwIK-h+`9pOa)TP(HWI1d5Cp_nMT2*HN14B=UX9SG$J%Mlb8 zxj0e)V;zeRx0rYGi<$yyYLI~9oAt(I5(j^oB$n3`fe0FYEl-4h^StmQuN5AOI$?r0 z=>MfRfrN-88Gf1Jx0#svI6j@kY0O066G%GI5k0R{MtY(Luli>GQKMz@e~%=GJ<{yz ziJ`0gujQQ(NGS{1ULmirb@|IvurDf1HFb_^XBDbDoG!Y9wde29V#?03Kj$|Q2YW3q z%ip*-M2uKcwd@7Vw^2Xorf zw3v~5{to+6fvje?m;F_TChmo7WVwa?_Su&~ICuFL^G`u|iC5ZJ={a|yqR_Ma9NEAM ziqavNEk(tNXgenCa~y6^i@uAUDOytzt=1-WCrU}NxMLbOt)m{rUaEFgkLhaaYB38Z zL8R6jb2O%N)$emS8^U#G)ul;VypJdRo1PmXwRNbxAHj)GkMIiwir@kWo;!=rgLMzS zi~*4wMeu`X`+9m9U-WH2+Jt}yi@t+^Yf~>mGr|$}`pT5_791W!Xhk@Vpd@@3>spyI z{}j$KBKj^uC&J8VXcq>c!$VWZ`@dOvkCe^Y{99-=GXix(r;&IMz@`~9QAdNj&Q-Oy zp6;)9(St}GV&AXI-pg<}71fxo-t9Wzc1??Jc3@y|$)FzS3^aaXjL##x3!xW-5b?af zz{3$+%D!Bm#jda3s_zDoE!CaCe!n8gOaFIms1npO=yO_K);>*L(6$Gm7h&2bfgLCn z*x(vLR|k8f@B1&W*+Gbt{ZPEZi*2Dah)qM4m=B>q$@~QzP32E*1EniEkimwiFsV7M z3OD8vB>DktqT1pEDEKeEU;tXFg3=6HJE$M`|8Z>%@%|DmpQa&fJUxRpN(Y?9(d+=u zqj0LXqf}qnyOKU8l_qElz+k}t#rhJy`l$=}*y>XYM?X~iHQGa zJ>?=gfk7!Js|`mTEW0%Q|2uv70mRGt4m(`x)TGdD>@TGyb6-L)FC!@T6@DS{CJWzg zWo+ZdBJy+g_Qv9DekITIKk^#>2VUSG@ml^N+yoPGG2-`}H&w@OZ7en_ktkn<3dV0* Xk(maL1TIa$y8>{*Q1u54hvD?sO{;o~-g;V1 zHtB5}vODyhlx@~Mlx?U|oVr){k>b)l;tun7-miD)ous=-?@|1HyQ{{k`h$8mX{|M8 z?a}v;(nh0Z6~E)*$vW*3>%21VVvFK;_gAcFEw@~8)9qOIB4dlzFFn61EGjNObV=d; z;znSX_1BYGl?6ri;1qjKTs*kn(@)luu1wZQ=Tez`&NOl+kBR2MgdyWK?nqH0YPo}tsjOcF z58Y)6v3IyY@%iCs+aQT+v_uRF5Q=>wJ>nPbBdc9Q^jGt74Y>idUD_ zS!1EVpGJ04bcF-SXAnk!DDW&nbJ?rfhlEBb*B5z~zm7=N(`LHt%w%Wa|6M#01F)j9 z)6CBrxdPvhOjNMZ9gYzSi`M1v1y*_q*_VMC;1wVX%mI1A`sz7cw})>3)Ih0ur07Ri zEXYmV_-jP|2Ec#?K<>sOqBnsPgtcw<9!UFgBTi00D*T z<<83naP-pSigjc>!%AOZg8z=7hfk1Ft50l@F|T){{~kngR1}l diff --git a/server/app/utils/__init__.py b/server/app/utils/__init__.py new file mode 100644 index 0000000..732203f --- /dev/null +++ b/server/app/utils/__init__.py @@ -0,0 +1,6 @@ +""" +工具模块 +""" +from .jwt_utils import create_token, verify_token, get_current_user_id, get_optional_user_id + +__all__ = ["create_token", "verify_token", "get_current_user_id", "get_optional_user_id"] diff --git a/server/app/utils/__pycache__/__init__.cpython-312.pyc b/server/app/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ea2ad0fb791fac2345f372fa29aa36af04bfff8 GIT binary patch literal 347 zcmXv}El^6quJzPb~E{Syqn#Qqfd&`&~N*RRl+H7#f8e-{-v_| zyjie~R5?g$YgO)`u9O8W+s#faN~eoln5B9$4BHSu770MRI=Vu;)26Z>F0p=|N*os&QjsZ+3-KH-%98(2;{ZC>4q&fr+LgnskhM`q ic7Q{=XR0lPFOO3C%^3B6dW4?QDdBI?>2+w=u=xi}7iV+; literal 0 HcmV?d00001 diff --git a/server/app/utils/__pycache__/jwt_utils.cpython-312.pyc b/server/app/utils/__pycache__/jwt_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f501a016d769e31d31001e2e2b5051fb1475df7 GIT binary patch literal 3065 zcma)8TWl2989sB}*}HA*Vjv-7Y@{qzNlilur6qEOi7|*ofrhfRx*2xHW}LlPXJ%uZ ztQ!Y`Sf>ONDPXs_$yN=KiCx7>RqWW1v`peTbAFB?^L&U83x0w2g^(Dw`E6mzFNN)XJDn3lj(1 zzejb_vA2^~T`;aL35Ec;9m{? zHGQnFCRPIzj`mY0<=oWG+{LNfrMvlSla6Jw({UebNvdLMrXJQTJE>JIWGX(!lAiA| z^=L#1`8dnncJky4t>=Ooow96)v>q*@8kX3mDXNCS+tsC+0YfuQJ7 ztzdP?P;!YIx!D<+;BqqhiWZ@h@Fj(_(HJ&-JSJ4dWMhld&h=QFu9%k4c4E)o?}C?I z33WxWZk`SMBy1<1WFt$AnZZc37n84LRdx580a&VIEkJ`>9L!jv770dG&GHblp#=e& zz$;pxPh7X7RDn_~2C!^BN?$0dsFq-ow>8LhA;*yAhkFlwuR~WgMef1TZY^kbD0-kv z32Pldf>$-%q4e~0#7sS8bdVisZ+B0h<$|4oc>x*^KMwK8VU06T4WI`Ss(S3_KO1OU zbk_|91_N{M?H_EJteEkyaVdc^n@8)j2Czlu?=M&fSZ{5gUn#tWc zpPQbh>u@zh0A4yOyfl{IfiD}AVV?3kH}&b#7nky*@BiZtY%#Tb>FxaW$@{;U&E36r zi?y7FshBY%K$$73nyKia7`apV;bi_=;{Go$&>%irk?eI~vA~VEmBBCjeTU8N!DypN;U<9aY-awQFNeSpmPF|xn~wADNNO4NK3rU*R^ z3>8q*QKDvn7lp&jVolxfu6K5g{3zKr-*h0|bl~3Rd-hDtiwWC;_Ztb%zZRXId1q7F z+4N7u2tQ^PJ=?~()YC6zJf~99DQc9{PMH`XR?&DU<7rJvtp$$EJv4|ATyAPcpR}Ns zW)S0+n0XfN{%Z=+XQCKzHpQ^q%KG`Q(VEBsIpAsns$mpIhI>tJNtT=7)<_jc{Y)Hn zmxK!zREo-vmo5ty;z$)#aVz+mUy#MZ4_U@>BSTW3&7mmo;&wsV_`|J7N zjpZ)hq;}i@Tf_)m@}nQ+hpuKnzp*qko}Im$o&Dhc;AOl6EDvoz)D)~?d6~TNaVmqL zzmz%-;3YPVUQk+0cwljm=x%ZNo6s*(=s(%BiR8jy3}Y<W27CgrDPz|6J z2XbvG!F@F2IhK-+fttCuf;`!3=WS1er^ku#~PhJ=vyY#rG<=V=&t zZoyeIbYSqn)xJ4rLyB*}+ac!0fL{d*1)S*vgmM9=!tW^#$!OCP?O|Kxm8P#|9Z%FW!+Z>ZyOOg=o`O)7Gi0NbZX zpWunVO)B!TxEZRi%RnswGFti#sv!9^_#Gs^r=cn#x!~PY;3c6J;7SM!&iZ+0W7^r6 zJd$zlUZ`zIHY9hXYIi1Dvo+gCVskY+sWuF~KKS~`{yFc?l(Z96qMk@V?cX2}T>ILG zh8Ln={LX&#nY~5e{>U>>`!-p&KtPQK0|87ZO>=%R*NbLf5Bz#z0*qzrgzKO{-lyjx zq{b4NcR(iI9gIdg^)4!-@J|sjEzU48O}`ee>Or&kSqNP*Iq5A#*;Uj<>HFcR8VhL$ zF-aJNM#d;q@Wu?Y!mU6Ii<2dCA0Pi6M`SCEIMGaHt#)~ idFCf8sEVl_lO{Laau-N9_43)29!kF)en3X_?f(tv!WotT literal 0 HcmV?d00001 diff --git a/server/app/utils/jwt_utils.py b/server/app/utils/jwt_utils.py new file mode 100644 index 0000000..2bff5ce --- /dev/null +++ b/server/app/utils/jwt_utils.py @@ -0,0 +1,78 @@ +""" +JWT 工具函数 +""" +import jwt +from datetime import datetime, timedelta +from typing import Optional +from fastapi import HTTPException, Depends, Header +from app.config import get_settings + + +def create_token(user_id: int, openid: str) -> str: + """ + 创建 JWT Token + """ + settings = get_settings() + expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours) + + payload = { + "user_id": user_id, + "openid": openid, + "exp": expire, + "iat": datetime.utcnow() + } + + token = jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + return token + + +def verify_token(token: str) -> dict: + """ + 验证 JWT Token + 返回 payload 或抛出异常 + """ + settings = get_settings() + + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token已过期,请重新登录") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="无效的Token") + + +def get_current_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> int: + """ + 从 Header 中获取并验证 Token,返回 user_id + 用作 FastAPI 依赖注入 + """ + if not authorization: + raise HTTPException(status_code=401, detail="未提供身份令牌") + + # 支持 "Bearer xxx" 格式 + token = authorization + if authorization.startswith("Bearer "): + token = authorization[7:] + + payload = verify_token(token) + return payload.get("user_id") + + +def get_optional_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> Optional[int]: + """ + 可选的用户验证,未提供 Token 时返回 None + 用于不强制要求登录的接口 + """ + if not authorization: + return None + + try: + token = authorization + if authorization.startswith("Bearer "): + token = authorization[7:] + + payload = verify_token(token) + return payload.get("user_id") + except HTTPException: + return None