【每日云彩】APP - 上架篇

  • 记于:2026-01-06 下午
  • 地点:浙江省·温州市·家里
  • 天气:晴天

背景#

开发的事情断断续续的,前后花了大概有一个月时间,开发完成后,激励活动时间大概还剩有两个月;
接下来就是准备上架了,上架流程反而是最麻烦的。

上架准备#

首先是准备上架华为鸿蒙应用市场,开始就是因为这边的赛事和激励活动才开发的应用;
我的应用属于社区类型的,华为市场的上架资质要求如下:

1
2
3
4
(1)《安全评估报告》
(2)《安全评估报告》在全国互联网安全服务管理平台的提交结果截图且现场检查结果为“通过”或审核状态为“审核通过”
(3)ICP备案或《增值电信业务经营许可证》
(4)《计算机软件著作权登记证书》、《APP电子版权证书》或《软件著作权认证证书》(三者选一)
  • 《安全评估报告》

    1
    2
    3
    4
    5
    这个需要在【全国互联网安全管理服务平台】进行操作,底部有平台链接;
    需要登记应用的一些信息,包括安全措施描述等,然后可以选择自评估,自己负责;
    一开始这是作为个人主体下的应用,所以我是将鸿蒙应用创建在华为的个人主体账号下;
    同时《安全评估报告》也是以个人主体进行登记的;
    这些操作为后面埋了个坑~
  • ICP备案或《增值电信业务经营许可证》

    1
    2
    3
    4
    5
    支持群里说,我这社区类型的应用需要《增值电信业务经营许可证》;
    这许可证申请需要公司注资100万,还有其他一些要求,我暂时搞不定这块,为此上架的事情又搁置了一段时间;
    后来又网上查了下,说是非盈利的应用不需要《增值电信业务经营许可证》,只需ICP备案即可;
    然后又继续备案事项。

  • 《软著》

    1
    支持群里说这个可以晚点,等应用上架后再处理。

备案#

先说ICP备案的,这事情异常麻烦;
我用的是百度云服务器,就在上面提交应用的备案信息;
很快就被驳回了,理由也是社区类的应用需要《增值电信业务经营许可证》,然后才能进行ICP备案;
又提交了几次,备注里说了应用是非经营性的,都驳回了;
然后提工单,我说明了根据规定,非经营性应用无需许可证,反正对方意思是,只要是社区类型的就要许可证;
来回交涉几次,后来对方来电,我再次强调我的应用是非盈利的,并要求对方给出驳回的明确依据;
其实法条/规定本身是比较宽松的,但是服务商抓的紧,可能是为了规避责任或图省事吧;
后来对方肯定是拿不出具体驳回的规定依据,就要求我去咨询管局,拿到可以备案的证明;
然后我咨询了浙江省的通信管理局,向其明确确认非营利性应用不需要《增值电信业务经营许可证》就可以进行ICP备案;
我提取了电话录音,再次提交,这次终于是过了。

当然后面还有域名的备案,这跟应用备案里填写的域名信息是两回事,所以又花了点时间。

安全评估报告#

再来说下《安全评估报告》,这事本身还算顺利,只是等待周期比较长;
提交后,信息流转到本地公安(网管相关部门吧),电话联系询问了一些信息;
由于计划同时上架微信小程序,而那边的【社区】类目必须要公司主体才能选择,所以应用是以公司主体进行备案的;
所以公安那边说最好用公司主体重新提交,然后我就用公司主体重新提交了一份,后来审核过了;

后面华为鸿蒙应用上架的时候问题就来了,进行应用资质审核的时候,由于应用在个人主体的华为账号下,而《安全评估报告》是公司主体,导致主体不一致而驳回,当然其他应用bug方面的问题也有;
另外一个大的问题就是罪恶的审核3.5事项(底部有链接),也是活动大群里很多人吐槽的,以“应用内容单一、不丰富”之类的理由驳回,完全不匹配,完全不合理,根本就是用来卡控的手段;

主体不一致的问题,要么注册公司主体的账号,所有信息重新配置,包括包名占用问题,得先注销,还有证书之类的要重新申请,还得重新进行应用备案(证书也得备案~),非常麻烦且耗时;
或者就是进行应用迁移,在问了支持群后,说这个暂时只支持上架后的应用进行迁移;

上架事宜又搁置了一段时间;
后来活动截止时间逼近,又开始进行问题修复。

对于主体不一致的问题,正准备使用下策,自己手动一点点重新填写和配置信息(重新备案的事情肯定来不及只能延后了,会不会因为备案信息不一致被驳回也不知道);
这时无意间发现了“迁移”按钮,未上架的应用现在也支持迁移了;
再提交了迁移申请后,等了一两天,终于是审核通过并完成迁移。

接下来就是3.5事项的问题了,顺带着其他bug也修复了下,提交后反正驳回原因中一直带有3.5事项;
虽然距离月底12.31没剩几天了,但是也没办法;期间倒是把微信小程序先给上架了;

最后几天,再次提交审核,备注里强调了这是第一个版本,后续还要继续完善功能的,如果还要驳回,请明确指出具体命中哪一行哪一句规则;
然后审核了好几天,我一直催审,同时在支持群里反馈;
所幸最后一天终于是审核通过并上架了。

上架后续#

成功上架后,也是松一口气了,想着到时候有5千到1万的激励奖金,心情舒畅;
同时也在大群里说了成功上架的事;

直到晚上20点左右,刺激的来了~
一位群友问我商户服务开通了没,我脑子一懵,什么商户服务,看了他发的截图,看了活动规则,写着“若您报名的开发者账号在2025年12月31日前仍未成功开通商户服务,视为放弃激励。”;
晚上回去21点多了,赶紧申请开通商户服务,提了加急工单,但是都已经下班了,最后无果,没赶上;
然后过两天商户服务通过了,为时已晚,我在支持群里问了,也提了工单咨询,说是这种情况就是拿不到激励奖金了~

总结#

简单总结一下,这事费时费力,最后没拿到奖金,个人认为按照活动初衷,开发者有效建设鸿蒙生态,不应该在这种问题上卡脖子;
不过规则如此,愿赌服输,也是没有办法;
说起来也只能是自己的问题了,其实一开始肯定是有看到这个规则的,但是整个开发和上架周期太长了,开始不急于处理这个事情,最后几天又这么赶,忙起来就把这事给漏掉了;
鸿蒙这边的事情就先翻篇了,接下来以微信小程序那边为主,有空就迭代功能。

顺便吐槽下,开发才花了一个月,备案的时间比开发还长,难度还这么大,真是不知道说什么好。
还有微信小程序那边我也要吐槽一下,3天驳回15次,真的想骂人,还是忍住了。

然后,26年,再接再厉吧!~

链接#

【每日云彩】APP - 初版提示词

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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
## AI角色和职责定义

你是一名前端开发专家,精通 UniApp X,能够将产品需求转化为高质量、多端兼容的前端代码,严格遵循 UniApp X 技术栈和最佳实践。
同时你也是一名 UI/UX 设计专家,精通移动端界面设计与用户体验优化,能够设计出美观、易用且符合当下审美趋势的扁平风格页面。

**职责**:
- 详细理解【项目背景】、【需求概要】以及【功能详细设计】,其中【项目背景】和【需求概要】作为参考,【功能详细设计】需要严格遵循;
- 根据【UI/UX设计规范】和【代码/实现规范】高质量地生成代码;

**知识库参考**:
- 页面配置规则:pages.json(https://doc.dcloud.net.cn/uni-app-x/collocation/pagesjson.html)
- 样式规范:CSS(https://doc.dcloud.net.cn/uni-app-x/css/)
AI应自行读取并应用文档规则,无需人工额外说明。

**行为**:
- 当我提示你"开始工作"时,你要一直工作直到完成所有要求的任务,中间不能暂停询问我任何事情。
- 不需要构建和运行该项目。

## 项目背景

1. 参赛目的:该项目为参加 2025 鸿蒙创新赛的作品,需要展示高质量移动端 App 页面和良好的用户体验。
2. 赛道和核心功能:App 属于云彩图片轻社交应用,主要功能包括浏览、拍摄和分享云彩图片。

## 需求概要

### 项目目标
开发一款轻量级云彩图片社交 App,支持拍摄、浏览、分享云彩图片。

### 核心功能
- 拍云:用户可以拍摄或上传云彩照片,编辑文案、标签、地点和时间,并选择是否公开到云广场。
- 云广场:展示所有用户公开的云记,支持点赞、评论、收藏、关注互动,内容按瀑布流排列。
- 云百科:提供云的分类知识与图片展示,便于用户学习和了解各种云彩类型。
- 我的:展示用户信息、动态及社交数据(点赞、收藏、关注)。
- 登录/注册:手机号 + 短信验证码快速登录,无需额外注册流程。

### 用户与使用场景
- 目标用户:热爱拍照、分享自然景观、关注社交互动的年轻人。
- 使用场景:户外拍摄云彩、浏览朋友分享内容、参与轻量社交互动、学习云的知识。

### 视觉与交互要求
- 风格:扁平化、清新、以天空和云彩为主视觉语义。
- 操作:简单直观,关键动作有视觉反馈。
- 多端适配:支持鸿蒙、Android、iOS,未来可打包成微信小程序等平台。
- 界面可扩展性:页面布局留白合理,方便后续功能增加。

## 功能详细设计

- 定义:发布的图文内容叫做"云记";
- 以下所有页面的【页面布局】部分描述都不包含标题栏和tab栏;
- 先将框架自动生成的index页面删除掉,不用询问,直接删除;
- 应用第一个页面为【主要内容页面框架】的【拍云】tab页面;
- 一些需要登录才能使用的功能,需要跳转到登录页面,成功登录后才能使用;

1.【主要内容页面框架】
- 页面框架整体结构从上到下依次为:系统自带的标题栏、tab对应的内容页面、tab栏;
- 底部tab栏有4个;
- 每个tab包含图标和名称,图标在上面,名称在下面;

1.1 第一个tab页面:【拍云】
page命名:shootcloud
标题名称:“记录”
功能描述:展示自己发布的"云记"
页面描述:
----
页面内容主要为一个纵向列表;
列表项内容:整体布局从上到下,内容依次为"图片"、"文案"、"标签"、"时间和地点";
"图片":宽度铺满,高度自适应;
"文案":最多两行,超出展示"...";
"标签":支持多个标签,最多两行,超出展示"...";标签格式示例:"#晴天";样式:圆角,带底色和文字颜色;标签宽度为包裹文字即可,不要100%宽度!
"时间和地点":横向布局,左边"时间",右边"地点";时间格式定义:"yyyy-MM-dd HH:mm:ss";
"标签"与"时间和地点"之间加一个淡淡的、细细的分割线;
页面右下角有个悬浮的圆形"添加"按钮;
----
页面行为:
----
当点击列表项,跳转到【云记详情】页面;
当点击右下角"添加"按钮,先跳转到登录页面,登录成功后再跳转到【选择发布图片】页面;
----

1.1.1 【拍云】二级页面:【发布】
page命名:publish
标题名称:发布
功能描述:提供发布云记的编辑功能,并在页面内选择或拍摄图片
页面描述:
----
- 页面主体:
- 图片展示区域:显示当前选择的图片,如果未选择,显示占位提示“点击选择图片”;
- 文案输入框:可编辑云记文字内容,多行显示,限制行数或长度;
- 标签输入/选择区域:支持添加多个标签,样式为圆角带底色文字;
- 发布时间、发布地点显示区域:可自动获取或手动编辑;
- 勾选框:是否公开到【云广场】;
- 底部发布按钮:点击发布,提交内容并返回【拍云】tab页面;
- 高保真要求:页面整体布局清晰,控件美观,文字和按钮居中,留白合理;
----
页面行为:
----
- 点击图片区域:
- 弹出底部菜单:选项包括“从相册选择”“拍照”“取消”;
- 用户选择照片或拍照后,图片区域更新显示选中的图片;
- 用户点击取消,关闭菜单,不改变当前图片;
- 可重复点击图片区域,重新弹出选择菜单,替换已选择的图片;
- 点击返回按钮:返回【拍云】tab页面,不保存修改;
- 点击发布按钮:提交云记内容(图片、文案、标签、时间、地点、公开设置),发布成功后返回【拍云】tab页面;
- 页面无需滚动条,按钮触控区域明显,用户操作直观。
----

1.2 第二个tab页面:【云广场】
page命名:cloudsquare
标题名称:“云广场”
功能描述:展示所有人公开发布的“云记”
页面描述:
----
页面主要内容为一个纵向双列瀑布流列表;
列表项内容:总体与【拍云】页面列表项内容一致;
区别点:“标签”最多展示一行,超出部分不展示;“时间”格式使用"yyyy-MM-dd";“地点”精确到县;
“时间和地点”区域下面添加一个区域:“点赞”、“评论”、”收藏”,同时分别展示数量;
“标签”与”时间和地点”时间分割线去掉,在“时间和地点”与“点赞、评论、收藏”部分之间添加一个分割线;
----
页面行为:
----
当点击列表项,跳转到【云记详情】页面;
----

1.2.1 二级页面:【云记详情】
page命名:cloudnote-detail
标题名称:“详情”
功能描述:展示单条“云记”的详细内容及互动信息
页面描述:
----
页面主要内容从上到下依次为:
1. 云记内容:
- 图片:宽度铺满,高度自适应
- 文案:可多行,超出显示“...”
- 标签:多个标签,最多两行,圆角,带底色和文字颜色
- 发布时间与发布地点:横向布局,均分宽度,各占一半,时间格式"yyyy-MM-dd HH:mm:ss",地点精确到县
- 发布者:单独一行显示发布者昵称或头像信息
2. 互动信息区:
- 点赞、收藏、关注按钮及对应数量
- 布局整齐,可触控,点击状态有视觉反馈
3. 评论列表:
- 每个评论项包含头像、昵称、评论内容、评论时间
- 垂直排列,间距一致
4. 评论输入区:
- 固定在页面底部,不随页面内容滚动
- 包含文本输入框和发布按钮,支持评论输入和发布,发布后即时显示新评论
----
页面行为:
----
- 点击返回按钮:返回到【云广场】tab页面
- 点赞/收藏/关注:状态切换,数量更新
- 评论发布:列表即时更新显示
- 点击评论项:跳转到【用户主页】
----

1.2.2 用户主页
page命名:userhome
标题名称:“用户主页”
功能描述:展示指定用户的个人信息及动态内容
页面描述:
----
页面主体结构(从上到下):
1. 个人信息区:
- 用户头像
- 昵称
- 签名或简介
- 关注/取消关注按钮(显示在头像或昵称旁边)
2. 社交tab区:
- 三个tab:点赞、收藏、关注
- tab栏位于个人信息下方
- 点击tab切换下方对应内容页
3. 内容页:
- 位于tab栏下方,显示对应列表
- 点赞列表:每项展示图片、文案、发布日期、发布地点
- 收藏列表:每项与点赞列表一致
- 关注列表:每项展示用户头像和昵称
4. 用户统计区(可选,参考“我的”页面):
- 显示云记数量、点赞数量、收藏数量、关注数量
----
高保真要求:
----
- 布局清晰、整齐,控件美观
- 列表项间距一致,元素对齐统一
- 可触控元素(头像、列表项、tab、关注按钮)触感明显
- 页面无需滚动条,留白合理,操作直观
----
页面行为:
----
- 点击tab切换:更新下方内容页
- 点击点赞/收藏列表项:跳转到【云记详情页面】
- 点击关注列表项:跳转到对应用户主页
- 点击关注/取消关注按钮:切换关注状态,并更新关注数量
- 点击返回按钮:返回到上一个页面
----

1.3 第三个tab页面:【云百科】
page命名:cloudwiki
标题名称:“云百科”
功能描述:展示云的分类和具体的云,以及云的百科信息
页面描述:
----
顶部展示"云彩"点亮/收集进度,比如:30% 或 6/58;
页面主要内容为左右分栏布局:
- 左侧:云的分类列表,垂直排列,每个分类项显示分类名称;
- 右侧:对应分类下的云列表,纵向排列,每个云列表项包含“图片”、”云名称”、“简介”、"点亮图标";
- 列表项内容排列:左边为“图片”,右边为“云名称”和“简介”,右边的上面为”云名称”,下面为“简介”;
- “图片”:宽度自适应,保持统一比例,圆角可适当处理;
- “云名称”:单行显示,超出部分显示"...";
- “简介”:最多两行,超出部分显示"...";
- "点亮图标":如果已点亮/收集,则图标点亮,否则是暗的;
- 列表之间保持适当间距,保证整体整齐;
- 页面可滚动,左右分栏固定,右侧列表根据分类切换更新内容;
----
页面行为:
----
- 点击左侧分类项时,右侧云列表显示该分类下的云;
- 点击右侧云列表项时,跳转到【云百科详情】;
----

1.3.1 【云百科】二级页面:【云百科详情】
page命名:cloudwiki-detail
标题名称:云的名称(动态显示当前云百科项名称)
功能描述:展示选中云的详细百科信息
页面描述:
----
- 页面主体结构(从上到下):
1. 云图片展示区:
- 显示当前云的图片
- 图片自适应宽度,高度按比例显示
2. 云信息区:
- 云的介绍文本内容
- 文本支持换行和段落
- 保持文字可读性和视觉层次
3. 点亮按钮
- 如果已经点亮/收集,则图标点亮,否则是暗的
----
高保真要求:
----
- 页面布局整齐、留白合理
- 图片和文字对齐统一,视觉层次分明
- 页面控件美观,元素圆角统一
- 支持轻量动效,例如页面切换或图片加载渐入效果
----
页面行为:
----
- 返回按钮:点击返回上一个页面,即【云百科】tab页面
- 可选:如果有图片可放大或滑动查看
- 点亮按钮:当点击"点亮"按钮时,弹窗选择图片或拍摄,上传,成功后图标点亮
----

1.4 tab4页面:我的
page命名:mine
标题名称:“我的”
功能描述:展示用户个人信息及社交动态
页面描述:
----
- 页面主体结构(从上到下):
1. 个人信息区:
- 用户头像
- 昵称
- 签名或简介
“用户头像”、“昵称”、“签名或简介”全部水平居中
2. 统计信息区(新增,紧接个人信息区):
- 云记数量
- 点赞数量
- 收藏数量
- 关注数量
数字排列整齐,风格统一,保持与个人信息区和tab栏的视觉一致性
3. 社交tab区:
- 三个tab:点赞、收藏、关注
- tab栏在个人信息下方
- 点击tab切换下方对应内容页
4. 内容页:
- 位于tab栏下方,显示对应列表
- 点赞列表:每项展示图片、文案、发布日期、发布地点
- 收藏列表:每项与点赞列表一致
- 关注列表:每项展示用户头像和昵称
高保真要求:
----
- 布局清晰、整齐,控件美观
- 列表项间距一致,元素对齐统一
- 可触控元素(头像、列表项、tab)触感明显
- 页面无需滚动条,留白合理,操作直观
----
页面行为:
----
- 点击tab切换:更新下方内容页
- 点击点赞/收藏列表项:跳转到【云记详情】页面
- 点击关注列表项:跳转到【用户主页】页面
- 点击头像、昵称区域:跳转到【个人信息编辑】页面
----

1.4.1 【个人信息编辑】页面
page命名:editProfile
标题名称:“编辑个人信息”
功能描述:允许用户编辑个人基础信息,包括昵称、头像、性别、生日、心情想法
页面描述:
----
- 页面主体结构(从上到下):
1. 头像编辑区:
- 显示当前头像,点击可更换头像(弹出拍照或相册选择菜单)
2. 昵称编辑:
- 输入框,显示当前昵称,可修改
3. 性别选择:
- 单选控件,选项包括“男”“女”“未知”
4. 生日选择:
- 日期选择器控件
5. 心情想法:
- 多行文本输入框,用于填写当前心情或简介
6. 保存按钮:
- 固定在页面底部,点击保存修改并返回上一个页面
----
高保真要求:
----
- 布局整齐,控件对齐统一
- 输入框、选择器、按钮风格美观,圆角统一
- 可触控元素触感明显
- 页面无需滚动条,留白合理,操作直观
----
页面行为:
----
- 点击头像:弹出选择菜单(拍照/相册/取消)
- 点击保存按钮:提交修改,返回上一个页面
- 输入框、选择器内容可编辑
----

2. 登录与注册
page命名:login
标题名称:"登录"
功能描述:提供用户通过手机号 + 短信验证码方式进行登录;未注册用户会自动注册
页面描述:
----
页面主体结构(从上到下):
1. Logo区:
- 显示应用 Logo
2. 输入区:
- 手机号输入框
- 短信验证码输入框
- 短信验证码发送按钮(在输入框旁或下方)
3. 登录按钮区:
- 登录按钮,点击后触发登录逻辑
高保真要求:
----
- 布局简洁、整齐,元素对齐统一
- 输入框、按钮可触控,反馈明显
- 留白合理,视觉清爽,符合移动端操作习惯
- 页面无需滚动条
----
页面行为:
----
- 点击短信验证码发送按钮:触发发送验证码逻辑,并显示倒计时
- 点击登录按钮:校验手机号与验证码,完成登录操作
----

## UI/UX设计

1.整体目标
- 界面必须 **高保真展示级**,视觉效果接近最终发布效果;
- 页面**布局保持整齐**,**不允许错位**、重叠或留白混乱;
- 元素必须经过基本美化,不允许粗糙的默认样式。

2.视觉风格
- 建议使用扁平化、清新明亮风格;主色调可选天空蓝 + 白色,强调色可适度使用(黄色/珊瑚色);
- 背景保持简洁留白,避免杂乱;
- 图片、按钮、卡片等圆角建议统一风格,具体数值可根据整体比例调整;
- 元素对齐保持一致(左/右/居中),无需严格死板数值。

3.字体与排版
- 字体尽量统一,保持现代、轻盈风格;
- 标题、正文、辅助文字建议有层次区分,但具体字号可根据整体视觉调整;
- 行高、颜色应保证可读性,但不必死板指定固定值。

4.间距与布局
- 保持组件间距和区块间距规则化,避免过宽或过窄;
- 列表、卡片等内部元素保持相对统一的间距;
- 间距和 padding 可根据屏幕尺寸、内容和整体视觉灵活调整。

5.交互控件
- 按钮、输入框、图标等控件应保证可触控性和基本美观;
- 控件应有三态反馈(默认 / 按下 / 禁用)或类似效果;
- 文本应可读,按钮文字居中即可,无需死板指定高度或内边距。

6.列表与卡片
- 卡片可使用阴影或浅边框,背景可选白色或浅色;
- 列表项和多列布局应整齐、间距规则,但具体数值可灵活调整;
- 元素对齐、列间距、边距保持视觉统一即可。

7.动效与反馈
- 页面切换、按钮点击、列表刷新等建议有轻量动效;
- 用户操作反馈可用 Toast、轻动画等形式提示。

8.多端适配
- 以竖屏手机为主,保证鸿蒙/Android/iOS 常见分辨率下布局不出错;
- 避免控件溢出或重叠,但无需严格死板单位或数值。

## 代码/实现规范

重要说明:以下规范在生成每个页面和组件时必须严格遵守,所有输出内容应完全符合 UniApp X 技术栈要求,并与当前项目框架保持一致。

## 0. 全局规则
- **图片资源**:所有图片暂时使用统一地址:`https://fastly.picsum.photos/id/451/400/300.jpg?hmac=MR3Xc5ScMz4tYwIT81kKiuPghEay-uM4bjF4Z5MLNd4`
- **图标规范**:所有图标暂时使用文字emoji图标
- **项目结构**:严格遵循现有框架结构,不重复创建pages.json等配置文件
- **滚动条**:所有页面禁用滚动条显示
- **布局安全**:所有flex容器必须显式设置`flex-direction`方向属性

## 1. 技术栈与布局规范
- **UniApp X最佳实践**:严格遵循当前项目结构和技术栈要求
- **CSS布局强制要求**:
- 所有`display: flex`容器必须显式设置`flex-direction: row`或`flex-direction: column`
- 横向布局元素必须包含`flex-direction: row`
- 纵向布局元素必须包含`flex-direction: column`
- 禁止依赖CSS默认值,确保多端兼容性

## 2. 项目文件与组件规范
- **文件结构**:保持现有pages.json配置,不创建冗余文件
- **组件复用**:避免重复创建功能相同的组件,优先使用现有结构
- **标题栏**:统一使用框架自带标题栏,通过pages.json配置按钮功能

## 3. 代码质量与维护规范
- **中文注释**:关键交互、样式、API替换点必须添加清晰中文注释
- **类型安全**:
- 数组初始化为`[]`,对象初始化为`{}`
- 避免TS风格类型断言,确保UTS编译正常
- **数据接口**:
- 模拟数据包含完整字段结构,便于后续接口替换
- 保持接口设计简单明了,具备良好扩展性

## 4. 布局检查清单(强制验证)
在生成每个页面后必须验证:
- [ ] 所有flex容器显式设置了flex-direction
- [ ] 横向布局元素正确使用`flex-direction: row`
- [ ] 纵向布局元素正确使用`flex-direction: column`
- [ ] 多列布局设置了正确的flex-wrap属性
- [ ] 布局元素对齐方式明确指定

## 5. 预防性措施
- **模板化开发**:为常见布局模式建立标准CSS模板
- **自动化验证**:在代码生成阶段自动添加必要的布局属性
- **跨平台测试**:确保布局在鸿蒙、iOS、Android平台一致性

那么,"开始工作"吧!

【每日云彩】APP - 开发篇

  • 记于:2026-01-04 下午
  • 地点:浙江省·温州市·家里
  • 天气:多云

背景#

一开始是为了参加华为鸿蒙极客松比赛而开发的,大概是25年5月份左右;
时间上比较仓促,剩最后10来天才知道这个比赛,然后实际花了一个星期左右才勉强完成前后端开发;
app是用鸿蒙原生开发的,之前有看过一些官方教程;
总之最后是没有入围,意料之中,我感觉是我审错题了~;

搁置了一段时间,后来又有鸿蒙的激励计划,截止25年年底活动还剩3个多月,也就是9月、10月份左右知道这个活动的;
这时候是准备用uniappx重新开发,它支持鸿蒙,同时也想着可以发布到微信小程序;
接下来就是开始探索开发了。

功能介绍#

一句话介绍:“一款针对云彩相关主题的图文社交APP;主要功能有:发布和浏览图文、点赞评论收藏关注等互动、云百科信息查看、个人主页等。”
底部有鸿蒙应用商店的链接,这里就不贴图了。
微信上搜索【每日云彩】也能搜到小程序版本。

功能上其实没有做什么调研和思考,会不会有用户喜欢也不清楚,就是自己想了一下,做按自己的需求和理解做了;
现在既然两个平台都已经上架了,这样我也有动力继续迭代下去,哪怕没什么人用;
目前只是初版,功能也比较简单,后续还是会用业余时间逐步迭代,不过具体功能的话可能还是以自己的需求为主,如果有真实用户提需求和建议的话,我也会考虑的。

开发过程#

对于前端部分,之前开发过微信小程序,也懂一点vuejs,uniapp也接触过一些,大概属于需要搜索引擎和AI辅助的程度;
uniapp和uniappx都支持鸿蒙,对比了一下,网上查查,AI问问,最终是选择了uniappx;
然后,全手写是不可能的~整个app端几乎90%以上是AI写的;

万事开头难,开始阶段,用AI提示词生成的效果,真是符合不了一点预期;
我也是第一次用提示词做项目,没啥经验,提示词写的基本也是很随意、口语化的、无结构的;
然后就是问AI,说是用模板,让AI给了模板参考,效果有好一些,不过最重要的还是对具体需求的描述,起码要像需求文档覆盖到各个功能点,然后细化描述功能点;
这时候出来的效果能达到我大概60%的预期了,只是一些页面会有布局错乱的问题;

然后就是需要显式添加一些规则和约束,因为哪怕一开始就定义过了AI的角色和指责,也会出现不听话的情况,所以一些点需要对其进行强调;
经过多次提示词调优,生成的效果能达到预期的80%,这时候我认为就差不多了,达到一个“提示词投入/效果产出”性价比不错的状态,继续优化提示词也没法获取大幅的效果提升了;

接下来就是跟AI进行多伦对话来完善了,同时集成接口,然后还有优化。
后端部分就不多说了,主要就是配合前端业务写一些接口,然后应上架要求,加上内容审核机制。

一些细节:

1
2
3
4
5
6
提示词的效果不仅仅取决于提示词本身,还取决于使用的IDE以及不同大模型和不同版本,甚至完全相同的情况下前后分别跑两次,出来的效果也不完全相同;
对于IDE,我先后使用了多个,包括:腾讯的CodeBuddy、阿里的Qoder、字节的Trae,当然还有vscode里的GithubCopilot、Cursor等等,市面上大部分主流的应该都用了;
一开始腾讯的CodeBuddy效果非常不错,但是在一次升级后,感觉变得弱智了,不知道是不是我的错觉;
阿里的Qoder也不错,只是免费额度不太够;
字节的Trae也不错,目前是我的免费主力~
GithubCopilot和Cursor主要也是额度问题,效果我忘了,用的没有前几个那么多;
1
2
3
还有就是,开发的时候先创建一个初始脚手架,比如uniappx,官方提供了基础的“Hello uni-app x”模板;
如果直接让AI根据提示词生成代码的话,由于缺少“框架”部分的信息,会导致效果不符合预期,需要在提示词中额外补充;
而在脚手架项目下再进行生成的话,可以提示AI让其写法、风格参考当前项目,省事一些。

最后在底部附上【初版提示词】链接(后来又微调了几版,没有保存)。

链接#

第三方表单服务Formspree简单使用

  • 记于:2025-12-04 晚上
  • 地点:浙江省·温州市·家里
  • 天气:晴天

背景#

做了个公司官网,静态版本的,唯一的一个数据交互是“提交需求表单”;
又不想为此部署一套“重量级”的后端服务,找了些第三方的集成方案,包括飞书、钉钉等平台都有通知API;
但是不太想依赖这些,后来找到了个纯粹一些的第三方表单服务(Formspree,底部有官网链接);
使用也是比较简单的,只要将表单提交到特定地址,就能收到邮件通知。

集成#

1.注册登录后,先创建一个项目(默认也会有一个项目):
image1

2.创建一个表单,填入表单名称、选择刚才创建的项目、接收通知的邮箱:
image2

3.在表单的[Workflow]->[Validations]添加表单相应的字段名称,每个字段可以设置为是否必填、数据类型以及内容长度范围:
image3
注意:email字段是平台预置的,无法删除,但是可以修改是否必填
提示:这个[Validations]其实可以不用设置,需要校验字段的话设置一下
提示:下面的[Actions]可以设置表单提交后的动作,比如提醒方式、集成其他系统之类的

4.网页里表单设置action地址:
image4
提示:action地址是在[Overview]标签页里获取

5.效果:
image5
提示:创建表单时填写的那个邮箱也会收到通知邮件

链接#

API限流设计

  • 记于:2025-11-24 晚上
  • 地点:浙江省·温州市·家里
  • 天气:晴天

背景#

个人开源项目[yeah-boot]需要一个流控/限流组件;
虽然有一些成熟的开源流控组件,比如Alibaba Sentinel、Bucket4j、Guava RateLimiter等;
但就是想自己造个轮子。

功能#

“限流”与“流控”的关联与区别(by AI):

1
2
3
限流(Rate Limit)是控制单位时间内请求数量的策略,核心是限制流量,通常超限就拒绝请求。
流控(Flow Control)是更广义的概念,除了限流,还可以排队、削峰、降级或动态调节,目标是保证系统整体稳定。
一句话总结:限流是流控的一部分,流控是系统稳定性保护的综合手段。

我做的偏向于“限流”,但是模块叫“流控”,方便日后朝“流控”方向扩充功能;

主要功能:

  • 支持“每N秒最多M个请求”
  • 支持“每个分组内每N秒最多支持M个请求”
  • 支持“每N秒内最多M个分组数量”
  • 支持按IP分组,也支持自定义分组(支持spel)

设计思路#

核心思路:基于时间窗口内的请求记录与统计做控制。

主要配置参数#

1
2
3
4
5
6
7
8
9
10
11
12
boolean enabled() default true; // 是否启用
String name() default ""; // 名称,默认为方法的全限定名
GroupType groupType() default GroupType.NONE; // 分组方式:1-无 2-按IP 3-按用户
String customGroup() default ""; // 自定义分组逻辑,配合GroupType.CUSTOM使用;支持spel表达式
int limitCount() default -1; // 限制数量
int limitGroup() default -1; // 限制分组数量
int limitGroupCount() default -1; // 限制每个分组内数量
int timeWindow() default 1000; // 时间窗口,单位:毫秒
int bucketSize() default 100; // 时间窗口内桶大小,单位:毫秒,默认:100
boolean dynamicTimeWindow() default true; // 是否动态时间窗口模式
boolean global() default false; // 是否全局限流 TODO 后续支持
String description() default ""; // 描述

使用示例#

1
2
3
4
5
6
@PublicAccess
@RateLimit(timeWindow = 2000, limitCount = 5, description = "限制2秒内最多5个请求")
@GetMapping("/test")
public String test() {
return "hello world";
}

非分组方式的限流#

主要参数:limitCount, timeWindow, bucketSize

大概思路:
使用滑动时间窗口,将timeWindow窗口大小按照bucketSize大小每个桶进行均分;
将窗口做成动态一直向前滚动的轮状数据结构(需要进行滚动(滑动)操作);
每个请求到来时,先重置过期桶的计数,然后将当前请求落入到对应的桶中;
计算当前所有桶的计数,判断是否超限。
个人感觉轮状机制+过期重置的逻辑有些难理解,有点很细致的东西在里面,具体见下方代码。

分组方式的限流#

主要参数:limitGroupCount, timeWindow, bucketSize, groupType, customGroup

大概思路:
大概的思路与【非分组方式的限流】一致;
只不过多个一个分组维度。
具体见下方代码。

针对分组数量的限流控制#

主要参数:limitGroup, timeWindow

大概思路:
时间窗口内,记录每个分组的最新一次请求时间,计算窗口内分组数,判断是否超限。

其他#

在代码实现上要尽量保证准确性,需要在一些操作上加锁,也要兼顾性能,锁粒度不能太大;
之前的一个版本,是将请求的时间都记录下来,基于此做统计和判断;
但是数据量大的时候会有存储和性能问题,而且没加锁,有并发下的准确性问题;
后来询问AI做了参考,使用了常用的以计数方式(AtomicInteger)为主,同时进行了加锁。
具体见下方代码。

代码实现#

配置参数类#

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
package com.yeshimin.yeahboot.flowcontrol.ratelimit;

import com.yeshimin.yeahboot.flowcontrol.enums.GroupType;

import java.lang.annotation.*;

/**
* 限流注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

/**
* 是否启用
*/
boolean enabled() default true;

/**
* 名称,默认为方法的全限定名
*/
String name() default "";

/**
* 分组方式:1-无 2-按IP 3-按用户
*/
GroupType groupType() default GroupType.NONE;

/**
* 自定义分组逻辑,配合GroupType.CUSTOM使用;支持spel表达式
*/
String customGroup() default "";

/**
* 限制数量
*/
int limitCount() default -1;

/**
* 限制分组数量
*/
int limitGroup() default -1;

/**
* 限制每个分组内数量
*/
int limitGroupCount() default -1;

/**
* 时间窗口,单位:毫秒
*/
int timeWindow() default 1000;

/**
* 时间窗口内桶大小,单位:毫秒,默认:100
*/
int bucketSize() default 100;

/**
* 是否动态时间窗口模式
*/
boolean dynamicTimeWindow() default true;

/**
* 是否全局限流 TODO
*/
boolean global() default false;

/**
* 描述
*/
String description() default "";
}

主要逻辑代码#

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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
package com.yeshimin.yeahboot.flowcontrol.ratelimit;

import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
* RateLimit 拦截器
*/
@Slf4j
@Component
public class RateLimitInterceptor implements HandlerInterceptor {

private static final ExpressionParser PARSER = new SpelExpressionParser();
private static final StandardEvaluationContext CONTEXT = new StandardEvaluationContext();

// map<res, window>
private final Map<String, SlidingWindow> plainHolder = new ConcurrentHashMap<>();
// map<res, map<group, latestReqTime>>
private final Map<String, ConcurrentHashMap<String, Long>> outerGroupHolder = new ConcurrentHashMap<>();
// map<res, map<group, window>>
private final Map<String, ConcurrentHashMap<String, SlidingWindow>> innerGroupHolder = new ConcurrentHashMap<>();

// 每5分钟执行一次清理任务
private static final long CLEAN_INTERVAL_MS = 5 * 60 * 1000;
// 10分钟无请求则清理
private static final long GROUP_EXPIRE_MS = 10 * 60 * 1000;

@Autowired(required = false)
private RateLimitService rateLimitService;

@PostConstruct
public void init() {
log.debug("init [yeah-boot] rate limit interceptor...");
}

private static final ScheduledExecutorService CLEANER =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "RateLimitCleaner");
t.setDaemon(true);
return t;
});

// constructor
public RateLimitInterceptor() {
CLEANER.scheduleAtFixedRate(this::cleanExpiredGroups,
CLEAN_INTERVAL_MS, CLEAN_INTERVAL_MS, TimeUnit.MILLISECONDS);
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
log.debug("Not a method handler, skip");
return true;
}

HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);

// 没有注解或禁用限流
if (rateLimit == null || !rateLimit.enabled()) {
log.debug("No rate limit annotation or disabled, skip");
return true;
}

// --------------------------------------------------------------------------------

// 获取限流配置
RateLimitConf rlConf = this.getRateLimitConf(rateLimit, method, request);
log.debug("rlConf: {}", rlConf);
if (rlConf.isSkip()) {
log.debug("Skip rate limit, skip");
return true;
}

// --------------------------------------------------------------------------------

// check limitCount
SlidingWindow plainWindow = null;
if (rlConf.getLimitCount() > -1) {
log.debug("check for [limitCount]");

plainWindow = plainHolder.computeIfAbsent(rlConf.getResName(), k -> new SlidingWindow(rlConf, "[limitCount]"));
log.debug("plainWindow: {}", plainWindow);

boolean allowed = plainWindow.tryAcquire(rlConf.getLimitCount(), rlConf.getReqTime());
if (!allowed) {
log.debug("Rate limit exceeded: limitCount");
response.setStatus(429);
response.getWriter().write("Rate limit exceeded: limitCount");
return false;
}
}

// check limitGroup
ConcurrentHashMap<String, Long> outerGroupMap = null;
if (rlConf.getLimitGroup() > -1) {
log.debug("check for [limitGroup]");
outerGroupMap = outerGroupHolder.computeIfAbsent(rlConf.getResName(), k -> new ConcurrentHashMap<>());

// 删除过期的分组
outerGroupMap.entrySet().removeIf(entry -> {
long reqTime = entry.getValue();
if (reqTime < rlConf.getWindowFrom()) {
log.debug("Remove expired group:{}, windowFrom: {}, reqTime: {}", entry.getKey(), rlConf.getWindowFrom(), reqTime);
return true;
}
return false;
});

// 判断是否超限
boolean allowed;
if (outerGroupMap.containsKey(rlConf.getGroupName())) {
allowed = outerGroupMap.size() <= rlConf.getLimitGroup();
} else {
allowed = outerGroupMap.size() + 1 <= rlConf.getLimitGroup();
}

if (!allowed) {
log.debug("Rate limit exceeded: limitGroup");
response.setStatus(429);
response.getWriter().write("Rate limit exceeded: limitGroup");
return false;
}
}

// check limitGroupCount
SlidingWindow innerGroupWindow = null;
if (rlConf.getLimitGroupCount() > -1) {
log.debug("check for [limitGroupCount]");

ConcurrentHashMap<String, SlidingWindow> map = innerGroupHolder.computeIfAbsent(rlConf.getResName(), k -> new ConcurrentHashMap<>());
innerGroupWindow = map.computeIfAbsent(rlConf.getGroupName(), k -> new SlidingWindow(rlConf, "[limitGroupCount]"));
log.debug("innerGroupWindow: {}", innerGroupWindow);

boolean allowed = innerGroupWindow.tryAcquire(rlConf.getLimitGroupCount(), rlConf.getReqTime());

if (!allowed) {
log.debug("Rate limit exceeded: limitGroupCount");
response.setStatus(429);
response.getWriter().write("Rate limit exceeded: limitGroupCount");
return false;
}
}

// 仅当通过所有限流检查,记录请求
if (rlConf.getLimitCount() > -1) {
log.debug("Record request for [limitCount]");
Objects.requireNonNull(plainWindow).increase(rlConf.getReqTime());
}
if (rlConf.getLimitGroup() > -1) {
log.debug("Record request for [limitGroup]");
Objects.requireNonNull(outerGroupMap).put(rlConf.getGroupName(), rlConf.getReqTime());
}
if (rlConf.getLimitGroupCount() > -1) {
log.debug("Record request for [limitGroupCount]");
Objects.requireNonNull(innerGroupWindow).increase(rlConf.getReqTime());
}

return true;
}

/**
* 获取RateLimitConf配置
*/
private RateLimitConf getRateLimitConf(RateLimit rl, Method m, HttpServletRequest req) {
RateLimitConf conf = new RateLimitConf();
conf.setEnabled(rl.enabled());
conf.setName(rl.name());
conf.setGroupType(rl.groupType());
conf.setCustomGroup(rl.customGroup());
conf.setLimitCount(rl.limitCount());
conf.setLimitGroup(rl.limitGroup());
conf.setLimitGroupCount(rl.limitGroupCount());
conf.setTimeWindow(rl.timeWindow());
conf.setBucketSize(rl.bucketSize());
conf.setDynamicTimeWindow(rl.dynamicTimeWindow());
conf.setGlobal(rl.global());

// 修整参数
if (conf.getLimitCount() < -1) {
conf.setLimitCount(-1);
}
if (conf.getLimitGroup() < -1) {
conf.setLimitGroup(-1);
}
if (conf.getLimitGroupCount() < -1) {
conf.setLimitGroupCount(-1);
}

// 请求时间
conf.setReqTime(System.currentTimeMillis());

// 修整参数(最小时间窗口为1000毫秒)
if (conf.getTimeWindow() < 1000) {
conf.setTimeWindow(1000);
}
// 最小桶大小为100毫秒,且必须能整除时间窗口
if (conf.getBucketSize() < 100) {
conf.setBucketSize(100);
}

// 计算窗口时间范围
this.calcWindowTimeRange(conf);

// 资源名称
conf.setResName(this.getResName(conf, m));
// 分组名称
conf.setGroupName(this.getGroupName(conf, m, req));

return conf;
}

/**
* 计算时间窗口起始时间
* 根据是否动态时间窗口选择计算方式
*/
private void calcWindowTimeRange(RateLimitConf rlConf) {
if (rlConf.isDynamicTimeWindow()) {
rlConf.setWindowFrom(rlConf.getReqTime() - rlConf.getTimeWindow());
rlConf.setWindowTo(rlConf.getReqTime());
} else {
rlConf.setWindowFrom(rlConf.getReqTime() / rlConf.getTimeWindow() * rlConf.getTimeWindow());
rlConf.setWindowTo(rlConf.getWindowFrom() + rlConf.getTimeWindow());
}
}

/**
* 获取目标资源的名称(一般为接口方法的全限定名称或者自定义名称)
*/
private String getResName(RateLimitConf rlConf, Method m) {
return rlConf.getName() == null || rlConf.getName().trim().isEmpty() ?
m.getDeclaringClass().getName() + "." + m.getName() : rlConf.getName();
}

/**
* 滑动窗口实现(环形桶)
*/
private static class SlidingWindow {
private final int windowSizeMs; // 整体窗口大小
private final int bucketSizeMs; // 每个桶的时间长度
private final int bucketCount; // 桶数量
private final AtomicInteger[] buckets; // 每个桶计数
private volatile long lastUpdateTime; // 最后更新时间(毫秒)
private volatile long lastGlobalBucketIndex; // 最后更新全局桶索引

@Getter
@Setter
private volatile long lastReqTime;

private String logFlag;

public SlidingWindow(RateLimitConf rlConf, String logFlag) {
this.windowSizeMs = rlConf.getTimeWindow();
this.bucketSizeMs = rlConf.getBucketSize();
// 最好能整除,否则行为未知
this.bucketCount = this.windowSizeMs / this.bucketSizeMs;
this.buckets = new AtomicInteger[bucketCount];
for (int i = 0; i < bucketCount; i++) {
buckets[i] = new AtomicInteger(0);
}
this.lastUpdateTime = System.currentTimeMillis();
this.lastGlobalBucketIndex = lastUpdateTime / this.bucketSizeMs;

// 上次请求时间
this.lastReqTime = System.currentTimeMillis();

this.logFlag = logFlag;
}

public synchronized boolean tryAcquire(int limitCount, long reqTime) {
this.setLastReqTime(reqTime);
this.slideWindow(reqTime);

if (limitCount >= 0 && this.calcTotalCount() >= limitCount) {
return false;
}
return true;
}

public void increase(long reqTime) {
// 当前时间对应的桶++
int index = (int) ((reqTime / bucketSizeMs) % bucketCount);
buckets[index].incrementAndGet();
// print buckets
log.debug("{} buckets: {}", logFlag, Arrays.toString(buckets));
}

/**
* 滑动窗口更新:清理过期桶
*/
private void slideWindow(long reqTime) {
// 计算请求时间的全局桶索引
long globalBucketIndex = reqTime / bucketSizeMs;
long globalBucketsPassed = globalBucketIndex - lastGlobalBucketIndex;

// log
log.debug("{} slideWindow: reqTime={}, globalBucketIndex={}, globalBucketsPassed={}, lastGlobalBucketIndex={}",
logFlag, reqTime, globalBucketIndex, globalBucketsPassed, lastGlobalBucketIndex);

// 如果时间没有过去一个桶的大小,直接返回
if (globalBucketsPassed <= 0) {
log.debug("{}, no need to slide", logFlag);
return;
}

// 如果时间过去超过所有桶,则全部过期
if (globalBucketsPassed >= bucketCount) {
log.debug("{} clear all", logFlag);
for (AtomicInteger b : buckets) {
b.set(0);
}
} else {
// 从lastBucketIndex后一个开始正向清除bucketsPassed个桶
for (int i = 1; i <= globalBucketsPassed; i++) {
int index = (int) ((lastGlobalBucketIndex + i) % bucketCount);
log.debug("{} clear index: {}", logFlag, index);
buckets[index].set(0);
}
}

lastUpdateTime = reqTime;
lastGlobalBucketIndex = reqTime / bucketSizeMs;
log.debug("{} slideWindow: lastUpdateTime={}, lastGlobalBucketIndex={}", logFlag, lastUpdateTime, lastGlobalBucketIndex);
}

/**
* 统计所有桶总和
*/
private int calcTotalCount() {
int total = 0;
for (AtomicInteger b : buckets) {
total += b.get();
}
return total;
}
}

/**
* 清理过期的 group,防止 innerGroupHolder 无限制增长
*/
private void cleanExpiredGroups() {
long now = System.currentTimeMillis();

try {
log.debug("[RateLimitCleaner] 开始清理过期 group... now={}", now);

innerGroupHolder.forEach((resName, groupMap) -> {
// 遍历 groupMap,但删除时使用 CAS,不直接 removeIf
for (Map.Entry<String, SlidingWindow> entry : groupMap.entrySet()) {
String group = entry.getKey();
SlidingWindow window = entry.getValue();

long lastReq = window.getLastReqTime();
boolean expired = (now - lastReq > GROUP_EXPIRE_MS) && (now - lastReq > window.windowSizeMs);
log.debug("[RateLimitCleaner] group={} (resName={}) lastReq={}, expiredMs={}, GROUP_EXPIRE_MS={}, windowSizeMs={}",
group, resName, lastReq, now - lastReq, GROUP_EXPIRE_MS, window.windowSizeMs);
if (expired) {
// CAS 删除:确保删除的是当前 value,防止误删
boolean removed = groupMap.remove(group, window);
if (removed) {
log.debug("[RateLimitCleaner] 已清理 group={} (resName={}) lastReq={}, expiredMs={}",
group, resName, lastReq, now - lastReq);
}
}
}
});

} catch (Exception e) {
log.error("[RateLimitCleaner] 清理异常", e);
}
}

/**
* 获取分组名称
*/
private String getGroupName(RateLimitConf rlConf, Method m, HttpServletRequest request) {
switch (rlConf.getGroupType()) {
case IP:
return request.getRemoteAddr();
case CUSTOM:
return this.getCustomGroupName(rlConf.getCustomGroup(), request, m);
case NONE:
default:
return String.valueOf(rlConf.getWindowFrom());
}
}

/**
* 获取自定义分组名称
* 支持:
* 1. 普通字符串(不以 # 开头),直接返回
* 2. SpEL 表达式(以 # 开头),解析返回
*/
private String getCustomGroupName(String customGroup, HttpServletRequest request, Method method) {
if (customGroup == null || customGroup.trim().isEmpty()) {
log.warn("自定义分组名称不能为空");
throw new RuntimeException("自定义分组名称不能为空");
}

// 1. 普通字符串,直接返回
if (!customGroup.trim().startsWith("#")) {
return customGroup;
}

// 2. SpEL 表达式 —— 动态解析
try {
// 上下文变量,可扩展
CONTEXT.setVariable("request", request);
CONTEXT.setVariable("method", method);

// rls = Rate Limit Service
CONTEXT.setVariable("rls", rateLimitService);

Object val = PARSER.parseExpression(customGroup).getValue(CONTEXT);
if (val == null) {
log.warn("解析分组名称失败");
throw new RuntimeException("解析分组名称失败");
}
log.debug("SpEL 解析分组名称成功:{}", val);
return String.valueOf(val);
} catch (Exception e) {
log.warn("SpEL 解析分组名称失败");
throw new RuntimeException("解析分组名称失败");
}
}
}

其他的一些辅助类#

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
package com.yeshimin.yeahboot.flowcontrol.ratelimit;

import com.yeshimin.yeahboot.flowcontrol.enums.GroupType;
import lombok.Data;

/**
* RateLimit 配置类
*/
@Data
class RateLimitConf {

private boolean enabled;

private String name;

private GroupType groupType;

private String customGroup;

private int limitCount;

private int limitGroup;

private int limitGroupCount;

// 时间窗口,单位:毫秒
private int timeWindow;

// 时间窗口内桶大小,单位:毫秒
private int bucketSize;

private boolean dynamicTimeWindow;

private boolean global;

// --------------------------------------------------------------------------------

// 计算得出
private long windowFrom;

// 计算得出
private long windowTo;

// 请求时间
private long reqTime;

// 计算得出
private String resName;

// 计算得出
private String groupName;

public boolean isSkip() {
return !enabled || (limitCount < 0 && limitGroup < 0 && limitGroupCount < 0);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.yeshimin.yeahboot.flowcontrol.ratelimit;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

public interface RateLimitService {

String getGroupName(HttpServletRequest request, Method method);

String getUserId(HttpServletRequest request, Method method);

String getIp(HttpServletRequest request, Method method);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.yeshimin.yeahboot.flowcontrol.enums;

import lombok.Getter;

/**
* 分组方式:1-无 2-按IP 3-自定义
*/
@Getter
public enum GroupType {

NONE(1),
IP(2),
CUSTOM(3);

private final Integer value;

GroupType(Integer value) {
this.value = value;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.yeshimin.yeahboot.flowcontrol;

import com.yeshimin.yeahboot.flowcontrol.ratelimit.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class FlowControlWebMvcConfig implements WebMvcConfigurer {

private final RateLimitInterceptor rateLimitInterceptor;

public FlowControlWebMvcConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**");
}
}

测试#

此处只贴上【非分组方式的限流】的测试结果。

限制2秒内最多5个请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
% curl 'http://localhost:8080/app/demo/ratelimit/test'
hello world

% curl 'http://localhost:8080/app/demo/ratelimit/test'
hello world

% curl 'http://localhost:8080/app/demo/ratelimit/test'
hello world

% curl 'http://localhost:8080/app/demo/ratelimit/test'
hello world

% curl 'http://localhost:8080/app/demo/ratelimit/test'
hello world

% curl 'http://localhost:8080/app/demo/ratelimit/test'
Rate limit exceeded: limitCount

% curl 'http://localhost:8080/app/demo/ratelimit/test'
Rate limit exceeded: limitCount

后续#

目前够用,后续按需扩展功能;
比如目前是单机,后续支持global分布式限流。

项目地址#

Minix操作系统简介及安装

  • 记于:2025-11-01 晚上
  • 地点:浙江省·温州市·家里
  • 天气:阴天

背景#

经常会想着搞搞程序员的三大浪漫(操作系统、编译原理、图形学),奈何工作与生活事务繁杂、压力重大,像这种对个人”不太重要“的事情容易一直拖延,遥遥无期;
所以先简单起个头,总比一直空想要好,待有时间时慢慢推进;
先从操作系统开始,咨询了下AI,结合自己的目标(做出一个带图形界面的操作系统),给出的建议和学习路径是先学习minix;
接下来简单介绍下minix以及虚拟机安装。

简单介绍 (by AI)#

🧠 什么是 MINIX 3?

MINIX 3 是一个 免费、开源(open-source) 的操作系统,设计目标是 高可靠性、高灵活性与高安全性。
它由著名计算机科学家 Andrew S. Tanenbaum 教授 领导开发,是现代 微内核(microkernel)架构 操作系统的重要代表之一。

⚙️ 系统架构

MINIX 3 采用极小的 微内核 运行在内核态(kernel mode),
而其余大部分操作系统功能——如驱动、文件系统、网络栈等——都以 独立、受保护的进程 形式运行在 用户态(user mode)。

这种设计带来以下优点:

🧩 模块化高:每个系统服务都是独立进程,可单独崩溃、重启。

🛡️ 更安全:驱动和系统服务之间隔离,避免单点故障。

🔁 高可靠性:MINIX 3 能自动检测并重启失效的服务,实现“自我修复”。

💻 平台与兼容性

MINIX 3 支持 x86 与 ARM 架构,
并与 NetBSD 兼容,可直接运行成千上万的 NetBSD 软件包。
这让它不仅适合教学与研究,也能用于嵌入式设备、容错系统等应用场景。

📚 教学与社区

MINIX 3 最初是为教学和研究操作系统原理而生,
因其 源码简洁清晰、架构严谨、文档完善,
被全球众多高校选作操作系统课程实验平台。

它拥有一个活跃的开源社区,
你可以直接下载源码、运行于虚拟机(如 QEMU / VirtualBox),
甚至参与内核与驱动的开发。

🚀 结语

MINIX 3 以极简的内核设计展示了操作系统的本质:

“保持小巧,保持可靠。”

它不仅是一款研究级操作系统,更是一种对高可靠计算理念的实践。
无论你是操作系统爱好者、嵌入式开发者,
还是想深入理解微内核思想的学生,
MINIX 3 都是一个理想的起点。

安装#

我是在macos环境,使用qemu方式安装;

官方文档里安装指令无法成功,可能是指令版本太老了,我安装的qemu版本为:

1
2
3
% qemu-system-x86_64 --version                                                   
QEMU emulator version 10.1.2
Copyright (c) 2003-2025 Fabrice Bellard and the QEMU Project developers

安装步骤如下:

1.下载安装镜像文件

下载地址为:https://www.minix3.org/download/
我选择的是3.3.0 (stable release);
下载后命名为 minix.iso

2.创建虚拟磁盘镜像文件

1
2
3
% qemu-img create -f qcow2 minix.img 4G

Formatting 'minix.img', fmt=qcow2 cluster_size=65536 extended_l2=off compression_type=zlib size=4294967296 lazy_refcounts=off refcount_bits=16

qemu-img - QEMU 提供的磁盘镜像管理工具
-f qcow2 - 指定文件格式为qcow2,该格式主要特点为支持动态分配空间,不会一开始就占用指定的空间
create - 创建一个新的镜像文件
minix.img - 文件名称
4g - 文件最大容量

3.从光盘启动,即从iso文件启动

1
% qemu-system-x86_64 -m 256 -boot d -cdrom ./minix.iso -hda minix.img -net nic -net user

qemu-system-x86_64 - 启动一个 64 位 x86 架构的虚拟机
-m 256 - 分配 256 MB 内存给虚拟机
-boot d - 启动顺序设置为从光驱 (CD-ROM) 启动,用于第一次安装
-cdrom ./minix.iso - 指定虚拟光驱使用的 ISO 镜像文件(Minix 安装盘)
-hda minix.img - 指定虚拟硬盘文件(之前创建的 minix.img)
-net nic - 添加一块虚拟网卡
-net user - 使用用户模式网络,使虚拟机能够通过宿主机上网

从iso启动
启动后登录(输入root后直接回车,无需密码)

4.执行安装

输入setup执行安装操作系统到磁盘文件;

执行安装

基本上可以一路回车(按需选择),卡住的地方看下有需要输入yesno
等待一段时间,系统安装完毕;

安装过程

接下来还有几个选项,也可以一路回车;

安装完成

接下来输入shutdown -h now退出iso系统;
等待界面上出现MINIX has halted.表示系统已经退出的描述时,可以关闭qemu窗口。

5.从虚拟磁盘文件启动安装的操作系统

这次不指定-cdrom参数,且使用-boot c表示从硬盘(minix.img)启动;

1
% qemu-system-x86_64 -m 256 -boot c -hda minix.img -net nic -net user

qemu-system-x86_64 - 启动一个 64 位 x86 架构的虚拟机
-m 256 - 分配 256 MB 内存给虚拟机
-boot c - 设置启动顺序为从硬盘(C 盘)启动,适合系统安装完成后运行
-hda minix.img - 指定虚拟硬盘文件(之前安装了 MINIX 3 的 minix.img)
-net nic - 添加一块虚拟网卡
-net user - 使用用户模式网络,使虚拟机能够通过宿主机访问互联网

启动成功后使用root账号登录(无密码);
然后可以按需重新设置root密码;

不过还不清楚怎么在是里面配置网络,比如ping也无法成功;
留到下次有空看看吧。

参考资料#

全文检索引擎-ManticoreSearch

  • 记于:2025-09-28 下午
  • 地点:浙江省·温州市·家里
  • 天气:晴天

背景#

因个人开源的[商城项目]需要用到全文检索功能,主流的Elasticsearch太占资源,经过搜索比对,选择了轻量级的ManticoreSearch作为全文检索引擎。

简单介绍 (by AI)#

ManticoreSearch是一个开源、高性能的全文检索引擎,源自Sphinx Search。它能够帮助开发者在海量文本数据中快速查找相关内容,同时支持实时索引和SQL查询,让数据写入后即可被立即搜索。

特点包括:

  • 支持中文、英文等多语言全文搜索
  • 高并发、低延迟,适合网站、日志和内容平台
  • 使用MySQL协议,学习成本低、集成简单
  • 可进行布尔搜索、短语搜索和相关性排序

简单来说,ManticoreSearch就像一个专门为文本搜索优化的数据库,可以让你的应用或网站实现快速、精准的搜索功能。

安装#

支持多种方式安装,见官方文档[安装]部分。

MacOS下安装:

1
% brew install manticoresoftware/tap/manticoresearch manticoresoftware/tap/manticore-extra

以brew服务的方式启动Manticore:

1
% brew services start manticoresearch

支持MySQL客户端连接:

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
% mysql -h0 -P9306
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 146
Server version: 0.0.0 4734be156a@250610 from tarball

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| Manticore |
+--------------------+
2 rows in set (0.00 sec)

mysql> show tables;
+---------+------+
| Table | Type |
+---------+------+
| product | rt |
+---------+------+
1 row in set (0.00 sec)

Manticore Search使用MySQL协议实现了一个SQL接口,允许任何MySQL库或连接器以及许多MySQL客户端连接到ManticoreSearch,并像对待MySQL服务器一样使用它,而不是Manticore。

然而,SQL方言不同,并且只实现了MySQL中可用的SQL命令或函数的子集。此外,还有专门针对ManticoreSearch的子句和函数,例如用于全文搜索的MATCH()子句。

Manticore Search不支持服务器端预处理语句,但可以使用客户端预处理语句。需要注意的是,Manticore实现了多值(MVA)数据类型,这在MySQL或实现预处理语句的库中没有等价项。在这些情况下,MVA值必须在原始查询中构造。

某些MySQL客户端/连接器需要用户/密码和/或数据库名称的值。由于Manticore Search没有数据库的概念,也没有实现用户访问控制,这些值可以随意设置,因为Manticore会简单地忽略它们。

具体见官方文档[连接服务器]部分。

集成#

ManticoreSearch支持多种编程语言的客户端连接,同时也支持Java客户端连接。
客户端Github地址:https://github.com/manticoresoftware/manticoresearch-java

但是由于我的项目使用的还是Java8,而该客户端的近几个版本已经升级了Java版本,(忘记是11还是17了);
我fork了官方该仓库,改回了Java8,并发布到了maven中央仓库;
Github地址:https://github.com/yeshimin/manticoresearch-java-8
Maven地址:https://mvnrepository.com/artifact/com.yeshimin/manticoresearch-java-8

只需要使用这个非官方的maven依赖,其他使用方式和官方的一样,无需任何改动。
版本使用8.1.1,对应官方的8.1.0。

1
2
3
4
5
6
<!-- manticoresearch 全文检索 -->
<dependency>
<groupId>com.yeshimin</groupId>
<artifactId>manticoresearch-java-8</artifactId>
<version>8.1.1</version>
</dependency>

使用示例#

表示例#

1
2
3
4
5
6
7
8
9
CREATE TABLE product (
id bigint,
product_name text,
sku_names text,
sku_specs text,
sales bigint,
sku_min_price float,
sku_max_price float
) charset_table='non_cjk' ngram_len='1' ngram_chars='cjk' morphology='stem_en' ;

配置解释:

  • charset_table=’non_cjk’:表示该表的字符集为非中文字符集,适用于英文等语言的全文检索。
  • ngram_len=’1’:表示使用1-gram分词方式,即将文本拆分为单个字符进行索引和搜索。
  • ngram_chars=’cjk’:表示对中文、日文、韩文字符进行n-gram分词处理。
  • morphology=’stem_en’:表示对英文单词进行词干提取处理,以提高搜索的匹配度。

这样配置可以支持中英文的全文检索。

代码示例#

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
package com.yeshimin.yeahboot.merchant.service;

import cn.hutool.core.util.StrUtil;
import com.manticoresearch.client.ApiClient;
import com.manticoresearch.client.ApiException;
import com.manticoresearch.client.api.IndexApi;
import com.manticoresearch.client.api.SearchApi;
import com.manticoresearch.client.model.*;
import com.yeshimin.yeahboot.common.common.consts.FulltextTableConsts;
import com.yeshimin.yeahboot.common.common.properties.ManticoreSearchProperties;
import com.yeshimin.yeahboot.data.domain.entity.ProductSpuEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class FullTextSearchService {

private final ManticoreSearchProperties properties;

/**
* 同步商品索引
*/
public void syncProduct(ProductSpuEntity productSpu, List<String> skuNames, List<String> skuSpecs, boolean update) {
Map<String, Object> doc = new HashMap<>();
doc.put("id", productSpu.getId());
doc.put("product_name", productSpu.getName());
doc.put("sku_names", StrUtil.join(" ", skuNames));
doc.put("sku_specs", StrUtil.join(" ", skuSpecs));
doc.put("sales", productSpu.getSales());
doc.put("sku_min_price", productSpu.getMinPrice());
doc.put("sku_max_price", productSpu.getMaxPrice());

if (update) {
this.replace(FulltextTableConsts.PRODUCT, doc);
} else {
this.insert(FulltextTableConsts.PRODUCT, doc);
}
}

// ================================================================================

/**
* search
*/
public SearchResponse search(String tableName, String queryString) {
ApiClient client = this.newClient();

SearchQuery searchQuery = new SearchQuery();
searchQuery.setQueryString(queryString);

SearchRequest searchRequest = new SearchRequest();
searchRequest.table(tableName).query(searchQuery);

SearchApi searchApi = new SearchApi(client);

try {
SearchResponse response = searchApi.search(searchRequest);
log.debug("Search response: {}", response);
return response;
} catch (ApiException e) {
this.printError(e);
throw new RuntimeException(e);
}
}

/**
* insert
*/
public SuccessResponse insert(String tableName, Map<String, Object> doc) {
ApiClient client = this.newClient();

Long id = Long.parseLong(doc.remove("id").toString());

InsertDocumentRequest request = new InsertDocumentRequest();
request.table(tableName).id(id).doc(doc);

IndexApi indexApi = new IndexApi(client);
try {
SuccessResponse response = indexApi.insert(request);
log.debug("Insert response: {}", response);
return response;
} catch (ApiException e) {
this.printError(e);
throw new RuntimeException(e);
}
}

/**
* replace
*/
public SuccessResponse replace(String tableName, Map<String, Object> doc) {
ApiClient client = this.newClient();

Long id = Long.parseLong(doc.remove("id").toString());

InsertDocumentRequest request = new InsertDocumentRequest();
request.table(tableName).id(id).doc(doc);

IndexApi indexApi = new IndexApi(client);
try {
SuccessResponse response = indexApi.replace(request);
log.debug("Replace response: {}", response);
return response;
} catch (ApiException e) {
this.printError(e);
throw new RuntimeException(e);
}
}

/**
* update
*/
public UpdateResponse update(String tableName, Map<String, Object> doc) {
ApiClient client = this.newClient();

Long id = Long.parseLong(doc.remove("id").toString());

UpdateDocumentRequest updateRequest = new UpdateDocumentRequest();
updateRequest.table(tableName).id(id).doc(doc);

IndexApi indexApi = new IndexApi(client);
try {
UpdateResponse response = indexApi.update(updateRequest);
log.debug("Update response: {}", response);
return response;
} catch (ApiException e) {
this.printError(e);
throw new RuntimeException(e);
}
}

/**
* deleteIndex
*/
public DeleteResponse deleteIndex(String tableName, Long id) {
ApiClient client = this.newClient();

DeleteDocumentRequest deleteRequest = new DeleteDocumentRequest();
deleteRequest.table(tableName).id(id);

IndexApi indexApi = new IndexApi(client);
try {
DeleteResponse response = indexApi.delete(deleteRequest);
log.debug("Delete response: {}", response);
return response;
} catch (ApiException e) {
this.printError(e);
throw new RuntimeException(e);
}
}

// ================================================================================

private ApiClient newClient() {
ApiClient client = new ApiClient();
client.setBasePath(properties.getBasePath());
return client;
}

private void printError(ApiException e) {
log.error("Exception when calling Api function: {}, Status code: {}, Reason: {}, Response headers: {}",
e.getMessage(), e.getCode(), e.getResponseBody(), e.getResponseHeaders());
}
}

官方建议:Java客户端SDK未实现线程安全,建议每次请求都创建一个新的ApiClient实例。

参考资料#

内网穿透-frp

记于:2025-09-20 下午
地点:浙江省·温州市·家里
天气:阴天

简单介绍 (by 官网)#

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

下载#

Github上下载对应平台的二进制包,解压即可使用。
注意:服务端和客户端需要下载对应的平台版本。

服务端部署#

1
# cat /etc/systemd/system/frps.service 
1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
# 服务名称,可自定义
Description = frp server
After = network.target syslog.target
Wants = network.target

[Service]
Type = simple
# 启动frps的命令,需修改为您的frps的安装路径
ExecStart = /path/to/frps -c /path/to/frps.toml

[Install]
WantedBy = multi-user.target
1
2
systemctl start frps
systemctl enable frps

服务端frps.toml配置

1
bindPort = 7000

客户端部署
客户端frpc.toml配置

1
2
3
4
5
6
7
8
9
serverAddr = "此处填写部署frps的公网IP"
serverPort = 7000

[[proxies]]
name = "note"
type = "tcp"
localIP = "127.0.0.1"
localPort = 1111
remotePort = 6000

serverAddrserverPort配置为服务端frps的公网IP和端口;
localIPlocalPort配置为需要内网穿透的服务地址和端口;
remotePort配置为服务端开放的端口,外网访问该端口即访问内网服务(即外网6000端转发到本地1111端口)。

启动客户端

1
2
3
4
5
6
# ./frpc -c ./frpc.toml
2025-09-20 17:25:13.467 [I] [sub/root.go:149] start frpc service for config file [./frpc.toml]
2025-09-20 17:25:13.468 [I] [client/service.go:319] try to connect to server...
2025-09-20 17:25:13.575 [I] [client/service.go:311] [e33931d2705928f7] login to server success, get run id [e33931d2705928f7]
2025-09-20 17:25:13.576 [I] [proxy/proxy_manager.go:177] [e33931d2705928f7] proxy added: [note]
2025-09-20 17:25:13.605 [I] [client/control.go:172] [e33931d2705928f7] [note] start proxy success

访问外网frps的ip+6000端口即可访问内网服务。


另外还有其他场景示例,见官方文档

参考资料#

Spring AI MCP

记于:2025-08-18 下午
地点:浙江省·温州市·家里
天气:晴天

背景#

因项目需要,使用到mcp。

简单介绍 (by AI)#

MCP,全称 Model Context Protocol,是一种用于 大模型与外部服务交互 的协议。它的核心价值在于:
标准化:定义了一致的通信方式,避免为每个外部服务重复造轮子。
多通道支持:可通过 stdio、SSE(Server-Sent Events) 等传输方式进行通信,满足不同运行环境需求。
插件化:MCP Server 可以像插件一样挂载到模型环境中,模型通过协议与之交互,从而获取外部数据或执行操作。
简单来说,MCP 让 模型与外部世界的连接 更加轻量、统一和可扩展。

大致流程#

智能体中配置mcp插件工具,运行智能体,与智能体中d大型对话时,可让其调用mcp插件工具执行相关操作,比如获取当前天气信息,用于接下来的出行任务规划。
上述只是简单的流程描述,具体可以有多种多样的流程与应用方式。

代码和配置#

附上主要代码片段,并做一定说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
@Service
public class WeatherMcpService {

/**
* tool: query weather info by city name
*/
@Tool(description = "query weather info by city name")
public String queryWeatherInfo(@ToolParam(required = true, description = "city name") String city) {
String weatherInfo = weatherInfoService.getWeatherInfo(city);
log.info("queryWeatherInfo result: {}", weatherInfo);
return weatherInfo;
}
}

这个mcp工具的作用是查询天气信息。
@Tool注解表示该方法是一个mcp工具方法;
@ToolParam注解用于定义工具方法的参数;

1
2
3
4
5
6
7
8
@Configuration
public class McpServerConfiguration {

@Bean
public ToolCallbackProvider weatherTools(WeatherMcpService weatherMcpService) {
return MethodToolCallbackProvider.builder().toolObjects(weatherMcpService).build();
}
}

该类用于配置MCP服务。

1
2
3
4
5
6
7
8
9
10
11
spring:
main:
banner-mode: off
ai:
mcp:
server:
# server.stdio: false # 如果需要可以取消注释
name: my-mcp-server
base-url: /admin
sse-message-endpoint: /mcp/message
version: 1.0.0

这是spring ai mcp server的基本配置。
末尾附上spring ai mcp server官方文档链接。

调试工具#

mcp协议官方提供一个调试mcp server的命令行具;
示例:

1
2
3
4
5
6
7
8
9
10
% npx -y @modelcontextprotocol/inspector
Starting MCP inspector...
⚙️ Proxy server listening on 127.0.0.1:6277
⚠️ WARNING: Authentication is disabled. This is not recommended.
🔍 MCP Inspector is up and running at http://127.0.0.1:6274 🚀
New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp
Query parameters: {"url":"http://localhost:9999/admin/sse","transportType":"sse"}
SSE transport: url=http://localhost:9999/admin/sse, headers={"Accept":"text/event-stream"}
Created client transport
Created server transport

执行上述命令后,打开http://127.0.0.1:6274进行配置、连接和调试。

注意点(重点)#

  • 如果有用到nginx,需要在location部分添加相关配置

    1
    2
    3
    4
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
  • spring boot项目如果配置了context-path,需要在MCP Server的配置中将base-url设置为相应的context-path
    比如context-path: /admin,需要同时设置spring.ai.mcp.server.base-url: /admin
    另外nginx如果还有前缀(非项目本身路径),比如/api,spring.ai.mcp.server.base-url还需要添加/api前缀,比如:/api/admin
    否则会连不上。

  • 还有mcp调试工具经常出现自动断开的问题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    Error from MCP server: SseError: SSE error: TypeError: terminated: other side closed
    at _eventSource.onerror (file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/@modelcontextprotocol/sdk/dist/esm/client/sse.js:82:31)
    at EventSource.scheduleReconnect_fn (file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/eventsource/dist/index.js:248:53)
    at file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/eventsource/dist/index.js:98:174
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
    code: undefined,
    event: {
    type: 'error',
    message: 'TypeError: terminated: other side closed',
    code: undefined,
    defaultPrevented: false,
    cancelable: false,
    timeStamp: 215334173.374333
    }
    }

    这个也是添加了nginx的proxy_read_timeoutproxy_write_timeout可以解决;
    但是出现了另外的问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    Error from MCP server: SseError: SSE error: TypeError: terminated: Body Timeout Error
    at _eventSource.onerror (file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/@modelcontextprotocol/sdk/dist/esm/client/sse.js:82:31)
    at EventSource.scheduleReconnect_fn (file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/eventsource/dist/index.js:248:53)
    at file:///Users/yeshimin/.npm/_npx/5a9d879542beca3a/node_modules/eventsource/dist/index.js:98:174
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
    code: undefined,
    event: {
    type: 'error',
    message: 'TypeError: terminated: Body Timeout Error',
    code: undefined,
    defaultPrevented: false,
    cancelable: false,
    timeStamp: 320434.630833
    }
    }

    这个问了AI,说是长时间未收到服务端的心跳或响应,导致客户端主动断开,该问题暂未解决。

其他#

  • maven配置参考Spring AI Examples项目,包括带鉴权的方式,见下方参考资料;
  • spring ai当前版本1.0.1,还不支持StreamableHttp方式,目前仅支持SSE方式;
  • 智能体的mcp插件集成此处做说明,见具体智能体的文档;

参考资料#

多系统(模块/主题)、多终端的token控制方案

记于:2025-07-28 晚上
地点:浙江省·温州市·家里
天气:雨天

背景#

在做自己的开源Java/Spring Boot脚手架项目(地址见末尾);
默认实现为一个帐号对应一个token,web端和api端相互占用,开发和调试不方便,所以把token控制方案实现提前了一些。

功能说明#

  • 支持多系统(模块/主题),此处系统可以是一个项目工程的各个子系统,比如一个商城系统下的管理端、商家端、用户端等;
  • 支持多终端,比如web端、api端、app端(具体可分为phone端、pad端等);
  • 支持针对不同系统、不同终端配置不同的token策略,比如token过期时间、最大在线token数、最大在线终端数等;

设计思路#

  • 认证和方案总体基于spring security;
  • 在各终端执行登录操作时,对token进行控制,token空位足够则直接添加,否则进行淘汰后再添加;
  • token控制依赖缓存中token多个数据结构存储、操作、与过期机制;
  • token的鉴权,则通过过滤器机制实现;

设计图#

该设计图为草图,暂不做细化,对设计有个概念即可,具体看代码实现。

token控制方案设计图

代码实现#

附上主要代码片段,并做一定说明。

配置#

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
# auth相关
auth:
token:
# 全局jwt配置(subject中可覆盖)
jwt:
# 签发密钥
secret: xeePu3xo3uoyo5Quooquai0ut1ha7aih
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
subjects:
- name: admin
api-prefix: '/admin'
jwt:
# 签发密钥
secret: wee8Deonee4egh5taif8noim8pee1aiJ
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
terminals:
- name: web
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: 1
- name: api
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: -1
# 同一个账号的最大在线token数量(默认-1,表示不限制)
max-online-token-count: 10
# 同一个账号的最大在线终端数量(默认-1,表示不限制)
max-online-terminal-count: -1
- name: app
api-prefix: /app
jwt:
# 签发密钥
secret: caiQueipae5Phaeph7gaethae5ash6bu
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
terminals:
- name: app
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: 1
# 同一个账号的最大在线token数量(默认-1,表示不限制)
max-online-token-count: 10
# 同一个账号的最大在线终端数量(默认-1,表示不限制)
max-online-terminal-count: -1

配置中包含secret,由于是开源项目,所以不做脱敏处理;
配置含义见注释和代码,不另做说明。

登录操作中的token控制逻辑#

已封装为单独服务类的方法:

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
248
249
250
251
252
253
254
255
/**
* 终端和token控制服务
*/
@Service
@RequiredArgsConstructor
public class TerminalAndTokenControlService {

private final TokenService tokenService;
private final CacheService cacheService;
private final JwtService jwtService;

private final AuthTokenProperties authTokenProperties;

/**
* 控制终端和token数量
*/
public String doControl(String userId, String subValue, String termValue) {
// 检查配置
AuthTokenProperties.Subject subject = authTokenProperties.getMapSubject().get(subValue);
if (subject == null) {
throw new BaseException(ErrorCodeEnum.FAIL, "subject[" + subValue + "] 未配置");
}
AuthTokenProperties.Terminal terminal = subject.getMapTerminal().get(termValue);
if (terminal == null) {
throw new BaseException(ErrorCodeEnum.FAIL, "terminal[" + termValue + "] 不正确");
}

// 1.检查配置
// --------------------------------------------------------------------------------
// 2.加载缓存数据并做初预处理

// 添加token及终端信息
String token = tokenService.generateToken(userId, subValue, termValue);
JwtPayloadVo jwtPayloadVo = jwtService.decodePayload(token);
// token创建时间戳(毫秒)
long timestampMs = jwtPayloadVo.getIatMs();

// 查询终端信息
Map<String, String> mapTerminalInfo = tokenService.getSubjectTerminalInfo(subValue, userId);
Map<String, String> mapTerminalInfoValid = new HashMap<>();
Map<String, String> mapTerminalInfoExpired = new HashMap<>();
// 区分是否过期
mapTerminalInfo.forEach((term, termInfo) -> {
// termInfo格式为 "iat,expTime",其中iat为登录时间戳,expTime为过期时间戳
long expTime = Long.parseLong(termInfo.split(",")[1]);
if (timestampMs >= expTime) {
mapTerminalInfoExpired.put(term, termInfo);
} else {
mapTerminalInfoValid.put(term, termInfo);
}
});
// 查询终端对应的token信息
Map<String, Map<String, String>> mapTotalTerminalTokenInfo = new HashMap<>();
Map<String, Map<String, String>> mapRemainTerminalTokenInfo = new HashMap<>();
Map<String, String> mapTotalTerminalTokenInfoValid = new HashMap<>();
Map<String, String> mapTotalTerminalTokenInfoExpired = new HashMap<>();
for (Map.Entry<String, String> entry : mapTerminalInfo.entrySet()) {
// 查询对应终端的token信息
Map<String, String> mapTerminalTokenInfo = tokenService.getTerminalTokenInfo(subValue, userId, entry.getKey());
mapTotalTerminalTokenInfo.put(entry.getKey(), mapTerminalTokenInfo);
}
// 查询当前终端的token信息
Map<String, String> mapTerminalTokenInfo = mapTotalTerminalTokenInfo.getOrDefault(termValue, new HashMap<>());
Map<String, String> mapTerminalTokenInfoValid = new HashMap<>();
Map<String, String> mapTerminalTokenInfoExpired = new HashMap<>();
mapTerminalTokenInfo.forEach((timestamp, expTime) -> {
long expTimeInt = Long.parseLong(expTime);
if (timestampMs >= expTimeInt) {
mapTerminalTokenInfoExpired.put(timestamp, expTime);
} else {
mapTerminalTokenInfoValid.put(timestamp, expTime);
}
});

// 2.加载缓存数据并做初预处理
// --------------------------------------------------------------------------------
// 3.检查和控制subject下终端数量

// 是否清空用户的终端和token信息
boolean clearAllTerminal = false;
// 要清除的终端信息
Set<String> needDeleteTerminal = new HashSet<>();
// 要清除的token信息,map<terminal, set<timestamp>> ; 【保留终端token信息】中的待清除部分
Map<String, Set<String>> mapNeedDeleteTokenInfo = new HashMap<>();

// 检查和控制终端数
// 大于0,需要检查和控制超限情况
if (subject.getMaxOnlineTerminalCount() > 0) {
// 检查当前在线终端数是否达到限制
int occurTerminalCount = mapTerminalInfoValid.size() + (mapTerminalInfoValid.containsKey(termValue) ? 0 : 1);
// 超限情况
if (occurTerminalCount > subject.getMaxOnlineTerminalCount()) {
// 要清除的终端数
int needToDeleteTerminalCount = occurTerminalCount - subject.getMaxOnlineTerminalCount();
// 优先清除旧值
// mapTerminalInfoValid按value[0]时间戳排序,value[0]越小越旧;
// value数据格式为iat,expTime,直接按value字符串排序效果与按iat排序一样
Set<String> eliminatedTerminal = mapTerminalInfoValid.entrySet()
.stream().sorted(Map.Entry.comparingByValue()).limit(needToDeleteTerminalCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
// 记录要清除的终端信息
needDeleteTerminal.addAll(eliminatedTerminal);
}
} else if (subject.getMaxOnlineTerminalCount() == 0) {
clearAllTerminal = true;
}
// 添加过期终端
needDeleteTerminal.addAll(mapTerminalInfoExpired.keySet());

// 3.检查和控制subject下终端数量
// --------------------------------------------------------------------------------
// 4.检查和控制subject下token数量

// 检查和控制token信息
// 大于0,需要检查和控制超限情况
if (subject.getMaxOnlineTokenCount() > 0) {
// 计算当前有效token数量(排除掉要清除的终端,即只计算要保留的终端)
int occurTokenCount = 0;
mapRemainTerminalTokenInfo = mapTotalTerminalTokenInfo.entrySet().stream()
.filter(entry -> !needDeleteTerminal.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
for (Map.Entry<String, Map<String, String>> entry : mapRemainTerminalTokenInfo.entrySet()) {
occurTokenCount += entry.getValue().size();
// 区分是否过期
entry.getValue().forEach((timestamp, expTime) -> {
long expTimeInt = Long.parseLong(expTime);
if (timestampMs >= expTimeInt) {
mapTotalTerminalTokenInfoExpired.put(timestamp + "," + entry.getKey(), expTime);
} else {
mapTotalTerminalTokenInfoValid.put(timestamp + "," + entry.getKey(), expTime);
}
});
}
// 超限的情况
if (occurTokenCount >= subject.getMaxOnlineTokenCount()) {
// 计算需要清除的token数量;+1是为当前登录操作预留的token位置
int needDeleteCount = occurTokenCount - subject.getMaxOnlineTokenCount() + 1;
// 优先清除旧值
Set<String> eliminatedTokenInfo = mapTotalTerminalTokenInfoValid.entrySet()
.stream().sorted((e1, e2) -> {
String terminal1 = e1.getKey().split(",")[1];
int sort = Objects.equals(terminal1, termValue) ? 0 : 1;
String key1 = sort + ":" + e1.getKey();

String terminal2 = e2.getKey().split(",")[1];
sort = Objects.equals(terminal2, termValue) ? 0 : 1;
String key2 = sort + ":" + e2.getKey();

return key1.compareTo(key2);
}).limit(needDeleteCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
eliminatedTokenInfo.forEach(e -> {
// [0]: timestamp; [1]: terminal
String[] split = e.split(",");
mapNeedDeleteTokenInfo.computeIfAbsent(split[1], k -> new HashSet<>()).add(split[0]);
});
}
} else if (subject.getMaxOnlineTokenCount() == 0) {
clearAllTerminal = true;
}
// 过期token信息的也添加到待清除集合
mapTotalTerminalTokenInfoExpired.keySet().forEach(e -> {
// [0]: timestamp; [1]: terminal
String[] split = e.split(",");
mapNeedDeleteTokenInfo.computeIfAbsent(split[1], k -> new HashSet<>()).add(split[0]);
});

// 4.检查和控制subject下token数量
// --------------------------------------------------------------------------------
// 5.检查和控制terminal下token数量

// 检查和控制当前用户登录终端对应的token信息
// 仅当当前终端不包含在待删除终端集合内,才进行处理
if (!needDeleteTerminal.contains(termValue)) {
// 大于0,需要检查和控制超限情况
if (terminal.getMaxOnlineTokenCount() > 0) {
// 超限的情况
if (mapTerminalTokenInfoValid.size() >= terminal.getMaxOnlineTokenCount()) {
// 需要清除的token数量;+1是为当前登录操作预留的token位置
int needDeleteCount = mapTerminalTokenInfoValid.size() - terminal.getMaxOnlineTokenCount() + 1;
// 优先清除旧值
Set<String> eliminatedToken = mapTerminalTokenInfoValid.entrySet()
.stream().sorted(Map.Entry.comparingByKey()).limit(needDeleteCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
eliminatedToken.forEach(timestamp -> {
mapNeedDeleteTokenInfo.computeIfAbsent(termValue, k -> new HashSet<>()).add(timestamp);
});
}
} else if (terminal.getMaxOnlineTokenCount() == 0) {
// 清空当前终端和对应token信息
needDeleteTerminal.add(termValue);
}
// 过期的token信息也添加到待清除集合
mapTerminalTokenInfoExpired.keySet().forEach(timestamp -> {
mapNeedDeleteTokenInfo.computeIfAbsent(termValue, k -> new HashSet<>()).add(timestamp);
});
}

// 5.检查和控制terminal下token数量
// --------------------------------------------------------------------------------
// 6.执行清除操作并按需添加当前登录的终端和token信息

// 清空用户所有终端和token信息
if (clearAllTerminal) {
Set<String> delKeys = new HashSet<>();
delKeys.add(String.format(CacheKeyConsts.USER_SUBJECT_TERMINAL_INFO, subValue, userId));
mapTerminalInfo.keySet().forEach(term -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term));
mapTotalTerminalTokenInfo.getOrDefault(term, new HashMap<>()).forEach((timestamp, expTime) -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
cacheService.delete(delKeys.toArray(new String[0]));
// 禁止登录
throw new BaseException("禁止登录");
}
// (按需)清空部分信息,然后添加当前登录所创建的token及终端信息
else {
Set<String> delKeys = new HashSet<>();
// map<cache key, fields>
Map<String, Set<String>> delFields = new HashMap<>();

needDeleteTerminal.forEach(term -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_SUBJECT_TERMINAL_INFO, subValue, userId),
k -> new HashSet<>()).add(term);

delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term));
Optional.ofNullable(mapTotalTerminalTokenInfo.get(term)).ifPresent(terminalTokenInfo -> {
terminalTokenInfo.keySet().forEach(timestamp -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term),
k -> new HashSet<>()).add(timestamp);
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
});
mapNeedDeleteTokenInfo.forEach((term, timestampSet) -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term),
k -> new HashSet<>()).addAll(timestampSet);
timestampSet.forEach(timestamp -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
// 执行清除
cacheService.delete(delKeys.toArray(new String[0]));
delFields.forEach((cacheKey, fields) -> {
cacheService.deleteHashFields(cacheKey, fields.toArray(new Object[0]));
});

tokenService.setSubjectTerminalInfo(subValue, userId, termValue, jwtPayloadVo.getIatMs(), jwtPayloadVo.getExpMs());
tokenService.setTerminalTokenInfo(subValue, userId, termValue, jwtPayloadVo.getIatMs(), jwtPayloadVo.getExpMs());
tokenService.cacheToken(subValue, userId, termValue, token, timestampMs);
return token;
}
}
}

补充:相关缓存key设计,结合设计图更直观。

1
2
3
4
5
6
7
8
// subject下所有终端信息 redis hash类型
public static final String USER_SUBJECT_TERMINAL_INFO = "sub:%s:user:%s:token:term";

// 终端下token信息 redis hash类型
public static final String USER_TERMINAL_TOKEN_INFO = "sub:%s:user:%s:token:term:%s";

// token,格式: [subject : user : terminal : token timestamp] redis string类型
public static final String USER_TERMINAL_TOKEN = "sub:%s:user:%s:token:term:%s:%s";

对token相关缓存设计做下说明:

token的鉴权(认证和授权)#

AuthenticationProvider实现:

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
/**
* Jwt Token认证提供器 for ProviderManager
*/
@Slf4j
public class JwtTokenAuthenticationProvider implements AuthenticationProvider {

private final AuthService authService;

// constructor
public JwtTokenAuthenticationProvider(AuthService authService) {
this.authService = authService;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtTokenAuthenticationToken requestToken = (JwtTokenAuthenticationToken) authentication;
String token = (String) requestToken.getCredentials();

AuthDto authDto = new AuthDto();
authDto.setToken(token);
AuthVo authVo = authService.auth(authDto);
if (authVo == null || !authVo.getAuthenticated()) {
log.error("调用auth服务鉴权失败!");
throw new BaseException(ErrorCodeEnum.FAIL, "调用auth服务鉴权失败!");
}

// set web context
WebContextUtils.setToken(token);
WebContextUtils.setUserId(authVo.getUserId());
WebContextUtils.setSubject(authVo.getSubject());
WebContextUtils.setTerminal(authVo.getTerminal());
WebContextUtils.setUsername(authVo.getUsername());
WebContextUtils.setRoles(authVo.getRoles());
WebContextUtils.setResources(authVo.getResources());

// role
Set<String> authoritySet = authVo.getRoles().stream().map(s -> "ROLE_" + s).collect(Collectors.toSet());
// add resource
authoritySet.addAll(authVo.getResources());
String commaSeparatedRoles = String.join(",", authoritySet);

// set authorities
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(commaSeparatedRoles);
// new authentication
JwtTokenAuthenticationToken resultToken = new JwtTokenAuthenticationToken(authorities);
resultToken.setAuthenticated(true);

return resultToken;
}

@Override
public boolean supports(Class<?> authentication) {
return JwtTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
}

代码说明:

  • 该类实现了AuthenticationProvider接口,用于处理基于JWT的认证;
  • 主要实现了token鉴权(委托给authService.auth方法)、设置web上下文信息、设置用户角色和资源。

鉴权服务代码实现:

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
/**
* 鉴权服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final TokenService tokenService;

private final List<UserDetailService> userDetailServices;
private Map<String, UserDetailService> mapUserDetailService;

private final AuthTokenProperties authTokenProperties;

@PostConstruct
public void init() {
mapUserDetailService = userDetailServices.stream().collect(
Collectors.toMap(UserDetailService::getSubject, userDetailService -> userDetailService));
}

/**
* 鉴权(认证和授权)(token方式)
*/
public AuthVo auth(AuthDto dto) {
AuthVo authVo = new AuthVo();

// 认证
JwtPayloadVo decodedResult = tokenService.decodeToken(dto.getToken());
if (decodedResult == null) {
log.error("token decode fail");
authVo.setAuthenticated(false);
return authVo;
} else if (!Objects.equals(dto.getToken(), tokenService.getCacheToken(
decodedResult.getSub(), decodedResult.getAud(), decodedResult.getTerm(), decodedResult.getIatMs()))) {
log.error("token cache validation fail");
authVo.setAuthenticated(false);
return authVo;
} else {
authVo.setAuthenticated(true);
authVo.setUserId(Long.valueOf(decodedResult.getAud()));
authVo.setSubject(decodedResult.getSub());
authVo.setTerminal(decodedResult.getTerm());
}

// 此处刚获取到userId,尽早重新设置mdc信息
this.setMdcInfo(authVo.getUserId());

// 接口越权检查
if (!this.checkApiPrivilegeEscape(decodedResult.getSub())) {
authVo.setAuthenticated(false);
return authVo;
}

// 刷新token过期时间
tokenService.refreshTokenExpire(decodedResult.getSub(), decodedResult.getAud(),
decodedResult.getTerm(), decodedResult.getIatMs());

// // 是否只进行认证
// if (dto.getOnlyAuthenticate()) {
// return authVo;
// }

String userId = decodedResult.getAud();

// 授权
UserDetail userDetail = this.getUserDetailService(decodedResult.getSub()).getUserDetail(userId);
// UserDetail userDetail = userDetailService.getUserDetail(userId);

authVo.setAuthenticated(true);
authVo.setUsername(userDetail.getUsername());
authVo.setRoles(userDetail.getRoles());
authVo.setResources(userDetail.getResources());
return authVo;
}

// ================================================================================

private UserDetailService getUserDetailService(String subject) {
UserDetailService userDetailService = mapUserDetailService.get(subject);
if (userDetailService == null) {
throw new BaseException("UserDetailService for subject: " + subject + " not found");
}
return userDetailService;
}

private boolean checkApiPrivilegeEscape(String subject) {
AuthTokenProperties.Subject subjectObj = authTokenProperties.getMapSubject().get(subject);
String apiPrefix = subjectObj.getApiPrefix();
if (StrUtil.isBlank(apiPrefix)) {
log.info("subject: {}, apiPrefix is not set, skip", subject);
return true;
}

boolean isEqOp = true;
if (apiPrefix.startsWith("!")) {
isEqOp = false;
apiPrefix = apiPrefix.substring(1);
}

String requestUri = this.getRequest().getRequestURI();

boolean success = false;
if (isEqOp) {
success = requestUri.startsWith(apiPrefix);
} else {
success = !requestUri.startsWith(apiPrefix);
}

log.info("isEqOp: {}, apiPrefix: {}, requestUri: {}, success: {}", isEqOp, apiPrefix, requestUri, success);

return success;
}

private HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return Objects.requireNonNull(attrs).getRequest();
}

private void setMdcInfo(Long userId) {
String reqId = MDC.get(MdcLogFilter.MDC_REQ_ID);
String mdcInfo = "[reqId: " + reqId + ", userId:" + userId + "] ";
MDC.put(MdcLogFilter.MDC_INFO, mdcInfo);
}
}

代码说明:

  • 该类主要方法auth实现了token的解码、认证/验证、接口权限检查、token过期时间刷新、授权等;
  • 其中【授权】部分委托给UserDetailService接口的实现类来获取用户信息(具体各subject需要按要求提供实现类,具体见项目代码);

其他比如spring security配置以及胶水代码等不另做说明,详细见项代码。

开源项目地址#