总结

简单来说,这个项目实际上并没有实现一个 完整的 AI AGENT 架构,而是一个AI 参与的工作流。AI 在这个工作流中只负责生成内容(返回文本/JSON),也就是调用了 LLM 来“思考”,而函数的调用、执行和整体流程都是后端解析 AI 生成的文本后进行处理的。

在以后,可以尝试把这个项目后端的一些 Services 抽离出一个 MCP Server,让 AI 能够自主选择调用这些函数/工具,比如说:收集用户偏好、生成旅行计划、更新天气信息等。这样就能更接近一个完整的 AI AGENT 架构,让 AI 不仅能“思考”,还能“动手”,并且自主决定下一步做什么。


清理任务:

  1. 定期清理AWS S3,删除数据库内不存在但AWS S3上存在的冗余文件
  2. 定期清理email_token table, 删除过期的token
  3. 定期检查trip表,删除user不存在的trip及其子表 (trip_weather, trip_insights …)

登录鉴权:

  1. 分发jwt时可以把userId存在jwt token 中的 subject,这样就不需要前端传入userId,在controller中用@AuthenticationPrincipal注解即可从subject中取出userId(前提是你subject中只存了userId)。jwt token 中的claims也可以存一些非保密字段,比如说username, email.
  2. AuthConfig中的异常是在过滤器链中发生的,不会被全局异常处理器捕捉。所以要在AuthConfig中用.authenticationEntryPoint和.accessDeniedHandler处理。
  3. 在jwtFilter中,异常不用留给全局异常处理器,直接catch然后SecurityContextHolder.clearContext();清空上下文就行。(因为不做任何处理,没有用.setAuthentication把jwt token放到header中,在controller层请求就会因为unauthenticated而被驳回)

前后端跨域:

  1. 第一种方法:后端配置CorsConfig,开启 CORS
  2. 第二种方法:前端反向代理到后端。(例如:开发期可用 vite.config.ts,生成部署阶段用 Nginx 反代)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* 开发环境下 Vite 转发请求使前后端同源(生成环境下用 Nginx)
* 在 Vite 的 server.proxy 里,
* 只有以 /api 开头的请求才会被代理到 target;其它路径仍由 Vite 本地开发服务器处理。
*
* GET http://localhost:5173/api/test → 代理为 GET http://localhost:8082/api/test
* GET http://localhost:5173/auth/login → 不代理,仍由 Vite 处理
*
* */

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8082', changeOrigin: true },
},
},
})

邮箱验证:

  1. 未注册用户第一次注册时,会发送一封验证邮件到注册邮箱,用户点击则user表email_verifed字段为true,同时分发jwt自动登录并跳转到my trip页面
  2. 用户注册后但未验证邮箱时,无法登录。在登陆时会提示用户尚未验证,并询问用户是否要再次发送验证邮件,用户点击是则发送,后续逻辑如1
  3. 用户忘记密码时,点击forgot password并输入注册时邮箱,会发送一封reset password邮件,用户点击并填写表单(新密码),提交后即可更新密码。注:如果该用户注册后尚未验证邮箱,通过forgot password修改密码后,会看作已验证,将email_verifed字段为true
  4. 用户可以修改邮箱。先发送一封带有表单的链接给当前邮箱,用户输入新邮箱账号并确认后,会再发送一封验证link给新邮箱,用户点击后则修改成功,并自动跳转回profile页面
  5. 所有邮箱link附带token,token有过期和使用标记,当发送新的验证link时,之前的验证link不在允许使用,即便仍未过期

Agent:

  1. 定义prompt,并要求AI返回严格的JSON格式。
  2. 用 objectMapper.readValue(json, dto)方法反序列化json,将规范的json转换成对应的dto,这样就可以被java操作了(注意:OPENAI返回的JSON一般不规范,这时需要先用objectMapper.readTree(openaiJson)解析openAI返回的json,找到其中真正规范的json,比如:realJson = objectMapper.readTree(openAiJson).path(“choices”).get(0).path(“message”).path(“content”).asText(),再用readValue去反序列化 realJson )
  3. 反序列化成DTO对象后就简单了,对其进行相应操作就行,比如存库
  4. re-plan. 前端:用户点击replan,并输入 new preference。后端:定义一个新的prompt并插入新preference,新的prompt要说明把new preference作为最严格的约束,然后拼接new prompt和之前定义的基本prompt,同时更新天气信息(删除数据库中旧的天气信息)。最后生成的replan的计划应该是基于:新preference(最上层约束) + 旧plan基本信息(目的点,时间范围等) + 更新的天气。
  5. 普通的后端服务就像“双手”,它只能处理各种数据去实现业务,但不能思考。普通的AI chat就像“大脑”,它可以思考问题并回答,但并不能处理。而AI AGENT就是“大脑+双手”,先把需求告诉AI,AI (”大脑“)思考并告诉我们它的回答,后端业务(双手)再去解析这个回答(String,JSON),把回答转换成具体的对象,并进行各种处理去实现业务。所以AI AGENT实际上并不是AI自动帮你做事,它还是像AI CHAT一样处理用户输入的消息(只不过后端可以额外加prompt)并返回回答(只不过回答没有直接给用户,而是交给后端处理),而正在实现处理的仍然是后端程序员通过解析AI的回答得到的信息写的各种业务。举个例子,AI AGENT实现根据药品信息自动定时提醒功能,这个功能并不是AI自动帮你完成了定时功能,实际上是AI根据药品信息和prompt(e.g. 你是一个药品提醒助手,请根据提供的药品信息返回正确的吃药时间)返回一个String(JSON)包含了每个药品的吃药时间给后端,然后后端解析这个String得到每个药品的吃药时间(比如说放在List,甚至说存在数据库里),然后再根据得到的吃药时间在后端写定时业务,和前端联动实现提醒功能。在这个例子中,虽然在用户的视角像是AI Agent帮他们自动定时了时间,但实际上AI只是思考并提供了吃药时间(这点和AI CHAT无异),而真正实现定时业务的仍然是后端程序员写的逻辑。

对于第5点的逻辑,我那时写的时候还是太浅显了。这个项目严格来说不能算是完整的AI AGENT项目,真正要算,其实是有AI参与的workflow,强流程、弱自由度,AI 只在既定节点产出内容,执行仍由后端业务完成。用户输入 → 定义 Prompt → LLM 解析 → 返回结果 → 由后端处理与落地。

真正的AI AGENT应当拥有高自由度,能够自主选择并调用开发者定义好的函数/工具,根据工具返回决定是否继续、如何继续或何时回调/终止(可以了解一下 LangGraph 框架,能够帮助开发者编排这种流程)。

但其实 Ai Agent 本身应该也算是一种特殊的工作流?只不过在这个工作流中分支并没有写死,相对更自由些。和普通workflow和AI参与的workflow对比,AI AGENT 将选择下一步/执行哪些函数的决策交给AI来做(不仅仅是思考),但真正到执行函数这一步,还是要有后端业务参与。

普通工作流:选择、计划、执行都由后端代码决定。AI 参与的工作流:执行仍在后端;AI在既定节点“思考/生成”,分支总体仍按预设推进。AI Agent:把选择与计划交给模型,但执行与落地仍归后端;分支不是完全写死,而是在后端定义的受控分支/工具白名单中,由AI基于工具返回自主决定下一步(继续/停止/回调),并受步数/超时/预算等护栏约束。

形态“选择/计划”“执行/副作用”分支定义方式工具调用
普通工作流后端写死后端if/else/流程图写死无/固定SDK调用
AI 参与的工作流AI 生成内容,但通常按既定节点走后端主干写死,某些节点让 AI 产出结构化结果多为固定调用或人工审核后调用
AI AgentLLM 在白名单与状态机内做策略选择后端状态机/图:边受控、AI选边函数/工具调用(name+args→DTO→执行→结果回馈)

图片:
插入cover:Agent生成img_subscription, 在根据img_subscription在unsplash上搜索返回第一张图片的Link作为cover
头像:用户被允许通过文件或链接上传头像,上传的头像存储在 aws s3

中国大陆地区适配:

  1. 阿里云cdn 加速访问 ec2服务器(新加坡),优先访问边缘节点上的缓存(但效果有限,边缘节点无缓存时仍然需要访问新加坡源站。后续考虑部署国内云服务器,做双端源站)
  2. 后端服务器位于 ec2(新加坡), 代替用户向 LLM (OpenAI) 发出请求,避免用户在中国大陆境内无法直接请求 LLM。
  3. 对于中国大陆无法直接访问的外部资源,例如 GeoDB Cites, Unsplash Image,在 ec2 (新加坡) 端用 Nginx 反向代理,把请求改为从ec2端发出,避免用户在中国大陆直接请求。
  4. 适配高德地图,用户访问时用一个很小的Google资源作为探针,在中国大陆境内则默认用高德地图,在海外则默认用Google地图;用 Google Geocode 得到地理位置编码,在把地理位置编码提供给高德地图,解决 AMAP API 对直接用英文搜索地名支持很差的问题。不足之处:高德地图无法对海外城市提供路线规划(场景:用户在中国大陆,但旅行计划在在海外城市);同样的,谷歌地图对中国大陆城市的路线规划的支持也不好。
  5. 部署双端OSS源站同步存放静态资源,并用cdn加速访问; 设置cdn规则,海外用户默认回源 OSS(新加坡),中国大陆用户默认回源OSS(杭州),加快国内外用户访问速度。

踩坑:

  1. Project包含多个Module时,用IDEA启动Application类是默认在项目根目录开始的,如果在module目录下定义了环境变量.env文件,是读取不到的。需要把Application类的Working directory改成
  2. 最麻烦的地方反而是国内网络的问题,有些外部服务和网址国内根本访问不了。用ec2+nginx反代这些外部服务,再用国内cdn加速ec2,这样用户请求外部服务的流程就是 用户 -> cdn边缘节点 -> ec2 -> 外部服务/网址 -> ec2 -> cdn边缘节点 -> 用户,跳过用户直接访问外部服务/网址。
  3. 给国内适配高德太麻烦了。。。本来向直接反代google map,但好像不合规,高德的官方文档又太久旧了,没有关于ts的内容。再加上高德api对英文的适配很差,如果要传中文就要改prompt估计还要改数据库结构,不太好。正好我的服务器在ec2(新加坡),可以让服务器向google map geocode 发起请求,得到英文地址的地理编码,再把地理编码给高德地图,这样就不用重构数据库了。还在前端加了一个小探针,来判断用户是不是在中国大陆:先让用户访问一个很小的谷歌资源,如果超时就默认让用户访问高德地图,能连通就让用户访问谷歌地图,并缓存结果6小时,避免每次都要判断。

鉴权

JwtConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthConfig {

private final JwtFilter jwtFilter;

@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
ObjectMapper om = new ObjectMapper();

http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/register", "/api/login", "/api/verify-email","/api/forgot-password",
"/api/reset-password", "/api/resend-verify-email", "/api/verify-reset-password-email")
.permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write(om.writeValueAsString(ApiRespond.error("Not authenticated, please log in first")));
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write(om.writeValueAsString(ApiRespond.error("You do not have permission to access this resource")));
})
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

JwtFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* Spring Security JWT filter that extracts and validates JWT from request headers
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

private final JwtUtils jwtUtil;
private final UserRepository userRepository;

/**
* Filter pass-through logic
* @param request
* @param response
* @param chain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {

// Extract and validate JWT from Authorization header: Bearer <JWT>
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
// Extract JWT from Bearer <JWT>
String token = header.substring(7);
try {
Claims claims = jwtUtil.parse(token);
String subject = claims.getSubject();
if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
Long userId = Long.valueOf(subject);
Integer tokenVersion = claims.get("version", Integer.class);

User user = userRepository.findById(userId).orElse(null);
// Check whether token version matches (mismatch after password change)
if (user != null && tokenVersion.equals(user.getTokenVersion())) {
// Set the subject content (String userId) as the JWT principal
// In controllers, you can get the principal from the authenticated user's token via @AuthenticationPrincipal (String userId)
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
subject, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(auth); // Authenticated
} else {
SecurityContextHolder.clearContext();
}
}
} catch (RuntimeException e) {
// If parsing fails, do not set Authentication; the request will be rejected at controller due to unauthenticated
SecurityContextHolder.clearContext();
log.debug("Invalid token", e);
}
}
chain.doFilter(request, response);
}
}


JwtUtils

工具类

工具类

AWS S3 Utils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/**
* AWS S3 utils
* See AWS SDK for Java v2 official documentation
* https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/java_s3_code_examples.html
*/
@Slf4j
@Component
public class AwsS3Utils {
private final S3Client s3Client;
public AwsS3Utils(@Value("${aws.accesskeyId}") String accesskeyId,
@Value("${aws.secretAccessKey}") String secretAccessKey,
@Value("${aws.region}") String region) {
AwsBasicCredentials creds = AwsBasicCredentials.create(accesskeyId, secretAccessKey);
this.s3Client = S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(creds))
.build();
}
@PreDestroy
public void close() {
try {
s3Client.close();
} catch (Exception e) {
log.debug("Ignore s3Client close error", e);
}
}

@Value("${aws.s3.bucket-name}")
private String bucketName;

@Value("${aws.s3.dir-name}")
private String dirName;

@Value("${aws.s3.cdn}")
private String cdn;

/**
* 1. S3 single file upload
*
* @param in file input stream
* @param originalFilename original filename
* @return Return URL (https://{cdn}/{dir/yyyy/MM/xxx.png})
*/
public String upload(InputStream in, String originalFilename, boolean isAvatar) throws Exception {
// If it is an avatar, put it under elec5620-stage2/avatars
String baseDir = isAvatar ? (dirName + "/avatars") : dirName;

// Directory and file name: rewrite the filename
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
String newFileName = UUID.randomUUID().toString().replace("-", "") + suffix;
String objectKey = baseDir + "/" + dir + "/" + newFileName;

// Read into byte[] to get content-length
ByteArrayOutputStream bos = new ByteArrayOutputStream(Math.max(32 * 1024, in.available()));
in.transferTo(bos);
byte[] bytes = bos.toByteArray();

String contentType = switch (suffix) {
case ".jpg", ".jpeg" -> "image/jpeg";
case ".png" -> "image/png";
case ".gif" -> "image/gif";
case ".svg" -> "image/svg+xml";
case ".webp" -> "image/webp";
default -> "application/octet-stream"; // Default type when file type is unknown
};

PutObjectRequest putReq = PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.contentType(contentType)
.build();

s3Client.putObject(putReq, RequestBody.fromBytes(bytes));

return "https://" + cdn + "/" + objectKey;
}

/**
* 1.1 Download image from external URL to memory, then upload, and return final URL (AWS S3)
*
* @param imageUrl external URL
* @return Return URL (https://{cdn}/{dir/yyyy/MM/xxx.png})
*/
public String uploadFromUrl(String imageUrl, boolean isAvatar) throws Exception {

HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();

HttpRequest request = HttpRequest.newBuilder(URI.create(imageUrl))
.timeout(Duration.ofSeconds(20))
.GET()
.build();

HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
throw new IllegalArgumentException("Fetch image failed, http status=" + response.statusCode());
}

String contentType = response.headers().firstValue("Content-Type").orElse("");
if (!contentType.isEmpty() && !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
throw new IllegalArgumentException("Unsupported content-type: " + contentType);
}

long maxSize = 10 * 1024 * 1024L;
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
if (contentLength > maxSize) {
throw new IllegalArgumentException("File too large, max size is 10 MB");
}

// Infer file suffix
String suffix = null;
String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.contains("jpeg") || ct.contains("jpg")) suffix = ".jpg";
else if (ct.contains("png")) suffix = ".png";
else if (ct.contains("gif")) suffix = ".gif";
else if (ct.contains("svg")) suffix = ".svg";
else if (ct.contains("webp")) suffix = ".webp";

if (suffix == null) {
String path = URI.create(imageUrl).getPath();
int dot = path.lastIndexOf('.');
if (dot != -1 && dot < path.length() - 1) {
String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT);
if (ext.matches("jpg|jpeg|png|gif|svg|webp")) {
suffix = "." + (ext.equals("jpeg") ? "jpg" : ext);
}
}
}
if (suffix == null) suffix = ".jpg";

// Read into memory and enforce 10 MB limit
try (InputStream in = response.body(); ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
long total = 0;
int n;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > maxSize) {
throw new IllegalArgumentException("File too large, max size is 10 MB");
}
bos.write(buf, 0, n);
}
String s3Url = upload(new ByteArrayInputStream(bos.toByteArray()), "temp" + suffix, isAvatar);
log.info("Upload from {} to {}", imageUrl, s3Url);
return s3Url;
}
}

/**
* 2. List files in AWS S3 with the specified prefix (applicable when file count ≤ 1000)
*
* @param preFix Prefix (e.g., elec5620-stage2/2025)
* @param size Maximum number to list (up to 1000)
*/
public List<String> listFiles(String preFix, int size) throws Exception {
if (size > 1000) {
size = 1000;
log.warn("A maximum of 1000 files is allowed, it has been automatically adjusted to 1000");
}

List<String> fileList = new ArrayList<>();
ListObjectsV2Response resp = s3Client.listObjectsV2(ListObjectsV2Request.builder()
.bucket(bucketName)
.prefix(preFix)
.maxKeys(size)
.build());
List<S3Object> contents = resp.contents();
if (ObjectUtil.isNotEmpty(contents)) {
fileList = contents.stream().map(S3Object::key).toList();
}

return fileList;
}

/**
* 3. Paginated listing of files in AWS S3 with the specified prefix (applicable when file count > 1000)
*
* @param preFix Prefix
* @param pageSize Page size (max 1000)
*/
public List<String> listPageAllFiles(String preFix, int pageSize) throws Exception {
if (pageSize > 1000) {
pageSize = 1000;
log.warn("A maximum of 1000 files every time is allowed, it has been automatically adjusted to 1000");
}

List<String> fileList = new ArrayList<>();
String continuationToken = null;

ListObjectsV2Response resp;
do {
var builder = ListObjectsV2Request.builder()
.bucket(bucketName)
.prefix(preFix)
.maxKeys(pageSize);
if (continuationToken != null) builder.continuationToken(continuationToken);

resp = s3Client.listObjectsV2(builder.build());

List<S3Object> contents = resp.contents();
if (ObjectUtil.isNotEmpty(contents)) {
fileList.addAll(contents.stream().map(S3Object::key).toList());
}
continuationToken = resp.nextContinuationToken();
} while (resp.isTruncated());

return fileList;
}

/**
* 4. Delete a single file
*
* @param objectKey File key (without bucket name), e.g.: elec5620-stage2/2025/06/xxx.png
*/
public void deleteFile(String objectKey) throws Exception {
s3Client.deleteObject(DeleteObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build());
}

/**
* 5. Batch delete multiple files (up to 1000 at a time; split into batches if exceeded)
*
* @param objectKeys List of keys to delete
*/
public void batchDeleteFiles(List<String> objectKeys) throws Exception {
for (int i = 0; i < objectKeys.size(); i += 1000) {
int end = Math.min(i + 1000, objectKeys.size());
List<String> subList = objectKeys.subList(i, end);
log.info("Batch deleting files, current batch: {}, file count: {}", (i / 1000) + 1, subList.size());

List<ObjectIdentifier> ids = new ArrayList<>(subList.size());
for (String key : subList) {
ids.add(ObjectIdentifier.builder().key(key).build());
}

s3Client.deleteObjects(DeleteObjectsRequest.builder()
.bucket(bucketName)
.delete(Delete.builder().objects(ids).quiet(true).build())
.build());
}
}
}

JWT Utils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* JWT utility class
*/
@Component
@Slf4j
public class JwtUtils {
private final Key key;
private final long expireTime;

// Authentication expires after 60 minutes
public JwtUtils(@Value("${jwt.token.secret}") String secret,
@Value("${jwt.token.expireTime}") long expireTime) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expireTime = expireTime;
}

/**
* Generate JWT
* @param subject
* @param claims
* @return
*/
public String generateJwt(String subject, Map<String, Object> claims) {
long now = System.currentTimeMillis();
return Jwts.builder()
.setSubject(subject) // subject is the user id (String)
.addClaims(claims == null ? Map.of() : claims) // claims include username and email
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + expireTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

/**
* Parse JWT
* @param token
* @return
*/
public Claims parse(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();

long secondsLeft = Math.max(0, (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000);
if (secondsLeft <= 600) {
log.warn("Token has less than 10 minutes remaining: {} seconds", secondsLeft);
} else {
log.debug("Token remaining: {} seconds", secondsLeft);
}
return claims;

} catch (ExpiredJwtException e) {
throw new BusinessException("Token has expired");
} catch (JwtException e) {
throw new BusinessException("Invalid token");
} catch (IllegalArgumentException e) {
throw new BusinessException("Token is null or wrong format");
}
}
}

SendGrid Email API Utils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/**
* SendGrid Email API Utils.
*/
@Component
@Slf4j
public class SendGridUtils {

private final SendGrid client;
public SendGridUtils(@Value("${sendgrid.api-key}") String apiKey) {
this.client = new SendGrid(apiKey);
}
@PreDestroy
public void close() {}

@Value("${sendgrid.from}")
private String from;

/**
* Send HTML email.
* @param to
* @param subject
* @param html
*/
public void sendHtml(String to, String subject, String html) {
Mail mail = new Mail();
mail.setFrom(new Email(from));
mail.setSubject(subject);

Personalization p = new Personalization();
p.addTo(new Email(to));
mail.addPersonalization(p);

mail.addContent(new Content("text/html", html));

Request req = new Request();
try {
req.setMethod(Method.POST);
req.setEndpoint("mail/send");
req.setBody(mail.build());
Response resp = client.api(req);
int code = resp.getStatusCode();
if (code >= 400) {
throw new RuntimeException("SendGrid error: " + code + " - " + resp.getBody());
}
} catch (Exception e) {
log.error("SendGrid sendHtml failed: to={}, subject={}", to, subject, e);
throw new RuntimeException("Email send failed", e);
}
}

/**
* Send verify email.
* @param to
* @param verifyLink
*/
public void sendVerifyEmail(String to, String verifyLink) {
String subject = "Verify your email";
String html = """
<div style="font-family:sans-serif">
<h2>Verify your email</h2>
<p>Click the button below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>If the button doesn't work, copy this URL:<br/>%s</p>
<p>This link expires in 2 hours.</p>
</div>
""".formatted(verifyLink, verifyLink);
sendHtml(to, subject, html);
}

/**
* Send reset password email.
* @param to
* @param resetLink
*/
public void sendResetEmail(String to, String resetLink) {
String subject = "Reset your password";
String html = """
<div style="font-family:sans-serif">
<h2>Reset your password</h2>
<p>If you didn't request this, you can ignore this email.</p>
<p><a href="%s">Reset Password</a></p>
<p>If the button doesn't work, copy this URL:<br/>%s</p>
<p>This link expires in 30 minutes.</p>
</div>
""".formatted(resetLink, resetLink);
sendHtml(to, subject, html);
}

/**
* Send change email verification email.
* @param to
* @param changeLink
*/
public void sendChangeEmail(String to, String changeLink) {
String subject = "Change your email";
String html = """
<div style="font-family:sans-serif">
<h2>Change your email</h2>
<p>Click the button below to change your email address:</p>
<p><a href="%s">Change Email</a></p>
<p>If the button doesn't work, copy this URL:<br/>%s</p>
<p>This link expires in 30 minutes.</p>
</div>
""".formatted(changeLink, changeLink);
sendHtml(to, subject, html);
}
}

Unsplash Image Utils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/**
* Unsplash Image Utils
*/
@Slf4j
@Component
public class UnsplashImgUtils {

private final WebClient unsplashWebClient;

public UnsplashImgUtils(WebClient.Builder builder,
@Value("${unsplash.access-key}") String accessKey) {
this.unsplashWebClient = builder
.baseUrl("https://api.unsplash.com")
.defaultHeader("Authorization", "Client-ID " + accessKey)
.build();
}

/**
* Search N image urls.
* @param q search keyword
* @param n number of images to be returned
* @param width width of the image
* @param height height of the image
* @return a list of Aws S3 URLs
*/
public List<String> getImgUrls(String q, int n, int width, int height) {
return searchNImg(q, n).stream()
.map(photo -> imgUrlFormat(photo, width, height)).toList();
}

/**
* Search N image urls (default size: 1600x900).
* @param q search keyword
* @param n number of images to be returned
* @return a list of Aws S3 URLs
*/
public List<String> getImgUrls(String q, int n){
return searchNImg(q, n).stream()
.map(photo -> imgUrlFormat(photo, 1600, 900)).toList();
}

/**
* Get N image Details (JSON) from Unsplash API.
*/
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchNImg(String query, int n) {
if(n > 10){
n = 10;
log.warn("maximum number is 10, automatically adjusted to 10");
}
if(n < 1){
throw new IllegalArgumentException("number must be greater than 0");
}
if (query == null || query.isBlank()) {
throw new IllegalArgumentException("query must not be blank");
}
final int count = n;
Map<String, Object> body = unsplashWebClient.get()
.uri(uri -> uri.path("/search/photos")
.queryParam("query", query)
.queryParam("per_page", count)
.build())
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(Map.class)
.block();

List<Map<String, Object>> results =
(List<Map<String, Object>>) (body != null ? body.getOrDefault("results", List.of()) : List.of());

if (results.isEmpty()) {
throw new NoSuchElementException("Unsplash has no result for: " + query);
}
return results;
}

/**
* Generate url and format the img.
*/
@SuppressWarnings("unchecked")
private String imgUrlFormat(Map<String, Object> photo, int width, int height) {
Map<String, Object> urls = (Map<String, Object>) photo.get("urls");
String base = (String) (urls.getOrDefault("raw",
urls.getOrDefault("full", urls.get("regular"))));
if (base == null || base.isBlank()) {
throw new IllegalStateException("No downloadable url in photo.urls");
}

String join = base.contains("?") ? "&" : "?";
// q: compress img quality 1-100
return base + join + "w=" + width + "&h=" + height + "&fit=crop&fm=jpg&q=80&auto=format";
}
}

其他

全局异常处理器

BusinessExceptionAuthExceptions 为自定义异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.demo.api.exception;

import com.demo.api.ApiRespond;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.io.IOException;
import java.util.Optional;

/**
* Global exception handler
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Handle all uncaught exceptions
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiRespond<Void> handleException(Exception e) {
log.error("Unhandled exception", e);
return ApiRespond.error("System Wrong: " + e.getMessage());
}

/**
* Handle business exceptions
*/
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiRespond<Void> handleBusinessException(BusinessException e) {
return ApiRespond.error(e.getMessage());
}

/**
* Handle parameter validation exceptions
*/
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiRespond<Void> handleIllegalArgumentException(IllegalArgumentException e) {
return ApiRespond.error("Parameter Error: " + e.getMessage());
}

/**
* Handle @Valid validation failures
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiRespond<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.orElse("Parameter Verification Failed");
return ApiRespond.error(msg);
}

/**
* Handle authentication exceptions
*/
@ExceptionHandler(AuthException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiRespond<Void> handleAuthException(AuthException e) {
return ApiRespond.error(e.getMessage());
}

/**
* Handle authorization exceptions
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiRespond<Void> handleAccessDenied(AccessDeniedException e) {
return ApiRespond.error("No Access");
}
}

统一响应结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Unified response format
* @param <T> data
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiRespond<T> {
private int code; // Response code: 1 means success, 0 means failure
private String msg; // Message. Success: success; Failure: exception message
private T data; // Returned data

public static <Z> ApiRespond<Z> success(){
ApiRespond<Z> apiRespond = new ApiRespond<>();
apiRespond.code = 1;
apiRespond.msg = "success";
return apiRespond;
}

public static <Z> ApiRespond<Z> success(Z data){
ApiRespond<Z> apiRespond = new ApiRespond<>();
apiRespond.code = 1;
apiRespond.msg = "success";
apiRespond.data = data;
return apiRespond;
}

public static <Z> ApiRespond<Z> error(String msg){
ApiRespond<Z> apiRespond = new ApiRespond<>();
apiRespond.code = 0;
apiRespond.msg = msg;
return apiRespond;
}
}