@5ime/likeme 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/.gitattributes +20 -0
- package/README.md +199 -212
- package/demo/index.html +42 -42
- package/index.js +14 -103
- package/package.json +5 -4
- package/public/index.html +1 -3
- package/src/app.js +87 -0
- package/src/config.js +16 -0
- package/src/db.js +93 -0
- package/src/http.js +10 -0
- package/src/ip.js +23 -0
- package/src/ratelimit.js +37 -0
- package/static/js/likeme.js +1 -1
package/.env.example
ADDED
package/.gitattributes
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
* text=auto
|
|
2
|
+
|
|
3
|
+
# Normalize line endings to LF for source/docs to avoid noisy diffs across OSes
|
|
4
|
+
*.js text eol=lf
|
|
5
|
+
*.cjs text eol=lf
|
|
6
|
+
*.mjs text eol=lf
|
|
7
|
+
*.ts text eol=lf
|
|
8
|
+
*.tsx text eol=lf
|
|
9
|
+
*.json text eol=lf
|
|
10
|
+
*.md text eol=lf
|
|
11
|
+
*.html text eol=lf
|
|
12
|
+
*.css text eol=lf
|
|
13
|
+
*.svg text eol=lf
|
|
14
|
+
*.yml text eol=lf
|
|
15
|
+
*.yaml text eol=lf
|
|
16
|
+
*.toml text eol=lf
|
|
17
|
+
|
|
18
|
+
# Keep Windows scripts with CRLF if you add any in the future
|
|
19
|
+
*.bat text eol=crlf
|
|
20
|
+
*.cmd text eol=crlf
|
package/README.md
CHANGED
|
@@ -1,212 +1,199 @@
|
|
|
1
|
-
# likeMe - 一个可爱的互动点赞组件
|
|
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
|
-
```json
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
返回:
|
|
203
|
-
```json
|
|
204
|
-
{
|
|
205
|
-
"code": "200",
|
|
206
|
-
"data": {
|
|
207
|
-
"count": 43
|
|
208
|
-
},
|
|
209
|
-
"msg": "success"
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
1
|
+
# likeMe - 一个可爱的互动点赞组件
|
|
2
|
+
|
|
3
|
+
快速给你的网站加一个「你喜欢我吗 / Do you like me」小组件。服务端可部署在 Vercel,点赞数据存 Vercel Postgres(Neon)。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- 🎨 简洁可爱的 UI 设计
|
|
8
|
+
- 🚀 一键部署到 Vercel
|
|
9
|
+
- 💾 数据存储在 Vercel Postgres(Neon)
|
|
10
|
+
- 🔒 防刷机制:同一 IP 每天只能点赞一次
|
|
11
|
+
- ⚡ 自动缓存优化,减少数据库查询
|
|
12
|
+
- 📦 支持 CDN 和本地文件两种使用方式
|
|
13
|
+
- 🎯 支持自定义主题颜色
|
|
14
|
+
|
|
15
|
+
## 一键部署
|
|
16
|
+
|
|
17
|
+
[](https://vercel.com/new/clone?repository-url=https://github.com/5ime/likeMe)
|
|
18
|
+
|
|
19
|
+
1. 点击上方按钮跳转至 **Vercel** 进行部署
|
|
20
|
+
2. 输入一个你喜欢的 Vercel 项目名称并点击 **Create**
|
|
21
|
+
3. 等待部署完成
|
|
22
|
+
4. 点击 **Go to Dashboard** 跳转到控制台
|
|
23
|
+
|
|
24
|
+
## 配置数据库
|
|
25
|
+
|
|
26
|
+
本项目使用 Vercel Postgres(底层是 Neon)。你只需要在 Vercel 给当前项目绑定数据库,并确保环境变量里存在 `POSTGRES_URL`。
|
|
27
|
+
|
|
28
|
+
### 1. 在 Vercel 创建并绑定数据库
|
|
29
|
+
|
|
30
|
+
1. 打开你的 Vercel 项目
|
|
31
|
+
2. 进入 **Storage**
|
|
32
|
+
3. 点击 **Create Database**,选择 **Neon**
|
|
33
|
+
4. 选择要绑定到的项目(通常就是当前项目)并创建
|
|
34
|
+
|
|
35
|
+
### 2. 确认环境变量已注入
|
|
36
|
+
|
|
37
|
+
创建完成后,Vercel 通常会自动把数据库连接信息注入到项目环境变量中。
|
|
38
|
+
|
|
39
|
+
- 进入 **Settings > Environment Variables**
|
|
40
|
+
- 搜索并确认存在:`POSTGRES_URL`
|
|
41
|
+
|
|
42
|
+
如果没有自动注入,请手动添加:
|
|
43
|
+
|
|
44
|
+
1. 进入 **Storage > Postgres 数据库详情页**
|
|
45
|
+
2. 找到连接字符串(Connection String),复制 `POSTGRES_URL` 对应的值
|
|
46
|
+
3. 回到 **Settings > Environment Variables**,新增变量:
|
|
47
|
+
- **Name**:`POSTGRES_URL`
|
|
48
|
+
- **Value**:你复制的连接字符串
|
|
49
|
+
- **Environments**:勾选 `Production / Preview / Development`
|
|
50
|
+
4. 保存后到 **Deployments** 点击 **Redeploy**(或触发一次新的部署),让最新环境变量生效
|
|
51
|
+
|
|
52
|
+
### 3. 如何验证数据库已可用
|
|
53
|
+
|
|
54
|
+
最简单的方式:访问一次接口触发建表,然后再看是否能正常返回。
|
|
55
|
+
|
|
56
|
+
- `GET /healthz`:服务是否存活(返回 `ok`)
|
|
57
|
+
- `GET /info`:会读取数据库并返回当前点赞数
|
|
58
|
+
|
|
59
|
+
如果数据库连不上,你通常会在 Vercel 的 **Logs** 里看到与连接/鉴权相关的报错(例如环境变量缺失、连接串无效等)。
|
|
60
|
+
|
|
61
|
+
> 本项目会在首次请求时自动建表(`like_count` / `like_user`),无需手动建表。
|
|
62
|
+
|
|
63
|
+
## 在你的网站中使用
|
|
64
|
+
|
|
65
|
+
### 方法一:使用 CDN
|
|
66
|
+
|
|
67
|
+
在你的网页中添加以下代码:
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<link rel="stylesheet" href="https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css" />
|
|
71
|
+
<script src="https://unpkg.com/@5ime/likeme@latest/static/js/likeme.js"></script>
|
|
72
|
+
|
|
73
|
+
<div id="likeme"></div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
likeMe({
|
|
77
|
+
el: '#likeme',
|
|
78
|
+
serverURL: 'https://your-domain.vercel.app', // 替换为你的服务端地址,结尾勿带 /
|
|
79
|
+
color: '#ff9797' // 可选:自定义主题颜色
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 方法二:下载使用
|
|
85
|
+
|
|
86
|
+
1. 下载 `static/css/likeme.css` 和 `static/js/likeme.js`
|
|
87
|
+
2. 在你的项目中引入这些文件
|
|
88
|
+
3. 按照上面的方式初始化组件
|
|
89
|
+
|
|
90
|
+
### 配置选项
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
likeMe({
|
|
94
|
+
el: '#likeme', // 必需:容器选择器
|
|
95
|
+
serverURL: 'https://your-domain.vercel.app', // 必需:服务端地址,结尾勿带 /
|
|
96
|
+
color: '#ff9797' // 可选:主题颜色,默认为 #ff9797
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 本地开发
|
|
101
|
+
|
|
102
|
+
环境:Node.js >= 18
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm install
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
创建 `.env`:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
POSTGRES_URL=postgres://...
|
|
112
|
+
PORT=3000
|
|
113
|
+
ALLOW_ORIGIN=*
|
|
114
|
+
TRUST_PROXY=1
|
|
115
|
+
INFO_CACHE_SECONDS=5
|
|
116
|
+
LIKE_RATE_LIMIT_WINDOW_MS=60000
|
|
117
|
+
LIKE_RATE_LIMIT_MAX=20
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
启动项目:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm start
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `PORT` 可选,默认 `3000`
|
|
127
|
+
- `ALLOW_ORIGIN` 可选,默认 `*`(如需限制跨域可填你的站点域名)
|
|
128
|
+
- `TRUST_PROXY` 默认 `1`,用于正确识别客户端 IP。
|
|
129
|
+
- `/info` 默认缓存 5 秒;`/like` 默认每 IP 每分钟 20 次。
|
|
130
|
+
|
|
131
|
+
## API 接口
|
|
132
|
+
|
|
133
|
+
### `GET /info`
|
|
134
|
+
|
|
135
|
+
获取当前点赞数。
|
|
136
|
+
|
|
137
|
+
**响应格式:**
|
|
138
|
+
|
|
139
|
+
成功:
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"code": "200",
|
|
143
|
+
"msg": "success",
|
|
144
|
+
"data": {
|
|
145
|
+
"count": 123
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
失败:
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"code": "201",
|
|
154
|
+
"msg": "哎呀,数据偷懒跑掉了"
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**缓存:** 响应头包含 `Cache-Control: public, max-age=5`(默认缓存 5 秒)
|
|
159
|
+
|
|
160
|
+
### `GET /like`
|
|
161
|
+
|
|
162
|
+
提交一次点赞。同一 IP 每天只能点赞一次。
|
|
163
|
+
|
|
164
|
+
**响应格式:**
|
|
165
|
+
|
|
166
|
+
成功(首次点赞):
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"code": "200",
|
|
170
|
+
"msg": "success",
|
|
171
|
+
"data": {
|
|
172
|
+
"count": 124
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
失败(今日已点赞):
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"code": "201",
|
|
181
|
+
"msg": "今天的爱意已经收到啦~"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
失败(系统错误):
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"code": "201",
|
|
189
|
+
"msg": "你的爱意传递失败~"
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**限流:** 默认每 IP 每分钟最多 20 次请求
|
|
194
|
+
|
|
195
|
+
### `GET /healthz`
|
|
196
|
+
|
|
197
|
+
健康检查接口,用于检测服务是否正常运行。
|
|
198
|
+
|
|
199
|
+
**响应:** 纯文本 `ok`(HTTP 200)
|
package/demo/index.html
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<meta name="description" content="Do You Like Me? - 组件演示页面">
|
|
7
|
-
<meta name="keywords" content="点赞,互动,like,web组件,演示">
|
|
8
|
-
<title>Do You Like Me? - 演示页面</title>
|
|
9
|
-
<link rel="stylesheet" href="/static/css/likeme.css">
|
|
10
|
-
<style>
|
|
11
|
-
.container {
|
|
12
|
-
margin: 0 auto;
|
|
13
|
-
max-width: 400px;
|
|
14
|
-
width: 100%;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.demo-info {
|
|
18
|
-
text-align: center;
|
|
19
|
-
margin-bottom: 20px;
|
|
20
|
-
padding: 10px;
|
|
21
|
-
border-radius: 5px;
|
|
22
|
-
}
|
|
23
|
-
</style>
|
|
24
|
-
</head>
|
|
25
|
-
<body>
|
|
26
|
-
<div class="container">
|
|
27
|
-
<div class="demo-info">
|
|
28
|
-
<h2>LikeMe 组件演示</h2>
|
|
29
|
-
<p>这是一个可交互的点赞组件演示页面</p>
|
|
30
|
-
</div>
|
|
31
|
-
<div id="likeme"></div>
|
|
32
|
-
</div>
|
|
33
|
-
|
|
34
|
-
<script src="/static/js/likeme.js"></script>
|
|
35
|
-
<script>
|
|
36
|
-
likeMe({
|
|
37
|
-
el: '#likeme',
|
|
38
|
-
serverURL: `${location.protocol}//${location.host}`,
|
|
39
|
-
color: '#ff4e6a'
|
|
40
|
-
});
|
|
41
|
-
</script>
|
|
42
|
-
</body>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="description" content="Do You Like Me? - 组件演示页面">
|
|
7
|
+
<meta name="keywords" content="点赞,互动,like,web组件,演示">
|
|
8
|
+
<title>Do You Like Me? - 演示页面</title>
|
|
9
|
+
<link rel="stylesheet" href="/static/css/likeme.css">
|
|
10
|
+
<style>
|
|
11
|
+
.container {
|
|
12
|
+
margin: 0 auto;
|
|
13
|
+
max-width: 400px;
|
|
14
|
+
width: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.demo-info {
|
|
18
|
+
text-align: center;
|
|
19
|
+
margin-bottom: 20px;
|
|
20
|
+
padding: 10px;
|
|
21
|
+
border-radius: 5px;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div class="container">
|
|
27
|
+
<div class="demo-info">
|
|
28
|
+
<h2>LikeMe 组件演示</h2>
|
|
29
|
+
<p>这是一个可交互的点赞组件演示页面</p>
|
|
30
|
+
</div>
|
|
31
|
+
<div id="likeme"></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<script src="/static/js/likeme.js"></script>
|
|
35
|
+
<script>
|
|
36
|
+
likeMe({
|
|
37
|
+
el: '#likeme',
|
|
38
|
+
serverURL: `${location.protocol}//${location.host}`,
|
|
39
|
+
color: '#ff4e6a'
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
</body>
|
|
43
43
|
</html>
|
package/index.js
CHANGED
|
@@ -1,103 +1,14 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
const app =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
async function getCurrentCount() {
|
|
17
|
-
try {
|
|
18
|
-
const result = await new AV.Query('likeCount').get(OBJECT_ID);
|
|
19
|
-
return result.get('count') || 0;
|
|
20
|
-
} catch (error) {
|
|
21
|
-
try {
|
|
22
|
-
const results = await new AV.Query('likeCount').find();
|
|
23
|
-
const record = results.find(r => r.id === OBJECT_ID) || results[0];
|
|
24
|
-
return record?.get('count') || 0;
|
|
25
|
-
} catch (fallbackError) {
|
|
26
|
-
const firstResult = await new AV.Query('likeCount').first();
|
|
27
|
-
return firstResult?.get('count') || 0;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function checkUserExists(ip) {
|
|
33
|
-
try {
|
|
34
|
-
return await new AV.Query('likeUser').equalTo('ip', ip).first();
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (error.code === 101) return false;
|
|
37
|
-
throw error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function saveUserRecord(ip) {
|
|
42
|
-
try {
|
|
43
|
-
const likeUser = new (AV.Object.extend('likeUser'))();
|
|
44
|
-
likeUser.set('ip', ip);
|
|
45
|
-
await likeUser.save();
|
|
46
|
-
} catch (error) {
|
|
47
|
-
console.warn('Error: ', error.message);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
app.use((req, res, next) => {
|
|
52
|
-
res.header('Access-Control-Allow-Origin', '*');
|
|
53
|
-
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
54
|
-
req.method === 'OPTIONS' ? res.sendStatus(200) : next();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
app.use('/static', express.static(`${__dirname}/static`));
|
|
58
|
-
app.use('/demo', express.static(`${__dirname}/demo`));
|
|
59
|
-
app.get('/', (req, res) => res.sendFile(`${__dirname}/public/index.html`));
|
|
60
|
-
|
|
61
|
-
app.get('/info', async (req, res) => {
|
|
62
|
-
try {
|
|
63
|
-
const count = await getCurrentCount();
|
|
64
|
-
res.json({ code: '200', msg: 'success', data: { count } });
|
|
65
|
-
} catch (error) {
|
|
66
|
-
console.error('Error in /info:', error);
|
|
67
|
-
res.json({ code: '201', msg: '哎呀,数据偷懒跑掉了' });
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
app.get('/like', async (req, res) => {
|
|
72
|
-
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || 'unknown';
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const userExists = await checkUserExists(ip);
|
|
76
|
-
if (userExists) {
|
|
77
|
-
return res.json({ code: '201', msg: '今天的爱意已经收到啦~' });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const currentCount = await getCurrentCount();
|
|
81
|
-
const newCount = currentCount + 1;
|
|
82
|
-
|
|
83
|
-
account.set('count', newCount);
|
|
84
|
-
await account.save();
|
|
85
|
-
|
|
86
|
-
await saveUserRecord(ip);
|
|
87
|
-
|
|
88
|
-
res.json({
|
|
89
|
-
code: '200',
|
|
90
|
-
msg: 'success',
|
|
91
|
-
data: { count: newCount }
|
|
92
|
-
});
|
|
93
|
-
} catch (error) {
|
|
94
|
-
console.error('Error in /like:', error);
|
|
95
|
-
res.json({ code: '201', msg: '你的爱意传递失败~' });
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
app.listen(port, () => {
|
|
100
|
-
console.log(`💖 启动成功!端口:${port}`);
|
|
101
|
-
console.log(`✨ 运行环境:${process.env.NODE_ENV || 'development'}`);
|
|
102
|
-
console.log(`🌍 访问地址:http://localhost:${port}`);
|
|
103
|
-
});
|
|
1
|
+
const { createApp } = require('./src/app');
|
|
2
|
+
const { port, isVercel } = require('./src/config');
|
|
3
|
+
|
|
4
|
+
const app = createApp();
|
|
5
|
+
|
|
6
|
+
if (isVercel) {
|
|
7
|
+
module.exports = app;
|
|
8
|
+
} else {
|
|
9
|
+
app.listen(port, () => {
|
|
10
|
+
console.log(`💖 启动成功!端口:${port}`);
|
|
11
|
+
console.log(`✨ 运行环境:${process.env.NODE_ENV || 'development'}`);
|
|
12
|
+
console.log(`🌍 访问地址:http://localhost:${port}`);
|
|
13
|
+
});
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@5ime/likeme",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Do you like me? - 一个可爱的互动小组件",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"start": "node index.js"
|
|
7
|
+
"start": "node index.js",
|
|
8
|
+
"dev": "node index.js"
|
|
8
9
|
},
|
|
9
10
|
"author": "iami233",
|
|
10
11
|
"license": "MIT",
|
|
11
12
|
"dependencies": {
|
|
12
|
-
"
|
|
13
|
-
"
|
|
13
|
+
"@vercel/postgres": "^0.10.0",
|
|
14
|
+
"express": "^5.1.0"
|
|
14
15
|
},
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=18.0.0"
|
package/public/index.html
CHANGED
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<meta name="description" content="Do You Like Me? -
|
|
6
|
+
<meta name="description" content="Do You Like Me? - 一个可爱的互动点赞组件">
|
|
7
7
|
<title>Do You Like Me?</title>
|
|
8
8
|
<link rel="stylesheet" href="/static/css/likeme.css">
|
|
9
9
|
<style>
|
|
10
10
|
body {
|
|
11
11
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
12
12
|
margin: 0;
|
|
13
|
-
padding: 50px 20px;
|
|
14
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
15
13
|
min-height: 100vh;
|
|
16
14
|
display: flex;
|
|
17
15
|
justify-content: center;
|
package/src/app.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
allowOrigin,
|
|
6
|
+
trustProxy,
|
|
7
|
+
infoCacheSeconds,
|
|
8
|
+
likeRateLimitMax,
|
|
9
|
+
likeRateLimitWindowMs,
|
|
10
|
+
} = require('./config');
|
|
11
|
+
const { getClientIp } = require('./ip');
|
|
12
|
+
const { getCurrentCount, likeOncePerDay } = require('./db');
|
|
13
|
+
const { ok, fail } = require('./http');
|
|
14
|
+
const { createIpRateLimiter } = require('./ratelimit');
|
|
15
|
+
|
|
16
|
+
function createApp() {
|
|
17
|
+
const app = express();
|
|
18
|
+
|
|
19
|
+
// 反代环境下正确识别客户端 IP(Vercel/CF 等)
|
|
20
|
+
app.set('trust proxy', trustProxy);
|
|
21
|
+
|
|
22
|
+
app.use((req, res, next) => {
|
|
23
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
24
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
25
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
26
|
+
res.setHeader(
|
|
27
|
+
'Permissions-Policy',
|
|
28
|
+
'geolocation=(), microphone=(), camera=()'
|
|
29
|
+
);
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.use((req, res, next) => {
|
|
34
|
+
res.header('Access-Control-Allow-Origin', allowOrigin);
|
|
35
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
36
|
+
res.header('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
|
37
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
38
|
+
next();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 健康检查
|
|
42
|
+
app.get('/healthz', (req, res) => res.status(200).send('ok'));
|
|
43
|
+
|
|
44
|
+
// 静态资源
|
|
45
|
+
app.use('/static', express.static(path.join(__dirname, '..', 'static')));
|
|
46
|
+
app.use('/demo', express.static(path.join(__dirname, '..', 'demo')));
|
|
47
|
+
app.get('/', (req, res) =>
|
|
48
|
+
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'))
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
app.get('/info', async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
// 计数查询短缓存
|
|
54
|
+
res.setHeader('Cache-Control', `public, max-age=${infoCacheSeconds}`);
|
|
55
|
+
const count = await getCurrentCount();
|
|
56
|
+
return ok(res, { count });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Error in /info:', error?.message || 'Unknown error');
|
|
59
|
+
return fail(res, '哎呀,数据偷懒跑掉了');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 点赞限流
|
|
64
|
+
const likeLimiter = createIpRateLimiter({
|
|
65
|
+
windowMs: likeRateLimitWindowMs,
|
|
66
|
+
max: likeRateLimitMax,
|
|
67
|
+
keyFn: (req) => getClientIp(req),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.get('/like', likeLimiter, async (req, res) => {
|
|
71
|
+
const ip = getClientIp(req);
|
|
72
|
+
try {
|
|
73
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
74
|
+
const result = await likeOncePerDay(ip);
|
|
75
|
+
if (!result.ok) return fail(res, '今天的爱意已经收到啦~');
|
|
76
|
+
return ok(res, { count: result.count });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Error in /like:', error?.message || 'Unknown error');
|
|
79
|
+
return fail(res, '你的爱意传递失败~');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return app;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { createApp };
|
|
87
|
+
|
package/src/config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function env(name, fallback) {
|
|
2
|
+
const v = process.env[name];
|
|
3
|
+
if (v === undefined || v === null || v === '') return fallback;
|
|
4
|
+
return v;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
port: Number(env('PORT', '3000')),
|
|
9
|
+
allowOrigin: env('ALLOW_ORIGIN', '*'),
|
|
10
|
+
trustProxy: env('TRUST_PROXY', '1'),
|
|
11
|
+
infoCacheSeconds: Number(env('INFO_CACHE_SECONDS', '5')),
|
|
12
|
+
likeRateLimitWindowMs: Number(env('LIKE_RATE_LIMIT_WINDOW_MS', '60000')),
|
|
13
|
+
likeRateLimitMax: Number(env('LIKE_RATE_LIMIT_MAX', '20')),
|
|
14
|
+
isVercel: Boolean(process.env.VERCEL),
|
|
15
|
+
};
|
|
16
|
+
|
package/src/db.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const { sql } = require('@vercel/postgres');
|
|
2
|
+
|
|
3
|
+
let schemaInitPromise = null;
|
|
4
|
+
|
|
5
|
+
async function ensureSchema() {
|
|
6
|
+
if (schemaInitPromise) return schemaInitPromise;
|
|
7
|
+
|
|
8
|
+
schemaInitPromise = (async () => {
|
|
9
|
+
// 总计数表
|
|
10
|
+
await sql`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS like_count (
|
|
12
|
+
id INT PRIMARY KEY DEFAULT 1,
|
|
13
|
+
count BIGINT NOT NULL DEFAULT 0
|
|
14
|
+
);
|
|
15
|
+
`;
|
|
16
|
+
await sql`
|
|
17
|
+
INSERT INTO like_count (id, count)
|
|
18
|
+
VALUES (1, 0)
|
|
19
|
+
ON CONFLICT (id) DO NOTHING;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
// 去重表:同一 IP 每天一次(PRIMARY KEY (ip, day))
|
|
23
|
+
await sql`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS like_user (
|
|
25
|
+
ip TEXT NOT NULL,
|
|
26
|
+
day DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
27
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
28
|
+
PRIMARY KEY (ip, day)
|
|
29
|
+
);
|
|
30
|
+
`;
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
return schemaInitPromise;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getCurrentCount() {
|
|
37
|
+
await ensureSchema();
|
|
38
|
+
const { rows } = await sql`SELECT count FROM like_count WHERE id = 1;`;
|
|
39
|
+
return Number(rows?.[0]?.count ?? 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function likeOncePerDay(ip) {
|
|
43
|
+
await ensureSchema();
|
|
44
|
+
|
|
45
|
+
const client = await sql.connect();
|
|
46
|
+
try {
|
|
47
|
+
await client.sql`BEGIN;`;
|
|
48
|
+
|
|
49
|
+
const exists = await client.sql`
|
|
50
|
+
SELECT 1
|
|
51
|
+
FROM like_user
|
|
52
|
+
WHERE ip = ${ip} AND day = CURRENT_DATE
|
|
53
|
+
LIMIT 1;
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
if (exists.rows.length > 0) {
|
|
57
|
+
await client.sql`ROLLBACK;`;
|
|
58
|
+
return { ok: false, reason: 'already_liked_today' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await client.sql`
|
|
62
|
+
INSERT INTO like_user (ip, day)
|
|
63
|
+
VALUES (${ip}, CURRENT_DATE)
|
|
64
|
+
ON CONFLICT (ip, day) DO NOTHING;
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const updated = await client.sql`
|
|
68
|
+
UPDATE like_count
|
|
69
|
+
SET count = count + 1
|
|
70
|
+
WHERE id = 1
|
|
71
|
+
RETURNING count;
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
await client.sql`COMMIT;`;
|
|
75
|
+
const count = Number(updated.rows?.[0]?.count ?? 0);
|
|
76
|
+
return { ok: true, count };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
try {
|
|
79
|
+
await client.sql`ROLLBACK;`;
|
|
80
|
+
} catch (_) {
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
} finally {
|
|
84
|
+
client.release();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
ensureSchema,
|
|
90
|
+
getCurrentCount,
|
|
91
|
+
likeOncePerDay,
|
|
92
|
+
};
|
|
93
|
+
|
package/src/http.js
ADDED
package/src/ip.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function sanitizeIp(ip) {
|
|
2
|
+
if (!ip || typeof ip !== 'string') return 'unknown';
|
|
3
|
+
// IPv6 最大长度 45
|
|
4
|
+
const cleaned = ip.trim().slice(0, 45);
|
|
5
|
+
if (!/^[a-fA-F0-9.:\-]+$/.test(cleaned)) return 'unknown';
|
|
6
|
+
return cleaned;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getClientIp(req) {
|
|
10
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
11
|
+
if (forwarded) {
|
|
12
|
+
const ip = String(forwarded).split(',')[0].trim();
|
|
13
|
+
return sanitizeIp(ip);
|
|
14
|
+
}
|
|
15
|
+
const ip =
|
|
16
|
+
req.socket?.remoteAddress ||
|
|
17
|
+
req.connection?.remoteAddress ||
|
|
18
|
+
'unknown';
|
|
19
|
+
return sanitizeIp(ip);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { getClientIp, sanitizeIp };
|
|
23
|
+
|
package/src/ratelimit.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function createIpRateLimiter({ windowMs, max, keyFn }) {
|
|
2
|
+
// key -> { count, resetAt }
|
|
3
|
+
const hits = new Map();
|
|
4
|
+
|
|
5
|
+
function cleanup(now) {
|
|
6
|
+
for (const [k, v] of hits) {
|
|
7
|
+
if (v.resetAt <= now) hits.delete(k);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return function rateLimit(req, res, next) {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
cleanup(now);
|
|
14
|
+
|
|
15
|
+
const key = (keyFn ? keyFn(req) : undefined) || 'unknown';
|
|
16
|
+
const existing = hits.get(key);
|
|
17
|
+
|
|
18
|
+
if (!existing || existing.resetAt <= now) {
|
|
19
|
+
hits.set(key, { count: 1, resetAt: now + windowMs });
|
|
20
|
+
return next();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
existing.count += 1;
|
|
24
|
+
if (existing.count > max) {
|
|
25
|
+
res.setHeader(
|
|
26
|
+
'Retry-After',
|
|
27
|
+
String(Math.ceil((existing.resetAt - now) / 1000))
|
|
28
|
+
);
|
|
29
|
+
return res.status(429).json({ code: '201', msg: '请求太频繁啦,稍后再试~' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return next();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { createIpRateLimiter };
|
|
37
|
+
|
package/static/js/likeme.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const likeMe=(()=>{let instance;return options=>instance||(instance=new LikeMeSingleton(options))})();class LikeMeSingleton{constructor({el,serverURL,color='#ff9797'}){Object.assign(this,{el,url:serverURL,color});this.isLiking=false;this.init()}async init(){try{await this.renderUI();this.attachEvents();this.updateCount()}catch(error){console.error('LikeMe init error:',error)}}async renderUI(){try{const response=await fetch(this.url);const html=await response.text();const element=document.querySelector(this.el);if(element){const parser=new DOMParser();const doc=parser.parseFromString(html,'text/html');element.innerHTML=doc.body.innerHTML;const card=element.querySelector('.likeCard');if(card){card.style.setProperty('background-color',this.color)}}}catch(error){console.error('Render UI error:',error)}}attachEvents(){const card=document.querySelector(`${this.el} .likeCard`);if(card){card.addEventListener('click',()=>this.handleLike())}}async updateCount(){try{const response=await fetch(`${this.url}/info`);
|
|
1
|
+
const likeMe=(()=>{let instance;return options=>instance||(instance=new LikeMeSingleton(options))})();class LikeMeSingleton{constructor({el,serverURL,color='#ff9797'}){Object.assign(this,{el,url:serverURL,color});this.isLiking=false;this.init()}async init(){try{await this.renderUI();this.attachEvents();this.updateCount()}catch(error){console.error('LikeMe init error:',error)}}async renderUI(){try{const response=await fetch(this.url);const html=await response.text();const element=document.querySelector(this.el);if(element){const parser=new DOMParser();const doc=parser.parseFromString(html,'text/html');element.innerHTML=doc.body.innerHTML;const card=element.querySelector('.likeCard');if(card){card.style.setProperty('background-color',this.color)}}}catch(error){console.error('Render UI error:',error)}}attachEvents(){const card=document.querySelector(`${this.el} .likeCard`);if(card){card.addEventListener('click',()=>this.handleLike())}}async updateCount(){try{const response=await fetch(`${this.url}/info`);const data=await response.json().catch(()=>null);if(response.ok&&data){const count=data.data?.count||0;const textElement=document.querySelector(`${this.el} .likeCard-text`);if(textElement){textElement.textContent=`❤ ${count}`}}}catch(error){console.error('Update count error:',error)}}setUIState(isLoading,text,isSuccess=false){const card=document.querySelector(`${this.el} .likeCard`);const textElement=document.querySelector(`${this.el} .likeCard-text`);if(card&&textElement){card.classList.remove('loading','success');if(isLoading){card.classList.add('loading')}else if(isSuccess){card.classList.add('success')}textElement.textContent=text}}async handleLike(){if(this.isLiking)return;this.isLiking=true;this.setUIState(true,'❤ 爱意传递中...');try{const response=await fetch(`${this.url}/like`);const payload=await response.json().catch(()=>null);if(!payload){this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500);return}const{code,data={},msg}=payload;const count=data.count||0;if(response.ok&&code==='200'){this.setUIState(false,'❤ 传递成功~',true);setTimeout(()=>{this.setUIState(false,`❤ ${count}`)},500);return}this.setUIState(false,msg||'❤ 传递失败~');setTimeout(()=>{this.updateCount()},1500)}catch(error){console.error('Handle like error:',error);this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500)}finally{this.isLiking=false}}}
|