chore: sync

This commit is contained in:
2026-03-18 22:06:43 +08:00
parent e89678e61a
commit 0c4380c3c3
45 changed files with 5883 additions and 2 deletions

26
.gitignore vendored
View File

@@ -1,3 +1,29 @@
debug-logs/ debug-logs/
*.log *.log
.DS_Store .DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
# Frontend (Vite/React)
node_modules/
dist/
build/
.vite/
coverage/
# Backend (Go)
*.exe
*.dll
*.so
*.dylib
*.test
*.out
# Env
.env
.env.*
.env.local
.env.*.local

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# Repository Guidelines
## Project Structure & Module Organization
- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, styles in `src/styles.css`.
- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/`, domain models in `internal/models/`, storage in `internal/storage/`.
- `sproutgate-backend/data/`: JSON-backed config and data. Config files live in `data/config/`, user records in `data/users/`.
- Root scripts `sproutgate.sh` and `sproutgate.bat` provide dev and build shortcuts.
## Build, Test, and Development Commands
- `./sproutgate.sh dev` or `sproutgate.bat dev`: start backend and frontend dev servers.
- `./sproutgate.sh build` or `sproutgate.bat build`: build the frontend bundle.
- `cd sproutgate-backend && go mod tidy`: sync Go dependencies.
- `cd sproutgate-backend && go run .`: run the API (default port `8080`).
- `cd sproutgate-frontend && npm install`: install frontend dependencies.
- `cd sproutgate-frontend && npm run dev`: run Vite dev server (default `5173`).
- `cd sproutgate-frontend && npm run build`: create a production build.
- `cd sproutgate-frontend && npm run preview`: preview the build locally.
## Coding Style & Naming Conventions
- Go code should be `gofmt`-formatted; packages are lowercase, exported types use PascalCase, JSON tags use lowerCamelCase.
- Frontend code follows existing `src/*.jsx` patterns: 2-space indentation, double quotes, components in PascalCase, hooks prefixed with `use`.
- File naming mirrors current structure: Go files are lowercase (use underscores when needed, e.g., `secondary_email.go`); React components use `.jsx`.
## Testing Guidelines
- No automated test suites are configured yet (no `*_test.go` and no `npm test` script).
- When adding backend tests, use Go's standard testing (`*_test.go`) and run `go test ./...` in `sproutgate-backend/`.
- If frontend tests are introduced, document the runner and command in `sproutgate-frontend/package.json` and update this guide.
## Commit & Pull Request Guidelines
- Git history currently contains only `Initial commit`; there is no established commit convention yet.
- Use short, imperative commit summaries; add a scope when helpful (e.g., `frontend: refine admin table`).
- PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates.
## Security & Configuration Tips
- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email).
- Frontend API base URL can be set via `sproutgate-frontend/.env` using `VITE_API_BASE`.
- Avoid committing real credentials or production tokens; use local overrides for secrets.
- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`.

View File

@@ -1,3 +1,40 @@
# SproutGate # 萌芽账户认证中心(SproutGate
Project scaffold repo. 前后端分离的统一账户认证中心:
- 前端React`sproutgate-frontend`
- 后端Golang + Gin`sproutgate-backend`
- 数据:`data/` 与子目录 JSON 文件存储
## 快速启动
### 后端
```bash
cd sproutgate-backend
go mod tidy
go run .
```
默认端口 `8080`,默认管理员 Token`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。
邮件发送配置位于 `sproutgate-backend/data/config/email.json`
### 前端
```bash
cd sproutgate-frontend
npm install
npm run dev
```
如需自定义后端地址,新增 `sproutgate-frontend/.env`
```
VITE_API_BASE=http://localhost:8080
```
### 管理员地址
```
http://localhost:5173/admin?token=shumengya520
```
## API 文档
- 文件:`sproutgate-backend/API_DOCS.md`
- 在线:`GET /api/docs`

View File

@@ -0,0 +1,10 @@
.git
.gitignore
.idea
.vscode
data
node_modules
dist
*.log

View File

@@ -0,0 +1,296 @@
# 萌芽账户认证中心 API 文档
基础地址:`http://<host>:8080`
## 认证与统一登录
### 登录获取统一令牌
`POST /api/auth/login`
请求:
```json
{
"account": "demo",
"password": "demo123"
}
```
响应:
```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",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00Z",
"updatedAt": "2026-03-14T12:00:00Z"
}
}
```
### 校验令牌
`POST /api/auth/verify`
请求:
```json
{
"token": "jwt-token"
}
```
响应:
```json
{
"valid": true,
"user": { "account": "demo", "...": "..." }
}
```
### 获取当前用户信息
`GET /api/auth/me`
请求头:
`Authorization: Bearer <jwt-token>`
响应:
```json
{
"user": { "account": "demo", "...": "..." }
}
```
> 说明:密码不会返回。
### 更新当前用户资料
`PUT /api/auth/profile`
请求头:
`Authorization: Bearer <jwt-token>`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"bio": "### 新简介"
}
```
响应:
```json
{
"user": { "account": "demo", "...": "..." }
}
```
### 注册账号(发送邮箱验证码)
`POST /api/auth/register`
请求:
```json
{
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com"
}
```
响应:
```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` 中,默认值为 `shumengya520`
请求时可使用以下任一方式携带:
- Query`?token=shumengya520`
- Header`X-Admin-Token: shumengya520`
- Header`Authorization: Bearer shumengya520`
### 获取用户列表
`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",
"bio": "### 简介"
}
```
### 更新用户
`PUT /api/admin/users/{account}`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"level": 1,
"secondaryEmails": ["demo2@example.com"],
"sproutCoins": 99
}
```
### 删除用户
`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`
## 快速联调用示例
```bash
# 登录
curl -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"demo","password":"demo123"}'
# 使用令牌获取用户信息
curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>'
```

View File

@@ -0,0 +1,23 @@
FROM golang:1.20-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/sproutgate-backend .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
ENV PORT=8080
ENV DATA_DIR=/data
COPY --from=builder /out/sproutgate-backend /usr/local/bin/sproutgate-backend
COPY API_DOCS.md /app/API_DOCS.md
EXPOSE 8080
VOLUME ["/data"]
ENTRYPOINT ["sproutgate-backend"]

View File

@@ -0,0 +1,3 @@
{
"token": "shumengya520"
}

View File

@@ -0,0 +1,4 @@
{
"jwtSecret": "c3Byb3V0Z2F0ZS1zZWNyZXQ=",
"issuer": "sproutgate"
}

View File

@@ -0,0 +1,9 @@
{
"fromName": "萌芽账户认证中心",
"fromAddress": "notice@smyhub.com",
"username": "notice@smyhub.com",
"password": "tyh@19900420",
"smtpHost": "smtp.qiye.aliyun.com",
"smtpPort": 465,
"encryption": "SSL"
}

View File

@@ -0,0 +1,16 @@
{
"account": "admin",
"passwordHash": "$2a$10$T3XCFYOldB7b3RLuu.oxJeXTIdifjXIRyZdf/nHFIEwWAFRedysHi",
"username": "管理员",
"email": "admin@smyhub.com",
"level": 0,
"sproutCoins": 0,
"secondaryEmails": [
"mail@smyhub.com"
],
"phone": "74074091740",
"avatarUrl": "https://img.shumengya.top/i/2025/11/02/69073c02060d3.webp",
"bio": "我是管理员",
"createdAt": "2026-03-14T18:38:07+08:00",
"updatedAt": "2026-03-14T19:26:11+08:00"
}

View File

@@ -0,0 +1,13 @@
{
"account": "shumengya",
"passwordHash": "$2a$10$f6JZ6S26BdfK8dxHQ/eeb.q9adTbkBmyprta8WlMCR3v5gMpERlgO",
"username": "树萌芽",
"email": "mail@smyhub.com",
"level": 0,
"sproutCoins": 100,
"secondaryEmails": [],
"avatarUrl": "https://img.shumengya.top/i/2025/11/02/69073c018174e.webp",
"bio": "(=^・ω・^=) 喵~",
"createdAt": "2026-03-14T18:12:20+08:00",
"updatedAt": "2026-03-14T18:12:20+08:00"
}

View File

@@ -0,0 +1,14 @@
services:
sproutgate-auth:
build:
context: .
dockerfile: Dockerfile
container_name: sproutgate-auth
restart: unless-stopped
environment:
PORT: "8080"
DATA_DIR: /data
volumes:
- ./data:/data
ports:
- "${AUTH_API_PORT:-18080}:8080"

39
sproutgate-backend/go.mod Normal file
View File

@@ -0,0 +1,39 @@
module sproutgate-backend
go 1.20
require (
github.com/gin-contrib/cors v1.5.0
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
golang.org/x/crypto v0.23.0
)
require (
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
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
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/goccy/go-json v0.10.2 // 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
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
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
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

95
sproutgate-backend/go.sum Normal file
View File

@@ -0,0 +1,95 @@
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=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
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/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/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=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,49 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Account string `json:"account"`
jwt.RegisteredClaims
}
func GenerateToken(secret []byte, issuer string, account string, 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,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: account,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(secret)
return signed, expiresAt, err
}
func ParseToken(secret []byte, issuer string, tokenString string) (*Claims, error) {
if tokenString == "" {
return nil, errors.New("token is required")
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return secret, nil
}, jwt.WithIssuer(issuer))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,167 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"net/smtp"
"strings"
"time"
"sproutgate-backend/internal/storage"
)
func SendVerificationEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error {
if strings.TrimSpace(to) == "" {
return fmt.Errorf("email is required")
}
fromName := strings.TrimSpace(cfg.FromName)
if fromName == "" {
fromName = "萌芽账户认证中心"
}
fromAddress := strings.TrimSpace(cfg.FromAddress)
if fromAddress == "" {
return fmt.Errorf("from address is required")
}
username := strings.TrimSpace(cfg.Username)
if username == "" {
username = fromAddress
}
subject := "萌芽账户认证中心 - 邮箱验证"
encodedName := mime.QEncoding.Encode("UTF-8", fromName)
fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress)
body := fmt.Sprintf("您的验证码是:%s\n有效期%d 分钟\n\n如非本人操作请忽略此邮件。",
code, int(expiresIn.Minutes()))
var msg bytes.Buffer
msg.WriteString("From: " + fromHeader + "\r\n")
msg.WriteString("To: " + to + "\r\n")
msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost)
encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption))
if cfg.SMTPPort == 465 || encryption == "SSL" {
tlsConfig := &tls.Config{
ServerName: cfg.SMTPHost,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, cfg.SMTPHost)
if err != nil {
return err
}
defer client.Close()
if err := client.Auth(auth); err != nil {
return err
}
if err := client.Mail(fromAddress); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(msg.Bytes()); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes())
}
func SendResetPasswordEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error {
if strings.TrimSpace(to) == "" {
return fmt.Errorf("email is required")
}
fromName := strings.TrimSpace(cfg.FromName)
if fromName == "" {
fromName = "萌芽账户认证中心"
}
fromAddress := strings.TrimSpace(cfg.FromAddress)
if fromAddress == "" {
return fmt.Errorf("from address is required")
}
username := strings.TrimSpace(cfg.Username)
if username == "" {
username = fromAddress
}
subject := "萌芽账户认证中心 - 重置密码"
encodedName := mime.QEncoding.Encode("UTF-8", fromName)
fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress)
body := fmt.Sprintf("您的重置密码验证码是:%s\n有效期%d 分钟\n\n如非本人操作请忽略此邮件。",
code, int(expiresIn.Minutes()))
var msg bytes.Buffer
msg.WriteString("From: " + fromHeader + "\r\n")
msg.WriteString("To: " + to + "\r\n")
msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost)
encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption))
if cfg.SMTPPort == 465 || encryption == "SSL" {
tlsConfig := &tls.Config{
ServerName: cfg.SMTPHost,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, cfg.SMTPHost)
if err != nil {
return err
}
defer client.Close()
if err := client.Auth(auth); err != nil {
return err
}
if err := client.Mail(fromAddress); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(msg.Bytes()); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes())
}

View File

@@ -0,0 +1,751 @@
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,11 @@
package models
type PendingUser struct {
Account string `json:"account"`
PasswordHash string `json:"passwordHash"`
Username string `json:"username"`
Email string `json:"email"`
CodeHash string `json:"codeHash"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
}

View File

@@ -0,0 +1,9 @@
package models
type ResetPassword struct {
Account string `json:"account"`
Email string `json:"email"`
CodeHash string `json:"codeHash"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
}

View File

@@ -0,0 +1,9 @@
package models
type SecondaryEmailVerification struct {
Account string `json:"account"`
Email string `json:"email"`
CodeHash string `json:"codeHash"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
}

View File

@@ -0,0 +1,52 @@
package models
import "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"`
}
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"`
}
func (u UserRecord) Public() UserPublic {
return UserPublic{
Account: u.Account,
Username: u.Username,
Email: u.Email,
Level: u.Level,
SproutCoins: u.SproutCoins,
SecondaryEmails: u.SecondaryEmails,
Phone: u.Phone,
AvatarURL: u.AvatarURL,
Bio: u.Bio,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}

View File

@@ -0,0 +1,55 @@
package storage
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"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)
}
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) {
return models.PendingUser{}, false, nil
}
var record models.PendingUser
if err := readJSONFile(path, &record); err != nil {
return models.PendingUser{}, false, err
}
return record, 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)
}
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"
}

View File

@@ -0,0 +1,55 @@
package storage
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"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)
}
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) {
return models.ResetPassword{}, false, nil
}
var record models.ResetPassword
if err := readJSONFile(path, &record); err != nil {
return models.ResetPassword{}, false, err
}
return record, 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"
}

View File

@@ -0,0 +1,60 @@
package storage
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"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)
}
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) {
return models.SecondaryEmailVerification{}, false, nil
}
var record models.SecondaryEmailVerification
if err := readJSONFile(path, &record); err != nil {
return models.SecondaryEmailVerification{}, false, err
}
return record, 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"
}

View File

@@ -0,0 +1,340 @@
package storage
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"sproutgate-backend/internal/models"
)
type AdminConfig struct {
Token string `json:"token"`
}
type AuthConfig struct {
JWTSecret string `json:"jwtSecret"`
Issuer string `json:"issuer"`
}
type EmailConfig struct {
FromName string `json:"fromName"`
FromAddress string `json:"fromAddress"`
Username string `json:"username"`
Password string `json:"password"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
Encryption string `json:"encryption"`
}
type Store struct {
dataDir string
usersDir string
pendingDir string
resetDir string
secondaryDir string
adminConfigPath string
authConfigPath string
emailConfigPath string
adminToken string
jwtSecret []byte
issuer string
emailConfig EmailConfig
mu sync.Mutex
}
func NewStore(dataDir string) (*Store, error) {
if dataDir == "" {
dataDir = "./data"
}
absDir, err := filepath.Abs(dataDir)
if 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"),
}
if err := store.loadOrCreateAdminConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateAuthConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateEmailConfig(); err != nil {
return nil, err
}
return store, nil
}
func (s *Store) DataDir() string {
return s.dataDir
}
func (s *Store) AdminToken() string {
return s.adminToken
}
func (s *Store) JWTSecret() []byte {
return s.jwtSecret
}
func (s *Store) JWTIssuer() string {
return s.issuer
}
func (s *Store) EmailConfig() EmailConfig {
return s.emailConfig
}
func (s *Store) loadOrCreateAdminConfig() error {
defaultToken := "shumengya520"
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
cfg := AdminConfig{Token: defaultToken}
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) == "" {
cfg.Token = defaultToken
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: "tyh@19900420",
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
return nil
}
func generateSecret() ([]byte, error) {
secret := make([]byte, 32)
_, err := rand.Read(secret)
return secret, err
}
func (s *Store) ListUsers() ([]models.UserRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := os.ReadDir(s.usersDir)
if 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)
}
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) {
return models.UserRecord{}, false, nil
}
var record models.UserRecord
if err := readJSONFile(path, &record); err != nil {
return models.UserRecord{}, false, err
}
return record, 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)
}
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)
}
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 (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)
if err != nil {
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)
}

View File

@@ -0,0 +1,68 @@
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/handlers"
"sproutgate-backend/internal/storage"
)
func main() {
dataDir := os.Getenv("DATA_DIR")
store, err := storage.NewStore(dataDir)
if err != nil {
log.Fatalf("failed to init storage: %v", err)
}
router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token"},
MaxAge: 12 * time.Hour,
}))
handler := handlers.NewHandler(store)
router.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"dataDir": store.DataDir(),
})
})
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)
router.POST("/api/auth/verify-email", handler.VerifyEmail)
router.POST("/api/auth/forgot-password", handler.ForgotPassword)
router.POST("/api/auth/reset-password", handler.ResetPassword)
router.POST("/api/auth/secondary-email/request", handler.RequestSecondaryEmail)
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
router.POST("/api/auth/verify", handler.Verify)
router.GET("/api/auth/me", handler.Me)
router.PUT("/api/auth/profile", handler.UpdateProfile)
admin := router.Group("/api/admin")
admin.Use(handler.AdminMiddleware())
admin.GET("/users", handler.ListUsers)
admin.POST("/users", handler.CreateUser)
admin.PUT("/users/:account", handler.UpdateUser)
admin.DELETE("/users/:account", handler.DeleteUser)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
if err := router.Run(":" + port); err != nil {
log.Fatalf("server stopped: %v", err)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#3b82f6" />
<meta name="description" content="萌芽账户认证中心 - 统一认证与账户管理" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="application-name" content="SproutGate" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>萌芽账户认证中心</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

1690
sproutgate-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "sproutgate-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview --host"
},
"dependencies": {
"marked": "^12.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,23 @@
{
"name": "萌芽账户认证中心",
"short_name": "SproutGate",
"description": "统一认证与账户管理中心",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f6fb",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,64 @@
const CACHE_NAME = "sproutgate-v1";
const PRECACHE_URLS = [
"/",
"/index.html",
"/manifest.webmanifest",
"/favicon.ico",
"/logo.png",
"/logo192.png",
"/icon-192.png",
"/icon-512.png",
"/apple-touch-icon.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match("/index.html")))
);
return;
}
event.respondWith(
caches.match(request).then((cached) =>
cached ||
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => cached)
)
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => {
console.error("Service worker registration failed:", error);
});
});
}

View File

@@ -0,0 +1,479 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f4f6fb;
color: #1f2a44;
}
.app-shell {
min-height: 100vh;
}
.splash {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, #eef4ff 0%, #f7f9ff 40%, #eef2ff 100%);
overflow: hidden;
}
.splash-glow {
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 50% 30%, rgba(59, 130, 246, 0.18), transparent 55%),
radial-gradient(circle at 40% 70%, rgba(99, 102, 241, 0.12), transparent 55%);
animation: splashPulse 6s ease-in-out infinite;
}
.splash-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.splash-logo-wrap {
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.splash-rings span {
position: absolute;
width: 160px;
height: 160px;
border-radius: 50%;
border: 1px solid rgba(59, 130, 246, 0.35);
animation: ringPulse 2.6s ease-out infinite;
}
.splash-rings span:nth-child(2) {
animation-delay: 0.8s;
border-color: rgba(99, 102, 241, 0.25);
}
.splash-rings span:nth-child(3) {
animation-delay: 1.6s;
border-color: rgba(16, 185, 129, 0.22);
}
.splash-logo {
width: 120px;
height: 120px;
padding: 12px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.2);
animation: floatLogo 3s ease-in-out infinite;
}
.splash-title {
font-size: 28px;
font-weight: 700;
letter-spacing: 1px;
color: #1f2a44;
}
.splash-subtitle {
font-size: 14px;
color: #6b7280;
}
.splash-dots {
display: flex;
gap: 6px;
margin-top: 6px;
}
.splash-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
animation: dotPulse 1.2s infinite ease-in-out;
}
.splash-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.splash-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes splashPulse {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes ringPulse {
0% {
transform: scale(0.6);
opacity: 0.6;
}
70% {
opacity: 0.2;
}
100% {
transform: scale(1.7);
opacity: 0;
}
}
@keyframes floatLogo {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes dotPulse {
0%,
100% {
transform: scale(0.7);
opacity: 0.6;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 600px) {
.splash-logo-wrap {
width: 160px;
height: 160px;
}
.splash-rings span {
width: 120px;
height: 120px;
}
.splash-logo {
width: 100px;
height: 100px;
}
.splash-title {
font-size: 22px;
}
}
a {
color: inherit;
text-decoration: none;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.app-header h1 {
margin: 0 0 4px;
font-size: 28px;
}
.app-header p {
margin: 0;
color: #6b7280;
}
nav {
display: flex;
gap: 12px;
}
nav a {
padding: 8px 14px;
border-radius: 999px;
background: #e8ecf8;
font-size: 14px;
}
.panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.panel-title {
font-size: 20px;
font-weight: 600;
}
.card {
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.form label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
font-size: 14px;
}
.label-text,
.info-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #111827;
}
.label-text .hint {
margin-left: 4px;
}
.icon {
width: 16px;
height: 16px;
display: inline-flex;
color: #111827;
}
.icon svg {
width: 16px;
height: 16px;
}
.form input,
.form textarea {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
}
.form textarea {
resize: vertical;
}
.actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tag {
background: #e0e7ff;
color: #3730a3;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
button {
border: none;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
}
.primary {
background: #3b82f6;
color: white;
}
.ghost {
background: #eef2ff;
color: #4338ca;
}
.danger {
background: #fee2e2;
color: #b91c1c;
}
.error {
color: #dc2626;
margin-top: 8px;
}
.success {
color: #16a34a;
margin-top: 8px;
}
.hint {
color: #6b7280;
font-size: 12px;
}
.profile {
display: flex;
flex-direction: column;
gap: 18px;
}
.profile-header {
display: flex;
gap: 16px;
align-items: center;
}
.profile-header img {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.profile-grid div {
background: #f8fafc;
padding: 12px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.profile-grid span {
color: #6b7280;
font-size: 12px;
}
.profile-grid .info-label {
color: #374151;
font-size: 13px;
font-weight: 600;
}
.profile-grid .info-label span {
color: #374151;
}
.markdown-body {
background: #f8fafc;
border-radius: 12px;
padding: 16px;
}
.grid {
display: grid;
grid-template-columns: 1.1fr 1.4fr;
gap: 20px;
}
.list .table {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-row {
display: grid;
grid-template-columns: 1.1fr 0.9fr 1.8fr 0.6fr 0.6fr 1fr;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 10px;
background: #f9fafb;
}
.table-row.header {
background: transparent;
font-weight: 600;
color: #6b7280;
}
.table-row span {
font-size: 13px;
word-break: break-all;
}
.table-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.table-cell .icon {
color: #111827;
}
.row-actions {
display: flex;
gap: 6px;
}
@media (max-width: 900px) {
.app {
padding: 16px;
}
.app-header {
flex-direction: column;
align-items: flex-start;
}
.grid {
grid-template-columns: 1fr;
}
.profile-grid {
grid-template-columns: 1fr;
}
.table-row {
grid-template-columns: 1fr;
gap: 6px;
}
.table-row.header {
display: none;
}
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});

33
sproutgate.bat Normal file
View File

@@ -0,0 +1,33 @@
@echo off
setlocal
set "ROOT_DIR=%~dp0"
set "MODE=%~1"
if "%MODE%"=="" set "MODE=dev"
if /I "%MODE%"=="dev" goto DEV
if /I "%MODE%"=="build" goto BUILD
echo Usage: sproutgate.bat [dev^|build]
exit /b 1
:DEV
echo ==^> Starting backend (Gin)...
start "SproutGate Backend" cmd /k "cd /d %ROOT_DIR%sproutgate-backend && go run ."
echo ==^> Starting frontend (React)...
cd /d %ROOT_DIR%sproutgate-frontend
if not exist node_modules (
npm install
)
npm run dev
exit /b 0
:BUILD
echo ==^> Building frontend...
cd /d %ROOT_DIR%sproutgate-frontend
if not exist node_modules (
npm install
)
npm run build
exit /b 0

45
sproutgate.sh Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODE="${1:-dev}"
start_dev() {
echo "==> Starting backend (Gin)..."
(cd "${ROOT_DIR}/sproutgate-backend" && go run .) &
BACKEND_PID=$!
trap 'echo; echo "==> Stopping backend..."; kill ${BACKEND_PID} 2>/dev/null || true; exit 0' INT TERM
echo "==> Starting frontend (React)..."
cd "${ROOT_DIR}/sproutgate-frontend"
if [ ! -d node_modules ]; then
npm install
fi
npm run dev
echo "==> Frontend stopped, shutting down backend..."
kill ${BACKEND_PID} 2>/dev/null || true
}
build_frontend() {
echo "==> Building frontend..."
cd "${ROOT_DIR}/sproutgate-frontend"
if [ ! -d node_modules ]; then
npm install
fi
npm run build
}
case "${MODE}" in
dev)
start_dev
;;
build)
build_frontend
;;
*)
echo "Usage: ./sproutgate.sh [dev|build]"
exit 1
;;
esac