@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 ADDED
@@ -0,0 +1,7 @@
1
+ POSTGRES_URL=postgres://...
2
+ PORT=3000
3
+ ALLOW_ORIGIN=*
4
+ TRUST_PROXY=1
5
+ INFO_CACHE_SECONDS=5
6
+ LIKE_RATE_LIMIT_WINDOW_MS=60000
7
+ LIKE_RATE_LIMIT_MAX=20
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
- > 🍰 快速为你的网站添加 "你喜欢我吗 / Do you like me" 小组件
4
-
5
- ---
6
-
7
- ## 功能特性
8
-
9
- - 🎨 **可自定义颜色** - 支持自定义主题色彩
10
- - 💾 **数据持久化** - 使用LeanCloud存储点赞数据
11
- - 🚀 **快速部署** - 一键部署到Vercel
12
- - 📱 **响应式设计** - 完美适配移动端和桌面端
13
- - 🔒 **防重复点赞** - 基于IP地址防止重复点赞
14
- - ⚡ **高性能** - 轻量级,加载速度快
15
-
16
- ---
17
-
18
- ## 快速开始
19
-
20
- ### 1. LeanCloud 设置
21
-
22
- 1. [登录](https://console.leancloud.app/login) 或 [注册](https://console.leancloud.app/register) `LeanCloud 国际版` 并进入 [控制台](https://console.leancloud.app/apps)。
23
- 2. 点击左上角 [创建应用](https://console.leancloud.app/apps) 并起一个你喜欢的名字(请选择免费的 **开发版**)。
24
- 3. 进入应用,选择左下角的 **设置 > 应用凭证**,你可以看到你的 **APP ID**、**APP Key** 和 **REST API 服务器地址**,请记录它们以备后续使用。
25
-
26
- ### 2. 创建数据表
27
-
28
- 进入应用,选择左上角的 **数据存储 > 结构化数据**,依次创建以下两个 Class:
29
-
30
- #### 创建 likeCount 表
31
- - **Class 名称**:`likeCount`
32
- - **Class 访问权限**:除 `update` 和 `find` 外均选择 **指定用户**
33
- - **默认 ACL 权限**:选定为 **限制读取**,然后点击 **创建**
34
-
35
- #### 创建 likeUser 表
36
- - **Class 名称**:`likeUser`
37
- - **Class 访问权限**:除 `create` 和 `find` 外均选择 **指定用户**
38
- - **默认 ACL 权限**:选定为 **限制读取**,然后点击 **创建**
39
-
40
- ### 3. 初始化数据
41
-
42
- 1. 选择新建的 `likeCount` Class,点击 **添加行**
43
- 2. 添加一个名为 `count` 的 **Number** 字段,设置值为 `0`
44
- 3. 将 **ACL 权限** 设置为 **所有用户**,点击 **添加**
45
- 4. 记录生成行的 `objectId`
46
-
47
- 此时你应该拥有以下信息:
48
- - `APP ID`
49
- - `APP Key`
50
- - `REST API 服务器地址`
51
- - `objectId` 🎉
52
-
53
- ---
54
-
55
- ## 部署到 Vercel
56
-
57
- ### 方法一:一键部署
58
-
59
- [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/5ime/likeMe)
60
-
61
- 1. 点击上方按钮跳转至 **Vercel** 进行部署
62
- 2. 输入一个你喜欢的 Vercel 项目名称并点击 **Create**
63
- 3. 等待部署完成
64
- 4. 点击 **Go to Dashboard** 跳转到控制台
65
-
66
- ### 方法二:手动部署
67
-
68
- 1. Fork 或 Clone 本项目到你的 GitHub
69
- 2. 在 Vercel 中导入你的项目
70
- 3. 配置构建设置(通常会自动检测)
71
-
72
- ### 配置环境变量
73
-
74
- 进入 **Settings > Environment Variables** 配置以下环境变量:
75
-
76
- | 变量名 | 说明 | 示例 |
77
- |--------|------|------|
78
- | `appId` | LeanCloud 的 APP ID | `your-app-id` |
79
- | `appKey` | LeanCloud 的 APP Key | `your-app-key` |
80
- | `serverURL` | LeanCloud 的 REST API 服务器地址 | `https://your-app.lc-cn-n1-shared.com` |
81
- | `objectId` | likeCount 表中记录的 objectId | `your-object-id` |
82
-
83
- ### 重新部署
84
-
85
- 环境变量配置完成后,进入 **Deployments**,找到最新一次部署并点击 **Redeploy** 按钮以使环境变量生效。
86
-
87
- 部署完成后,点击 **Visit** 跳转到部署好的地址,此地址即为你的服务端地址。
88
-
89
- ---
90
-
91
- ## 本地开发
92
-
93
- ### 环境要求
94
-
95
- - Node.js >= 18.0.0
96
- - npm 或 yarn
97
-
98
- ### 安装依赖
99
-
100
- ```bash
101
- npm install
102
- ```
103
-
104
- ### 配置环境变量
105
-
106
- 创建 `.env` 文件(或在系统环境变量中设置):
107
-
108
- ```bash
109
- appId=your-leancloud-app-id
110
- appKey=your-leancloud-app-key
111
- serverURL=your-leancloud-server-url
112
- objectId=your-object-id
113
- PORT=3000
114
- ```
115
-
116
- ### 启动开发服务器
117
-
118
- ```bash
119
- npm start
120
- ```
121
-
122
- 服务器将在 `http://localhost:3000` 启动。
123
-
124
- ---
125
-
126
- ## 在你的网站中使用
127
-
128
- ### 方法一:使用 CDN
129
-
130
- 在你的网页中添加以下代码:
131
-
132
- ```html
133
- <link rel="stylesheet" href="https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css" />
134
- <script src="https://unpkg.com/@5ime/likeme@latest/static/js/likeme.js"></script>
135
-
136
- <div id="likeme"></div>
137
-
138
- <script>
139
- likeMe({
140
- el: '#likeme',
141
- serverURL: 'https://your-domain.vercel.app', // 替换为你的服务端地址
142
- color: '#ff9797' // 可选:自定义主题颜色
143
- });
144
- </script>
145
- ```
146
-
147
- ### 方法二:下载使用
148
-
149
- 1. 下载 `static/css/likeme.css` 和 `static/js/likeme.js`
150
- 2. 在你的项目中引入这些文件
151
- 3. 按照上面的方式初始化组件
152
-
153
- ### 配置选项
154
-
155
- ```javascript
156
- likeMe({
157
- el: '#likeme', // 必需:容器选择器
158
- serverURL: 'https://your-domain.vercel.app', // 必需:服务端地址
159
- color: '#ff9797' // 可选:主题颜色,默认为 #ff9797
160
- });
161
- ```
162
-
163
- ---
164
-
165
- ## 自定义域名(可选)
166
-
167
- 1. 进入 `Vercel` 控制台的 **Settings > Domains**
168
- 2. 输入需要绑定的域名并点击 **Add**
169
- 3. 在域名服务商处添加 `CNAME` 记录:
170
- - **Type**: `CNAME`
171
- - **Name**: `your-subdomain`
172
- - **Value**: `cname.vercel-dns.com`
173
- 4. 等待 `DNS` 生效后,即可通过自定义域名访问 🎉
174
-
175
- ---
176
-
177
- ## API 接口
178
-
179
- ### 获取点赞数
180
-
181
- ```
182
- GET /info
183
- ```
184
-
185
- 返回:
186
- ```json
187
- {
188
- "code": "200",
189
- "data": {
190
- "count": 42
191
- },
192
- "msg": "success"
193
- }
194
- ```
195
-
196
- ### 提交点赞
197
-
198
- ```
199
- GET /like
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
+ [![Deploy with Vercel](https://vercel.com/button)](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 express = require('express');
2
- const AV = require('leancloud-storage');
3
-
4
- const app = express();
5
- const port = process.env.PORT || 80;
6
-
7
- AV.init({
8
- appId: process.env.appId,
9
- appKey: process.env.appKey,
10
- serverURL: process.env.serverURL
11
- });
12
-
13
- const OBJECT_ID = process.env.objectId;
14
- const account = AV.Object.createWithoutData('likeCount', process.env.objectId);
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",
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
- "express": "^5.1.0",
13
- "leancloud-storage": "^4.15.2"
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
@@ -0,0 +1,10 @@
1
+ function ok(res, data) {
2
+ return res.json({ code: '200', msg: 'success', data });
3
+ }
4
+
5
+ function fail(res, msg) {
6
+ return res.json({ code: '201', msg });
7
+ }
8
+
9
+ module.exports = { ok, fail };
10
+
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
+
@@ -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
+
@@ -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`);if(response.ok){const data=await response.json();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`);if(response.ok){const{code,data={},msg}=await response.json();const count=data.count||0;if(code==='200'){this.setUIState(false,'❤ 传递成功~',true);setTimeout(()=>{this.setUIState(false,`❤ ${count}`)},500)}else{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}}}
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}}}