feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -1,543 +0,0 @@
|
||||
# 萌芽账户认证中心 API 文档
|
||||
|
||||
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs` 与 `/api/health` 入口、路由前缀摘要)。
|
||||
|
||||
接入地址:
|
||||
- 统一登录前端:`https://auth.shumengya.top`
|
||||
- 后端 API:`https://auth.api.shumengya.top`
|
||||
- 本地开发 API:`http://<host>:8080`
|
||||
|
||||
对外接入建议:
|
||||
1. 第三方应用按钮跳转到统一登录前端。
|
||||
2. 登录成功后回跳到业务站点。
|
||||
3. 业务站点使用回跳带回的 `token` 调用后端 API。
|
||||
|
||||
示例按钮:
|
||||
```html
|
||||
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
|
||||
使用萌芽统一账户认证登录
|
||||
</a>
|
||||
```
|
||||
|
||||
回跳说明:
|
||||
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
|
||||
- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
|
||||
- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**(无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。
|
||||
|
||||
### 统一登录前端:查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
|
||||
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
|
||||
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
|
||||
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
|
||||
| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64)。写入用户「应用接入记录」,并随登录请求提交给后端。 |
|
||||
| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 |
|
||||
|
||||
### 回跳 URL:`#` 哈希参数
|
||||
|
||||
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
|
||||
| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 |
|
||||
| `account` | 账户名(与 JWT `sub` 一致)。 |
|
||||
| `username` | 展示用昵称,可能为空。 |
|
||||
| `state` | 若登录请求携带了 `state`,则原样返回。 |
|
||||
|
||||
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
|
||||
|
||||
### 第三方后端接入建议
|
||||
|
||||
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`(JSON body:`{"token":"..."}`),根据 `valid` 与 `user.account` 建立会话。
|
||||
2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
|
||||
3. **令牌过期**:`verify` / `me` 返回 401 或 `verify` 中 `valid:false` 时,应引导用户重新走统一登录。
|
||||
|
||||
## 认证与统一登录
|
||||
|
||||
### 登录获取统一令牌
|
||||
`POST /api/auth/login`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"clientId": "my-app",
|
||||
"clientName": "我的应用"
|
||||
}
|
||||
```
|
||||
|
||||
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token",
|
||||
"expiresAt": "2026-03-14T12:00:00Z",
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00Z",
|
||||
"updatedAt": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
`banReason` 可能为空字符串或省略。
|
||||
|
||||
**常见 HTTP 状态码(登录)**
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 200 | 成功,返回 `token`、`expiresAt`、`user`。 |
|
||||
| 400 | 请求体非法或缺少 `account` / `password`。 |
|
||||
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
|
||||
| 403 | 账户已封禁(见上文 JSON)。 |
|
||||
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
|
||||
|
||||
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
|
||||
|
||||
### 校验令牌
|
||||
`POST /api/auth/verify`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
若账户已封禁,返回 **200** 且 `valid` 为 **false**(不返回 `user` 对象),示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`。
|
||||
|
||||
`verify` 与 `me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。
|
||||
|
||||
**应用接入记录(可选)**:第三方在 **`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头:
|
||||
|
||||
- `X-Auth-Client`:应用 ID(格式同登录 JSON 的 `clientId`)
|
||||
- `X-Auth-Client-Name`:可选展示名
|
||||
|
||||
校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId`、`displayName`、`firstSeenAt`、`lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user`(`OwnerPublic`)**包含** `authClients`,用户可在统一登录前端的个人中心查看。
|
||||
|
||||
### 获取当前用户信息
|
||||
`GET /api/auth/me`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」):
|
||||
- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可)
|
||||
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`)
|
||||
|
||||
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." },
|
||||
"checkIn": {
|
||||
"rewardCoins": 1,
|
||||
"checkedInToday": false,
|
||||
"lastCheckInDate": "",
|
||||
"lastCheckInAt": "",
|
||||
"today": "2026-03-14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
|
||||
|
||||
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account` 与 **`POST /api/auth/verify` 的 `user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
|
||||
|
||||
### 每日签到
|
||||
`POST /api/auth/check-in`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"checkedIn": true,
|
||||
"alreadyCheckedIn": false,
|
||||
"rewardCoins": 1,
|
||||
"awardedCoins": 1,
|
||||
"message": "签到成功",
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 更新当前用户资料
|
||||
`PUT /api/auth/profile`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 新简介"
|
||||
}
|
||||
```
|
||||
|
||||
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 用户广场
|
||||
|
||||
### 获取用户公开主页
|
||||
`GET /api/public/users/{account}`
|
||||
|
||||
说明:
|
||||
- 仅支持账户名 `account`,不支持昵称查询。
|
||||
- 适合第三方应用展示用户公开资料。
|
||||
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
|
||||
- 响应中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 3,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"lastVisitIp": "203.0.113.1",
|
||||
"lastVisitDisplayLocation": "中国 广东省 深圳市",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 公开注册策略
|
||||
`GET /api/public/registration-policy`
|
||||
|
||||
无需鉴权。用于前端判断是否展示「邀请码」输入框。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"requireInviteCode": false
|
||||
}
|
||||
```
|
||||
|
||||
当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
|
||||
|
||||
### 注册账号(发送邮箱验证码)
|
||||
`POST /api/auth/register`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"inviteCode": "ABCD1234"
|
||||
}
|
||||
```
|
||||
|
||||
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证邮箱并完成注册
|
||||
`POST /api/auth/verify-email`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 忘记密码(发送重置验证码)
|
||||
`POST /api/auth/forgot-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"email": "demo@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
`POST /api/auth/reset-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456",
|
||||
"newPassword": "newpass"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "reset": true }
|
||||
```
|
||||
|
||||
### 申请添加辅助邮箱(发送验证码)
|
||||
`POST /api/auth/secondary-email/request`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证辅助邮箱
|
||||
`POST /api/auth/secondary-email/verify`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 管理端接口(需要管理员 Token)
|
||||
|
||||
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
|
||||
请求时可使用以下任一方式携带:
|
||||
- Query:`?token=<admin-token>`
|
||||
- Header:`X-Admin-Token: <admin-token>`
|
||||
|
||||
### 签到奖励设置
|
||||
`GET /api/admin/check-in/config`
|
||||
|
||||
`PUT /api/admin/check-in/config`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"rewardCoins": 1
|
||||
}
|
||||
```
|
||||
- Header:`Authorization: Bearer <admin-token>`
|
||||
|
||||
### 注册策略与邀请码
|
||||
|
||||
`GET /api/admin/registration`
|
||||
|
||||
响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。
|
||||
|
||||
`PUT /api/admin/registration`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "requireInviteCode": true }
|
||||
```
|
||||
|
||||
`POST /api/admin/registration/invites`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"note": "内测批次",
|
||||
"maxUses": 10,
|
||||
"expiresAt": "2026-12-31T15:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。
|
||||
|
||||
`DELETE /api/admin/registration/invites/{code}`
|
||||
|
||||
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
|
||||
|
||||
### 获取用户列表
|
||||
`GET /api/admin/users`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"users": [{ "account": "demo", "...": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
### 新建用户
|
||||
`POST /api/admin/users`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新用户
|
||||
`PUT /api/admin/users/{account}`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"level": 1,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"sproutCoins": 99,
|
||||
"websiteUrl": "https://example.com",
|
||||
"banned": true,
|
||||
"banReason": "违规说明(最多 500 字)"
|
||||
}
|
||||
```
|
||||
|
||||
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
|
||||
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于用户 JSON)。
|
||||
|
||||
管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned`、`banReason`(不含 `bannedAt` 亦可从存储文件中查看)。
|
||||
|
||||
### 删除用户
|
||||
`DELETE /api/admin/users/{account}`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "deleted": true }
|
||||
```
|
||||
|
||||
## 数据存储说明
|
||||
|
||||
- 用户数据:`data/users/*.json`
|
||||
- 注册待验证:`data/pending/*.json`
|
||||
- 密码重置记录:`data/reset/*.json`
|
||||
- 辅助邮箱验证:`data/secondary/*.json`
|
||||
- 管理员 Token:`data/config/admin.json`
|
||||
- JWT 配置:`data/config/auth.json`
|
||||
- 邮件配置:`data/config/email.json`
|
||||
- 注册策略与邀请码:`data/config/registration.json`
|
||||
|
||||
## 快速联调用示例
|
||||
|
||||
```bash
|
||||
# 服务根路径 JSON 说明
|
||||
curl -s http://localhost:8080/ | jq .
|
||||
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"demo","password":"demo123"}'
|
||||
|
||||
# 校验令牌(推荐第三方网关先调此接口)
|
||||
curl -X POST http://localhost:8080/api/auth/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token":"<jwt-token>"}'
|
||||
|
||||
# 使用令牌获取用户信息(会更新访问记录)
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H 'Authorization: Bearer <jwt-token>'
|
||||
```
|
||||
218
sproutgate-backend/cmd/migrate/main.go
Normal file
218
sproutgate-backend/cmd/migrate/main.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// migrate 将旧版 JSON 文件数据迁移到 MySQL 数据库。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/migrate [--data-dir ./data]
|
||||
//
|
||||
// 环境变量(与主程序相同):
|
||||
//
|
||||
// APP_ENV=production 使用生产数据库(默认使用开发测试库)
|
||||
// DB_DSN 自定义 DSN(优先级最高)
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/database"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data-dir", "./data", "旧版 JSON 数据目录路径")
|
||||
flag.Parse()
|
||||
|
||||
absDir, err := filepath.Abs(*dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("路径解析失败: %v", err)
|
||||
}
|
||||
log.Printf("数据目录: %s", absDir)
|
||||
|
||||
db, err := database.Open()
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移表结构
|
||||
if err := db.AutoMigrate(
|
||||
&storage.DBUser{},
|
||||
&storage.DBPendingUser{},
|
||||
&storage.DBResetPassword{},
|
||||
&storage.DBSecondaryEmailVerification{},
|
||||
&storage.DBAppConfig{},
|
||||
&storage.DBInviteCode{},
|
||||
); err != nil {
|
||||
log.Fatalf("表结构同步失败: %v", err)
|
||||
}
|
||||
log.Println("✓ 表结构已同步")
|
||||
|
||||
migrateAdminConfig(db, absDir)
|
||||
migrateAuthConfig(db, absDir)
|
||||
migrateEmailConfig(db, absDir)
|
||||
migrateCheckinConfig(db, absDir)
|
||||
migrateRegistrationConfig(db, absDir)
|
||||
migrateUsers(db, absDir)
|
||||
|
||||
log.Println("\n✅ 数据迁移完成!")
|
||||
}
|
||||
|
||||
// ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func readJSONFile(path string, target any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
// putConfig 将任意值序列化为 JSON 后写入 app_configs(冲突时整体覆盖)。
|
||||
func putConfig(db *gorm.DB, key string, value any) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
log.Printf(" ✗ 序列化 [%s] 失败: %v", key, err)
|
||||
return
|
||||
}
|
||||
row := storage.DBAppConfig{ConfigKey: key, ConfigValue: string(raw)}
|
||||
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil {
|
||||
log.Printf(" ✗ app_configs[%s] 失败: %v", key, err)
|
||||
} else {
|
||||
log.Printf(" ✓ app_configs[%s]", key)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 各节配置迁移 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func migrateAdminConfig(db *gorm.DB, dataDir string) {
|
||||
path := filepath.Join(dataDir, "config", "admin.json")
|
||||
var cfg storage.AdminConfig
|
||||
if err := readJSONFile(path, &cfg); err != nil {
|
||||
log.Printf("[admin] 跳过(%v)", err)
|
||||
return
|
||||
}
|
||||
putConfig(db, "admin", cfg)
|
||||
}
|
||||
|
||||
func migrateAuthConfig(db *gorm.DB, dataDir string) {
|
||||
path := filepath.Join(dataDir, "config", "auth.json")
|
||||
var cfg storage.AuthConfig
|
||||
if err := readJSONFile(path, &cfg); err != nil {
|
||||
log.Printf("[auth] 跳过(%v)", err)
|
||||
return
|
||||
}
|
||||
putConfig(db, "auth", cfg)
|
||||
}
|
||||
|
||||
func migrateEmailConfig(db *gorm.DB, dataDir string) {
|
||||
path := filepath.Join(dataDir, "config", "email.json")
|
||||
var cfg storage.EmailConfig
|
||||
if err := readJSONFile(path, &cfg); err != nil {
|
||||
log.Printf("[email] 跳过(%v)", err)
|
||||
return
|
||||
}
|
||||
putConfig(db, "email", cfg)
|
||||
}
|
||||
|
||||
func migrateCheckinConfig(db *gorm.DB, dataDir string) {
|
||||
path := filepath.Join(dataDir, "config", "checkin.json")
|
||||
var cfg storage.CheckInConfig
|
||||
if err := readJSONFile(path, &cfg); err != nil {
|
||||
log.Printf("[checkin] 跳过(%v)", err)
|
||||
return
|
||||
}
|
||||
putConfig(db, "checkin", cfg)
|
||||
}
|
||||
|
||||
type oldRegistrationConfig struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
Invites []storage.InviteEntry `json:"invites"`
|
||||
}
|
||||
|
||||
type registrationPolicy struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"`
|
||||
}
|
||||
|
||||
func migrateRegistrationConfig(db *gorm.DB, dataDir string) {
|
||||
path := filepath.Join(dataDir, "config", "registration.json")
|
||||
var old oldRegistrationConfig
|
||||
if err := readJSONFile(path, &old); err != nil {
|
||||
log.Printf("[registration] 跳过(%v)", err)
|
||||
return
|
||||
}
|
||||
|
||||
putConfig(db, "registration", registrationPolicy{RequireInviteCode: old.RequireInviteCode})
|
||||
|
||||
for _, entry := range old.Invites {
|
||||
row := storage.DBInviteCode{
|
||||
Code: entry.Code,
|
||||
Note: entry.Note,
|
||||
MaxUses: entry.MaxUses,
|
||||
Uses: entry.Uses,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
log.Printf(" ✗ 邀请码 %s 失败: %v", entry.Code, err)
|
||||
} else {
|
||||
log.Printf(" ✓ 邀请码 %s", entry.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateUsers(db *gorm.DB, dataDir string) {
|
||||
usersDir := filepath.Join(dataDir, "users")
|
||||
entries, err := os.ReadDir(usersDir)
|
||||
if err != nil {
|
||||
log.Printf("[users] 读取目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
total, ok, skipped := 0, 0, 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
path := filepath.Join(usersDir, entry.Name())
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
log.Printf(" ✗ 读取 %s 失败: %v", entry.Name(), err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 优先用 JSON 中的 account 字段,没有则从文件名(base64)解码
|
||||
if strings.TrimSpace(record.Account) == "" {
|
||||
name := strings.TrimSuffix(entry.Name(), ".json")
|
||||
decoded, decErr := base64.RawURLEncoding.DecodeString(name)
|
||||
if decErr != nil || len(decoded) == 0 {
|
||||
log.Printf(" ✗ 无法确定账号,跳过 %s", entry.Name())
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
record.Account = string(decoded)
|
||||
}
|
||||
|
||||
row := storage.DBUserFromRecord(record)
|
||||
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil {
|
||||
log.Printf(" ✗ 用户 %s 失败: %v", record.Account, err)
|
||||
skipped++
|
||||
} else {
|
||||
log.Printf(" ✓ 用户 %s", record.Account)
|
||||
ok++
|
||||
}
|
||||
}
|
||||
log.Printf("[users] 合计 %d 个,成功 %d,跳过 %d", total, ok, skipped)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
@@ -19,7 +20,10 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
@@ -33,7 +37,9 @@ require (
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
@@ -25,12 +27,18 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -83,6 +91,8 @@ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
@@ -91,5 +101,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -8,17 +8,19 @@ import (
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Account string `json:"account"`
|
||||
Account string `json:"account"`
|
||||
TokenEpoch int64 `json:"epoch"` // 与 users.token_epoch 一致;管理员封禁等操作会递增,旧 JWT 全部作废
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(secret []byte, issuer string, account string, ttl time.Duration) (string, time.Time, error) {
|
||||
func GenerateToken(secret []byte, issuer string, account string, tokenEpoch int64, ttl time.Duration) (string, time.Time, error) {
|
||||
if account == "" {
|
||||
return "", time.Time{}, errors.New("account is required")
|
||||
}
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
claims := Claims{
|
||||
Account: account,
|
||||
Account: account,
|
||||
TokenEpoch: tokenEpoch,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: account,
|
||||
|
||||
84
sproutgate-backend/internal/database/db.go
Normal file
84
sproutgate-backend/internal/database/db.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DBConfig holds MySQL connection parameters.
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
}
|
||||
|
||||
func (c DBConfig) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
c.User, c.Password, c.Host, c.Port, c.DBName)
|
||||
}
|
||||
|
||||
func devConfig() DBConfig {
|
||||
return DBConfig{
|
||||
Host: "10.1.1.100",
|
||||
Port: "3306",
|
||||
User: "sproutgate-test",
|
||||
Password: "sproutgate-test",
|
||||
DBName: "sproutgate-test",
|
||||
}
|
||||
}
|
||||
|
||||
func prodConfig() DBConfig {
|
||||
return DBConfig{
|
||||
Host: "192.168.1.100",
|
||||
Port: "3306",
|
||||
User: "sproutgate",
|
||||
Password: "sproutgate",
|
||||
DBName: "sproutgate",
|
||||
}
|
||||
}
|
||||
|
||||
// Open 根据环境变量打开数据库连接。
|
||||
// 优先级:DB_DSN > APP_ENV(production/prod -> 生产库) > 默认开发库
|
||||
func Open() (*gorm.DB, error) {
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "production" || env == "prod" {
|
||||
dsn = prodConfig().DSN()
|
||||
log.Println("[database] 使用生产数据库 192.168.1.100:3306/sproutgate")
|
||||
} else {
|
||||
dsn = devConfig().DSN()
|
||||
log.Println("[database] 使用开发数据库 10.1.1.100:3306/sproutgate-test")
|
||||
}
|
||||
} else {
|
||||
log.Println("[database] 使用自定义 DB_DSN")
|
||||
}
|
||||
|
||||
logLevel := logger.Info
|
||||
if os.Getenv("GIN_MODE") == "release" {
|
||||
logLevel = logger.Warn
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -23,30 +23,6 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.PublicProfile()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -151,7 +127,11 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if req.Banned != nil {
|
||||
wasBanned := user.Banned
|
||||
user.Banned = *req.Banned
|
||||
if user.Banned && !wasBanned {
|
||||
user.TokenEpoch++
|
||||
}
|
||||
if !user.Banned {
|
||||
user.BanReason = ""
|
||||
user.BannedAt = ""
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, user.TokenEpoch, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
@@ -82,11 +82,10 @@ func (h *Handler) Verify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
h := gin.H{"valid": false, "error": "account is banned"}
|
||||
if r := strings.TrimSpace(user.BanReason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusOK, h)
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
@@ -118,6 +117,9 @@ func (h *Handler) Me(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
|
||||
user = rec
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
@@ -19,13 +20,17 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
inviteTrim := strings.TrimSpace(req.InviteCode)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
requireInv := h.store.RegistrationRequireInvite()
|
||||
if requireInv && inviteTrim == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
|
||||
@@ -89,12 +94,16 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pending, found, err := h.store.GetPending(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
||||
@@ -114,13 +123,17 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
sproutCoins := 0
|
||||
if strings.TrimSpace(pending.InviteCode) != "" {
|
||||
sproutCoins = h.store.RegistrationInviteRegisterRewardCoins()
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: pending.Account,
|
||||
PasswordHash: pending.PasswordHash,
|
||||
Username: pending.Username,
|
||||
Email: pending.Email,
|
||||
Level: 0,
|
||||
SproutCoins: 0,
|
||||
SproutCoins: sproutCoins,
|
||||
SecondaryEmails: []string{},
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
|
||||
@@ -35,6 +35,9 @@ func (h *Handler) CheckIn(c *gin.Context) {
|
||||
if abortIfUserBanned(c, userPre) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, userPre) {
|
||||
return
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
nowAt := models.CurrentActivityTime()
|
||||
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -54,13 +55,22 @@ func verifyCode(code string, hash string) bool {
|
||||
}
|
||||
|
||||
func writeBanJSON(c *gin.Context, reason string) {
|
||||
h := gin.H{"error": "account is banned"}
|
||||
h := gin.H{"error": "account is banned", "valid": false}
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusForbidden, h)
|
||||
}
|
||||
|
||||
// abortIfTokenEpochStale 若 JWT 内 epoch 与用户当前 token_epoch 不一致,则拒绝(例如管理员已封禁并递增 epoch)。
|
||||
func abortIfTokenEpochStale(c *gin.Context, claims *auth.Claims, u models.UserRecord) bool {
|
||||
if claims.TokenEpoch == u.TokenEpoch {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "token revoked"})
|
||||
return true
|
||||
}
|
||||
|
||||
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
|
||||
if !u.Banned {
|
||||
return false
|
||||
|
||||
@@ -38,6 +38,9 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
154
sproutgate-backend/internal/handlers/public_profile.go
Normal file
154
sproutgate-backend/internal/handlers/public_profile.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
// ListPublicUsers 公开用户目录(未封禁用户,默认按注册时间从早到晚)。
|
||||
func (h *Handler) ListPublicUsers(c *gin.Context) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
out := make([]models.PublicUserListEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.Banned {
|
||||
continue
|
||||
}
|
||||
out = append(out, u.PublicListEntry())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.TrimSpace(out[i].CreatedAt) < strings.TrimSpace(out[j].CreatedAt)
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(out), "users": out})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
likeCount, err := h.store.CountProfileLikes(user.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load likes"})
|
||||
return
|
||||
}
|
||||
payload := gin.H{
|
||||
"user": user.PublicProfile(),
|
||||
"profileLikeCount": likeCount,
|
||||
}
|
||||
if token := bearerToken(c.GetHeader("Authorization")); token != "" {
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err == nil {
|
||||
viewer, vFound, vErr := h.store.GetUser(claims.Account)
|
||||
if vErr == nil && vFound && !viewer.Banned && claims.TokenEpoch == viewer.TokenEpoch {
|
||||
if strings.EqualFold(viewer.Account, user.Account) {
|
||||
payload["viewerIsOwner"] = true
|
||||
} else {
|
||||
has, _ := h.store.ViewerHasLikedProfileToday(viewer.Account, user.Account)
|
||||
payload["viewerHasLikedToday"] = has
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(viewer.Account)
|
||||
payload["viewerLikesRemainingToday"] = rem
|
||||
payload["profileLikeDailyMax"] = storage.MaxProfileLikesPerDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
}
|
||||
|
||||
func (h *Handler) PostPublicProfileLike(c *gin.Context) {
|
||||
likedParam := strings.TrimSpace(c.Param("account"))
|
||||
if likedParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
likerUser, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, likerUser) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, likerUser) {
|
||||
return
|
||||
}
|
||||
|
||||
newCount, err := h.store.AddProfileLike(likerUser.Account, likedParam)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrCannotLikeOwnProfile):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrProfileLikeTarget):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrAlreadyLikedToday):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrDailyLikeLimitReached):
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"viewerLikesRemainingToday": rem,
|
||||
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
|
||||
})
|
||||
return
|
||||
case errors.Is(err, storage.ErrProfileLikeLiker):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"profileLikeCount": newCount,
|
||||
"viewerHasLikedToday": true,
|
||||
"viewerLikesRemainingToday": rem,
|
||||
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
|
||||
// GetPublicRegistrationPolicy 公开:是否必须邀请码、自助注册账号规则说明(不含具体邀请码)。
|
||||
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": h.store.RegistrationRequireInvite(),
|
||||
"requireInviteCode": h.store.RegistrationRequireInvite(),
|
||||
"inviteRegisterRewardCoins": h.store.RegistrationInviteRegisterRewardCoins(),
|
||||
"selfServiceAccountMin": storage.MinSelfServiceAccountLen,
|
||||
"selfServiceAccountMax": storage.MaxSelfServiceAccountLen,
|
||||
"selfServiceAccountHint": "lowercase letters and digits only",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
func (h *Handler) GetAdminRegistration(c *gin.Context) {
|
||||
cfg := h.store.GetRegistrationConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"invites": cfg.Invites,
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"forbiddenAccounts": cfg.ForbiddenAccounts,
|
||||
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
|
||||
"invites": cfg.Invites,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,11 +23,16 @@ func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
|
||||
if err := h.store.UpdateRegistrationPolicy(req.RequireInviteCode, req.ForbiddenAccounts, req.InviteRegisterRewardCoins); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
|
||||
cfg := h.store.GetRegistrationConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"forbiddenAccounts": cfg.ForbiddenAccounts,
|
||||
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PostAdminInvite(c *gin.Context) {
|
||||
|
||||
@@ -64,7 +64,9 @@ type updateCheckInConfigRequest struct {
|
||||
}
|
||||
|
||||
type updateRegistrationPolicyRequest struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项
|
||||
}
|
||||
|
||||
type createInviteRequest struct {
|
||||
|
||||
@@ -46,6 +46,9 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Email) == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
||||
return
|
||||
@@ -137,6 +140,9 @@ func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
|
||||
55
sproutgate-backend/internal/models/qq_avatar.go
Normal file
55
sproutgate-backend/internal/models/qq_avatar.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
func qqNumericLocalPart(email string) string {
|
||||
e := strings.TrimSpace(strings.ToLower(email))
|
||||
if e == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.LastIndex(e, "@")
|
||||
if at <= 0 || at >= len(e)-1 {
|
||||
return ""
|
||||
}
|
||||
local, domain := e[:at], e[at+1:]
|
||||
if domain != "qq.com" {
|
||||
return ""
|
||||
}
|
||||
if local == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range local {
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
func firstQQUINFromRecord(u UserRecord) string {
|
||||
if q := qqNumericLocalPart(u.Email); q != "" {
|
||||
return q
|
||||
}
|
||||
for _, em := range u.SecondaryEmails {
|
||||
if q := qqNumericLocalPart(em); q != "" {
|
||||
return q
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func qqAvatarHeadimgURL(uin string) string {
|
||||
return "https://q.qlogo.cn/headimg_dl?dst_uin=" + uin + "&spec=640&img_type=jpg"
|
||||
}
|
||||
|
||||
// ResolvedAvatarURL 返回用户自选头像;若未设置但主邮箱或辅助邮箱为「纯数字 @qq.com」,
|
||||
// 则返回 QQ 头像 CDN 地址(与手动填写该链接等价,不入库)。
|
||||
func (u UserRecord) ResolvedAvatarURL() string {
|
||||
if s := strings.TrimSpace(u.AvatarURL); s != "" {
|
||||
return s
|
||||
}
|
||||
if uin := firstQQUINFromRecord(u); uin != "" {
|
||||
return qqAvatarHeadimgURL(uin)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type UserRecord struct {
|
||||
Banned bool `json:"banned"`
|
||||
BanReason string `json:"banReason,omitempty"`
|
||||
BannedAt string `json:"bannedAt,omitempty"`
|
||||
TokenEpoch int64 `json:"-"` // 递增使用户此前签发的 JWT 全局失效;不对外 JSON 序列化
|
||||
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ type UserPublic struct {
|
||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
CustomAvatarURL string `json:"customAvatarUrl,omitempty"` // 用户自选头像链接;为空时 avatarUrl 可能为 QQ 邮箱推断头像
|
||||
WebsiteURL string `json:"websiteUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
@@ -85,13 +87,14 @@ func (u UserRecord) Public() UserPublic {
|
||||
LastVisitAt: lastVisitAt,
|
||||
VisitDays: visitDays,
|
||||
VisitStreak: visitStreak,
|
||||
SecondaryEmails: u.SecondaryEmails,
|
||||
Phone: u.Phone,
|
||||
AvatarURL: u.AvatarURL,
|
||||
WebsiteURL: u.WebsiteURL,
|
||||
Bio: u.Bio,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
SecondaryEmails: u.SecondaryEmails,
|
||||
Phone: u.Phone,
|
||||
AvatarURL: u.ResolvedAvatarURL(),
|
||||
CustomAvatarURL: strings.TrimSpace(u.AvatarURL),
|
||||
WebsiteURL: u.WebsiteURL,
|
||||
Bio: u.Bio,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +136,25 @@ func (u UserRecord) Showcase() UserShowcase {
|
||||
Username: u.Username,
|
||||
Level: u.Level,
|
||||
SproutCoins: u.SproutCoins,
|
||||
AvatarURL: u.AvatarURL,
|
||||
AvatarURL: u.ResolvedAvatarURL(),
|
||||
WebsiteURL: u.WebsiteURL,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
// PublicUserListEntry 公开用户目录条目(含注册时间供排序与展示)。
|
||||
type PublicUserListEntry struct {
|
||||
UserShowcase
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (u UserRecord) PublicListEntry() PublicUserListEntry {
|
||||
return PublicUserListEntry{
|
||||
UserShowcase: u.Showcase(),
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NowISO() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── JSON 序列化辅助类型 ────────────────────────────────────────────────────────
|
||||
|
||||
// StringSlice 将 []string 序列化为 JSON 字符串存入 TEXT 列。
|
||||
type StringSlice []string
|
||||
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]string(s))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("StringSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]string)(s))
|
||||
}
|
||||
|
||||
// AuthClientSlice 将 []models.AuthClientEntry 序列化为 JSON 存入 TEXT 列。
|
||||
type AuthClientSlice []models.AuthClientEntry
|
||||
|
||||
func (a AuthClientSlice) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]models.AuthClientEntry(a))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (a *AuthClientSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("AuthClientSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]models.AuthClientEntry)(a))
|
||||
}
|
||||
|
||||
// ─── GORM DB 模型 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DBUser 对应数据库 users 表。
|
||||
type DBUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
Level int `gorm:"column:level;default:0"`
|
||||
SproutCoins int `gorm:"column:sprout_coins;default:0"`
|
||||
LastCheckInDate string `gorm:"column:last_check_in_date;size:20"`
|
||||
LastCheckInAt string `gorm:"column:last_check_in_at;size:128"`
|
||||
LastVisitDate string `gorm:"column:last_visit_date;size:20"`
|
||||
LastVisitAt string `gorm:"column:last_visit_at;size:128"`
|
||||
LastVisitIP string `gorm:"column:last_visit_ip;size:45"`
|
||||
LastVisitDisplayLocation string `gorm:"column:last_visit_display_location;size:512"`
|
||||
CheckInTimes StringSlice `gorm:"column:check_in_times;type:mediumtext"`
|
||||
VisitTimes StringSlice `gorm:"column:visit_times;type:mediumtext"`
|
||||
SecondaryEmails StringSlice `gorm:"column:secondary_emails;type:text"`
|
||||
Phone string `gorm:"column:phone;size:50"`
|
||||
AvatarURL string `gorm:"column:avatar_url;size:1024"`
|
||||
WebsiteURL string `gorm:"column:website_url;size:1024"`
|
||||
Bio string `gorm:"column:bio;type:text"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50;not null"`
|
||||
Banned bool `gorm:"column:banned;default:false"`
|
||||
BanReason string `gorm:"column:ban_reason;type:text"`
|
||||
BannedAt string `gorm:"column:banned_at;size:50"`
|
||||
TokenEpoch int64 `gorm:"column:token_epoch;default:0"`
|
||||
AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"`
|
||||
}
|
||||
|
||||
func (DBUser) TableName() string { return "users" }
|
||||
|
||||
// DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。
|
||||
func DBUserFromRecord(r models.UserRecord) DBUser {
|
||||
return dbUserFromRecord(r)
|
||||
}
|
||||
|
||||
func dbUserFromRecord(r models.UserRecord) DBUser {
|
||||
return DBUser{
|
||||
Account: r.Account,
|
||||
PasswordHash: r.PasswordHash,
|
||||
Username: r.Username,
|
||||
Email: r.Email,
|
||||
Level: r.Level,
|
||||
SproutCoins: r.SproutCoins,
|
||||
LastCheckInDate: r.LastCheckInDate,
|
||||
LastCheckInAt: r.LastCheckInAt,
|
||||
LastVisitDate: r.LastVisitDate,
|
||||
LastVisitAt: r.LastVisitAt,
|
||||
LastVisitIP: r.LastVisitIP,
|
||||
LastVisitDisplayLocation: r.LastVisitDisplayLocation,
|
||||
CheckInTimes: StringSlice(r.CheckInTimes),
|
||||
VisitTimes: StringSlice(r.VisitTimes),
|
||||
SecondaryEmails: StringSlice(r.SecondaryEmails),
|
||||
Phone: r.Phone,
|
||||
AvatarURL: r.AvatarURL,
|
||||
WebsiteURL: r.WebsiteURL,
|
||||
Bio: r.Bio,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Banned: r.Banned,
|
||||
BanReason: r.BanReason,
|
||||
BannedAt: r.BannedAt,
|
||||
TokenEpoch: r.TokenEpoch,
|
||||
AuthClients: AuthClientSlice(r.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBUser) toRecord() models.UserRecord {
|
||||
return models.UserRecord{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
Level: d.Level,
|
||||
SproutCoins: d.SproutCoins,
|
||||
LastCheckInDate: d.LastCheckInDate,
|
||||
LastCheckInAt: d.LastCheckInAt,
|
||||
LastVisitDate: d.LastVisitDate,
|
||||
LastVisitAt: d.LastVisitAt,
|
||||
LastVisitIP: d.LastVisitIP,
|
||||
LastVisitDisplayLocation: d.LastVisitDisplayLocation,
|
||||
CheckInTimes: []string(d.CheckInTimes),
|
||||
VisitTimes: []string(d.VisitTimes),
|
||||
SecondaryEmails: []string(d.SecondaryEmails),
|
||||
Phone: d.Phone,
|
||||
AvatarURL: d.AvatarURL,
|
||||
WebsiteURL: d.WebsiteURL,
|
||||
Bio: d.Bio,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
Banned: d.Banned,
|
||||
BanReason: d.BanReason,
|
||||
BannedAt: d.BannedAt,
|
||||
TokenEpoch: d.TokenEpoch,
|
||||
AuthClients: []models.AuthClientEntry(d.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
// DBPendingUser 对应 pending_users 表。
|
||||
type DBPendingUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
InviteCode string `gorm:"column:invite_code;size:32"`
|
||||
}
|
||||
|
||||
func (DBPendingUser) TableName() string { return "pending_users" }
|
||||
|
||||
func dbPendingFromModel(p models.PendingUser) DBPendingUser {
|
||||
return DBPendingUser{
|
||||
Account: p.Account,
|
||||
PasswordHash: p.PasswordHash,
|
||||
Username: p.Username,
|
||||
Email: p.Email,
|
||||
CodeHash: p.CodeHash,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
CreatedAt: p.CreatedAt,
|
||||
InviteCode: p.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBPendingUser) toModel() models.PendingUser {
|
||||
return models.PendingUser{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
InviteCode: d.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
// DBResetPassword 对应 password_resets 表。
|
||||
type DBResetPassword struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBResetPassword) TableName() string { return "password_resets" }
|
||||
|
||||
func dbResetFromModel(r models.ResetPassword) DBResetPassword {
|
||||
return DBResetPassword{
|
||||
Account: r.Account,
|
||||
Email: r.Email,
|
||||
CodeHash: r.CodeHash,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBResetPassword) toModel() models.ResetPassword {
|
||||
return models.ResetPassword{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBSecondaryEmailVerification 对应 secondary_email_verifications 表。
|
||||
// 联合主键 (account, email)。
|
||||
type DBSecondaryEmailVerification struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"primaryKey;column:email;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBSecondaryEmailVerification) TableName() string { return "secondary_email_verifications" }
|
||||
|
||||
func dbSecondaryFromModel(s models.SecondaryEmailVerification) DBSecondaryEmailVerification {
|
||||
return DBSecondaryEmailVerification{
|
||||
Account: s.Account,
|
||||
Email: s.Email,
|
||||
CodeHash: s.CodeHash,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBSecondaryEmailVerification) toModel() models.SecondaryEmailVerification {
|
||||
return models.SecondaryEmailVerification{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBAppConfig 对应 app_configs 表,存储应用配置键值对(value 为 JSON 文本)。
|
||||
type DBAppConfig struct {
|
||||
ConfigKey string `gorm:"primaryKey;column:config_key;size:128"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null"`
|
||||
}
|
||||
|
||||
func (DBAppConfig) TableName() string { return "app_configs" }
|
||||
|
||||
// DBInviteCode 对应 invite_codes 表。
|
||||
type DBInviteCode struct {
|
||||
Code string `gorm:"primaryKey;column:code;size:32"`
|
||||
Note string `gorm:"column:note;size:512"`
|
||||
MaxUses int `gorm:"column:max_uses;default:0"`
|
||||
Uses int `gorm:"column:uses;default:0"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBInviteCode) TableName() string { return "invite_codes" }
|
||||
|
||||
func dbInviteFromEntry(e InviteEntry) DBInviteCode {
|
||||
return DBInviteCode{
|
||||
Code: e.Code,
|
||||
Note: e.Note,
|
||||
MaxUses: e.MaxUses,
|
||||
Uses: e.Uses,
|
||||
ExpiresAt: e.ExpiresAt,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBInviteCode) toEntry() InviteEntry {
|
||||
return InviteEntry{
|
||||
Code: d.Code,
|
||||
Note: d.Note,
|
||||
MaxUses: d.MaxUses,
|
||||
Uses: d.Uses,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,34 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SavePending(record models.PendingUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbPendingFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetPending(account string) (models.PendingUser, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBPendingUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.PendingUser{}, false, nil
|
||||
}
|
||||
var record models.PendingUser
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.PendingUser{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.PendingUser{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePending(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
return s.db.Delete(&DBPendingUser{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
func (s *Store) pendingFilePath(account string) string {
|
||||
return filepath.Join(s.pendingDir, pendingFileName(account))
|
||||
}
|
||||
|
||||
func pendingFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
}
|
||||
// DataDir 返回空字符串(MySQL 模式下无本地数据目录)。
|
||||
func (s *Store) DataDir() string { return "" }
|
||||
|
||||
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// MaxProfileLikesPerDay 每个点赞者每个自然日最多给多少位不同用户的公开主页点赞。
|
||||
const MaxProfileLikesPerDay = 5
|
||||
|
||||
var (
|
||||
ErrCannotLikeOwnProfile = errors.New("cannot like own profile")
|
||||
ErrAlreadyLikedToday = errors.New("already liked today")
|
||||
ErrDailyLikeLimitReached = errors.New("daily like limit reached")
|
||||
ErrProfileLikeTarget = errors.New("user not found")
|
||||
ErrProfileLikeLiker = errors.New("invalid liker")
|
||||
)
|
||||
|
||||
// DBProfileLikeDailyQuota 点赞者当日剩余可点赞人数(与 profile_likes 计数同步;新自然日新行即视为刷新为满额)。
|
||||
type DBProfileLikeDailyQuota struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"`
|
||||
Remaining int `gorm:"column:remaining;not null"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLikeDailyQuota) TableName() string { return "profile_like_daily_quota" }
|
||||
|
||||
// DBProfileLike 记录「谁在某日给谁的主页点赞」(每人每天对同一主页最多一条)。
|
||||
type DBProfileLike struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikedAccount string `gorm:"primaryKey;column:liked_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"` // YYYY-MM-DD,与签到同历
|
||||
CreatedAt string `gorm:"column:created_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLike) TableName() string { return "profile_likes" }
|
||||
|
||||
// CountProfileLikes 主页累计获赞次数(历史所有日的点赞条数之和)。
|
||||
func (s *Store) CountProfileLikes(likedAccount string) (int64, error) {
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likedAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&DBProfileLike{}).Where("liked_account = ?", likedAccount).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ViewerHasLikedProfileToday 当前自然日(Asia/Shanghai)内是否已为该主页点过赞。
|
||||
func (s *Store) ViewerHasLikedProfileToday(likerAccount, likedAccount string) (bool, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return false, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var n int64
|
||||
err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND liked_account = ? AND like_date = ?", likerAccount, likedAccount, today).
|
||||
Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ProfileLikeRemainingToday 当日还可给多少人点赞(根据 profile_likes 计数计算,并与 quota 表对齐)。
|
||||
func (s *Store) ProfileLikeRemainingToday(likerAccount string) (int, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
if likerAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var used int64
|
||||
if err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(used)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
var q DBProfileLikeDailyQuota
|
||||
err := s.db.Where("liker_account = ? AND like_date = ?", likerAccount, today).First(&q).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return rem, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if q.Remaining != rem {
|
||||
_ = s.db.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
}
|
||||
return rem, nil
|
||||
}
|
||||
|
||||
// AddProfileLike 为公开主页点赞(每人每天每主页一次;每人每天最多给 MaxProfileLikesPerDay 位用户点赞)。返回新的累计赞数。
|
||||
func (s *Store) AddProfileLike(likerAccount, likedAccount string) (int64, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return 0, errors.New("account is required")
|
||||
}
|
||||
if strings.EqualFold(likerAccount, likedAccount) {
|
||||
return 0, ErrCannotLikeOwnProfile
|
||||
}
|
||||
|
||||
likedUser, found, err := s.GetUser(likedAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found || likedUser.Banned {
|
||||
return 0, ErrProfileLikeTarget
|
||||
}
|
||||
likerUser, foundL, err := s.GetUser(likerAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !foundL || likerUser.Banned {
|
||||
return 0, ErrProfileLikeLiker
|
||||
}
|
||||
|
||||
today := models.CurrentActivityDate()
|
||||
likerCanon := likerUser.Account
|
||||
likedCanon := likedUser.Account
|
||||
row := DBProfileLike{
|
||||
LikerAccount: likerCanon,
|
||||
LikedAccount: likedCanon,
|
||||
LikeDate: today,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
|
||||
txErr := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var quota DBProfileLikeDailyQuota
|
||||
qErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error
|
||||
if errors.Is(qErr, gorm.ErrRecordNotFound) {
|
||||
quota = DBProfileLikeDailyQuota{
|
||||
LikerAccount: likerCanon,
|
||||
LikeDate: today,
|
||||
Remaining: MaxProfileLikesPerDay,
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if cerr := tx.Create("a).Error; cerr != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if qErr != nil {
|
||||
return qErr
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var used int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if int(used) >= MaxProfileLikesPerDay {
|
||||
if err := tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": 0,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrDailyLikeLimitReached
|
||||
}
|
||||
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrAlreadyLikedToday
|
||||
}
|
||||
|
||||
var usedAfter int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&usedAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(usedAfter)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
return tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
return 0, txErr
|
||||
}
|
||||
return s.CountProfileLikes(likedCanon)
|
||||
}
|
||||
@@ -3,13 +3,60 @@ package storage
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultForbiddenAccountsCSV = "sb,mail,nmsl,cnmb,smy,shumengya"
|
||||
defaultInviteRegisterRewardCoins = 10
|
||||
// MinSelfServiceAccountLen / MaxSelfServiceAccountLen 自助注册账号长度(仅小写与数字)。
|
||||
MinSelfServiceAccountLen = 3
|
||||
MaxSelfServiceAccountLen = 32
|
||||
)
|
||||
|
||||
func effectiveInviteRegisterRewardCoins(stored *int) int {
|
||||
if stored == nil {
|
||||
return defaultInviteRegisterRewardCoins
|
||||
}
|
||||
if *stored < 0 {
|
||||
return 0
|
||||
}
|
||||
return *stored
|
||||
}
|
||||
|
||||
var selfServiceAccountPattern = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
// NormalizeSelfServiceAccount 自助注册账号:去空白并转为小写。
|
||||
func NormalizeSelfServiceAccount(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func effectiveForbiddenAccountsCSV(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return defaultForbiddenAccountsCSV
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func parseForbiddenAccountSet(csv string) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
t := strings.ToLower(strings.TrimSpace(part))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InviteEntry 管理员发放的注册邀请码。
|
||||
type InviteEntry struct {
|
||||
Code string `json:"code"`
|
||||
@@ -20,64 +67,155 @@ type InviteEntry struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RegistrationConfig 注册策略与邀请码列表(data/config/registration.json)。
|
||||
// RegistrationConfig 注册策略(不含邀请码列表,邀请码单独存入 invite_codes 表)。
|
||||
type RegistrationConfig struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
Invites []InviteEntry `json:"invites"`
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"` // 逗号分隔;空串表示使用内置默认禁注列表
|
||||
InviteRegisterRewardCoins int `json:"inviteRegisterRewardCoins"` // 使用邀请码完成注册时赠送(生效值,默认 10)
|
||||
Invites []InviteEntry `json:"invites"` // 内存缓存,非 DB 字段
|
||||
}
|
||||
|
||||
func normalizeInviteCode(raw string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
type dbRegistrationPolicy struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"` // nil 表示未配置,按内置默认 10
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateRegistrationConfig() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := os.Stat(s.registrationPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := RegistrationConfig{RequireInviteCode: false, Invites: []InviteEntry{}}
|
||||
if err := writeJSONFile(s.registrationPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg RegistrationConfig
|
||||
if err := readJSONFile(s.registrationPath, &cfg); err != nil {
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Invites == nil {
|
||||
cfg.Invites = []InviteEntry{}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{RequireInviteCode: false, ForbiddenAccounts: ""}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
|
||||
// 从 invite_codes 表加载所有邀请码
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
s.registrationConfig = RegistrationConfig{
|
||||
RequireInviteCode: policy.RequireInviteCode,
|
||||
ForbiddenAccounts: policy.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: rewardEff,
|
||||
Invites: invites,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) persistRegistrationConfigLocked() error {
|
||||
return writeJSONFile(s.registrationPath, s.registrationConfig)
|
||||
func (s *Store) loadAllInvites() ([]InviteEntry, error) {
|
||||
var rows []DBInviteCode
|
||||
if err := s.db.Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]InviteEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
entries = append(entries, r.toEntry())
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。
|
||||
// RegistrationRequireInvite 是否强制邀请码。
|
||||
func (s *Store) RegistrationRequireInvite() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.RequireInviteCode
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(管理端)。
|
||||
// RegistrationInviteRegisterRewardCoins 使用邀请码完成邮箱验证注册时赠送的萌芽币(未配置时为 10)。
|
||||
func (s *Store) RegistrationInviteRegisterRewardCoins() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.InviteRegisterRewardCoins
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(含邀请码列表)。
|
||||
func (s *Store) GetRegistrationConfig() RegistrationConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.registrationConfig
|
||||
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := effectiveRegistrationConfigForAPI(s.registrationConfig)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRegistrationRequireInvite 更新是否强制邀请码。
|
||||
func (s *Store) SetRegistrationRequireInvite(require bool) error {
|
||||
// effectiveRegistrationConfigForAPI 管理端展示用:禁注列表空时透出当前生效的内置默认文案。
|
||||
func effectiveRegistrationConfigForAPI(in RegistrationConfig) RegistrationConfig {
|
||||
out := RegistrationConfig{
|
||||
RequireInviteCode: in.RequireInviteCode,
|
||||
ForbiddenAccounts: in.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: in.InviteRegisterRewardCoins,
|
||||
Invites: append([]InviteEntry(nil), in.Invites...),
|
||||
}
|
||||
if strings.TrimSpace(out.ForbiddenAccounts) == "" {
|
||||
out.ForbiddenAccounts = defaultForbiddenAccountsCSV
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateSelfServiceAccount 自助注册账号:仅小写字母与数字,长度与禁注表(可配置,空则用内置默认)。
|
||||
func (s *Store) ValidateSelfServiceAccount(account string) error {
|
||||
acc := NormalizeSelfServiceAccount(account)
|
||||
if acc == "" {
|
||||
return errors.New("account is required")
|
||||
}
|
||||
if len(acc) < MinSelfServiceAccountLen || len(acc) > MaxSelfServiceAccountLen {
|
||||
return errors.New("account must be 3-32 characters")
|
||||
}
|
||||
if !selfServiceAccountPattern.MatchString(acc) {
|
||||
return errors.New("account may only contain lowercase letters and digits")
|
||||
}
|
||||
s.mu.RLock()
|
||||
csv := s.registrationConfig.ForbiddenAccounts
|
||||
s.mu.RUnlock()
|
||||
blocked := parseForbiddenAccountSet(effectiveForbiddenAccountsCSV(csv))
|
||||
if _, ok := blocked[acc]; ok {
|
||||
return errors.New("this account name is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRegistrationPolicy 更新注册策略;inviteRewardCoins 为 nil 时不改库中该项(兼容旧客户端)。
|
||||
func (s *Store) UpdateRegistrationPolicy(require bool, forbiddenAccounts string, inviteRewardCoins *int) error {
|
||||
forbiddenAccounts = strings.TrimSpace(forbiddenAccounts)
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{}
|
||||
}
|
||||
policy.RequireInviteCode = require
|
||||
policy.ForbiddenAccounts = forbiddenAccounts
|
||||
if inviteRewardCoins != nil {
|
||||
v := *inviteRewardCoins
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
policy.InviteRegisterRewardCoins = &v
|
||||
}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registrationConfig.RequireInviteCode = require
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.ForbiddenAccounts = forbiddenAccounts
|
||||
s.registrationConfig.InviteRegisterRewardCoins = rewardEff
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func inviteEntryValid(e *InviteEntry) error {
|
||||
@@ -93,14 +231,14 @@ func inviteEntryValid(e *InviteEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(发验证码前,不扣次)。
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(不扣次)。
|
||||
func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return errors.New("invite code is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -110,14 +248,16 @@ func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
return errors.New("invalid invite code")
|
||||
}
|
||||
|
||||
// RedeemInvite 邮箱验证通过创建用户后扣减邀请码使用次数。
|
||||
// RedeemInvite 邮箱验证通过后扣减邀请码使用次数。
|
||||
func (s *Store) RedeemInvite(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -125,7 +265,10 @@ func (s *Store) RedeemInvite(code string) error {
|
||||
return err
|
||||
}
|
||||
e.Uses++
|
||||
return s.persistRegistrationConfigLocked()
|
||||
// 写回数据库
|
||||
return s.db.Model(&DBInviteCode{}).
|
||||
Where("code = ?", e.Code).
|
||||
Update("uses", e.Uses).Error
|
||||
}
|
||||
}
|
||||
return errors.New("invalid invite code")
|
||||
@@ -146,10 +289,11 @@ func randomInviteToken(n int) (string, error) {
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// AddInviteEntry 生成新邀请码并写入配置。
|
||||
// AddInviteEntry 生成新邀请码并写入数据库。
|
||||
func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (InviteEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var code string
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
c, err := randomInviteToken(8)
|
||||
@@ -171,6 +315,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if code == "" {
|
||||
return InviteEntry{}, errors.New("failed to generate unique invite code")
|
||||
}
|
||||
|
||||
expiresAt = strings.TrimSpace(expiresAt)
|
||||
if expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
|
||||
@@ -180,6 +325,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if maxUses < 0 {
|
||||
maxUses = 0
|
||||
}
|
||||
|
||||
entry := InviteEntry{
|
||||
Code: code,
|
||||
Note: strings.TrimSpace(note),
|
||||
@@ -188,11 +334,13 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
if err := s.persistRegistrationConfigLocked(); err != nil {
|
||||
s.registrationConfig.Invites = s.registrationConfig.Invites[:len(s.registrationConfig.Invites)-1]
|
||||
|
||||
row := dbInviteFromEntry(entry)
|
||||
if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return InviteEntry{}, err
|
||||
}
|
||||
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -202,13 +350,40 @@ func (s *Store) DeleteInviteEntry(code string) error {
|
||||
if n == "" {
|
||||
return errors.New("code is required")
|
||||
}
|
||||
|
||||
result := s.db.Where("UPPER(code) = ?", n).Delete(&DBInviteCode{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("invite not found")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, e := range s.registrationConfig.Invites {
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites[:i], s.registrationConfig.Invites[i+1:]...)
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.Invites = append(
|
||||
s.registrationConfig.Invites[:i],
|
||||
s.registrationConfig.Invites[i+1:]...,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.New("invite not found")
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshInviteCache 重新从数据库加载邀请码列表(内部用)。
|
||||
func (s *Store) refreshInviteCache() error {
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.registrationConfig.Invites = invites
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确保 gorm 包被使用
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
|
||||
@@ -1,55 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveReset(record models.ResetPassword) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbResetFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetReset(account string) (models.ResetPassword, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBResetPassword
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.ResetPassword{}, false, nil
|
||||
}
|
||||
var record models.ResetPassword
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.ResetPassword{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.ResetPassword{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteReset(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) resetFilePath(account string) string {
|
||||
return filepath.Join(s.resetDir, resetFileName(account))
|
||||
}
|
||||
|
||||
func resetFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBResetPassword{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveSecondaryVerification(record models.SecondaryEmailVerification) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(record.Account, record.Email)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbSecondaryFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetSecondaryVerification(account string, email string) (models.SecondaryEmailVerification, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBSecondaryEmailVerification
|
||||
result := s.db.First(&row, "account = ? AND email = ?", account, email)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.SecondaryEmailVerification{}, false, nil
|
||||
}
|
||||
var record models.SecondaryEmailVerification
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.SecondaryEmailVerification{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.SecondaryEmailVerification{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSecondaryVerification(account string, email string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) secondaryFilePath(account string, email string) string {
|
||||
return filepath.Join(s.secondaryDir, secondaryFileName(account, email))
|
||||
}
|
||||
|
||||
func secondaryFileName(account string, email string) string {
|
||||
accountSafe := strings.TrimSpace(account)
|
||||
emailSafe := strings.TrimSpace(email)
|
||||
if accountSafe == "" {
|
||||
accountSafe = "unknown"
|
||||
}
|
||||
if emailSafe == "" {
|
||||
emailSafe = "unknown"
|
||||
}
|
||||
raw := accountSafe + "::" + emailSafe
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBSecondaryEmailVerification{}, "account = ? AND email = ?", account, email).Error
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── 配置结构体(公开 API 不变)────────────────────────────────────────────────
|
||||
|
||||
type AdminConfig struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -36,66 +40,36 @@ type CheckInConfig struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
usersDir string
|
||||
pendingDir string
|
||||
resetDir string
|
||||
secondaryDir string
|
||||
adminConfigPath string
|
||||
authConfigPath string
|
||||
emailConfigPath string
|
||||
checkInPath string
|
||||
registrationPath string
|
||||
registrationConfig RegistrationConfig
|
||||
db *gorm.DB
|
||||
adminToken string
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
emailConfig EmailConfig
|
||||
checkInConfig CheckInConfig
|
||||
mu sync.Mutex
|
||||
registrationConfig RegistrationConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewStore(dataDir string) (*Store, error) {
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
absDir, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
// NewStore 接收已建立连接的 *gorm.DB,自动迁移表结构并加载配置。
|
||||
func NewStore(db *gorm.DB) (*Store, error) {
|
||||
if err := db.AutoMigrate(
|
||||
&DBUser{},
|
||||
&DBPendingUser{},
|
||||
&DBResetPassword{},
|
||||
&DBSecondaryEmailVerification{},
|
||||
&DBAppConfig{},
|
||||
&DBInviteCode{},
|
||||
&DBProfileLike{},
|
||||
&DBProfileLikeDailyQuota{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersDir := filepath.Join(absDir, "users")
|
||||
pendingDir := filepath.Join(absDir, "pending")
|
||||
resetDir := filepath.Join(absDir, "reset")
|
||||
secondaryDir := filepath.Join(absDir, "secondary")
|
||||
configDir := filepath.Join(absDir, "config")
|
||||
if err := os.MkdirAll(usersDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(pendingDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(resetDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(secondaryDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{
|
||||
dataDir: absDir,
|
||||
usersDir: usersDir,
|
||||
pendingDir: pendingDir,
|
||||
resetDir: resetDir,
|
||||
secondaryDir: secondaryDir,
|
||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||
emailConfigPath: filepath.Join(configDir, "email.json"),
|
||||
checkInPath: filepath.Join(configDir, "checkin.json"),
|
||||
registrationPath: filepath.Join(configDir, "registration.json"),
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
|
||||
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,32 +85,136 @@ func NewStore(dataDir string) (*Store, error) {
|
||||
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) DataDir() string {
|
||||
return s.dataDir
|
||||
// ─── 配置辅助 ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) getConfig(key string, target any) (bool, error) {
|
||||
var row DBAppConfig
|
||||
result := s.db.First(&row, "config_key = ?", key)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return true, json.Unmarshal([]byte(row.ConfigValue), target)
|
||||
}
|
||||
|
||||
func (s *Store) AdminToken() string {
|
||||
return s.adminToken
|
||||
func (s *Store) setConfig(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := DBAppConfig{ConfigKey: key, ConfigValue: string(raw)}
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) JWTSecret() []byte {
|
||||
return s.jwtSecret
|
||||
// ─── Admin 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) AdminToken() string { return s.adminToken }
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
var cfg AdminConfig
|
||||
found, err := s.getConfig("admin", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(cfg.Token) == "" {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = token
|
||||
if err := s.setConfig("admin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) JWTIssuer() string {
|
||||
return s.issuer
|
||||
// ─── Auth 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) JWTSecret() []byte { return s.jwtSecret }
|
||||
func (s *Store) JWTIssuer() string { return s.issuer }
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
var cfg AuthConfig
|
||||
found, err := s.getConfig("auth", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, decodeErr := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if !found || decodeErr != nil || len(secretBytes) == 0 {
|
||||
secretBytes, err = generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
}
|
||||
if err := s.setConfig("auth", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig {
|
||||
return s.emailConfig
|
||||
// ─── Email 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig { return s.emailConfig }
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
var cfg EmailConfig
|
||||
found, err := s.getConfig("email", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed := !found
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
changed = true
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := s.setConfig("email", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── CheckIn 配置 ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CheckInConfig() CheckInConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cfg := s.checkInConfig
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
@@ -145,160 +223,27 @@ func (s *Store) CheckInConfig() CheckInConfig {
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AdminConfig{Token: token}
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
var cfg AdminConfig
|
||||
if err := readJSONFile(s.adminConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = token
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AuthConfig{
|
||||
JWTSecret: base64.StdEncoding.EncodeToString(secret),
|
||||
Issuer: "sproutgate",
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secret
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
var cfg AuthConfig
|
||||
if err := readJSONFile(s.authConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if err != nil || len(secretBytes) == 0 {
|
||||
secretBytes, err = generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := EmailConfig{
|
||||
FromName: "萌芽账户认证中心",
|
||||
FromAddress: "notice@smyhub.com",
|
||||
Username: "",
|
||||
Password: "",
|
||||
SMTPHost: "smtp.qiye.aliyun.com",
|
||||
SMTPPort: 465,
|
||||
Encryption: "SSL",
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg EmailConfig
|
||||
if err := readJSONFile(s.emailConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
if _, err := os.Stat(s.checkInPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := CheckInConfig{RewardCoins: 1}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg CheckInConfig
|
||||
if err := readJSONFile(s.checkInPath, &cfg); err != nil {
|
||||
found, err := s.getConfig("checkin", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.RewardCoins <= 0 {
|
||||
if !found || cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -306,158 +251,143 @@ func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
// ─── 用户 CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListUsers() ([]models.UserRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.usersDir)
|
||||
if err != nil {
|
||||
var rows []DBUser
|
||||
if err := s.db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]models.UserRecord, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
var record models.UserRecord
|
||||
path := filepath.Join(s.usersDir, entry.Name())
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, record)
|
||||
users := make([]models.UserRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
users = append(users, row.toRecord())
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(account string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, false, nil
|
||||
}
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
return writeJSONFile(path, record)
|
||||
|
||||
row := dbUserFromRecord(record)
|
||||
result := s.db.Create(&row)
|
||||
if result.Error != nil {
|
||||
if strings.Contains(result.Error.Error(), "Duplicate entry") ||
|
||||
strings.Contains(result.Error.Error(), "duplicate key") {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
record.UpdatedAt = models.NowISO()
|
||||
return writeJSONFile(path, record)
|
||||
row := dbUserFromRecord(record)
|
||||
return s.db.Save(&row).Error
|
||||
}
|
||||
|
||||
// RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
return s.db.Delete(&DBUser{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
// ─── RecordAuthClient ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) RecordAuthClient(account string, clientID string, displayName string) (models.UserRecord, error) {
|
||||
if clientID == "" {
|
||||
return models.UserRecord{}, errors.New("client id required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
return models.UserRecord{}, err
|
||||
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, result.Error
|
||||
}
|
||||
|
||||
now := models.NowISO()
|
||||
displayName = models.ClampAuthClientName(displayName)
|
||||
clients := []models.AuthClientEntry(row.AuthClients)
|
||||
found := false
|
||||
for i := range record.AuthClients {
|
||||
if record.AuthClients[i].ClientID == clientID {
|
||||
record.AuthClients[i].LastSeenAt = now
|
||||
for i := range clients {
|
||||
if clients[i].ClientID == clientID {
|
||||
clients[i].LastSeenAt = now
|
||||
if displayName != "" {
|
||||
record.AuthClients[i].DisplayName = displayName
|
||||
clients[i].DisplayName = displayName
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
record.AuthClients = append(record.AuthClients, models.AuthClientEntry{
|
||||
clients = append(clients, models.AuthClientEntry{
|
||||
ClientID: clientID,
|
||||
DisplayName: displayName,
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := writeJSONFile(path, &record); err != nil {
|
||||
row.AuthClients = AuthClientSlice(clients)
|
||||
row.UpdatedAt = now
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── RecordVisit ──────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) {
|
||||
return record, false, nil
|
||||
visitTimes := []string(row.VisitTimes)
|
||||
if row.LastVisitDate == today || models.HasActivityDate(visitTimes, today) {
|
||||
return row.toRecord(), false, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastVisitDate = today
|
||||
record.LastVisitAt = at
|
||||
record.VisitTimes = append(record.VisitTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastVisitDate = today
|
||||
row.LastVisitAt = at
|
||||
row.VisitTimes = StringSlice(append(visitTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
// ─── UpdateLastVisitMeta ─────────────────────────────────────────────────────
|
||||
|
||||
const maxLastVisitIPLen = 45
|
||||
const maxLastVisitDisplayLocationLen = 512
|
||||
|
||||
@@ -473,7 +403,6 @@ func clampVisitMeta(ip, displayLocation string) (string, string) {
|
||||
return ip, displayLocation
|
||||
}
|
||||
|
||||
// UpdateLastVisitMeta 更新用户最近一次访问的客户端 IP 与展示用地理位置(由前端调用地理接口后传入)。
|
||||
func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) {
|
||||
ip, displayLocation = clampVisitMeta(ip, displayLocation)
|
||||
if ip == "" && displayLocation == "" {
|
||||
@@ -487,101 +416,83 @@ func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation s
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, result.Error
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
record.LastVisitIP = ip
|
||||
row.LastVisitIP = ip
|
||||
}
|
||||
if displayLocation != "" {
|
||||
record.LastVisitDisplayLocation = displayLocation
|
||||
row.LastVisitDisplayLocation = displayLocation
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── CheckIn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, 0, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, 0, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) {
|
||||
return record, 0, true, nil
|
||||
checkInTimes := []string(row.CheckInTimes)
|
||||
if row.LastCheckInDate == today || models.HasActivityDate(checkInTimes, today) {
|
||||
return row.toRecord(), 0, true, nil
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
reward := s.checkInConfig.RewardCoins
|
||||
s.mu.RUnlock()
|
||||
if reward <= 0 {
|
||||
reward = 1
|
||||
}
|
||||
record.SproutCoins += reward
|
||||
record.LastCheckInDate = today
|
||||
|
||||
row.SproutCoins += reward
|
||||
row.LastCheckInDate = today
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastCheckInAt = at
|
||||
record.CheckInTimes = append(record.CheckInTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastCheckInAt = at
|
||||
row.CheckInTimes = StringSlice(append(checkInTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
}
|
||||
return record, reward, false, nil
|
||||
return row.toRecord(), reward, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func (s *Store) userFilePath(account string) string {
|
||||
return filepath.Join(s.usersDir, userFileName(account))
|
||||
}
|
||||
|
||||
func userFileName(account string) string {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
return encoded + ".json"
|
||||
}
|
||||
|
||||
func readJSONFile(path string, target any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
func generateToken() (string, error) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
return base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ import (
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/database"
|
||||
"sproutgate-backend/internal/handlers"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
store, err := storage.NewStore(dataDir)
|
||||
db, err := database.Open()
|
||||
if err != nil {
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化储存失败: %v", err)
|
||||
}
|
||||
@@ -36,12 +41,11 @@ func main() {
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"apiDocs": "GET /api/docs — Markdown 接口说明(本仓库 API_DOCS.md)",
|
||||
"health": "GET /api/health",
|
||||
"health": "GET /api/health",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户资料、注册策略(是否强制邀请码)",
|
||||
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
@@ -53,14 +57,15 @@ func main() {
|
||||
})
|
||||
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"dataDir": store.DataDir(),
|
||||
"status": "ok",
|
||||
"env": env,
|
||||
})
|
||||
})
|
||||
router.GET("/api/docs", func(c *gin.Context) {
|
||||
c.File("API_DOCS.md")
|
||||
})
|
||||
|
||||
router.POST("/api/auth/login", handler.Login)
|
||||
router.POST("/api/auth/register", handler.Register)
|
||||
@@ -73,7 +78,9 @@ func main() {
|
||||
router.GET("/api/auth/me", handler.Me)
|
||||
router.POST("/api/auth/check-in", handler.CheckIn)
|
||||
router.PUT("/api/auth/profile", handler.UpdateProfile)
|
||||
router.GET("/api/public/users", handler.ListPublicUsers)
|
||||
router.GET("/api/public/users/:account", handler.GetPublicUser)
|
||||
router.POST("/api/public/users/:account/like", handler.PostPublicProfileLike)
|
||||
router.GET("/api/public/registration-policy", handler.GetPublicRegistrationPolicy)
|
||||
|
||||
admin := router.Group("/api/admin")
|
||||
|
||||
110
sproutgate-backend/后端文档.md
Normal file
110
sproutgate-backend/后端文档.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# SproutGate 后端文档
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **语言**:Go 1.20+
|
||||
- **Web 框架**:Gin;跨域中间件 **`github.com/gin-contrib/cors`**
|
||||
- **数据存储**:MySQL(通过 GORM + `gorm.io/driver/mysql`)
|
||||
- **JWT**:`github.com/golang-jwt/jwt/v5`
|
||||
- **密码哈希**:`golang.org/x/crypto/bcrypt`
|
||||
|
||||
业务数据与配置均保存在 MySQL 中,**不再使用** `data/users/*.json` 与 `data/config/*.json` 作为运行时数据源。仓库中的 `data/` 仅可作迁移脚本输入或本地备份参考。
|
||||
|
||||
## 目录结构(要点)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `main.go` | 入口:连接数据库、初始化 `Store`、注册路由 |
|
||||
| `internal/database/` | MySQL 连接与环境选择(`DB_DSN` / `APP_ENV`) |
|
||||
| `internal/handlers/` | HTTP 处理:认证、资料、签到、公开页、管理端等 |
|
||||
| `internal/models/` | 领域模型(用户、待激活、重置密码、辅助邮箱等) |
|
||||
| `internal/storage/` | 持久化:`Store` + GORM 模型与业务方法 |
|
||||
| `internal/auth/` | JWT 签发与校验 |
|
||||
| `internal/email/` | 发信 |
|
||||
| `cmd/migrate/` | 一次性工具:将旧版 JSON `data/` 导入 MySQL |
|
||||
|
||||
## 环境与数据库
|
||||
|
||||
连接字符串优先级:**`DB_DSN` > 内置规则**。
|
||||
|
||||
1. **若设置 `DB_DSN`**:直接使用该完整 DSN(适合 CI、容器或临时切换)。
|
||||
2. **否则**:根据 `APP_ENV` 选择内置库:
|
||||
- `APP_ENV=production` 或 `prod` → 生产库:`192.168.1.100:3306` / 库名 `sproutgate` / 用户 `sproutgate`
|
||||
- 未设置或其它值 → 开发/测试库:`10.1.1.100:3306` / 库名 `sproutgate-test` / 用户 `sproutgate-test`
|
||||
|
||||
其它常用环境变量:
|
||||
|
||||
| 变量 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `PORT` | HTTP 监听端口 | `8080` |
|
||||
| `APP_ENV` | 影响默认 MySQL 选择与 `/api/health` 返回的 `env` | 空则 health 中为 `development` |
|
||||
| `GIN_MODE` | `release` 时 GORM 日志级别更安静(警告级) | debug 模式 |
|
||||
| `DB_DSN` | 完整 MySQL DSN,覆盖上述内置地址 | 未设置 |
|
||||
| `GEO_LOOKUP_URL` | `GET /api/auth/me` 未带 `X-Visit-Location` 时,服务端按 IP 请求该基址反查展示地理位置(实现见 `internal/clientgeo`,会拼接 `?ip=`) | 默认 `https://cf-ip-geo.smyhub.com/api` |
|
||||
|
||||
**建议**:生产部署时设置 `APP_ENV=production`,并确保 MySQL 防火墙与账号权限正确;敏感连接信息也可只通过 `DB_DSN` 注入,无需改代码。
|
||||
|
||||
## 数据库表(AutoMigrate)
|
||||
|
||||
应用启动与 `migrate` 工具均会同步下列表结构(GORM `AutoMigrate`):
|
||||
|
||||
| 表名 | 用途 |
|
||||
|------|------|
|
||||
| `users` | 用户主数据;签到/访问时间列表、`auth_clients` 等以 JSON 文本列存储 |
|
||||
| `pending_users` | 注册邮箱验证流程中的待激活用户 |
|
||||
| `password_resets` | 忘记密码验证码 |
|
||||
| `secondary_email_verifications` | 绑定辅助邮箱验证码 |
|
||||
| `app_configs` | 键值配置:`admin`、`auth`、`email`、`checkin`、`registration`(JSON) |
|
||||
| `invite_codes` | 注册邀请码 |
|
||||
| `profile_likes` | 公开用户主页点赞明细(点赞者、被赞者、自然日等) |
|
||||
| `profile_like_daily_quota` | 点赞者当日已用额度(与代码常量 `MaxProfileLikesPerDay` 配合) |
|
||||
|
||||
管理员令牌、JWT Secret、邮件 SMTP、签到奖励、是否强制邀请码等,均从 `app_configs` / `invite_codes` 读写,首次无记录时由 `Store` 按逻辑补全默认项。
|
||||
|
||||
## 从旧 JSON 迁移到 MySQL
|
||||
|
||||
在 **`sproutgate-backend`** 目录下:
|
||||
|
||||
```bash
|
||||
# 导入到当前环境对应的库(默认开发库,除非设置 APP_ENV 或 DB_DSN)
|
||||
go run ./cmd/migrate --data-dir ./data
|
||||
```
|
||||
|
||||
Windows PowerShell 示例(写入生产库):
|
||||
|
||||
```powershell
|
||||
$env:APP_ENV = "production"
|
||||
go run ./cmd/migrate --data-dir ./data
|
||||
Remove-Item Env:APP_ENV # 可选:清除变量,避免影响本机其它命令
|
||||
```
|
||||
|
||||
迁移内容:`data/config/*.json`(写入 `app_configs` 与 `invite_codes`)、`data/users/*.json`(写入 `users`)。对已存在主键执行 **UPSERT**:同账号、同配置键会更新为迁移文件中的值。
|
||||
|
||||
## HTTP 路由速览
|
||||
|
||||
- **`GET /`**、**`GET /api`**:API 简要说明 JSON(`main.go` 内嵌字段,含 `version`、`routePrefixes` 等)。**当前未注册**单独返回 Markdown 的 `/api/docs` 路由;对外长文档见仓库根目录 **`萌芽账户认证中心-第三方应用API接入文档.md`**。
|
||||
- **`GET /api/health`**:`{ "status": "ok", "env": "<APP_ENV 或 development>" }`。
|
||||
- **`/api/auth/*`**:登录、注册、邮箱验证、忘记/重置密码、辅助邮箱、令牌校验、`/me`、签到、更新资料等;可选请求头 `X-Auth-Client`、`X-Auth-Client-Name`(记录第三方应用接入)。
|
||||
- **`/api/public/*`**:
|
||||
- **`GET /api/public/users`**:公开用户目录(未封禁;默认按 `createdAt` 升序)。
|
||||
- **`GET /api/public/users/:account`**:公开资料 + 累计赞数;若带合法 Bearer,可附加「是否已赞今日」「当日剩余可点赞人数」等浏览者上下文。
|
||||
- **`POST /api/public/users/:account/like`**:登录用户给指定主页点赞(不能赞自己;每人每主页每日一次;每自然日每名用户最多给 **5** 位不同用户点赞,常量见 `internal/storage/profile_likes.go`)。
|
||||
- **`GET /api/public/registration-policy`**:是否强制邀请码等。
|
||||
- **`/api/admin/*`**:需 **`X-Admin-Token`**(或 Query `token`):用户 CRUD、签到配置、注册策略与邀请码管理。
|
||||
|
||||
允许的 CORS 自定义头包含:`Authorization`、`X-Admin-Token`、`X-Visit-Ip`、`X-Visit-Location`、`X-Auth-Client`、`X-Auth-Client-Name` 等。
|
||||
|
||||
## 本地开发命令
|
||||
|
||||
```bash
|
||||
cd sproutgate-backend
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
默认监听 `:8080`。前端开发时请将 `VITE_API_BASE` 指向本服务(详见仓库根目录下 `sproutgate-frontend/前端文档.md`)。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 勿将生产 `DB_DSN`、SMTP 密码、真实 `admin` 令牌提交到版本库。
|
||||
- 旧版 `data/config/email.json` 等若含真实口令,迁移后应以环境或运维密钥管理为准,并限制数据库与 SMTP 账号权限。
|
||||
Reference in New Issue
Block a user