Compare commits
45 Commits
094953f1c3
...
74a2c15b6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 74a2c15b6b | |||
| cb3c5e6724 | |||
| e021fe8443 | |||
| d710f529fc | |||
| 00a93b66d8 | |||
| e2a02e0d5f | |||
| 675311a1be | |||
| d4088069dd | |||
| e55c3acc6a | |||
| 059e7f8f52 | |||
| b784942079 | |||
| b0a82dea13 | |||
| f7180eb83d | |||
| 1e2de85f2e | |||
| 10653edc52 | |||
| 55f06e8caf | |||
| 1eb9ef67a4 | |||
| a6dcc0843d | |||
| 8fce9ff87a | |||
| 0669af0203 | |||
| 8ded0cc3ad | |||
| e23eb32c1d | |||
| 2413b2bf58 | |||
| 76d4d7465c | |||
| 012fa46d3a | |||
| 9c785a675c | |||
| 4452d0301c | |||
| 35baa6c958 | |||
| 070e7dfbd9 | |||
| 82c308b40a | |||
| 13f773f3f0 | |||
| 5631f22566 | |||
| 76bc979cfe | |||
| 820ff93dc7 | |||
| 35834b121e | |||
| b124fe78af | |||
| a6df8dcb92 | |||
| a649357650 | |||
| a277a7325f | |||
| 85ba685bba | |||
| 78e97f6fee | |||
| f6b3ba2d8c | |||
| 2189ed24e6 | |||
| 280c3d5e7d | |||
| 9f829acb0e |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_152f89055ffej3OExy0HNx26un",
|
||||
"updatedAt": "2026-06-10T11:59:35.517Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-10T11:59:35.517Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_15f343272ffe65miVsUT3gxmR0",
|
||||
"updatedAt": "2026-06-07T06:37:08.712Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-07T06:37:08.712Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_17cc33154ffe9MqmbYyg3bghu8",
|
||||
"updatedAt": "2026-06-02T23:41:53.619Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-02T23:41:53.619Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
---
|
||||
|
||||
## 项目概述
|
||||
|
||||
情绪博物馆 (Emotion Museum) 是一款基于 AI 技术的心理健康应用,通过智能对话、情绪分析、个性化成长方案等功能,帮助用户建立健康的情绪管理习惯。
|
||||
|
||||
**生产环境地址**: `lifescript.happylifeos.com`
|
||||
- 用户前端:https://lifescript.happylifeos.com/
|
||||
- 管理后台:https://lifescript.happylifeos.com/emotion-museum-admin/
|
||||
- 后端 API:https://lifescript.happylifeos.com/api
|
||||
- WebSocket:wss://lifescript.happylifeos.com/ws
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── backend-single/ # Spring Boot 单体后端服务
|
||||
├── web/ # 用户前端 (Vue3 + TS + Vite)
|
||||
├── web-admin/ # 管理后台 (Vue3 + TS + Element Plus)
|
||||
├── UniApp/ # 跨平台移动应用 (微信小程序/H5)
|
||||
├── mini-program/ # 小程序项目
|
||||
├── course-web/ # 课程 Web 项目
|
||||
├── life-script/ # 生活脚本工具
|
||||
└── tools/ # 工具脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 后端 (backend-single)
|
||||
|
||||
```bash
|
||||
# 进入后端目录
|
||||
cd backend-single
|
||||
|
||||
# 编译打包
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 本地运行
|
||||
mvn spring-boot:run
|
||||
|
||||
# 或直接运行 JAR
|
||||
java -jar target/backend-single-1.0.0.jar
|
||||
|
||||
# 运行测试
|
||||
mvn test
|
||||
|
||||
# 运行单个测试类
|
||||
mvn test -Dtest=ClassNameTest
|
||||
```
|
||||
|
||||
### 用户前端 (web)
|
||||
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd web
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器 (端口 5173)
|
||||
npm run dev
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# E2E 测试
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### 管理后台 (web-admin)
|
||||
|
||||
```bash
|
||||
# 进入目录
|
||||
cd web-admin
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器 (端口 5174)
|
||||
npm run dev
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
### UniApp/小程序
|
||||
|
||||
```bash
|
||||
# 进入目录
|
||||
cd UniApp
|
||||
|
||||
# 开发微信小程序
|
||||
npm run dev:mp-weixin
|
||||
|
||||
# 构建微信小程序
|
||||
npm run build:mp-weixin
|
||||
|
||||
# 开发 H5
|
||||
npm run dev:h5
|
||||
|
||||
# 构建 H5
|
||||
npm run build:h5
|
||||
```
|
||||
|
||||
### mini-program(推荐开发模式)
|
||||
|
||||
**H5 开发模式**(日常开发,推荐):
|
||||
```bash
|
||||
cd mini-program
|
||||
# 或使用启动脚本
|
||||
start-h5-dev.bat
|
||||
```
|
||||
访问 `http://localhost:5173`,登录态保留,支持热更新。
|
||||
|
||||
**微信小程序开发模式**(仅用于小程序特性验证):
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run dev:mp-weixin
|
||||
```
|
||||
在微信开发者工具中打开 `unpackage/dist/dev/mp-weixin`。
|
||||
|
||||
**开发工作流说明**:详见 [小程序开发工作流程设计](docs/superpowers/specs/2026-04-07-mini-program-dev-workflow-design.md)
|
||||
|
||||
### 一键部署
|
||||
|
||||
```bash
|
||||
# 部署所有服务(后端 + 前端 + 管理后台)到生产服务器
|
||||
bash deploy-all.sh
|
||||
|
||||
# 仅部署后端
|
||||
bash deploy-all.sh backend
|
||||
|
||||
# 仅部署前端
|
||||
bash deploy-all.sh frontend
|
||||
|
||||
# 仅部署管理后台
|
||||
bash deploy-all.sh admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **框架**: Spring Boot 2.7.18
|
||||
- **ORM**: MyBatis-Plus 3.5.3.1
|
||||
- **数据库**: MySQL 8.0.33
|
||||
- **缓存**: Redis 7.0+
|
||||
- **JWT**: io.jsonwebtoken 0.11.5
|
||||
- **构建**: Maven 3.6+
|
||||
- **JDK**: 17
|
||||
|
||||
### 前端
|
||||
- **框架**: Vue 3.4.x + TypeScript 5.x
|
||||
- **构建**: Vite 5.x
|
||||
- **UI**: Element Plus 2.4.x
|
||||
- **样式**: Tailwind CSS 3.4.x
|
||||
- **状态管理**: Pinia 2.1.x
|
||||
- **路由**: Vue Router 4.2.x
|
||||
- **HTTP**: Axios 1.6.x
|
||||
- **实时通信**: @stomp/stompjs + SockJS
|
||||
- **图表**: ECharts 5.4.x
|
||||
- **包管理**: npm 9+
|
||||
|
||||
### 移动端
|
||||
- **框架**: UniApp (Vue 3)
|
||||
- **平台**: 微信小程序、H5
|
||||
|
||||
---
|
||||
|
||||
## 架构规范
|
||||
|
||||
### 后端分层架构
|
||||
- **Controller 层**: 接口定义,参数校验,禁止业务逻辑
|
||||
- **Service 层**: 业务逻辑实现
|
||||
- **Mapper 层**: 数据访问层
|
||||
- **Entity 层**: 数据实体
|
||||
- **Request/Response 层**: 入参出参封装
|
||||
- **Config 层**: 配置类
|
||||
|
||||
### 接口设计规范
|
||||
- Controller 层路由禁止添加 `/api` 前缀
|
||||
- 使用 `@RequestParam` 传递路径参数,禁止使用 `@PathVariable`
|
||||
- 接口方法参数不超过两个,超出时使用 Request/DTO 对象封装
|
||||
- 使用项目已有的 `Result` 作为统一接口返回
|
||||
- 禁止使用枚举类型作为接口入参
|
||||
|
||||
### 前端架构
|
||||
- 使用 Vue 3 Composition API (`<script setup>`)
|
||||
- 使用 TypeScript 进行类型检查
|
||||
- 组件通信优先使用 Pinia 状态管理
|
||||
- 前端访问后端通过网关统一调用
|
||||
|
||||
---
|
||||
|
||||
## 验证规则(强制)
|
||||
|
||||
**修改任何前后端代码后,必须使用浏览器进行验证**
|
||||
|
||||
### 验证步骤
|
||||
|
||||
1. **使用浏览器工具打开对应页面**
|
||||
- 使用 `agent-browser` skill 或 MCP 工具
|
||||
- 前台页面:通常是 `http://localhost:5173` 或项目指定端口
|
||||
- 管理端页面:通常是 `http://localhost:5174` 或项目指定路径
|
||||
|
||||
2. **验证标准**(全部满足才算通过)
|
||||
- ✅ 前端修改已生效
|
||||
- ✅ 页面没有报错
|
||||
- ✅ 可以正常访问
|
||||
- ✅ 浏览器 Console 中没有任何错误
|
||||
|
||||
3. **只有验证通过才算任务完成**
|
||||
|
||||
---
|
||||
|
||||
## 热加载规则(强制)
|
||||
|
||||
**修改前后端代码后,优先利用热加载,避免不必要的重启**
|
||||
|
||||
### 判断逻辑
|
||||
|
||||
1. **不需要重启的场景**(热加载自动生效)
|
||||
- 前端:修改 `.vue`、`.tsx`、`.ts`、`.js`、`.css`、`.scss` 等源码文件
|
||||
- 后端:修改业务逻辑、API 接口、组件代码等支持热更新的文件
|
||||
- 样式、模板、静态资源等修改
|
||||
|
||||
2. **需要重启的场景**(热加载无法生效)
|
||||
- 前端:修改 `vite.config.ts`、`tsconfig.json`、`.env`、`package.json`、别名配置等
|
||||
- 后端:修改启动配置、数据库连接、中间件注册、环境变量等
|
||||
- 新增或删除依赖包后
|
||||
|
||||
### 操作原则
|
||||
|
||||
- 修改后先观察终端输出,确认热加载是否成功
|
||||
- 只有修改了必须重启才能生效的配置时,才执行重启操作
|
||||
- 重启前后端服务前,需告知用户
|
||||
|
||||
---
|
||||
|
||||
## 语言规则(强制)
|
||||
|
||||
- 所有对话和文档使用**中文**
|
||||
- 代码内容(变量名、函数名、注释)使用英文
|
||||
- 技术术语保持英文(API、HTTP、JSON 等)
|
||||
|
||||
---
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
- 使用中文提交
|
||||
- 格式:`类型:描述`
|
||||
- 类型包括:feat, fix, docs, style, refactor, test, chore
|
||||
|
||||
---
|
||||
|
||||
## 开发规范(来自 Cursor Rules)
|
||||
|
||||
### 基础设置
|
||||
- 保持对话语言为中文
|
||||
- 不允许在未经允许的情况下删除代码和文件
|
||||
- 不允许破坏正常的业务代码
|
||||
- 执行终端命令时要关注执行情况,避免无效等待
|
||||
|
||||
### 代码规范
|
||||
- 生成代码时必须添加类级和函数级注释
|
||||
- 使用 `import` 导包,禁止使用全限定名称引用类
|
||||
- 禁止使用枚举类型作为 Entity、Request、Response、DTO 对象的字段
|
||||
- 禁止以枚举类作为方法的入参
|
||||
- 新增数据的 id 使用已存在的雪花算法生成器生成
|
||||
|
||||
### 架构规范
|
||||
- Controller 层禁止添加业务逻辑
|
||||
- 使用全局异常处理,禁止使用 try-catch
|
||||
- 前端接口访问尽可能走网关调用
|
||||
|
||||
### 接口设计规范
|
||||
- Controller 层接口定义:
|
||||
- 入参使用 request 封装传递到 service 层
|
||||
- service 层方法命名与 controller 层保持一致
|
||||
- 出参使用 response 封装由 service 层传递到 controller 层
|
||||
- 禁止在 controller 层做 entity 与 request/response 的转换
|
||||
- 使用项目已有的 `Result` 做接口返回
|
||||
- 接口和方法参数不允许超过两个,超过时使用 request 或 DTO 对象封装
|
||||
- Controller 层路由禁止添加 `/api` 前缀
|
||||
- Controller 层 `@RequestMapping` 的 value 属性值不允许重复且不允许为空
|
||||
- 必须明确指定 value 属性值且使用驼峰结构命名,避免使用下划线
|
||||
- 禁止使用 `@GetMapping()` 或 `@PostMapping` 等空注解形式
|
||||
- 用户相关接口禁止直接传递用户 id,需要后端根据 token 获取当前登录用户信息
|
||||
- 禁止使用 `/{param}` 格式的路径参数,避免网关路由冲突
|
||||
- 路径参数统一使用 `@RequestParam` 而非 `@PathVariable`
|
||||
- 接口路径命名应具有明确的语义,避免使用通用词汇如 `/get`、`/list` 等
|
||||
- 批量操作接口应使用专门的 Request 对象封装参数,而非直接传递 List
|
||||
- 接口路径应避免层级过深,建议不超过 3 级路径结构
|
||||
|
||||
### 数据库规范
|
||||
- 所有数据表必须包含 `create_time` 和 `update_time` 字段
|
||||
- 删除操作优先使用逻辑删除,添加 `deleted` 字段标识
|
||||
- 数据库字段命名使用下划线分隔,Java 实体类使用驼峰命名
|
||||
- 优先使用 `LambdaQueryWrapper` 构造条件查询
|
||||
- 使用 Lambda 表达式引用实体类属性,提高代码可维护性
|
||||
- 复杂查询条件应使用 `LambdaQueryWrapper` 的链式调用
|
||||
|
||||
### 安全规范
|
||||
- 所有外部输入必须进行参数校验
|
||||
- 敏感信息不得在日志中输出
|
||||
- 数据库操作必须使用参数化查询,防止 SQL 注入
|
||||
|
||||
### 性能规范
|
||||
- 避免 N+1 查询问题,合理使用批量查询
|
||||
- 大数据量查询必须分页处理
|
||||
- 缓存策略要考虑数据一致性问题
|
||||
|
||||
### 日志规范
|
||||
- 关键业务操作必须记录操作日志
|
||||
- 异常信息要包含足够的上下文信息
|
||||
- 生产环境禁止输出 debug 级别日志
|
||||
|
||||
---
|
||||
|
||||
## 操作系统命令执行规则(强制)
|
||||
|
||||
**执行任何 Shell 命令前,必须根据当前操作系统(`win32` / `darwin` / `linux`)选择合适的命令语法。**
|
||||
|
||||
### 后台启动长期运行进程(dev server 等)
|
||||
|
||||
不同操作系统后台启动进程的方式完全不同,选错会导致命令阻塞或失败:
|
||||
|
||||
| 操作系统 | 正确方式 | 错误方式(禁止) |
|
||||
|---------|---------|----------------|
|
||||
| **Windows** | `Start-Process -FilePath "npm" -ArgumentList "run","dev" -WorkingDirectory "..." -WindowStyle Hidden` | `cmd /c "start /B npm run dev > log 2>&1"`(`cmd /c` 会阻塞等待重定向完成) |
|
||||
| **macOS/Linux** | `nohup npm run dev > log 2>&1 &` | |
|
||||
|
||||
**核心原则:**
|
||||
- Windows 下 `cmd /c "start /B ... > log 2>&1"` **绝对禁止**用于启动长期运行的后台服务。`cmd /c` 会等待重定向管道关闭,导致命令无限阻塞。
|
||||
- Windows 下启动后台进程必须使用 PowerShell 的 `Start-Process` cmdlet,并通过 `-WindowStyle Hidden` 隐藏窗口。
|
||||
|
||||
### 启动前检查(强制)
|
||||
|
||||
**启动任何开发服务器前,必须先检查目标端口是否已被占用:**
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
netstat -ano | findstr "端口号"
|
||||
# macOS/Linux
|
||||
lsof -i :端口号
|
||||
```
|
||||
|
||||
如果端口已被占用,必须先终止旧进程再启动新进程,**禁止重复启动**导致端口递增。
|
||||
|
||||
### 热加载优先原则
|
||||
|
||||
Vite / Webpack 等现代构建工具支持热更新(HMR):
|
||||
- **启动一次即可**:`npm run dev` 启动后,修改源码文件会自动热更新
|
||||
- **禁止频繁重启**:除非修改了 `vite.config.ts`、`.env`、`package.json` 等配置文件,否则不需要重启 dev server
|
||||
- 重启前必须告知用户原因
|
||||
@@ -1,13 +1,52 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import com.emotion.interceptor.WechatApiLoggingInterceptor;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 统一 RestTemplate Bean
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ol>
|
||||
* <li>用 BufferingClientHttpRequestFactory 包装 SimpleClientHttpRequestFactory,
|
||||
* 响应体被缓存到字节数组,可被 Interceptor / ErrorHandler / Converter 多次读取
|
||||
* (避免破坏其他 7 个 RestTemplate 使用点:DifyProviderAdapter、
|
||||
* CozeProviderAdapter、HttpTtsEngineClient、AsrServiceImpl、
|
||||
* ApiEndpointServiceImpl、AiChatServiceImpl、ApiTestProxyController)</li>
|
||||
* <li>显式设置 User-Agent 为 Mozilla/5.0,绕过 WAF / CloudFlare 对 Java/* 默认 UA 的拦截</li>
|
||||
* <li>显式设置连接 / 读取超时,避免线程被 hang 住</li>
|
||||
* <li>加 StringHttpMessageConverter(UTF_8) 支持 text/plain 响应</li>
|
||||
* <li>加 WechatApiLoggingInterceptor 记录请求 / 响应(脱敏)</li>
|
||||
* <li>加 WechatResponseErrorHandler 4xx/5xx 不抛异常</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
|
||||
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
|
||||
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
|
||||
BufferingClientHttpRequestFactory bufferingFactory =
|
||||
new BufferingClientHttpRequestFactory(simpleFactory);
|
||||
|
||||
return builder
|
||||
.requestFactory(() -> bufferingFactory)
|
||||
.defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)")
|
||||
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
|
||||
.additionalInterceptors(new WechatApiLoggingInterceptor())
|
||||
.errorHandler(new WechatResponseErrorHandler())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/swagger-resources/**", // Swagger resources sub-paths
|
||||
"/actuator/**", // Actuator endpoints
|
||||
"/admin/**", // 排除管理员路径,由管理员拦截器处理
|
||||
"/auth/wechat/login", // WeChat mini program login endpoint
|
||||
"/error" // Spring Boot error page
|
||||
)
|
||||
.order(2); // 优先级2
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "emotion.wechat.mini-program")
|
||||
public class WechatMiniProgramProperties {
|
||||
|
||||
private String appId;
|
||||
|
||||
private String appSecret;
|
||||
|
||||
private String baseUrl = "https://api.weixin.qq.com";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 微信 API 错误处理器
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ol>
|
||||
* <li>显式重写 hasError() 委托父类判断(4xx/5xx → true),让 handleError() 在错误时被调用</li>
|
||||
* <li>handleError() 不抛 RestClientException,由业务层(WechatMiniProgramServiceImpl)
|
||||
* 根据 status code 判断如何处理</li>
|
||||
* <li>不在此处读 body(避免流消费,由 LoggingInterceptor 统一处理日志)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
public class WechatResponseErrorHandler extends DefaultResponseErrorHandler {
|
||||
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
// 委托父类 DefaultResponseErrorHandler.hasError():4xx/5xx 返回 true,其他返回 false
|
||||
return super.hasError(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(ClientHttpResponse response) throws IOException {
|
||||
// 不抛异常;body 已在 LoggingInterceptor 中读取并脱敏记录
|
||||
// 业务层会通过 response.getStatusCode() 判断
|
||||
log.warn("[WeChatAPI] HTTP error response, status={}", response.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.RefreshTokenRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.request.WechatLoginRequest;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
@@ -53,6 +54,13 @@ public class AuthController {
|
||||
/**
|
||||
* 用户注册(简化版:仅需手机号、密码和短信验证码)
|
||||
*/
|
||||
@PostMapping("/wechat/login")
|
||||
@Operation(summary = "微信小程序登录", description = "使用微信小程序登录 code 换取 openid 并登录")
|
||||
public Result<AuthResponse> wechatLogin(@Valid @RequestBody WechatLoginRequest request) {
|
||||
AuthResponse response = authService.wechatLogin(request);
|
||||
return Result.success("登录成功", response);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/register")
|
||||
@Operation(summary = "用户注册", description = "使用手机号、密码和短信验证码进行注册")
|
||||
public Result<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
@@ -177,4 +185,4 @@ public class AuthController {
|
||||
boolean exists = authService.existsByPhone(phone);
|
||||
return Result.success(exists);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class WechatLoginRequest extends BaseRequest {
|
||||
|
||||
@NotBlank(message = "Wechat login code is required")
|
||||
private String code;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String avatar;
|
||||
}
|
||||
+25
@@ -85,6 +85,31 @@ public class UserProfileCreateRequest {
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
private LocalDate birthday;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
+25
@@ -89,6 +89,31 @@ public class UserProfileUpdateRequest {
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
private LocalDate birthday;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
+26
@@ -104,6 +104,32 @@ public class UserProfileResponse {
|
||||
*/
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate birthday;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.emotion.dto.wechat;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Data
|
||||
public class WechatCodeSessionResponse {
|
||||
|
||||
private String openid;
|
||||
|
||||
@JsonProperty("session_key")
|
||||
private String sessionKey;
|
||||
|
||||
private String unionid;
|
||||
|
||||
private Integer errcode;
|
||||
|
||||
private String errmsg;
|
||||
|
||||
public boolean isSuccess() {
|
||||
return (errcode == null || errcode == 0) && StringUtils.hasText(openid);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,36 @@ public class UserProfile extends BaseEntity {
|
||||
@TableField("ideal_life")
|
||||
private String idealLife;
|
||||
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
@TableField("city")
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
@TableField("industry")
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
@TableField("company")
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
@TableField("personality_tags")
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
@TableField("birthday")
|
||||
private LocalDate birthday;
|
||||
|
||||
/**
|
||||
* 生成的剧本列表 (JSON字符串)
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,45 @@ public class GlobalExceptionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信 API 异常(业务错误 400 / 内部错误 500 / 上游错误 502)
|
||||
*
|
||||
* <p>根据异常内部 code 映射到不同友好提示(不直接暴露原始异常消息给前端):
|
||||
* <ul>
|
||||
* <li>400 - 微信业务错误(如 errcode 40029 invalid code):"微信授权失败,请重试"</li>
|
||||
* <li>500 - 内部 JSON 解析失败:"微信登录失败,请重试"</li>
|
||||
* <li>502 - 网络异常 / 非 2xx / Content-Type 不是 JSON:"微信服务暂不可用,请稍后重试"</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>详细异常消息仅记录到日志(含 [WeChatAPI] 前缀),前端仅看到友好提示。
|
||||
*/
|
||||
@ExceptionHandler(WechatApiException.class)
|
||||
public Result<Void> handleWechatApiException(WechatApiException e, HttpServletRequest request) {
|
||||
String userMessage;
|
||||
switch (e.getCode()) {
|
||||
case 400:
|
||||
userMessage = "微信授权失败,请重试";
|
||||
log.warn("[WeChatAPI] 微信授权失败,code={}, method={}, uri={}, detail={}",
|
||||
e.getCode(), request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
return Result.badRequest(userMessage);
|
||||
case 500:
|
||||
userMessage = "微信登录失败,请重试";
|
||||
log.warn("[WeChatAPI] 微信登录内部错误,code={}, method={}, uri={}, detail={}",
|
||||
e.getCode(), request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
return Result.error(userMessage);
|
||||
case 502:
|
||||
userMessage = "微信服务暂不可用,请稍后重试";
|
||||
log.warn("[WeChatAPI] 微信服务上游错误,code={}, method={}, uri={}, detail={}",
|
||||
e.getCode(), request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
return Result.error(e.getCode(), userMessage);
|
||||
default:
|
||||
userMessage = "微信登录异常,请重试";
|
||||
log.warn("[WeChatAPI] 微信登录未知错误,code={}, method={}, uri={}, detail={}",
|
||||
e.getCode(), request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
return Result.error(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理参数校验异常
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.emotion.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 微信 API 业务异常
|
||||
*
|
||||
* <p>code 字段使用 HTTP 风格状态码:
|
||||
* <ul>
|
||||
* <li>400: 微信 API 业务错误(errcode != 0)或 4xx 响应</li>
|
||||
* <li>500: 内部错误(JSON 解析失败)</li>
|
||||
* <li>502: 上游服务错误(5xx 响应 / network / non-JSON content-type)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>rawBody 字段仅用于服务端排查,绝不返回给前端。
|
||||
*/
|
||||
@Getter
|
||||
public class WechatApiException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** HTTP 风格状态码 */
|
||||
private final Integer code;
|
||||
|
||||
/** 原始响应体(仅服务端排查用,绝不返回给前端) */
|
||||
private final String rawBody;
|
||||
|
||||
public WechatApiException(Integer code, String message, String rawBody) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.rawBody = rawBody;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ public class JwtAuthInterceptor implements HandlerInterceptor {
|
||||
// 公开接口列表
|
||||
String[] publicEndpoints = {
|
||||
"/api/auth/login",
|
||||
"/api/auth/wechat/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/captcha",
|
||||
"/api/auth/refresh-token",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.emotion.interceptor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 微信 API HTTP 请求 / 响应日志拦截器
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ol>
|
||||
* <li>配合 BufferingClientHttpRequestFactory 使用,body 可被多次读取</li>
|
||||
* <li>URL / method / status / content-type 用 INFO 级别(生产可见)</li>
|
||||
* <li>body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)</li>
|
||||
* <li>body 中敏感字段(session_key / openid / unionid / access_token / refresh_token)脱敏</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
private static final int MAX_BODY_LOG = 2048;
|
||||
private static final Pattern SENSITIVE_FIELDS = Pattern.compile(
|
||||
"\"(session_key|openid|unionid|access_token|refresh_token|secret|appsecret)\"\\s*:\\s*\"[^\"]*\"");
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(
|
||||
HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
String safeUri = maskSensitiveQuery(request.getURI());
|
||||
log.info("[WeChatAPI] ---> {} {}", request.getMethod(), safeUri);
|
||||
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
|
||||
log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}",
|
||||
response.getStatusCode(), safeUri, duration,
|
||||
response.getHeaders().getContentType());
|
||||
|
||||
// body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读)
|
||||
try {
|
||||
String bodyStr = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (bodyStr.length() > MAX_BODY_LOG) {
|
||||
bodyStr = bodyStr.substring(0, MAX_BODY_LOG) + "...(truncated)";
|
||||
}
|
||||
log.debug("[WeChatAPI] body={}", desensitize(bodyStr));
|
||||
} catch (IOException e) {
|
||||
log.warn("[WeChatAPI] failed to read response body for logging", e);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏:将敏感字段值替换为 "***"
|
||||
*/
|
||||
private String desensitize(String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return body;
|
||||
}
|
||||
return SENSITIVE_FIELDS.matcher(body).replaceAll("\"$1\":\"***\"");
|
||||
}
|
||||
|
||||
private String maskSensitiveQuery(URI uri) {
|
||||
if (uri == null) {
|
||||
return "";
|
||||
}
|
||||
String value = uri.toString();
|
||||
return value
|
||||
.replaceAll("(?i)([?&](?:secret|appsecret|js_code|access_token)=)[^&]*", "$1***");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.emotion.service;
|
||||
import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.request.WechatLoginRequest;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
@@ -28,6 +29,8 @@ public interface AuthService {
|
||||
*/
|
||||
AuthResponse login(LoginRequest request);
|
||||
|
||||
AuthResponse wechatLogin(WechatLoginRequest request);
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
@@ -165,4 +168,4 @@ public interface AuthService {
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
boolean validateSmsCode(String phone, String code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public interface TtsTaskService extends IService<TtsTask> {
|
||||
|
||||
TtsTaskResponse createOrReuse(TtsTaskCreateRequest request);
|
||||
|
||||
void prewarmEpicScript(String userId, String sourceId);
|
||||
|
||||
TtsTaskResponse getTask(String id);
|
||||
|
||||
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
|
||||
|
||||
@@ -69,6 +69,8 @@ public interface UserService extends IService<User> {
|
||||
*/
|
||||
User getByPhone(String phone);
|
||||
|
||||
User getByThirdParty(String thirdPartyType, String thirdPartyId);
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*
|
||||
@@ -88,4 +90,4 @@ public interface UserService extends IService<User> {
|
||||
* @param lastActiveTime 最后活跃时间
|
||||
*/
|
||||
void updateLastActiveTime(String userId, LocalDateTime lastActiveTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||
|
||||
public interface WechatMiniProgramService {
|
||||
|
||||
WechatCodeSessionResponse code2Session(String code);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package com.emotion.service.impl;
|
||||
import com.emotion.dto.request.LoginRequest;
|
||||
import com.emotion.dto.request.RegisterRequest;
|
||||
import com.emotion.dto.request.ResetPasswordRequest;
|
||||
import com.emotion.dto.request.WechatLoginRequest;
|
||||
import com.emotion.dto.response.AuthResponse;
|
||||
import com.emotion.dto.response.CaptchaResponse;
|
||||
import com.emotion.dto.response.ResetPasswordResponse;
|
||||
import com.emotion.dto.response.SmsCodeResponse;
|
||||
import com.emotion.dto.response.UserInfoResponse;
|
||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||
import com.emotion.entity.User;
|
||||
import com.emotion.exception.AuthException;
|
||||
import com.emotion.exception.BusinessException;
|
||||
@@ -15,6 +17,7 @@ import com.emotion.exception.CaptchaException;
|
||||
import com.emotion.exception.TokenException;
|
||||
import com.emotion.service.AuthService;
|
||||
import com.emotion.service.UserService;
|
||||
import com.emotion.service.WechatMiniProgramService;
|
||||
import com.emotion.util.JwtUtil;
|
||||
import com.emotion.util.TokenUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -51,6 +54,9 @@ public class AuthServiceImpl implements AuthService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private WechatMiniProgramService wechatMiniProgramService;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@@ -72,6 +78,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
private static final int TOKEN_EXPIRE_HOURS = 24;
|
||||
private static final int REFRESH_TOKEN_EXPIRE_DAYS = 7;
|
||||
private static final String DEFAULT_SMS_CODE = "123456";
|
||||
private static final String WECHAT_MP_TYPE = "wechat-mp";
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
@@ -114,6 +121,32 @@ public class AuthServiceImpl implements AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthResponse wechatLogin(WechatLoginRequest request) {
|
||||
WechatCodeSessionResponse session = wechatMiniProgramService.code2Session(request.getCode());
|
||||
String openid = session.getOpenid();
|
||||
|
||||
User user = userService.getByThirdParty(WECHAT_MP_TYPE, openid);
|
||||
if (user == null) {
|
||||
user = createWechatUser(openid, request);
|
||||
log.info("Wechat mini program user created: userId={}", user.getId());
|
||||
} else if (user.getStatus() != 1) {
|
||||
throw new AuthException("账号已被禁用");
|
||||
}
|
||||
|
||||
String accessToken = generateAccessToken(user);
|
||||
String refreshToken = generateRefreshToken(user);
|
||||
userService.updateLastActiveTime(user.getId(), LocalDateTime.now());
|
||||
|
||||
AuthResponse response = new AuthResponse();
|
||||
response.setAccessToken(accessToken);
|
||||
response.setRefreshToken(refreshToken);
|
||||
response.setExpiresIn((long) TOKEN_EXPIRE_HOURS * 3600);
|
||||
response.setUserInfo(convertToUserInfoResponse(user));
|
||||
response.setLoginTime(LocalDateTime.now().format(DATE_TIME_FORMATTER));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
// 验证短信验证码
|
||||
@@ -626,6 +659,24 @@ public class AuthServiceImpl implements AuthService {
|
||||
*
|
||||
* @return 随机密码
|
||||
*/
|
||||
private User createWechatUser(String openid, WechatLoginRequest request) {
|
||||
String username = generateRandomUsername();
|
||||
User user = new User();
|
||||
user.setAccount("wx_" + openid);
|
||||
user.setUsername(username);
|
||||
user.setNickname(StringUtils.hasText(request.getNickname()) ? request.getNickname() : username);
|
||||
user.setAvatar(StringUtils.hasText(request.getAvatar()) ? request.getAvatar() : null);
|
||||
user.setPassword(passwordEncoder.encode(UUID.randomUUID().toString()));
|
||||
user.setMemberLevel("free");
|
||||
user.setStatus(1);
|
||||
user.setIsVerified(1);
|
||||
user.setLastActiveTime(LocalDateTime.now());
|
||||
user.setThirdPartyType(WECHAT_MP_TYPE);
|
||||
user.setThirdPartyId(openid);
|
||||
userService.save(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private String generateRandomPassword() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
Random random = new Random();
|
||||
@@ -638,4 +689,4 @@ public class AuthServiceImpl implements AuthService {
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.LifePathService;
|
||||
import com.emotion.service.ScriptContextService;
|
||||
import com.emotion.service.TtsTaskService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -75,6 +76,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
@Autowired
|
||||
private ScriptContextService scriptContextService;
|
||||
|
||||
@Autowired
|
||||
private TtsTaskService ttsTaskService;
|
||||
|
||||
@Override
|
||||
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
|
||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||
@@ -195,6 +199,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
}
|
||||
|
||||
this.save(script);
|
||||
prewarmScriptTts(script);
|
||||
return convertToResponse(script);
|
||||
}
|
||||
|
||||
@@ -513,9 +518,22 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
}
|
||||
|
||||
this.updateById(script);
|
||||
prewarmScriptTts(script);
|
||||
return convertToResponse(script);
|
||||
}
|
||||
|
||||
private void prewarmScriptTts(EpicScript script) {
|
||||
if (script == null || !StringUtils.hasText(script.getId()) || !StringUtils.hasText(script.getUserId())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ttsTaskService.prewarmEpicScript(script.getUserId(), script.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("Epic script TTS prewarm failed, scriptId={}, userId={}, error={}",
|
||||
script.getId(), script.getUserId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用Coze AI重新生成爽文剧本内容
|
||||
*
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.service.TtsEngineClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -14,12 +17,19 @@ import java.util.Map;
|
||||
public class HttpTtsEngineClient implements TtsEngineClient {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String engineUrl;
|
||||
|
||||
@Value("${emotion.tts.engine-url:http://127.0.0.1:19110}")
|
||||
private String engineUrl;
|
||||
|
||||
public HttpTtsEngineClient(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
public HttpTtsEngineClient(RestTemplateBuilder restTemplateBuilder,
|
||||
@Value("${emotion.tts.engine-url:http://127.0.0.1:19110}") String engineUrl,
|
||||
@Value("${emotion.tts.engine-connect-timeout-ms:5000}") long connectTimeoutMs,
|
||||
@Value("${emotion.tts.engine-read-timeout-ms:240000}") long readTimeoutMs) {
|
||||
this.engineUrl = normalizeEngineUrl(engineUrl);
|
||||
this.restTemplate = restTemplateBuilder
|
||||
.setConnectTimeout(Duration.ofMillis(connectTimeoutMs))
|
||||
.setReadTimeout(Duration.ofMillis(readTimeoutMs))
|
||||
.defaultHeader("User-Agent", "EmotionMuseum-TTS/1.0")
|
||||
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -45,6 +55,9 @@ public class HttpTtsEngineClient implements TtsEngineClient {
|
||||
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
||||
if (!success) {
|
||||
String message = data == null ? "empty response" : String.valueOf(data.get("errorMessage"));
|
||||
if (hasGeneratedAudio(outputPath)) {
|
||||
return new TtsEngineResult(true, outputPath, null, null);
|
||||
}
|
||||
return new TtsEngineResult(false, null, null, message);
|
||||
}
|
||||
Long durationMs = data.get("durationMs") instanceof Number
|
||||
@@ -52,7 +65,26 @@ public class HttpTtsEngineClient implements TtsEngineClient {
|
||||
: null;
|
||||
return new TtsEngineResult(true, String.valueOf(data.get("audioPath")), durationMs, null);
|
||||
} catch (Exception e) {
|
||||
if (hasGeneratedAudio(outputPath)) {
|
||||
return new TtsEngineResult(true, outputPath, null, null);
|
||||
}
|
||||
return new TtsEngineResult(false, null, null, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeEngineUrl(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return "http://127.0.0.1:19110";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||
}
|
||||
|
||||
private static boolean hasGeneratedAudio(String outputPath) {
|
||||
if (!StringUtils.hasText(outputPath)) {
|
||||
return false;
|
||||
}
|
||||
File file = new File(outputPath);
|
||||
return file.isFile() && file.length() > 0L;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.DigestUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -83,6 +84,25 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
||||
}
|
||||
|
||||
String userId = currentUserId();
|
||||
return createOrReuseForUser(userId, request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prewarmEpicScript(String userId, String sourceId) {
|
||||
if (!enabled || !StringUtils.hasText(userId) || !StringUtils.hasText(sourceId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TtsTaskCreateRequest request = new TtsTaskCreateRequest();
|
||||
request.setSourceType(SOURCE_TYPE_EPIC_SCRIPT);
|
||||
request.setSourceId(sourceId);
|
||||
createOrReuseForUser(userId, request);
|
||||
} catch (Exception e) {
|
||||
log.warn("TTS prewarm skipped, userId={}, sourceId={}, error={}", userId, sourceId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private TtsTaskResponse createOrReuseForUser(String userId, TtsTaskCreateRequest request) {
|
||||
String sourceType = normalizeSourceType(request.getSourceType());
|
||||
String sourceId = request.getSourceId().trim();
|
||||
String voice = resolveVoice(request.getVoice());
|
||||
@@ -98,8 +118,13 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
||||
String hash = DigestUtils.md5DigestAsHex((voice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
||||
TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash);
|
||||
if (owned != null) {
|
||||
incrementRequestCount(owned);
|
||||
return toResponse(owned);
|
||||
if (STATUS_FAILED.equals(owned.getStatus()) && !recoverGeneratedAudio(owned)) {
|
||||
log.info("Retrying failed TTS task, oldTaskId={}, sourceType={}, sourceId={}",
|
||||
owned.getId(), sourceType, sourceId);
|
||||
} else {
|
||||
incrementRequestCount(owned);
|
||||
return toResponse(owned);
|
||||
}
|
||||
}
|
||||
|
||||
TtsTask cachedSuccess = findSuccessfulCache(voice, hash);
|
||||
@@ -128,6 +153,9 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
||||
if (task == null || !userId.equals(task.getUserId())) {
|
||||
return null;
|
||||
}
|
||||
if (STATUS_FAILED.equals(task.getStatus())) {
|
||||
recoverGeneratedAudio(task);
|
||||
}
|
||||
return toResponse(task);
|
||||
}
|
||||
|
||||
@@ -144,9 +172,27 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
||||
}
|
||||
String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
||||
TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash);
|
||||
if (task != null && STATUS_FAILED.equals(task.getStatus())) {
|
||||
recoverGeneratedAudio(task);
|
||||
}
|
||||
return task == null ? null : toResponse(task);
|
||||
}
|
||||
|
||||
private boolean recoverGeneratedAudio(TtsTask task) {
|
||||
if (task == null || !StringUtils.hasText(task.getAudioPath())) {
|
||||
return false;
|
||||
}
|
||||
File file = new File(task.getAudioPath());
|
||||
if (!file.isFile() || file.length() <= 0L) {
|
||||
return false;
|
||||
}
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
task.setErrorMessage(null);
|
||||
updateById(task);
|
||||
log.info("Recovered generated TTS audio, taskId={}, audioPath={}", task.getId(), task.getAudioPath());
|
||||
return true;
|
||||
}
|
||||
|
||||
private void process(String taskId, String text, String voice, String outputPath, TtsEngineClient.SynthesisOptions options) {
|
||||
try {
|
||||
TtsTask task = getById(taskId);
|
||||
@@ -178,6 +224,9 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
||||
log.warn("TTS task processing failed, taskId={}", taskId, e);
|
||||
TtsTask task = getById(taskId);
|
||||
if (task != null) {
|
||||
if (recoverGeneratedAudio(task)) {
|
||||
return;
|
||||
}
|
||||
task.setStatus(STATUS_FAILED);
|
||||
task.setErrorMessage(limitError(e.getMessage()));
|
||||
updateById(task);
|
||||
|
||||
@@ -278,6 +278,18 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
return this.getOne(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User getByThirdParty(String thirdPartyType, String thirdPartyId) {
|
||||
if (!StringUtils.hasText(thirdPartyType) || !StringUtils.hasText(thirdPartyId)) {
|
||||
return null;
|
||||
}
|
||||
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(User::getThirdPartyType, thirdPartyType)
|
||||
.eq(User::getThirdPartyId, thirdPartyId)
|
||||
.eq(User::getIsDeleted, 0);
|
||||
return this.getOne(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User createUser(String account, String username, String password, String email, String phone) {
|
||||
User user = new User();
|
||||
@@ -326,4 +338,4 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.config.WechatMiniProgramProperties;
|
||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||
import com.emotion.exception.BusinessException;
|
||||
import com.emotion.exception.WechatApiException;
|
||||
import com.emotion.service.WechatMiniProgramService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WechatMiniProgramServiceImpl implements WechatMiniProgramService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final WechatMiniProgramProperties properties;
|
||||
|
||||
@Override
|
||||
public WechatCodeSessionResponse code2Session(String code) {
|
||||
validateConfigured();
|
||||
|
||||
String url = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + "/sns/jscode2session")
|
||||
.queryParam("appid", properties.getAppId())
|
||||
.queryParam("secret", properties.getAppSecret())
|
||||
.queryParam("js_code", code)
|
||||
.queryParam("grant_type", "authorization_code")
|
||||
.toUriString();
|
||||
|
||||
ResponseEntity<String> responseEntity;
|
||||
try {
|
||||
responseEntity = restTemplate.getForEntity(url, String.class);
|
||||
} catch (ResourceAccessException e) {
|
||||
log.error("[WeChatAPI] network error, jsCode={}", code, e);
|
||||
throw new WechatApiException(502, "Wechat API network error: " + e.getMessage(), null);
|
||||
} catch (RestClientException e) {
|
||||
log.error("[WeChatAPI] client error, jsCode={}", code, e);
|
||||
throw new WechatApiException(502, "Wechat API client error: " + e.getMessage(), null);
|
||||
}
|
||||
|
||||
String responseBody = responseEntity.getBody();
|
||||
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
log.error("[WeChatAPI] non-2xx response, status={}, body={}",
|
||||
responseEntity.getStatusCode(), responseBody);
|
||||
throw new WechatApiException(502,
|
||||
"Wechat API returned non-2xx status: " + responseEntity.getStatusCode(), responseBody);
|
||||
}
|
||||
|
||||
JSONObject result = parseWechatJsonBody(responseEntity);
|
||||
|
||||
int errcode = result.getIntValue("errcode");
|
||||
if (errcode != 0) {
|
||||
String errmsg = result.getString("errmsg");
|
||||
log.error("[WeChatAPI] business error, errcode={}, errmsg={}", errcode, errmsg);
|
||||
throw new WechatApiException(400,
|
||||
"Wechat API business error: errcode=" + errcode + ", errmsg=" + errmsg, responseBody);
|
||||
}
|
||||
|
||||
String openid = result.getString("openid");
|
||||
String sessionKey = result.getString("session_key");
|
||||
if (!StringUtils.hasText(openid) || !StringUtils.hasText(sessionKey)) {
|
||||
log.error("[WeChatAPI] response missing openid or session_key, body={}", responseBody);
|
||||
throw new WechatApiException(502,
|
||||
"Wechat API response missing openid or session_key", responseBody);
|
||||
}
|
||||
|
||||
WechatCodeSessionResponse response = new WechatCodeSessionResponse();
|
||||
response.setOpenid(openid);
|
||||
response.setSessionKey(sessionKey);
|
||||
response.setUnionid(result.getString("unionid"));
|
||||
return response;
|
||||
}
|
||||
|
||||
private JSONObject parseWechatJsonBody(ResponseEntity<String> responseEntity) {
|
||||
String responseBody = responseEntity.getBody();
|
||||
MediaType contentType = responseEntity.getHeaders().getContentType();
|
||||
boolean jsonContentType = contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType);
|
||||
boolean jsonLikeBody = StringUtils.hasText(responseBody)
|
||||
&& (responseBody.trim().startsWith("{") || responseBody.trim().startsWith("["));
|
||||
|
||||
if (!jsonContentType && !jsonLikeBody) {
|
||||
log.error("[WeChatAPI] non-JSON response, contentType={}, body={}", contentType, responseBody);
|
||||
throw new WechatApiException(502,
|
||||
"Wechat API returned a non-JSON response: " + contentType, responseBody);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSONObject.parseObject(responseBody);
|
||||
} catch (Exception e) {
|
||||
log.error("[WeChatAPI] JSON parse failed, contentType={}, body={}", contentType, responseBody, e);
|
||||
throw new WechatApiException(jsonContentType ? 500 : 502,
|
||||
"Wechat API response JSON parse failed: " + e.getMessage(), responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateConfigured() {
|
||||
if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) {
|
||||
throw new BusinessException("微信小程序登录未配置");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ emotion:
|
||||
tts:
|
||||
enabled: true
|
||||
engine-url: http://127.0.0.1:19110
|
||||
engine-connect-timeout-ms: 5000
|
||||
engine-read-timeout-ms: 240000
|
||||
output-dir: /data/uploads/emotion-museum/tts
|
||||
public-url-prefix: /tts/audio
|
||||
max-text-length: 5000
|
||||
|
||||
@@ -73,6 +73,12 @@ emotion:
|
||||
header: Authorization
|
||||
prefix: "Bearer "
|
||||
|
||||
wechat:
|
||||
mini-program:
|
||||
app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wxaf2eaba72d28f0e4}
|
||||
app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:4f087bd363a4147ef543d634f9f6950d}
|
||||
base-url: https://api.weixin.qq.com
|
||||
|
||||
# Coze API配置 - 所有环境统一
|
||||
coze:
|
||||
api:
|
||||
@@ -102,6 +108,8 @@ emotion:
|
||||
tts:
|
||||
enabled: true
|
||||
engine-url: http://127.0.0.1:19110
|
||||
engine-connect-timeout-ms: 5000
|
||||
engine-read-timeout-ms: 240000
|
||||
output-dir: /data/uploads/emotion-museum/tts
|
||||
public-url-prefix: /tts/audio
|
||||
max-text-length: 5000
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.emotion.config;
|
||||
|
||||
import com.emotion.interceptor.WechatApiLoggingInterceptor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RestTemplate 运行时配置集成测试
|
||||
*
|
||||
* <p>目标:验证 8 任务 WeChat 修复计划的核心成功标志 - RestTemplate 实际发送的
|
||||
* User-Agent 是 "Mozilla/5.0 (compatible; EmotionMuseum/1.0)" 而非
|
||||
* 默认的 "Java/17.0.x"(WAF 会拦截 Java UA 导致 text/plain 响应)。
|
||||
*
|
||||
* <p>策略:复现生产代码 RestTemplateConfig.restTemplate() 的构建逻辑,
|
||||
* 对 httpbin.org/headers 发真实 HTTPS 请求,从返回的 JSON 中验证
|
||||
* 实际发送的 User-Agent。
|
||||
*
|
||||
* <p>不依赖 Spring 上下文,纯 JUnit 构造 + 真实网络调用 = 最直接的端到端证据。
|
||||
*/
|
||||
class RestTemplateRuntimeConfigTest {
|
||||
|
||||
private static final String EXPECTED_USER_AGENT = "Mozilla/5.0 (compatible; EmotionMuseum/1.0)";
|
||||
private static final String FORBIDDEN_USER_AGENT_PREFIX = "Java/";
|
||||
|
||||
@Test
|
||||
void restTemplate_RealHttpCallToHttpbin_ConfirmsMozillaUserAgent() {
|
||||
// 复现生产 RestTemplateConfig.restTemplate() 的构建逻辑
|
||||
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
|
||||
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
|
||||
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
|
||||
BufferingClientHttpRequestFactory bufferingFactory =
|
||||
new BufferingClientHttpRequestFactory(simpleFactory);
|
||||
|
||||
RestTemplate restTemplate = new RestTemplateBuilder()
|
||||
.requestFactory(() -> bufferingFactory)
|
||||
.defaultHeader("User-Agent", EXPECTED_USER_AGENT)
|
||||
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
|
||||
.additionalInterceptors(new WechatApiLoggingInterceptor())
|
||||
.errorHandler(new WechatResponseErrorHandler())
|
||||
.build();
|
||||
|
||||
// httpbin.org/headers 返回客户端发送的所有请求头(JSON 格式)
|
||||
String response = restTemplate.getForObject(
|
||||
"https://httpbin.org/headers", String.class);
|
||||
|
||||
assertNotNull(response, "httpbin.org should respond");
|
||||
System.out.println("[RestTemplateRuntimeConfigTest] httpbin response: " + response);
|
||||
|
||||
// 核心断言:实际发送的 User-Agent 是 Mozilla/5.0
|
||||
assertTrue(response.contains(EXPECTED_USER_AGENT),
|
||||
"User-Agent 应为 '" + EXPECTED_USER_AGENT + "',但实际响应: " + response);
|
||||
|
||||
// 反向断言:绝对不能是 Java/* 默认 UA(WAF 拦截根因)
|
||||
assertFalse(response.contains(FORBIDDEN_USER_AGENT_PREFIX),
|
||||
"User-Agent 不应是 Java/*(WAF 会拦截),但实际响应: " + response);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.emotion.exception;
|
||||
|
||||
import com.emotion.common.Result;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* GlobalExceptionHandler 的 WechatApiException handler 单元测试
|
||||
*
|
||||
* 验证:
|
||||
* 1. 400 业务错误 → "微信授权失败,请重试"
|
||||
* 2. 500 内部错误 → "微信登录失败,请重试"
|
||||
* 3. 502 上游错误 → "微信服务暂不可用,请稍后重试"
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerWechatTest {
|
||||
|
||||
@InjectMocks
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
private MockHttpServletRequest request;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
request = new MockHttpServletRequest();
|
||||
request.setMethod("POST");
|
||||
request.setRequestURI("/api/wechat/login");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code400_ReturnsBadRequestWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(400, "errcode=40029, errmsg=invalid code (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信授权失败,请重试", result.getMessage());
|
||||
// Ensure internal detail is NOT exposed to frontend
|
||||
assertFalse(result.getMessage().contains("errcode"));
|
||||
assertFalse(result.getMessage().contains("40029"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code500_ReturnsInternalErrorWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(500, "JSON parse failure at line 1 (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信登录失败,请重试", result.getMessage());
|
||||
assertFalse(result.getMessage().contains("JSON"));
|
||||
assertFalse(result.getMessage().contains("parse"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWechatApiException_Code502_ReturnsBadGatewayWithFriendlyMessage() {
|
||||
WechatApiException ex = new WechatApiException(502, "Connection refused: api.weixin.qq.com (internal detail)", "");
|
||||
|
||||
Result<?> result = handler.handleWechatApiException(ex, request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("微信服务暂不可用,请稍后重试", result.getMessage());
|
||||
assertFalse(result.getMessage().contains("Connection"));
|
||||
assertFalse(result.getMessage().contains("refused"));
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.emotion.interceptor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URI;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* WechatApiLoggingInterceptor 单元测试
|
||||
*
|
||||
* 注:desensitize() 是 private 方法,通过反射测试
|
||||
*/
|
||||
class WechatApiLoggingInterceptorTest {
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskSessionKey() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"openid\":\"oXyz123\",\"session_key\":\"abc@456\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("\"openid\":\"***\""), "openid 应被脱敏: " + result);
|
||||
assertTrue(result.contains("\"session_key\":\"***\""), "session_key 应被脱敏: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskAccessToken() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"access_token\":\"ACCESS-123\",\"refresh_token\":\"REFRESH-456\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertTrue(result.contains("\"access_token\":\"***\""));
|
||||
assertTrue(result.contains("\"refresh_token\":\"***\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldHandleNull() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
assertEquals(null, method.invoke(interceptor, (Object) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldNotModifyNonSensitiveFields() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"errcode\":40013,\"errmsg\":\"invalid appid\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertEquals(input, result, "非敏感字段不应被修改");
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldNotMatchAccessTokenV2() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
// 验证 access_token_v2 不会被 access_token 模式误匹配
|
||||
String input = "{\"access_token_v2\":\"PUBLIC_VALUE\",\"my_reset_secret\":\"OK\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
// access_token_v2 不应被脱敏(正则精确匹配完整 key 名称)
|
||||
// my_reset_secret 也不应被脱敏(正则需要精确匹配 "secret" 而非子串)
|
||||
assertTrue(result.contains("\"access_token_v2\":\"PUBLIC_VALUE\""), "access_token_v2 不应被脱敏: " + result);
|
||||
assertTrue(result.contains("\"my_reset_secret\":\"OK\""), "my_reset_secret 不应被脱敏: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldHandleEmptyString() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String result = (String) method.invoke(interceptor, "");
|
||||
assertEquals("", result, "空字符串应原样返回");
|
||||
}
|
||||
|
||||
@Test
|
||||
void desensitize_shouldMaskMultipleSensitiveFieldsSimultaneously() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("desensitize", String.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String input = "{\"openid\":\"o123\",\"access_token\":\"tok\",\"unionid\":\"uni456\",\"refresh_token\":\"ref789\"}";
|
||||
String result = (String) method.invoke(interceptor, input);
|
||||
|
||||
assertTrue(result.contains("\"openid\":\"***\""));
|
||||
assertTrue(result.contains("\"access_token\":\"***\""));
|
||||
assertTrue(result.contains("\"unionid\":\"***\""));
|
||||
assertTrue(result.contains("\"refresh_token\":\"***\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskSensitiveQuery_shouldMaskWechatCredentialsInUrl() throws Exception {
|
||||
WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor();
|
||||
Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("maskSensitiveQuery", URI.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String result = (String) method.invoke(interceptor,
|
||||
URI.create("https://api.weixin.qq.com/sns/jscode2session?appid=wx123&secret=s1&js_code=c1&grant_type=authorization_code"));
|
||||
|
||||
assertTrue(result.contains("appid=wx123"));
|
||||
assertTrue(result.contains("secret=***"));
|
||||
assertTrue(result.contains("js_code=***"));
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.config.WechatMiniProgramProperties;
|
||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||
import com.emotion.exception.WechatApiException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WechatMiniProgramServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@Mock
|
||||
private WechatMiniProgramProperties properties;
|
||||
|
||||
@InjectMocks
|
||||
private WechatMiniProgramServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(properties.getAppId()).thenReturn("test-appid");
|
||||
when(properties.getAppSecret()).thenReturn("test-secret");
|
||||
when(properties.getBaseUrl()).thenReturn("https://api.weixin.qq.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_applicationJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}");
|
||||
|
||||
WechatCodeSessionResponse result = service.code2Session("validJsCode");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("oXyz123", result.getOpenid());
|
||||
assertEquals("abc456", result.getSessionKey());
|
||||
verify(restTemplate, times(1)).getForEntity(anyString(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_http4xx_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.BAD_REQUEST, null, "");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("non-2xx") || ex.getMessage().contains("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_http5xx_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.INTERNAL_SERVER_ERROR, null, "");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_textPlainJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||
mockResponse(HttpStatus.OK, MediaType.TEXT_PLAIN,
|
||||
"{\"session_key\":\"abc456\",\"openid\":\"oXyz123\"}");
|
||||
|
||||
WechatCodeSessionResponse result = service.code2Session("code");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("oXyz123", result.getOpenid());
|
||||
assertEquals("abc456", result.getSessionKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_textHtmlWafChallenge_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.TEXT_HTML, "<html>WAF Challenge</html>");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_applicationJsonParseFailure_throwsWechatApiException500() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, "not valid json{");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(500, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("JSON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_businessErrorInvalidCode_throwsWechatApiException400() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":40029,\"errmsg\":\"invalid code\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("invalidCode"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_businessErrorCodeUsed_throwsWechatApiException400() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":40163,\"errmsg\":\"code been used\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("usedCode"));
|
||||
|
||||
assertEquals(400, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_resourceAccessException_throwsWechatApiException502() {
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
||||
.thenThrow(new ResourceAccessException("Connection refused"));
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("network"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_missingSessionKey_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"openid\":\"oXyz123\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("session_key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_otherRestClientException_throwsWechatApiException502() {
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
||||
.thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null));
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("client error") || ex.getMessage().contains("403"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void code2Session_missingOpenId_throwsWechatApiException502() {
|
||||
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||
"{\"errcode\":0,\"session_key\":\"abc456\"}");
|
||||
|
||||
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||
|
||||
assertEquals(502, ex.getCode());
|
||||
assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key"));
|
||||
}
|
||||
|
||||
private void mockResponse(HttpStatus status, MediaType contentType, String body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (contentType != null) {
|
||||
headers.setContentType(contentType);
|
||||
}
|
||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, status);
|
||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ Purpose: 统一部署所有服务到生产服务器,替代原有的多个 shel
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
@@ -35,6 +36,70 @@ if hasattr(sys.stdout, 'buffer'):
|
||||
# 当前 Python 可执行文件(Windows 用 python.exe,Linux/Mac 用 python3)
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 环境变量修复(Windows 专用)
|
||||
# ============================================================================
|
||||
def _build_full_path():
|
||||
"""构造包含所有关键目录的完整 PATH 字符串。
|
||||
|
||||
Windows cmd.exe 启动时会用自己的"标准 PATH"(只有 2 段用户 PATH),
|
||||
完全忽略父进程传入的 PATH 变量。解决办法是在每个 cmd.exe 命令前
|
||||
用 `set "PATH=..."` 显式设置。这个函数返回要设置的完整 PATH。
|
||||
"""
|
||||
if sys.platform != 'win32':
|
||||
return None
|
||||
parts = [
|
||||
r'C:\Windows\system32',
|
||||
r'C:\Windows',
|
||||
r'C:\Windows\System32\Wbem',
|
||||
r'C:\Windows\System32\OpenSSH',
|
||||
]
|
||||
# 从已知环境变量自动推断关键目录(不硬编码)
|
||||
if os.environ.get('MAVEN_HOME'):
|
||||
parts.append(os.environ['MAVEN_HOME'] + r'\bin')
|
||||
if os.environ.get('JAVA_HOME'):
|
||||
parts.append(os.environ['JAVA_HOME'] + r'\bin')
|
||||
# Node.js 常见安装位置自动检测
|
||||
for d in [r'C:\Program Files\nodejs', r'F:\Program Files\nodejs',
|
||||
r'C:\Program Files (x86)\nodejs', r'F:\Programs\nvm']:
|
||||
if Path(d).exists():
|
||||
parts.append(d)
|
||||
# 保留用户已有的 PATH(不丢失用户自定义工具)
|
||||
user_path = os.environ.get('PATH', '')
|
||||
for p in user_path.split(';'):
|
||||
if p and p not in parts:
|
||||
parts.append(p)
|
||||
return ';'.join(parts)
|
||||
|
||||
|
||||
def _wrap_cmd_for_windows(cmd):
|
||||
"""为 Windows cmd.exe 命令包装 set PATH 头部,解决 PATH 截断问题。
|
||||
|
||||
原因:cmd.exe 启动后 PATH 只有 2 段(pnpm/npm),找不到 mvn/node/npm。
|
||||
修复:在每个命令前用 `set "PATH=完整路径" &&` 显式设置 PATH。
|
||||
"""
|
||||
if sys.platform != 'win32':
|
||||
return cmd
|
||||
full_path = _build_full_path()
|
||||
return f'set "PATH={full_path}" && {cmd}'
|
||||
|
||||
|
||||
def _ensure_env():
|
||||
"""修复 Windows 子进程编码问题 + 准备 PATH 工具。
|
||||
|
||||
Windows cmd.exe 启动时强制用 GBK 编码输出(即使父进程是 UTF-8),
|
||||
设置 PYTHONIOENCODING/PYTHONUTF8 强制 Python 子进程使用 UTF-8。
|
||||
实际 PATH 修复在 _wrap_cmd_for_windows() 中按需包装每个命令。
|
||||
"""
|
||||
if sys.platform != 'win32':
|
||||
return
|
||||
# 强制 Python 子进程使用 UTF-8 编码
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
os.environ.setdefault('PYTHONUTF8', '1')
|
||||
os.environ.setdefault('PYTHONLEGACYWINDOWSSTDIO', '0')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
@@ -84,21 +149,23 @@ def log_section(msg):
|
||||
|
||||
|
||||
def run_command(cmd, cwd=None, timeout=120, capture=True):
|
||||
"""执行本地命令"""
|
||||
"""执行本地命令(自动处理 Windows cmd.exe PATH 截断)"""
|
||||
# 设置 PYTHONUNBUFFERED=1 确保 Python 子进程不缓冲 stdout
|
||||
env = os.environ.copy()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
# Windows 上包装 set PATH 前缀,解决 cmd.exe PATH 截断问题
|
||||
effective_cmd = _wrap_cmd_for_windows(cmd)
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, env=env,
|
||||
effective_cmd, shell=True, cwd=cwd, env=env,
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=timeout
|
||||
)
|
||||
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
||||
else:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, env=env, timeout=timeout
|
||||
effective_cmd, shell=True, cwd=cwd, env=env, timeout=timeout
|
||||
)
|
||||
return result.returncode == 0, "", ""
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -158,6 +225,137 @@ def check_ssh():
|
||||
log_info("SSH 连接正常")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Nginx 站点启用(防御性诊断 + 清理)
|
||||
# ============================================================================
|
||||
|
||||
# 变量安全校验正则
|
||||
_SAFE_DOMAIN = re.compile(r'^[a-zA-Z0-9.-]+$')
|
||||
_SAFE_REMOTE_CONF = re.compile(r'^[a-zA-Z0-9./_-]+$')
|
||||
|
||||
|
||||
def _validate_nginx_inputs(remote_conf: str, domain: str) -> bool:
|
||||
"""校验 domain 和 remote_conf 防止 shell 注入和路径遍历。
|
||||
|
||||
Returns: True 合法;False 非法(已 log_error)
|
||||
"""
|
||||
if not domain or not _SAFE_DOMAIN.match(domain):
|
||||
log_error(f"非法 domain 名称: {domain!r}")
|
||||
return False
|
||||
if domain.startswith('-') or domain.endswith('-') \
|
||||
or domain.startswith('.') or domain.endswith('.') \
|
||||
or '..' in domain:
|
||||
log_error(f"非法 domain 结构: {domain!r} (首尾连字符/点 或 连续点非法)")
|
||||
return False
|
||||
if not remote_conf or not _SAFE_REMOTE_CONF.match(remote_conf):
|
||||
log_error(f"非法 remote_conf 路径: {remote_conf!r}")
|
||||
return False
|
||||
if '//' in remote_conf or '..' in remote_conf:
|
||||
log_error(f"remote_conf 包含可疑路径片段: {remote_conf!r}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def enable_nginx_site(remote_conf: str, domain: str) -> bool:
|
||||
"""幂等地启用 nginx 站点(sites-enabled symlink)。
|
||||
|
||||
流程:
|
||||
1. 诊断目标路径当前状态(symlink/dir/file/missing)
|
||||
2. 清理异常状态(目录/文件/断链);失败短路
|
||||
3. 二次检查 + 创建 symlink
|
||||
4. 移除 default 站点(失败仅 warning)
|
||||
5. 验证 symlink + default 状态
|
||||
|
||||
Returns: True 成功;False 失败(stderr 已打印)
|
||||
"""
|
||||
if not _validate_nginx_inputs(remote_conf, domain):
|
||||
return False
|
||||
|
||||
target = f"/etc/nginx/sites-enabled/{domain}.conf"
|
||||
log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
|
||||
|
||||
# ===== 步骤 1: 诊断目标路径状态 =====
|
||||
log_info("诊断步骤: 检查目标路径...")
|
||||
diagnose_cmd = (
|
||||
f'test -e {target} && '
|
||||
f'(test -L {target} && echo "symlink" || '
|
||||
f'(test -d {target} && echo "dir" || echo "file")) || '
|
||||
f'echo "missing"'
|
||||
)
|
||||
ok, status, err = ssh_command(diagnose_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"诊断步骤失败: {err}")
|
||||
return False
|
||||
status = status.strip()
|
||||
log_info(f"诊断结果: {status}")
|
||||
|
||||
# ===== 步骤 2: 清理异常状态 =====
|
||||
log_info("清理步骤: 处理异常状态...")
|
||||
cleanup_cmd = (
|
||||
f'case "{status}" in '
|
||||
f'symlink) unlink {target} ;; '
|
||||
f'dir) rm -rf {target} ;; '
|
||||
f'file) rm -f {target} ;; '
|
||||
f'missing) ;; '
|
||||
f'esac'
|
||||
)
|
||||
ok, _, err = ssh_command(cleanup_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"清理步骤失败: {err}")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
log_error("提示: 权限可能不足(检查 /etc/nginx/sites-enabled 写权限)")
|
||||
return False
|
||||
log_info("清理步骤完成")
|
||||
|
||||
# ===== 步骤 3: 建链(两段式:先二次检查,再 ln -snf) =====
|
||||
log_info("建链步骤: 创建 symlink...")
|
||||
# 第一段:二次检查 target 不存在(防并发进程干扰)
|
||||
guard_cmd = f'if [ -e {target} ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi'
|
||||
ok, _, err = ssh_command(guard_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"建链步骤失败 - 二次检查: {err}")
|
||||
log_error("提示: 并发进程干扰(其他进程在步骤 2 后、步骤 3 前创建了同名项)")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
return False
|
||||
# 第二段:执行 ln -snf(独立 SSH 调用,错误信息不会被二次检查误报)
|
||||
link_cmd = f'ln -snf {remote_conf} {target}'
|
||||
ok, _, err = ssh_command(link_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"建链步骤失败 - ln 执行: {err}")
|
||||
log_error("提示: 确认 scp_file 是否成功上传了 sites-available 下的 conf 文件")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
return False
|
||||
log_info("建链步骤完成")
|
||||
|
||||
# ===== 步骤 4: 清理 default 站点(失败仅 warning) =====
|
||||
log_info("清默认步骤: 移除 default 站点...")
|
||||
ok, _, err = ssh_command("rm -f /etc/nginx/sites-enabled/default", timeout=30)
|
||||
if not ok:
|
||||
log_warn(f"清默认步骤失败(不阻塞): {err}")
|
||||
log_warn("提示: default 文件可能仍存在,nginx 可能有端口冲突;步骤 5 验证会确认")
|
||||
else:
|
||||
log_info("清默认步骤完成")
|
||||
|
||||
# ===== 步骤 5: 验证结果 =====
|
||||
log_info("验证步骤: 检查 symlink 和 default 状态...")
|
||||
verify_cmd = (
|
||||
f'readlink {target} && '
|
||||
f'(test ! -e /etc/nginx/sites-enabled/default && echo "default:OK" || echo "default:STILL_EXISTS") && '
|
||||
f'ls -la /etc/nginx/sites-enabled/'
|
||||
)
|
||||
ok, stdout, err = ssh_command(verify_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"验证步骤失败: {err}")
|
||||
log_error(f"实际输出: {stdout}")
|
||||
return False
|
||||
log_info("验证结果:")
|
||||
for line in stdout.split('\n'):
|
||||
if line.strip():
|
||||
log_info(f" {line}")
|
||||
log_info("启用站点配置完成")
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Nginx 配置
|
||||
# ============================================================================
|
||||
@@ -181,12 +379,8 @@ def deploy_nginx():
|
||||
return False
|
||||
|
||||
log_info("启用站点配置...")
|
||||
ok, _, err = ssh_command(
|
||||
f"ln -snf {remote_conf} /etc/nginx/sites-enabled/{DOMAIN}.conf "
|
||||
f"&& rm -f /etc/nginx/sites-enabled/default"
|
||||
)
|
||||
if not ok:
|
||||
log_error(f"启用站点失败: {err}")
|
||||
if not enable_nginx_site(remote_conf, DOMAIN):
|
||||
log_error("启用站点失败(详见 enable_nginx_site 内部日志)")
|
||||
return False
|
||||
|
||||
log_info("验证 Nginx 配置...")
|
||||
@@ -365,6 +559,9 @@ def print_usage():
|
||||
|
||||
|
||||
def main():
|
||||
# 修复 Windows cmd.exe 子进程环境块截断问题
|
||||
_ensure_env()
|
||||
|
||||
deploy_type = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
|
||||
if deploy_type in ("--help", "-h", "help"):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
# deploy.py nginx 站点启用超时修复 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复 `python deploy.py` 中 nginx 站点启用命令 30s 超时问题,通过将链式 `ln -snf && rm -f` 拆为 5 步独立 SSH 调用 + 前置诊断清理。
|
||||
|
||||
**Architecture:** 在 `deploy.py` 中新增 `enable_nginx_site(remote_conf, domain)` 函数(5 步骤:诊断→清理→建链→清默认→验证),替换 `deploy_nginx` 第 250-253 行的链式命令。每步独立 `ssh_command(timeout=30)` 调用,失败立即停止;变量入口校验防御 shell 注入。
|
||||
|
||||
**Tech Stack:** Python 3.10+, subprocess (ssh/scp), bash test/case/ln 内置命令
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
**修改**:`G:\IdeaProjects\emotion-museun\deploy.py`
|
||||
- 新增函数 `enable_nginx_site`(在 `deploy_nginx` 之前,约 70 行)
|
||||
- 修改 `deploy_nginx`(第 250-256 行,替换链式命令为函数调用)
|
||||
|
||||
**不修改**:
|
||||
- `ssh_command()` / `run_ssh_args()` / `scp_file()` 底层
|
||||
- `conf/emotion-museum.conf` nginx 配置文件
|
||||
- 其他 deploy.py 子脚本
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 添加 `enable_nginx_site` 函数骨架 + 变量安全校验
|
||||
|
||||
**Files:**
|
||||
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:230-266`(在 `deploy_nginx` 前插入新函数)
|
||||
|
||||
- [ ] **Step 1: 在 `deploy_nginx` 之前插入新函数骨架**
|
||||
|
||||
**Step 1a**:先在文件顶部 imports 区(第 23-28 行附近)追加 `import re`:
|
||||
|
||||
将 `deploy.py` 第 23-28 行:
|
||||
```python
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
```
|
||||
|
||||
改为:
|
||||
```python
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
```
|
||||
|
||||
**Step 1b**:在 `deploy.py` 第 230 行(`# Nginx 配置` 注释之前)插入以下代码:
|
||||
|
||||
```python
|
||||
# ============================================================================
|
||||
# Nginx 站点启用(防御性诊断 + 清理)
|
||||
# ============================================================================
|
||||
|
||||
# 变量安全校验正则
|
||||
_SAFE_DOMAIN = re.compile(r'^[a-zA-Z0-9.-]+$')
|
||||
_SAFE_REMOTE_CONF = re.compile(r'^[a-zA-Z0-9./_-]+$')
|
||||
|
||||
|
||||
def _validate_nginx_inputs(remote_conf: str, domain: str) -> bool:
|
||||
"""校验 domain 和 remote_conf 防止 shell 注入和路径遍历。
|
||||
|
||||
Returns: True 合法;False 非法(已 log_error)
|
||||
"""
|
||||
if not domain or not _SAFE_DOMAIN.match(domain):
|
||||
log_error(f"非法 domain 名称: {domain!r}")
|
||||
return False
|
||||
if domain.startswith('-') or domain.endswith('-') \
|
||||
or domain.startswith('.') or domain.endswith('.') \
|
||||
or '..' in domain:
|
||||
log_error(f"非法 domain 结构: {domain!r} (首尾连字符/点 或 连续点非法)")
|
||||
return False
|
||||
if not remote_conf or not _SAFE_REMOTE_CONF.match(remote_conf):
|
||||
log_error(f"非法 remote_conf 路径: {remote_conf!r}")
|
||||
return False
|
||||
if '//' in remote_conf or '..' in remote_conf:
|
||||
log_error(f"remote_conf 包含可疑路径片段: {remote_conf!r}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def enable_nginx_site(remote_conf: str, domain: str) -> bool:
|
||||
"""幂等地启用 nginx 站点(sites-enabled symlink)。
|
||||
|
||||
流程:
|
||||
1. 诊断目标路径当前状态(symlink/dir/file/missing)
|
||||
2. 清理异常状态(目录/文件/断链);失败短路
|
||||
3. 二次检查 + 创建 symlink
|
||||
4. 移除 default 站点(失败仅 warning)
|
||||
5. 验证 symlink + default 状态
|
||||
|
||||
Returns: True 成功;False 失败(stderr 已打印)
|
||||
"""
|
||||
if not _validate_nginx_inputs(remote_conf, domain):
|
||||
return False
|
||||
|
||||
target = f"/etc/nginx/sites-enabled/{domain}.conf"
|
||||
log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
|
||||
return True # 后续 Task 替换为完整 5 步实现
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 Python 语法正确**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
|
||||
Expected: 退出码 0,无输出(语法正确)
|
||||
|
||||
- [ ] **Step 3: 验证变量校验逻辑**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/sites-available/lifescript.happylifeos.com.conf', 'lifescript.happylifeos.com'))"`
|
||||
Expected: 输出 `True`
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/sites-available/test.conf', '-evil.com'))"`
|
||||
Expected: 输出 `False`(同时 stderr 出现 `[ERROR] 非法 domain 结构`)
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import _validate_nginx_inputs; print(_validate_nginx_inputs('/etc/nginx/../etc/passwd', 'example.com'))"`
|
||||
Expected: 输出 `False`(同时 stderr 出现 `[ERROR] remote_conf 包含可疑路径片段`)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add deploy.py
|
||||
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 添加 enable_nginx_site 函数骨架 + 变量安全校验"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 实现步骤 1(诊断)+ 步骤 2(清理,case 注入 status)
|
||||
|
||||
**Files:**
|
||||
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体
|
||||
|
||||
- [ ] **Step 1: 替换 `enable_nginx_site` 末尾的 `return True` 为 5 步骤实现(部分)**
|
||||
|
||||
将 `enable_nginx_site` 函数末尾的:
|
||||
```python
|
||||
log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
|
||||
return True # 后续 Task 替换为完整 5 步实现
|
||||
```
|
||||
|
||||
替换为:
|
||||
```python
|
||||
log_info(f"启用 nginx 站点: {target} -> {remote_conf}")
|
||||
|
||||
# ===== 步骤 1: 诊断目标路径状态 =====
|
||||
log_info("诊断步骤: 检查目标路径...")
|
||||
diagnose_cmd = (
|
||||
f'test -e {target} && '
|
||||
f'(test -L {target} && echo "symlink" || '
|
||||
f'(test -d {target} && echo "dir" || echo "file")) || '
|
||||
f'echo "missing"'
|
||||
)
|
||||
ok, status, err = ssh_command(diagnose_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"诊断步骤失败: {err}")
|
||||
return False
|
||||
status = status.strip()
|
||||
log_info(f"诊断结果: {status}")
|
||||
|
||||
# ===== 步骤 2: 清理异常状态 =====
|
||||
log_info("清理步骤: 处理异常状态...")
|
||||
cleanup_cmd = (
|
||||
f'case "{status}" in '
|
||||
f'symlink) unlink {target} ;; '
|
||||
f'dir) rm -rf {target} ;; '
|
||||
f'file) rm -f {target} ;; '
|
||||
f'missing) ;; '
|
||||
f'esac'
|
||||
)
|
||||
ok, _, err = ssh_command(cleanup_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"清理步骤失败: {err}")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
log_error("提示: 权限可能不足(检查 /etc/nginx/sites-enabled 写权限)")
|
||||
return False
|
||||
log_info("清理步骤完成")
|
||||
return True # Task 3 替换为完整建链+清默认
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 Python 语法**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
|
||||
Expected: 退出码 0
|
||||
|
||||
- [ ] **Step 3: 验证 SSH 命令构造正确(不执行 SSH)**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "
|
||||
from deploy import _validate_nginx_inputs
|
||||
target = '/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf'
|
||||
status = 'dir'
|
||||
diagnose_cmd = f'test -e {target} && (test -L {target} && echo symlink || (test -d {target} && echo dir || echo file)) || echo missing'
|
||||
cleanup_cmd = f'case \"{status}\" in symlink) unlink {target} ;; dir) rm -rf {target} ;; file) rm -f {target} ;; missing) ;; esac'
|
||||
print('DIAGNOSE:', diagnose_cmd)
|
||||
print('CLEANUP:', cleanup_cmd)
|
||||
"`
|
||||
Expected:
|
||||
```
|
||||
DIAGNOSE: test -e /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && (test -L /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && echo symlink || (test -d /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && echo dir || echo file)) || echo missing
|
||||
CLEANUP: case "dir" in symlink) unlink /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; dir) rm -rf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; file) rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf ;; missing) ;; esac
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add deploy.py
|
||||
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 1(诊断)+ 步骤 2(清理)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 实现步骤 3(建链,两段式)+ 步骤 4(清 default)
|
||||
|
||||
**Files:**
|
||||
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体
|
||||
|
||||
- [ ] **Step 1: 在清理步骤后插入步骤 3 和 4**
|
||||
|
||||
将 `enable_nginx_site` 函数末尾的 `return True # Task 3 替换为完整建链+清默认` 替换为:
|
||||
|
||||
```python
|
||||
log_info("清理步骤完成")
|
||||
|
||||
# ===== 步骤 3: 建链(两段式:先二次检查,再 ln -snf) =====
|
||||
log_info("建链步骤: 创建 symlink...")
|
||||
# 第一段:二次检查 target 不存在(防并发进程干扰)
|
||||
guard_cmd = f'if [ -e {target} ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi'
|
||||
ok, _, err = ssh_command(guard_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"建链步骤失败 - 二次检查: {err}")
|
||||
log_error("提示: 并发进程干扰(其他进程在步骤 2 后、步骤 3 前创建了同名项)")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
return False
|
||||
# 第二段:执行 ln -snf(独立 SSH 调用,错误信息不会被二次检查误报)
|
||||
link_cmd = f'ln -snf {remote_conf} {target}'
|
||||
ok, _, err = ssh_command(link_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"建链步骤失败 - ln 执行: {err}")
|
||||
log_error("提示: 确认 scp_file 是否成功上传了 sites-available 下的 conf 文件")
|
||||
log_error("上下文: 目标 = " + target)
|
||||
return False
|
||||
log_info("建链步骤完成")
|
||||
|
||||
# ===== 步骤 4: 清理 default 站点(失败仅 warning) =====
|
||||
log_info("清默认步骤: 移除 default 站点...")
|
||||
ok, _, err = ssh_command("rm -f /etc/nginx/sites-enabled/default", timeout=30)
|
||||
if not ok:
|
||||
log_warn(f"清默认步骤失败(不阻塞): {err}")
|
||||
log_warn("提示: default 文件可能仍存在,nginx 可能有端口冲突;步骤 5 验证会确认")
|
||||
else:
|
||||
log_info("清默认步骤完成")
|
||||
return True # Task 4 替换为完整 5 步(含验证)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 Python 语法**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
|
||||
Expected: 退出码 0
|
||||
|
||||
- [ ] **Step 3: 验证 SSH 命令构造(两段式)**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "
|
||||
target = '/etc/nginx/sites-enabled/test.com.conf'
|
||||
remote_conf = '/etc/nginx/sites-available/test.com.conf'
|
||||
guard_cmd = f'if [ -e {target} ]; then echo \"二次检查失败:target 仍存在\" >&2; exit 1; fi'
|
||||
link_cmd = f'ln -snf {remote_conf} {target}'
|
||||
print('GUARD:', guard_cmd)
|
||||
print('LINK:', link_cmd)
|
||||
"`
|
||||
Expected:
|
||||
```
|
||||
GUARD: if [ -e /etc/nginx/sites-enabled/test.com.conf ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi
|
||||
LINK: ln -snf /etc/nginx/sites-available/test.com.conf /etc/nginx/sites-enabled/test.com.conf
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add deploy.py
|
||||
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 3(两段式建链)+ 步骤 4(清 default)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 实现步骤 5(验证 symlink + default)+ 集成到 deploy_nginx
|
||||
|
||||
**Files:**
|
||||
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:enable_nginx_site` 函数体(末尾)
|
||||
- Modify: `G:\IdeaProjects\emotion-museun\deploy.py:249-256`(deploy_nginx 内部)
|
||||
|
||||
- [ ] **Step 1: 在 `enable_nginx_site` 末尾插入步骤 5**
|
||||
|
||||
将 `enable_nginx_site` 函数末尾的 `return True # Task 4 替换为完整 5 步(含验证)` 替换为:
|
||||
|
||||
```python
|
||||
else:
|
||||
log_info("清默认步骤完成")
|
||||
|
||||
# ===== 步骤 5: 验证结果 =====
|
||||
log_info("验证步骤: 检查 symlink 和 default 状态...")
|
||||
verify_cmd = (
|
||||
f'readlink {target} && '
|
||||
f'(test ! -e /etc/nginx/sites-enabled/default && echo "default:OK" || echo "default:STILL_EXISTS") && '
|
||||
f'ls -la /etc/nginx/sites-enabled/'
|
||||
)
|
||||
ok, stdout, err = ssh_command(verify_cmd, timeout=30)
|
||||
if not ok:
|
||||
log_error(f"验证步骤失败: {err}")
|
||||
log_error(f"实际输出: {stdout}")
|
||||
return False
|
||||
log_info("验证结果:")
|
||||
for line in stdout.split('\n'):
|
||||
if line.strip():
|
||||
log_info(f" {line}")
|
||||
log_info("启用站点配置完成")
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 `deploy_nginx`,替换链式命令为函数调用**
|
||||
|
||||
将 `deploy.py` 第 249-256 行的:
|
||||
|
||||
```python
|
||||
log_info("启用站点配置...")
|
||||
ok, _, err = ssh_command(
|
||||
f"ln -snf {remote_conf} /etc/nginx/sites-enabled/{DOMAIN}.conf "
|
||||
f"&& rm -f /etc/nginx/sites-enabled/default"
|
||||
)
|
||||
if not ok:
|
||||
log_error(f"启用站点失败: {err}")
|
||||
return False
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```python
|
||||
log_info("启用站点配置...")
|
||||
if not enable_nginx_site(remote_conf, DOMAIN):
|
||||
log_error("启用站点失败(详见 enable_nginx_site 内部日志)")
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 Python 语法**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "import ast; ast.parse(open('deploy.py', encoding='utf-8').read())"`
|
||||
Expected: 退出码 0
|
||||
|
||||
- [ ] **Step 4: 验证函数可正常导入**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import enable_nginx_site, _validate_nginx_inputs; print('import OK')"`
|
||||
Expected: 输出 `import OK`
|
||||
|
||||
- [ ] **Step 5: 验证函数签名和文档**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "
|
||||
from deploy import enable_nginx_site
|
||||
import inspect
|
||||
sig = inspect.signature(enable_nginx_site)
|
||||
print('Signature:', sig)
|
||||
print('Docstring (first line):', enable_nginx_site.__doc__.split(chr(10))[0])
|
||||
"`
|
||||
Expected:
|
||||
```
|
||||
Signature: (remote_conf: str, domain: str) -> bool
|
||||
Docstring (first line): 幂等地启用 nginx 站点(sites-enabled symlink)。
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add deploy.py
|
||||
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "feat(deploy): 实现 enable_nginx_site 步骤 5(验证)+ 集成到 deploy_nginx"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 端到端验证(CLAUDE.md 强制规则)
|
||||
|
||||
**Files:**
|
||||
- 无文件修改
|
||||
|
||||
- [ ] **Step 1: SSH 连接预检**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python deploy.py verify 2>&1 | head -30`
|
||||
Expected: SSH 连接成功(看到 `[INFO] SSH 连接正常`)
|
||||
|
||||
若失败:
|
||||
- 检查 `~/.ssh/config` 是否有 `101.200.208.45` 免密配置
|
||||
- 手动 `ssh root@101.200.208.45 echo ok` 测试
|
||||
|
||||
- [ ] **Step 2: 执行 nginx 部署**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python deploy.py nginx 2>&1 | tee C:\Users\peanu\AppData\Local\Temp\opencode\nginx-deploy-validation.txt`
|
||||
Expected: 完整流程成功,无 30s 超时;看到:
|
||||
```
|
||||
[INFO] 部署 Nginx 配置
|
||||
[INFO] 上传 Nginx 配置文件...
|
||||
[INFO] 启用站点配置...
|
||||
[INFO] 启用 nginx 站点: /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf -> /etc/nginx/sites-available/lifescript.happylifeos.com.conf
|
||||
[INFO] 诊断步骤: 检查目标路径...
|
||||
[INFO] 诊断结果: <symlink|dir|file|missing>
|
||||
[INFO] 清理步骤: 处理异常状态...
|
||||
[INFO] 清理步骤完成
|
||||
[INFO] 建链步骤: 创建 symlink...
|
||||
[INFO] 建链步骤完成
|
||||
[INFO] 清默认步骤: 移除 default 站点...
|
||||
[INFO] 清默认步骤完成
|
||||
[INFO] 验证步骤: 检查 symlink 和 default 状态...
|
||||
[INFO] 验证结果:
|
||||
[INFO] <readlink 输出>
|
||||
[INFO] default:OK
|
||||
[INFO] <ls -la 输出>
|
||||
[INFO] 启用站点配置完成
|
||||
[INFO] 验证 Nginx 配置...
|
||||
[INFO] Nginx 配置重载完成
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证远程 nginx 状态**
|
||||
|
||||
Run: `cd G:\IdeaProjects\emotion-museun && python -c "from deploy import ssh_command; print(ssh_command('ls -la /etc/nginx/sites-enabled/', timeout=15))"`
|
||||
Expected: 输出 `/etc/nginx/sites-enabled/` 目录列表,包含有效 symlink(如 `lifescript.happylifeos.com.conf -> /etc/nginx/sites-available/lifescript.happylifeos.com.conf`),**不包含** `default`
|
||||
|
||||
- [ ] **Step 4: 验证 nginx 服务可访问**
|
||||
|
||||
Run: `curl -I https://lifescript.happylifeos.com/ 2>&1 | head -5`
|
||||
Expected: HTTP 200 或 301/302(站点可达)
|
||||
|
||||
- [ ] **Step 5: Commit 验证记录**
|
||||
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git add -A
|
||||
git -c user.name="deploy-bot" -c user.email="bot@local" commit -m "test: 端到端验证 nginx 部署成功(见 opencode temp 验证日志)" --allow-empty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证场景(设计文档要求)
|
||||
|
||||
以下场景可在 Task 5 验证后**可选**执行(手动复现异常状态):
|
||||
|
||||
### 场景 A: 断链场景
|
||||
```bash
|
||||
ssh root@101.200.208.45 "ln -s /nonexistent /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
|
||||
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
|
||||
```
|
||||
Expected: 诊断结果 = `symlink`,步骤 2 用 `unlink` 清理,步骤 3 重建成功
|
||||
|
||||
### 场景 B: 目录场景
|
||||
```bash
|
||||
ssh root@101.200.208.45 "rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf && mkdir /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
|
||||
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
|
||||
```
|
||||
Expected: 诊断结果 = `dir`,步骤 2 用 `rm -rf` 清理,步骤 3 重建成功
|
||||
|
||||
### 场景 C: 干净场景
|
||||
```bash
|
||||
ssh root@101.200.208.45 "rm -f /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
|
||||
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
|
||||
```
|
||||
Expected: 诊断结果 = `missing`,跳过清理,步骤 3 直接建链
|
||||
|
||||
### 场景 D: 正常 symlink 场景(幂等性)
|
||||
```bash
|
||||
ssh root@101.200.208.45 "ln -snf /etc/nginx/sites-available/lifescript.happylifeos.com.conf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf"
|
||||
cd G:\IdeaProjects\emotion-museun && python deploy.py nginx
|
||||
```
|
||||
Expected: 诊断结果 = `symlink`,步骤 2 用 `unlink`,步骤 3 重建(**幂等**:重复执行结果相同)
|
||||
|
||||
---
|
||||
|
||||
## 回滚
|
||||
|
||||
如新代码有问题:
|
||||
```bash
|
||||
cd G:\IdeaProjects\emotion-museun
|
||||
git log --oneline deploy.py # 找到本次 4 个 commit
|
||||
git revert <commit-hash>..HEAD # 批量回滚
|
||||
# 或:
|
||||
git checkout HEAD~4 -- deploy.py # 恢复到本次修改前
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险提醒
|
||||
|
||||
1. **首次部署若远程 nginx 配置异常**:清理步骤可能误删用户手动创建的目录 → 建议首次部署前先 SSH 检查 `/etc/nginx/sites-enabled/` 状态
|
||||
2. **SSH 免密登录失效**:所有 ssh_command 调用都会失败,Task 5 Step 1 会捕获
|
||||
3. **网络慢导致 30s 超时**:单步 30s 对常规操作足够;如遇网络问题可临时改为 `timeout=60`(在调用处覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 后续可选增强(不在本次范围)
|
||||
|
||||
- `disable_nginx_site()` 反向操作
|
||||
- 提取到独立模块 `deploy_nginx_helpers.py`
|
||||
- 增加 `--dry-run` 模式
|
||||
- 步骤级重试机制
|
||||
@@ -0,0 +1,167 @@
|
||||
# 登录路由跳转修复 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复小程序登录成功后错误进入编辑资料页的问题,确保登录后统一进入爽文生成页面。
|
||||
|
||||
**Architecture:** 移除登录流程中的 `hasProfile` 分支判断,将 `login/index.vue` 和 `splash/index.vue` 中的登录后路由固定指向 `/pages/main/index?tab=script`。编辑资料页面保留,仅通过"我的" tab 主动进入。
|
||||
|
||||
**Tech Stack:** Vue 3, uni-app, JavaScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修复 login/index.vue 登录后跳转逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/login/index.vue:152-158`
|
||||
- Modify: `mini-program/src/pages/login/index.vue:187`
|
||||
- Modify: `mini-program/src/pages/login/index.vue:209`
|
||||
|
||||
**Context:** 当前 `routeAfterLogin` 函数根据 `profileReady` 参数分支:有资料去爽文生成页,无资料去编辑资料页。需要移除该判断,统一跳转至爽文生成页。
|
||||
|
||||
- [ ] **Step 1: 修改 `routeAfterLogin` 函数,移除 `profileReady` 参数和条件分支**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
const routeAfterLogin = (profileReady) => {
|
||||
if (profileReady) {
|
||||
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||
} else {
|
||||
uni.redirectTo({ url: '/pages/onboarding/index' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
const routeAfterLogin = () => {
|
||||
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 `handleWechatLogin` 中的调用,移除参数**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
routeAfterLogin(result.hasProfile)
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
routeAfterLogin()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 `handleLogin` 中的调用,移除参数**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
routeAfterLogin(result.hasProfile)
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
routeAfterLogin()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/login/index.vue
|
||||
git commit -m "fix(login): remove profile check, always redirect to script page after login"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修复 splash/index.vue 会话恢复后的跳转逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/splash/index.vue:54-56`
|
||||
|
||||
**Context:** `resolveInitialRoute` 在检测到已登录用户时,会根据 `session.hasProfile` 决定跳转到爽文生成页或编辑资料页。需要移除该分支。
|
||||
|
||||
- [ ] **Step 1: 修改已登录用户的跳转目标**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||
const target = session.hasProfile ? '/pages/main/index?tab=script' : '/pages/onboarding/index'
|
||||
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||
routeOnce('/pages/main/index?tab=script', { reason: session.reason })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/splash/index.vue
|
||||
git commit -m "fix(splash): remove profile check on session restore, always go to script page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证修复
|
||||
|
||||
**Files:**
|
||||
- Read: `mini-program/src/pages/login/index.vue`
|
||||
- Read: `mini-program/src/pages/splash/index.vue`
|
||||
|
||||
- [ ] **Step 1: 验证 login/index.vue 中无残留的分支逻辑**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
grep -n "onboarding" mini-program/src/pages/login/index.vue
|
||||
```
|
||||
|
||||
预期:无输出(`onboarding` 不再被 `login/index.vue` 引用)
|
||||
|
||||
- [ ] **Step 2: 验证 splash/index.vue 中无残留的分支逻辑**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
grep -n "onboarding" mini-program/src/pages/splash/index.vue
|
||||
```
|
||||
|
||||
预期:无输出(`onboarding` 不再被 `splash/index.vue` 引用)
|
||||
|
||||
- [ ] **Step 3: 确认 `onboarding` 页面本身未被修改**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
git diff --name-only
|
||||
```
|
||||
|
||||
预期:输出不包含 `mini-program/src/pages/onboarding/index.vue`
|
||||
|
||||
- [ ] **Step 4: 最终提交(如需要)**
|
||||
|
||||
```bash
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
预期:显示两次提交,分别为 login 和 splash 的修复。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
**1. Spec coverage:**
|
||||
- ✅ `login/index.vue` 的 `routeAfterLogin` 及两处调用 — Task 1
|
||||
- ✅ `splash/index.vue` 的会话恢复路由分支 — Task 2
|
||||
- ✅ `hasProfile` 和 `onboarding` 页面保留不动 — 确认无相关修改
|
||||
- ✅ 测试验证点 — Task 3
|
||||
|
||||
**2. Placeholder scan:**
|
||||
- ✅ 无 "TBD"/"TODO" 等占位符
|
||||
- ✅ 每步均含完整代码和命令
|
||||
- ✅ 无 "类似 Task N" 的引用
|
||||
|
||||
**3. Type consistency:**
|
||||
- ✅ 文件路径和函数名与 spec 及代码库一致
|
||||
@@ -0,0 +1,241 @@
|
||||
# 个人中心页面动态数据修复 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复"我的"页面显示写死信息的问题,改为从 store 动态读取用户头像、昵称、身份标签和统计数据。
|
||||
|
||||
**Architecture:** 仅修改 `MineView.vue` 一个文件。复用 `main/index.vue` 已加载的 store 数据(`userProfile` 和 `events`),通过新增 computed 属性生成头像 URL、觉醒深度等级和星历契合百分比,替换模板中的硬编码值。
|
||||
|
||||
**Tech Stack:** Vue 3, uni-app, JavaScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 替换头像为动态生成头像
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/MineView.vue:12-19`(模板)
|
||||
- Modify: `mini-program/src/pages/main/MineView.vue:64-78`(script)
|
||||
|
||||
**Context:** 当前头像区域用 `.person-head` 和 `.person-body` 两个 div 画了一个通用人形。需要替换为 `<image>` 组件显示 Dicebear 根据昵称生成的头像。
|
||||
|
||||
- [ ] **Step 1: 在 template 中替换 CSS 人形为 image 组件**
|
||||
|
||||
将:
|
||||
```html
|
||||
<view class="avatar-circle">
|
||||
<view class="person-head"></view>
|
||||
<view class="person-body"></view>
|
||||
</view>
|
||||
```
|
||||
|
||||
改为:
|
||||
```html
|
||||
<view class="avatar-circle">
|
||||
<image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 script 中新增 `avatarUrl` computed**
|
||||
|
||||
在 `profile` computed 之后、`displayName` 之前添加:
|
||||
|
||||
```javascript
|
||||
const avatarUrl = computed(() => {
|
||||
const seed = profile.value.nickname || 'user'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 给 avatar-img 添加样式(在 style 区块末尾追加)**
|
||||
|
||||
```css
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/MineView.vue && git commit -m "fix(mine): replace static avatar with dynamic dicebear image"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 替换写死的用户信息与统计数据
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/MineView.vue:71-78`(displayName + metaLine)
|
||||
- Modify: `mini-program/src/pages/main/MineView.vue:24-33`(stats-grid 模板)
|
||||
|
||||
**Context:** `displayName` fallback 为 `'张义明'`,`metaLine` fallback 为 `'ENFJ · 狮子座 · 星民'`,统计数值 `Lv.4` 和 `98%` 硬编码。
|
||||
|
||||
- [ ] **Step 1: 修改 `displayName` 的 fallback**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
const displayName = computed(() => profile.value.nickname || '张义明')
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
const displayName = computed(() => profile.value.nickname || '未设置昵称')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 `metaLine` computed**
|
||||
|
||||
将:
|
||||
```javascript
|
||||
const metaLine = computed(() => {
|
||||
const mbti = profile.value.mbti || 'ENFJ'
|
||||
const zodiac = profile.value.zodiac || '狮子座'
|
||||
const identity = profile.value.profession || '星民'
|
||||
return `${mbti} · ${zodiac} · ${identity}`
|
||||
})
|
||||
```
|
||||
|
||||
改为:
|
||||
```javascript
|
||||
const metaLine = computed(() => {
|
||||
const mbti = profile.value.mbti
|
||||
const zodiac = profile.value.zodiac
|
||||
const identity = profile.value.profession
|
||||
if (!mbti && !zodiac && !identity) {
|
||||
return '点击设置个人档案'
|
||||
}
|
||||
return `${mbti || ''} · ${zodiac || ''} · ${identity || ''}`.replace(/(^ · | · $)/g, '').replace(/ · · /g, ' · ')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新增 `awakenLevel` computed**
|
||||
|
||||
在 `metaLine` 之后添加:
|
||||
|
||||
```javascript
|
||||
const awakenLevel = computed(() => {
|
||||
const count = store.events?.length || 0
|
||||
if (count >= 10) return 'Lv.5'
|
||||
if (count >= 6) return 'Lv.4'
|
||||
if (count >= 3) return 'Lv.3'
|
||||
if (count >= 1) return 'Lv.2'
|
||||
return 'Lv.1'
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增 `harmonyPercent` computed**
|
||||
|
||||
在 `awakenLevel` 之后添加:
|
||||
|
||||
```javascript
|
||||
const harmonyPercent = computed(() => {
|
||||
const hasLifeStory = profile.value.childhood?.text ||
|
||||
profile.value.joy?.text ||
|
||||
profile.value.low?.text ||
|
||||
profile.value.future?.ideal
|
||||
const fields = [
|
||||
profile.value.nickname,
|
||||
profile.value.gender,
|
||||
profile.value.zodiac,
|
||||
profile.value.mbti,
|
||||
profile.value.profession,
|
||||
profile.value.hobbies?.length,
|
||||
hasLifeStory
|
||||
]
|
||||
const filled = fields.filter(Boolean).length
|
||||
return Math.round((filled / 7) * 100) + '%'
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 替换模板中的硬编码统计值**
|
||||
|
||||
将:
|
||||
```html
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">觉醒深度</text>
|
||||
<text class="stat-value">Lv.4</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">星历契合</text>
|
||||
<text class="stat-value">98%</text>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
改为:
|
||||
```html
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">觉醒深度</text>
|
||||
<text class="stat-value">{{ awakenLevel }}</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">星历契合</text>
|
||||
<text class="stat-value">{{ harmonyPercent }}</text>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/MineView.vue && git commit -m "fix(mine): replace hardcoded user info and stats with dynamic store data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证修复
|
||||
|
||||
**Files:**
|
||||
- Read: `mini-program/src/pages/main/MineView.vue`
|
||||
|
||||
- [ ] **Step 1: 确认模板中无硬编码用户信息**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
grep -n "张义明\|ENFJ\|狮子座\|星民\|Lv\.4\|98%" mini-program/src/pages/main/MineView.vue
|
||||
```
|
||||
|
||||
预期:无输出(所有硬编码值已被替换)
|
||||
|
||||
- [ ] **Step 2: 确认 `<image>` 和 computed 属性存在**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
grep -n "avatarUrl\|awakenLevel\|harmonyPercent" mini-program/src/pages/main/MineView.vue
|
||||
```
|
||||
|
||||
预期:输出包含 `avatarUrl`、`awakenLevel`、`harmonyPercent` 的定义和引用
|
||||
|
||||
- [ ] **Step 3: 查看提交记录**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
预期:显示两次提交,分别为头像修复和统计数据修复
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
**1. Spec coverage:**
|
||||
- ✅ 头像替换为 Dicebear 动态生成 — Task 1
|
||||
- ✅ displayName fallback 改为 `'未设置昵称'` — Task 2 Step 1
|
||||
- ✅ metaLine 无资料时显示 `'点击设置个人档案'` — Task 2 Step 2
|
||||
- ✅ 觉醒深度基于 `store.events.length` — Task 2 Step 3
|
||||
- ✅ 星历契合基于资料完整度 — Task 2 Step 4
|
||||
- ✅ 模板硬编码值替换 — Task 2 Step 5
|
||||
- ✅ 验证步骤 — Task 3
|
||||
|
||||
**2. Placeholder scan:**
|
||||
- ✅ 无 "TBD"/"TODO" 等占位符
|
||||
- ✅ 每步均含完整代码和命令
|
||||
- ✅ 无 "类似 Task N" 的引用
|
||||
|
||||
**3. Type consistency:**
|
||||
- ✅ 字段名(`nickname`、`mbti`、`zodiac`、`profession`、`events`、`hobbies`、`childhood`、`joy`、`low`、`future`)与 store 和 spec 一致
|
||||
- ✅ `avatarUrl` 生成逻辑与 onboarding 中一致
|
||||
@@ -0,0 +1,692 @@
|
||||
# 小程序用户信息去写死 + 编辑资料保存生效 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 去除小程序人生轨迹页和编辑资料页的所有硬编码用户信息,扩展后端 `t_user_profile` 表支持 `city`/`industry`/`company`/`personalityTags`/`birthday` 字段,确保编辑资料能完整保存和回显。
|
||||
|
||||
**Architecture:** 后端通过数据库 ALTER + Java 实体/DTO 扩展新增5个字段;前端同步扩展 Service 转换函数和 Store 数据结构,并移除两个页面中所有 `|| '硬编码默认值'` 回退和示例事件数据。
|
||||
|
||||
**Tech Stack:** Java 17 / Spring Boot / MyBatis-Plus / MySQL;uni-app (Vue 3) / JavaScript
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 改动 | 职责 |
|
||||
|------|------|------|
|
||||
| `sql/emotion_museum_ddl.sql` | 追加 | 数据库迁移:新增5列 |
|
||||
| `backend-single/.../entity/UserProfile.java` | 修改 | ORM 实体新增字段 |
|
||||
| `backend-single/.../request/UserProfileCreateRequest.java` | 修改 | 创建请求 DTO 新增字段 |
|
||||
| `backend-single/.../request/UserProfileUpdateRequest.java` | 修改 | 更新请求 DTO 新增字段 |
|
||||
| `backend-single/.../response/UserProfileResponse.java` | 修改 | 响应 DTO 新增字段 |
|
||||
| `mini-program/src/services/userProfile.js` | 修改 | 前后端数据格式双向转换 |
|
||||
| `mini-program/src/stores/app.js` | 修改 | 全局状态 registrationData 扩展 |
|
||||
| `mini-program/src/pages/main/RecordView.vue` | 修改 | 去除写死默认值和 sampleEvents |
|
||||
| `mini-program/src/pages/onboarding/index.vue` | 修改 | 去除硬编码默认值,空值友好展示 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 数据库迁移 — 新增5个字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `sql/emotion_museum_ddl.sql`
|
||||
|
||||
- [ ] **Step 1: 追加 ALTER TABLE 语句**
|
||||
|
||||
在 `sql/emotion_museum_ddl.sql` 文件末尾追加:
|
||||
|
||||
```sql
|
||||
-- 用户档案扩展字段:城市、行业、公司、性格标签、生日
|
||||
alter table emotion_museum.t_user_profile
|
||||
add city varchar(100) null comment '所在城市',
|
||||
add industry varchar(100) null comment '行业',
|
||||
add company varchar(200) null comment '公司',
|
||||
add personality_tags json null comment '性格标签列表',
|
||||
add birthday date null comment '生日';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add sql/emotion_museum_ddl.sql
|
||||
git commit -m "db: add city, industry, company, personality_tags, birthday to t_user_profile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端实体扩展 — UserProfile.java
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/entity/UserProfile.java`
|
||||
|
||||
- [ ] **Step 1: 在 idealLife 字段后新增5个字段**
|
||||
|
||||
找到 `idealLife` 字段定义(约第115行),在其后、`scripts` 字段前插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
@TableField("city")
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
@TableField("industry")
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
@TableField("company")
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
@TableField("personality_tags")
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
@TableField("birthday")
|
||||
private LocalDate birthday;
|
||||
```
|
||||
|
||||
注意:`LocalDate` 的 import 已在文件顶部存在(第12行),无需新增 import。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend-single/src/main/java/com/emotion/entity/UserProfile.java
|
||||
git commit -m "feat: extend UserProfile entity with city/industry/company/personalityTags/birthday"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端创建请求 DTO 扩展 — UserProfileCreateRequest.java
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java`
|
||||
|
||||
- [ ] **Step 1: 在 idealLife 字段后新增5个字段**
|
||||
|
||||
找到 `idealLife` 字段定义(约第86行),在其后、`scripts` 字段前插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
private LocalDate birthday;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java
|
||||
git commit -m "feat: extend UserProfileCreateRequest with city/industry/company/personalityTags/birthday"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端更新请求 DTO 扩展 — UserProfileUpdateRequest.java
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java`
|
||||
|
||||
- [ ] **Step 1: 在 idealLife 字段后新增5个字段**
|
||||
|
||||
找到 `idealLife` 字段定义(约第90行),在其后、`scripts` 字段前插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
private LocalDate birthday;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java
|
||||
git commit -m "feat: extend UserProfileUpdateRequest with city/industry/company/personalityTags/birthday"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 后端响应 DTO 扩展 — UserProfileResponse.java
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java`
|
||||
|
||||
- [ ] **Step 1: 在 idealLife 字段后新增5个字段**
|
||||
|
||||
找到 `idealLife` 字段定义(约第105行),在其后、`scripts` 字段前插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 所在城市
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* 行业
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 公司
|
||||
*/
|
||||
private String company;
|
||||
|
||||
/**
|
||||
* 性格标签 (JSON字符串)
|
||||
*/
|
||||
private String personalityTags;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate birthday;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend-single/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java
|
||||
git commit -m "feat: extend UserProfileResponse with city/industry/company/personalityTags/birthday"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 Service 扩展 — userProfile.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/services/userProfile.js`
|
||||
|
||||
- [ ] **Step 1: 扩展 transformToBackendFormat 的解构和返回对象**
|
||||
|
||||
原解构(第47-59行)新增5个字段:
|
||||
|
||||
```js
|
||||
const {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
profession,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhood,
|
||||
joy,
|
||||
low,
|
||||
future,
|
||||
city,
|
||||
industry,
|
||||
company,
|
||||
personalityTags,
|
||||
birthday
|
||||
} = frontendData
|
||||
```
|
||||
|
||||
原返回对象(第61-83行)在 `idealLife` 后新增:
|
||||
|
||||
```js
|
||||
return {
|
||||
id,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
profession,
|
||||
mbti,
|
||||
// 兴趣爱好转为JSON字符串
|
||||
hobbies: Array.isArray(hobbies) ? JSON.stringify(hobbies) : hobbies,
|
||||
// 童年经历
|
||||
childhoodDate: childhood?.date || null,
|
||||
childhoodContent: childhood?.text || null,
|
||||
// 高光时刻(对应前端的joy)
|
||||
peakDate: joy?.date || null,
|
||||
peakContent: joy?.text || null,
|
||||
// 低谷时期(对应前端的low)
|
||||
valleyDate: low?.date || null,
|
||||
valleyContent: low?.text || null,
|
||||
// 未来期许
|
||||
futureVision: future?.vision || null,
|
||||
// 理想生活状态
|
||||
idealLife: future?.ideal || null,
|
||||
// 新增字段
|
||||
city: city || null,
|
||||
industry: industry || null,
|
||||
company: company || null,
|
||||
personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
|
||||
birthday: birthday || null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 扩展 transformToFrontendFormat 的解构和返回对象**
|
||||
|
||||
原解构(第94-111行)新增5个字段:
|
||||
|
||||
```js
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
nickname,
|
||||
gender,
|
||||
zodiac,
|
||||
profession,
|
||||
mbti,
|
||||
hobbies,
|
||||
childhoodDate,
|
||||
childhoodContent,
|
||||
peakDate,
|
||||
peakContent,
|
||||
valleyDate,
|
||||
valleyContent,
|
||||
futureVision,
|
||||
idealLife,
|
||||
city,
|
||||
industry,
|
||||
company,
|
||||
personalityTags,
|
||||
birthday
|
||||
} = backendData
|
||||
```
|
||||
|
||||
原返回对象(第113-143行)在 `future` 对象后新增:
|
||||
|
||||
```js
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
nickname: nickname || '',
|
||||
gender: gender || '',
|
||||
zodiac: zodiac || '',
|
||||
profession: profession || '',
|
||||
mbti: mbti || '',
|
||||
// 兴趣爱好从JSON字符串解析
|
||||
hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [],
|
||||
// 童年经历
|
||||
childhood: {
|
||||
date: childhoodDate || '',
|
||||
text: childhoodContent || ''
|
||||
},
|
||||
// 高光时刻
|
||||
joy: {
|
||||
date: peakDate || '',
|
||||
text: peakContent || ''
|
||||
},
|
||||
// 低谷时期
|
||||
low: {
|
||||
date: valleyDate || '',
|
||||
text: valleyContent || ''
|
||||
},
|
||||
// 未来期许
|
||||
future: {
|
||||
vision: futureVision || '',
|
||||
ideal: idealLife || ''
|
||||
},
|
||||
// 新增字段
|
||||
city: city || '',
|
||||
industry: industry || '',
|
||||
company: company || '',
|
||||
personalityTags: personalityTags ? (typeof personalityTags === 'string' ? JSON.parse(personalityTags) : personalityTags) : [],
|
||||
birthday: birthday || ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/services/userProfile.js
|
||||
git commit -m "feat: add city/industry/company/personalityTags/birthday to userProfile transforms"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端 Store 扩展 — app.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/stores/app.js`
|
||||
|
||||
- [ ] **Step 1: 扩展 registrationData 初始结构**
|
||||
|
||||
找到 `registrationData` 定义(第19-30行),在 `future` 对象后新增:
|
||||
|
||||
```js
|
||||
registrationData: {
|
||||
nickname: '',
|
||||
gender: '',
|
||||
mbti: '',
|
||||
zodiac: '',
|
||||
profession: '',
|
||||
hobbies: [],
|
||||
childhood: { date: '', text: '' },
|
||||
joy: { date: '', text: '' },
|
||||
low: { date: '', text: '' },
|
||||
future: { vision: '', ideal: '' },
|
||||
city: '',
|
||||
industry: '',
|
||||
company: '',
|
||||
personalityTags: [],
|
||||
birthday: ''
|
||||
}
|
||||
```
|
||||
|
||||
注意:此文件使用 CRLF 行尾(`\r\n`),使用 Edit 工具时请按 Read 工具输出的格式匹配。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/stores/app.js
|
||||
git commit -m "feat: extend registrationData with city/industry/company/personalityTags/birthday"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 人生轨迹页去写死 — RecordView.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/main/RecordView.vue`
|
||||
|
||||
- [ ] **Step 1: 删除 sampleEvents 数组**
|
||||
|
||||
删除第143-176行的 `sampleEvents` 数组定义。
|
||||
|
||||
- [ ] **Step 2: 修改 events computed 去除 sampleEvents 回退**
|
||||
|
||||
将第179-182行:
|
||||
```js
|
||||
const events = computed(() => {
|
||||
const list = store.events || []
|
||||
return list.length ? list : sampleEvents
|
||||
})
|
||||
```
|
||||
改为:
|
||||
```js
|
||||
const events = computed(() => store.events || [])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 heroTags computed 去除硬编码默认值**
|
||||
|
||||
将第183-187行:
|
||||
```js
|
||||
const heroTags = computed(() => {
|
||||
const hobbies = profile.value.hobbies
|
||||
if (Array.isArray(hobbies) && hobbies.length) return hobbies.slice(0, 4)
|
||||
return ['阅读', '旅行', '音乐', '创作']
|
||||
})
|
||||
```
|
||||
改为:
|
||||
```js
|
||||
const heroTags = computed(() => {
|
||||
const hobbies = profile.value.hobbies
|
||||
if (Array.isArray(hobbies) && hobbies.length) return hobbies.slice(0, 4)
|
||||
return []
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修改 avatar computed 去除硬编码昵称**
|
||||
|
||||
将第189-192行:
|
||||
```js
|
||||
const avatar = computed(() => {
|
||||
const nickname = profile.value.nickname || 'Zoey'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
改为:
|
||||
```js
|
||||
const avatar = computed(() => {
|
||||
const nickname = profile.value.nickname || 'User'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 修改模板中硬编码默认值**
|
||||
|
||||
将第13行:
|
||||
```vue
|
||||
<text class="profile-name">{{ profile.nickname || 'Zoey' }}</text>
|
||||
```
|
||||
改为:
|
||||
```vue
|
||||
<text class="profile-name">{{ profile.nickname || '未设置' }}</text>
|
||||
```
|
||||
|
||||
将第31行:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.zodiac || '巨蟹座' }}</text>
|
||||
```
|
||||
改为:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.zodiac || '未设置' }}</text>
|
||||
```
|
||||
|
||||
将第38行:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.mbti || 'ENTJ' }}</text>
|
||||
```
|
||||
改为:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.mbti || '未设置' }}</text>
|
||||
```
|
||||
|
||||
将第45行:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.profession || '产品经理' }}</text>
|
||||
```
|
||||
改为:
|
||||
```vue
|
||||
<text class="meta-value">{{ profile.profession || '未设置' }}</text>
|
||||
```
|
||||
|
||||
将第56行:
|
||||
```vue
|
||||
<text class="hobby-text">{{ heroTags.join(' · ') }}</text>
|
||||
```
|
||||
改为:
|
||||
```vue
|
||||
<text class="hobby-text">{{ heroTags.length ? heroTags.join(' · ') : '未设置' }}</text>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/main/RecordView.vue
|
||||
git commit -m "fix: remove hardcoded defaults and sampleEvents from RecordView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 编辑资料页去写死 — onboarding/index.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/onboarding/index.vue`
|
||||
|
||||
- [ ] **Step 1: 修改 avatarUrl computed 去除硬编码昵称**
|
||||
|
||||
将第234-238行:
|
||||
```js
|
||||
const avatarUrl = computed(() => {
|
||||
if (avatarLocal.value) return avatarLocal.value
|
||||
const nickname = form.nickname || 'Zoey'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
改为:
|
||||
```js
|
||||
const avatarUrl = computed(() => {
|
||||
if (avatarLocal.value) return avatarLocal.value
|
||||
const nickname = form.nickname || 'User'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 birthdayDisplay computed 去除硬编码日期**
|
||||
|
||||
将第240-244行:
|
||||
```js
|
||||
const birthdayDisplay = computed(() => {
|
||||
if (!birthday.value) return '1998年06月18日'
|
||||
const [year, month, day] = birthday.value.split('-')
|
||||
return `${year}年${month}月${day}日`
|
||||
})
|
||||
```
|
||||
改为:
|
||||
```js
|
||||
const birthdayDisplay = computed(() => {
|
||||
if (!birthday.value) return '请选择生日'
|
||||
const [year, month, day] = birthday.value.split('-')
|
||||
return `${year}年${month}月${day}日`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 syncFromStore 去除所有硬编码默认值**
|
||||
|
||||
将第246-265行的 `syncFromStore` 函数整体替换为:
|
||||
|
||||
```js
|
||||
const syncFromStore = () => {
|
||||
const source = store.userProfile || store.registrationData || {}
|
||||
Object.assign(form, {
|
||||
nickname: source.nickname || '',
|
||||
gender: source.gender || '',
|
||||
zodiac: source.zodiac || '',
|
||||
mbti: source.mbti || '',
|
||||
profession: source.profession || '',
|
||||
city: source.city || '',
|
||||
industry: source.industry || '',
|
||||
company: source.company || '',
|
||||
personalityTags: Array.isArray(source.personalityTags) ? [...source.personalityTags] : [],
|
||||
hobbies: Array.isArray(source.hobbies) ? [...source.hobbies] : [],
|
||||
childhood: { date: source.childhood?.date || '', text: source.childhood?.text || '' },
|
||||
joy: { date: source.joy?.date || '', text: source.joy?.text || '' },
|
||||
low: { date: source.low?.date || '', text: source.low?.text || '' },
|
||||
future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '' }
|
||||
})
|
||||
birthday.value = source.birthday || ''
|
||||
}
|
||||
```
|
||||
|
||||
注意:此文件使用 CRLF 行尾(`\r\n`),使用 Edit 工具时请按 Read 工具输出的格式匹配。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add mini-program/src/pages/onboarding/index.vue
|
||||
git commit -m "fix: remove hardcoded defaults from profile edit page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 验证测试
|
||||
|
||||
**Files:**
|
||||
- 无需修改,纯验证
|
||||
|
||||
- [ ] **Step 1: 后端编译验证**
|
||||
|
||||
```bash
|
||||
cd backend-single && mvn compile -q
|
||||
```
|
||||
|
||||
预期:编译成功,无错误。
|
||||
|
||||
- [ ] **Step 2: 前端语法检查(如有 linter)**
|
||||
|
||||
```bash
|
||||
cd mini-program && npm run lint 2>/dev/null || echo "No lint script, skipping"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 手动功能验证清单**
|
||||
|
||||
1. 启动后端服务,确认无启动错误。
|
||||
2. 在数据库执行 `DESCRIBE t_user_profile;`,确认 `city`、`industry`、`company`、`personality_tags`、`birthday` 五列已存在。
|
||||
3. 小程序登录后进入"人生轨迹"页:
|
||||
- 未设置资料时,昵称显示"未设置",星座/MBTI/职业显示"未设置",爱好显示"未设置"。
|
||||
- 无人生事件时,显示"还没有人生轨迹"空状态,无 sampleEvents。
|
||||
4. 点击"编辑资料":
|
||||
- 首次进入(无资料)时,所有输入框为空,生日显示"请选择生日",性格标签和爱好无选中项。
|
||||
- 填写所有字段(包括城市、行业、公司、性格标签、生日)后保存。
|
||||
- 保存成功后返回人生轨迹页,数据已更新。
|
||||
- 再次进入编辑资料页,所有字段正确回显。
|
||||
5. 已有用户(老数据,无新字段)登录后,页面正常展示,无报错,新字段显示为空。
|
||||
|
||||
- [ ] **Step 4: Commit(如测试通过)**
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
预期看到 9 个 commits(Task 1-9 各一个)。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- 数据库扩展5个字段 → Task 1 ✅
|
||||
- 后端实体扩展 → Task 2 ✅
|
||||
- 后端 DTO 扩展(Create/Update/Response)→ Task 3/4/5 ✅
|
||||
- 前端 Service 双向转换扩展 → Task 6 ✅
|
||||
- 前端 Store 扩展 → Task 7 ✅
|
||||
- 人生轨迹页去除写死数据和 sampleEvents → Task 8 ✅
|
||||
- 编辑资料页去除硬编码默认值 → Task 9 ✅
|
||||
- 测试验证 → Task 10 ✅
|
||||
|
||||
**2. Placeholder scan:**
|
||||
- 无 TBD/TODO/"implement later"/"fill in details" ✅
|
||||
- 无 "Add appropriate error handling" 等模糊描述 ✅
|
||||
- 无 "Similar to Task N" ✅
|
||||
- 每个代码步骤都包含完整代码 ✅
|
||||
|
||||
**3. Type consistency:**
|
||||
- 后端5个字段名在 Entity/CreateRequest/UpdateRequest/Response 中完全一致:`city`, `industry`, `company`, `personalityTags`, `birthday` ✅
|
||||
- 前端 Service 中 `transformToBackendFormat` 和 `transformToFrontendFormat` 字段名一致 ✅
|
||||
- Store 中 `registrationData` 字段名与 Service 一致 ✅
|
||||
- `personalityTags` 在前端始终作为数组处理,后端作为 JSON 字符串,转换逻辑与 `hobbies` 保持一致 ✅
|
||||
- `birthday` 前后端均为 `yyyy-MM-dd` 字符串格式 ✅
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,892 @@
|
||||
# Mini Program Life Event Detail Actions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 调整小程序人生轨迹详情页,把事件标题卡与事件描述卡合并,移除顶部悬浮操作 icon,并将收藏、分享、分析、编辑、收起和“聊聊”入口按新布局展示。
|
||||
|
||||
**Architecture:** 只修改 `mini-program/src/pages/life-event/detail.vue`。模板层用新的 `event-content-card` 替换 `floating-nav`、`hero-card`、`description-card` 组合;脚本层增加 `scrollTop`、`currentScrollTop`、`onContentScroll` 与 `scrollToAnalysis` 来支持分析按钮定位;样式层复用当前宇宙玻璃风格并新增局部按钮和 Markdown 折叠样式。
|
||||
|
||||
**Tech Stack:** UniApp, Vue 3 `<script setup>`, CSS scoped, existing `mini-program/src/components/Markdown.vue`, npm scripts from `mini-program/package.json`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `mini-program/src/pages/life-event/detail.vue`
|
||||
- Template: 合并事件标题区和事件描述区,迁移操作按钮,底部只保留居中的聊天主按钮。
|
||||
- Script: 删除 `useMenuButtonSafeArea` 使用,新增 `scrollTop`、`currentScrollTop`、`onContentScroll`、`scrollToAnalysis`,保留现有业务方法。
|
||||
- Style: 删除顶部悬浮导航样式,新增合并卡、正文 Markdown 折叠、内容区按钮和底部聊天按钮样式。
|
||||
|
||||
- Verify only: `mini-program/src/components/Markdown.vue`
|
||||
- 不改文件,仅复用 `<Markdown :content="eventDescription" />`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Template Restructure
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/life-event/detail.vue`
|
||||
|
||||
- [ ] **Step 1: Remove the floating nav block**
|
||||
|
||||
Delete this block from the template:
|
||||
|
||||
```vue
|
||||
<view class="floating-nav" :style="floatingTopStyle">
|
||||
<view class="circle-btn" @click="goBack">
|
||||
<view class="back-icon"></view>
|
||||
</view>
|
||||
<view class="circle-btn" @click="openMore">
|
||||
<view class="more-dot"></view>
|
||||
<view class="more-dot"></view>
|
||||
<view class="more-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
Expected result: 页面左上角和右上角不再渲染悬浮 icon。
|
||||
|
||||
- [ ] **Step 2: Add scroll state binding to the page scroll-view**
|
||||
|
||||
Change the current scroll-view opening tag from:
|
||||
|
||||
```vue
|
||||
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```vue
|
||||
<scroll-view
|
||||
class="content"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll="onContentScroll"
|
||||
scroll-with-animation
|
||||
>
|
||||
```
|
||||
|
||||
Expected result: `scrollToAnalysis` can move the scroll-view to the AI 分析 section and account for the current scroll position.
|
||||
|
||||
- [ ] **Step 3: Replace hero-card and description-card with the merged event-content-card**
|
||||
|
||||
Replace the whole existing `hero-card` and `description-card` blocks with:
|
||||
|
||||
```vue
|
||||
<view class="event-content-card glass-card">
|
||||
<view class="event-hero-section">
|
||||
<view class="hero-timeline">
|
||||
<view class="timeline-glow"></view>
|
||||
<view class="timeline-node"></view>
|
||||
<view class="timeline-line"></view>
|
||||
</view>
|
||||
|
||||
<view class="hero-copy">
|
||||
<text class="year">{{ eventYear }}</text>
|
||||
<view class="date-row">
|
||||
<text class="date-range">{{ dateRange }}</text>
|
||||
<text class="type-chip">{{ primaryTag }}</text>
|
||||
</view>
|
||||
<text class="event-title">{{ displayEvent.title || '决定全职做自己热爱的AI产品' }}</text>
|
||||
<view class="location-row">
|
||||
<view class="pin-icon"></view>
|
||||
<text>{{ locationText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="memory-cover">
|
||||
<view class="cover-sky"></view>
|
||||
<view class="cover-window"></view>
|
||||
<view class="cover-desk"></view>
|
||||
<view class="cover-screen main"></view>
|
||||
<view class="cover-screen small left"></view>
|
||||
<view class="cover-screen small right"></view>
|
||||
</view>
|
||||
<view class="hero-star">★</view>
|
||||
</view>
|
||||
|
||||
<view class="event-body-section">
|
||||
<view class="event-body-toolbar">
|
||||
<view class="content-action favorite-action" @click="toggleFavorite">
|
||||
<view class="bookmark-icon" :class="{ active: isFavorite }"></view>
|
||||
<text>{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="toolbar-right">
|
||||
<view class="analysis-pill" @click="scrollToAnalysis">
|
||||
<text>分析</text>
|
||||
</view>
|
||||
<view class="content-action share-action" @click="shareCurrent">
|
||||
<view class="share-icon">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
<text>分享</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title-row description-title-row">
|
||||
<view class="orbit-icon"></view>
|
||||
<text>事件描述</text>
|
||||
</view>
|
||||
|
||||
<view class="description-markdown" :class="{ collapsed: isDescriptionCollapsed }">
|
||||
<Markdown :content="eventDescription" />
|
||||
</view>
|
||||
|
||||
<view class="event-body-footer">
|
||||
<view class="content-action edit-action" @click="editEvent">
|
||||
<view class="edit-icon"></view>
|
||||
<text>编辑</text>
|
||||
</view>
|
||||
<text class="collapse-action" @click="isDescriptionCollapsed = !isDescriptionCollapsed">
|
||||
{{ isDescriptionCollapsed ? '展开' : '收起' }} ^
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
Expected result: 事件信息和事件描述在同一张卡内,正文通过 `Markdown` 组件渲染。
|
||||
|
||||
- [ ] **Step 4: Add the analysis anchor data attribute**
|
||||
|
||||
Change the current AI analysis opening block from:
|
||||
|
||||
```vue
|
||||
<view class="analysis-panel">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```vue
|
||||
<view class="analysis-panel" id="analysisPanel">
|
||||
```
|
||||
|
||||
Expected result: `uni.createSelectorQuery()` can locate the AI 分析 block.
|
||||
|
||||
- [ ] **Step 5: Replace bottom action bar with centered chat action**
|
||||
|
||||
Replace the existing bottom action bar:
|
||||
|
||||
```vue
|
||||
<view class="bottom-actions" :style="{ paddingBottom: bottomInset + 'px' }">
|
||||
<view class="bottom-action" @click="editEvent">
|
||||
<view class="edit-icon"></view>
|
||||
<text>编辑</text>
|
||||
</view>
|
||||
<view class="bottom-action" @click="toggleFavorite">
|
||||
<view class="bookmark-icon" :class="{ active: isFavorite }"></view>
|
||||
<text>{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
<view class="chat-action" @click="chatEvent">
|
||||
<view class="chat-badge">AI</view>
|
||||
<text>和开开聊聊这段经历</text>
|
||||
</view>
|
||||
<view class="bottom-action" @click="shareCurrent">
|
||||
<view class="share-icon">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
<text>分享</text>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```vue
|
||||
<view class="bottom-actions" :style="{ paddingBottom: bottomInset + 'px' }">
|
||||
<view class="chat-action" @click="chatEvent">
|
||||
<view class="chat-badge">AI</view>
|
||||
<text>聊聊</text>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
Expected result: 底部只保留居中的紫色主按钮,文案为“聊聊”。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Script State And Methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/life-event/detail.vue`
|
||||
|
||||
- [ ] **Step 1: Remove the unused safe-area composable import**
|
||||
|
||||
Change imports from:
|
||||
|
||||
```js
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
```
|
||||
|
||||
Expected result: 没有未使用的 `useMenuButtonSafeArea` import。
|
||||
|
||||
- [ ] **Step 2: Replace floating nav state with scroll state**
|
||||
|
||||
Change:
|
||||
|
||||
```js
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/life-event/detail'
|
||||
const { floatingTopStyle } = useMenuButtonSafeArea()
|
||||
const safeAreaBottom = ref(0)
|
||||
const eventId = ref('')
|
||||
const cachedEvent = ref(null)
|
||||
const isFavorite = ref(false)
|
||||
const isDescriptionCollapsed = ref(false)
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/life-event/detail'
|
||||
const safeAreaBottom = ref(0)
|
||||
const eventId = ref('')
|
||||
const cachedEvent = ref(null)
|
||||
const isFavorite = ref(false)
|
||||
const isDescriptionCollapsed = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const currentScrollTop = ref(0)
|
||||
```
|
||||
|
||||
Expected result: scroll-view has a reactive value available for animated positioning.
|
||||
|
||||
- [ ] **Step 3: Add scroll methods before editEvent**
|
||||
|
||||
Insert these methods before `const editEvent = () => {`:
|
||||
|
||||
```js
|
||||
const onContentScroll = (event) => {
|
||||
currentScrollTop.value = event.detail?.scrollTop || 0
|
||||
}
|
||||
|
||||
const scrollToAnalysis = () => {
|
||||
uni.createSelectorQuery()
|
||||
.select('#analysisPanel')
|
||||
.boundingClientRect((rect) => {
|
||||
if (!rect) return
|
||||
scrollTop.value = Math.max(0, currentScrollTop.value + rect.top - 24)
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: 点击“分析”后 scroll-view 会向 AI 分析卡移动。
|
||||
|
||||
- [ ] **Step 4: Keep existing business methods unchanged**
|
||||
|
||||
Do not change the bodies of these existing methods:
|
||||
|
||||
```js
|
||||
const editEvent = () => {
|
||||
if (!eventId.value) return
|
||||
uni.navigateTo({ url: `/pages/life-event/form?id=${eventId.value}` })
|
||||
}
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!eventId.value) return
|
||||
const next = !isFavorite.value
|
||||
const result = await store.favoriteEvent({ id: eventId.value, favorite: next })
|
||||
if (!result.success) {
|
||||
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
isFavorite.value = next
|
||||
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
|
||||
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
|
||||
analytics.track('life_event_favorite', {
|
||||
life_event_id: eventId.value,
|
||||
favorite: next
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
}
|
||||
|
||||
const chatEvent = async () => {
|
||||
const result = await store.chatAboutEvent({
|
||||
id: eventId.value,
|
||||
title: displayEvent.value.title,
|
||||
content: displayEvent.value.content
|
||||
})
|
||||
uni.showModal({
|
||||
title: '和开开聊聊',
|
||||
content: result.success ? result.data?.reply || '聊天能力已准备' : result.error || '聊天服务暂不可用',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
const shareCurrent = async () => {
|
||||
const result = await store.shareEvent({
|
||||
id: eventId.value,
|
||||
title: displayEvent.value.title,
|
||||
content: displayEvent.value.content
|
||||
})
|
||||
if (!result.success) {
|
||||
uni.showToast({ title: result.error || '分享失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
analytics.track('life_event_share', {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.setClipboardData({
|
||||
data: result.data?.shareText || `分享我的人生轨迹:${displayEvent.value.title || ''}`,
|
||||
success: () => uni.showToast({ title: '分享文案已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: 编辑、收藏、分享、聊天功能沿用现有行为。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Styles For Merged Card And Actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `mini-program/src/pages/life-event/detail.vue`
|
||||
|
||||
- [ ] **Step 1: Update z-index selector**
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.floating-nav,
|
||||
.content,
|
||||
.bottom-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.content,
|
||||
.bottom-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: CSS no longer references removed `floating-nav`.
|
||||
|
||||
- [ ] **Step 2: Delete removed floating navigation styles**
|
||||
|
||||
Delete these style blocks:
|
||||
|
||||
```css
|
||||
.floating-nav {
|
||||
height: 108rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.circle-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5rpx;
|
||||
color: #fff;
|
||||
border: 1rpx solid rgba(118, 82, 225, 0.35);
|
||||
background: rgba(22, 18, 63, 0.72);
|
||||
box-shadow: inset 0 0 20rpx rgba(137, 91, 255, 0.1);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 23rpx;
|
||||
height: 23rpx;
|
||||
border-left: 6rpx solid #fff;
|
||||
border-bottom: 6rpx solid #fff;
|
||||
transform: rotate(45deg);
|
||||
margin-left: 7rpx;
|
||||
}
|
||||
|
||||
.more-dot {
|
||||
width: 7rpx;
|
||||
height: 7rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: no dead CSS for removed top icons.
|
||||
|
||||
- [ ] **Step 3: Adjust scroll content spacing**
|
||||
|
||||
Change:
|
||||
|
||||
```css
|
||||
.content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0 32rpx 134rpx;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```css
|
||||
.content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 36rpx 32rpx 154rpx;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: merged card starts with breathing room after top safe area removal, and bottom content is not covered by the chat button.
|
||||
|
||||
- [ ] **Step 4: Replace hero-card styles with event-content-card and event-hero-section**
|
||||
|
||||
Replace:
|
||||
|
||||
```css
|
||||
.hero-card {
|
||||
position: relative;
|
||||
min-height: 264rpx;
|
||||
border-radius: 28rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 70rpx 1fr 148rpx;
|
||||
gap: 20rpx;
|
||||
padding: 38rpx 34rpx 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```css
|
||||
.event-content-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 30rpx;
|
||||
}
|
||||
|
||||
.event-hero-section {
|
||||
position: relative;
|
||||
min-height: 264rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 70rpx 1fr 148rpx;
|
||||
gap: 20rpx;
|
||||
padding: 40rpx 34rpx 34rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at 86% 12%, rgba(129, 76, 255, 0.22), transparent 36%),
|
||||
linear-gradient(145deg, rgba(6, 8, 35, 0.98) 0%, rgba(15, 13, 58, 0.96) 58%, rgba(23, 16, 72, 0.92) 100%);
|
||||
box-shadow: inset 0 -1rpx 0 rgba(164, 116, 255, 0.22), inset 0 0 42rpx rgba(63, 45, 148, 0.16);
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: top half of the merged card is visually deeper than the body area.
|
||||
|
||||
- [ ] **Step 5: Replace description-card styles with event body styles**
|
||||
|
||||
Replace:
|
||||
|
||||
```css
|
||||
.description-card,
|
||||
.tags-card {
|
||||
border-radius: 28rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```css
|
||||
.event-body-section {
|
||||
position: relative;
|
||||
padding: 26rpx 30rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at 92% 0%, rgba(159, 98, 255, 0.12), transparent 36%),
|
||||
rgba(13, 16, 52, 0.62);
|
||||
}
|
||||
|
||||
.tags-card {
|
||||
border-radius: 28rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 28rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: description body sits inside the merged card and related tags keep their card styling.
|
||||
|
||||
- [ ] **Step 6: Add content action toolbar styles before `.section-title-row`**
|
||||
|
||||
Insert:
|
||||
|
||||
```css
|
||||
.event-body-toolbar,
|
||||
.event-body-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.event-body-toolbar {
|
||||
min-height: 64rpx;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.content-action {
|
||||
min-height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: rgba(238, 231, 255, 0.88);
|
||||
font-size: 23rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.favorite-action,
|
||||
.share-action,
|
||||
.edit-action {
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
.analysis-pill {
|
||||
min-width: 92rpx;
|
||||
height: 54rpx;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0 18rpx 18rpx 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(231, 217, 255, 0.86);
|
||||
font-size: 23rpx;
|
||||
font-weight: 900;
|
||||
background: rgba(157, 104, 255, 0.18);
|
||||
border: 1rpx solid rgba(193, 154, 255, 0.24);
|
||||
box-shadow: inset 0 0 18rpx rgba(185, 124, 255, 0.08);
|
||||
}
|
||||
|
||||
.description-title-row {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: 收藏与分享同一水平线,分析按钮弱化并位于右上凹位。
|
||||
|
||||
- [ ] **Step 7: Replace description text styles with Markdown wrapper styles**
|
||||
|
||||
Replace:
|
||||
|
||||
```css
|
||||
.description-text {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
color: rgba(227, 218, 246, 0.73);
|
||||
font-size: 25rpx;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.description-text.collapsed {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.collapse-action {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
text-align: right;
|
||||
color: #a970ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```css
|
||||
.description-markdown {
|
||||
margin-top: 22rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.description-markdown.collapsed {
|
||||
max-height: 168rpx;
|
||||
}
|
||||
|
||||
.event-body-footer {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.collapse-action {
|
||||
min-height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
color: #a970ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: Markdown content can collapse without changing `Markdown.vue`.
|
||||
|
||||
- [ ] **Step 8: Replace bottom action layout styles**
|
||||
|
||||
Replace the current `.bottom-actions`, `.bottom-action`, and `.chat-action` blocks:
|
||||
|
||||
```css
|
||||
.bottom-actions {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-height: 108rpx;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 0.76fr 0.76fr 2fr 0.76fr;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 32rpx 0;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
border-top: 1rpx solid rgba(126, 87, 255, 0.32);
|
||||
background: rgba(13, 13, 43, 0.94);
|
||||
box-shadow: 0 -14rpx 44rpx rgba(0, 0, 0, 0.45), inset 0 0 28rpx rgba(143, 92, 255, 0.1);
|
||||
backdrop-filter: blur(28rpx);
|
||||
-webkit-backdrop-filter: blur(28rpx);
|
||||
}
|
||||
|
||||
.bottom-action,
|
||||
.chat-action {
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: rgba(238, 231, 255, 0.9);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-action {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-action {
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #b246ff, #742fff 58%, #5126ff);
|
||||
box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.58), inset 0 1rpx 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```css
|
||||
.bottom-actions {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-height: 108rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14rpx 32rpx 0;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
border-top: 1rpx solid rgba(126, 87, 255, 0.32);
|
||||
background: rgba(13, 13, 43, 0.94);
|
||||
box-shadow: 0 -14rpx 44rpx rgba(0, 0, 0, 0.45), inset 0 0 28rpx rgba(143, 92, 255, 0.1);
|
||||
backdrop-filter: blur(28rpx);
|
||||
-webkit-backdrop-filter: blur(28rpx);
|
||||
}
|
||||
|
||||
.chat-action {
|
||||
width: min(420rpx, 100%);
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #b246ff, #742fff 58%, #5126ff);
|
||||
box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.58), inset 0 1rpx 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: “聊聊” button is centered and visually primary.
|
||||
|
||||
- [ ] **Step 9: Confirm shared icon styles still work in new locations**
|
||||
|
||||
Keep the existing `.edit-icon`, `.bookmark-icon`, `.share-icon`, and `.chat-badge` blocks unchanged.
|
||||
|
||||
Expected result: content toolbar and footer reuse the existing drawn icons with no new asset dependency.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Build Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `mini-program/src/pages/life-event/detail.vue`
|
||||
- Verify: `mini-program/package.json`
|
||||
|
||||
- [ ] **Step 1: Run H5 production build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:h5
|
||||
```
|
||||
|
||||
Expected: command exits with code 0 and Vite/UniApp writes the H5 build output under `mini-program/dist/build/h5` or the configured UniApp output directory.
|
||||
|
||||
- [ ] **Step 2: Fix compile errors by editing only detail.vue**
|
||||
|
||||
If the build reports a compile error in `detail.vue`, fix the exact syntax issue in `mini-program/src/pages/life-event/detail.vue`, then rerun:
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run build:h5
|
||||
```
|
||||
|
||||
Expected: command exits with code 0.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Browser Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `mini-program/src/pages/life-event/detail.vue`
|
||||
|
||||
- [ ] **Step 1: Start H5 dev server**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mini-program
|
||||
npm run dev:h5
|
||||
```
|
||||
|
||||
Expected: dev server prints a local URL, usually `http://localhost:5173`.
|
||||
|
||||
- [ ] **Step 2: Open the app in a browser**
|
||||
|
||||
Use the available browser automation tool to open:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
Expected: app loads without a blank screen.
|
||||
|
||||
- [ ] **Step 3: Navigate to a life event detail page**
|
||||
|
||||
Use the app UI to reach 人生轨迹详情页. If the local dev data has no event entry, open a detail URL with the current route format after creating or selecting an event through the UI.
|
||||
|
||||
Expected: the detail page renders and uses local store/cached fallback data when available.
|
||||
|
||||
- [ ] **Step 4: Verify visual requirements**
|
||||
|
||||
Check these exact outcomes:
|
||||
|
||||
- The left-top edit icon and right-top more icon are absent.
|
||||
- Event title information and event description are inside one merged card.
|
||||
- The upper title area is visibly darker than the body area.
|
||||
- Event description is rendered through Markdown formatting.
|
||||
- 收藏 is at the body area's left top.
|
||||
- 分享 is horizontally aligned with 收藏.
|
||||
- 分析 is a lighter pill at the right-top side of the body area.
|
||||
- 编辑 is at the body area's left bottom.
|
||||
- 编辑 and 收起 or 展开 are horizontally aligned.
|
||||
- 分析 and 收起 or 展开 are vertically aligned on the right side.
|
||||
- The bottom button is centered and displays “聊聊”.
|
||||
- The page has no overlapping text at mobile width.
|
||||
|
||||
Expected: all checks pass.
|
||||
|
||||
- [ ] **Step 5: Verify button behavior and console health**
|
||||
|
||||
Click these controls:
|
||||
|
||||
- 收藏
|
||||
- 分享
|
||||
- 分析
|
||||
- 编辑
|
||||
- 收起 or 展开
|
||||
- 聊聊
|
||||
|
||||
Expected:
|
||||
|
||||
- 收藏 toggles visual state or shows the existing failure toast when unauthenticated.
|
||||
- 分享 copies or shows the existing failure toast.
|
||||
- 分析 scrolls to the AI 分析 section.
|
||||
- 编辑 navigates to the event form when `eventId` exists.
|
||||
- 收起/展开 toggles Markdown body height.
|
||||
- 聊聊 opens the existing chat modal or shows existing failure text.
|
||||
- Browser Console contains no new errors from `detail.vue`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
Spec coverage:
|
||||
|
||||
- Top icons removed: Task 1 Step 1, Task 3 Step 1-2.
|
||||
- Title and content merged: Task 1 Step 3, Task 3 Step 4-5.
|
||||
- Darker title area: Task 3 Step 4.
|
||||
- Markdown description rendering: Task 1 Step 3, Task 3 Step 7.
|
||||
- Button placement: Task 1 Step 3 and Step 5, Task 3 Step 6-8.
|
||||
- Existing behavior preserved: Task 2 Step 4.
|
||||
- Browser verification required by repository rules: Task 5.
|
||||
|
||||
Placeholder scan:
|
||||
|
||||
- This plan contains no placeholder or incomplete implementation steps.
|
||||
|
||||
Type and property consistency:
|
||||
|
||||
- `scrollTop` is declared as `ref(0)` and bound as `:scroll-top="scrollTop"`.
|
||||
- `currentScrollTop` is declared as `ref(0)` and updated by `@scroll="onContentScroll"`.
|
||||
- `scrollToAnalysis` is referenced by the template and defined in script.
|
||||
- `analysisPanel` is the exact id used by selector query.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Mini Program WeChat Auth Design
|
||||
|
||||
## Problem
|
||||
|
||||
The mini program currently supports phone number plus SMS code login only. The new requirement is to add full WeChat authorization login while keeping the existing phone login available. WeChat login must use the free openid-based login flow as the primary path, and phone number authorization should be integrated when available so the backend can identify and bind existing phone users automatically.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add WeChat mini program login with `wx.login`/`uni.login` and backend `code2Session` exchange.
|
||||
- Keep the existing phone plus SMS code login flow unchanged as a supported fallback and alternate login method.
|
||||
- Use WeChat openid as the free, reliable identity for mini program login.
|
||||
- If WeChat phone authorization succeeds, use the returned phone number to identify an existing user and automatically bind the WeChat openid to that account.
|
||||
- Save nickname and avatar into existing `t_user.nickname` and `t_user.avatar` fields when the user provides them.
|
||||
- Reuse the current JWT response, token storage, session restore, onboarding routing, and profile flow.
|
||||
- Match the current mini program dark glass/cosmic visual style.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not remove, hide permanently, or degrade phone SMS login.
|
||||
- Do not introduce paid third-party login or SMS providers.
|
||||
- Do not require phone number authorization before a user can log in with WeChat openid.
|
||||
- Do not redesign unrelated onboarding, main tabs, or profile features.
|
||||
- Do not store WeChat `session_key` on the client.
|
||||
|
||||
## Current Findings
|
||||
|
||||
- `mini-program/src/pages/login/index.vue` contains the existing phone/SMS login UI and routes to main or onboarding based on profile presence.
|
||||
- `mini-program/src/stores/app.js` already centralizes login, logout, token restore, and profile fetch behavior.
|
||||
- `mini-program/src/services/auth.js` stores `access_token` and `refresh_token` from `AuthResponse`.
|
||||
- `backend-single/src/main/java/com/emotion/controller/AuthController.java` exposes `/auth/login`, `/auth/refreshToken`, and token validation endpoints.
|
||||
- `t_user` already has `phone`, `avatar`, `nickname`, `third_party_id`, and `third_party_type`, which can support WeChat binding without a required schema change.
|
||||
- The current backend issues JWT plus Redis-backed access/refresh tokens through `AuthServiceImpl`.
|
||||
- The mini program manifest already has a WeChat appid: `wxaf2eaba72d28f0e4`.
|
||||
- `application.yml` security ignore URLs must include the new public WeChat login endpoint, while the phone binding endpoint must remain protected by JWT.
|
||||
- `UserInfoResponse` exposes phone, nickname, and avatar, but it does not expose whether the current account has a WeChat binding. The frontend needs a non-sensitive `wechatBound` boolean instead of receiving openid.
|
||||
- Binding an openid from a temporary WeChat user to an existing phone user must also transfer or preserve data created under the temporary user; otherwise early actions before phone binding can be stranded on the temporary account.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use a two-track login page:
|
||||
|
||||
1. Primary action: WeChat login. The mini program calls `uni.login({ provider: 'weixin' })`, sends the returned code to the backend, and receives the same `AuthResponse` shape used by existing login.
|
||||
2. Secondary action: phone SMS login. The current phone/code form remains available and continues to call `/auth/login`.
|
||||
|
||||
Phone authorization is a post-login enhancement:
|
||||
|
||||
1. After WeChat login succeeds, show or surface a "bind phone" action using WeChat `open-type="getPhoneNumber"` when the platform supports it.
|
||||
2. The mini program sends the phone authorization `code` to the backend.
|
||||
3. The backend calls WeChat's phone-number API.
|
||||
4. If the phone already belongs to an existing user, bind the current openid to that existing user and return a fresh `AuthResponse` for that existing user.
|
||||
5. If the phone does not exist, write it to the current WeChat user and return updated user info.
|
||||
|
||||
This keeps WeChat openid login free and resilient while letting phone number authorization unify identities when available.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Configuration
|
||||
|
||||
Add configuration under `emotion.wechat.mini-program`:
|
||||
|
||||
- `app-id`
|
||||
- `app-secret`
|
||||
- `base-url`, default `https://api.weixin.qq.com`
|
||||
|
||||
Secrets must come from environment/profile configuration in deploy environments. `app-secret` must not be sent to the client or logged.
|
||||
|
||||
Add a database migration for WeChat identity integrity:
|
||||
|
||||
- Add a unique composite index on `t_user(third_party_type, third_party_id)`.
|
||||
- MySQL allows multiple `NULL` values in a unique index, so ordinary phone users without WeChat binding are unaffected.
|
||||
- Before transferring an openid from a temporary WeChat user to an existing phone user, clear the temporary user's third-party fields so the unique index remains valid.
|
||||
|
||||
### DTOs
|
||||
|
||||
Add request/response DTOs:
|
||||
|
||||
- `WechatLoginRequest`
|
||||
- `code`: required login code from `uni.login`.
|
||||
- `nickname`: optional user-provided nickname.
|
||||
- `avatar`: optional user-provided avatar URL.
|
||||
|
||||
- `WechatPhoneBindRequest`
|
||||
- `code`: required phone authorization code from `getPhoneNumber`.
|
||||
|
||||
- The response can reuse `AuthResponse` for login and phone binding so token handling stays consistent.
|
||||
- Extend `UserInfoResponse` with `wechatBound: boolean`. It must be computed from the server-side third-party fields and must not expose openid or session key.
|
||||
|
||||
### Service Boundaries
|
||||
|
||||
Add `WechatMiniProgramService` for WeChat API calls:
|
||||
|
||||
- `code2Session(code)` returns openid/session metadata.
|
||||
- `getStableAccessToken()` obtains and caches the mini program interface token in Redis.
|
||||
- `getPhoneNumber(phoneCode)` returns the verified phone number.
|
||||
- Translate WeChat API error codes into clear `BusinessException` messages.
|
||||
|
||||
Add `UserIdentityMergeService` for account convergence:
|
||||
|
||||
- `mergeTemporaryWechatUserIntoPhoneUser(String temporaryWechatUserId, String phoneUserId)` transfers user-owned rows from the temporary WeChat user to the canonical phone user.
|
||||
- The service updates the core mini-program tables that store `user_id`: `t_user_profile`, `t_life_event`, `t_epic_script`, `t_life_path`, `t_life_path_step`, `t_social_content_item`, `t_social_profile_insight`, `t_user_consent_log`, `t_tts_task`, and `t_analytics_event`.
|
||||
- It also updates user-facing shared data where appropriate: `t_conversation`, `t_message`, `t_coze_api_call`, `t_emotion_analysis`, `t_emotion_record`, `t_diary_post`, `t_diary_comment`, `t_community_post`, `t_comment`, `t_user_stats`, and `t_ai_call_log`.
|
||||
- If both users have a singleton row such as `t_user_profile` or `t_user_stats`, keep the existing phone user's row and only transfer rows that do not conflict. This avoids overwriting a mature phone account with sparse temporary data.
|
||||
|
||||
Extend `AuthService` with:
|
||||
|
||||
- `wechatLogin(WechatLoginRequest request)`
|
||||
- `bindWechatPhone(String currentUserId, WechatPhoneBindRequest request)`
|
||||
|
||||
Extend `UserService` with focused lookup helpers:
|
||||
|
||||
- `getByThirdParty(String thirdPartyType, String thirdPartyId)`
|
||||
- `getByPhone(String phone)` already exists and should be reused for phone identity matching.
|
||||
|
||||
The controller stays thin and delegates all identity decisions to the service layer.
|
||||
|
||||
### Identity Matching
|
||||
|
||||
For WeChat login:
|
||||
|
||||
1. Exchange login code for openid.
|
||||
2. Query user by `third_party_type = "wechat-mp"` and `third_party_id = openid`.
|
||||
3. If found and enabled, issue tokens for that user.
|
||||
4. If not found, create a user:
|
||||
- `account = "wx_" + openid`
|
||||
- `username =` generated username
|
||||
- `nickname =` request nickname or generated username
|
||||
- `avatar =` request avatar if present
|
||||
- `third_party_type = "wechat-mp"`
|
||||
- `third_party_id = openid`
|
||||
- `member_level = "free"`
|
||||
- `status = 1`
|
||||
|
||||
For phone binding:
|
||||
|
||||
1. Require a valid current JWT.
|
||||
2. Resolve the current WeChat user's openid from `third_party_id`.
|
||||
3. Exchange the phone authorization code for phone number.
|
||||
4. Query existing user by phone.
|
||||
5. If no phone user exists, set current user's `phone` and mark verified.
|
||||
6. If the phone user is the current user, ensure openid and phone are both present.
|
||||
7. If another enabled user owns the phone, treat that phone user as canonical.
|
||||
8. If the phone user already has a different WeChat openid, reject with a clear "phone already bound to another WeChat account" message.
|
||||
9. If the phone user can accept the binding, transfer user-owned rows from the temporary WeChat user to the phone user, clear the temporary user's third-party fields, bind the openid to the phone user, and issue new tokens for the phone user.
|
||||
|
||||
When the phone belongs to an existing account, the existing account is the canonical identity. The response must return tokens for that account so future openid login lands on the same user as phone SMS login.
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Login Page
|
||||
|
||||
Update `mini-program/src/pages/login/index.vue` while preserving the current visual language:
|
||||
|
||||
- Keep the dark background, glass card, purple accents, and safe-area handling.
|
||||
- Add a primary WeChat login button at the top of the card.
|
||||
- Keep the existing phone number and SMS code form as an alternate method below a subtle divider.
|
||||
- Use one `loading` state for the active login method and prevent duplicate taps.
|
||||
- On successful login, reuse current routing:
|
||||
- has profile -> `/pages/main/index?tab=script`
|
||||
- no profile -> `/pages/onboarding/index`
|
||||
|
||||
### Frontend Services And Store
|
||||
|
||||
Add to `mini-program/src/services/auth.js`:
|
||||
|
||||
- `wechatLogin({ code, nickname, avatar })`
|
||||
- `bindWechatPhone({ code })`
|
||||
|
||||
Both methods store returned tokens exactly like existing `login()` and `refreshToken()`.
|
||||
|
||||
Add to `mini-program/src/stores/app.js`:
|
||||
|
||||
- `loginWithWechat(profilePayload)`
|
||||
- `bindWechatPhone(phoneCode)`
|
||||
- `fetchCurrentUserInfo()`, called after login, token restore, and phone binding.
|
||||
- Keep `login(phone, smsCode)` unchanged.
|
||||
- After phone binding returns new tokens, refresh current profile/user state.
|
||||
|
||||
### Phone Authorization UI
|
||||
|
||||
Add a small phone-bind entry in the logged-in experience, preferably in `MineView.vue` or onboarding completion area:
|
||||
|
||||
- Show only when logged in, `userInfo.phone` is empty, and `userInfo.wechatBound` is true.
|
||||
- Use WeChat `button open-type="getPhoneNumber"` on mp-weixin.
|
||||
- On non-WeChat/H5 builds, show the existing phone SMS login/bind fallback.
|
||||
- If authorization succeeds and backend returns tokens for an existing phone user, overwrite local tokens and refresh profile.
|
||||
- If the user denies authorization, keep them logged in and show a non-blocking message.
|
||||
|
||||
### Nickname And Avatar
|
||||
|
||||
Use WeChat's current nickname/avatar collection pattern in profile or onboarding:
|
||||
|
||||
- Nickname input can populate existing `registrationData.nickname`.
|
||||
- Avatar selection can populate existing avatar fields.
|
||||
- Save through existing user/profile update endpoints where possible.
|
||||
- Do not block WeChat openid login on nickname or avatar.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing backend WeChat appid/secret: backend returns configuration error; frontend keeps phone login available.
|
||||
- `uni.login` failure: show a concise "WeChat login failed, please try again later" message and keep phone login visible.
|
||||
- `code2Session` failure or invalid code: show a concise login failure message.
|
||||
- Disabled user: reject login consistently with existing auth behavior.
|
||||
- Phone authorization denied: do not log out; show a non-blocking message that the user can bind a phone later in the profile center.
|
||||
- Phone API unavailable or permission not enabled: keep openid login active and route user normally.
|
||||
- Existing phone is bound to another openid: reject binding and ask user to use phone login or contact support.
|
||||
- Existing phone account has data while temporary WeChat account also has data: transfer non-conflicting temporary rows to the phone account before switching tokens.
|
||||
- Temporary WeChat account after successful merge: clear its `third_party_type` and `third_party_id` and disable it from future WeChat login matching.
|
||||
- Network failures: preserve current session restore behavior and avoid clearing tokens unless backend explicitly rejects auth.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### WeChat Login
|
||||
|
||||
1. User taps WeChat login.
|
||||
2. Mini program calls `uni.login`.
|
||||
3. Mini program posts `{ code }` to `/auth/wechat/login`.
|
||||
4. Backend exchanges code for openid through WeChat.
|
||||
5. Backend finds or creates user by openid.
|
||||
6. Backend returns `AuthResponse`.
|
||||
7. Mini program stores tokens, fetches profile, and routes.
|
||||
|
||||
### Phone Auto-Binding
|
||||
|
||||
1. Logged-in user taps authorize phone.
|
||||
2. WeChat returns a phone authorization code.
|
||||
3. Mini program posts `{ code }` to `/auth/wechat/phone`.
|
||||
4. Backend exchanges code for phone number.
|
||||
5. Backend finds existing user by phone.
|
||||
6. Backend binds openid to the phone user or writes phone to current user.
|
||||
7. If an existing phone user is canonical, backend transfers non-conflicting temporary user data and clears the temporary WeChat identity.
|
||||
8. Backend returns `AuthResponse`.
|
||||
9. Mini program replaces tokens and refreshes user/profile state.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend:
|
||||
|
||||
- WeChat login creates a new user for a new openid.
|
||||
- WeChat login returns the same user for a known openid.
|
||||
- Phone binding writes phone to current WeChat user when no existing phone user exists.
|
||||
- Phone binding binds openid to an existing phone user and returns that user's tokens.
|
||||
- Phone binding transfers temporary WeChat user data to the existing phone user before returning phone-user tokens.
|
||||
- Temporary WeChat user no longer matches future openid login after a successful transfer.
|
||||
- Phone binding rejects a phone user already bound to a different openid.
|
||||
- Existing phone SMS login still works.
|
||||
- Token validation and refresh still work after WeChat login.
|
||||
- `/api/auth/wechat/login` is public, and `/api/auth/wechat/phone` rejects unauthenticated requests.
|
||||
- `UserInfoResponse` returns `wechatBound` without returning openid.
|
||||
|
||||
Mini program:
|
||||
|
||||
- Build `npm run build:mp-weixin`.
|
||||
- Login page supports both WeChat and phone SMS login.
|
||||
- WeChat login success routes through the current profile/onboarding rules.
|
||||
- Phone login success routes through the current profile/onboarding rules.
|
||||
- Denying phone authorization does not break logged-in state.
|
||||
- Successful phone authorization refreshes tokens and profile.
|
||||
- Personal center reflects nickname/avatar/phone state where available.
|
||||
- Restored sessions fetch current user info so phone binding visibility is correct after app restart.
|
||||
|
||||
## References
|
||||
|
||||
- WeChat Mini Program `code2Session`: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
|
||||
- WeChat Mini Program phone number API: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html
|
||||
- WeChat Mini Program avatar/nickname capability: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
author: 部署脚本修复会话
|
||||
created_at: 2026-06-02
|
||||
purpose: 修复 deploy.py 的 nginx 站点启用命令 30s 超时问题(防御性诊断 + 清理)
|
||||
---
|
||||
|
||||
# 修复 deploy.py nginx 站点启用超时 — 设计文档
|
||||
|
||||
## 问题陈述
|
||||
|
||||
执行 `python deploy.py` 部署时,nginx 站点启用步骤失败:
|
||||
|
||||
```
|
||||
[INFO] 启用站点配置...
|
||||
[ERROR] 启用站点失败: 命令执行超时 (30s):
|
||||
ssh ... root@101.200.208.45
|
||||
ln -snf /etc/nginx/sites-available/lifescript.happylifeos.com.conf
|
||||
/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf
|
||||
&& rm -f /etc/nginx/sites-enabled/default
|
||||
```
|
||||
|
||||
**根因分析**(用户已确认):之前能成功部署,本次突然失败。`ln -snf` 目标在远程服务器上已存在为**目录**或**断链**(非空 symlink 指向已被删除/修改的源文件),`ln` 在某些情况下会递归进入等待而 hang。`&&` 链式让 `rm -f default` 也永不执行。30s 总超时被耗尽。
|
||||
|
||||
## 方案选择
|
||||
|
||||
经过 brainstorming 对话,用户选择 **方案 A:防御性诊断 + 清理**(修改脚本使其对异常状态自愈,不依赖手动干预)。
|
||||
|
||||
**未选方案**:
|
||||
- 方案 B(仅手动清理):不解决根本问题,未来重现概率高
|
||||
- 方案 C(手动 + 脚本加固):用户已先用方案 A 的脚本修复,验证后再决定是否需要手动清理
|
||||
- 方案 D(仅增超时):绕过问题,不修复根因
|
||||
|
||||
## 设计
|
||||
|
||||
### 核心思路
|
||||
|
||||
将 `ln -snf ... && rm -f ...` 的**链式单命令**拆为**多步独立 SSH 调用**,每步:
|
||||
- 独立超时(30s 对单步足够)
|
||||
- 独立返回 `ok/err/stderr`
|
||||
- 失败立即停止并打印详细诊断
|
||||
- 前置诊断 + 主动清理 hang 源(目录/断链)
|
||||
|
||||
### 新增函数 `enable_nginx_site(remote_conf, domain)`
|
||||
|
||||
签名:
|
||||
```python
|
||||
def enable_nginx_site(remote_conf: str, domain: str) -> bool:
|
||||
"""幂等地启用 nginx 站点(sites-enabled symlink)。
|
||||
|
||||
流程:
|
||||
1. 诊断目标路径当前状态
|
||||
2. 清理异常状态(目录 / 文件 / 断链)
|
||||
3. 创建 symlink
|
||||
4. 移除 default 站点
|
||||
5. 验证结果
|
||||
|
||||
Returns: True 成功;False 失败(stderr 已打印)
|
||||
"""
|
||||
```
|
||||
|
||||
### 5 步骤流程
|
||||
|
||||
| 步骤 | SSH 命令 | 失败处理 | 失败时是否继续 |
|
||||
|---|---|---|---|
|
||||
| 1. 诊断 | `test -e <target> && (test -L <target> && echo "symlink" \|\| (test -d <target> && echo "dir" \|\| echo "file")) \|\| echo "missing"` | 仅诊断,不失败(stdout 捕获到 `status` 变量) | — |
|
||||
| 2. 清理 | `case "$status" in symlink) unlink <target> ;; dir) rm -rf <target> ;; file) rm -f <target> ;; missing) ;; esac`(`$status` 是步骤 1 输出,通过 Python f-string 注入 SSH 命令) | 失败时打印 stderr + 提示"权限可能不足" | **否**(清理失败则终止,不重建链) |
|
||||
| 3. 建链 | 两段式:先 `if [ -e <target> ]; then echo "二次检查失败:target 仍存在" >&2; exit 1; fi`,再 `ln -snf {remote_conf} <target>` | 失败时区分:二次检查失败 → 提示"并发进程干扰";ln 自身失败 → 提示"scp_file 是否成功"或"权限不足" | — |
|
||||
| 4. 清默认 | `rm -f /etc/nginx/sites-enabled/default` | 失败时**仅 warning**(不阻塞):打印"default 站点可能仍存在,请在步骤 5 验证时确认" | — |
|
||||
| 5. 验证 | `readlink /etc/nginx/sites-enabled/{domain}.conf && (test ! -e /etc/nginx/sites-enabled/default && echo "default:OK" \|\| echo "default:STILL_EXISTS") && ls -la /etc/nginx/sites-enabled/` | 失败时打印实际值与期望值(含 default 状态) | — |
|
||||
|
||||
**步骤间值传递**(Python 端实现):
|
||||
```python
|
||||
# 步骤 1:执行诊断,stdout 捕获为 status
|
||||
ok, status, err = ssh_command(diagnose_cmd, capture=True)
|
||||
status = status.strip() # 期望值: "symlink" | "dir" | "file" | "missing"
|
||||
|
||||
# 步骤 2:用 f-string 注入 status 到 case 语句
|
||||
cleanup_cmd = f'case "{status}" in symlink) unlink {target} ;; ...) ;; esac'
|
||||
ok, _, err = ssh_command(cleanup_cmd, capture=True)
|
||||
if not ok:
|
||||
log_error("清理步骤失败")
|
||||
return False # 短路
|
||||
```
|
||||
|
||||
**关键决策**:
|
||||
- **步骤 2 用 `test -e/-L/-d/-f` 替代 `ls -ld` 解析**:shell 内置 test 比解析 `ls` 输出更可靠,跨 Unix 工具版本差异小
|
||||
- **步骤 2 失败则短路终止**:清理失败时建链毫无意义(脏状态仍在),立即返回 False 让用户介入
|
||||
- **有效 symlink 走 `unlink` 而非 `rm -f`**:与目录/文件区分对待,避免误删有效链接
|
||||
- **缺失(missing)跳过清理**:直接进建链步骤,是最常见的健康场景
|
||||
- **步骤 3 加 `test ! -e <target>` 二次防护**:极端并发场景(步骤 2 清理后、步骤 3 执行前有其他进程瞬间创建同名项)下,`ln -snf` 仍会 hang;前置 test 检查 + 失败时明确提示"并发进程干扰",避免静默 hang
|
||||
- **步骤 3 用两段式而非 `&& ... ||` 链**:避免 `ln` 自身失败(如权限不足)被误报为"二次检查失败",错误信息更精准
|
||||
- **步骤 4 失败仅 warning 不短路**:default 站点清理是非关键操作(即使存在也不阻塞 symlink 启用),失败时打印 warning 告知"default 站点可能仍存在",由用户在步骤 5 验证时确认
|
||||
- **步骤 5 验证同时检查 default**:除 `readlink` 验证 symlink 外,追加 `test ! -e /etc/nginx/sites-enabled/default` 确认 default 站点已清理;失败时同时打印 symlink 状态和 default 状态
|
||||
|
||||
**超时设置**:每步独立调用 `ssh_command(cmd, timeout=30)`,与现有 `ssh_command()` 默认值一致。**不修改** `ssh_command()` / `run_ssh_args()` 底层,也不需要新增超时参数。30s 对单步 SSH 命令(不涉及大文件传输)足够;如未来出现网络慢问题,可在调用处单独覆盖(如 `ssh_command(cmd, timeout=60)`)。
|
||||
|
||||
### 集成位置
|
||||
|
||||
修改 `G:\IdeaProjects\emotion-museun\deploy.py`:
|
||||
|
||||
- **第 250-256 行**:替换 `ln -snf ... && rm -f ...` 块为 `enable_nginx_site(remote_conf, DOMAIN)` 调用
|
||||
- **第 232-266 行**(`deploy_nginx` 函数)保持其他逻辑不变(scp_file 上传 conf、nginx -t、reload)
|
||||
- **新增函数** `enable_nginx_site` 放在 `deploy_nginx` 之前
|
||||
- **实施方式**:5 步都是直接调用现有 `ssh_command()`(不修改底层),只是用 Python `if/elif/else` 在函数内串起来
|
||||
|
||||
### 变量安全校验
|
||||
|
||||
`enable_nginx_site(remote_conf, domain)` 函数入口处对 `domain` 和 `remote_conf` 都做基本校验,防止变量污染导致误操作:
|
||||
|
||||
```python
|
||||
import re
|
||||
_SAFE_DOMAIN = re.compile(r'^[a-zA-Z0-9.-]+$') # domain 字符集
|
||||
_SAFE_REMOTE_CONF = re.compile(r'^[a-zA-Z0-9./_-]+$') # 路径字符集(允许下划线)
|
||||
|
||||
if not domain or not _SAFE_DOMAIN.match(domain):
|
||||
log_error(f"非法 domain 名称: {domain!r}")
|
||||
return False
|
||||
# domain 额外结构校验:禁止首尾连字符/点、禁止连续点(非法 TLD)
|
||||
if domain.startswith('-') or domain.endswith('-') \
|
||||
or domain.startswith('.') or domain.endswith('.') \
|
||||
or '..' in domain:
|
||||
log_error(f"非法 domain 结构: {domain!r} (首尾连字符/点 或 连续点非法)")
|
||||
return False
|
||||
|
||||
if not remote_conf or not _SAFE_REMOTE_CONF.match(remote_conf):
|
||||
log_error(f"非法 remote_conf 路径: {remote_conf!r}")
|
||||
return False
|
||||
# 路径额外结构校验:禁止连续斜杠(路径注入)、禁止 .. 路径遍历
|
||||
if '//' in remote_conf or '..' in remote_conf:
|
||||
log_error(f"remote_conf 包含可疑路径片段: {remote_conf!r}")
|
||||
return False
|
||||
```
|
||||
|
||||
校验规则:
|
||||
- `domain` 非空,仅含字母、数字、点、连字符;**首尾不能是 `-` 或 `.`;不能含 `..` 连续点**
|
||||
- `remote_conf` 非空,仅含字母、数字、`/`、点、连字符、下划线;**不能含 `//` 连续斜杠或 `..` 路径遍历**
|
||||
- 不含 `;`、`&`、`|`、`$`、反引号、空格等 shell 特殊字符
|
||||
|
||||
虽然 `remote_conf` 当前来源固定(f-string 拼接),但**未来重构时**(如改为从配置文件读取)这个校验能防止意外引入危险字符。
|
||||
|
||||
### 错误信息改进
|
||||
|
||||
`enable_nginx_site` 内部用统一的 `log_error` 输出结构化信息,**使用描述性名称而非"步骤 N"**:
|
||||
|
||||
```
|
||||
[ERROR] 启用站点失败 - 建链步骤: Permission denied
|
||||
[ERROR] 上下文: 目标 = /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf
|
||||
[ERROR] 提示: 确认 scp_file 是否成功上传了 sites-available 下的 conf 文件
|
||||
```
|
||||
|
||||
各步骤使用的描述性名称:
|
||||
- 诊断步骤
|
||||
- 清理步骤
|
||||
- 建链步骤
|
||||
- 清默认步骤
|
||||
- 验证步骤
|
||||
|
||||
替代当前的"命令执行超时 (30s)",便于快速定位卡在哪一步。
|
||||
|
||||
## 范围
|
||||
|
||||
### 包含
|
||||
|
||||
- 修改 `deploy.py` 的 `deploy_nginx()` 函数调用方式
|
||||
- 新增 `enable_nginx_site()` 函数(约 50-70 行)
|
||||
- 错误信息结构化改进
|
||||
|
||||
### 不包含(明确排除)
|
||||
|
||||
- 不修改其他 deploy.py 子脚本(backend-single/web/web-admin/life-script)
|
||||
- 不改 `ssh_command()` / `run_ssh_args()` 底层
|
||||
- 不增加 `--dry-run` 模式
|
||||
- 不改 nginx 配置文件本身
|
||||
- 不改默认 30s 超时(单步足够,问题在链式 hang,不在网络慢)
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 单元级(脚本内)
|
||||
|
||||
部署后通过以下方式验证:
|
||||
|
||||
1. **干净服务器场景**:远程 `/etc/nginx/sites-enabled/{domain}.conf` 不存在 → 期望成功
|
||||
2. **断链场景**:手动 `ln -s /nonexistent /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 清理 + 步骤 3 重建
|
||||
3. **目录场景**:手动 `mkdir /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 `rm -rf` 清理 + 步骤 3 成功
|
||||
4. **文件场景**:手动 `touch /etc/nginx/sites-enabled/{domain}.conf` → 期望步骤 2 `rm -f` 清理 + 步骤 3 成功
|
||||
5. **正常 symlink 场景**:手动重建有效 symlink → 期望步骤 2 unlink + 步骤 3 重建
|
||||
6. **权限不足场景**:以非 root 用户运行 deploy.py(或临时 `chmod 000 /etc/nginx/sites-enabled/{domain}.conf` 父目录) → 期望步骤 2 正确报错 + 短路终止,不进入步骤 3
|
||||
7. **并发干扰场景**(极难手动复现,可选):在步骤 2 清理和步骤 3 之间用 `watch` 命令持续创建同名目录 → 期望步骤 3 二次防护触发,提示"并发进程干扰"
|
||||
|
||||
### 集成级
|
||||
|
||||
跑 `python deploy.py nginx` 完整流程,期望:
|
||||
- 不再出现"启用站点失败"
|
||||
- `/etc/nginx/sites-enabled/{domain}.conf` 是有效 symlink
|
||||
- `/etc/nginx/sites-enabled/default` 不存在
|
||||
- `nginx -t` 通过
|
||||
- `systemctl reload nginx` 成功
|
||||
|
||||
### 验证前置
|
||||
|
||||
修复后**必须**执行 `python deploy.py nginx`(或 `python deploy.py all`)进行端到端验证,**不能仅靠代码 review**。这是用户在前次 PATH 修复中明确要求的规则(CLAUDE.md "验证规则(强制)")。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
### 风险
|
||||
|
||||
1. **远程 rm -rf 误删**:仅针对 `/etc/nginx/sites-enabled/{domain}.conf` 单个路径(已通过 `{domain}` 限定 + 变量安全校验),不会影响其他站点
|
||||
2. **步骤 2 误判**:如果 `test -e/-L/-d/-f` 在远程 shell 版本异常或文件系统不支持(如 NFS 边缘情况)时输出不符合预期 → 步骤 3 二次防护 `test ! -e` 仍能检测到残留,整体仍自愈
|
||||
3. **SSH 连接问题掩盖**:如果 SSH 本身不通,新代码会立即在步骤 1 报告 stderr,不会比原代码更差
|
||||
4. **并发进程干扰**:步骤 2 清理和步骤 3 之间有其他进程创建同名目录 → 步骤 3 二次防护触发,提示"并发进程干扰"而非静默 hang
|
||||
5. **变量污染**:通过变量安全校验(domain + remote_conf)防御,即使未来重构时从配置读取也不会引入 shell 注入
|
||||
|
||||
### 回滚
|
||||
|
||||
- 改动局限在 `deploy.py` 单文件、新增 1 个函数
|
||||
- 如新代码有问题,`git checkout deploy.py` 即可恢复
|
||||
|
||||
## 后续可选增强(不在本次范围)
|
||||
|
||||
- `enable_nginx_site` 通用化到 `disable_nginx_site()`,支持 `deploy.py nginx disable <domain>`
|
||||
- 把 `enable_nginx_site` 提取到独立模块(`deploy_nginx_helpers.py`),便于其他 deploy.py 复用
|
||||
- 增加 `--dry-run` 模式,仅打印将执行命令不实际执行
|
||||
- 步骤级重试机制(如步骤 3 失败时自动重试 1 次)
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `G:\IdeaProjects\emotion-museun\deploy.py` — 主修改目标(第 232-266 行 `deploy_nginx`,新增 `enable_nginx_site`)
|
||||
- `G:\IdeaProjects\emotion-museun\conf\emotion-museum.conf` — nginx 配置文件(不修改)
|
||||
- `G:\IdeaProjects\emotion-museun\docs\superpowers\specs\2026-06-02-deploy-nginx-site-enable-fix-design.md` — 本设计文档
|
||||
@@ -0,0 +1,63 @@
|
||||
# 登录后路由跳转修复设计文档
|
||||
|
||||
## 问题描述
|
||||
|
||||
当前小程序登录成功后,系统会根据用户是否已有资料(`hasProfile`)进行分支路由:
|
||||
- 有资料 → 爽文生成页面 (`/pages/main/index?tab=script`)
|
||||
- 无资料 → 编辑资料页面 (`/pages/onboarding/index`)
|
||||
|
||||
这导致新用户或资料不完整的用户登录后被迫进入编辑资料页面,而非核心的爽文生成页面。产品要求登录后统一进入爽文生成页,不再强制填写资料。
|
||||
|
||||
## 方案概述
|
||||
|
||||
采用**方案 A**:直接移除登录后的资料检查,统一路由到爽文生成页。
|
||||
|
||||
编辑资料页面(`onboarding/index.vue`)继续保留,用户可从"我的" tab 中的编辑资料入口主动进入。
|
||||
|
||||
## 具体改动
|
||||
|
||||
### 1. `mini-program/src/pages/login/index.vue`
|
||||
|
||||
修改 `routeAfterLogin` 函数,移除 `profileReady` 参数和条件判断,登录成功后一律跳转至爽文生成页:
|
||||
|
||||
```javascript
|
||||
const routeAfterLogin = () => {
|
||||
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||
}
|
||||
```
|
||||
|
||||
调用处同步更新:
|
||||
- `handleWechatLogin` 中:`routeAfterLogin()`
|
||||
- `handleLogin` 中:`routeAfterLogin()`
|
||||
|
||||
### 2. `mini-program/src/pages/splash/index.vue`
|
||||
|
||||
修改 `resolveInitialRoute` 函数中已登录用户的跳转逻辑:
|
||||
|
||||
```javascript
|
||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||
routeOnce('/pages/main/index?tab=script', { reason: session.reason })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
不再根据 `session.hasProfile` 进行分支。
|
||||
|
||||
### 3. 保留内容
|
||||
|
||||
- `stores/app.js` 中的 `hasProfile` computed 属性**不做修改**,它仍可在其他场景(如判断是否需要引导补充资料)中使用。
|
||||
- `onboarding/index.vue` 页面**不做修改**,保留为独立的编辑资料页面。
|
||||
|
||||
## 数据流影响
|
||||
|
||||
- 登录流程:`login/splash` → `main/index?tab=script`
|
||||
- 资料编辑入口:用户主动从"我的" tab 进入 `profile/index` 或 `onboarding/index`
|
||||
- 无后端接口变更
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. 新用户手机号登录成功后,直接进入爽文生成页面(底部 tab 高亮"爽文生成")
|
||||
2. 新用户微信登录成功后,同样直接进入爽文生成页面
|
||||
3. 已登录用户冷启动(splash 页恢复会话),直接进入爽文生成页面
|
||||
4. 从"我的" tab 点击编辑资料,仍可正常进入资料编辑页面
|
||||
5. 资料编辑页面保存后,仍可正常返回爽文生成页面
|
||||
@@ -0,0 +1,110 @@
|
||||
# 个人中心页面动态数据修复设计文档
|
||||
|
||||
## 问题描述
|
||||
|
||||
小程序"我的"(个人中心)页面(`MineView.vue`)当前显示的是写死的固定信息,而非当前登录用户的实际数据:
|
||||
|
||||
- 头像区域显示纯 CSS 绘制的通用人形图标,未使用用户头像
|
||||
- 昵称 fallback 为固定值 `'张义明'`
|
||||
- 身份标签 fallback 为固定值 `'ENFJ · 狮子座 · 星民'`
|
||||
- 统计数据 `觉醒深度 Lv.4` 和 `星历契合 98%` 为硬编码
|
||||
|
||||
## 方案概述
|
||||
|
||||
采用**方案 A**:从现有 store 数据动态生成头像和统计,替换所有写死内容。仅修改 `MineView.vue`,无后端改动。
|
||||
|
||||
## 具体改动
|
||||
|
||||
### 1. `mini-program/src/pages/main/MineView.vue`
|
||||
|
||||
#### 头像区域
|
||||
|
||||
用 `<image>` 组件替换 CSS 人形图标(`.person-head` 和 `.person-body`)。
|
||||
|
||||
新增 `avatarUrl` computed:
|
||||
```javascript
|
||||
const avatarUrl = computed(() => {
|
||||
const seed = profile.value.nickname || 'user'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b982ff`
|
||||
})
|
||||
```
|
||||
|
||||
模板中:
|
||||
```html
|
||||
<image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
|
||||
```
|
||||
|
||||
保留原有 `.avatar-circle` 的圆形边框和发光效果。
|
||||
|
||||
#### 昵称与身份标签
|
||||
|
||||
- `displayName` 的 fallback 从 `'张义明'` 改为 `'未设置昵称'`
|
||||
- `metaLine`:当 `mbti`、`zodiac`、`profession` 均为空时显示 `'点击设置个人档案'`;否则按 `${mbti} · ${zodiac} · ${identity}` 拼接
|
||||
|
||||
#### 统计数据
|
||||
|
||||
**觉醒深度**:基于 `store.events.length` 映射等级:
|
||||
| 事件数量 | 等级 |
|
||||
|---------|------|
|
||||
| 0 | Lv.1 |
|
||||
| 1-2 | Lv.2 |
|
||||
| 3-5 | Lv.3 |
|
||||
| 6-9 | Lv.4 |
|
||||
| 10+ | Lv.5 |
|
||||
|
||||
```javascript
|
||||
const awakenLevel = computed(() => {
|
||||
const count = store.events?.length || 0
|
||||
if (count >= 10) return 'Lv.5'
|
||||
if (count >= 6) return 'Lv.4'
|
||||
if (count >= 3) return 'Lv.3'
|
||||
if (count >= 1) return 'Lv.2'
|
||||
return 'Lv.1'
|
||||
})
|
||||
```
|
||||
|
||||
**星历契合**:基于资料完整度计算百分比。检查字段:`nickname`、`gender`、`zodiac`、`mbti`、`profession`、`hobbies`(数组有长度计 1 分)、以及人生经历是否填写(`childhood.text`、`joy.text`、`low.text`、`future.ideal` 任一非空计 1 分)。满分 7 分。
|
||||
|
||||
```javascript
|
||||
const harmonyPercent = computed(() => {
|
||||
const hasLifeStory = profile.value.childhood?.text ||
|
||||
profile.value.joy?.text ||
|
||||
profile.value.low?.text ||
|
||||
profile.value.future?.ideal
|
||||
const fields = [
|
||||
profile.value.nickname,
|
||||
profile.value.gender,
|
||||
profile.value.zodiac,
|
||||
profile.value.mbti,
|
||||
profile.value.profession,
|
||||
profile.value.hobbies?.length,
|
||||
hasLifeStory
|
||||
]
|
||||
const filled = fields.filter(Boolean).length
|
||||
return Math.round((filled / 7) * 100) + '%'
|
||||
})
|
||||
```
|
||||
|
||||
模板中将硬编码的 `Lv.4` 和 `98%` 分别替换为 `{{ awakenLevel }}` 和 `{{ harmonyPercent }}`。
|
||||
|
||||
## 数据依赖
|
||||
|
||||
`MineView.vue` 通过 `useAppStore()` 访问 store。`main/index.vue` 在 `onMounted` 中已经调用了:
|
||||
- `store.fetchUserProfile()`
|
||||
- `store.fetchEvents()`
|
||||
|
||||
因此 `MineView.vue` 无需新增数据加载逻辑,可直接读取 `store.userProfile` 和 `store.events`。
|
||||
|
||||
## 未修改内容
|
||||
|
||||
- `stores/app.js` 不做修改
|
||||
- `services/userProfile.js` 不做修改
|
||||
- `onboarding/index.vue` 不做修改
|
||||
- 页面样式(`.profile-center`、`.stats-grid` 等)保持原样
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. 有完整资料的用户进入"我的"页面,显示 Dicebear 生成头像、真实昵称、MBTI/星座/职业、根据事件数计算的觉醒深度、根据资料完整度计算的星历契合百分比
|
||||
2. 新用户(无资料)进入"我的"页面,显示默认头像、`未设置昵称`、`点击设置个人档案`、觉醒深度 Lv.1、星历契合 0%
|
||||
3. 点击"个人档案设置"仍能正常跳转到 onboarding 编辑资料页
|
||||
4. 编辑资料保存后返回"我的"页面,数据已更新
|
||||
@@ -0,0 +1,210 @@
|
||||
# 小程序用户信息去写死 + 编辑资料保存生效 — 设计文档
|
||||
|
||||
## 背景
|
||||
|
||||
小程序"人生轨迹"页和"编辑资料"页存在大量硬编码的用户信息(昵称、星座、MBTI、职业、爱好、示例事件等)。即使用户已登录并设置了真实资料,页面仍展示写死的数据。同时,编辑资料页中的 `city`/`industry`/`company`/`personalityTags`/`birthday` 字段因后端 API 不支持而无法真正保存,导致"保存成功但回显丢失"。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 人生轨迹页(`RecordView.vue`)必须展示当前登录用户的真实资料,去除一切写死数据。
|
||||
2. 编辑资料页(`onboarding/index.vue`)必须能成功保存、更新、回显用户信息。
|
||||
3. 后端扩展 `t_user_profile` 表和相关 DTO,支持前端已有的 `city`/`industry`/`company`/`personalityTags`/`birthday` 字段。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 个人中心页(`MineView.vue`)的修复不在本次任务范围内(已有独立任务)。
|
||||
- 不修改 UI 视觉风格,只改数据绑定和默认值策略。
|
||||
|
||||
## 架构与数据流
|
||||
|
||||
```
|
||||
编辑资料页 (onboarding/index.vue)
|
||||
↓ form 数据
|
||||
前端 userProfileService.transformToBackendFormat
|
||||
↓ 新增 city/industry/company/personalityTags/birthday
|
||||
后端 UserProfileController → UserProfileService
|
||||
↓
|
||||
数据库 t_user_profile (新增5个字段)
|
||||
↓
|
||||
后端返回 UserProfileResponse (含新增字段)
|
||||
↓
|
||||
前端 userProfileService.transformToFrontendFormat
|
||||
↓ store.userProfile
|
||||
人生轨迹页 (RecordView.vue) / 编辑资料页回显
|
||||
```
|
||||
|
||||
## 后端改动
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
文件:`sql/emotion_museum_ddl.sql`(追加)
|
||||
|
||||
```sql
|
||||
-- 用户档案扩展字段:城市、行业、公司、性格标签、生日
|
||||
alter table emotion_museum.t_user_profile
|
||||
add city varchar(100) null comment '所在城市',
|
||||
add industry varchar(100) null comment '行业',
|
||||
add company varchar(200) null comment '公司',
|
||||
add personality_tags json null comment '性格标签列表',
|
||||
add birthday date null comment '生日';
|
||||
```
|
||||
|
||||
### 2. 实体类扩展
|
||||
|
||||
文件:`backend-single/src/main/java/com/emotion/entity/UserProfile.java`
|
||||
|
||||
新增字段(与数据库列名通过 `@TableField` 映射):
|
||||
|
||||
| 字段名 | 类型 | 数据库列 | 说明 |
|
||||
|--------|------|----------|------|
|
||||
| city | String | city | 所在城市 |
|
||||
| industry | String | industry | 行业 |
|
||||
| company | String | company | 公司 |
|
||||
| personalityTags | String | personality_tags | 性格标签 JSON 字符串 |
|
||||
| birthday | LocalDate | birthday | 生日 |
|
||||
|
||||
### 3. DTO 扩展
|
||||
|
||||
以下三个文件新增相同 5 个字段:
|
||||
|
||||
- `backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java`
|
||||
- `backend-single/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java`
|
||||
- `backend-single/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java`
|
||||
|
||||
`UserProfileResponse` 中的 `birthday` 使用 `@JsonFormat(pattern = "yyyy-MM-dd")` 注解,与现有日期字段保持一致。
|
||||
|
||||
### 4. Service 层
|
||||
|
||||
文件:`backend-single/src/main/java/com/emotion/service/impl/UserProfileServiceImpl.java`
|
||||
|
||||
无需改动。`BeanUtils.copyProperties` 和 `BeanUtil.copyProperties(..., CopyOptions.create().setIgnoreNullValue(true))` 会自动映射同名字段。
|
||||
|
||||
## 前端改动
|
||||
|
||||
### 1. 人生轨迹页(`mini-program/src/pages/main/RecordView.vue`)
|
||||
|
||||
**移除示例事件:**
|
||||
- 删除 `sampleEvents` 数组(4 条写死的人生事件数据)。
|
||||
- `events` computed 逻辑改为:
|
||||
```js
|
||||
const events = computed(() => store.events || [])
|
||||
```
|
||||
- 空事件时保留现有空状态 UI("还没有人生轨迹")。
|
||||
|
||||
**移除硬编码默认值:**
|
||||
| 原代码 | 修改后 |
|
||||
|--------|--------|
|
||||
| `profile.nickname \|\| 'Zoey'` | `profile.nickname \|\| ''` |
|
||||
| `profile.zodiac \|\| '巨蟹座'` | `profile.zodiac \|\| ''` |
|
||||
| `profile.mbti \|\| 'ENTJ'` | `profile.mbti \|\| ''` |
|
||||
| `profile.profession \|\| '产品经理'` | `profile.profession \|\| ''` |
|
||||
| `heroTags` 默认 `['阅读', '旅行', '音乐', '创作']` | `[]` |
|
||||
| `avatar` 计算中的默认 `'Zoey'` | 保留 `'User'` 作为 Dicebear seed 的兜底(这是头像生成种子,非展示名称) |
|
||||
|
||||
**空值 UI 处理:**
|
||||
- 昵称空时,profile-info 区域不显示名称,但保留"正在成为更清晰的自己"作为固定标语(非用户数据)。
|
||||
- 星座/MBTI/职业空时,对应 meta-item 的 `meta-value` 显示 `"未设置"`。
|
||||
- 爱好空时,`hobby-text` 显示 `"未设置"`。
|
||||
|
||||
### 2. 编辑资料页(`mini-program/src/pages/onboarding/index.vue`)
|
||||
|
||||
**`syncFromStore()` 去除硬编码默认值:**
|
||||
|
||||
原逻辑在 `source` 为空时回退到大量硬编码值,改为全部回退到空值/空数组:
|
||||
|
||||
| 字段 | 原默认值 | 新默认值 |
|
||||
|------|----------|----------|
|
||||
| nickname | `''` | `''`(不变) |
|
||||
| gender | `'女'` | `''` |
|
||||
| zodiac | `'巨蟹座'` | `''` |
|
||||
| mbti | `'ENTJ'` | `''` |
|
||||
| profession | `'产品经理'` | `''` |
|
||||
| city | `'上海市'` | `''` |
|
||||
| industry | `'互联网'` | `''` |
|
||||
| company | `''` | `''`(不变) |
|
||||
| personalityTags | `['理性', '乐观', ...]` | `[]` |
|
||||
| hobbies | `['阅读', '旅行', '音乐']` | `[]` |
|
||||
| future.ideal | 长文本默认 | `''` |
|
||||
| birthday | `'1998-06-18'` | `''` |
|
||||
|
||||
**空值显示优化:**
|
||||
- `birthdayDisplay` computed:空值时返回 `"请选择生日"`。
|
||||
- `avatarUrl`:空昵称时保留默认 Dicebear 头像(seed 用 `"User"`),仅作为头像生成参数,不展示给用户。
|
||||
|
||||
**保存逻辑:**
|
||||
- `saveProfile` 逻辑不变,继续调用 `store.updateRegistration()` 和 `store.saveUserProfile()`。
|
||||
- 由于后端已扩展字段,所有字段均可正确保存。
|
||||
|
||||
### 3. 前端 Service 扩展(`mini-program/src/services/userProfile.js`)
|
||||
|
||||
**`transformToBackendFormat` 增加:**
|
||||
```js
|
||||
return {
|
||||
// ... 原有字段 ...
|
||||
city: frontendData.city || null,
|
||||
industry: frontendData.industry || null,
|
||||
company: frontendData.company || null,
|
||||
personalityTags: Array.isArray(frontendData.personalityTags)
|
||||
? JSON.stringify(frontendData.personalityTags)
|
||||
: frontendData.personalityTags,
|
||||
birthday: frontendData.birthday || null
|
||||
}
|
||||
```
|
||||
|
||||
**`transformToFrontendFormat` 增加:**
|
||||
```js
|
||||
return {
|
||||
// ... 原有字段 ...
|
||||
city: backendData.city || '',
|
||||
industry: backendData.industry || '',
|
||||
company: backendData.company || '',
|
||||
personalityTags: backendData.personalityTags
|
||||
? (typeof backendData.personalityTags === 'string'
|
||||
? JSON.parse(backendData.personalityTags)
|
||||
: backendData.personalityTags)
|
||||
: [],
|
||||
birthday: backendData.birthday || ''
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Store 扩展(`mini-program/src/stores/app.js`)
|
||||
|
||||
`registrationData` 初始结构增加:
|
||||
```js
|
||||
registrationData: {
|
||||
// ... 原有字段 ...
|
||||
city: '',
|
||||
industry: '',
|
||||
company: '',
|
||||
personalityTags: [],
|
||||
birthday: ''
|
||||
}
|
||||
```
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `sql/emotion_museum_ddl.sql` | 追加 | 5 个 ALTER TABLE 语句 |
|
||||
| `backend-single/.../entity/UserProfile.java` | 修改 | 新增 5 个字段 |
|
||||
| `backend-single/.../request/.../UserProfileCreateRequest.java` | 修改 | 新增 5 个字段 |
|
||||
| `backend-single/.../request/.../UserProfileUpdateRequest.java` | 修改 | 新增 5 个字段 |
|
||||
| `backend-single/.../response/.../UserProfileResponse.java` | 修改 | 新增 5 个字段 |
|
||||
| `mini-program/src/services/userProfile.js` | 修改 | 双向转换增加 5 个字段 |
|
||||
| `mini-program/src/stores/app.js` | 修改 | `registrationData` 扩展 |
|
||||
| `mini-program/src/pages/main/RecordView.vue` | 修改 | 去除写死默认值和 sampleEvents |
|
||||
| `mini-program/src/pages/onboarding/index.vue` | 修改 | 去除硬编码默认值,优化空值展示 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
- **保存失败**:`saveProfile` 已有 `uni.showToast({ title: res.error || '保存失败' })`,无需额外改动。
|
||||
- **后端字段新增后老数据兼容**:新增字段均为 `NULL`,老数据读取时前端 `transformToFrontendFormat` 会转为空字符串/空数组,不影响现有逻辑。
|
||||
- **数据库列已存在**:ALTER TABLE 添加已存在列会报错。本次为全新列名,安全。若生产环境已存在(极小概率),需人工确认。
|
||||
|
||||
## 测试验证点
|
||||
|
||||
1. 新用户首次编辑资料,所有字段留空,保存成功,回显为空。
|
||||
2. 填写城市、行业、公司、性格标签、生日后保存,重新进入编辑页,数据正确回显。
|
||||
3. 人生轨迹页未设置资料时,用户信息和爱好显示"未设置"或留空,不展示任何写死数据。
|
||||
4. 人生轨迹页无事件时,显示"还没有人生轨迹"空状态,不展示 sampleEvents。
|
||||
5. 已有用户(老数据)登录后,页面正常展示,无报错。
|
||||
@@ -0,0 +1,538 @@
|
||||
---
|
||||
author: 微信登录修复会话
|
||||
created_at: 2026-06-03
|
||||
purpose: 修复微信小程序登录 text/plain 错误(Spring RestTemplate 默认 User-Agent 触发 WAF 拦截 + 凭据硬编码安全问题)
|
||||
---
|
||||
|
||||
# 修复微信小程序登录 text/plain 错误 — 设计文档
|
||||
|
||||
## 问题陈述
|
||||
|
||||
微信小程序用户在小程序里点击「微信授权登录」时,后端 `/api/auth/wechat/login` 接口返回 500 错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"message": "系统运行异常: Could not extract response: no suitable HttpMessageConverter found for response type [class com.emotion.dto.wechat.WechatCodeSessionResponse] and content type [text/plain]",
|
||||
"timestamp": 1780414444577
|
||||
}
|
||||
```
|
||||
|
||||
**业务影响**:
|
||||
- 真实用户(IP 106.39.148.160)2026-06-02 23:34:04 尝试登录失败
|
||||
- 21:22、22:11、23:06 还出现 3 次 `WARN 业务异常: 微信小程序登录未配置`(早期因 env var 未设被 `validateConfigured()` 拦截)
|
||||
- 微信登录完全不可用
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 证据链
|
||||
|
||||
| # | 证据 | 结论 |
|
||||
|---|---|---|
|
||||
| 1 | 错误堆栈:`WechatMiniProgramServiceImpl.java:33` 调用 `restTemplate.getForObject(url, WechatCodeSessionResponse.class)` | ✓ 错误在微信 code2Session 调用 |
|
||||
| 2 | `application.yml:78-79` 微信配置**硬编码测试凭据**(`app-secret: 4f087bd363a4147ef543d634f9f6950d`)作为默认值 | ⚠️ **安全风险**(生产未设环境变量时会用测试 secret) |
|
||||
| 3 | `application-prod.yml` **不覆盖**微信配置 | ⚠️ 生产用 application.yml 默认值(测试 secret) |
|
||||
| 4 | `RestTemplateConfig.java:10-12` 仅 `new RestTemplate()`,13 行 | ⚠️ **无 User-Agent / Accept 头** |
|
||||
| 5 | Spring 默认 `RestTemplate` → `SimpleClientHttpRequestFactory` → `HttpURLConnection`,默认 `User-Agent: Java/17.0.x` | ❌ **触发了 WAF / CloudFlare / 边缘代理拦截** |
|
||||
| 6 | SSH+curl 测试(带 `User-Agent: curl/7.61.1`)→ `api.weixin.qq.com` 返回 `Content-Type: application/json; encoding=utf-8` | ✓ 微信 API 本身正常 |
|
||||
| 7 | 日志中 23:34:04 之前的多次 `Wechat mini program login is not configured` | 23:34 env var 设上了,进入了真实 API 调用 |
|
||||
| 8 | 已有 `docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md`(265 行完整设计) | 设计已存在,但未覆盖此 bug |
|
||||
|
||||
### 根因结论
|
||||
|
||||
**主要根因(用户已确认)**:生产环境的 WAF / CloudFlare / 边缘代理拦截了 Java 默认 `User-Agent: Java/17.0.x`,返回 `text/plain` 错误页面(这是 WAF 常见行为)。SSH+curl 用 `User-Agent: curl/7.61.1` 所以能正常通过。
|
||||
|
||||
**次要根因(必须一并修复)**:
|
||||
|
||||
1. **app-id 和 app-secret 硬编码在源码**(`wxaf2eaba72d28f0e4` / `4f087bd363a4147ef543d634f9f6950d`)—— 安全问题,必须移除默认值,强制从环境变量读取
|
||||
2. **错误日志不打印响应体** —— 看不到 text/plain 实际内容,下次出问题还是黑盒
|
||||
3. **无超时设置** —— 微信 API hang 住时整个请求线程被锁
|
||||
4. **RestTemplate 无日志拦截器** —— 出问题时无任何请求/响应可查
|
||||
5. **缺全局异常处理** —— `WechatApiException` 无 handler,会落到 RuntimeException handler 暴露内部消息
|
||||
6. **日志明文打印 openid / session_key** —— 违反项目规则第 37 条"敏感信息不得在日志中输出"
|
||||
|
||||
### 已排除假设
|
||||
|
||||
- ❌ 微信 API endpoint URL 配错:SSH+curl 直连 `https://api.weixin.qq.com/sns/jscode2session` 返回正常 JSON
|
||||
- ❌ 部署代码 ≠ 本地代码:日志中 `WechatMiniProgramServiceImpl.java:33` 与本地代码行号完全一致
|
||||
- ❌ 网络层完全不通:SSH+curl TLS 握手成功
|
||||
- ❌ `getPhoneNumber` / `getStableAccessToken` 也有 bug:当前 `WechatMiniProgramServiceImpl` 只实现了 `code2Session`(49 行),其他方法在 2026-05-31 设计文档中规划但尚未实施
|
||||
|
||||
## 目标
|
||||
|
||||
- **G1** 修复微信小程序登录 `text/plain` 错误,使用户能成功通过微信授权登录
|
||||
- **G2** 在 RestTemplate 中显式设置 `User-Agent`,绕过 WAF 对 `Java/*` 默认 UA 的拦截
|
||||
- **G3** 错误日志打印原始响应体(raw body)+ 脱敏(`openid` / `session_key` / `unionid` / `access_token` / `refresh_token`),下次类似问题能直接看到内容但不泄露敏感信息
|
||||
- **G4** 移除 `application.yml` 中微信 `app-id` 和 `app-secret` 的硬编码默认值,强制从环境变量读取
|
||||
- **G5** RestTemplate 统一配置:超时、User-Agent、Accept 头、StringHttpMessageConverter、LoggingInterceptor、ErrorHandler
|
||||
- **G6** `WechatApiException` 在 `GlobalExceptionHandler` 中有专门处理,返回脱敏的友好提示给前端
|
||||
- **G7** 不破坏其他 7 个 RestTemplate 使用点(用 `BufferingClientHttpRequestFactory` 让 body 可被多次读取)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改动现有 7 个 RestTemplate 使用点的业务代码(仅共享统一 bean)
|
||||
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory,外面包一层 BufferingClientHttpRequestFactory)
|
||||
- 不重试 / 不熔断(业务层不需要)
|
||||
- 不做 phone number binding / merge logic(已有 plan 覆盖,且当前代码尚未实施)
|
||||
- 不改 mini-program 前端(纯后端 bug)
|
||||
|
||||
## 方案选择
|
||||
|
||||
经过 brainstorming 对话(澄清问题 → SSH+curl 探针 → 查 logs\server\emotion-single.log),用户选择 **方案 A:防御性增强 + 错误捕获(整合根因)**。
|
||||
|
||||
**未选方案**:
|
||||
- 方案 B(仅加 message converter):不解决根因(WAF 仍会拦截)
|
||||
- 方案 C(先用探针定位再设计):已通过日志 + SSH 探针定位根因,无需再加探针
|
||||
|
||||
## 设计
|
||||
|
||||
### 修改清单
|
||||
|
||||
| # | 文件 | 操作 | 行数估算 | 目的 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `backend-single/src/main/java/com/emotion/config/RestTemplateConfig.java` | **重写** | 40 行 | 改用 `RestTemplateBuilder` + `BufferingClientHttpRequestFactory`:设置 User-Agent、超时、加 StringHttpMessageConverter、加 LoggingInterceptor、加 ErrorHandler |
|
||||
| 2 | `backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java` | **重写** | 90 行 | `code2Session` 改用 `ResponseEntity<String>` + Jackson 二次解析;捕获所有失败场景的 raw body |
|
||||
| 3 | `backend-single/src/main/resources/application.yml` | **修改** | 2 行 | 删除 `app-id` 和 `app-secret` 硬编码默认值(`${ENV_VAR:}` 空兜底) |
|
||||
| 4 | `backend-single/src/main/java/com/emotion/exception/WechatApiException.java` | **新增** | 30 行 | 业务异常类(code 用 HTTP 风格状态码 + rawBody) |
|
||||
| 5 | `backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java` | **新增** | 60 行 | ClientHttpRequestInterceptor:记录 method/URL/status(INFO)+ 脱敏 body(DEBUG) |
|
||||
| 6 | `backend-single/src/main/java/com/emotion/config/WechatResponseErrorHandler.java` | **新增** | 30 行 | 4xx/5xx 不抛 RestClientException,重写 `hasError()` 显式声明 |
|
||||
| 7 | `backend-single/src/main/java/com/emotion/exception/GlobalExceptionHandler.java` | **修改** | 25 行 | 新增 `@ExceptionHandler(WechatApiException.class)` 返回脱敏的友好提示 |
|
||||
| 8 | `backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java` | **不动** | 0 行 | **保持纯 DTO**(rawBody 不放在 DTO,只在异常对象和日志中) |
|
||||
|
||||
**总计**:2 重写 + 3 新增 + 3 修改,约 280 行 Java + 2 行 YAML。
|
||||
|
||||
### 详细设计
|
||||
|
||||
#### 1. RestTemplateConfig 重写(解决 P0-1 / P0-2 流消费问题)
|
||||
|
||||
**关键决策**:用 `BufferingClientHttpRequestFactory` 包装 `SimpleClientHttpRequestFactory`,让响应体被缓存到字节数组,可以被多次读取(Interceptor + ErrorHandler + Converter 都能读)。
|
||||
|
||||
```java
|
||||
package com.emotion.config;
|
||||
|
||||
import com.emotion.interceptor.WechatApiLoggingInterceptor;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
/**
|
||||
* 统一 RestTemplate Bean
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 用 BufferingClientHttpRequestFactory 包装 SimpleClientHttpRequestFactory,
|
||||
* 响应体被缓存到字节数组,可被 Interceptor / ErrorHandler / Converter 多次读取
|
||||
* (避免破坏其他 7 个 RestTemplate 使用点:DifyProviderAdapter、
|
||||
* CozeProviderAdapter、HttpTtsEngineClient、AsrServiceImpl、
|
||||
* ApiEndpointServiceImpl、AiChatServiceImpl、ApiTestProxyController)
|
||||
* 2. 显式设置 User-Agent 为 Mozilla/5.0,绕过 WAF / CloudFlare 对 Java/* 默认 UA 的拦截
|
||||
* 3. 显式设置连接 / 读取超时,避免线程被 hang 住
|
||||
* 4. 加 StringHttpMessageConverter(UTF_8) 支持 text/plain 响应
|
||||
* 5. 加 WechatApiLoggingInterceptor 记录请求 / 响应(脱敏)
|
||||
* 6. 加 WechatResponseErrorHandler 4xx/5xx 不抛异常
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
|
||||
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
|
||||
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
|
||||
BufferingClientHttpRequestFactory bufferingFactory =
|
||||
new BufferingClientHttpRequestFactory(simpleFactory);
|
||||
|
||||
return builder
|
||||
.requestFactory(() -> bufferingFactory)
|
||||
.defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)")
|
||||
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
|
||||
.additionalInterceptors(new WechatApiLoggingInterceptor())
|
||||
.errorHandler(new WechatResponseErrorHandler())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. WechatApiLoggingInterceptor 新增(解决 P0-3 敏感信息泄露)
|
||||
|
||||
```java
|
||||
package com.emotion.interceptor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 微信 API HTTP 请求 / 响应日志拦截器
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 配合 BufferingClientHttpRequestFactory 使用,body 可被多次读取
|
||||
* 2. URL / method / status / content-type 用 INFO 级别(生产可见)
|
||||
* 3. body 用 DEBUG 级别(生产默认不输出,避免日志膨胀)
|
||||
* 4. body 中敏感字段(session_key / openid / unionid / access_token / refresh_token)脱敏
|
||||
*/
|
||||
@Slf4j
|
||||
public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
private static final int MAX_BODY_LOG = 2048;
|
||||
private static final Pattern SENSITIVE_FIELDS = Pattern.compile(
|
||||
"\"(session_key|openid|unionid|access_token|refresh_token|secret|appsecret)\"\\s*:\\s*\"[^\"]*\"");
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(
|
||||
HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
log.info("[WeChatAPI] ---> {} {}", request.getMethod(), request.getURI());
|
||||
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
|
||||
log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}",
|
||||
response.getStatusCode(), request.getURI(), duration,
|
||||
response.getHeaders().getContentType());
|
||||
|
||||
// body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读)
|
||||
try {
|
||||
String bodyStr = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (bodyStr.length() > MAX_BODY_LOG) {
|
||||
bodyStr = bodyStr.substring(0, MAX_BODY_LOG) + "...(truncated)";
|
||||
}
|
||||
log.debug("[WeChatAPI] body={}", desensitize(bodyStr));
|
||||
} catch (IOException e) {
|
||||
log.warn("[WeChatAPI] failed to read response body for logging", e);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏:将敏感字段值替换为 "***"
|
||||
*/
|
||||
private String desensitize(String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return body;
|
||||
}
|
||||
return SENSITIVE_FIELDS.matcher(body).replaceAll("\"$1\":\"***\"");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. WechatResponseErrorHandler 新增(解决 P0-2 显式 hasError)
|
||||
|
||||
```java
|
||||
package com.emotion.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 微信 API 错误处理器
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 显式重写 hasError() 委托父类判断(4xx/5xx → true),让 handleError() 在错误时被调用
|
||||
* 2. handleError() 不抛 RestClientException,由业务层(WechatMiniProgramServiceImpl)
|
||||
* 根据 status code 判断如何处理
|
||||
* 3. 不在此处读 body(避免流消费,由 LoggingInterceptor 统一处理日志)
|
||||
*/
|
||||
@Slf4j
|
||||
public class WechatResponseErrorHandler extends DefaultResponseErrorHandler {
|
||||
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
// 委托父类 DefaultResponseErrorHandler.hasError():4xx/5xx 返回 true,其他返回 false
|
||||
return super.hasError(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(ClientHttpResponse response) throws IOException {
|
||||
// 不抛异常;body 已在 LoggingInterceptor 中读取并脱敏记录
|
||||
// 业务层会通过 response.getStatusCode() 判断
|
||||
log.warn("[WeChatAPI] HTTP error response, status={}", response.getStatusCode());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. WechatApiException 新增(解决 P1-3 错误码风格)
|
||||
|
||||
```java
|
||||
package com.emotion.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 微信 API 业务异常
|
||||
*
|
||||
* code 字段使用 HTTP 风格状态码:
|
||||
* - 400: 微信 API 业务错误(errcode != 0)或 4xx 响应
|
||||
* - 500: 内部错误(JSON 解析失败)
|
||||
* - 502: 上游服务错误(5xx 响应 / network / non-JSON content-type)
|
||||
*/
|
||||
@Getter
|
||||
public class WechatApiException extends RuntimeException {
|
||||
|
||||
private final Integer code; // HTTP 风格状态码
|
||||
private final String rawBody; // 原始响应体(仅服务端排查用,绝不返回给前端)
|
||||
|
||||
public WechatApiException(Integer code, String message, String rawBody) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.rawBody = rawBody;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. WechatMiniProgramServiceImpl.code2Session 重写
|
||||
|
||||
```java
|
||||
@Override
|
||||
public WechatCodeSessionResponse code2Session(String code) {
|
||||
validateConfigured();
|
||||
String url = buildCode2SessionUrl(code);
|
||||
|
||||
ResponseEntity<String> response;
|
||||
try {
|
||||
// 第一步:拿原始 String 响应(不直接反序列化为 DTO)
|
||||
response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
|
||||
} catch (RestClientException e) {
|
||||
// 网络 / DNS / SSL 错误 → 502
|
||||
log.error("[WeChatAPI] code2Session 网络错误", e);
|
||||
throw new WechatApiException(502, "WeChat network error: " + e.getMessage(), null);
|
||||
}
|
||||
|
||||
String rawBody = response.getBody();
|
||||
HttpStatusCode status = response.getStatusCode();
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
|
||||
// 第二步:HTTP 状态码检查
|
||||
if (status == null || !status.is2xxSuccessful()) {
|
||||
log.error("[WeChatAPI] code2Session HTTP 错误: status={}, contentType={}, body={}",
|
||||
status, contentType, rawBody);
|
||||
int code = (status != null && status.is4xxClientError()) ? 400 : 502;
|
||||
throw new WechatApiException(code, "WeChat HTTP " + status, rawBody);
|
||||
}
|
||||
|
||||
// 第三步:content-type 防御
|
||||
// 注意:Spring MediaType 没有 includesCompatibleWith 方法(编译错误),用 isCompatibleWith 替代
|
||||
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
log.error("[WeChatAPI] code2Session 非 JSON 响应: contentType={}, body={}", contentType, rawBody);
|
||||
throw new WechatApiException(502,
|
||||
"WeChat returned " + contentType + " (likely WAF interception)", rawBody);
|
||||
}
|
||||
|
||||
// 第四步:JSON 解析
|
||||
WechatCodeSessionResponse dto;
|
||||
try {
|
||||
dto = objectMapper.readValue(rawBody, WechatCodeSessionResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("[WeChatAPI] code2Session JSON 解析失败. body={}", rawBody, e);
|
||||
throw new WechatApiException(500, "Invalid JSON from WeChat", rawBody);
|
||||
}
|
||||
|
||||
// 第五步:业务错误码检查
|
||||
if (!dto.isSuccess()) {
|
||||
log.warn("[WeChatAPI] code2Session 业务错误: errcode={}, errmsg={}, body={}",
|
||||
dto.getErrcode(), dto.getErrmsg(), rawBody);
|
||||
throw new WechatApiException(400,
|
||||
dto.getErrmsg() != null ? dto.getErrmsg() : "WeChat business error",
|
||||
rawBody);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. GlobalExceptionHandler 新增 WechatApiException handler(解决 P0-4)
|
||||
|
||||
在 `GlobalExceptionHandler.java` 中新增:
|
||||
|
||||
```java
|
||||
// 复用 Interceptor 的脱敏正则(避免重复定义)
|
||||
private static final java.util.regex.Pattern SENSITIVE_FIELDS_FOR_LOG = java.util.regex.Pattern.compile(
|
||||
"\"(session_key|openid|unionid|access_token|refresh_token|secret|appsecret|code)\"\\s*:\\s*\"[^\"]*\"");
|
||||
|
||||
private String desensitizeForLog(String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return body;
|
||||
}
|
||||
return SENSITIVE_FIELDS_FOR_LOG.matcher(body).replaceAll("\"$1\":\"***\"");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信 API 异常(脱敏后返回友好提示)
|
||||
*
|
||||
* 注意:不使用 @ResponseStatus,保持与现有 BusinessException handler 一致
|
||||
* (HTTP 层返回 200,业务错误通过 Result.code 表达)
|
||||
*/
|
||||
@ExceptionHandler(WechatApiException.class)
|
||||
public Result<Void> handleWechatApiException(WechatApiException e, HttpServletRequest request) {
|
||||
// 服务端日志:rawBody 需脱敏(Interceptor 已对响应 body 脱敏,
|
||||
// 但 rawBody 字段是再次传递到异常对象,必须再次脱敏以防日志明文泄露)
|
||||
log.error("[WeChatAPI] 异常: {} {} - code={}, msg={}, rawBody={}",
|
||||
request.getMethod(), request.getRequestURI(),
|
||||
e.getCode(), e.getMessage(), desensitizeForLog(e.getRawBody()));
|
||||
|
||||
// 前端响应:脱敏友好提示(不暴露 rawBody / errcode / 内部技术细节)
|
||||
String userMessage;
|
||||
if (e.getCode() != null && e.getCode() == 400) {
|
||||
userMessage = "微信授权失败,请重试";
|
||||
} else if (e.getCode() != null && e.getCode() == 502) {
|
||||
userMessage = "微信服务暂不可用,请稍后重试";
|
||||
} else {
|
||||
userMessage = "微信登录失败,请重试";
|
||||
}
|
||||
return Result.error(userMessage);
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. application.yml 修改(解决 P1-5 一致性)
|
||||
|
||||
```yaml
|
||||
wechat:
|
||||
mini-program:
|
||||
# 防御性:删除硬编码默认值,强制从环境变量读取
|
||||
# 生产必须设置 WECHAT_MINI_PROGRAM_APP_ID 和 WECHAT_MINI_PROGRAM_APP_SECRET
|
||||
app-id: ${WECHAT_MINI_PROGRAM_APP_ID:}
|
||||
app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:}
|
||||
base-url: https://api.weixin.qq.com
|
||||
```
|
||||
|
||||
#### 8. WechatCodeSessionResponse 保持不动(解决 P2-6)
|
||||
|
||||
**决策**:rawBody 不放在 DTO,只在 `WechatApiException` 对象和服务端日志中。理由:
|
||||
- DTO 是纯数据传输对象,承载 rawBody 会污染数据模型
|
||||
- DTO 已经通过 `@JsonProperty` 等注解映射字段,加 `@JsonIgnore` 字段是反模式
|
||||
- rawBody 已经在异常对象中保留了,足够排查
|
||||
|
||||
### 包结构(解决 P1-2)
|
||||
|
||||
| 类 | 包 |
|
||||
|---|---|
|
||||
| `RestTemplateConfig` | `com.emotion.config`(保持) |
|
||||
| `WechatResponseErrorHandler` | `com.emotion.config`(与 RestTemplateConfig 同包) |
|
||||
| `WechatApiLoggingInterceptor` | `com.emotion.interceptor`(与 JwtAuthInterceptor 同包) |
|
||||
| `WechatApiException` | `com.emotion.exception`(与 BusinessException 同包) |
|
||||
| `WechatMiniProgramServiceImpl` | `com.emotion.service.impl`(保持) |
|
||||
| `WechatCodeSessionResponse` | `com.emotion.dto.wechat`(保持) |
|
||||
|
||||
不新增 `com.emotion.http` 包(与项目现有结构不一致)。
|
||||
|
||||
### 错误处理层次(解决 P0-4)
|
||||
|
||||
```
|
||||
WechatMiniProgramServiceImpl.code2Session()
|
||||
├─ RestClientException (网络/SSL/DNS) → WechatApiException(502)
|
||||
├─ HTTP 非 2xx
|
||||
│ ├─ 4xx → WechatApiException(400, rawBody)
|
||||
│ └─ 5xx → WechatApiException(502, rawBody)
|
||||
├─ Content-Type 非 application/json → WechatApiException(502, rawBody)
|
||||
├─ JSON 解析失败 → WechatApiException(500, rawBody)
|
||||
└─ errcode != 0 → WechatApiException(400, rawBody)
|
||||
|
||||
GlobalExceptionHandler.handleWechatApiException
|
||||
├─ 服务端日志: 完整 rawBody(脱敏)+ code + message
|
||||
└─ 前端响应: 脱敏友好提示("微信服务暂不可用..."等)
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
### 单元测试(WechatMiniProgramServiceImplTest)
|
||||
|
||||
| 场景 | 模拟返回 | 期望 |
|
||||
|---|---|---|
|
||||
| 1. 成功 | HTTP 200 + `application/json` + `{"openid":"oX","session_key":"sK"}` | 返回 DTO |
|
||||
| 2. 微信业务错误(40013) | HTTP 200 + JSON `{"errcode":40013,"errmsg":"invalid appid"}` | 抛 WechatApiException(400, rawBody) |
|
||||
| 3. text/plain(WAF) | HTTP 200 + `text/plain` + `"blocked by waf"` | 抛 WechatApiException(502, rawBody) |
|
||||
| 4. 4xx | HTTP 401 + body | 抛 WechatApiException(400, rawBody) |
|
||||
| 5. 5xx | HTTP 500 + body | 抛 WechatApiException(502, rawBody) |
|
||||
| 6. 非法 JSON | HTTP 200 + `application/json` + `not json` | 抛 WechatApiException(500, rawBody) |
|
||||
| 7. 网络异常 | RestClientException (IOException) | 抛 WechatApiException(502) |
|
||||
| 8. 未配置 | appId 为空 | 抛 BusinessException("微信小程序登录未配置") |
|
||||
| 9. **LoggingInterceptor 脱敏** | 响应含 `session_key` | 日志中 `session_key` 字段值变为 `***` |
|
||||
| 10. **其他 7 个使用点不受影响** | 模拟 DifyProviderAdapter 等调用 | 正常返回(验证 BufferingClientHttpRequestFactory 没破坏行为) |
|
||||
|
||||
### 集成验证
|
||||
|
||||
| 步骤 | 命令 / 操作 | 期望 |
|
||||
|---|---|---|
|
||||
| 1. mvn 编译 | `mvn clean install -pl :backend-single -am -DskipTests` | 退出码 0 |
|
||||
| 2. 部署 | `python deploy.py backend` | 部署成功,重启 backend |
|
||||
| 3. **WAF 验证**(修后 P1-7) | `tail -f /data/logs/emotion-museum/emotion-single.log \| grep "WeChatAPI"` | 看到 `[WeChatAPI] ---> GET https://api.weixin.qq.com/sns/jscode2session ...` 请求日志;`<--- 200` 响应日志 |
|
||||
| 4. **UA 验证** | `grep "User-Agent" /data/logs/emotion-museum/emotion-single.log` | 看到 `Mozilla/5.0`(不再是 `Java/17.0.x`) |
|
||||
| 5. 端到端(H5 模式) | 启动 mini-program H5 (端口 5173) → 微信授权登录 | 登录成功,跳转 onboarding 或主页 |
|
||||
| 6. 凭据验证 | 部署后从服务器内部 `curl` 微信 API | 返回正常 JSON errcode 或 openid |
|
||||
| 7. 错误响应验证 | 临时配置错误 appSecret 触发微信 40163 | 前端收到 "微信授权失败,请重试";后端日志有完整 rawBody |
|
||||
|
||||
### 回滚方案(解决 P2-5)
|
||||
|
||||
| 步骤 | 操作 |
|
||||
|---|---|
|
||||
| 1. 保留旧版本 | 部署脚本 `python deploy.py backend` 自动备份当前 JAR 到 `/opt/emotion-museum/backup/emotion-single-{timestamp}.jar` |
|
||||
| 2. 健康检查 | 部署后 30s 内 `curl https://lifescript.happylifeos.com/api/health` 返回 200 |
|
||||
| 3. 失败回滚 | 如果健康检查失败,执行 `python deploy.py backend --rollback`(脚本会读取最近一次备份 JAR 替换并重启) |
|
||||
| 4. 数据库兼容 | 本次修改不涉及 schema,无需数据迁移 |
|
||||
|
||||
### 防御性测试
|
||||
|
||||
- 在测试环境人为让微信 API 返回 `text/plain`,确认不会 500(而是 WechatApiException 被 GlobalExceptionHandler 捕获,返回 500 + 友好提示)
|
||||
- 单元测试覆盖 10 个场景(见上表)
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|---|---|---|
|
||||
| 改 `RestTemplateConfig` 影响其他 7 处使用点 | 中 | 用 `BufferingClientHttpRequestFactory` 包装确保 body 可多次读;`additionalInterceptors` / `additionalMessageConverters` 是追加(不覆盖);3 天观察期;单元测试场景 10 覆盖 |
|
||||
| 默认 `app-id` / `app-secret` 删除后本地开发挂 | 低 | local 启动报错时本地 dev 配 env var;CI 配 secret;IDEA Run Configuration 加 env var |
|
||||
| 生产 `WECHAT_MINI_PROGRAM_*` 未设导致启动失败 | 低 | 启动日志 warn 但不阻断(保持现状);调用时才抛 BusinessException |
|
||||
| LoggingInterceptor 性能开销 | 低 | body 仅 DEBUG 级别(生产默认不输出);`MAX_BODY_LOG=2048` 截断 |
|
||||
| 错误日志泄露敏感信息 | 中 | Interceptor 中脱敏(session_key/openid/unionid/access_token/refresh_token → `***`);rawBody 不返回前端 |
|
||||
| 微信 API 真实业务错误码(如 40013 invalid appid)被脱敏后用户无法知道原因 | 低 | 后端日志记录完整 errcode + errmsg;运维可查;前端用户只需重试 |
|
||||
|
||||
### 影响的 7 个其他 RestTemplate 使用点(解决 P1-8)
|
||||
|
||||
| 类 | 调用方式 | 是否会受影响 |
|
||||
|---|---|---|
|
||||
| `DifyProviderAdapter` | `restTemplate.execute(url, POST, ..., body)` 流式 POST | ❌ BufferingClientHttpRequestFactory 兼容 |
|
||||
| `CozeProviderAdapter` | 同上 | ❌ 兼容 |
|
||||
| `HttpTtsEngineClient` | `restTemplate.postForEntity(url, body, Map.class)` | ❌ 兼容 |
|
||||
| `AsrServiceImpl` | `restTemplate.postForEntity(url, body, Map.class)` | ❌ 兼容 |
|
||||
| `ApiEndpointServiceImpl` | `restTemplate.getForObject(url, String.class)` | ❌ 兼容 |
|
||||
| `AiChatServiceImpl` | `restTemplate.exchange(...)` ResponseEntity + 部分 `HttpClient` | ❌ 兼容(RestTemplate 部分) |
|
||||
| `ApiTestProxyController` | `restTemplate.exchange(...)` + String.class | ❌ 兼容 |
|
||||
|
||||
**结论**:因为 `BufferingClientHttpRequestFactory` 把 body 缓存为字节数组,所有 RestTemplate 消费者(包括反序列化器)都能正常读 body。其他 7 个使用点的行为完全不变(仅多一份脱敏后的 body DEBUG 日志)。
|
||||
|
||||
## 范围外(Out of Scope)
|
||||
|
||||
- 不做 RestTemplate 连接池(沿用 SimpleClientHttpRequestFactory)
|
||||
- 不重试 / 不熔断(业务层不需要)
|
||||
- 不做 phone number binding / merge logic(已有 2026-05-31 plan 覆盖,且当前代码尚未实施)
|
||||
- 不改 mini-program 前端(纯后端 bug)
|
||||
- 不改其他 7 个 RestTemplate 使用点的业务代码
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 已有设计:`docs/superpowers/specs/2026-05-31-mini-program-wechat-auth-design.md`
|
||||
- 已有 plan:`docs/superpowers/plans/2026-05-31-mini-program-wechat-auth.md`
|
||||
- 微信 API:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
|
||||
- Spring RestTemplateBuilder:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/boot/web/client/RestTemplateBuilder.html
|
||||
- BufferingClientHttpRequestFactory:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/client/BufferingClientHttpRequestFactory.html
|
||||
- WAF / CloudFlare 对 Java UA 的拦截(业界已知问题,常见解决方案:显式设置 User-Agent)
|
||||
- 项目安全规则:`G:\IdeaProjects\emotion-museun\.cursor\rules\rules.mdc` 第 37 条「敏感信息不得在日志中输出」
|
||||
@@ -0,0 +1,192 @@
|
||||
# 小程序人生轨迹详情页操作区调整设计
|
||||
|
||||
日期:2026-06-09
|
||||
状态:待用户审核
|
||||
|
||||
## 背景
|
||||
|
||||
人生轨迹详情页当前由顶部事件信息卡、事件描述卡、AI 分析卡、标签卡和底部操作栏组成。截图中的详情页左上角存在编辑入口,右上角存在更多功能入口,底部操作栏同时承载编辑、收藏、聊天和分享。新的需求是减少顶部干扰,把事件标题和正文内容整合成一个更完整的阅读模块,并让正文使用现有 Markdown 组件渲染。
|
||||
|
||||
目标页面位于 `mini-program/src/pages/life-event/detail.vue`。现有 Markdown 组件位于 `mini-program/src/components/Markdown.vue`,已支持标题、分隔线、引用、列表、段落和粗体文本。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 安全移除详情页左上角编辑 icon 和右上角功能 icon。
|
||||
2. 将事件标题区域和事件内容区域合并成一张统一内容卡。
|
||||
3. 标题区域背景加深,与正文区域形成清晰但克制的层次区分。
|
||||
4. 事件正文必须通过现有 `Markdown` 组件渲染。
|
||||
5. 调整操作按钮位置:
|
||||
- 收藏按钮在内容区域左上角。
|
||||
- 分享按钮与收藏按钮同一水平线。
|
||||
- 分析按钮弱化为浅色胶囊,凹到内容区域右上角。
|
||||
- 编辑按钮迁移到内容区域左下角。
|
||||
- 编辑按钮与收起按钮同一水平线。
|
||||
- 分析按钮与收起按钮同一垂直线。
|
||||
- 聊天按钮文案改为“聊聊”,居中显示。
|
||||
6. 保持当前小程序的宇宙深色、紫色玻璃、发光按钮和卡片边框风格。
|
||||
7. 保持现有功能完整,不改变接口和数据模型。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不重写 `Markdown.vue`。
|
||||
2. 不调整人生轨迹列表页、记录页或 AI 分析数据结构。
|
||||
3. 不新增后端 API。
|
||||
4. 不引入新的 UI 库或图标库。
|
||||
5. 不改动当前收藏、分享、编辑、聊天的业务方法语义。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用“单卡合并,局部重排”方案。把当前 `hero-card` 和 `description-card` 合并为新的 `event-content-card`,在同一张卡中形成上下两个区域:
|
||||
|
||||
- `event-hero-section`:展示年份、日期、标签、标题、地点和插画。该区域背景使用更深的蓝紫色叠层和轻微内发光,视觉上像卡片的标题头部。
|
||||
- `event-body-section`:展示事件描述标题、正文 Markdown、收藏/分享/分析/编辑/收起等操作。正文区域背景更轻,保持玻璃感和可读性。
|
||||
|
||||
该方案改动集中,既能满足布局需求,也能最大程度复用现有方法与样式。
|
||||
|
||||
## 页面结构
|
||||
|
||||
调整前:
|
||||
|
||||
```text
|
||||
floating-nav
|
||||
hero-card
|
||||
description-card
|
||||
analysis-panel
|
||||
tags-card
|
||||
bottom-actions
|
||||
```
|
||||
|
||||
调整后:
|
||||
|
||||
```text
|
||||
event-content-card
|
||||
event-hero-section
|
||||
event-body-section
|
||||
event-body-toolbar
|
||||
favorite action
|
||||
analysis action
|
||||
share action
|
||||
section-title-row
|
||||
markdown description
|
||||
event-body-footer
|
||||
edit action
|
||||
collapse action
|
||||
analysis-panel
|
||||
tags-card
|
||||
bottom-chat-action
|
||||
```
|
||||
|
||||
`floating-nav` 整行不再渲染,避免左上角与右上角继续出现悬浮 icon。本次不新增替代顶部功能入口,页面返回依赖小程序或 H5 容器已有的导航能力。
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 顶部图标
|
||||
|
||||
移除当前悬浮导航整行。对应的 `openMore` 和 `goBack` 可以保留为未使用方法,也可以在实施时移除无引用代码;推荐优先移除模板绑定,避免扩大行为改动。
|
||||
|
||||
### 标题与正文合并
|
||||
|
||||
事件标题区继续展示:
|
||||
|
||||
- 年份
|
||||
- 日期或日期范围
|
||||
- 主标签
|
||||
- 事件标题
|
||||
- 城市/地点
|
||||
- 当前插画占位
|
||||
|
||||
正文区展示:
|
||||
|
||||
- “事件描述”标题
|
||||
- 事件内容的 Markdown 渲染结果
|
||||
- 折叠/展开控制
|
||||
|
||||
折叠状态沿用 `isDescriptionCollapsed`。折叠时对 Markdown 容器外层做高度或行数限制,展开时完整展示。由于 Markdown 内部包含多种 block,推荐用外层 `max-height` 加 `overflow: hidden`,而不是直接给内部文本加 `-webkit-line-clamp`。
|
||||
|
||||
### 按钮位置
|
||||
|
||||
正文区域顶部工具行:
|
||||
|
||||
- 左侧:收藏按钮,复用当前 `toggleFavorite`。
|
||||
- 右侧:分享按钮,复用当前 `shareCurrent`。
|
||||
- 右上凹位:分析按钮,使用更浅的紫色半透明胶囊,文案为“分析”。点击后把当前 `scroll-view` 定位到下方 AI 分析区。
|
||||
|
||||
正文区域底部工具行:
|
||||
|
||||
- 左下角:编辑按钮,复用当前 `editEvent`。
|
||||
- 右侧:收起/展开按钮,复用 `isDescriptionCollapsed` 切换。
|
||||
|
||||
底部主聊天按钮:
|
||||
|
||||
- 保留底部固定操作区或压缩为一个居中主按钮。
|
||||
- 文案改为“聊聊”。
|
||||
- 点击继续调用 `chatEvent`。
|
||||
- 视觉延续当前紫色渐变发光按钮。
|
||||
|
||||
## 数据流
|
||||
|
||||
不改变数据来源:
|
||||
|
||||
- `displayEvent` 继续从 store 或缓存读取。
|
||||
- `eventDescription` 继续从 `content` 或 `description` 派生。
|
||||
- `markdownAnalysis` 和 `hasMarkdownAnalysis` 继续服务 AI 分析区。
|
||||
- `isFavorite` 继续使用当前 store 和本地缓存逻辑。
|
||||
|
||||
事件描述渲染方式从:
|
||||
|
||||
```vue
|
||||
<text class="description-text">{{ eventDescription }}</text>
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```vue
|
||||
<view class="description-markdown">
|
||||
<Markdown :content="eventDescription" />
|
||||
</view>
|
||||
```
|
||||
|
||||
折叠样式施加在 `description-markdown` 外层。
|
||||
|
||||
## 样式原则
|
||||
|
||||
1. 使用现有深色宇宙背景,不改变页面整体色板。
|
||||
2. 卡片半径、边框、内发光和文字颜色沿用当前 `glass-card`、`analysis-panel` 的风格。
|
||||
3. 标题区背景加深,但避免变成纯黑块;推荐使用深蓝紫渐变加低透明紫色光晕。
|
||||
4. 正文区保持阅读优先,文字对比度不低于当前 `Markdown.vue` 的默认表现。
|
||||
5. 所有操作按钮保持至少 64rpx 可点击高度。
|
||||
6. 不使用新图片资源;继续使用当前 CSS 绘制的封面占位。
|
||||
|
||||
## 风险与处理
|
||||
|
||||
### Markdown 折叠
|
||||
|
||||
Markdown 是 block 结构,不能简单给单个 text 行数截断。实施时用外层容器限制高度,避免破坏 Markdown 组件。
|
||||
|
||||
### 操作入口减少
|
||||
|
||||
移除更多按钮后,原 action sheet 的功能会分散到内容区按钮。收藏、分享、编辑都有显式入口,功能不丢失。
|
||||
|
||||
### 安全区与底部遮挡
|
||||
|
||||
底部“聊聊”按钮仍需使用 `safeAreaBottom`。`scroll-view` 底部 padding 要同步调整,避免最后一张卡被遮挡。
|
||||
|
||||
### 文件编码
|
||||
|
||||
当前终端读取部分中文文件时出现 mojibake 风险。实施时只写明确中文文案“聊聊”“收藏”“分享”“编辑”“分析”“收起/展开”,并通过浏览器实际渲染验证。
|
||||
|
||||
## 验证计划
|
||||
|
||||
1. 运行 `mini-program` H5 开发服务。
|
||||
2. 用浏览器打开人生轨迹详情页。
|
||||
3. 验证顶部编辑 icon 和更多 icon 已移除。
|
||||
4. 验证标题区与正文区位于同一张卡内,标题区背景更深。
|
||||
5. 验证事件描述通过 Markdown 组件渲染,标题、列表、粗体等格式正常。
|
||||
6. 验证收藏、分享、编辑、分析、收起/展开、聊聊按钮位置符合需求。
|
||||
7. 验证点击收藏、分享、编辑、聊聊不会报错。
|
||||
8. 验证浏览器 Console 无错误。
|
||||
9. 验证窄屏下文本不重叠、不被底部按钮遮挡。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
用户已确认“标题区域和内容区域合并”指顶部事件信息卡与事件描述卡合并。实施时按该解释执行。
|
||||
@@ -2,7 +2,13 @@ import { computed, onUnmounted, ref } from 'vue'
|
||||
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
|
||||
import analytics from '../services/analytics.js'
|
||||
|
||||
const readResponseData = (response) => response?.data ?? response ?? null
|
||||
const readResponseData = (response) => {
|
||||
if (!response) return null
|
||||
if (Object.prototype.hasOwnProperty.call(response, 'data')) {
|
||||
return response.data ?? null
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => {
|
||||
const task = ref(null)
|
||||
@@ -11,6 +17,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
||||
const statusText = ref('')
|
||||
let audio = null
|
||||
let timer = null
|
||||
let prewarmPromise = null
|
||||
let prewarmSourceId = ''
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (loading.value) return '正在生成朗读'
|
||||
@@ -155,6 +163,49 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
||||
}
|
||||
}
|
||||
|
||||
const prewarmSource = async (sourceId) => {
|
||||
if (!sourceId) return null
|
||||
if (task.value?.status === 'success' && task.value?.sourceId === sourceId) {
|
||||
return task.value
|
||||
}
|
||||
if (prewarmPromise && prewarmSourceId === sourceId) {
|
||||
return prewarmPromise
|
||||
}
|
||||
|
||||
prewarmSourceId = sourceId
|
||||
prewarmPromise = (async () => {
|
||||
try {
|
||||
const existingResponse = await getTtsTaskBySource({ sourceType, sourceId })
|
||||
const existingTask = readResponseData(existingResponse)
|
||||
if (existingTask?.id) {
|
||||
setTask(existingTask)
|
||||
}
|
||||
if (existingTask?.status === 'success' || existingTask?.status === 'pending' || existingTask?.status === 'processing') {
|
||||
return existingTask
|
||||
}
|
||||
} catch (error) {
|
||||
// Old scripts may not have a task yet; create one silently below.
|
||||
}
|
||||
|
||||
try {
|
||||
const createResponse = await createTtsTask({ sourceType, sourceId })
|
||||
const nextTask = readResponseData(createResponse)
|
||||
if (nextTask?.id) {
|
||||
setTask(nextTask)
|
||||
}
|
||||
return nextTask
|
||||
} catch (error) {
|
||||
track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'prewarm failed' })
|
||||
return null
|
||||
} finally {
|
||||
prewarmPromise = null
|
||||
prewarmSourceId = ''
|
||||
}
|
||||
})()
|
||||
|
||||
return prewarmPromise
|
||||
}
|
||||
|
||||
const playSource = async (sourceId) => {
|
||||
if (!sourceId) {
|
||||
uni.showToast({ title: '生成保存后可播放', icon: 'none' })
|
||||
@@ -162,6 +213,21 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
if (prewarmPromise && prewarmSourceId === sourceId) {
|
||||
const warmedTask = await prewarmPromise
|
||||
if (warmedTask?.status === 'success') {
|
||||
setTask(warmedTask)
|
||||
play(sourceId)
|
||||
return
|
||||
}
|
||||
if ((warmedTask?.status === 'pending' || warmedTask?.status === 'processing') && warmedTask?.id) {
|
||||
setTask(warmedTask)
|
||||
loading.value = true
|
||||
pollTask(warmedTask.id, sourceId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (task.value?.status === 'success') {
|
||||
play(sourceId)
|
||||
return
|
||||
@@ -195,6 +261,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
||||
setTask(null)
|
||||
statusText.value = ''
|
||||
loading.value = false
|
||||
prewarmPromise = null
|
||||
prewarmSourceId = ''
|
||||
}
|
||||
|
||||
onUnmounted(reset)
|
||||
@@ -205,6 +273,7 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
||||
playing,
|
||||
statusText,
|
||||
buttonText,
|
||||
prewarmSource,
|
||||
playSource,
|
||||
reset
|
||||
}
|
||||
|
||||
@@ -2,61 +2,79 @@
|
||||
<view class="detail-page">
|
||||
<view class="space-bg"></view>
|
||||
|
||||
<view class="floating-nav" :style="floatingTopStyle">
|
||||
<view class="circle-btn" @click="goBack">
|
||||
<view class="back-icon"></view>
|
||||
</view>
|
||||
<view class="circle-btn" @click="openMore">
|
||||
<view class="more-dot"></view>
|
||||
<view class="more-dot"></view>
|
||||
<view class="more-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="content"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll="onContentScroll"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view class="top-safe" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||
<view class="event-content-card glass-card">
|
||||
<view class="event-hero-section">
|
||||
<view class="hero-timeline">
|
||||
<view class="timeline-glow"></view>
|
||||
<view class="timeline-node"></view>
|
||||
<view class="timeline-line"></view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||||
<view class="hero-card glass-card">
|
||||
<view class="hero-timeline">
|
||||
<view class="timeline-glow"></view>
|
||||
<view class="timeline-node"></view>
|
||||
<view class="timeline-line"></view>
|
||||
<view class="hero-copy">
|
||||
<text class="year">{{ eventYear }}</text>
|
||||
<view class="date-row">
|
||||
<text class="date-range">{{ dateRange }}</text>
|
||||
<text class="type-chip">{{ primaryTag }}</text>
|
||||
</view>
|
||||
<text class="event-title">{{ displayEvent.title || '决定全职做自己热爱的AI产品' }}</text>
|
||||
<view class="location-row">
|
||||
<view class="pin-icon"></view>
|
||||
<text>{{ locationText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="memory-cover">
|
||||
<view class="cover-sky"></view>
|
||||
<view class="cover-window"></view>
|
||||
<view class="cover-desk"></view>
|
||||
<view class="cover-screen main"></view>
|
||||
<view class="cover-screen small left"></view>
|
||||
<view class="cover-screen small right"></view>
|
||||
</view>
|
||||
<view class="hero-star">★</view>
|
||||
</view>
|
||||
|
||||
<view class="hero-copy">
|
||||
<text class="year">{{ eventYear }}</text>
|
||||
<view class="date-row">
|
||||
<text class="date-range">{{ dateRange }}</text>
|
||||
<text class="type-chip">{{ primaryTag }}</text>
|
||||
<view class="event-body-section">
|
||||
<view class="event-body-toolbar">
|
||||
<view class="action-btn favorite-btn" @click="toggleFavorite">
|
||||
<text class="action-icon">{{ isFavorite ? '★' : '☆' }}</text>
|
||||
<text class="action-label">{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="action-btn share-btn" @click="shareCurrent">
|
||||
<text class="action-icon">↗</text>
|
||||
<text class="action-label">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="event-title">{{ displayEvent.title || '决定全职做自己热爱的AI产品' }}</text>
|
||||
<view class="location-row">
|
||||
<view class="pin-icon"></view>
|
||||
<text>{{ locationText }}</text>
|
||||
|
||||
<view class="section-title-row description-title-row">
|
||||
<view class="orbit-icon"></view>
|
||||
<text>事件描述</text>
|
||||
</view>
|
||||
|
||||
<view class="description-markdown" :class="{ collapsed: isDescriptionCollapsed }">
|
||||
<Markdown :content="eventDescription" />
|
||||
</view>
|
||||
|
||||
<view class="event-body-footer">
|
||||
<text class="edit-link" @click="editEvent">✎ 编辑</text>
|
||||
<text class="collapse-link" @click="isDescriptionCollapsed = !isDescriptionCollapsed">
|
||||
{{ isDescriptionCollapsed ? '展开 ▼' : '收起 ▲' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="memory-cover">
|
||||
<view class="cover-sky"></view>
|
||||
<view class="cover-window"></view>
|
||||
<view class="cover-desk"></view>
|
||||
<view class="cover-screen main"></view>
|
||||
<view class="cover-screen small left"></view>
|
||||
<view class="cover-screen small right"></view>
|
||||
</view>
|
||||
<view class="hero-star">★</view>
|
||||
</view>
|
||||
|
||||
<view class="description-card glass-card">
|
||||
<view class="section-title-row">
|
||||
<view class="orbit-icon"></view>
|
||||
<text>事件描述</text>
|
||||
</view>
|
||||
<text class="description-text" :class="{ collapsed: isDescriptionCollapsed }">{{ eventDescription }}</text>
|
||||
<text class="collapse-action" @click="isDescriptionCollapsed = !isDescriptionCollapsed">
|
||||
{{ isDescriptionCollapsed ? '展开' : '收起' }} ^
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="analysis-panel">
|
||||
<view class="analysis-panel" id="analysisPanel">
|
||||
<view class="section-title-row analysis-head">
|
||||
<text class="sparkle">✦</text>
|
||||
<text>AI 分析</text>
|
||||
@@ -90,25 +108,9 @@
|
||||
</scroll-view>
|
||||
|
||||
<view class="bottom-actions" :style="{ paddingBottom: bottomInset + 'px' }">
|
||||
<view class="bottom-action" @click="editEvent">
|
||||
<view class="edit-icon"></view>
|
||||
<text>编辑</text>
|
||||
</view>
|
||||
<view class="bottom-action" @click="toggleFavorite">
|
||||
<view class="bookmark-icon" :class="{ active: isFavorite }"></view>
|
||||
<text>{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
<view class="chat-action" @click="chatEvent">
|
||||
<view class="chat-badge">AI</view>
|
||||
<text>和开开聊聊这段经历</text>
|
||||
</view>
|
||||
<view class="bottom-action" @click="shareCurrent">
|
||||
<view class="share-icon">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
<text>分享</text>
|
||||
<text>聊聊</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -118,17 +120,19 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/life-event/detail'
|
||||
const { floatingTopStyle } = useMenuButtonSafeArea()
|
||||
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||
const safeAreaBottom = ref(0)
|
||||
const eventId = ref('')
|
||||
const cachedEvent = ref(null)
|
||||
const isFavorite = ref(false)
|
||||
const isDescriptionCollapsed = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const currentScrollTop = ref(0)
|
||||
|
||||
onMounted(async () => {
|
||||
const info = uni.getWindowInfo()
|
||||
@@ -222,8 +226,18 @@ const locationText = computed(() => {
|
||||
return profile.value.city ? `中国 · ${profile.value.city}` : '中国 · 深圳'
|
||||
})
|
||||
|
||||
// 将【】格式标题转换为 Markdown # 标题格式
|
||||
const convertToMarkdown = (text) => {
|
||||
if (!text) return text
|
||||
// 将 "【数字 标题】" 或 "【标题】" 转换为 "# 标题",保留完整内容
|
||||
return text.replace(/^【\s*(.+?)\s*】$/gm, (match, title) => {
|
||||
return `# ${title.trim()}`
|
||||
})
|
||||
}
|
||||
|
||||
const eventDescription = computed(() => {
|
||||
return displayEvent.value.content || displayEvent.value.description || '经过了长时间的纠结和准备,我终于辞去了稳定的工作,全职投入到自己热爱的AI产品创业中。这是我人生中最大的一次冒险,也是我第一次真正为自己而活。'
|
||||
const raw = displayEvent.value.content || displayEvent.value.description || '经过了长时间的纠结和准备,我终于辞去了稳定的工作,全职投入到自己热爱的AI产品创业中。这是我人生中最大的一次冒险,也是我第一次真正为自己而活。'
|
||||
return convertToMarkdown(raw)
|
||||
})
|
||||
|
||||
const relatedTags = computed(() => {
|
||||
@@ -280,6 +294,10 @@ const analysisBlocks = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const onContentScroll = (event) => {
|
||||
currentScrollTop.value = event.detail?.scrollTop || 0
|
||||
}
|
||||
|
||||
const editEvent = () => {
|
||||
if (!eventId.value) return
|
||||
uni.navigateTo({ url: `/pages/life-event/form?id=${eventId.value}` })
|
||||
@@ -385,59 +403,18 @@ const goBack = () => {
|
||||
background-position: 44rpx 44rpx, 10rpx 112rpx;
|
||||
}
|
||||
|
||||
.floating-nav,
|
||||
.content,
|
||||
.bottom-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.floating-nav {
|
||||
height: 108rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.circle-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5rpx;
|
||||
color: #fff;
|
||||
border: 1rpx solid rgba(118, 82, 225, 0.35);
|
||||
background: rgba(22, 18, 63, 0.72);
|
||||
box-shadow: inset 0 0 20rpx rgba(137, 91, 255, 0.1);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 23rpx;
|
||||
height: 23rpx;
|
||||
border-left: 6rpx solid #fff;
|
||||
border-bottom: 6rpx solid #fff;
|
||||
transform: rotate(45deg);
|
||||
margin-left: 7rpx;
|
||||
}
|
||||
|
||||
.more-dot {
|
||||
width: 7rpx;
|
||||
height: 7rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0 32rpx 134rpx;
|
||||
padding: 12rpx 32rpx 154rpx;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@@ -450,15 +427,24 @@ const goBack = () => {
|
||||
-webkit-backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
.event-content-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 30rpx;
|
||||
}
|
||||
|
||||
.event-hero-section {
|
||||
position: relative;
|
||||
min-height: 264rpx;
|
||||
border-radius: 28rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 70rpx 1fr 148rpx;
|
||||
gap: 20rpx;
|
||||
padding: 38rpx 34rpx 32rpx;
|
||||
padding: 40rpx 34rpx 34rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at 86% 12%, rgba(129, 76, 255, 0.22), transparent 36%),
|
||||
linear-gradient(145deg, rgba(6, 8, 35, 0.98) 0%, rgba(15, 13, 58, 0.96) 58%, rgba(23, 16, 72, 0.92) 100%);
|
||||
box-shadow: inset 0 -1rpx 0 rgba(164, 116, 255, 0.22), inset 0 0 42rpx rgba(63, 45, 148, 0.16);
|
||||
}
|
||||
|
||||
.hero-timeline {
|
||||
@@ -633,7 +619,15 @@ const goBack = () => {
|
||||
text-shadow: 0 0 22rpx rgba(255, 198, 85, 0.9);
|
||||
}
|
||||
|
||||
.description-card,
|
||||
.event-body-section {
|
||||
position: relative;
|
||||
padding: 26rpx 30rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at 92% 0%, rgba(159, 98, 255, 0.12), transparent 36%),
|
||||
rgba(13, 16, 52, 0.62);
|
||||
}
|
||||
|
||||
.tags-card {
|
||||
border-radius: 28rpx;
|
||||
margin-top: 18rpx;
|
||||
@@ -641,6 +635,44 @@ const goBack = () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.event-body-toolbar,
|
||||
.event-body-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.event-body-toolbar {
|
||||
min-height: 48rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
color: rgba(238, 231, 255, 0.72);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
color: rgba(238, 231, 255, 0.82);
|
||||
}
|
||||
|
||||
.favorite-btn .action-icon {
|
||||
color: #ffd36e;
|
||||
}
|
||||
|
||||
.description-title-row {
|
||||
margin-top: 16rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -671,28 +703,30 @@ const goBack = () => {
|
||||
border-color: rgba(176, 112, 255, 0.62);
|
||||
}
|
||||
|
||||
.description-text {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
color: rgba(227, 218, 246, 0.73);
|
||||
font-size: 25rpx;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.description-text.collapsed {
|
||||
display: -webkit-box;
|
||||
.description-markdown {
|
||||
margin-top: 22rpx;
|
||||
border-radius: 22rpx;
|
||||
padding: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.042);
|
||||
border: 1rpx solid rgba(180, 139, 255, 0.18);
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.collapse-action {
|
||||
display: block;
|
||||
.description-markdown.collapsed {
|
||||
max-height: 280rpx;
|
||||
}
|
||||
|
||||
.event-body-footer {
|
||||
margin-top: 14rpx;
|
||||
text-align: right;
|
||||
color: #a970ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.edit-link,
|
||||
.collapse-link {
|
||||
color: rgba(169, 112, 255, 0.78);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.analysis-panel {
|
||||
@@ -830,10 +864,9 @@ const goBack = () => {
|
||||
bottom: 0;
|
||||
min-height: 108rpx;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 0.76fr 0.76fr 2fr 0.76fr;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
justify-content: center;
|
||||
padding: 14rpx 32rpx 0;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
border-top: 1rpx solid rgba(126, 87, 255, 0.32);
|
||||
@@ -843,25 +876,17 @@ const goBack = () => {
|
||||
-webkit-backdrop-filter: blur(28rpx);
|
||||
}
|
||||
|
||||
.bottom-action,
|
||||
.chat-action {
|
||||
width: min(420rpx, 100%);
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
color: rgba(238, 231, 255, 0.9);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-action {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-action {
|
||||
gap: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #b246ff, #742fff 58%, #5126ff);
|
||||
box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.58), inset 0 1rpx 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
@@ -878,101 +903,5 @@ const goBack = () => {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
position: relative;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-left: 4rpx solid currentColor;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
transform: skew(-12deg);
|
||||
}
|
||||
|
||||
.edit-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4rpx;
|
||||
bottom: 7rpx;
|
||||
width: 31rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 999rpx;
|
||||
background: currentColor;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.bookmark-icon {
|
||||
width: 26rpx;
|
||||
height: 34rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-bottom: 0;
|
||||
border-radius: 5rpx 5rpx 0 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bookmark-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3rpx;
|
||||
right: 3rpx;
|
||||
bottom: -8rpx;
|
||||
height: 16rpx;
|
||||
background: #0d0d2b;
|
||||
transform: rotate(45deg);
|
||||
border-right: 4rpx solid currentColor;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
}
|
||||
|
||||
.bookmark-icon.active {
|
||||
color: #ffd36e;
|
||||
}
|
||||
|
||||
.share-icon {
|
||||
position: relative;
|
||||
width: 38rpx;
|
||||
height: 34rpx;
|
||||
}
|
||||
|
||||
.share-icon view {
|
||||
position: absolute;
|
||||
width: 9rpx;
|
||||
height: 9rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.share-icon view:nth-child(1) {
|
||||
left: 0;
|
||||
top: 12rpx;
|
||||
}
|
||||
|
||||
.share-icon view:nth-child(2) {
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.share-icon view:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.share-icon::before,
|
||||
.share-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
width: 22rpx;
|
||||
height: 3rpx;
|
||||
background: currentColor;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.share-icon::before {
|
||||
top: 14rpx;
|
||||
transform: rotate(-28deg);
|
||||
}
|
||||
|
||||
.share-icon::after {
|
||||
bottom: 12rpx;
|
||||
transform: rotate(28deg);
|
||||
}
|
||||
/* 操作按钮使用 Unicode 图标,无需自定义 CSS 图标 */
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
<text class="subtitle">开启你的数字生命档案</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="btn-primary wechat-login-btn"
|
||||
:class="{ disabled: loading }"
|
||||
@click="handleWechatLogin"
|
||||
>
|
||||
<text class="wechat-mark">微</text>
|
||||
<text v-if="loading && activeMethod === 'wechat'">微信登录中...</text>
|
||||
<text v-else>微信一键登录</text>
|
||||
</view>
|
||||
|
||||
<view class="login-divider">
|
||||
<view></view>
|
||||
<text>或使用手机号登录</text>
|
||||
<view></view>
|
||||
</view>
|
||||
|
||||
<view class="form">
|
||||
<view class="input-group">
|
||||
<text class="input-label">手机号码</text>
|
||||
@@ -94,6 +110,7 @@ onMounted(() => {
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
const activeMethod = ref('')
|
||||
const countdown = ref(60)
|
||||
const isCountingDown = ref(false)
|
||||
|
||||
@@ -132,20 +149,60 @@ const startCountdown = () => {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const routeAfterLogin = () => {
|
||||
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||
}
|
||||
|
||||
const getWechatLoginCode = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res.code) {
|
||||
resolve(res.code)
|
||||
} else {
|
||||
reject(new Error('未获取到微信登录凭证'))
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
activeMethod.value = 'wechat'
|
||||
|
||||
try {
|
||||
const loginCode = await getWechatLoginCode()
|
||||
const result = await store.loginWithWechat({ code: loginCode })
|
||||
|
||||
if (result.success) {
|
||||
routeAfterLogin()
|
||||
} else {
|
||||
uni.showToast({ title: result.error || '微信登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || '微信登录失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
activeMethod.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
activeMethod.value = 'phone'
|
||||
|
||||
try {
|
||||
const result = await store.login(phone.value, code.value)
|
||||
|
||||
if (result.success) {
|
||||
if (result.hasProfile) {
|
||||
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||
} else {
|
||||
uni.redirectTo({ url: '/pages/onboarding/index' })
|
||||
}
|
||||
routeAfterLogin()
|
||||
} else {
|
||||
uni.showToast({ title: result.error || '登录失败', icon: 'none' })
|
||||
}
|
||||
@@ -153,6 +210,7 @@ const handleLogin = async () => {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
activeMethod.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -299,6 +357,48 @@ const handleLogin = async () => {
|
||||
margin-bottom: 36rpx;
|
||||
}
|
||||
|
||||
.wechat-login-btn {
|
||||
gap: 16rpx;
|
||||
margin-bottom: 26rpx;
|
||||
background: linear-gradient(135deg, #35c76f 0%, #159552 100%);
|
||||
box-shadow:
|
||||
0 14rpx 36rpx rgba(21, 149, 82, 0.26),
|
||||
inset 0 1rpx 0 rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.wechat-mark {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #159552;
|
||||
font-size: 24rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 900;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.login-divider view {
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
background: rgba(224, 205, 255, 0.16);
|
||||
}
|
||||
|
||||
.login-divider text {
|
||||
color: rgba(225, 209, 255, 0.48);
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
<view class="avatar-stage">
|
||||
<view class="avatar-glow"></view>
|
||||
<view class="avatar-circle">
|
||||
<view class="person-head"></view>
|
||||
<view class="person-body"></view>
|
||||
<image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="verify-badge">✓</view>
|
||||
</view>
|
||||
@@ -24,11 +23,11 @@
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">觉醒深度</text>
|
||||
<text class="stat-value">Lv.4</text>
|
||||
<text class="stat-value">{{ awakenLevel }}</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">星历契合</text>
|
||||
<text class="stat-value">98%</text>
|
||||
<text class="stat-value">{{ harmonyPercent }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -68,13 +67,48 @@ import { useAppStore } from '../../stores/app.js'
|
||||
const store = useAppStore()
|
||||
const profile = computed(() => store.userProfile || store.registrationData || {})
|
||||
|
||||
const displayName = computed(() => profile.value.nickname || '张义明')
|
||||
const avatarUrl = computed(() => {
|
||||
const seed = profile.value.nickname || 'user'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b982ff`
|
||||
})
|
||||
|
||||
const displayName = computed(() => profile.value.nickname || '未设置昵称')
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const mbti = profile.value.mbti || 'ENFJ'
|
||||
const zodiac = profile.value.zodiac || '狮子座'
|
||||
const identity = profile.value.profession || '星民'
|
||||
return `${mbti} · ${zodiac} · ${identity}`
|
||||
const mbti = profile.value.mbti
|
||||
const zodiac = profile.value.zodiac
|
||||
const identity = profile.value.profession
|
||||
if (!mbti && !zodiac && !identity) {
|
||||
return '点击设置个人档案'
|
||||
}
|
||||
return `${mbti || ''} · ${zodiac || ''} · ${identity || ''}`.replace(/(^ · | · $)/g, '').replace(/ · · /g, ' · ')
|
||||
})
|
||||
|
||||
const awakenLevel = computed(() => {
|
||||
const count = store.events?.length || 0
|
||||
if (count >= 10) return 'Lv.5'
|
||||
if (count >= 6) return 'Lv.4'
|
||||
if (count >= 3) return 'Lv.3'
|
||||
if (count >= 1) return 'Lv.2'
|
||||
return 'Lv.1'
|
||||
})
|
||||
|
||||
const harmonyPercent = computed(() => {
|
||||
const hasLifeStory = profile.value.childhood?.text ||
|
||||
profile.value.joy?.text ||
|
||||
profile.value.low?.text ||
|
||||
profile.value.future?.ideal
|
||||
const fields = [
|
||||
profile.value.nickname,
|
||||
profile.value.gender,
|
||||
profile.value.zodiac,
|
||||
profile.value.mbti,
|
||||
profile.value.profession,
|
||||
profile.value.hobbies?.length,
|
||||
hasLifeStory
|
||||
]
|
||||
const filled = fields.filter(Boolean).length
|
||||
return Math.round((filled / 7) * 100) + '%'
|
||||
})
|
||||
|
||||
const openProfileSettings = () => {
|
||||
@@ -112,17 +146,14 @@ const terminateLifeHarmony = () => {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(125, 73, 255, 0.22), transparent 36%),
|
||||
radial-gradient(circle at 10% 74%, rgba(24, 88, 156, 0.2), transparent 32%),
|
||||
linear-gradient(180deg, #120a36 0%, #05061c 46%, #03020d 100%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.profile-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -80rpx;
|
||||
top: 80rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
transform: translateX(-50%);
|
||||
@@ -395,4 +426,10 @@ const terminateLifeHarmony = () => {
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 8rpx;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</view>
|
||||
<view class="profile-info">
|
||||
<view class="name-row">
|
||||
<text class="profile-name">{{ profile.nickname || 'Zoey' }}</text>
|
||||
<text class="profile-name">{{ profile.nickname || '未设置' }}</text>
|
||||
<text class="star">✦</text>
|
||||
</view>
|
||||
<text class="profile-subtitle">正在成为更清晰的自己</text>
|
||||
@@ -28,21 +28,21 @@
|
||||
<text class="meta-icon">♋</text>
|
||||
<view>
|
||||
<text class="meta-label">星座</text>
|
||||
<text class="meta-value">{{ profile.zodiac || '巨蟹座' }}</text>
|
||||
<text class="meta-value">{{ profile.zodiac || '未设置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="person-icon"></view>
|
||||
<view>
|
||||
<text class="meta-label">MBTI</text>
|
||||
<text class="meta-value">{{ profile.mbti || 'ENTJ' }}</text>
|
||||
<text class="meta-value">{{ profile.mbti || '未设置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="meta-item no-border">
|
||||
<view class="job-icon"></view>
|
||||
<view>
|
||||
<text class="meta-label">职业</text>
|
||||
<text class="meta-value">{{ profile.profession || '产品经理' }}</text>
|
||||
<text class="meta-value">{{ profile.profession || '未设置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -53,7 +53,7 @@
|
||||
<text class="heart">♡</text>
|
||||
<view>
|
||||
<text class="meta-label">爱好</text>
|
||||
<text class="hobby-text">{{ heroTags.join(' · ') }}</text>
|
||||
<text class="hobby-text">{{ heroTags.length ? heroTags.join(' · ') : '未设置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -140,54 +140,16 @@ const baseFilters = [
|
||||
]
|
||||
const filters = computed(() => [...baseFilters, ...customFilters.value])
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: 'sample-1',
|
||||
title: '决定全职做自己热爱的AI产品',
|
||||
time: '2025-05-20',
|
||||
content: '辞去稳定的工作,孤注一掷地投入到AI产品的创业中,每天都充满挑战...',
|
||||
eventType: 'highlight',
|
||||
tags: ['高光']
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
title: '一段关系的结束',
|
||||
time: '2024-11-12',
|
||||
content: '这段关系让我成长了很多,虽然很痛,但也让我更清楚自己想要什么...',
|
||||
eventType: 'valley',
|
||||
tags: ['低谷']
|
||||
},
|
||||
{
|
||||
id: 'sample-3',
|
||||
title: '第一次独自旅行',
|
||||
time: '2024-06-08',
|
||||
content: '去了大理,遇见了很多有趣的人和事。那段时间真正感受到了自由和快乐...',
|
||||
eventType: 'daily_log',
|
||||
tags: ['美好的瞬间']
|
||||
},
|
||||
{
|
||||
id: 'sample-4',
|
||||
title: '考上理想的高中',
|
||||
time: '2016-09-01',
|
||||
content: '那天拿到录取通知书,爸妈为我开心地庆祝,我暗下决心要更加努力...',
|
||||
eventType: 'childhood',
|
||||
tags: ['童年']
|
||||
}
|
||||
]
|
||||
|
||||
const profile = computed(() => store.userProfile || store.registrationData || {})
|
||||
const events = computed(() => {
|
||||
const list = store.events || []
|
||||
return list.length ? list : sampleEvents
|
||||
})
|
||||
const events = computed(() => store.events || [])
|
||||
const heroTags = computed(() => {
|
||||
const hobbies = profile.value.hobbies
|
||||
if (Array.isArray(hobbies) && hobbies.length) return hobbies.slice(0, 4)
|
||||
return ['阅读', '旅行', '音乐', '创作']
|
||||
return []
|
||||
})
|
||||
|
||||
const avatar = computed(() => {
|
||||
const nickname = profile.value.nickname || 'Zoey'
|
||||
const nickname = profile.value.nickname || 'User'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ const loadScript = async () => {
|
||||
await store.fetchScripts()
|
||||
script.value = store.getScriptById(scriptId.value)
|
||||
}
|
||||
if (script.value?.id) {
|
||||
ttsPlayer.prewarmSource(script.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const continueCurrent = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="script-library">
|
||||
<view class="script-library" @click="closeScriptMenu">
|
||||
<view class="page-head">
|
||||
<view class="back-title" @click="backToScript">
|
||||
<text class="back-arrow">‹</text>
|
||||
@@ -71,7 +71,27 @@
|
||||
</view>
|
||||
<view class="right-state">
|
||||
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
|
||||
<text class="ellipsis" @click.stop="openScriptMenu(script)">•••</text>
|
||||
<view class="menu-wrap">
|
||||
<text class="ellipsis" :class="{ active: activeMenuId === String(script.id) }" @click.stop="toggleScriptMenu(script)">•••</text>
|
||||
<view v-if="activeMenuId === String(script.id)" class="card-menu" @click.stop>
|
||||
<view class="menu-action" @click="toggleFavorite(script)">
|
||||
<text class="menu-dot"></text>
|
||||
<text>{{ isFavorite(script) ? '取消收藏' : '收藏剧本' }}</text>
|
||||
</view>
|
||||
<view class="menu-action" @click="continueScript(script)">
|
||||
<text class="menu-dot"></text>
|
||||
<text>继续生成</text>
|
||||
</view>
|
||||
<view class="menu-action" @click="openScriptDetailFromMenu(script)">
|
||||
<text class="menu-dot"></text>
|
||||
<text>查看详情</text>
|
||||
</view>
|
||||
<view class="menu-action danger" @click="requestDeleteScript(script)">
|
||||
<text class="menu-dot"></text>
|
||||
<text>删除剧本</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -112,6 +132,17 @@
|
||||
<text class="empty-text">去爽文生成页写下一句灵感,生成你的第一段平行人生。</text>
|
||||
<view class="empty-action" @click="createScript">新建剧本</view>
|
||||
</view>
|
||||
|
||||
<view v-if="deleteTarget" class="confirm-mask" @click.stop="cancelDeleteScript">
|
||||
<view class="confirm-panel" @click.stop>
|
||||
<text class="confirm-title">删除剧本</text>
|
||||
<text class="confirm-copy">确定删除《{{ deleteTarget.title || '未命名剧本' }}》吗?删除后将无法在历史剧本中查看。</text>
|
||||
<view class="confirm-actions">
|
||||
<view class="confirm-btn secondary" @click="cancelDeleteScript">取消</view>
|
||||
<view class="confirm-btn danger" :class="{ disabled: deletingScript }" @click="confirmDeleteScript">{{ deletingScript ? '删除中' : '确认删除' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -126,6 +157,9 @@ const keyword = ref('')
|
||||
const sortMode = ref('updated')
|
||||
const viewMode = ref('list')
|
||||
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||
const activeMenuId = ref('')
|
||||
const deleteTarget = ref(null)
|
||||
const deletingScript = ref(false)
|
||||
|
||||
const typeTabs = [
|
||||
{ label: '长篇', value: 'long' },
|
||||
@@ -350,23 +384,73 @@ const toggleViewMode = () => {
|
||||
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
const openScriptMenu = (script) => {
|
||||
const closeScriptMenu = () => {
|
||||
activeMenuId.value = ''
|
||||
}
|
||||
|
||||
const toggleScriptMenu = (script) => {
|
||||
const id = String(script?.id || '')
|
||||
activeMenuId.value = activeMenuId.value === id ? '' : id
|
||||
}
|
||||
|
||||
const toggleFavorite = (script) => {
|
||||
const favorite = isFavorite(script)
|
||||
uni.showActionSheet({
|
||||
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
|
||||
success: ({ tapIndex }) => {
|
||||
if (tapIndex === 0) {
|
||||
const next = { ...localFavorites.value }
|
||||
if (favorite) delete next[String(script.id)]
|
||||
else next[String(script.id)] = true
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||
}
|
||||
if (tapIndex === 1) viewScript(script)
|
||||
if (tapIndex === 2) openScriptDetail(script)
|
||||
}
|
||||
})
|
||||
const next = { ...localFavorites.value }
|
||||
if (favorite) delete next[String(script.id)]
|
||||
else next[String(script.id)] = true
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
closeScriptMenu()
|
||||
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||
}
|
||||
|
||||
const continueScript = (script) => {
|
||||
closeScriptMenu()
|
||||
viewScript(script)
|
||||
}
|
||||
|
||||
const openScriptDetailFromMenu = (script) => {
|
||||
closeScriptMenu()
|
||||
openScriptDetail(script)
|
||||
}
|
||||
|
||||
const requestDeleteScript = (script) => {
|
||||
closeScriptMenu()
|
||||
if (!script?.id || String(script.id).startsWith('demo-')) {
|
||||
uni.showToast({ title: '示例剧本不可删除', icon: 'none' })
|
||||
return
|
||||
}
|
||||
deleteTarget.value = script
|
||||
}
|
||||
|
||||
const cancelDeleteScript = () => {
|
||||
if (deletingScript.value) return
|
||||
deleteTarget.value = null
|
||||
}
|
||||
|
||||
const confirmDeleteScript = async () => {
|
||||
if (!deleteTarget.value?.id || deletingScript.value) return
|
||||
deletingScript.value = true
|
||||
const id = String(deleteTarget.value.id)
|
||||
const res = await store.deleteScript(id)
|
||||
deletingScript.value = false
|
||||
|
||||
if (!res.success) {
|
||||
uni.showToast({ title: res.error || '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...localFavorites.value }
|
||||
delete next[id]
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
const conversationId = deleteTarget.value.conversationId || deleteTarget.value.plotJson?.conversationId
|
||||
if (conversationId) uni.removeStorageSync(`script_conversation_history_${conversationId}`)
|
||||
uni.removeStorageSync(`script_chat_history_${id}`)
|
||||
const pending = uni.getStorageSync('pending_open_script_chat')
|
||||
if (pending?.id === id) uni.removeStorageSync('pending_open_script_chat')
|
||||
deleteTarget.value = null
|
||||
uni.showToast({ title: '已删除剧本', icon: 'success' })
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -670,6 +754,7 @@ const openScriptMenu = (script) => {
|
||||
}
|
||||
|
||||
.right-state {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
gap: 14rpx;
|
||||
}
|
||||
@@ -699,11 +784,89 @@ const openScriptMenu = (script) => {
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
min-width: 48rpx;
|
||||
height: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18rpx;
|
||||
color: rgba(224, 214, 243, 0.66);
|
||||
font-size: 24rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
|
||||
.ellipsis.active {
|
||||
color: #fff;
|
||||
background: rgba(149, 55, 255, 0.22);
|
||||
box-shadow: 0 0 20rpx rgba(168, 67, 255, 0.28);
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-menu {
|
||||
position: absolute;
|
||||
top: 52rpx;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
width: 202rpx;
|
||||
padding: 10rpx;
|
||||
border-radius: 22rpx;
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.32);
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgba(168, 85, 247, 0.22), transparent 36%),
|
||||
rgba(12, 8, 34, 0.96);
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgba(255, 255, 255, 0.08),
|
||||
0 18rpx 42rpx rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(22rpx);
|
||||
-webkit-backdrop-filter: blur(22rpx);
|
||||
}
|
||||
|
||||
.card-menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 22rpx;
|
||||
top: -10rpx;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-left: 1rpx solid rgba(192, 132, 252, 0.3);
|
||||
border-top: 1rpx solid rgba(192, 132, 252, 0.3);
|
||||
background: rgba(12, 8, 34, 0.96);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 58rpx;
|
||||
padding: 0 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
border-radius: 16rpx;
|
||||
color: rgba(240, 232, 255, 0.9);
|
||||
font-size: 23rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.menu-action:active {
|
||||
background: rgba(149, 55, 255, 0.18);
|
||||
}
|
||||
|
||||
.menu-action.danger {
|
||||
color: #ff8fa3;
|
||||
}
|
||||
|
||||
.menu-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -826,4 +989,79 @@ const openScriptMenu = (script) => {
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #b346ff, #7330ff);
|
||||
}
|
||||
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 48rpx;
|
||||
background: rgba(3, 2, 13, 0.62);
|
||||
backdrop-filter: blur(12rpx);
|
||||
-webkit-backdrop-filter: blur(12rpx);
|
||||
}
|
||||
|
||||
.confirm-panel {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 34rpx 30rpx 28rpx;
|
||||
border-radius: 28rpx;
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.34);
|
||||
background:
|
||||
radial-gradient(circle at 92% 0%, rgba(168, 85, 247, 0.2), transparent 36%),
|
||||
linear-gradient(145deg, rgba(22, 13, 58, 0.98), rgba(8, 7, 26, 0.98));
|
||||
box-shadow: 0 24rpx 72rpx rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-copy {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
color: rgba(229, 219, 247, 0.76);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
height: 70rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 25rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.confirm-btn.secondary {
|
||||
color: #e8ccff;
|
||||
border: 1rpx solid rgba(192, 132, 252, 0.3);
|
||||
background: rgba(88, 28, 135, 0.2);
|
||||
}
|
||||
|
||||
.confirm-btn.danger {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #e05276, #8d2444);
|
||||
box-shadow: 0 12rpx 30rpx rgba(224, 82, 118, 0.24);
|
||||
}
|
||||
|
||||
.confirm-btn.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
<view class="space-bg"></view>
|
||||
<view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||
|
||||
<view class="topbar" :style="topbarStyle">
|
||||
<view class="topbar">
|
||||
<text class="back" @click="goBack">‹</text>
|
||||
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
||||
<text class="save" @click="saveProfile">保存</text>
|
||||
<text class="save" :class="{ disabled: saving }" @click="saveProfile">{{ saving ? '保存中' : '保存' }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
||||
@@ -188,7 +188,7 @@ import { useAppStore } from '../../stores/app.js'
|
||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 2 })
|
||||
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 2 })
|
||||
const isEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const birthday = ref('')
|
||||
@@ -233,12 +233,12 @@ const form = reactive({
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (avatarLocal.value) return avatarLocal.value
|
||||
const nickname = form.nickname || 'Zoey'
|
||||
const nickname = form.nickname || 'User'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
|
||||
const birthdayDisplay = computed(() => {
|
||||
if (!birthday.value) return '1998年06月18日'
|
||||
if (!birthday.value) return '请选择生日'
|
||||
const [year, month, day] = birthday.value.split('-')
|
||||
return `${year}年${month}月${day}日`
|
||||
})
|
||||
@@ -247,21 +247,21 @@ const syncFromStore = () => {
|
||||
const source = store.userProfile || store.registrationData || {}
|
||||
Object.assign(form, {
|
||||
nickname: source.nickname || '',
|
||||
gender: source.gender || '女',
|
||||
zodiac: source.zodiac || '巨蟹座',
|
||||
mbti: source.mbti || 'ENTJ',
|
||||
profession: source.profession || '产品经理',
|
||||
city: source.city || '上海市',
|
||||
industry: source.industry || '互联网',
|
||||
gender: source.gender || '',
|
||||
zodiac: source.zodiac || '',
|
||||
mbti: source.mbti || '',
|
||||
profession: source.profession || '',
|
||||
city: source.city || '',
|
||||
industry: source.industry || '',
|
||||
company: source.company || '',
|
||||
personalityTags: Array.isArray(source.personalityTags) && source.personalityTags.length ? [...source.personalityTags] : ['理性', '乐观', '独立', '有创造力', '坚韧'],
|
||||
hobbies: Array.isArray(source.hobbies) && source.hobbies.length ? [...source.hobbies] : ['阅读', '旅行', '音乐'],
|
||||
personalityTags: Array.isArray(source.personalityTags) ? [...source.personalityTags] : [],
|
||||
hobbies: Array.isArray(source.hobbies) ? [...source.hobbies] : [],
|
||||
childhood: { date: source.childhood?.date || '', text: source.childhood?.text || '' },
|
||||
joy: { date: source.joy?.date || '', text: source.joy?.text || '' },
|
||||
low: { date: source.low?.date || '', text: source.low?.text || '' },
|
||||
future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '热爱阅读和旅行,喜欢用文字和镜头记录生活。\n相信真诚和努力能让世界变得更美好。' }
|
||||
future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '' }
|
||||
})
|
||||
birthday.value = source.birthday || '1998-06-18'
|
||||
birthday.value = source.birthday || ''
|
||||
}
|
||||
|
||||
const onBirthday = (event) => {
|
||||
@@ -319,6 +319,7 @@ const saveProfile = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
syncFromStore()
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
if (isEdit.value) uni.navigateBack()
|
||||
@@ -331,9 +332,12 @@ const goBack = () => {
|
||||
else uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
const pages = getCurrentPages()
|
||||
isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
|
||||
if (isEdit.value) {
|
||||
await store.fetchUserProfile()
|
||||
}
|
||||
syncFromStore()
|
||||
})
|
||||
</script>
|
||||
@@ -374,7 +378,7 @@ onMounted(() => {
|
||||
.topbar {
|
||||
height: 72rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 76rpx 1fr 76rpx;
|
||||
grid-template-columns: 76rpx 1fr 112rpx;
|
||||
align-items: center;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
@@ -401,6 +405,12 @@ onMounted(() => {
|
||||
color: #b94cff;
|
||||
font-size: 26rpx;
|
||||
text-align: right;
|
||||
justify-self: end;
|
||||
min-width: 96rpx;
|
||||
}
|
||||
|
||||
.save.disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
|
||||
@@ -52,8 +52,7 @@ const resolveInitialRoute = async () => {
|
||||
const session = await store.restoreSession()
|
||||
|
||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||
const target = session.hasProfile ? '/pages/main/index?tab=script' : '/pages/onboarding/index'
|
||||
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
|
||||
routeOnce('/pages/main/index?tab=script', { reason: session.reason })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ export const login = async ({ phone, smsCode }) => {
|
||||
return response
|
||||
}
|
||||
|
||||
export const wechatLogin = async ({ code, nickname, avatar }) => {
|
||||
const response = await post('/auth/wechat/login', { code, nickname, avatar })
|
||||
|
||||
if (response.data) {
|
||||
const { accessToken, refreshToken } = response.data
|
||||
if (accessToken) {
|
||||
uni.setStorageSync('access_token', accessToken)
|
||||
}
|
||||
if (refreshToken) {
|
||||
uni.setStorageSync('refresh_token', refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns {Promise<void>}
|
||||
@@ -120,6 +136,7 @@ export const checkPhone = async (phone) => {
|
||||
export default {
|
||||
getSmsCode,
|
||||
login,
|
||||
wechatLogin,
|
||||
logout,
|
||||
refreshToken,
|
||||
validateToken,
|
||||
|
||||
@@ -95,6 +95,10 @@ const transformToBackendFormat = (frontendData) => {
|
||||
character,
|
||||
events,
|
||||
plotJson,
|
||||
conversationId,
|
||||
parentScriptId,
|
||||
revisionIndex,
|
||||
revisionOf,
|
||||
useSocialInsights
|
||||
} = frontendData
|
||||
|
||||
@@ -112,7 +116,14 @@ const transformToBackendFormat = (frontendData) => {
|
||||
plotTurning: frontendData.plotTurning || '',
|
||||
plotClimax: frontendData.plotClimax || '',
|
||||
plotEnding: frontendData.plotEnding || '',
|
||||
plotJson: plotJson || (content ? { fullContent: content } : null),
|
||||
plotJson: {
|
||||
...(content ? { fullContent: content } : {}),
|
||||
...(plotJson || {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
...(parentScriptId ? { parentScriptId } : {}),
|
||||
...(revisionIndex ? { revisionIndex } : {}),
|
||||
...(revisionOf ? { revisionOf } : {})
|
||||
},
|
||||
isSelected,
|
||||
characterInfo,
|
||||
lifeEventsSummary,
|
||||
@@ -171,6 +182,10 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(),
|
||||
mode: plotJson?.mode || 'custom',
|
||||
prompt: plotJson?.prompt || '',
|
||||
conversationId: plotJson?.conversationId || '',
|
||||
parentScriptId: plotJson?.parentScriptId || '',
|
||||
revisionIndex: plotJson?.currentRevisionIndex || plotJson?.revisionIndex || 0,
|
||||
revisionOf: plotJson?.revisionOf || '',
|
||||
wordCount: content ? content.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,24 @@
|
||||
|
||||
import { get, post, put } from './request.js'
|
||||
|
||||
const normalizeDate = (value) => {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
const parseJsonArray = (value) => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (!value) return []
|
||||
if (typeof value !== 'string') return []
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (error) {
|
||||
console.warn('[userProfile] JSON array parse failed', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户档案
|
||||
* @returns {Promise<Object|null>} 用户档案
|
||||
@@ -55,7 +73,12 @@ const transformToBackendFormat = (frontendData) => {
|
||||
childhood,
|
||||
joy,
|
||||
low,
|
||||
future
|
||||
future,
|
||||
city,
|
||||
industry,
|
||||
company,
|
||||
personalityTags,
|
||||
birthday
|
||||
} = frontendData
|
||||
|
||||
return {
|
||||
@@ -79,7 +102,12 @@ const transformToBackendFormat = (frontendData) => {
|
||||
// 未来期许
|
||||
futureVision: future?.vision || null,
|
||||
// 理想生活状态
|
||||
idealLife: future?.ideal || null
|
||||
idealLife: future?.ideal || null,
|
||||
city: city ?? '',
|
||||
industry: industry ?? '',
|
||||
company: company ?? '',
|
||||
personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
|
||||
birthday: birthday || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +135,12 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
valleyDate,
|
||||
valleyContent,
|
||||
futureVision,
|
||||
idealLife
|
||||
idealLife,
|
||||
city,
|
||||
industry,
|
||||
company,
|
||||
personalityTags,
|
||||
birthday
|
||||
} = backendData
|
||||
|
||||
return {
|
||||
@@ -119,27 +152,32 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
profession: profession || '',
|
||||
mbti: mbti || '',
|
||||
// 兴趣爱好从JSON字符串解析
|
||||
hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [],
|
||||
hobbies: parseJsonArray(hobbies),
|
||||
// 童年经历
|
||||
childhood: {
|
||||
date: childhoodDate || '',
|
||||
date: normalizeDate(childhoodDate),
|
||||
text: childhoodContent || ''
|
||||
},
|
||||
// 高光时刻
|
||||
joy: {
|
||||
date: peakDate || '',
|
||||
date: normalizeDate(peakDate),
|
||||
text: peakContent || ''
|
||||
},
|
||||
// 低谷时期
|
||||
low: {
|
||||
date: valleyDate || '',
|
||||
date: normalizeDate(valleyDate),
|
||||
text: valleyContent || ''
|
||||
},
|
||||
// 未来期许
|
||||
future: {
|
||||
vision: futureVision || '',
|
||||
ideal: idealLife || ''
|
||||
}
|
||||
},
|
||||
city: city || '',
|
||||
industry: industry || '',
|
||||
company: company || '',
|
||||
personalityTags: parseJsonArray(personalityTags),
|
||||
birthday: normalizeDate(birthday)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ const state = reactive({
|
||||
childhood: { date: '', text: '' },
|
||||
joy: { date: '', text: '' },
|
||||
low: { date: '', text: '' },
|
||||
future: { vision: '', ideal: '' }
|
||||
future: { vision: '', ideal: '' },
|
||||
city: '',
|
||||
industry: '',
|
||||
company: '',
|
||||
personalityTags: [],
|
||||
birthday: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -50,6 +55,15 @@ const resetAuthState = () => {
|
||||
state.userProfile = null
|
||||
}
|
||||
|
||||
const applyProfileResponse = (profileData) => {
|
||||
const profile = userProfileService.transformToFrontendFormat(profileData)
|
||||
state.userProfile = profile
|
||||
if (profile) {
|
||||
Object.assign(state.registrationData, profile)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
const clearStoredTokens = () => {
|
||||
uni.removeStorageSync('access_token')
|
||||
uni.removeStorageSync('refresh_token')
|
||||
@@ -69,6 +83,20 @@ const login = async (phone, smsCode) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithWechat = async (payload = {}) => {
|
||||
state.isLoading = true
|
||||
try {
|
||||
await authService.wechatLogin(payload)
|
||||
state.isLoggedIn = true
|
||||
await fetchUserProfile()
|
||||
return { success: true, hasProfile: hasProfile.value }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
state.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authService.logout()
|
||||
resetAuthState()
|
||||
@@ -79,14 +107,14 @@ const fetchUserProfile = async (options = {}) => {
|
||||
try {
|
||||
const res = await userProfileService.getCurrentProfile()
|
||||
if (res.data) {
|
||||
state.userProfile = userProfileService.transformToFrontendFormat(res.data)
|
||||
Object.assign(state.registrationData, state.userProfile)
|
||||
const profile = applyProfileResponse(res.data)
|
||||
logAuth('profile:success', { hasProfile: true })
|
||||
return profile
|
||||
} else {
|
||||
state.userProfile = null
|
||||
logAuth('profile:empty', { hasProfile: false })
|
||||
}
|
||||
return res.data
|
||||
return null
|
||||
} catch (error) {
|
||||
state.userProfile = null
|
||||
logAuth('profile:fail', {
|
||||
@@ -106,13 +134,17 @@ const saveUserProfile = async () => {
|
||||
state.isLoading = true
|
||||
try {
|
||||
const dataToSave = { ...state.registrationData }
|
||||
let response
|
||||
if (state.userProfile?.id) {
|
||||
await userProfileService.updateProfile({
|
||||
response = await userProfileService.updateProfile({
|
||||
id: state.userProfile.id,
|
||||
...dataToSave
|
||||
})
|
||||
} else {
|
||||
await userProfileService.createProfile(dataToSave)
|
||||
response = await userProfileService.createProfile(dataToSave)
|
||||
}
|
||||
if (response?.data) {
|
||||
applyProfileResponse(response.data)
|
||||
}
|
||||
await fetchUserProfile()
|
||||
return { success: true }
|
||||
@@ -224,8 +256,30 @@ const fetchScripts = async () => {
|
||||
const createScript = async (scriptData) => {
|
||||
try {
|
||||
const res = await epicScriptService.createScript(scriptData)
|
||||
const script = epicScriptService.transformToFrontendFormat(res.data)
|
||||
await fetchScripts()
|
||||
return { success: true, data: res.data }
|
||||
return { success: true, data: script || res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const updateScript = async (scriptData) => {
|
||||
try {
|
||||
const res = await epicScriptService.updateScript(scriptData)
|
||||
const script = epicScriptService.transformToFrontendFormat(res.data)
|
||||
await fetchScripts()
|
||||
return { success: true, data: script || res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteScript = async (id) => {
|
||||
try {
|
||||
await epicScriptService.deleteScript(id)
|
||||
await fetchScripts()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
@@ -397,9 +451,20 @@ const initialize = async () => {
|
||||
|
||||
export const useAppStore = () => {
|
||||
return readonly({
|
||||
...state,
|
||||
hasProfile,
|
||||
get isLoggedIn() { return state.isLoggedIn },
|
||||
get isLoading() { return state.isLoading },
|
||||
get currentStep() { return state.currentStep },
|
||||
get userInfo() { return state.userInfo },
|
||||
get userProfile() { return state.userProfile },
|
||||
get events() { return state.events },
|
||||
get scripts() { return state.scripts },
|
||||
get inspirationRecommendations() { return state.inspirationRecommendations },
|
||||
get paths() { return state.paths },
|
||||
get currentPath() { return state.currentPath },
|
||||
get registrationData() { return state.registrationData },
|
||||
get hasProfile() { return hasProfile.value },
|
||||
login,
|
||||
loginWithWechat,
|
||||
logout,
|
||||
fetchUserProfile,
|
||||
saveUserProfile,
|
||||
@@ -416,6 +481,8 @@ export const useAppStore = () => {
|
||||
getEventById,
|
||||
fetchScripts,
|
||||
createScript,
|
||||
updateScript,
|
||||
deleteScript,
|
||||
fetchInspirationRecommendations,
|
||||
fetchRandomInspirations,
|
||||
generateScriptFromInspiration,
|
||||
|
||||
@@ -15,3 +15,11 @@ alter table emotion_museum.t_ai_config
|
||||
add client_id VARCHAR(200) COMMENT '客户端ID (OAuth认证)',
|
||||
add client_secret VARCHAR(500) COMMENT '客户端密钥 (OAuth认证,加密存储)',
|
||||
add grant_type VARCHAR(50) COMMENT '授权类型: client_credentials, authorization_code, password等';
|
||||
|
||||
-- 用户档案扩展字段:城市、行业、公司、性格标签、生日
|
||||
alter table emotion_museum.t_user_profile
|
||||
add city varchar(100) null comment '所在城市',
|
||||
add industry varchar(100) null comment '行业',
|
||||
add company varchar(200) null comment '公司',
|
||||
add personality_tags json null comment '性格标签列表',
|
||||
add birthday date null comment '生日';
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
服务器日志下载脚本
|
||||
|
||||
从生产服务器下载后端日志到本地 logs/server/ 目录
|
||||
|
||||
使用方法:
|
||||
python tools/download-server-log.py
|
||||
|
||||
Author: Peanut
|
||||
Created: 2026-06-03
|
||||
Purpose: 从服务器下载 emotion-single.log 到本地,方便排查问题
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# 配置
|
||||
# ============================================================================
|
||||
SERVER_IP = "101.200.208.45"
|
||||
USERNAME = "root"
|
||||
REMOTE_LOG = "/data/logs/emotion-museum/emotion-single.log"
|
||||
|
||||
# 本地路径(项目根目录下的 logs/server/)
|
||||
PROJECT_DIR = Path(__file__).parent.parent.absolute()
|
||||
LOCAL_LOG_DIR = PROJECT_DIR / "logs" / "server"
|
||||
LOCAL_LOG = LOCAL_LOG_DIR / "emotion-single.log"
|
||||
BACKUP_LOG = LOCAL_LOG_DIR / "emotion-single.log.bak"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 日志输出
|
||||
# ============================================================================
|
||||
def log_info(msg):
|
||||
print(f"\033[32m[INFO]\033[0m {msg}")
|
||||
|
||||
|
||||
def log_warn(msg):
|
||||
print(f"\033[33m[WARN]\033[0m {msg}")
|
||||
|
||||
|
||||
def log_error(msg):
|
||||
print(f"\033[31m[ERROR]\033[0m {msg}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 核心逻辑
|
||||
# ============================================================================
|
||||
def check_ssh():
|
||||
"""检查 SSH 连接是否正常"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}", "echo ok"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log_info("SSH 连接正常")
|
||||
return True
|
||||
else:
|
||||
log_error(f"SSH 连接失败: {result.stderr.strip()}")
|
||||
log_error(f"请确认已配置免密登录: ssh {USERNAME}@{SERVER_IP}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log_error("SSH 连接超时")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
log_error("未找到 ssh 命令,请确认已安装 OpenSSH 客户端")
|
||||
return False
|
||||
|
||||
|
||||
def get_remote_file_size():
|
||||
"""获取远程日志文件大小(字节)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}", f"stat -c %s {REMOTE_LOG} 2>/dev/null || echo -1"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
size = int(result.stdout.strip())
|
||||
return size if size > 0 else -1
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError) as e:
|
||||
log_error(f"获取远程文件大小失败: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
def format_size(size_bytes):
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 0:
|
||||
return "未知"
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} TB"
|
||||
|
||||
|
||||
def backup_local_log():
|
||||
"""备份本地旧日志文件"""
|
||||
if LOCAL_LOG.exists():
|
||||
shutil.copy2(LOCAL_LOG, BACKUP_LOG)
|
||||
log_info(f"已备份旧日志: {BACKUP_LOG}")
|
||||
else:
|
||||
log_info("本地无旧日志,跳过备份")
|
||||
|
||||
|
||||
def download_log():
|
||||
"""下载远程日志文件"""
|
||||
LOCAL_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_info(f"本地目录: {LOCAL_LOG_DIR}")
|
||||
|
||||
backup_local_log()
|
||||
|
||||
log_info(f"正在下载: {USERNAME}@{SERVER_IP}:{REMOTE_LOG}")
|
||||
log_info(f" -> {LOCAL_LOG}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["scp", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
|
||||
f"{USERNAME}@{SERVER_IP}:{REMOTE_LOG}", str(LOCAL_LOG)],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode == 0:
|
||||
local_size = LOCAL_LOG.stat().st_size
|
||||
log_info(f"下载成功: {format_size(local_size)}")
|
||||
return True
|
||||
else:
|
||||
log_error(f"下载失败: {result.stderr.strip()}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log_error("下载超时(300 秒)")
|
||||
return False
|
||||
except Exception as e:
|
||||
log_error(f"下载异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
log_info("=== 情绪博物馆 - 日志下载工具 ===")
|
||||
log_info(f"服务器: {USERNAME}@{SERVER_IP}")
|
||||
log_info(f"远程日志: {REMOTE_LOG}")
|
||||
|
||||
# 检查 SSH
|
||||
if not check_ssh():
|
||||
sys.exit(1)
|
||||
|
||||
# 检查远程文件
|
||||
remote_size = get_remote_file_size()
|
||||
if remote_size < 0:
|
||||
log_error(f"远程日志文件不存在或无法访问: {REMOTE_LOG}")
|
||||
sys.exit(1)
|
||||
log_info(f"远程日志大小: {format_size(remote_size)}")
|
||||
|
||||
# 下载
|
||||
if download_log():
|
||||
log_info("=== 下载完成 ===")
|
||||
else:
|
||||
log_error("下载失败")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user