完善初始化更新

This commit is contained in:
2026-03-20 20:42:33 +08:00
parent 568ccb08fa
commit e6866feb29
39 changed files with 6986 additions and 2379 deletions

View File

@@ -1,6 +1,59 @@
# 萌芽账户认证中心 API 文档
基础地址:`http://<host>:8080`
访问 **`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` 时,应引导用户重新走统一登录。
## 认证与统一登录
@@ -11,10 +64,14 @@
```json
{
"account": "demo",
"password": "demo123"
"password": "demo123",
"clientId": "my-app",
"clientName": "我的应用"
}
```
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
响应:
```json
{
@@ -29,6 +86,7 @@
"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"
@@ -36,6 +94,29 @@
}
```
若账户已被管理员封禁,返回 **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`
@@ -54,21 +135,79 @@
}
```
若账户已封禁,返回 **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`
@@ -82,10 +221,13 @@
"username": "新昵称",
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 新简介"
}
```
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`
响应:
```json
{
@@ -93,6 +235,48 @@
}
```
## 用户广场
### 获取用户公开主页
`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`
@@ -102,10 +286,13 @@
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com"
"email": "demo@example.com",
"inviteCode": "ABCD1234"
}
```
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
响应:
```json
{
@@ -218,8 +405,50 @@
请求时可使用以下任一方式携带:
- 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`
@@ -246,6 +475,7 @@
"secondaryEmails": ["demo2@example.com"],
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介"
}
```
@@ -260,10 +490,18 @@
"username": "新昵称",
"level": 1,
"secondaryEmails": ["demo2@example.com"],
"sproutCoins": 99
"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}`
@@ -281,16 +519,25 @@
- 管理员 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>'
```

View File

@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: sproutgate-auth
container_name: sproutgate-backend
restart: unless-stopped
environment:
PORT: "8080"
@@ -11,4 +11,4 @@ services:
volumes:
- ./data:/data
ports:
- "${AUTH_API_PORT:-18080}:8080"
- "${AUTH_API_PORT:-28080}:8080"

View File

@@ -0,0 +1,70 @@
package clientgeo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const DefaultLookupURL = "https://cf-ip-geo.smyhub.com/api"
type apiPayload struct {
Geo *struct {
CountryName string `json:"countryName"`
RegionName string `json:"regionName"`
CityName string `json:"cityName"`
} `json:"geo"`
}
func FormatDisplay(countryName, regionName, cityName string) string {
parts := make([]string, 0, 3)
for _, s := range []string{countryName, regionName, cityName} {
s = strings.TrimSpace(s)
if s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, " ")
}
// FetchDisplayLocation 使用 cf-ip-geo 的 ?ip= 查询展示用位置(服务端调用,避免浏览器 CORS
func FetchDisplayLocation(ctx context.Context, lookupURL, ip string) (string, error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return "", nil
}
base := strings.TrimSuffix(strings.TrimSpace(lookupURL), "/")
u, err := url.Parse(base)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("invalid lookup URL")
}
q := u.Query()
q.Set("ip", ip)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 6 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("geo status %d", resp.StatusCode)
}
var payload apiPayload
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
if payload.Geo == nil {
return "", nil
}
return FormatDisplay(payload.Geo.CountryName, payload.Geo.RegionName, payload.Geo.CityName), nil
}

View File

@@ -0,0 +1,204 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/models"
)
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
publicUsers := make([]models.UserPublic, 0, len(users))
for _, u := range users {
publicUsers = append(publicUsers, u.OwnerPublic())
}
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
wu, err := normalizePublicWebsiteURL(req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
record := models.UserRecord{
Account: req.Account,
PasswordHash: string(hash),
Username: req.Username,
Email: req.Email,
Level: req.Level,
SproutCoins: req.SproutCoins,
SecondaryEmails: req.SecondaryEmails,
Phone: req.Phone,
AvatarURL: req.AvatarURL,
WebsiteURL: wu,
Bio: req.Bio,
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),
}
if err := h.store.CreateUser(record); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"user": record.OwnerPublic()})
}
func (h *Handler) UpdateUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, found, err := h.store.GetUser(account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Email != nil {
user.Email = *req.Email
}
if req.Level != nil {
user.Level = *req.Level
}
if req.SproutCoins != nil {
user.SproutCoins = *req.SproutCoins
}
if req.SecondaryEmails != nil {
user.SecondaryEmails = *req.SecondaryEmails
}
if req.Phone != nil {
user.Phone = *req.Phone
}
if req.AvatarURL != nil {
user.AvatarURL = *req.AvatarURL
}
if req.WebsiteURL != nil {
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user.WebsiteURL = wu
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if req.Banned != nil {
user.Banned = *req.Banned
if !user.Banned {
user.BanReason = ""
user.BannedAt = ""
} else if strings.TrimSpace(user.BannedAt) == "" {
user.BannedAt = models.NowISO()
}
}
if req.BanReason != nil {
r := strings.TrimSpace(*req.BanReason)
if len(r) > maxBanReasonLen {
c.JSON(http.StatusBadRequest, gin.H{"error": "ban reason is too long"})
return
}
if r != "" && !user.Banned {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot set ban reason while user is not banned"})
return
}
user.BanReason = r
}
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
}
func (h *Handler) DeleteUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
if err := h.store.DeleteUser(account); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := adminTokenFromRequest(c)
if token == "" || token != h.store.AdminToken() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1,18 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/models"
)
func authClientFromHeaders(c *gin.Context) (id string, name string, ok bool) {
id, ok = models.NormalizeAuthClientID(strings.TrimSpace(c.GetHeader("X-Auth-Client")))
if !ok {
return "", "", false
}
name = models.ClampAuthClientName(c.GetHeader("X-Auth-Client-Name"))
return id, name, true
}

View File

@@ -0,0 +1,173 @@
package handlers
import (
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/clientgeo"
"sproutgate-backend/internal/models"
)
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
user, found, err := h.store.GetUser(req.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": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if user.Banned {
writeBanJSON(c, user.BanReason)
return
}
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
if cid, ok := models.NormalizeAuthClientID(req.ClientID); ok {
name := models.ClampAuthClientName(req.ClientName)
if rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil {
user = rec
}
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"expiresAt": expiresAt.Format(time.RFC3339),
"user": user.OwnerPublic(),
})
}
func (h *Handler) Verify(c *gin.Context) {
var req verifyRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
return
}
user, 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{"valid": false, "error": "user not found"})
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)
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
_, _ = h.store.RecordAuthClient(claims.Account, cid, cname)
}
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
}
func (h *Handler) Me(c *gin.Context) {
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
}
user, 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, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
user = rec
}
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, _, err = h.store.RecordVisit(claims.Account, today, nowAt)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit"})
return
}
visitIP := strings.TrimSpace(c.GetHeader("X-Visit-Ip"))
visitLoc := strings.TrimSpace(c.GetHeader("X-Visit-Location"))
if visitIP == "" {
visitIP = strings.TrimSpace(c.ClientIP())
}
lookupURL := strings.TrimSpace(os.Getenv("GEO_LOOKUP_URL"))
if lookupURL == "" {
lookupURL = clientgeo.DefaultLookupURL
}
if visitLoc == "" && visitIP != "" {
if loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil {
visitLoc = loc
}
}
if visitIP != "" || visitLoc != "" {
user, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit meta"})
return
}
}
checkInConfig := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{
"user": user.OwnerPublic(),
"checkIn": gin.H{
"rewardCoins": checkInConfig.RewardCoins,
"checkedInToday": user.LastCheckInDate == today,
"lastCheckInDate": user.LastCheckInDate,
"lastCheckInAt": user.LastCheckInAt,
"today": today,
},
})
}

View File

@@ -0,0 +1,252 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
)
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(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
}
requireInv := h.store.RegistrationRequireInvite()
if requireInv && inviteTrim == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
return
}
if inviteTrim != "" {
if err := h.store.ValidateInviteForRegister(inviteTrim); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
if _, found, err := h.store.GetUser(req.Account); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
} else if found {
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
return
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
pending := models.PendingUser{
Account: req.Account,
PasswordHash: string(hash),
Username: req.Username,
Email: req.Email,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if inviteTrim != "" {
pending.InviteCode = strings.ToUpper(inviteTrim)
}
if err := h.store.SavePending(pending); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
return
}
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) VerifyEmail(c *gin.Context) {
var req verifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(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
}
pending, found, err := h.store.GetPending(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
return
}
if !verifyCode(req.Code, pending.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
return
}
record := models.UserRecord{
Account: pending.Account,
PasswordHash: pending.PasswordHash,
Username: pending.Username,
Email: pending.Email,
Level: 0,
SproutCoins: 0,
SecondaryEmails: []string{},
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),
}
if err := h.store.CreateUser(record); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(pending.InviteCode) != "" {
if err := h.store.RedeemInvite(pending.InviteCode); err != nil {
_ = h.store.DeleteUser(record.Account)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()})
}
func (h *Handler) ForgotPassword(c *gin.Context) {
var req forgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Email = strings.TrimSpace(req.Email)
if req.Account == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
return
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
return
}
if user.Banned {
writeBanJSON(c, user.BanReason)
return
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
resetRecord := models.ResetPassword{
Account: user.Account,
Email: user.Email,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if err := h.store.SaveReset(resetRecord); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
return
}
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
_ = h.store.DeleteReset(user.Account)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Code = strings.TrimSpace(req.Code)
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
return
}
resetRecord, found, err := h.store.GetReset(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeleteReset(req.Account)
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
return
}
if !verifyCode(req.Code, resetRecord.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
return
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
if user.Banned {
writeBanJSON(c, user.BanReason)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
_ = h.store.DeleteReset(req.Account)
c.JSON(http.StatusOK, gin.H{"reset": true})
}

View File

@@ -0,0 +1,91 @@
package handlers
import (
"errors"
"net/http"
"os"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
func (h *Handler) CheckIn(c *gin.Context) {
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
}
userPre, foundPre, err := h.store.GetUser(claims.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !foundPre {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if abortIfUserBanned(c, userPre) {
return
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in"})
return
}
checkInConfig := h.store.CheckInConfig()
message := "签到成功"
if alreadyCheckedIn {
message = "今日已签到"
}
c.JSON(http.StatusOK, gin.H{
"checkedIn": !alreadyCheckedIn,
"alreadyCheckedIn": alreadyCheckedIn,
"rewardCoins": h.store.CheckInConfig().RewardCoins,
"awardedCoins": reward,
"message": message,
"user": user.OwnerPublic(),
"checkIn": gin.H{
"rewardCoins": checkInConfig.RewardCoins,
"checkedInToday": user.LastCheckInDate == today,
"lastCheckInDate": user.LastCheckInDate,
"lastCheckInAt": user.LastCheckInAt,
"today": today,
},
})
}
func (h *Handler) GetCheckInConfig(c *gin.Context) {
cfg := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins})
}
func (h *Handler) UpdateCheckInConfig(c *gin.Context) {
var req updateCheckInConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.RewardCoins <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "rewardCoins must be greater than 0"})
return
}
if err := h.store.UpdateCheckInConfig(storage.CheckInConfig{RewardCoins: req.RewardCoins}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in config"})
return
}
c.JSON(http.StatusOK, gin.H{"rewardCoins": req.RewardCoins})
}

View File

@@ -0,0 +1,11 @@
package handlers
import "sproutgate-backend/internal/storage"
type Handler struct {
store *storage.Store
}
func NewHandler(store *storage.Store) *Handler {
return &Handler{store: store}
}

View File

@@ -1,751 +0,0 @@
package handlers
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
type Handler struct {
store *storage.Store
}
func NewHandler(store *storage.Store) *Handler {
return &Handler{store: store}
}
type loginRequest struct {
Account string `json:"account"`
Password string `json:"password"`
}
type verifyRequest struct {
Token string `json:"token"`
}
type registerRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
}
type verifyEmailRequest struct {
Account string `json:"account"`
Code string `json:"code"`
}
type updateProfileRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Phone *string `json:"phone"`
AvatarURL *string `json:"avatarUrl"`
Bio *string `json:"bio"`
}
type forgotPasswordRequest struct {
Account string `json:"account"`
Email string `json:"email"`
}
type resetPasswordRequest struct {
Account string `json:"account"`
Code string `json:"code"`
NewPassword string `json:"newPassword"`
}
type secondaryEmailRequest struct {
Email string `json:"email"`
}
type verifySecondaryEmailRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type createUserRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
SecondaryEmails []string `json:"secondaryEmails"`
Phone string `json:"phone"`
AvatarURL string `json:"avatarUrl"`
Bio string `json:"bio"`
}
type updateUserRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Email *string `json:"email"`
Level *int `json:"level"`
SproutCoins *int `json:"sproutCoins"`
SecondaryEmails *[]string `json:"secondaryEmails"`
Phone *string `json:"phone"`
AvatarURL *string `json:"avatarUrl"`
Bio *string `json:"bio"`
}
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
user, found, err := h.store.GetUser(req.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": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"expiresAt": expiresAt.Format(time.RFC3339),
"user": user.Public(),
})
}
func (h *Handler) Verify(c *gin.Context) {
var req verifyRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
return
}
user, 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{"valid": false, "error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
}
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Email = strings.TrimSpace(req.Email)
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
return
}
if _, found, err := h.store.GetUser(req.Account); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
} else if found {
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
return
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
pending := models.PendingUser{
Account: req.Account,
PasswordHash: string(hash),
Username: req.Username,
Email: req.Email,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if err := h.store.SavePending(pending); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
return
}
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) VerifyEmail(c *gin.Context) {
var req verifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(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
}
pending, found, err := h.store.GetPending(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
return
}
if !verifyCode(req.Code, pending.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
return
}
record := models.UserRecord{
Account: pending.Account,
PasswordHash: pending.PasswordHash,
Username: pending.Username,
Email: pending.Email,
Level: 0,
SproutCoins: 0,
SecondaryEmails: []string{},
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),
}
if err := h.store.CreateUser(record); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_ = h.store.DeletePending(req.Account)
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()})
}
func (h *Handler) ForgotPassword(c *gin.Context) {
var req forgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Email = strings.TrimSpace(req.Email)
if req.Account == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
return
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
return
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
resetRecord := models.ResetPassword{
Account: user.Account,
Email: user.Email,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if err := h.store.SaveReset(resetRecord); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
return
}
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
_ = h.store.DeleteReset(user.Account)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Code = strings.TrimSpace(req.Code)
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
return
}
resetRecord, found, err := h.store.GetReset(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeleteReset(req.Account)
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
return
}
if !verifyCode(req.Code, resetRecord.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
return
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
_ = h.store.DeleteReset(req.Account)
c.JSON(http.StatusOK, gin.H{"reset": true})
}
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
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
}
var req secondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
emailAddr := strings.TrimSpace(req.Email)
if emailAddr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
user, 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 strings.TrimSpace(user.Email) == emailAddr {
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
return
}
for _, e := range user.SecondaryEmails {
if e == emailAddr {
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
return
}
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
record := models.SecondaryEmailVerification{
Account: user.Account,
Email: emailAddr,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if err := h.store.SaveSecondaryVerification(record); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
return
}
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
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
}
var req verifySecondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
emailAddr := strings.TrimSpace(req.Email)
code := strings.TrimSpace(req.Code)
if emailAddr == "" || code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
return
}
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
return
}
if !verifyCode(code, record.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
return
}
user, 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
}
for _, e := range user.SecondaryEmails {
if e == emailAddr {
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
return
}
}
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
}
func (h *Handler) Me(c *gin.Context) {
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
}
user, 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
}
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
}
func (h *Handler) UpdateProfile(c *gin.Context) {
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
}
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, 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 req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Phone != nil {
user.Phone = *req.Phone
}
if req.AvatarURL != nil {
user.AvatarURL = *req.AvatarURL
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
}
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
publicUsers := make([]models.UserPublic, 0, len(users))
for _, u := range users {
publicUsers = append(publicUsers, u.Public())
}
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
}
func (h *Handler) CreateUser(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
record := models.UserRecord{
Account: req.Account,
PasswordHash: string(hash),
Username: req.Username,
Email: req.Email,
Level: req.Level,
SproutCoins: req.SproutCoins,
SecondaryEmails: req.SecondaryEmails,
Phone: req.Phone,
AvatarURL: req.AvatarURL,
Bio: req.Bio,
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),
}
if err := h.store.CreateUser(record); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"user": record.Public()})
}
func (h *Handler) UpdateUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, found, err := h.store.GetUser(account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Email != nil {
user.Email = *req.Email
}
if req.Level != nil {
user.Level = *req.Level
}
if req.SproutCoins != nil {
user.SproutCoins = *req.SproutCoins
}
if req.SecondaryEmails != nil {
user.SecondaryEmails = *req.SecondaryEmails
}
if req.Phone != nil {
user.Phone = *req.Phone
}
if req.AvatarURL != nil {
user.AvatarURL = *req.AvatarURL
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
}
func (h *Handler) DeleteUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
if err := h.store.DeleteUser(account); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := adminTokenFromRequest(c)
if token == "" || token != h.store.AdminToken() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
c.Abort()
return
}
c.Next()
}
}
func adminTokenFromRequest(c *gin.Context) string {
if token := strings.TrimSpace(c.Query("token")); token != "" {
return token
}
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
return token
}
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
return bearerToken(authHeader)
}
func bearerToken(header string) string {
if header == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
return strings.TrimSpace(header[7:])
}
return ""
}
func generateVerificationCode() (string, error) {
randomBytes := make([]byte, 3)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
return fmt.Sprintf("%06d", number%1000000), nil
}
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func verifyCode(code string, hash string) bool {
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/models"
)
func bearerToken(header string) string {
if header == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
return strings.TrimSpace(header[7:])
}
return ""
}
func adminTokenFromRequest(c *gin.Context) string {
if token := strings.TrimSpace(c.Query("token")); token != "" {
return token
}
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
return token
}
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
return bearerToken(authHeader)
}
func generateVerificationCode() (string, error) {
randomBytes := make([]byte, 3)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
return fmt.Sprintf("%06d", number%1000000), nil
}
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func verifyCode(code string, hash string) bool {
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
}
func writeBanJSON(c *gin.Context, reason string) {
h := gin.H{"error": "account is banned"}
if r := strings.TrimSpace(reason); r != "" {
h["banReason"] = r
}
c.JSON(http.StatusForbidden, h)
}
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
if !u.Banned {
return false
}
writeBanJSON(c, u.BanReason)
return true
}

View File

@@ -0,0 +1,74 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
)
func (h *Handler) UpdateProfile(c *gin.Context) {
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
}
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, 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, user) {
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user.PasswordHash = string(hash)
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Phone != nil {
user.Phone = *req.Phone
}
if req.AvatarURL != nil {
user.AvatarURL = *req.AvatarURL
}
if req.WebsiteURL != nil {
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user.WebsiteURL = wu
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
}

View File

@@ -0,0 +1,14 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": h.store.RegistrationRequireInvite(),
})
}

View File

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetAdminRegistration(c *gin.Context) {
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"invites": cfg.Invites,
})
}
func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
var req updateRegistrationPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
return
}
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
}
func (h *Handler) PostAdminInvite(c *gin.Context) {
var req createInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
entry, err := h.store.AddInviteEntry(req.Note, req.MaxUses, strings.TrimSpace(req.ExpiresAt))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"invite": entry})
}
func (h *Handler) DeleteAdminInvite(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if err := h.store.DeleteInviteEntry(code); err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}

View File

@@ -0,0 +1,135 @@
package handlers
import (
"errors"
"net/url"
"strings"
)
type loginRequest struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
}
type verifyRequest struct {
Token string `json:"token"`
}
type registerRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
InviteCode string `json:"inviteCode"`
}
type verifyEmailRequest struct {
Account string `json:"account"`
Code string `json:"code"`
}
type updateProfileRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Phone *string `json:"phone"`
AvatarURL *string `json:"avatarUrl"`
WebsiteURL *string `json:"websiteUrl"`
Bio *string `json:"bio"`
}
type forgotPasswordRequest struct {
Account string `json:"account"`
Email string `json:"email"`
}
type resetPasswordRequest struct {
Account string `json:"account"`
Code string `json:"code"`
NewPassword string `json:"newPassword"`
}
type secondaryEmailRequest struct {
Email string `json:"email"`
}
type verifySecondaryEmailRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type updateCheckInConfigRequest struct {
RewardCoins int `json:"rewardCoins"`
}
type updateRegistrationPolicyRequest struct {
RequireInviteCode bool `json:"requireInviteCode"`
}
type createInviteRequest struct {
Note string `json:"note"`
MaxUses int `json:"maxUses"`
ExpiresAt string `json:"expiresAt"`
}
type createUserRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
SecondaryEmails []string `json:"secondaryEmails"`
Phone string `json:"phone"`
AvatarURL string `json:"avatarUrl"`
WebsiteURL string `json:"websiteUrl"`
Bio string `json:"bio"`
}
const maxBanReasonLen = 500
type updateUserRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Email *string `json:"email"`
Level *int `json:"level"`
SproutCoins *int `json:"sproutCoins"`
SecondaryEmails *[]string `json:"secondaryEmails"`
Phone *string `json:"phone"`
AvatarURL *string `json:"avatarUrl"`
WebsiteURL *string `json:"websiteUrl"`
Bio *string `json:"bio"`
Banned *bool `json:"banned"`
BanReason *string `json:"banReason"`
}
const maxWebsiteURLLen = 2048
func normalizePublicWebsiteURL(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
if len(s) > maxWebsiteURLLen {
return "", errors.New("website url is too long")
}
lower := strings.ToLower(s)
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
return "", errors.New("invalid website url")
}
candidate := s
if !strings.Contains(candidate, "://") {
candidate = "https://" + candidate
}
u, err := url.Parse(candidate)
if err != nil || u.Host == "" {
return "", errors.New("invalid website url")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", errors.New("only http and https urls are allowed")
}
u.Scheme = scheme
return u.String(), nil
}

View File

@@ -0,0 +1,154 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
)
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
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
}
var req secondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
emailAddr := strings.TrimSpace(req.Email)
if emailAddr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
user, 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, user) {
return
}
if strings.TrimSpace(user.Email) == emailAddr {
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
return
}
for _, e := range user.SecondaryEmails {
if e == emailAddr {
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
return
}
}
code, err := generateVerificationCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
return
}
expiresAt := time.Now().Add(10 * time.Minute)
record := models.SecondaryEmailVerification{
Account: user.Account,
Email: emailAddr,
CodeHash: hashCode(code),
CreatedAt: models.NowISO(),
ExpiresAt: expiresAt.Format(time.RFC3339),
}
if err := h.store.SaveSecondaryVerification(record); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
return
}
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"sent": true,
"expiresAt": expiresAt.Format(time.RFC3339),
})
}
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
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
}
var req verifySecondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
emailAddr := strings.TrimSpace(req.Email)
code := strings.TrimSpace(req.Code)
if emailAddr == "" || code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
return
}
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
return
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
return
}
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
return
}
if !verifyCode(code, record.CodeHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
return
}
user, 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, user) {
return
}
for _, e := range user.SecondaryEmails {
if e == emailAddr {
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()})
return
}
}
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
if err := h.store.SaveUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
return
}
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()})
}

View File

@@ -0,0 +1,106 @@
package models
import (
"strings"
"time"
)
var activityLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
const (
// 日与时刻之间留空格避免「20日11点」粘连难读
ActivityTimeLayout = "2006年1月2日 15点04分05秒"
// 历史数据可能无空格,解析时兼容
activityTimeLayoutLegacy = "2006年1月2日15点04分05秒"
ActivityDateLayout = "2006-01-02"
)
func CurrentActivityDate() string {
return time.Now().In(activityLocation).Format(ActivityDateLayout)
}
func CurrentActivityTime() string {
return FormatActivityTime(time.Now())
}
func FormatActivityTime(t time.Time) string {
return t.In(activityLocation).Format(ActivityTimeLayout)
}
func ParseActivityTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{ActivityTimeLayout, activityTimeLayoutLegacy, time.RFC3339Nano, time.RFC3339}
for _, layout := range layouts {
if parsed, err := time.ParseInLocation(layout, value, activityLocation); err == nil {
return parsed.In(activityLocation), true
}
if parsed, err := time.Parse(layout, value); err == nil {
return parsed.In(activityLocation), true
}
}
return time.Time{}, false
}
func ActivityDate(value string) (string, bool) {
parsed, ok := ParseActivityTime(value)
if !ok {
return "", false
}
return parsed.In(activityLocation).Format(ActivityDateLayout), true
}
func HasActivityDate(values []string, date string) bool {
for _, value := range values {
if parsedDate, ok := ActivityDate(value); ok && parsedDate == date {
return true
}
}
return false
}
func ActivitySummary(values []string, fallbackDate string) (days int, streak int, lastAt string) {
dateSet := make(map[string]struct{}, len(values))
var latest time.Time
hasLatest := false
for _, value := range values {
parsed, ok := ParseActivityTime(value)
if !ok {
continue
}
dateKey := parsed.In(activityLocation).Format(ActivityDateLayout)
dateSet[dateKey] = struct{}{}
if !hasLatest || parsed.After(latest) {
latest = parsed
hasLatest = true
lastAt = FormatActivityTime(parsed)
}
}
if len(dateSet) == 0 && strings.TrimSpace(fallbackDate) != "" {
dateSet[strings.TrimSpace(fallbackDate)] = struct{}{}
days = 1
streak = 1
return
}
days = len(dateSet)
if !hasLatest {
return
}
cursor := time.Date(latest.In(activityLocation).Year(), latest.In(activityLocation).Month(), latest.In(activityLocation).Day(), 0, 0, 0, 0, activityLocation)
for {
key := cursor.Format(ActivityDateLayout)
if _, ok := dateSet[key]; !ok {
break
}
streak++
cursor = cursor.AddDate(0, 0, -1)
}
return
}

View File

@@ -0,0 +1,40 @@
package models
import (
"regexp"
"strings"
)
const (
MaxAuthClientIDLen = 64
MaxAuthClientNameLen = 128
)
var authClientIDRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,63}$`)
// AuthClientEntry 记录某第三方应用曾用本账号完成认证(登录 / 校验令牌 / 拉取 me
type AuthClientEntry struct {
ClientID string `json:"clientId"`
DisplayName string `json:"displayName,omitempty"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
}
func NormalizeAuthClientID(raw string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" || len(s) > MaxAuthClientIDLen {
return "", false
}
if !authClientIDRe.MatchString(s) {
return "", false
}
return s, true
}
func ClampAuthClientName(raw string) string {
s := strings.TrimSpace(raw)
if len(s) > MaxAuthClientNameLen {
return s[:MaxAuthClientNameLen]
}
return s
}

View File

@@ -8,4 +8,5 @@ type PendingUser struct {
CodeHash string `json:"codeHash"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
InviteCode string `json:"inviteCode,omitempty"`
}

View File

@@ -1,52 +1,144 @@
package models
import "time"
import (
"strings"
"time"
)
type UserRecord struct {
Account string `json:"account"`
PasswordHash string `json:"passwordHash"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Account string `json:"account"`
PasswordHash string `json:"passwordHash"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
LastVisitDate string `json:"lastVisitDate,omitempty"`
LastVisitAt string `json:"lastVisitAt,omitempty"`
LastVisitIP string `json:"lastVisitIp,omitempty"`
LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"`
CheckInTimes []string `json:"checkInTimes,omitempty"`
VisitTimes []string `json:"visitTimes,omitempty"`
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Banned bool `json:"banned"`
BanReason string `json:"banReason,omitempty"`
BannedAt string `json:"bannedAt,omitempty"`
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
}
type UserPublic struct {
Account string `json:"account"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Account string `json:"account"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
LastVisitDate string `json:"lastVisitDate,omitempty"`
CheckInDays int `json:"checkInDays"`
CheckInStreak int `json:"checkInStreak"`
LastVisitAt string `json:"lastVisitAt,omitempty"`
LastVisitIP string `json:"lastVisitIp,omitempty"`
LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"`
VisitDays int `json:"visitDays"`
VisitStreak int `json:"visitStreak"`
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Banned bool `json:"banned,omitempty"`
BanReason string `json:"banReason,omitempty"`
BannedAt string `json:"bannedAt,omitempty"`
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
}
func (u UserRecord) Public() UserPublic {
checkInDays, checkInStreak, lastCheckInAt := ActivitySummary(u.CheckInTimes, u.LastCheckInDate)
visitDays, visitStreak, lastVisitAt := ActivitySummary(u.VisitTimes, u.LastVisitDate)
if strings.TrimSpace(u.LastCheckInAt) != "" {
lastCheckInAt = u.LastCheckInAt
}
if strings.TrimSpace(u.LastVisitAt) != "" {
lastVisitAt = u.LastVisitAt
}
return UserPublic{
Account: u.Account,
Username: u.Username,
Email: u.Email,
Level: u.Level,
Username: u.Username,
Email: u.Email,
Level: u.Level,
SproutCoins: u.SproutCoins,
LastCheckInDate: u.LastCheckInDate,
LastCheckInAt: lastCheckInAt,
LastVisitDate: u.LastVisitDate,
CheckInDays: checkInDays,
CheckInStreak: checkInStreak,
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,
}
}
// PublicProfile 在 Public 基础上附带最近访问 IP / 展示用地理位置,仅供「用户公开主页」接口使用。
func (u UserRecord) PublicProfile() UserPublic {
p := u.Public()
p.LastVisitIP = strings.TrimSpace(u.LastVisitIP)
p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation)
return p
}
// OwnerPublic 包含仅本人/管理员可见的字段(如最近访问 IP勿用于公开资料接口。
func (u UserRecord) OwnerPublic() UserPublic {
p := u.Public()
p.LastVisitIP = strings.TrimSpace(u.LastVisitIP)
p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation)
p.Banned = u.Banned
p.BanReason = strings.TrimSpace(u.BanReason)
p.BannedAt = strings.TrimSpace(u.BannedAt)
if len(u.AuthClients) > 0 {
p.AuthClients = append([]AuthClientEntry(nil), u.AuthClients...)
}
return p
}
type UserShowcase struct {
Account string `json:"account"`
Username string `json:"username"`
Level int `json:"level"`
SproutCoins int `json:"sproutCoins"`
AvatarURL string `json:"avatarUrl,omitempty"`
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"`
}
func (u UserRecord) Showcase() UserShowcase {
return UserShowcase{
Account: u.Account,
Username: u.Username,
Level: u.Level,
SproutCoins: u.SproutCoins,
AvatarURL: u.AvatarURL,
WebsiteURL: u.WebsiteURL,
Bio: u.Bio,
}
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}

View File

@@ -0,0 +1,214 @@
package storage
import (
"crypto/rand"
"errors"
"os"
"strings"
"time"
"sproutgate-backend/internal/models"
)
// InviteEntry 管理员发放的注册邀请码。
type InviteEntry struct {
Code string `json:"code"`
Note string `json:"note,omitempty"`
MaxUses int `json:"maxUses"` // 0 表示不限次数
Uses int `json:"uses"`
ExpiresAt string `json:"expiresAt,omitempty"` // RFC3339空表示不过期
CreatedAt string `json:"createdAt"`
}
// RegistrationConfig 注册策略与邀请码列表data/config/registration.json
type RegistrationConfig struct {
RequireInviteCode bool `json:"requireInviteCode"`
Invites []InviteEntry `json:"invites"`
}
func normalizeInviteCode(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
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 {
return err
}
if cfg.Invites == nil {
cfg.Invites = []InviteEntry{}
}
s.registrationConfig = cfg
return nil
}
func (s *Store) persistRegistrationConfigLocked() error {
return writeJSONFile(s.registrationPath, s.registrationConfig)
}
// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。
func (s *Store) RegistrationRequireInvite() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.registrationConfig.RequireInviteCode
}
// GetRegistrationConfig 返回配置副本(管理端)。
func (s *Store) GetRegistrationConfig() RegistrationConfig {
s.mu.Lock()
defer s.mu.Unlock()
out := s.registrationConfig
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
return out
}
// SetRegistrationRequireInvite 更新是否强制邀请码。
func (s *Store) SetRegistrationRequireInvite(require bool) error {
s.mu.Lock()
defer s.mu.Unlock()
s.registrationConfig.RequireInviteCode = require
return s.persistRegistrationConfigLocked()
}
func inviteEntryValid(e *InviteEntry) error {
if strings.TrimSpace(e.ExpiresAt) != "" {
t, err := time.Parse(time.RFC3339, e.ExpiresAt)
if err == nil && time.Now().After(t) {
return errors.New("invite code expired")
}
}
if e.MaxUses > 0 && e.Uses >= e.MaxUses {
return errors.New("invite code has been fully used")
}
return nil
}
// 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()
for i := range s.registrationConfig.Invites {
e := &s.registrationConfig.Invites[i]
if strings.EqualFold(e.Code, n) {
return inviteEntryValid(e)
}
}
return errors.New("invalid invite code")
}
// 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) {
if err := inviteEntryValid(e); err != nil {
return err
}
e.Uses++
return s.persistRegistrationConfigLocked()
}
}
return errors.New("invalid invite code")
}
const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func randomInviteToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
sb.WriteByte(inviteCodeAlphabet[int(b[i])%len(inviteCodeAlphabet)])
}
return sb.String(), nil
}
// 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)
if err != nil {
return InviteEntry{}, err
}
dup := false
for _, ex := range s.registrationConfig.Invites {
if strings.EqualFold(ex.Code, c) {
dup = true
break
}
}
if !dup {
code = c
break
}
}
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 {
return InviteEntry{}, errors.New("invalid expiresAt (use RFC3339)")
}
}
if maxUses < 0 {
maxUses = 0
}
entry := InviteEntry{
Code: code,
Note: strings.TrimSpace(note),
MaxUses: maxUses,
Uses: 0,
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]
return InviteEntry{}, err
}
return entry, nil
}
// DeleteInviteEntry 按码删除(大小写不敏感)。
func (s *Store) DeleteInviteEntry(code string) error {
n := normalizeInviteCode(code)
if n == "" {
return errors.New("code is required")
}
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()
}
}
return errors.New("invite not found")
}

View File

@@ -32,6 +32,10 @@ type EmailConfig struct {
Encryption string `json:"encryption"`
}
type CheckInConfig struct {
RewardCoins int `json:"rewardCoins"`
}
type Store struct {
dataDir string
usersDir string
@@ -41,10 +45,14 @@ type Store struct {
adminConfigPath string
authConfigPath string
emailConfigPath string
checkInPath string
registrationPath string
registrationConfig RegistrationConfig
adminToken string
jwtSecret []byte
issuer string
emailConfig EmailConfig
checkInConfig CheckInConfig
mu sync.Mutex
}
@@ -85,6 +93,8 @@ func NewStore(dataDir string) (*Store, error) {
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"),
}
if err := store.loadOrCreateAdminConfig(); err != nil {
return nil, err
@@ -95,6 +105,12 @@ func NewStore(dataDir string) (*Store, error) {
if err := store.loadOrCreateEmailConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateCheckInConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateRegistrationConfig(); err != nil {
return nil, err
}
return store, nil
}
@@ -118,6 +134,29 @@ func (s *Store) EmailConfig() EmailConfig {
return s.emailConfig
}
func (s *Store) CheckInConfig() CheckInConfig {
s.mu.Lock()
defer s.mu.Unlock()
cfg := s.checkInConfig
if cfg.RewardCoins <= 0 {
cfg.RewardCoins = 1
}
return cfg
}
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 {
return err
}
s.checkInConfig = cfg
return nil
}
func (s *Store) loadOrCreateAdminConfig() error {
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
token, err := generateToken()
@@ -244,6 +283,29 @@ func (s *Store) loadOrCreateEmailConfig() error {
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 {
return err
}
if cfg.RewardCoins <= 0 {
cfg.RewardCoins = 1
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
return err
}
}
s.checkInConfig = cfg
return nil
}
func generateSecret() ([]byte, error) {
secret := make([]byte, 32)
_, err := rand.Read(secret)
@@ -319,6 +381,176 @@ func (s *Store) SaveUser(record models.UserRecord) error {
return writeJSONFile(path, record)
}
// RecordAuthClient 在成功认证后记录第三方应用标识clientID 须已规范化)。
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
}
now := models.NowISO()
displayName = models.ClampAuthClientName(displayName)
found := false
for i := range record.AuthClients {
if record.AuthClients[i].ClientID == clientID {
record.AuthClients[i].LastSeenAt = now
if displayName != "" {
record.AuthClients[i].DisplayName = displayName
}
found = true
break
}
}
if !found {
record.AuthClients = append(record.AuthClients, models.AuthClientEntry{
ClientID: clientID,
DisplayName: displayName,
FirstSeenAt: now,
LastSeenAt: now,
})
}
record.UpdatedAt = now
if err := writeJSONFile(path, &record); err != nil {
return models.UserRecord{}, err
}
return record, nil
}
func (s *Store) RecordVisit(account string, today string, at 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) {
return models.UserRecord{}, false, os.ErrNotExist
}
var record models.UserRecord
if err := readJSONFile(path, &record); err != nil {
return models.UserRecord{}, false, err
}
if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) {
return record, 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()
}
record.UpdatedAt = models.NowISO()
if err := writeJSONFile(path, record); err != nil {
return models.UserRecord{}, false, err
}
return record, true, nil
}
const maxLastVisitIPLen = 45
const maxLastVisitDisplayLocationLen = 512
func clampVisitMeta(ip, displayLocation string) (string, string) {
ip = strings.TrimSpace(ip)
displayLocation = strings.TrimSpace(displayLocation)
if len(ip) > maxLastVisitIPLen {
ip = ip[:maxLastVisitIPLen]
}
if len(displayLocation) > maxLastVisitDisplayLocationLen {
displayLocation = displayLocation[:maxLastVisitDisplayLocationLen]
}
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 == "" {
rec, found, err := s.GetUser(account)
if err != nil {
return models.UserRecord{}, err
}
if !found {
return models.UserRecord{}, os.ErrNotExist
}
return rec, nil
}
s.mu.Lock()
defer s.mu.Unlock()
path := s.userFilePath(account)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return models.UserRecord{}, os.ErrNotExist
}
var record models.UserRecord
if err := readJSONFile(path, &record); err != nil {
return models.UserRecord{}, err
}
if ip != "" {
record.LastVisitIP = ip
}
if displayLocation != "" {
record.LastVisitDisplayLocation = displayLocation
}
record.UpdatedAt = models.NowISO()
if err := writeJSONFile(path, record); err != nil {
return models.UserRecord{}, err
}
return record, nil
}
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
path := s.userFilePath(account)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
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 record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) {
return record, 0, true, nil
}
reward := s.checkInConfig.RewardCoins
if reward <= 0 {
reward = 1
}
record.SproutCoins += reward
record.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()
}
record.UpdatedAt = models.NowISO()
if err := writeJSONFile(path, record); err != nil {
return models.UserRecord{}, 0, false, err
}
return record, reward, false, nil
}
func (s *Store) DeleteUser(account string) error {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -24,12 +24,34 @@ func main() {
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token", "X-Visit-Ip", "X-Visit-Location", "X-Auth-Client", "X-Auth-Client-Name"},
MaxAge: 12 * time.Hour,
}))
handler := handlers.NewHandler(store)
apiIntro := gin.H{
"name": "SproutGate API",
"title": "萌芽账户认证中心",
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
"version": "0.1.0",
"links": gin.H{
"apiDocs": "GET /api/docs — Markdown 接口说明(本仓库 API_DOCS.md",
"health": "GET /api/health",
},
"routePrefixes": []string{
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
"/api/public — 公开用户资料、注册策略(是否强制邀请码)",
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token",
},
}
router.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, apiIntro)
})
router.GET("/api", func(c *gin.Context) {
c.JSON(http.StatusOK, apiIntro)
})
router.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
@@ -49,7 +71,10 @@ func main() {
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
router.POST("/api/auth/verify", handler.Verify)
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/:account", handler.GetPublicUser)
router.GET("/api/public/registration-policy", handler.GetPublicRegistrationPolicy)
admin := router.Group("/api/admin")
admin.Use(handler.AdminMiddleware())
@@ -57,6 +82,12 @@ func main() {
admin.POST("/users", handler.CreateUser)
admin.PUT("/users/:account", handler.UpdateUser)
admin.DELETE("/users/:account", handler.DeleteUser)
admin.GET("/check-in/config", handler.GetCheckInConfig)
admin.PUT("/check-in/config", handler.UpdateCheckInConfig)
admin.GET("/registration", handler.GetAdminRegistration)
admin.PUT("/registration", handler.PutAdminRegistrationPolicy)
admin.POST("/registration/invites", handler.PostAdminInvite)
admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite)
port := os.Getenv("PORT")
if port == "" {