feat: implement Spark IM V3 modular monolith

Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhr 2026-09-17 19:52:11 +08:00
parent 6ce89511fa
commit ae9a71034d
283 changed files with 19530 additions and 617 deletions

View File

@ -0,0 +1,16 @@
---
description: HTTP MVC, validator i18n keys, RealIP and language on context, REST groups.
globs: internal/transport/http/**/*.go,internal/pkg/validate/**/*.go,internal/pkg/reqctx/**/*.go,internal/pkg/i18n/**/*.go
alwaysApply: false
---
# HTTP MVC
Source: `.cursor/skills/spark-im-v3/http.md` and `i18n.md`.
- Handlers only bind and call Application services. Login and other business live in `internal/module/*`, never in Gin handlers.
- Bind JSON with `respond.BindJSON` + `validate` / `label` / `errmsg` **i18n keys**. Client `msg` is localized (`zh-CN` / `en` / `zh-TW`).
- Put `RealIP` and `Language` on `reqctx.RequestContext`. HTTP: `X-Language` or `Accept-Language`. TCP: AUTH `language`. Rate limit uses `reqctx.RealIP(ctx)`. Errors/notices use `reqctx.Lang(ctx)`.
- Routes are REST under `/v1/{resource}` and grouped by business in `router.go`.
- Do not import package `httpx` from `handler/*` (cycle). Use `internal/transport/http/respond`.
- Do not hardcode error or notification strings; add catalog entries in `internal/pkg/i18n`.

View File

@ -17,3 +17,5 @@ Queue: `RPUSH` / `BRPOPLPUSH` to `deliver:processing` / `LREM` ack. On boot `Req
Inbox: Redis ZSET `im:dialog:unread|read:{uid}`, preview, unread hash. Mailbox MySQL is only missed push + control events (recall/kick/friend). Never write `NEW_MESSAGE` mailbox for a successful online push.
P2P conversation key is `(user_small, user_big)`. Sync messages with `id > last_msg_id`.
`messages.content` is a BLOB of serialized JSON bytes for a typed payload (text/image/voice/video/emoji/gif/file/location/system). Do not store a raw string. Media bytes stay in object storage; content holds the object key plus metadata.

View File

@ -0,0 +1,19 @@
---
description: Module layering — entity, model, logic. One GORM file per table.
globs: internal/module/**/*.go
alwaysApply: false
---
# Module layers
Each business module under `internal/module/{biz}` is three packages:
| Layer | Path | Owns |
|-------|------|------|
| entity | `{biz}/entity` | Domain structs, message payload types, no I/O |
| model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT tags), Redis adapters |
| logic | `{biz}/logic` | One file per **function** (profile, login, send, inbox sync). Not one file per table. |
`logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only).
Message `content` is serialized JSON **bytes** in a BLOB. Never persist a raw string. Media is an object-storage key, never file bytes.

View File

@ -2,9 +2,11 @@
name: spark-im-v3
description: >-
Implements Spark IM modular monolith per architecture V3. Use when writing or
reviewing spark-server Go code, TCP/gnet, Gin HTTP, messages, deliver queue,
inbox ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM,
MsgId, TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP.
reviewing spark-server Go code, TCP/gnet, Gin HTTP, REST /v1 groups, MVC
handlers, go-playground validator, RealIP, messages, deliver queue, inbox
ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId,
TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP, validator, LoginService,
i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic.
---
# Spark IM V3
@ -14,15 +16,55 @@ Read [invariants.md](invariants.md) before changing send/sync/transport. Full de
## Layout
```text
cmd/server process entry
internal/bootstrap config, lifecycle
internal/transport/http Gin, loopback :8080
internal/transport/tcp gnet EventLoop + TaskPool + Frame
internal/module/* Application services (HTTP and TCP share these)
internal/infrastructure mysql, redis, id, logger, push
cmd/server process entry
cmd/migrate apply migrations/*.sql (re-runs all files)
internal/bootstrap config, lifecycle, wire model → logic
internal/transport/http Gin engine, REST groups, middleware
internal/transport/http/handler thin MVC controllers (no business)
internal/transport/http/respond BindJSON + envelope write
internal/transport/tcp gnet EventLoop + TaskPool + Frame
internal/module/{biz} entity / model / logic (see Layers)
internal/pkg process-agnostic helpers (no DSN / pools)
internal/infrastructure process adapters (mysql, redis, id, logger, push)
```
Module files: `service.go` + entity types + `repository.go` interface. No extra DDD layer.
HTTP: read [http.md](http.md) — validator keys, `reqctx.RealIP`, `/v1/{resource}` groups, MVC login.
i18n: read [i18n.md](i18n.md) — request language, error `msg`, notification types.
## Layers
Each `internal/module/{biz}` is three packages. Do not put entity, GORM rows, and use-cases in one directory.
| Layer | Path | Owns |
|-------|------|------|
| entity | `{biz}/entity` | Domain types, message payloads. No I/O. |
| model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT). Redis adapters. |
| logic | `{biz}/logic` | One file per **function** (profile, login, send). Not per table. |
`logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only). HTTP/TCP both call `logic`; Gin handlers only bind and write the envelope.
## pkg vs infrastructure
Do **not** move `infrastructure` into `pkg` (or a public repo-root `pkg/`).
| Path | Owns | May import |
|------|------|------------|
| `internal/pkg` | `errs`, `i18n`, `validate`, `reqctx`, `reply`, in-memory `ratelimit` | stdlib + other pkg only |
| `internal/infrastructure` | MySQL pool, Redis client, Snowflake `id`, zap `logger`, push, metrics | pkg + vendor drivers |
`pkg` has no connection lifecycle and must not know DSN, Redis AUTH, or `snowflake_worker_id`. `infrastructure` is opened/closed by `bootstrap`. Direction: infrastructure → pkg, never the reverse.
## IDs
Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys.
| Kind | Examples | Rule |
|------|----------|------|
| Snowflake | `users.id` (uid), `messages.id` (MsgId), `conversations.id` | Time-ordered business IDs. One `id.Generator` per process; `snowflake_worker_id` unique per process. Clock rewind fails issuance for the whole process. |
| AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号) | Internal row id, or sequential public 群号. Business chat cursor is still Snowflake `conversations.id`. |
| Natural key | `user_emails.email`, P2P `(user_small, user_big)`, members, sync cursors | Composite / natural PK. No extra surrogate id. |
`device_id` is **client-generated**, stored on the device, written to JWT `did`. The server does not allocate it. `uid` in JSON **is** `users.id`; do not add a second uid column.
## Send path (must keep this order)
@ -48,16 +90,27 @@ If step 5 fails, do not ACK. Client retries the same `client_msg_id`.
`AUTH HEARTBEAT GENERATE_MSG_ID SEND_MESSAGE SEND_ACK MESSAGE PUSH_ACK READ RECALL SYNC_INBOX SYNC_MESSAGES KICK`
Media: HTTP upload credential, message content is object key only. Frame max 64KB. Compress body over 512B.
Message types: text / image / voice / video / emoji / gif / file / location / system. `messages.content` stores serialized JSON bytes (BLOB), never a raw string.
TCP body is JSON envelope `{code,msg,data}`. AUTH request is `{"token","device_id","app_version","language"}` (not protobuf).
Public TLS is Nginx `:443` SNI: HTTP cert on loopback `:8443`, IM cert on loopback `:9001` then PROXY to gnet `:9000`. See `deploy/nginx/`.
AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Deliver workers start with `RequeueStale` + `SETNX deliver:done:{msgId}`.
## When adding a feature
- HTTP writes must enqueue deliver jobs for online devices.
- List/unread go to Redis ZSET, not MySQL row-per-member.
- New goroutine I/O does not belong in `OnTraffic`.
- HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context.
- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`).
- SQL tables and GORM models carry COMMENT on the table and every column.
- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy.
- Run `make lint` (funlen 100).
## Additional resources
- [invariants.md](invariants.md)
- [send-path.md](send-path.md)
- [http.md](http.md)
- [i18n.md](i18n.md)
- `docs/architecture-v3.md`

View File

@ -0,0 +1,105 @@
# HTTP: validator, RealIP, REST groups, MVC
HTTP and TCP share Application services in `internal/module/*`. Gin is a thin adapter.
## MVC
| Layer | Lives in | Owns |
|-------|----------|------|
| Model / application | `internal/module/{biz}/{entity,model,logic}` | Domain types; one GORM file per table; logic by function, not by table |
| Controller | `internal/transport/http/handler/{biz}` | Bind, call service, write envelope |
| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,data}` |
Handlers must not contain business rules (credential check, token issue, friend/group permission, persist). Example: login belongs on `auth/logic.LoginService`, not in the Gin handler.
```go
func (h *Handler) Login(c *gin.Context) {
var in auth.LoginInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
tok, err := h.login.Login(c.Request.Context(), &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, tok)
}
```
Wire services in `internal/bootstrap`, pass them as `httpx.EngineDeps`. TCP commands call the same services.
Do not import `internal/transport/http` (package `httpx`) from `handler/*` — that is an import cycle. Use `respond` for Bind/JSON.
## Validator (`github.com/go-playground/validator/v10`)
Use `validate` tags. Bind with `respond.BindJSON` (JSON decode + `validate.StructLang`). Do not use Gin's `binding` tags as the source of client messages.
`label` and `errmsg` are **i18n keys** (see [i18n.md](i18n.md)), not a single language's sentences:
- `label:"field.email"` — field name in default templates (`validate.required` → `{label}不能为空` / `{label} is required`)
- `errmsg:"required:validate.login.email_required"` — per-tag key, `;` separates tags
Failed validation is `errs.ErrInvalidArgument` (`40001`) with `Msg` already localized. Transport must not replace it.
Shared DTO example (`internal/module/auth/logic.LoginInput`):
```go
type LoginInput struct {
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"`
DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"`
}
```
TCP may call `validate.StructLang` / `LoginService` on the same struct. Keep DTOs in the module, not in the handler package.
## RealIP on context (C6)
`reqctx.RequestContext.RealIP` is the client address for rate limit and audit. Never use gnet `RemoteAddr` (Nginx → 127.0.0.1).
| Transport | How RealIP is set |
|-----------|-------------------|
| HTTP | `X-Real-IP`, else first `X-Forwarded-For`, else `RemoteAddr` host (`reqctx.ParseHTTPRealIP`) |
| TCP | PROXY protocol on the session, copied in `session.RequestContext()` |
Read it with `reqctx.RealIP(ctx)`. Trace middleware must attach `*reqctx.RequestContext` before Auth and handlers. Zap fields already include `real_ip` via `logger.Attach`.
Gin binds loopback only (C4); Nginx on 443 sets `X-Real-IP`. `SetTrustedProxies` is `127.0.0.1` / `::1`.
## Language on context
Every HTTP request carries `X-Language` or `Accept-Language`. TCP AUTH carries `language`. Canonical values: `zh-CN` (default), `en`, `zh-TW`. Details: [i18n.md](i18n.md).
## REST grouping
Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/transport/http/router.go`, one function per business.
| Group | Prefix | Examples |
|-------|--------|----------|
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI |
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
| users | `/v1/users` | `GET/PATCH /v1/users/me` |
| friends | `/v1/friends` | apply, requests, accept, profile, remark, block, report |
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
| conversations | `/v1/conversations` | list, mute |
| messages | `/v1/messages` | history (sync still prefers TCP) |
| files | `/v1/files` | upload credential |
Rules:
- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP.
- Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`.
- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs).
- OpenAPI `tags` must match these groups.
## Checklist when adding an HTTP API
1. Input struct in `internal/module/{biz}` with `validate` / `label` / `errmsg` **keys**.
2. Use-case method on a service in that module (HTTP and TCP share it).
3. Thin handler under `internal/transport/http/handler/{biz}`.
4. Register on the matching `v1.Group` in `router.go`.
5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`.
6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit.
7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`.

View File

@ -0,0 +1,57 @@
# i18n (zh-CN / en / zh-TW)
User-visible **error `msg`** and **notification copy** are never hardcoded in handlers. They come from `internal/pkg/i18n` using the request language.
## Language on every request
Canonical tags: `zh-CN` (default), `en`, `zh-TW`. Parse with `i18n.Parse`; store on `reqctx.RequestContext.Language`. Read with `reqctx.Lang(ctx)` / `reqctx.Language(ctx)`.
| Transport | Client sends | Server stores |
|-----------|--------------|----------------|
| HTTP | `X-Language` if set, else `Accept-Language` | Trace middleware, every request |
| TCP | AUTH JSON field `language` | `session.Conn.SetLanguage` for the connection; copied into each command's `RequestContext` |
Missing or unknown tags → `zh-CN`. `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`.
Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language.
## Errors
Sentinel `AppError` keeps an English `Msg` for logs. Envelope `msg` is localized at the transport boundary:
- HTTP: `reply.FromContext(ctx, err)`
- TCP: `protocol.EncodeErrLang(i18n.Parse(conn.Language()), err)`
`WithMsg` is only for **already-localized** text (validator). Do not `WithMsg("unauthorized")` in a handler.
Add a new business code: register `err.{code}` in `internal/pkg/i18n/catalog_err.go` for all three languages.
## Validation
`label` and `errmsg` are **catalog keys**, not Chinese sentences:
```go
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required"`
```
`validate.StructLang(reqctx.Lang(ctx), in)`. Default templates are `validate.required` etc. with `{label}` / `{param}`.
## Notification types
Push and in-app system notices use type keys, then `i18n.Notify(lang, typ, args)` for title/body. Recipient language is the **target device** session language (TCP) or the user's stored lang — never the sender's.
| Type | Key |
|------|-----|
| Same-device kick | `notify.kick.replaced` |
| Friend request | `notify.friend.request` |
| Group invite | `notify.group.invite` |
| Removed from group | `notify.group.removed` |
| Message recall | `notify.message.recall` |
`push.Notification.Type` holds the key. Fill `Title`/`Body` with `i18n.Notify` before `Notifier.Notify`. Add new types in `catalog_notify.go` (`.title` / `.body`, `{name}` placeholders).
## Do not
- Hardcode `msg` or kick/push strings in `handler/*` or EventLoop.
- Localize with the sender language when notifying another user.
- Introduce a fourth locale without adding it to `i18n.Lang` and every catalog entry.

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
.git
.cursor
.idea
.vscode
bin
logs
vendor
examples
docs
*.md
*.test
*.out
coverage.out
.env
certs
deploy/nginx

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
bin/
logs/
vendor/
*.exe
*.test

View File

@ -5,7 +5,7 @@ run:
tests: true
go: "1.26"
linters-settings:
linters_settings:
funlen:
lines: 100
statements: 100

View File

@ -7,6 +7,7 @@ Before writing code:
1. Load `.cursor/skills/spark-im-v3/SKILL.md`
2. Follow `.cursor/rules/` (always-on V3 invariants + file-scoped rules)
3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240)
4. HTTP and TCP share Application services under `internal/module/*`
4. HTTP and TCP share Application services under `internal/module/{biz}/logic`. Each module is `entity` / `model` (one file per table) / `logic` (one file per function, not per table). Gin handlers only bind and call those services (MVC). REST paths are `/v1/{resource}` grouped by business. Request DTOs use go-playground/validator (`label` / `errmsg` i18n keys). Context must carry `reqctx.RealIP` and the user language (`zh-CN` / `en` / `zh-TW`). Error `msg` and notification copy come from `internal/pkg/i18n`. Table and column COMMENT is required. Message `content` is serialized bytes, not a string.
5. After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself. Do not ask the user to apply it later.
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-server ./cmd/server \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-migrate ./cmd/migrate
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata wget \
&& adduser -D -H -u 10001 spark
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=build /out/spark-server /usr/local/bin/spark-server
COPY --from=build /out/spark-migrate /usr/local/bin/spark-migrate
COPY migrations /app/migrations
COPY deploy/docker/config.docker.yaml /app/configs/config.yaml
RUN mkdir -p /app/logs && chown -R spark:spark /app
USER spark
EXPOSE 8080 9000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/spark-server"]
CMD ["-config", "/app/configs/config.yaml"]

View File

@ -2,8 +2,9 @@ GO ?= go
MODULE := spark-server
BIN := bin/spark-server
LINT ?= golangci-lint
CONFIG ?= configs/config.yaml
.PHONY: all build test lint fmt vet tidy run
.PHONY: all build test lint fmt vet tidy migrate start run deploy
all: lint test build
@ -25,5 +26,14 @@ lint:
tidy:
$(GO) mod tidy
migrate:
$(GO) run ./cmd/migrate -config $(CONFIG)
start:
$(GO) run ./cmd/server -config $(CONFIG)
run: build
./$(BIN) -config configs/config.yaml
./$(BIN) -config $(CONFIG)
deploy:
bash deploy/docker/deploy.sh

View File

@ -3,14 +3,19 @@
Spark IM modular monolith. Architecture: [docs/architecture-v3.md](docs/architecture-v3.md).
- Go 1.26
- Gin HTTP on `127.0.0.1:8080`
- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; production terminates TLS at Nginx or a PROXY+TLS wrapper in front of gnet. Loopback is not public.
- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","data"}`. Docs: `GET /docs`, spec: `GET /openapi.yaml`.
- Probes: `GET /healthz` liveness; `GET /readyz` readiness (HTTP 503 when MySQL/Redis/task pool/accepting are not ready). `env: prod` (or `SPARK_ENV=prod`) rejects empty MySQL DSN, empty Redis, and the default JWT secret.
- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `trace_id` / `request_id` follow context (`X-Request-ID` / `X-Trace-ID`).
- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`.
- TCP Frame body is JSON `{code,msg,data}`. AUTH body: `{"token","device_id","app_version","language"}`.
- Public entry is Nginx `:443` only (SNI split)
```bash
go build -o bin/spark-server ./cmd/server
./bin/spark-server -config configs/config.yaml
make start
make migrate
make lint
```
`make start` runs `cmd/server` against `configs/config.yaml` (override with `make start CONFIG=path/to.yaml`). `make run` builds `bin/spark-server` first, then starts that binary.
Agent conventions live in `.cursor/rules` and `.cursor/skills/spark-im-v3`. Lint conventions live in `.golangci.yaml`.

129
cmd/migrate/main.go Normal file
View File

@ -0,0 +1,129 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"go.uber.org/zap"
"spark-server/internal/bootstrap"
"spark-server/internal/infrastructure/mysql"
redisx "spark-server/internal/infrastructure/redis"
)
func main() {
cfgPath := flag.String("config", "configs/config.yaml", "config path")
dir := flag.String("dir", "migrations", "SQL migrations directory")
flag.Parse()
if err := run(*cfgPath, *dir); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run(cfgPath, dir string) error {
cfg, err := bootstrap.Load(cfgPath)
if err != nil {
return err
}
log := zap.NewNop()
db, err := mysql.Open(log, &mysql.Options{
DSN: cfg.MySQL.DSN,
SlowThreshold: cfg.MySQL.SlowThreshold,
MaxOpenConns: cfg.MySQL.MaxOpenConns,
MaxIdleConns: cfg.MySQL.MaxIdleConns,
ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime,
ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime,
})
if err != nil {
return fmt.Errorf("mysql: %w", err)
}
defer db.Close()
if !db.Ready() {
return fmt.Errorf("mysql: dsn is empty")
}
rdb, err := redisx.Open(&redisx.Options{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
if err != nil {
return fmt.Errorf("redis: %w", err)
}
defer rdb.Close()
if !rdb.Ready() {
return fmt.Errorf("redis: addr is empty")
}
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer pingCancel()
if err := rdb.Ping(pingCtx); err != nil {
return fmt.Errorf("redis ping: %w", err)
}
files, err := filepath.Glob(filepath.Join(dir, "*.sql"))
if err != nil {
return err
}
gdb := db.Gorm()
ctx := context.Background()
for _, path := range files {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
for i, stmt := range splitSQL(string(raw)) {
if err := gdb.WithContext(ctx).Exec(stmt).Error; err != nil {
if ignorableDDL(err) {
continue
}
return fmt.Errorf("%s statement %d: %w", filepath.Base(path), i+1, err)
}
}
fmt.Printf("ok %s\n", filepath.Base(path))
}
fmt.Println("mysql ok, redis ok")
return nil
}
func splitSQL(raw string) []string {
var b strings.Builder
for _, line := range strings.Split(raw, "\n") {
trim := strings.TrimSpace(line)
if trim == "" || strings.HasPrefix(trim, "--") {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
parts := strings.Split(b.String(), ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
out = append(out, s)
}
}
return out
}
func ignorableDDL(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, // table already exists
1060, // duplicate column
1061: // duplicate key name
return true
default:
return false
}
}

View File

@ -1,8 +1,10 @@
env: dev
server:
http:
addr: "127.0.0.1:8080"
tcp:
addr: "127.0.0.1:9000"
addr: "127.0.0.1:8090"
proxy_protocol: true
tls:
cert_file: ""
@ -18,13 +20,42 @@ server:
id:
snowflake_worker_id: 1
log:
level: debug
encoding: console
console: true
file:
path: "logs/spark-server.log"
max_size_mb: 100
max_backups: 7
max_age_days: 7
compress: true
mysql:
dsn: ""
slow_threshold: 200ms
log_level: ""
max_open_conns: 64
max_idle_conns: 16
conn_max_lifetime: 30m
conn_max_idle_time: 10m
redis:
addr: ""
username: ""
password: ""
db: 0
deliver_queue_key: "deliver:queue"
deliver_processing_key: "deliver:processing"
deliver_done_prefix: "deliver:done:"
jwt:
secret: "dev-only-change-me"
issuer: "spark-im"
ttl: 168h
refresh_ttl: 720h
denylist_prefix: "denylist:"
refresh_prefix: "refresh:"
taskpool:
size: 256
@ -44,8 +75,19 @@ rate_limit:
sync_inbox_per_minute: 60
sync_messages_per_minute: 120
history_per_minute: 200
upload_per_second: 10
message:
max_content_size: 65536
sync_limit: 200
msgid_expire_ms: 300000
object_storage:
endpoint: ""
access_key: ""
secret_key: ""
bucket: "spark"
region: "us-east-1"
use_ssl: false
presign_ttl: 15m
chat_expire_days: 365

View File

@ -0,0 +1,93 @@
env: prod
server:
http:
addr: "0.0.0.0:8080"
tcp:
addr: "0.0.0.0:9000"
proxy_protocol: true
tls:
cert_file: ""
key_file: ""
max_frame_size: 65536
idle_timeout: 120s
keepalive: 30s
recv_buffer: 16777216
send_buffer: 16777216
multicore: true
compress_min_size: 512
id:
snowflake_worker_id: 1
log:
level: info
encoding: json
console: true
file:
path: "logs/spark-server.log"
max_size_mb: 100
max_backups: 7
max_age_days: 7
compress: true
mysql:
dsn: "spark:@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local"
slow_threshold: 200ms
log_level: ""
max_open_conns: 64
max_idle_conns: 16
conn_max_lifetime: 30m
conn_max_idle_time: 10m
redis:
addr: "redis:6379"
username: ""
password: ""
db: 0
deliver_queue_key: "deliver:queue"
deliver_processing_key: "deliver:processing"
deliver_done_prefix: "deliver:done:"
jwt:
secret: "dev-only-change-me"
issuer: "spark-im"
ttl: 168h
refresh_ttl: 720h
denylist_prefix: "denylist:"
refresh_prefix: "refresh:"
taskpool:
size: 256
queue: 1024
deliver:
workers: 8
take_timeout: 5s
done_ttl: 10m
session:
outbound_queue_size: 1024
rate_limit:
message_per_second: 20
auth_per_second: 5
sync_inbox_per_minute: 60
sync_messages_per_minute: 120
history_per_minute: 200
upload_per_second: 10
message:
max_content_size: 65536
sync_limit: 200
msgid_expire_ms: 300000
object_storage:
endpoint: "rustfs:9000"
access_key: ""
secret_key: ""
bucket: "spark"
region: "us-east-1"
use_ssl: false
presign_ttl: 15m
chat_expire_days: 365

28
deploy/docker/deploy.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}"
REMOTE="${SPARK_REMOTE_DIR:-/opt/spark-server}"
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
rsync -az --delete \
--exclude '.git/' \
--exclude 'bin/' \
--exclude 'logs/' \
--exclude 'vendor/' \
--exclude 'examples/' \
--exclude '.cursor/' \
--exclude '.idea/' \
--exclude '.vscode/' \
-e "ssh -i $KEY -o BatchMode=yes" \
"$ROOT/" "$HOST:$REMOTE/"
"${SSH[@]}" "mkdir -p /data/spark-server/logs && chown -R 10001:10001 /data/spark-server/logs"
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml build"
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml up -d spark-server"
"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations"
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"

View File

@ -0,0 +1,24 @@
# Joins the existing mysql/redis/rustfs network (docker compose project "shell").
services:
spark-server:
build:
context: ../..
dockerfile: Dockerfile
image: spark-server:latest
container_name: spark-server
restart: unless-stopped
environment:
TZ: Asia/Shanghai
ports:
- "127.0.0.1:18080:8080"
- "127.0.0.1:19000:9000"
volumes:
- /data/spark-server/logs:/app/logs
- ./config.docker.yaml:/app/configs/config.yaml:ro
networks:
- shell
networks:
shell:
name: shell_default
external: true

30
deploy/nginx/README.md Normal file
View File

@ -0,0 +1,30 @@
# Nginx 443 SNI (C3 / C4)
gnet v2 has no native TLS. IM certificates live here, not in spark-server `tcp.tls`.
```text
Client --TLS--> :443 ssl_preread
├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
→ PROXY protocol → 127.0.0.1:19000 gnet
```
On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/etc/nginx/nginx.conf`. Drop-ins:
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443 and `$remote_addr` stays the client IP.
spark-server (Docker):
- HTTP `127.0.0.1:18080` → container `:8080`
- TCP `127.0.0.1:19000` with `proxy_protocol: true` → container `:9000`
- Public security group: 443 only
```bash
bash deploy/nginx/install-host.sh
```
`http.conf` / `stream.conf` / `nginx.conf` are a dedicated-box template. `/readyz` is loopback-only on 8443; local probes hit Gin on `127.0.0.1:18080`.

View File

@ -0,0 +1,15 @@
# Temporary HTTP-01 vhosts. Replaced by sparkapi.conf after certificates exist.
server {
listen 80;
listen [::]:80;
server_name sparkapi.wyrs365.com sparkim.wyrs365.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 404;
}
}

View File

@ -0,0 +1,29 @@
# Spark IM + shared HTTPS on :443 (ssl_preread).
# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :19000
# default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP)
map $ssl_preread_server_name $spark_sni_backend {
sparkim.wyrs365.com 127.0.0.1:19001;
default 127.0.0.1:8443;
}
server {
listen 443 reuseport;
listen [::]:443 reuseport;
ssl_preread on;
proxy_connect_timeout 5s;
proxy_timeout 3600s;
proxy_protocol on;
proxy_pass $spark_sni_backend;
}
server {
listen 127.0.0.1:19001 ssl proxy_protocol;
ssl_certificate /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sparkim.wyrs365.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
proxy_connect_timeout 5s;
proxy_timeout 3600s;
proxy_protocol on;
proxy_pass 127.0.0.1:19000;
}

View File

@ -0,0 +1,77 @@
# Spark HTTP — sparkapi.wyrs365.com
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
# This vhost terminates TLS on loopback :8443 (PROXY protocol from stream).
upstream spark_http {
server 127.0.0.1:18080;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name sparkapi.wyrs365.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name sparkim.wyrs365.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 404;
}
}
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
listen [::1]:8443 ssl proxy_protocol;
http2 on;
server_name sparkapi.wyrs365.com;
ssl_certificate /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sparkapi.wyrs365.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SparkSSL:10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
client_max_body_size 32m;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_http_version 1.1;
proxy_set_header Connection "";
location /readyz {
allow 127.0.0.1;
allow ::1;
deny all;
proxy_pass http://spark_http;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://spark_http;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}

View File

@ -2,6 +2,20 @@ http {
server {
listen 127.0.0.1:8443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/api.example.com.crt;
ssl_certificate_key /etc/nginx/certs/api.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
location /readyz {
allow 127.0.0.1;
allow ::1;
deny all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://127.0.0.1:8080;

76
deploy/nginx/install-host.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Install Spark SNI vhosts into the shared host nginx (do not replace nginx.conf).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HOST_DIR="$ROOT/host"
BACKUP="/root/nginx-backup-$(date +%Y%m%d%H%M%S)"
need_certs() {
[[ -f /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem ]] \
&& [[ -f /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem ]]
}
install_acme() {
mkdir -p /var/www/certbot
cp "$HOST_DIR/spark-acme.conf" /etc/nginx/conf.d/spark-acme.conf
nginx -t
systemctl reload nginx
}
issue_certs() {
certbot certonly --webroot -w /var/www/certbot \
--non-interactive --agree-tos --keep-until-expiring \
-d sparkapi.wyrs365.com
certbot certonly --webroot -w /var/www/certbot \
--non-interactive --agree-tos --keep-until-expiring \
-d sparkim.wyrs365.com
}
rewrite_http_443() {
local f
for f in /etc/nginx/conf.d/*.conf; do
case "$(basename "$f")" in
sparkapi.conf|spark-acme.conf) continue ;;
esac
if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then
continue
fi
if grep -qE 'listen[[:space:]]+443[[:space:]]+ssl' "$f"; then
sed -i \
-e 's/listen 443 ssl;/listen 127.0.0.1:8443 ssl proxy_protocol;/' \
-e 's/listen \[::\]:443 ssl;/listen [::1]:8443 ssl proxy_protocol;/' \
"$f"
fi
done
}
install_sni() {
rewrite_http_443
rm -f /etc/nginx/conf.d/spark-acme.conf
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then
echo "http still binds public 443" >&2
exit 1
fi
nginx -t
systemctl reload nginx
}
mkdir -p "$BACKUP"
cp -a /etc/nginx "$BACKUP/nginx"
echo "backup $BACKUP"
if ! need_certs; then
install_acme
issue_certs
fi
if ! need_certs; then
echo "certificates missing" >&2
exit 1
fi
install_sni
echo "nginx sni ready"

10
deploy/nginx/nginx.conf Normal file
View File

@ -0,0 +1,10 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
include /etc/nginx/stream.conf;
include /etc/nginx/http.conf;

View File

@ -1,3 +1,7 @@
# Public :443 is stream ssl_preread (SNI). Unknown SNI must not fall through to HTTP.
# IM TLS is terminated here (gnet v2 has no native TLS), then PROXY+plaintext to 127.0.0.1:9000.
# HTTP TLS is terminated on 127.0.0.1:8443 (see http.conf).
stream {
map $ssl_preread_server_name $backend {
api.example.com 127.0.0.1:8443;
@ -13,12 +17,14 @@ stream {
proxy_pass $backend;
}
# Add PROXY protocol only on the IM hop so Gin/HTTP is unaffected.
server {
listen 127.0.0.1:9001;
proxy_protocol on;
listen 127.0.0.1:9001 ssl;
ssl_certificate /etc/nginx/certs/im.example.com.crt;
ssl_certificate_key /etc/nginx/certs/im.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
proxy_connect_timeout 5s;
proxy_timeout 3600s;
proxy_protocol on;
proxy_pass 127.0.0.1:9000;
}
}

View File

@ -283,16 +283,16 @@ type TaskPool interface {
不学 BFF 握手加密、双版本头、把 Response 当 Request。
```text
TCP → PROXY → TLS 1.2+ → Frame → Protobuf
TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope
```
```text
Magic u16 | Version u8 | Command u16 | Flags u16
RequestID u64 | SequenceID u32 | BodyLength u32
Protobuf Body
JSON Body {"code","msg","data"}
```
`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配),对齐 BFF `CompressMinSize`。
Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。
`SequenceID` 只匹配请求/响应和 PUSH_ACK。
`MaxFrameSize = 64KB`。超大 `BodyLength` 断开。媒体不走 Frame。
@ -407,19 +407,21 @@ P2P 好友关系、未拉黑、对方未禁你
```sql
CREATE TABLE messages (
id BIGINT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
message_type SMALLINT NOT NULL,
content VARBINARY(65535) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标',
conversation_id BIGINT NOT NULL COMMENT '所属会话 id',
sender_id BIGINT NOT NULL COMMENT '发送者 user_id',
client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键',
message_type SMALLINT NOT NULL COMMENT '1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统',
content BLOB NOT NULL COMMENT '序列化 JSON 字节,禁止存原始 string;媒体只存对象存储 key',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏',
created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间',
UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id),
KEY idx_conversation_id (conversation_id, id)
);
) COMMENT='消息正文。PK=MsgId;content 为序列化 BLOB';
```
`message_type` 与 `content` 内 JSON 的 `t` 必须一致。正文用 `json.Marshal` 得到的 `[]byte` 写入 BLOB,不要把 Go `string` 当列类型。图片/语音/GIF/文件只存对象存储 key。完整 COMMENT 见 `migrations/0001_init.sql`。
```sql
SELECT * FROM messages
WHERE conversation_id = ? AND id > ?
@ -458,7 +460,7 @@ CREATE TABLE conversation_members (
```
`last_read_msg_id` 在 MySQL,作为已读真相(重启 Redis 可从这里重建未读)。
会话列表不扫这张表的热路径。
会话列表不扫这张表的热路径。列 COMMENT 见 `migrations/0001_init.sql`。
不做 `message_receipts`。
@ -488,7 +490,7 @@ CREATE TABLE device_sync_cursors (
);
```
**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。
**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。列 COMMENT 见 `migrations/0001_init.sql`。
---

5
examples/android/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
*.apk
.idea/

View File

@ -0,0 +1,32 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.spark.check"
compileSdk = 35
defaultConfig {
applicationId = "com.spark.check"
minSdk = 24
targetSdk = 35
versionCode = 2
versionName = "0.1.1"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Spark Check"
android:icon="@drawable/ic_launcher"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:allowBackup="false">
<activity
android:name=".CheckActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,88 @@
package com.spark.check
import android.app.Activity
import android.graphics.Typeface
import android.os.Bundle
import android.util.TypedValue
import android.view.ViewGroup.LayoutParams
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import kotlin.concurrent.thread
class CheckActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pad = dp(16)
val url = EditText(this).apply {
setText(SparkClient.DEFAULT_BASE)
hint = "http://10.0.2.2:8080"
setSingleLine()
setPadding(pad, pad, pad, pad)
}
val log = TextView(this).apply {
setPadding(pad, pad, pad, pad)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
typeface = Typeface.MONOSPACE
text = "电脑先 make start,再选地址后点「开始校验」。"
}
val run = Button(this).apply { text = "开始校验" }
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
addView(label("服务地址"))
addView(url)
addView(row(
preset("官方模拟器", SparkClient.DEFAULT_BASE, url),
preset("夜神", SparkClient.NOX_BASE, url),
))
addView(preset("真机 (adb reverse)", SparkClient.DEVICE_BASE, url))
addView(run)
addView(log)
}
setContentView(ScrollView(this).apply {
isFillViewport = true
addView(root)
})
run.setOnClickListener {
val base = SparkClient.normalizeBase(url.text.toString())
url.setText(base)
run.isEnabled = false
log.text = "请求 $base …"
thread {
val text = try {
SparkClient(base).check()
} catch (e: Exception) {
e.message ?: e.toString()
}
runOnUiThread {
log.text = text
run.isEnabled = true
}
}
}
}
private fun preset(title: String, value: String, url: EditText): Button =
Button(this).apply {
text = title
setOnClickListener { url.setText(value) }
}
private fun row(a: Button, b: Button): LinearLayout =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
addView(a, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(b, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
}
private fun label(text: String): TextView =
TextView(this).apply {
this.text = text
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
}

View File

@ -0,0 +1,174 @@
package com.spark.check
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.UUID
/**
* 对着本机 spark-server 校验登录 / 资料。
*
* 服务端 Gin 只绑 127.0.0.1:8080:
* - 模拟器:BASE = http://10.0.2.2:8080
* - 真机:先 `adb reverse tcp:8080 tcp:8080`,再 BASE = http://127.0.0.1:8080
*
* 先在仓库根目录 `make start`。测试号任意验证码即可。
*/
class SparkClient(
base: String = DEFAULT_BASE,
private val deviceId: Long = DEVICE_ID,
) {
private val base: String = normalizeBase(base)
data class Envelope(val code: String, val msg: String, val data: JSONObject)
data class Token(
val access: String,
val refresh: String,
val userId: Long,
val deviceId: Long,
val sparkId: String,
val expiresIn: Long,
)
fun healthz(): Envelope = get("/healthz")
fun login(email: String = TEST_EMAIL, verifyCode: String = "000000"): Token {
val body = JSONObject()
.put("email", email)
.put("verify_code", verifyCode)
.put("device_id", deviceId)
.put("device_type", "android")
.put("device_name", "SparkCheck")
val env = post("/v1/auth/login", body)
requireOk(env, "login")
val d = env.data
return Token(
access = d.getString("access_token"),
refresh = d.getString("refresh_token"),
userId = d.getLong("user_id"),
deviceId = d.getLong("device_id"),
sparkId = d.optString("spark_id"),
expiresIn = d.optLong("expires_in"),
)
}
fun me(access: String): Envelope = get("/v1/users/me", access)
fun refresh(refreshToken: String): Token {
val env = post("/v1/auth/token/refresh", JSONObject().put("refresh_token", refreshToken))
requireOk(env, "refresh")
val d = env.data
return Token(
access = d.getString("access_token"),
refresh = d.getString("refresh_token"),
userId = d.getLong("user_id"),
deviceId = d.getLong("device_id"),
sparkId = d.optString("spark_id"),
expiresIn = d.optLong("expires_in"),
)
}
fun logout(access: String): Envelope = post("/v1/auth/logout", JSONObject(), access)
fun check(): String {
val out = StringBuilder()
fun line(title: String, env: Envelope) {
out.append(title).append(" code=").append(env.code)
.append(" msg=").append(env.msg)
.append('\n').append(env.data.toString(2)).append("\n\n")
}
line("GET /healthz", healthz())
val tok = login()
out.append("login user_id=").append(tok.userId)
.append(" spark_id=").append(tok.sparkId)
.append(" expires_in=").append(tok.expiresIn).append("\n\n")
val profile = me(tok.access)
requireOk(profile, "me")
line("GET /v1/users/me", profile)
val next = refresh(tok.refresh)
out.append("refresh user_id=").append(next.userId).append("\n\n")
line("POST /v1/auth/logout", logout(next.access))
return out.toString()
}
private fun get(path: String, access: String? = null): Envelope =
call("GET", path, null, access)
private fun post(path: String, body: JSONObject, access: String? = null): Envelope =
call("POST", path, body, access)
private fun call(method: String, path: String, body: JSONObject?, access: String?): Envelope {
val conn = try {
(URL(base + path).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 8_000
readTimeout = 15_000
instanceFollowRedirects = true
setRequestProperty("Accept", "application/json")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("X-Language", "zh-CN")
setRequestProperty("X-App-Version", "0.1.1-check")
setRequestProperty("X-Request-ID", UUID.randomUUID().toString())
setRequestProperty("X-Device-ID", deviceId.toString())
if (!access.isNullOrEmpty()) {
setRequestProperty("Authorization", "Bearer $access")
}
if (body != null) {
doOutput = true
outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) }
}
}
} catch (e: Exception) {
throw IllegalStateException(connectHint(base, e), e)
}
val raw = try {
read(if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream)
} catch (e: Exception) {
throw IllegalStateException(connectHint(base, e), e)
} finally {
conn.disconnect()
}
val json = JSONObject(raw.ifBlank { "{}" })
val data = json.opt("data")
return Envelope(
code = json.optString("code", ""),
msg = json.optString("msg", ""),
data = if (data is JSONObject) data else JSONObject(),
)
}
companion object {
const val DEFAULT_BASE = "http://10.0.2.2:8080"
const val NOX_BASE = "http://10.0.3.2:8080"
const val DEVICE_BASE = "http://127.0.0.1:8080"
const val TEST_EMAIL = "test01@spark.com"
const val DEVICE_ID = 10001L
fun normalizeBase(raw: String): String {
var s = raw.trim()
if (s.isEmpty()) return DEFAULT_BASE
if (!s.contains("://")) s = "http://$s"
return s.trimEnd('/')
}
fun connectHint(base: String, e: Exception): String {
return "连不上 $base(${e.javaClass.simpleName}: ${e.message})\n\n" +
"1. 电脑仓库里先 make start\n" +
"2. 官方模拟器:http://10.0.2.2:8080\n" +
"3. 夜神模拟器:http://10.0.3.2:8080\n" +
"4. 真机:电脑执行 adb reverse tcp:8080 tcp:8080,地址填 http://127.0.0.1:8080\n" +
"(服务只监听电脑 127.0.0.1,填局域网 IP 也进不去)"
}
private fun requireOk(env: Envelope, what: String) {
check(env.code == "0") { "$what failed code=${env.code} msg=${env.msg}" }
}
private fun read(stream: InputStream?): String {
if (stream == null) return ""
return stream.bufferedReader(StandardCharsets.UTF_8).use(BufferedReader::readText)
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1565C0" />
<corners android:radius="16dp" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
examples/android/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
examples/android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SparkCheck"
include(":app")

43
go.mod
View File

@ -3,17 +3,35 @@ module spark-server
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/credentials v1.20.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1
github.com/aws/smithy-go v1.28.1
github.com/bwmarrin/snowflake v0.3.0
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-sql-driver/mysql v1.10.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/panjf2000/gnet/v2 v2.10.0
github.com/redis/go-redis/v9 v9.22.0
google.golang.org/protobuf v1.36.10
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569
go.uber.org/zap v1.28.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.30.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@ -23,31 +41,34 @@ require (
github.com/gin-contrib/sse v1.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.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.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/panjf2000/ants/v2 v2.12.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

76
go.sum
View File

@ -1,5 +1,29 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 h1:cFHmwLxZPvtoAlo79MboURL2+7b0TMHycnrcf5VZNXk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@ -33,19 +57,26 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -63,8 +94,8 @@ github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZn
github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY=
github.com/panjf2000/gnet/v2 v2.10.0 h1:rC4jNF+jtXj/FH+8JOIQ3XxjD+yBunYBLKg9TE3dc4g=
github.com/panjf2000/gnet/v2 v2.10.0/go.mod h1:f9wdbOFsdbZqlSvXctWbPRW5bB/W++q8Zqz+D7tQIVQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@ -73,8 +104,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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=
@ -86,6 +117,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI=
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI=
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@ -106,21 +139,23 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -131,3 +166,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

View File

@ -5,56 +5,161 @@ import (
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"go.uber.org/zap"
"spark-server/internal/infrastructure/id"
"spark-server/internal/infrastructure/logger"
"spark-server/internal/infrastructure/mysql"
"spark-server/internal/infrastructure/objectstore"
redisx "spark-server/internal/infrastructure/redis"
authlogic "spark-server/internal/module/auth/logic"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
deliverlogic "spark-server/internal/module/deliver/logic"
delivermodel "spark-server/internal/module/deliver/model"
favlogic "spark-server/internal/module/favorite/logic"
favmodel "spark-server/internal/module/favorite/model"
friendlogic "spark-server/internal/module/friend/logic"
friendmodel "spark-server/internal/module/friend/model"
grouplogic "spark-server/internal/module/group/logic"
groupmodel "spark-server/internal/module/group/model"
msgmodel "spark-server/internal/module/message/model"
uploadlogic "spark-server/internal/module/upload/logic"
userlogic "spark-server/internal/module/user/logic"
usermodel "spark-server/internal/module/user/model"
"spark-server/internal/pkg/ratelimit"
httpx "spark-server/internal/transport/http"
tcp "spark-server/internal/transport/tcp"
"spark-server/internal/transport/tcp/session"
)
type App struct {
cfg *Config
log *slog.Logger
http *httpx.Server
tcp *tcp.Server
pool tcp.TaskPool
mysql *mysql.DB
redis *redisx.Client
idgen *id.Generator
sessions session.Manager
cfg *Config
log *zap.Logger
logClose func() error
http *httpx.Server
tcp *tcp.Server
pool tcp.TaskPool
mysql *mysql.DB
redis *redisx.Client
idgen *id.Generator
sessions session.Manager
deliver *deliverlogic.Worker
accepting atomic.Bool
shutdownOnce sync.Once
}
func New(cfg *Config) (*App, error) {
log := logger.New()
log, logClose := logger.New(&logger.Options{
Level: cfg.Log.Level,
Encoding: cfg.Log.Encoding,
Console: cfg.Log.Console,
File: logger.FileOptions{
Path: cfg.Log.File.Path,
MaxSizeMB: cfg.Log.File.MaxSizeMB,
MaxBackups: cfg.Log.File.MaxBackups,
MaxAgeDays: cfg.Log.File.MaxAgeDays,
Compress: cfg.Log.File.Compress,
},
})
idgen, err := id.New(cfg.ID.SnowflakeWorkerID)
if err != nil {
_ = logClose()
return nil, err
}
db, err := mysql.Open(cfg.MySQL.DSN)
db, err := mysql.Open(log, &mysql.Options{
DSN: cfg.MySQL.DSN,
SlowThreshold: cfg.MySQL.SlowThreshold,
LogLevel: cfg.MySQL.LogLevel,
ZapLevel: cfg.Log.Level,
MaxOpenConns: cfg.MySQL.MaxOpenConns,
MaxIdleConns: cfg.MySQL.MaxIdleConns,
ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime,
ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime,
})
if err != nil {
_ = logClose()
return nil, err
}
rdb, err := redisx.Open(cfg.Redis.Addr)
rdb, err := redisx.Open(&redisx.Options{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
if err != nil {
_ = db.Close()
_ = logClose()
return nil, err
}
pool := tcp.NewTaskPool(cfg.TaskPool.Size, cfg.TaskPool.Queue)
return finishApp(cfg, log, logClose, idgen, db, rdb)
}
func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (*App, error) {
jwtSvc, err := authlogic.NewJWT(&authlogic.Options{
Secret: cfg.JWT.Secret,
Issuer: cfg.JWT.Issuer,
TTL: cfg.JWT.TTL,
RefreshTTL: cfg.JWT.RefreshTTL,
Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix),
Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix),
})
if err != nil {
_ = rdb.Close()
_ = db.Close()
_ = logClose()
return nil, err
}
pool := tcp.NewTaskPoolRecover(cfg.TaskPool.Size, cfg.TaskPool.Queue, func(rec any) {
log.Error("taskpool panic", zap.Any("panic", rec))
})
sessions := session.NewManager()
tlsConf, err := loadTLS(cfg.Server.TCP.TLS)
if err != nil {
pool.Close()
_ = rdb.Close()
_ = db.Close()
_ = logClose()
return nil, err
}
tcpSrv := tcp.NewServer(log, pool, sessions, &tcp.Options{
authLimit := ratelimit.New(cfg.RateLimit.AuthPerSecond)
objects, err := objectstore.Open(&objectstore.Options{
Endpoint: cfg.ObjectStorage.Endpoint,
AccessKey: cfg.ObjectStorage.AccessKey,
SecretKey: cfg.ObjectStorage.SecretKey,
Bucket: cfg.ObjectStorage.Bucket,
Region: cfg.ObjectStorage.Region,
UseSSL: cfg.ObjectStorage.UseSSL,
ChatExpireDays: cfg.ObjectStorage.ChatExpireDays,
Log: log,
})
if err != nil {
pool.Close()
_ = rdb.Close()
_ = db.Close()
_ = logClose()
return nil, err
}
app := &App{cfg: cfg, log: log, logClose: logClose, pool: pool, mysql: db, redis: rdb, idgen: idgen, sessions: sessions}
probe := &Probe{mysql: db, redis: rdb, objects: objects, pool: pool, accepting: &app.accepting, requireDeps: cfg.Production()}
users := wireUsers(idgen, db, rdb, objects)
login := authlogic.NewLoginService(jwtSvc, users)
login.Sessions = sessions
var deliverQueue deliverlogic.Queue
app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions)
friends := wireFriends(idgen, db, users, deliverQueue)
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects)
files := wireFiles(cfg, objects, idgen, groups)
favorites := wireFavorites(idgen, db, objects)
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
Addr: cfg.Server.TCP.Addr,
Multicore: cfg.Server.TCP.Multicore,
KeepAlive: cfg.Server.TCP.KeepAlive,
@ -68,18 +173,21 @@ func New(cfg *Config) (*App, error) {
RatePerSec: cfg.RateLimit.MessagePerSecond,
CompressMin: cfg.Server.TCP.CompressMinSize,
IDs: idgen,
Auth: jwtSvc,
AuthLimiter: authLimit,
})
return &App{
cfg: cfg,
log: log,
http: httpx.New(log, cfg.Server.HTTP.Addr),
tcp: tcpSrv,
pool: pool,
mysql: db,
redis: rdb,
idgen: idgen,
sessions: sessions,
}, nil
app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{
Auth: jwtSvc,
Login: login,
Users: users,
Friends: friends,
Groups: groups,
Files: files,
Favorites: favorites,
Health: probe,
AuthLimiter: authLimit,
})
return app, nil
}
func loadTLS(t TLSConfig) (*tls.Config, error) {
@ -93,23 +201,179 @@ func loadTLS(t TLSConfig) (*tls.Config, error) {
return &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}, nil
}
func wireUsers(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client, store *objectstore.Store) *userlogic.Service {
var repo userlogic.Repository
if db != nil && db.Ready() {
repo = usermodel.NewRepo(db.Gorm())
} else {
repo = usermodel.NewMemoryRepo()
}
var codes userlogic.CodeStore
if rdb != nil && rdb.Ready() {
codes = usermodel.NewRedisCodes(rdb.RDB())
} else {
codes = usermodel.NewMemoryCodes()
}
var objects userlogic.Objects
if store != nil && store.Ready() {
objects = store
}
return userlogic.NewService(repo, codes, idgen, objects)
}
func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue) *friendlogic.Service {
var repo friendlogic.Repository
var chats friendlogic.P2PChats
var msgs friendlogic.Messages
if db != nil && db.Ready() {
repo = friendmodel.NewRepo(db.Gorm())
chats = convlogic.NewP2PService(convmodel.NewRepo(db.Gorm()), idgen)
msgs = msgmodel.NewRepo(db.Gorm())
} else {
repo = friendmodel.NewMemoryRepo()
chats = convlogic.NewP2PService(convmodel.NewMemoryP2P(), idgen)
msgs = msgmodel.NewMemoryRepo()
}
return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chats, msgs, idgen, q))
}
func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store) *grouplogic.Service {
var repo grouplogic.Repository
var msgs grouplogic.Messages
if db != nil && db.Ready() {
repo = groupmodel.NewRepo(db.Gorm())
msgs = msgmodel.NewRepo(db.Gorm())
} else {
repo = groupmodel.NewMemoryRepo()
msgs = msgmodel.NewMemoryRepo()
}
var objects grouplogic.Objects
if store != nil && store.Ready() {
objects = store
}
return grouplogic.NewService(repo, users, friends, idgen, msgs, q, objects)
}
func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, groups *grouplogic.Service) *uploadlogic.Service {
if store == nil || !store.Ready() || idgen == nil {
return nil
}
return uploadlogic.NewService(store, idgen, uploadlogic.Options{
TTL: cfg.ObjectStorage.PresignTTL,
Limit: ratelimit.New(cfg.RateLimit.UploadPerSecond),
Groups: groups,
})
}
func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store) *favlogic.Service {
if idgen == nil {
return nil
}
var repo favlogic.Repository
var msgs favlogic.Messages
var members favlogic.Members
if db != nil && db.Ready() {
repo = favmodel.NewRepo(db.Gorm())
msgs = msgmodel.NewRepo(db.Gorm())
members = convmodel.NewRepo(db.Gorm())
} else {
repo = favmodel.NewMemoryRepo()
msgs = msgmodel.NewMemoryRepo()
members = convmodel.NewMemoryP2P()
}
var objects favlogic.Objects
if store != nil && store.Ready() {
objects = store
}
return favlogic.NewService(repo, msgs, members, objects, idgen)
}
func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager) (*deliverlogic.Worker, deliverlogic.Queue) {
var q deliverlogic.Queue
var done deliverlogic.Deduper
if rdb != nil && rdb.Ready() {
q = delivermodel.NewRedisQueue(rdb.RDB(), cfg.Redis.DeliverQueueKey, cfg.Redis.DeliverProcessingKey)
done = delivermodel.NewRedisDone(rdb.RDB(), cfg.Redis.DeliverDonePrefix, cfg.Deliver.DoneTTL)
} else {
q = delivermodel.NewMemoryQueue()
done = delivermodel.NewMemoryDone()
}
return deliverlogic.NewWorker(log, q, done, deliverlogic.WorkerOptions{
Workers: cfg.Deliver.Workers,
TakeTimeout: cfg.Deliver.TakeTimeout,
Handler: deliverlogic.SessionFanout(sessions),
}), q
}
func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
if rdb != nil && rdb.Ready() {
return authlogic.NewRedisDenylist(rdb.RDB(), prefix)
}
return authlogic.NewMemoryDenylist()
}
func jwtPairs(rdb *redisx.Client, prefix string) authlogic.PairStore {
if rdb != nil && rdb.Ready() {
return authlogic.NewRedisPairs(rdb.RDB(), prefix)
}
return authlogic.NewMemoryPairs()
}
func (a *App) Run() error {
if a.deliver != nil {
a.deliver.Start(context.Background())
}
errCh := make(chan error, 2)
go func() { errCh <- a.http.Start() }()
go func() { errCh <- a.tcp.Start() }()
if err := a.waitListening(errCh); err != nil {
return errors.Join(err, a.Shutdown())
}
a.accepting.Store(true)
a.log.Info("ready")
return a.serve(errCh)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
func (a *App) waitListening(errCh <-chan error) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- errors.Join(a.http.WaitReady(ctx), a.tcp.WaitReady(ctx))
}()
select {
case err := <-errCh:
if err == nil {
return ctx.Err()
}
return err
case err := <-done:
return err
}
}
func (a *App) serve(errCh <-chan error) error {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
select {
case err := <-errCh:
a.log.Error("server exit", zap.Error(err))
return errors.Join(err, a.Shutdown())
case sig := <-sigCh:
a.log.Info("shutdown", "signal", sig.String())
a.log.Info("shutdown", zap.String("signal", sig.String()))
return a.Shutdown()
}
}
func (a *App) Shutdown() error {
var err error
a.shutdownOnce.Do(func() { err = a.doShutdown() })
return err
}
func (a *App) doShutdown() error {
a.accepting.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var err error
@ -119,7 +383,15 @@ func (a *App) Shutdown() error {
if e := a.tcp.Stop(ctx); e != nil {
err = errors.Join(err, e)
}
a.pool.Close()
if a.pool != nil {
a.pool.Close()
}
if a.deliver != nil {
a.deliver.Stop()
}
err = errors.Join(err, a.redis.Close(), a.mysql.Close())
if a.logClose != nil {
err = errors.Join(err, a.logClose())
}
return err
}

View File

@ -4,21 +4,41 @@ package bootstrap
import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
ID IDConfig `yaml:"id"`
MySQL MySQLConfig `yaml:"mysql"`
Redis RedisConfig `yaml:"redis"`
TaskPool TaskPoolConfig `yaml:"taskpool"`
Deliver DeliverConfig `yaml:"deliver"`
Session SessionConfig `yaml:"session"`
RateLimit RateLimitConfig `yaml:"rate_limit"`
Message MessageConfig `yaml:"message"`
Env string `yaml:"env"`
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
ID IDConfig `yaml:"id"`
MySQL MySQLConfig `yaml:"mysql"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
TaskPool TaskPoolConfig `yaml:"taskpool"`
Deliver DeliverConfig `yaml:"deliver"`
Session SessionConfig `yaml:"session"`
RateLimit RateLimitConfig `yaml:"rate_limit"`
Message MessageConfig `yaml:"message"`
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
}
type LogConfig struct {
Level string `yaml:"level"`
Encoding string `yaml:"encoding"`
Console bool `yaml:"console"`
File LogFileConfig `yaml:"file"`
}
type LogFileConfig struct {
Path string `yaml:"path"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
MaxAgeDays int `yaml:"max_age_days"`
Compress bool `yaml:"compress"`
}
type ServerConfig struct {
@ -53,13 +73,23 @@ type IDConfig struct {
}
type MySQLConfig struct {
DSN string `yaml:"dsn"`
DSN string `yaml:"dsn"`
SlowThreshold time.Duration `yaml:"slow_threshold"`
LogLevel string `yaml:"log_level"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time"`
}
type RedisConfig struct {
Addr string `yaml:"addr"`
Username string `yaml:"username"`
Password string `yaml:"password"`
DB int `yaml:"db"`
DeliverQueueKey string `yaml:"deliver_queue_key"`
DeliverProcessingKey string `yaml:"deliver_processing_key"`
DeliverDonePrefix string `yaml:"deliver_done_prefix"`
}
type TaskPoolConfig struct {
@ -83,6 +113,18 @@ type RateLimitConfig struct {
SyncInboxPerMinute int `yaml:"sync_inbox_per_minute"`
SyncMessagesPerMinute int `yaml:"sync_messages_per_minute"`
HistoryPerMinute int `yaml:"history_per_minute"`
UploadPerSecond int `yaml:"upload_per_second"`
}
type ObjectStorageConfig struct {
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
Region string `yaml:"region"`
UseSSL bool `yaml:"use_ssl"`
PresignTTL time.Duration `yaml:"presign_ttl"`
ChatExpireDays int `yaml:"chat_expire_days"`
}
type MessageConfig struct {
@ -91,6 +133,15 @@ type MessageConfig struct {
MsgIDExpireMS int64 `yaml:"msgid_expire_ms"`
}
type JWTConfig struct {
Secret string `yaml:"secret"`
Issuer string `yaml:"issuer"`
TTL time.Duration `yaml:"ttl"`
RefreshTTL time.Duration `yaml:"refresh_ttl"`
DenylistPrefix string `yaml:"denylist_prefix"`
RefreshPrefix string `yaml:"refresh_prefix"`
}
func Load(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
@ -100,5 +151,136 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
const (
EnvDev = "dev"
EnvProd = "prod"
// DefaultJWTSecret is rejected when env is prod.
DefaultJWTSecret = "dev-only-change-me"
)
func (cfg *Config) Production() bool {
return cfg != nil && cfg.Env == EnvProd
}
func (cfg *Config) normalize() error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
applyDefaults(cfg)
return cfg.validate()
}
func applyDefaults(cfg *Config) {
if v := os.Getenv("SPARK_ENV"); v != "" {
cfg.Env = v
}
cfg.Env = parseEnv(cfg.Env)
if v := os.Getenv("SPARK_JWT_SECRET"); v != "" {
cfg.JWT.Secret = v
}
if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" {
cfg.Redis.Password = v
}
if cfg.JWT.Issuer == "" {
cfg.JWT.Issuer = "spark-im"
}
if cfg.JWT.TTL <= 0 {
cfg.JWT.TTL = 7 * 24 * time.Hour
}
if cfg.JWT.RefreshTTL <= 0 {
cfg.JWT.RefreshTTL = 30 * 24 * time.Hour
}
if cfg.JWT.DenylistPrefix == "" {
cfg.JWT.DenylistPrefix = "denylist:"
}
if cfg.JWT.RefreshPrefix == "" {
cfg.JWT.RefreshPrefix = "refresh:"
}
if cfg.Redis.DeliverQueueKey == "" {
cfg.Redis.DeliverQueueKey = "deliver:queue"
}
if cfg.Redis.DeliverProcessingKey == "" {
cfg.Redis.DeliverProcessingKey = "deliver:processing"
}
if cfg.Redis.DeliverDonePrefix == "" {
cfg.Redis.DeliverDonePrefix = "deliver:done:"
}
if cfg.Deliver.Workers < 1 {
cfg.Deliver.Workers = 8
}
if cfg.Deliver.TakeTimeout <= 0 {
cfg.Deliver.TakeTimeout = 5 * time.Second
}
if cfg.Deliver.DoneTTL <= 0 {
cfg.Deliver.DoneTTL = 10 * time.Minute
}
if cfg.RateLimit.AuthPerSecond < 1 {
cfg.RateLimit.AuthPerSecond = 5
}
if cfg.RateLimit.MessagePerSecond < 1 {
cfg.RateLimit.MessagePerSecond = 20
}
if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" {
cfg.ObjectStorage.Endpoint = v
}
if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" {
cfg.ObjectStorage.AccessKey = v
}
if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" {
cfg.ObjectStorage.SecretKey = v
}
if v := os.Getenv("SPARK_S3_BUCKET"); v != "" {
cfg.ObjectStorage.Bucket = v
}
if cfg.ObjectStorage.Bucket == "" {
cfg.ObjectStorage.Bucket = "spark"
}
if cfg.ObjectStorage.Region == "" {
cfg.ObjectStorage.Region = "us-east-1"
}
if cfg.ObjectStorage.PresignTTL <= 0 {
cfg.ObjectStorage.PresignTTL = 15 * time.Minute
}
if cfg.ObjectStorage.ChatExpireDays < 1 {
cfg.ObjectStorage.ChatExpireDays = 365
}
if cfg.RateLimit.UploadPerSecond < 1 {
cfg.RateLimit.UploadPerSecond = 10
}
}
func parseEnv(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "prod", "production":
return EnvProd
default:
return EnvDev
}
}
func (cfg *Config) validate() error {
if !cfg.Production() {
return nil
}
if strings.TrimSpace(cfg.MySQL.DSN) == "" {
return fmt.Errorf("prod: mysql.dsn is required")
}
if strings.TrimSpace(cfg.Redis.Addr) == "" {
return fmt.Errorf("prod: redis.addr is required")
}
if strings.TrimSpace(cfg.JWT.Secret) == "" || strings.TrimSpace(cfg.JWT.Secret) == DefaultJWTSecret {
return fmt.Errorf("prod: jwt.secret must be set via config or SPARK_JWT_SECRET")
}
if ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" {
if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" {
return fmt.Errorf("prod: object_storage access_key and secret_key are required")
}
}
return nil
}

View File

@ -0,0 +1,95 @@
package bootstrap
import (
"context"
"sync/atomic"
"time"
"spark-server/internal/infrastructure/mysql"
redisx "spark-server/internal/infrastructure/redis"
tcp "spark-server/internal/transport/tcp"
)
const (
depOK = "ok"
depSkipped = "skipped"
depDown = "down"
depClosed = "closed"
)
// ProbeReport is /readyz data. LB should use HTTP 503 when Ready is false.
type ProbeReport struct {
Status string `json:"status"`
Accepting bool `json:"accepting"`
MySQL string `json:"mysql"`
Redis string `json:"redis"`
ObjectStorage string `json:"object_storage"`
TaskPool string `json:"taskpool"`
}
// Probe implements httpx.Health for liveness/readiness.
type Probe struct {
mysql *mysql.DB
redis *redisx.Client
objects pinger
pool tcp.TaskPool
accepting *atomic.Bool
requireDeps bool
}
func (p *Probe) Ready(ctx context.Context) (bool, any) {
if p == nil {
return true, ProbeReport{Status: depOK, Accepting: true, MySQL: depSkipped, Redis: depSkipped, ObjectStorage: depSkipped, TaskPool: depOK}
}
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
rep := ProbeReport{
Accepting: p.accepting != nil && p.accepting.Load(),
MySQL: p.checkStore(ctx, p.mysql),
Redis: p.checkStore(ctx, p.redis),
ObjectStorage: p.checkOptional(ctx, p.objects),
TaskPool: depOK,
}
ready := rep.Accepting
if p.pool != nil && p.pool.Closed() {
rep.TaskPool = depClosed
ready = false
}
if rep.MySQL == depDown || rep.Redis == depDown || rep.ObjectStorage == depDown {
ready = false
}
if ready {
rep.Status = depOK
} else {
rep.Status = "not_ready"
}
return ready, rep
}
type pinger interface {
Ready() bool
Ping(ctx context.Context) error
}
func (p *Probe) checkStore(ctx context.Context, s pinger) string {
if s == nil || !s.Ready() {
if p.requireDeps {
return depDown
}
return depSkipped
}
if err := s.Ping(ctx); err != nil {
return depDown
}
return depOK
}
func (p *Probe) checkOptional(ctx context.Context, s pinger) string {
if s == nil || !s.Ready() {
return depSkipped
}
if err := s.Ping(ctx); err != nil {
return depDown
}
return depOK
}

View File

@ -0,0 +1,85 @@
package bootstrap
import (
"sync/atomic"
"testing"
)
func TestParseEnvAndProductionValidate(t *testing.T) {
t.Setenv("SPARK_ENV", "")
t.Setenv("SPARK_JWT_SECRET", "")
if parseEnv("") != EnvDev || parseEnv("production") != EnvProd || parseEnv("PROD") != EnvProd {
t.Fatal("parseEnv")
}
dev := &Config{Env: "dev", JWT: JWTConfig{Secret: DefaultJWTSecret}}
if err := dev.normalize(); err != nil {
t.Fatal(err)
}
if dev.Production() {
t.Fatal("dev")
}
prod := &Config{Env: "prod"}
if err := prod.normalize(); err == nil {
t.Fatal("prod missing stores")
}
prod = &Config{
Env: "prod",
MySQL: MySQLConfig{DSN: "user:pass@tcp(127.0.0.1:3306)/im"},
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
JWT: JWTConfig{Secret: DefaultJWTSecret},
}
if err := prod.normalize(); err == nil {
t.Fatal("prod default jwt")
}
prod.JWT.Secret = "long-enough-production-secret"
if err := prod.normalize(); err != nil {
t.Fatal(err)
}
if !prod.Production() {
t.Fatal("prod flag")
}
}
func TestRedisPasswordFromEnv(t *testing.T) {
t.Setenv("SPARK_REDIS_PASSWORD", "from-env")
cfg := &Config{Redis: RedisConfig{Addr: "127.0.0.1:6379", Password: "from-yaml"}}
if err := cfg.normalize(); err != nil {
t.Fatal(err)
}
if cfg.Redis.Password != "from-env" {
t.Fatalf("password %q", cfg.Redis.Password)
}
}
func TestProbeReadyDevSkipsStores(t *testing.T) {
p := &Probe{requireDeps: false}
ok, data := p.Ready(t.Context())
if ok {
t.Fatal("not accepting")
}
rep := data.(ProbeReport)
if rep.MySQL != depSkipped || rep.Redis != depSkipped || rep.Status != "not_ready" {
t.Fatalf("%+v", rep)
}
var accepting atomic.Bool
accepting.Store(true)
p.accepting = &accepting
ok, data = p.Ready(t.Context())
if !ok {
t.Fatalf("%+v", data)
}
}
func TestProbeReadyProdRequiresStores(t *testing.T) {
var accepting atomic.Bool
accepting.Store(true)
p := &Probe{requireDeps: true, accepting: &accepting}
ok, data := p.Ready(t.Context())
if ok {
t.Fatal("want down")
}
rep := data.(ProbeReport)
if rep.MySQL != depDown || rep.Redis != depDown {
t.Fatalf("%+v", rep)
}
}

View File

@ -27,6 +27,11 @@ func New(workerID int64) (*Generator, error) {
}
func (g *Generator) NextMsgID() (int64, error) {
return g.NextID()
}
// NextID returns a time-ordered Snowflake ID (user id, conversation id, msg id, mailbox event id).
func (g *Generator) NextID() (int64, error) {
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UnixMilli()

View File

@ -0,0 +1,20 @@
package logger
import (
"regexp"
)
const MaxBody = 4096
var secretJSON = regexp.MustCompile(`(?i)("(token|access_token|refresh_token|authorization|password|verify_code|upload_url|download_url|secret_key|access_key)"\s*:\s*")[^"]*`)
func ClipBody(b []byte) string {
if len(b) == 0 {
return ""
}
s := secretJSON.ReplaceAllString(string(b), `${1}***`)
if len(s) > MaxBody {
return s[:MaxBody] + "...<truncated>"
}
return s
}

View File

@ -1,10 +1,67 @@
package logger
import (
"log/slog"
"os"
"context"
"go.uber.org/zap"
"spark-server/internal/pkg/reqctx"
)
func New() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
type ctxKey struct{}
func Nop() *zap.Logger { return zap.NewNop() }
func With(ctx context.Context, log *zap.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, log)
}
func From(ctx context.Context) *zap.Logger {
if ctx == nil {
return zap.NewNop()
}
if log, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && log != nil {
return log
}
return zap.NewNop()
}
// Attach stores a logger on ctx with trace_id / request_id from RequestContext.
func Attach(ctx context.Context, base *zap.Logger) context.Context {
if base == nil {
base = zap.NewNop()
}
return With(ctx, WithReq(base, reqctx.From(ctx)))
}
func WithReq(log *zap.Logger, rc *reqctx.RequestContext) *zap.Logger {
if log == nil {
log = zap.NewNop()
}
if rc == nil {
return log
}
fields := make([]zap.Field, 0, 8)
if rc.TraceID != "" {
fields = append(fields, zap.String("trace_id", rc.TraceID))
}
if rc.RequestID != "" {
fields = append(fields, zap.String("request_id", rc.RequestID))
}
if rc.ConnectionID != "" {
fields = append(fields, zap.String("connection_id", rc.ConnectionID))
}
if rc.RealIP != "" {
fields = append(fields, zap.String("real_ip", rc.RealIP))
}
if rc.Language != "" {
fields = append(fields, zap.String("lang", rc.Language))
}
if rc.UserID != 0 {
fields = append(fields, zap.Int64("user_id", rc.UserID))
}
if len(fields) == 0 {
return log
}
return log.With(fields...)
}

View File

@ -0,0 +1,43 @@
package logger
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap/zapcore"
)
func TestNewWritesRotatingFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "app.log")
log, closeFn := New(&Options{
Level: "info",
Console: false,
File: FileOptions{Path: path, MaxSizeMB: 1, MaxBackups: 1, MaxAgeDays: 1},
})
log.Info("hello_file")
if err := closeFn(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "hello_file") {
t.Fatalf("file content %s", raw)
}
}
func TestParseLevel(t *testing.T) {
if parseLevel("debug") != zapcore.DebugLevel || parseLevel("DEBUG") != zapcore.DebugLevel {
t.Fatal("debug")
}
if parseLevel("error") != zapcore.ErrorLevel {
t.Fatal("error")
}
if parseLevel("info") != zapcore.InfoLevel {
t.Fatal("info")
}
}

View File

@ -0,0 +1,137 @@
package logger
import (
"os"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
// Options control zap sinks. File uses lumberjack rotation; Console is for local debug.
type Options struct {
Level string
Encoding string // json | console (stdout only; files are always JSON)
Console bool
File FileOptions
}
// FileOptions is lumberjack rotation for a JSON log file.
type FileOptions struct {
Path string
MaxSizeMB int
MaxBackups int
MaxAgeDays int
Compress bool
}
type closer struct {
log *zap.Logger
lj *lumberjack.Logger
}
func (c *closer) Close() error {
if c == nil {
return nil
}
if c.log != nil {
_ = c.log.Sync()
}
if c.lj != nil {
return c.lj.Close()
}
return nil
}
// New builds a zap logger. Close the returned func on shutdown to flush and close the file sink.
func New(o *Options) (*zap.Logger, func() error) {
if o == nil {
o = &Options{}
}
applyDefaults(o)
level := parseLevel(o.Level)
var lj *lumberjack.Logger
cores := make([]zapcore.Core, 0, 2)
if o.Console {
cores = append(cores, zapcore.NewCore(stdoutEncoder(o.Encoding), zapcore.AddSync(os.Stdout), level))
}
if o.File.Path != "" {
lj = newLumberjack(o.File)
cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(lj), level))
}
if len(cores) == 0 {
cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(os.Stdout), level))
}
log := zap.New(zapcore.NewTee(cores...), zap.AddCaller())
c := &closer{log: log, lj: lj}
return log, c.Close
}
func applyDefaults(o *Options) {
if o.Level == "" {
o.Level = "info"
}
if o.Encoding == "" {
if strings.EqualFold(o.Level, "debug") {
o.Encoding = "console"
} else {
o.Encoding = "json"
}
}
if !o.Console && o.File.Path == "" {
o.Console = true
}
if o.File.Path != "" {
if o.File.MaxSizeMB <= 0 {
o.File.MaxSizeMB = 100
}
if o.File.MaxBackups <= 0 {
o.File.MaxBackups = 7
}
if o.File.MaxAgeDays <= 0 {
o.File.MaxAgeDays = 7
}
}
}
func parseLevel(s string) zapcore.Level {
switch strings.ToLower(s) {
case "debug":
return zapcore.DebugLevel
case "warn", "warning":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
func jsonEncoder() zapcore.Encoder {
cfg := zap.NewProductionEncoderConfig()
cfg.TimeKey = "ts"
cfg.EncodeTime = zapcore.ISO8601TimeEncoder
cfg.MessageKey = "msg"
return zapcore.NewJSONEncoder(cfg)
}
func stdoutEncoder(encoding string) zapcore.Encoder {
if strings.EqualFold(encoding, "json") {
return jsonEncoder()
}
cfg := zap.NewDevelopmentEncoderConfig()
cfg.EncodeTime = zapcore.ISO8601TimeEncoder
return zapcore.NewConsoleEncoder(cfg)
}
func newLumberjack(f FileOptions) *lumberjack.Logger {
return &lumberjack.Logger{
Filename: f.Path,
MaxSize: f.MaxSizeMB,
MaxBackups: f.MaxBackups,
MaxAge: f.MaxAgeDays,
Compress: f.Compress,
LocalTime: true,
}
}

View File

@ -0,0 +1,102 @@
package mysql
import (
"context"
"errors"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"spark-server/internal/infrastructure/logger"
"spark-server/internal/pkg/reqctx"
)
type gormZap struct {
base *zap.Logger
level gormlogger.LogLevel
slow time.Duration
}
func newGormLogger(base *zap.Logger, slow time.Duration, level gormlogger.LogLevel) *gormZap {
if base == nil {
base = zap.NewNop()
}
if slow <= 0 {
slow = 200 * time.Millisecond
}
if level == 0 {
level = gormlogger.Warn
}
return &gormZap{base: base.Named("gorm"), level: level, slow: slow}
}
func (l *gormZap) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
cp := *l
cp.level = level
return &cp
}
func (l *gormZap) Info(ctx context.Context, msg string, data ...interface{}) {
if l.level < gormlogger.Info {
return
}
l.zap(ctx).Sugar().Infof(msg, data...)
}
func (l *gormZap) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.level < gormlogger.Warn {
return
}
l.zap(ctx).Sugar().Warnf(msg, data...)
}
func (l *gormZap) Error(ctx context.Context, msg string, data ...interface{}) {
if l.level < gormlogger.Error {
return
}
l.zap(ctx).Sugar().Errorf(msg, data...)
}
func (l *gormZap) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
log := l.zap(ctx)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error:
log.Error("sql", zap.Error(err), zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
case elapsed >= l.slow && l.level >= gormlogger.Warn:
log.Warn("sql_slow", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
case l.level >= gormlogger.Info:
log.Info("sql", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
}
}
func (l *gormZap) zap(ctx context.Context) *zap.Logger {
if reqctx.From(ctx) != nil {
return logger.From(ctx).Named("gorm")
}
return l.base
}
func parseGormLevel(s, zapLevel string) gormlogger.LogLevel {
switch s {
case "silent":
return gormlogger.Silent
case "error":
return gormlogger.Error
case "warn", "warning":
return gormlogger.Warn
case "info":
return gormlogger.Info
default:
if zapLevel == "debug" {
return gormlogger.Info
}
return gormlogger.Warn
}
}

View File

@ -0,0 +1,60 @@
package mysql
import (
"context"
"errors"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func TestGormLoggerTrace(t *testing.T) {
core, logs := observer.New(zapcore.DebugLevel)
z := zap.New(core)
gl := newGormLogger(z, time.Millisecond, gormlogger.Info)
gl.Trace(context.Background(), time.Now().Add(-10*time.Millisecond), func() (string, int64) {
return "SELECT 1", 1
}, nil)
if logs.FilterMessage("sql_slow").Len() != 1 {
t.Fatalf("want sql_slow, got %+v", logs.All())
}
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
return "SELECT 2", 1
}, nil)
if logs.FilterMessage("sql").Len() < 1 {
t.Fatalf("want sql, got %+v", logs.All())
}
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
return "SELECT 3", 0
}, errors.New("boom"))
if logs.FilterMessage("sql").FilterLevelExact(zapcore.ErrorLevel).Len() < 1 {
t.Fatalf("want sql error, got %+v", logs.All())
}
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
return "SELECT 4", 0
}, gorm.ErrRecordNotFound)
}
func TestParseGormLevel(t *testing.T) {
if parseGormLevel("", "debug") != gormlogger.Info {
t.Fatal("debug follows zap")
}
if parseGormLevel("", "info") != gormlogger.Warn {
t.Fatal("info default warn")
}
if parseGormLevel("silent", "debug") != gormlogger.Silent {
t.Fatal("silent")
}
}
func TestOpenEmptyDSN(t *testing.T) {
db, err := Open(zap.NewNop(), &Options{})
if err != nil || db.Ready() {
t.Fatalf("empty dsn %v ready=%v", err, db.Ready())
}
}

View File

@ -1,45 +1,128 @@
// Package mysql opens GORM and writes SQL logs through zap.
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
// registers the MySQL driver with database/sql
_ "github.com/go-sql-driver/mysql"
"go.uber.org/zap"
mysqldriver "gorm.io/driver/mysql"
"gorm.io/gorm"
)
// DB is a GORM handle. Connection pool is the standard library pool behind GORM v2.
type DB struct {
sql *sql.DB
gdb *gorm.DB
}
func Open(dsn string) (*DB, error) {
if dsn == "" {
// Options configure Open, including the underlying sql.DB pool.
type Options struct {
DSN string
SlowThreshold time.Duration
LogLevel string
ZapLevel string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
const (
defaultMaxOpen = 64
defaultMaxIdle = 16
defaultConnLife = 30 * time.Minute
defaultConnIdle = 10 * time.Minute
defaultPingTimeout = 10 * time.Second
)
// Open connects to MySQL. An empty DSN skips the connection for local bring-up.
func Open(log *zap.Logger, o *Options) (*DB, error) {
if o == nil {
o = &Options{}
}
if o.DSN == "" {
return &DB{}, nil
}
sqlDB, err := sql.Open("mysql", dsn)
applyPoolDefaults(o)
gdb, err := gorm.Open(mysqldriver.Open(withDialTimeout(o.DSN)), &gorm.Config{
Logger: newGormLogger(log, o.SlowThreshold, parseGormLevel(o.LogLevel, o.ZapLevel)),
})
if err != nil {
return nil, fmt.Errorf("mysql open: %w", err)
}
sqlDB.SetMaxOpenConns(64)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
sqlDB, err := gdb.DB()
if err != nil {
return nil, fmt.Errorf("mysql pool: %w", err)
}
sqlDB.SetMaxOpenConns(o.MaxOpenConns)
sqlDB.SetMaxIdleConns(o.MaxIdleConns)
sqlDB.SetConnMaxLifetime(o.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(o.ConnMaxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), defaultPingTimeout)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("mysql ping: %w", err)
}
return &DB{sql: sqlDB}, nil
return &DB{gdb: gdb}, nil
}
func (d *DB) SQL() *sql.DB { return d.sql }
func applyPoolDefaults(o *Options) {
if o.MaxOpenConns <= 0 {
o.MaxOpenConns = defaultMaxOpen
}
if o.MaxIdleConns <= 0 {
o.MaxIdleConns = defaultMaxIdle
}
if o.ConnMaxLifetime <= 0 {
o.ConnMaxLifetime = defaultConnLife
}
if o.ConnMaxIdleTime <= 0 {
o.ConnMaxIdleTime = defaultConnIdle
}
}
func (d *DB) Close() error {
if d == nil || d.sql == nil {
func withDialTimeout(dsn string) string {
if strings.Contains(dsn, "timeout=") {
return dsn
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn + sep + "timeout=10s"
}
// Gorm returns the ORM handle (nil when DSN was empty).
func (d *DB) Gorm() *gorm.DB {
if d == nil {
return nil
}
return d.sql.Close()
return d.gdb
}
func (d *DB) Ready() bool { return d != nil && d.sql != nil }
func (d *DB) Close() error {
if d == nil || d.gdb == nil {
return nil
}
sqlDB, err := d.gdb.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func (d *DB) Ready() bool { return d != nil && d.gdb != nil }
func (d *DB) Ping(ctx context.Context) error {
if !d.Ready() {
return nil
}
sqlDB, err := d.gdb.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
}

View File

@ -0,0 +1,157 @@
package objectstore
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http"
"spark-server/internal/pkg/errs"
)
const maxObjectBytes = 4 << 20
func (s *Store) PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (string, error) {
if !s.Ready() {
return "", errs.ErrAvailable
}
in := &s3.PutObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}
if contentType != "" {
in.ContentType = aws.String(contentType)
}
out, err := s.presign.PresignPutObject(ctx, in, s3.WithPresignExpires(ttl))
if err != nil {
return "", mapObjectErr(err)
}
return out.URL, nil
}
func (s *Store) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
if !s.Ready() {
return "", errs.ErrAvailable
}
out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(ttl))
if err != nil {
return "", mapObjectErr(err)
}
return out.URL, nil
}
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
if !s.Ready() {
return nil, errs.ErrAvailable
}
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, mapObjectErr(err)
}
defer func() { _ = out.Body.Close() }()
data, err := io.ReadAll(io.LimitReader(out.Body, maxObjectBytes+1))
if err != nil {
return nil, mapObjectErr(err)
}
if len(data) > maxObjectBytes {
return nil, errs.ErrInvalidArgument
}
return data, nil
}
func (s *Store) Put(ctx context.Context, key string, body []byte, contentType string) error {
if !s.Ready() {
return errs.ErrAvailable
}
if len(body) == 0 || len(body) > maxObjectBytes {
return errs.ErrInvalidArgument
}
in := &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
ContentLength: aws.Int64(int64(len(body))),
}
if contentType != "" {
in.ContentType = aws.String(contentType)
}
if _, err := s.client.PutObject(ctx, in); err != nil {
return mapObjectErr(err)
}
return nil
}
func (s *Store) Copy(ctx context.Context, src, dst string) error {
if !s.Ready() {
return errs.ErrAvailable
}
_, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(dst),
CopySource: aws.String(s.bucket + "/" + src),
})
if err != nil {
return mapObjectErr(err)
}
return nil
}
func (s *Store) Delete(ctx context.Context, key string) error {
if !s.Ready() {
return errs.ErrAvailable
}
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return mapObjectErr(err)
}
return nil
}
func mapObjectErr(err error) error {
if err == nil {
return nil
}
if app, ok := errs.AsAppError(err); ok {
return app
}
if isMissingObject(err) {
return errs.ErrMediaExpired.WithError("objectstore", err)
}
return errs.ErrOperatFail.WithError("objectstore", err)
}
func isMissingObject(err error) bool {
var nf *types.NotFound
var nk *types.NoSuchKey
var nb *types.NoSuchBucket
if errors.As(err, &nf) || errors.As(err, &nk) || errors.As(err, &nb) {
return true
}
var api smithy.APIError
if errors.As(err, &api) {
switch strings.ToLower(api.ErrorCode()) {
case "nosuchkey", "nosuchobject", "notfound", "nosuchbucket":
return true
}
}
var httpErr *smithyhttp.ResponseError
if errors.As(err, &httpErr) && httpErr.HTTPStatusCode() == http.StatusNotFound {
return true
}
return false
}

View File

@ -0,0 +1,85 @@
package objectstore
import (
"context"
"errors"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"go.uber.org/zap"
)
func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays int) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
if err != nil {
if !isMissingObject(err) {
return fmt.Errorf("objectstore ping: %w", err)
}
if err := s.createBucket(ctx, region); err != nil {
return err
}
}
s.applyLifecycle(ctx, chatExpireDays)
s.applyCORS(ctx)
return nil
}
func (s *Store) createBucket(ctx context.Context, region string) error {
in := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)}
if region != "" && region != defaultRegion {
in.CreateBucketConfiguration = &types.CreateBucketConfiguration{
LocationConstraint: types.BucketLocationConstraint(region),
}
}
if _, err := s.client.CreateBucket(ctx, in); err != nil {
var owned *types.BucketAlreadyOwnedByYou
var exists *types.BucketAlreadyExists
if errors.As(err, &owned) || errors.As(err, &exists) {
return nil
}
return fmt.Errorf("objectstore make bucket: %w", err)
}
return nil
}
func (s *Store) applyLifecycle(ctx context.Context, days int) {
if days < 1 {
return
}
_, err := s.client.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(s.bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{{
ID: aws.String("expire-chat"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("chat/")},
Expiration: &types.LifecycleExpiration{
Days: aws.Int32(int32(days)),
},
}},
},
})
if err != nil {
s.log.Warn("objectstore lifecycle", zap.Error(err))
}
}
func (s *Store) applyCORS(ctx context.Context) {
_, err := s.client.PutBucketCors(ctx, &s3.PutBucketCorsInput{
Bucket: aws.String(s.bucket),
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "PUT", "HEAD"},
AllowedHeaders: []string{"*"},
ExposeHeaders: []string{"ETag", "Content-Length"},
MaxAgeSeconds: aws.Int32(3600),
}},
},
})
if err != nil {
s.log.Warn("objectstore cors", zap.Error(err))
}
}

View File

@ -0,0 +1,109 @@
// Package objectstore talks to S3-compatible object storage (RustFS) via AWS SDK v2.
package objectstore
import (
"context"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.uber.org/zap"
)
const (
defaultRegion = "us-east-1"
defaultTimeout = 15 * time.Second
)
// Store is a RustFS / S3 client. Nil or !Ready means object storage is not configured.
type Store struct {
client *s3.Client
presign *s3.PresignClient
bucket string
log *zap.Logger
}
// Options configure Open. Empty Endpoint skips the connection.
type Options struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
Region string
UseSSL bool
ChatExpireDays int
Log *zap.Logger
}
func Open(o *Options) (*Store, error) {
if o == nil || strings.TrimSpace(o.Endpoint) == "" {
return &Store{}, nil
}
host, secure, err := parseEndpoint(o.Endpoint, o.UseSSL)
if err != nil {
return nil, err
}
bucket := strings.TrimSpace(o.Bucket)
if bucket == "" {
bucket = "spark"
}
region := strings.TrimSpace(o.Region)
if region == "" {
region = defaultRegion
}
scheme := "http"
if secure {
scheme = "https"
}
client := newS3Client(region, scheme+"://"+host, o.AccessKey, o.SecretKey)
s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: bucket, log: o.Log}
if s.log == nil {
s.log = zap.NewNop()
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
if err := s.ensureBucket(ctx, region, o.ChatExpireDays); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) Ready() bool {
return s != nil && s.client != nil && s.presign != nil && s.bucket != ""
}
func (s *Store) Ping(ctx context.Context) error {
if !s.Ready() {
return nil
}
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
return err
}
func newS3Client(region, baseURL, access, secret string) *s3.Client {
return s3.New(s3.Options{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(access, secret, ""),
BaseEndpoint: aws.String(baseURL),
UsePathStyle: true,
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
})
}
func parseEndpoint(raw string, useSSL bool) (host string, secure bool, err error) {
raw = strings.TrimSpace(raw)
switch {
case raw == "":
return "", false, fmt.Errorf("objectstore: empty endpoint")
case strings.HasPrefix(raw, "https://"):
return strings.TrimPrefix(raw, "https://"), true, nil
case strings.HasPrefix(raw, "http://"):
return strings.TrimPrefix(raw, "http://"), false, nil
default:
return raw, useSSL, nil
}
}

View File

@ -0,0 +1,104 @@
package objectstore
import (
"errors"
"os"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"go.uber.org/zap"
"spark-server/internal/pkg/errs"
)
func TestParseEndpoint(t *testing.T) {
host, secure, err := parseEndpoint("http://47.236.66.9:9000", true)
if err != nil || host != "47.236.66.9:9000" || secure {
t.Fatalf("%s %v %v", host, secure, err)
}
host, secure, err = parseEndpoint("47.236.66.9:9000", false)
if err != nil || host != "47.236.66.9:9000" || secure {
t.Fatalf("plain %s %v %v", host, secure, err)
}
host, secure, err = parseEndpoint("https://fs.example.com", false)
if err != nil || host != "fs.example.com" || !secure {
t.Fatalf("https %s %v %v", host, secure, err)
}
}
func TestOpenEmpty(t *testing.T) {
s, err := Open(&Options{})
if err != nil || s.Ready() {
t.Fatalf("ready=%v err=%v", s.Ready(), err)
}
}
func TestPresignLocal(t *testing.T) {
client := newS3Client(defaultRegion, "http://127.0.0.1:9000", "ak", "sk")
s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: "spark"}
u, err := s.PresignPut(t.Context(), "chat/a.jpg", "image/jpeg", time.Hour)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(u, "127.0.0.1:9000/spark/chat/a.jpg") {
t.Fatalf("%s", u)
}
g, err := s.PresignGet(t.Context(), "perm/avatar/1.jpg", time.Minute)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(g, "perm/avatar/1.jpg") {
t.Fatalf("%s", g)
}
}
func TestGetPutNotReady(t *testing.T) {
s, err := Open(&Options{})
if err != nil {
t.Fatal(err)
}
if _, err := s.Get(t.Context(), "perm/a"); !errors.Is(err, errs.ErrAvailable) {
t.Fatalf("get %v", err)
}
if err := s.Put(t.Context(), "perm/a", []byte("x"), "image/jpeg"); !errors.Is(err, errs.ErrAvailable) {
t.Fatalf("put %v", err)
}
}
func TestMapObjectErr(t *testing.T) {
err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")})
if !errors.Is(err, errs.ErrMediaExpired) {
t.Fatalf("%v", err)
}
err = mapObjectErr(errors.New("boom"))
if !errors.Is(err, errs.ErrOperatFail) {
t.Fatalf("%v", err)
}
}
func TestOpenLive(t *testing.T) {
if os.Getenv("SPARK_S3_LIVE") == "" {
t.Skip("set SPARK_S3_LIVE=1 to hit RustFS")
}
s, err := Open(&Options{
Endpoint: "47.236.66.9:9000",
AccessKey: os.Getenv("SPARK_S3_ACCESS_KEY"),
SecretKey: os.Getenv("SPARK_S3_SECRET_KEY"),
Bucket: "spark",
ChatExpireDays: 365,
Log: zap.NewNop(),
})
if err != nil {
t.Fatal(err)
}
if !s.Ready() {
t.Fatal("not ready")
}
if err := s.Ping(t.Context()); err != nil {
t.Fatal(err)
}
}

View File

@ -3,10 +3,12 @@ package push
import "context"
// Notification is a system push. Mute is decided in Deliver Worker, not gnet.
// Title/Body must be localized with i18n.Notify using the recipient device language.
type Notification struct {
UserID int64
DeviceID uint64
ConversationID int64
Type string
Title string
Body string
Badge int

View File

@ -12,12 +12,28 @@ type Client struct {
rdb *redis.Client
}
func Open(addr string) (*Client, error) {
if addr == "" {
// Options configure Open. Empty Addr skips the connection for local bring-up.
type Options struct {
Addr string
Username string
Password string
DB int
}
func Open(o *Options) (*Client, error) {
if o == nil || o.Addr == "" {
return &Client{}, nil
}
rdb := redis.NewClient(&redis.Options{Addr: addr})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
rdb := redis.NewClient(&redis.Options{
Addr: o.Addr,
Username: o.Username,
Password: o.Password,
DB: o.DB,
DialTimeout: 10 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
_ = rdb.Close()
@ -36,3 +52,10 @@ func (c *Client) Close() error {
}
func (c *Client) Ready() bool { return c != nil && c.rdb != nil }
func (c *Client) Ping(ctx context.Context) error {
if !c.Ready() {
return nil
}
return c.rdb.Ping(ctx).Err()
}

View File

@ -0,0 +1,14 @@
package redisx
import "testing"
func TestOpenEmptyAddrSkips(t *testing.T) {
c, err := Open(nil)
if err != nil || c.Ready() {
t.Fatalf("nil options: %v ready=%v", err, c.Ready())
}
c, err = Open(&Options{})
if err != nil || c.Ready() {
t.Fatalf("empty addr: %v ready=%v", err, c.Ready())
}
}

View File

@ -0,0 +1,23 @@
// Package entity holds auth domain types shared by HTTP Bearer and TCP AUTH.
package entity
import "time"
// Principal is the authenticated identity.
type Principal struct {
UserID int64
DeviceID uint64
JTI string
ExpiresAt time.Time
}
// IssuedToken is the login / refresh `data` payload.
type IssuedToken struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshExpiresIn int64 `json:"refresh_expires_in"`
UserID int64 `json:"user_id"`
DeviceID uint64 `json:"device_id"`
SparkID string `json:"spark_id"`
}

View File

@ -0,0 +1,91 @@
package logic
import (
"context"
"sync"
"time"
"github.com/redis/go-redis/v9"
"spark-server/internal/pkg/errs"
)
// Denylist stores revoked JWT ids until their original expiry.
type Denylist interface {
Has(ctx context.Context, jti string) (bool, error)
Add(ctx context.Context, jti string, ttl time.Duration) error
}
// MemoryDenylist is process-local. Used in tests and when Redis is not configured.
type MemoryDenylist struct {
mu sync.Mutex
items map[string]int64 // jti → expiry unix nano
}
func NewMemoryDenylist() *MemoryDenylist {
return &MemoryDenylist{items: make(map[string]int64)}
}
func (d *MemoryDenylist) Has(_ context.Context, jti string) (bool, error) {
if jti == "" {
return false, nil
}
now := time.Now().UnixNano()
d.mu.Lock()
defer d.mu.Unlock()
exp, ok := d.items[jti]
if !ok {
return false, nil
}
if now >= exp {
delete(d.items, jti)
return false, nil
}
return true, nil
}
func (d *MemoryDenylist) Add(_ context.Context, jti string, ttl time.Duration) error {
if jti == "" || ttl <= 0 {
return nil
}
d.mu.Lock()
d.items[jti] = time.Now().Add(ttl).UnixNano()
d.mu.Unlock()
return nil
}
// RedisDenylist uses SETEX denylist:{jti} with remaining token TTL.
type RedisDenylist struct {
rdb *redis.Client
prefix string
}
func NewRedisDenylist(rdb *redis.Client, prefix string) *RedisDenylist {
if prefix == "" {
prefix = "denylist:"
}
return &RedisDenylist{rdb: rdb, prefix: prefix}
}
func (d *RedisDenylist) key(jti string) string { return d.prefix + jti }
func (d *RedisDenylist) Has(ctx context.Context, jti string) (bool, error) {
if d == nil || d.rdb == nil {
return false, errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready")
}
n, err := d.rdb.Exists(ctx, d.key(jti)).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
func (d *RedisDenylist) Add(ctx context.Context, jti string, ttl time.Duration) error {
if d == nil || d.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready")
}
if jti == "" || ttl <= 0 {
return nil
}
return d.rdb.Set(ctx, d.key(jti), "1", ttl).Err()
}

View File

@ -0,0 +1,308 @@
// Package logic is the auth application use-case layer (JWT, login, denylist).
package logic
import (
"context"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
authentity "spark-server/internal/module/auth/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
)
// Service validates an access token. HTTP middleware and TCP AUTH share this.
type Service interface {
Authenticate(ctx context.Context, token string) (*authentity.Principal, error)
}
// Issuer mints, refreshes, and revokes access tokens.
type Issuer interface {
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
Revoke(ctx context.Context, token string) error
}
// TokenService is JWT: authenticate, issue, and revoke.
type TokenService interface {
Service
Issuer
}
const (
defaultIssuer = "spark-im"
defaultTTL = 7 * 24 * time.Hour
defaultRefreshTTL = 30 * 24 * time.Hour
)
// Options configure HS256 JWT issuance and validation.
type Options struct {
Secret string
Issuer string
TTL time.Duration
RefreshTTL time.Duration
Denylist Denylist
Pairs PairStore
Clock func() time.Time
}
type claims struct {
DeviceID uint64 `json:"did"`
Use string `json:"use,omitempty"`
jwt.RegisteredClaims
}
// JWT implements TokenService with HS256 and an optional jti denylist.
type JWT struct {
secret []byte
issuer string
ttl time.Duration
refreshTTL time.Duration
denylist Denylist
pairs PairStore
clock func() time.Time
}
func NewJWT(opt *Options) (*JWT, error) {
if opt == nil || strings.TrimSpace(opt.Secret) == "" {
return nil, errs.ErrInner.WithInnerMsg("jwt: secret is required")
}
issuer := opt.Issuer
if issuer == "" {
issuer = defaultIssuer
}
ttl := opt.TTL
if ttl <= 0 {
ttl = defaultTTL
}
refreshTTL := opt.RefreshTTL
if refreshTTL <= 0 {
refreshTTL = defaultRefreshTTL
}
dl := opt.Denylist
if dl == nil {
dl = NewMemoryDenylist()
}
pairs := opt.Pairs
if pairs == nil {
pairs = NewMemoryPairs()
}
clock := opt.Clock
if clock == nil {
clock = time.Now
}
return &JWT{
secret: []byte(opt.Secret),
issuer: issuer,
ttl: ttl,
refreshTTL: refreshTTL,
denylist: dl,
pairs: pairs,
clock: clock,
}, nil
}
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) {
if err := ctx.Err(); err != nil {
return nil, errs.FromRPCError("jwt", err)
}
if userID <= 0 || deviceID == 0 {
return nil, errs.ErrInvalidArgument
}
access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl)
if err != nil {
return nil, err
}
refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL)
if err != nil {
return nil, err
}
if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil {
return nil, err
}
return &authentity.IssuedToken{
AccessToken: access.raw,
RefreshToken: refresh.raw,
ExpiresIn: int64(j.ttl / time.Second),
RefreshExpiresIn: int64(j.refreshTTL / time.Second),
UserID: userID,
DeviceID: deviceID,
}, nil
}
type signedToken struct {
raw string
jti string
}
func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) {
now := j.clock()
jti := reqctx.NewID()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
DeviceID: deviceID,
Use: use,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatInt(userID, 10),
Issuer: j.issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
ID: jti,
},
})
raw, err := tok.SignedString(j.secret)
if err != nil {
return signedToken{}, errs.ErrInner.WithError("jwt", err)
}
return signedToken{raw: raw, jti: jti}, nil
}
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error {
old, err := j.pairs.Get(ctx, userID, deviceID)
if err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
if old.Access != "" {
_ = j.denylist.Add(ctx, old.Access, j.ttl)
}
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
return nil
}
func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) {
c, err := j.parse(refreshToken, false)
if err != nil {
return nil, err
}
if c.Use != tokenUseRefresh {
return nil, errs.ErrUnauthorized
}
denied, err := j.denylist.Has(ctx, c.ID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
}
if denied {
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
return nil, errs.ErrUnauthorized
}
stored, err := j.pairs.Get(ctx, uid, c.DeviceID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-refresh", err)
}
if stored.Refresh == "" || stored.Refresh != c.ID {
return nil, errs.ErrUnauthorized
}
return j.Issue(ctx, uid, c.DeviceID)
}
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error {
if accessJTI != "" {
remain := accessExp.Sub(j.clock())
if remain > 0 {
if err := j.denylist.Add(ctx, accessJTI, remain); err != nil {
return errs.ErrAvailable.WithError("jwt-denylist", err)
}
}
}
old, err := j.pairs.Get(ctx, userID, deviceID)
if err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
if old.Access != "" && old.Access != accessJTI {
_ = j.denylist.Add(ctx, old.Access, j.ttl)
}
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Delete(ctx, userID, deviceID); err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
return nil
}
func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
c, err := j.parse(token, false)
if err != nil {
return nil, err
}
denied, err := j.denylist.Has(ctx, c.ID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
}
if denied {
return nil, errs.ErrUnauthorized
}
if c.Use != "" && c.Use != tokenUseAccess {
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
return nil, errs.ErrUnauthorized
}
exp := time.Time{}
if c.ExpiresAt != nil {
exp = c.ExpiresAt.Time
}
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil
}
func (j *JWT) Revoke(ctx context.Context, token string) error {
c, err := j.parse(token, true)
if err != nil {
return err
}
if c.ExpiresAt == nil {
return nil
}
remain := c.ExpiresAt.Time.Sub(j.clock())
if remain <= 0 {
return nil
}
if err := j.denylist.Add(ctx, c.ID, remain); err != nil {
return errs.ErrAvailable.WithError("jwt-denylist", err)
}
return nil
}
func (j *JWT) parse(token string, allowExpired bool) (*claims, error) {
if token == "" {
return nil, errs.ErrUnauthorized
}
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithTimeFunc(j.clock),
}
if allowExpired {
opts = append(opts, jwt.WithoutClaimsValidation())
} else {
opts = append(opts, jwt.WithIssuer(j.issuer), jwt.WithIssuedAt(), jwt.WithExpirationRequired())
}
parsed, err := jwt.ParseWithClaims(token, &claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, jwt.ErrSignatureInvalid
}
return j.secret, nil
}, opts...)
if err != nil || parsed == nil || !parsed.Valid {
return nil, errs.ErrUnauthorized
}
c, ok := parsed.Claims.(*claims)
if !ok || c.ID == "" {
return nil, errs.ErrUnauthorized
}
if allowExpired && c.Issuer != j.issuer {
return nil, errs.ErrUnauthorized
}
return c, nil
}

View File

@ -0,0 +1,209 @@
package logic
import (
"context"
"errors"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"spark-server/internal/pkg/errs"
)
func testJWT(t *testing.T, clock func() time.Time) *JWT {
t.Helper()
j, err := NewJWT(&Options{
Secret: "test-secret-test-secret",
Issuer: "spark-im",
TTL: time.Hour,
Clock: clock,
})
if err != nil {
t.Fatal(err)
}
return j
}
func TestJWTIssueAuthenticate(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 42, 7)
if err != nil {
t.Fatal(err)
}
if tok.UserID != 42 || tok.DeviceID != 7 || tok.ExpiresIn != 3600 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.RefreshExpiresIn != 2592000 {
t.Fatalf("%+v", tok)
}
p, err := j.Authenticate(ctx, tok.AccessToken)
if err != nil {
t.Fatal(err)
}
if p.UserID != 42 || p.DeviceID != 7 || p.JTI == "" {
t.Fatalf("%+v", p)
}
}
func TestJWTRejectsBadToken(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
if _, err := j.Authenticate(ctx, ""); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("empty: %v", err)
}
if _, err := j.Authenticate(ctx, "not-a-jwt"); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("garbage: %v", err)
}
other, err := NewJWT(&Options{Secret: "other-secret-other-secret", Issuer: "spark-im"})
if err != nil {
t.Fatal(err)
}
tok, err := other.Issue(ctx, 1, 1)
if err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("wrong secret: %v", err)
}
}
func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
ctx := context.Background()
now := time.Now()
clock := func() time.Time { return now }
j := testJWT(t, clock)
tok, err := j.Issue(ctx, 1, 2)
if err != nil {
t.Fatal(err)
}
j.clock = func() time.Time { return now.Add(2 * time.Hour) }
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("expired: %v", err)
}
wrongIss, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "other", TTL: time.Hour, Clock: clock})
if err != nil {
t.Fatal(err)
}
issTok, err := wrongIss.Issue(ctx, 1, 2)
if err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, issTok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("issuer: %v", err)
}
}
func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 5, 8)
if err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("refresh as access: %v", err)
}
next, err := j.Refresh(ctx, tok.RefreshToken)
if err != nil {
t.Fatal(err)
}
if next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken {
t.Fatal("expected new pair")
}
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("old access: %v", err)
}
if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("old refresh: %v", err)
}
if _, err := j.Authenticate(ctx, next.AccessToken); err != nil {
t.Fatal(err)
}
}
func TestJWTLogoutRevokesPair(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 4, 2)
if err != nil {
t.Fatal(err)
}
p, err := j.Authenticate(ctx, tok.AccessToken)
if err != nil {
t.Fatal(err)
}
if err := j.Logout(ctx, p.UserID, p.DeviceID, p.JTI, p.ExpiresAt); err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("access after logout: %v", err)
}
if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("refresh after logout: %v", err)
}
}
func TestJWTDenylistRevoke(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 9, 3)
if err != nil {
t.Fatal(err)
}
if err := j.Revoke(ctx, tok.AccessToken); err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("revoked: %v", err)
}
}
func TestJWTIssueRejectsBadIDs(t *testing.T) {
j := testJWT(t, nil)
if _, err := j.Issue(context.Background(), 0, 1); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("user: %v", err)
}
if _, err := j.Issue(context.Background(), 1, 0); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("device: %v", err)
}
}
func TestJWTRejectsNoneAlg(t *testing.T) {
j := testJWT(t, nil)
tok := jwt.NewWithClaims(jwt.SigningMethodNone, &claims{
DeviceID: 1,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "1",
Issuer: "spark-im",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
ID: "jti-none",
},
})
raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(context.Background(), raw); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("none: %v", err)
}
}
func TestNewJWTRequiresSecret(t *testing.T) {
if _, err := NewJWT(&Options{}); err == nil {
t.Fatal("expected secret error")
}
}
func TestMemoryDenylistExpiry(t *testing.T) {
d := NewMemoryDenylist()
ctx := context.Background()
if err := d.Add(ctx, "x", time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(5 * time.Millisecond)
ok, err := d.Has(ctx, "x")
if err != nil || ok {
t.Fatalf("expired still present: %v %v", ok, err)
}
}

View File

@ -0,0 +1,151 @@
package logic
import (
"context"
authentity "spark-server/internal/module/auth/entity"
userentity "spark-server/internal/module/user/entity"
userlogic "spark-server/internal/module/user/logic"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
// Verifier checks email OTP and records the login device. The user module owns the implementation.
type Verifier interface {
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error)
SendEmailCode(ctx context.Context, email string) error
}
// LoginInput is the HTTP/TCP-shared login body. Validation tags drive client-visible messages.
type LoginInput struct {
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"`
DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"`
DeviceType string `json:"device_type" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
Platform string `json:"platform" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"`
}
// SendCodeInput requests an email OTP. Test accounts do not need this.
type SendCodeInput struct {
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
}
type SentCode struct {
ExpiresIn int64 `json:"expires_in"`
}
type RefreshInput struct {
RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"`
}
// SessionKicker drops the IM connection for one device after HTTP logout.
type SessionKicker interface {
KickDevice(userID int64, deviceID uint64)
}
// LoginService is the application login use-case. Handlers only bind and call this.
type LoginService struct {
Issuer Issuer
Users Verifier
Sessions SessionKicker
}
func NewLoginService(issuer Issuer, users Verifier) *LoginService {
return &LoginService{Issuer: issuer, Users: users}
}
func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.IssuedToken, error) {
if s == nil || s.Issuer == nil || s.Users == nil || in == nil {
return nil, errs.ErrUnauthorized
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if !userentity.ValidDeviceType(loginDeviceType(in)) {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
}
u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, loginDevice(ctx, in))
if err != nil {
return nil, err
}
if u == nil || u.ID <= 0 {
return nil, errs.ErrUnauthorized
}
tok, err := s.Issuer.Issue(ctx, u.ID, in.DeviceID)
if err != nil {
return nil, err
}
tok.SparkID = u.SparkID
return tok, nil
}
func (s *LoginService) SendEmailCode(ctx context.Context, in SendCodeInput) (*SentCode, error) {
if s == nil || s.Users == nil {
return nil, errs.ErrAvailable
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if err := s.Users.SendEmailCode(ctx, in.Email); err != nil {
return nil, err
}
return &SentCode{ExpiresIn: userlogic.EmailCodeTTLSeconds}, nil
}
func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) {
if s == nil || s.Issuer == nil {
return nil, errs.ErrUnauthorized
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
return s.Issuer.Refresh(ctx, in.RefreshToken)
}
func (s *LoginService) Logout(ctx context.Context) error {
if s == nil || s.Issuer == nil {
return errs.ErrUnauthorized
}
rc := reqctx.From(ctx)
if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 {
return errs.ErrUnauthorized
}
if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.JTI, rc.TokenExpires); err != nil {
return err
}
if s.Sessions != nil {
s.Sessions.KickDevice(rc.UserID, rc.DeviceID)
}
return nil
}
func loginDeviceType(in *LoginInput) string {
if in == nil {
return ""
}
if t := userentity.NormalizeDeviceType(in.DeviceType); userentity.ValidDeviceType(t) {
return t
}
return userentity.NormalizeDeviceType(in.Platform)
}
func loginDevice(ctx context.Context, in *LoginInput) *userentity.LoginDevice {
if in == nil {
return &userentity.LoginDevice{}
}
d := &userentity.LoginDevice{
DeviceID: in.DeviceID,
DeviceType: loginDeviceType(in),
Platform: in.Platform,
DeviceName: in.DeviceName,
RealIP: reqctx.RealIP(ctx),
Language: reqctx.Language(ctx),
}
if rc := reqctx.From(ctx); rc != nil {
d.AppVersion = rc.AppVersion
}
return d
}

View File

@ -0,0 +1,92 @@
package logic
import (
"errors"
"strings"
"testing"
userlogic "spark-server/internal/module/user/logic"
usermodel "spark-server/internal/module/user/model"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
)
func TestLoginServiceValidatesAndIssues(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
if err != nil {
t.Fatal(err)
}
svc := NewLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"})
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
app, ok := errs.AsAppError(err)
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "请填写验证码") {
t.Fatalf("validate %+v", err)
}
enCtx := reqctx.With(t.Context(), &reqctx.RequestContext{Language: "en"})
_, err = svc.Login(enCtx, &LoginInput{Email: "test01@spark.com"})
app, ok = errs.AsAppError(err)
if !ok || !strings.Contains(app.Msg, "Please enter the verification code") {
t.Fatalf("en validate %+v", err)
}
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
if err != nil {
t.Fatal(err)
}
if tok.UserID != 11 || tok.DeviceID != 7 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" {
t.Fatalf("%+v", tok)
}
}
func TestLoginServiceUnauthorized(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret"})
if err != nil {
t.Fatal(err)
}
in := LoginInput{Email: "ada@example.com", VerifyCode: "000000", DeviceID: 1, DeviceType: "web"}
svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11})
if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("bad code: %v", err)
}
in.VerifyCode = "123456"
if _, err = NewLoginService(j, nil).Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("nil verifier: %v", err)
}
}
func TestLoginServiceRecordsContextDevice(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
if err != nil {
t.Fatal(err)
}
repo := usermodel.NewMemoryRepo()
ids := seqIDs{n: 1000}
users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil)
svc := NewLoginService(j, users)
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
})
tok, err := svc.Login(ctx, &LoginInput{
Email: "test07@spark.com", VerifyCode: "x", DeviceID: 9, Platform: "android", DeviceName: "Pixel",
})
if err != nil {
t.Fatal(err)
}
dev, err := repo.GetDevice(ctx, tok.UserID, 9)
if err != nil {
t.Fatal(err)
}
if dev.DeviceType != "android" || dev.DeviceName != "Pixel" || dev.LastIP != "198.51.100.9" || dev.AppVersion != "2.1.0" || dev.Language != "en" {
t.Fatalf("%+v", dev)
}
}
type seqIDs struct{ n int64 }
func (s *seqIDs) NextID() (int64, error) {
s.n++
return s.n, nil
}

View File

@ -0,0 +1,135 @@
package logic
import (
"context"
"encoding/json"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"spark-server/internal/pkg/errs"
)
const (
tokenUseAccess = "access"
tokenUseRefresh = "refresh"
)
// TokenPair is the current access+refresh jti for one user device.
type TokenPair struct {
Access string `json:"a"`
Refresh string `json:"r"`
}
// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both.
type PairStore interface {
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
Delete(ctx context.Context, userID int64, deviceID uint64) error
}
type MemoryPairs struct {
mu sync.Mutex
items map[string]storedPair
}
type storedPair struct {
pair TokenPair
exp int64
}
func NewMemoryPairs() *MemoryPairs {
return &MemoryPairs{items: make(map[string]storedPair)}
}
func pairKey(userID int64, deviceID uint64) string {
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
}
func (s *MemoryPairs) Put(_ context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
if userID <= 0 || deviceID == 0 || ttl <= 0 {
return nil
}
s.mu.Lock()
s.items[pairKey(userID, deviceID)] = storedPair{pair: pair, exp: time.Now().Add(ttl).UnixNano()}
s.mu.Unlock()
return nil
}
func (s *MemoryPairs) Get(_ context.Context, userID int64, deviceID uint64) (TokenPair, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.items[pairKey(userID, deviceID)]
if !ok {
return TokenPair{}, nil
}
if time.Now().UnixNano() >= row.exp {
delete(s.items, pairKey(userID, deviceID))
return TokenPair{}, nil
}
return row.pair, nil
}
func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) error {
s.mu.Lock()
delete(s.items, pairKey(userID, deviceID))
s.mu.Unlock()
return nil
}
type RedisPairs struct {
rdb *redis.Client
prefix string
}
func NewRedisPairs(rdb *redis.Client, prefix string) *RedisPairs {
if prefix == "" {
prefix = "refresh:"
}
return &RedisPairs{rdb: rdb, prefix: prefix}
}
func (s *RedisPairs) key(userID int64, deviceID uint64) string {
return s.prefix + pairKey(userID, deviceID)
}
func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
if s == nil || s.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
if userID <= 0 || deviceID == 0 || ttl <= 0 {
return nil
}
raw, err := json.Marshal(pair)
if err != nil {
return err
}
return s.rdb.Set(ctx, s.key(userID, deviceID), raw, ttl).Err()
}
func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) {
if s == nil || s.rdb == nil {
return TokenPair{}, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
raw, err := s.rdb.Get(ctx, s.key(userID, deviceID)).Bytes()
if err == redis.Nil {
return TokenPair{}, nil
}
if err != nil {
return TokenPair{}, err
}
var pair TokenPair
if err := json.Unmarshal(raw, &pair); err != nil {
return TokenPair{}, err
}
return pair, nil
}
func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64) error {
if s == nil || s.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
return s.rdb.Del(ctx, s.key(userID, deviceID)).Err()
}

View File

@ -0,0 +1,47 @@
package logic
import (
"context"
authentity "spark-server/internal/module/auth/entity"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/pkg/errs"
)
// Static accepts one opaque token. Tests and local bring-up only; production uses JWT.
type Static struct {
Token string
UserID int64
DeviceID uint64
}
func (s Static) Authenticate(_ context.Context, token string) (*authentity.Principal, error) {
if s.Token == "" || token != s.Token {
return nil, errs.ErrUnauthorized
}
return &authentity.Principal{UserID: s.UserID, DeviceID: s.DeviceID}, nil
}
// StaticUser is a single email/OTP pair for tests that do not need the user module.
type StaticUser struct {
Email string
Code string
UserID int64
SparkID string
}
func (s StaticUser) AuthenticateEmail(_ context.Context, email, verifyCode string, _ *userentity.LoginDevice) (*userentity.User, error) {
if s.Email == "" || userentity.NormalizeEmail(email) != userentity.NormalizeEmail(s.Email) {
return nil, errs.ErrUnauthorized
}
if s.Code != "" && verifyCode != s.Code {
return nil, errs.ErrUnauthorized
}
sparkID := s.SparkID
if sparkID == "" {
sparkID = "static"
}
return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil
}
func (s StaticUser) SendEmailCode(context.Context, string) error { return nil }

View File

@ -1,12 +0,0 @@
package auth
import "context"
type Principal struct {
UserID int64
DeviceID uint64
}
type Service interface {
Authenticate(ctx context.Context, token string) (*Principal, error)
}

View File

@ -1,21 +0,0 @@
package auth
import (
"context"
"spark-server/internal/pkg/errs"
)
// Static accepts one token. Used in tests and local bring-up.
type Static struct {
Token string
UserID int64
DeviceID uint64
}
func (s Static) Authenticate(_ context.Context, token string) (*Principal, error) {
if s.Token == "" || token != s.Token {
return nil, errs.ErrUnauthorized
}
return &Principal{UserID: s.UserID, DeviceID: s.DeviceID}, nil
}

View File

@ -0,0 +1,15 @@
// Package entity holds conversation domain types.
package entity
const (
TypeP2P = 1
TypeGroup = 2
)
// Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq.
type Conversation struct {
ID int64
Type int
LastMsgID int64
LastMsgAt int64
}

View File

@ -0,0 +1,10 @@
package entity
// Member is a row in conversation_members. LastReadMsgID is the durable read cursor.
type Member struct {
ConversationID int64
UserID int64
LastReadMsgID int64
JoinedAtMS int64
Muted bool
}

View File

@ -0,0 +1,15 @@
package entity
// P2P is the unique pair mapping. UserSmall is min(a,b), UserBig is max(a,b).
type P2P struct {
UserSmall int64
UserBig int64
ConversationID int64
}
func OrderUsers(a, b int64) (small, big int64) {
if a <= b {
return a, b
}
return b, a
}

View File

@ -0,0 +1,17 @@
// Package logic is the conversation use-case layer.
package logic
import (
"context"
"spark-server/internal/module/conversation/entity"
)
type Repository interface {
Create(ctx context.Context, c *entity.Conversation) error
Get(ctx context.Context, id int64) (*entity.Conversation, error)
GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error)
UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error
GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error)
UpdateLastRead(ctx context.Context, conversationID, userID, msgID int64) error
}

View File

@ -0,0 +1,72 @@
package logic
import (
"context"
"errors"
"time"
"spark-server/internal/module/conversation/entity"
"spark-server/internal/pkg/errs"
)
type IDGen interface {
NextID() (int64, error)
}
type P2PRepository interface {
GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error)
CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error
UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error
}
// P2PService opens the unique 1:1 conversation for a user pair.
type P2PService struct {
repo P2PRepository
ids IDGen
}
func NewP2PService(repo P2PRepository, ids IDGen) *P2PService {
return &P2PService{repo: repo, ids: ids}
}
func (s *P2PService) EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) {
if s == nil || s.repo == nil || s.ids == nil || userA <= 0 || userB <= 0 || userA == userB {
return 0, errs.ErrInvalidArgument
}
if c, err := s.repo.GetP2P(ctx, userA, userB); err == nil {
return c.ID, nil
} else if !errors.Is(err, errs.ErrNotFound) {
return 0, err
}
id, err := s.ids.NextID()
if err != nil {
return 0, err
}
now := time.Now()
small, big := entity.OrderUsers(userA, userB)
conv := &entity.Conversation{ID: id, Type: entity.TypeP2P, LastMsgAt: now.UnixMilli()}
p2p := &entity.P2P{UserSmall: small, UserBig: big, ConversationID: id}
joined := now.UnixMilli()
members := []*entity.Member{
{ConversationID: id, UserID: userA, JoinedAtMS: joined},
{ConversationID: id, UserID: userB, JoinedAtMS: joined},
}
if err := s.repo.CreateP2P(ctx, conv, p2p, members); err != nil {
if errors.Is(err, errs.ErrDuplicate) {
c, err := s.repo.GetP2P(ctx, userA, userB)
if err != nil {
return 0, err
}
return c.ID, nil
}
return 0, err
}
return id, nil
}
func (s *P2PService) UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error {
if s == nil || s.repo == nil {
return errs.ErrAvailable
}
return s.repo.UpdateLastMsg(ctx, conversationID, msgID, atMS)
}

View File

@ -0,0 +1,15 @@
// Package model is conversation persistence. One file per table.
package model
import "time"
// Conversation maps table conversations.
type Conversation struct {
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 会话 id,时间有序主键"`
Type int `gorm:"column:type;comment:会话类型:1=单聊 2=群聊"`
LastMsgID int64 `gorm:"column:last_msg_id;comment:最新消息 Snowflake id,0 表示尚无消息"`
LastMsgAt time.Time `gorm:"column:last_msg_at;comment:最新消息时间(毫秒精度)"`
CreatedAt time.Time `gorm:"column:created_at;comment:会话创建时间"`
}
func (Conversation) TableName() string { return "conversations" }

View File

@ -0,0 +1,47 @@
package model
import (
"context"
"time"
"spark-server/internal/module/conversation/entity"
"spark-server/internal/pkg/errs"
)
// ConversationMember maps table conversation_members.
type ConversationMember struct {
ConversationID int64 `gorm:"column:conversation_id;primaryKey;comment:会话 id"`
UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"`
LastReadMsgID int64 `gorm:"column:last_read_msg_id;comment:该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建"`
JoinedAt time.Time `gorm:"column:joined_at;comment:加入会话时间"`
Muted int `gorm:"column:muted;comment:0=未静音 1=免打扰"`
}
func (ConversationMember) TableName() string { return "conversation_members" }
func (r *ConversationMember) entity() *entity.Member {
if r == nil {
return nil
}
m := &entity.Member{
ConversationID: r.ConversationID, UserID: r.UserID,
LastReadMsgID: r.LastReadMsgID, JoinedAtMS: r.JoinedAt.UnixMilli(),
}
m.Muted = r.Muted != 0
return m
}
func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) {
if conversationID <= 0 || userID <= 0 {
return nil, errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row ConversationMember
if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.entity(), nil
}

View File

@ -0,0 +1,102 @@
package model
import (
"context"
"sync"
"time"
"spark-server/internal/module/conversation/entity"
"spark-server/internal/pkg/errs"
)
type p2pKey struct {
Small int64
Big int64
}
// MemoryP2P is an in-process conversation store for tests and empty-DSN bring-up.
type MemoryP2P struct {
mu sync.Mutex
byID map[int64]*entity.Conversation
byP2P map[p2pKey]int64
members map[int64]map[int64]*entity.Member
}
func NewMemoryP2P() *MemoryP2P {
return &MemoryP2P{
byID: make(map[int64]*entity.Conversation),
byP2P: make(map[p2pKey]int64),
members: make(map[int64]map[int64]*entity.Member),
}
}
func (m *MemoryP2P) GetP2P(_ context.Context, userA, userB int64) (*entity.Conversation, error) {
m.mu.Lock()
defer m.mu.Unlock()
small, big := entity.OrderUsers(userA, userB)
id, ok := m.byP2P[p2pKey{Small: small, Big: big}]
if !ok {
return nil, errs.ErrNotFound
}
cp := *m.byID[id]
return &cp, nil
}
func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error {
if c == nil || p2p == nil {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
key := p2pKey{Small: p2p.UserSmall, Big: p2p.UserBig}
if _, ok := m.byP2P[key]; ok {
return errs.ErrDuplicate
}
if _, ok := m.byID[c.ID]; ok {
return errs.ErrDuplicate
}
cp := *c
m.byID[c.ID] = &cp
m.byP2P[key] = c.ID
byUser := make(map[int64]*entity.Member, len(members))
for _, mem := range members {
if mem == nil {
continue
}
row := *mem
byUser[mem.UserID] = &row
}
m.members[c.ID] = byUser
return nil
}
func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) {
m.mu.Lock()
defer m.mu.Unlock()
byUser, ok := m.members[conversationID]
if !ok {
return nil, errs.ErrNotFound
}
mem, ok := byUser[userID]
if !ok {
return nil, errs.ErrNotFound
}
cp := *mem
return &cp, nil
}
func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) error {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.byID[id]
if !ok {
return errs.ErrNotFound
}
c.LastMsgID = msgID
if atMS > 0 {
c.LastMsgAt = atMS
} else {
c.LastMsgAt = time.Now().UnixMilli()
}
return nil
}

View File

@ -0,0 +1,10 @@
package model
// P2PConversation maps table p2p_conversations.
type P2PConversation struct {
UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"`
UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"`
ConversationID int64 `gorm:"column:conversation_id;comment:对应 conversations.id"`
}
func (P2PConversation) TableName() string { return "p2p_conversations" }

View File

@ -0,0 +1,138 @@
package model
import (
"context"
"errors"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"spark-server/internal/module/conversation/entity"
"spark-server/internal/pkg/errs"
)
// Repo is MySQL persistence for conversations / p2p / members.
type Repo struct {
gdb *gorm.DB
}
func NewRepo(gdb *gorm.DB) *Repo {
return &Repo{gdb: gdb}
}
func (m *Repo) session(ctx context.Context) (*gorm.DB, error) {
if m == nil || m.gdb == nil {
return nil, errs.ErrAvailable
}
return m.gdb.WithContext(ctx), nil
}
func mapDBErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrNotFound
}
var me *mysqldriver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return errs.ErrDuplicate
}
return errs.ErrInner.WithError("conversation-mysql", err)
}
func (m *Repo) Get(ctx context.Context, id int64) (*entity.Conversation, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row Conversation
if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.entity(), nil
}
func (m *Repo) GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
small, big := entity.OrderUsers(userA, userB)
var row P2PConversation
if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return m.Get(ctx, row.ConversationID)
}
func (m *Repo) CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error {
if c == nil || p2p == nil {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
conv := Conversation{
ID: c.ID, Type: c.Type, LastMsgID: c.LastMsgID,
LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt),
}
if err := tx.Create(&conv).Error; err != nil {
return mapDBErr(err)
}
link := P2PConversation{UserSmall: p2p.UserSmall, UserBig: p2p.UserBig, ConversationID: p2p.ConversationID}
if err := tx.Create(&link).Error; err != nil {
return mapDBErr(err)
}
for _, mem := range members {
row := ConversationMember{
ConversationID: mem.ConversationID, UserID: mem.UserID,
LastReadMsgID: mem.LastReadMsgID, JoinedAt: msTime(mem.JoinedAtMS),
}
if mem.Muted {
row.Muted = 1
}
if err := tx.Create(&row).Error; err != nil {
return mapDBErr(err)
}
}
return nil
})
}
func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error {
db, err := m.session(ctx)
if err != nil {
return err
}
res := db.Model(&Conversation{}).Where("id = ?", id).Updates(map[string]any{
"last_msg_id": msgID,
"last_msg_at": msTime(atMS),
})
if res.Error != nil {
return mapDBErr(res.Error)
}
if res.RowsAffected == 0 {
return errs.ErrNotFound
}
return nil
}
func (r *Conversation) entity() *entity.Conversation {
if r == nil {
return nil
}
return &entity.Conversation{
ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(),
}
}
func msTime(ms int64) time.Time {
if ms <= 0 {
return time.Now()
}
return time.UnixMilli(ms)
}

View File

@ -1,22 +0,0 @@
package conversation
import "context"
const (
TypeP2P int = 1
TypeGroup int = 2
)
type Conversation struct {
ID int64
Type int
LastMsgID int64
LastMsgAt int64
}
type Repository interface {
Create(ctx context.Context, c *Conversation) error
Get(ctx context.Context, id int64) (*Conversation, error)
GetP2P(ctx context.Context, userA, userB int64) (*Conversation, error)
UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error
}

View File

@ -0,0 +1,30 @@
// Package entity holds deliver-queue job types.
package entity
// Job is a fanout unit. ExcludeDevice is the sender device. NotMailbox matches BFF NotSaveIch.
type Job struct {
MsgID int64 `json:"msg_id"`
Cmd uint16 `json:"cmd"`
ConversationID int64 `json:"conversation_id"`
FromUserID int64 `json:"from_user_id"`
FromDeviceID uint64 `json:"from_device_id"`
ExcludeDevice uint64 `json:"exclude_device"`
ToUserIDs []int64 `json:"to_user_ids,omitempty"`
NotPush bool `json:"not_push,omitempty"`
NotMailbox bool `json:"not_mailbox,omitempty"`
Payload []byte `json:"payload,omitempty"`
raw []byte `json:"-"`
}
func (j *Job) Raw() []byte {
if j == nil {
return nil
}
return j.raw
}
func (j *Job) SetRaw(raw []byte) {
if j != nil {
j.raw = raw
}
}

View File

@ -0,0 +1,8 @@
package logic
import "context"
// Deduper is SETNX deliver:done:{msgId} so a retried job cannot increment unread twice.
type Deduper interface {
Mark(ctx context.Context, msgID int64) (first bool, err error)
}

View File

@ -0,0 +1,15 @@
package logic
import (
"context"
"time"
"spark-server/internal/module/deliver/entity"
)
type Queue interface {
Push(ctx context.Context, job *entity.Job) error
Take(ctx context.Context, timeout time.Duration) (*entity.Job, error)
Ack(ctx context.Context, job *entity.Job) error
RequeueStale(ctx context.Context) error
}

View File

@ -0,0 +1,161 @@
// Package logic is the deliver worker and queue contracts.
package logic
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"spark-server/internal/infrastructure/logger"
"spark-server/internal/module/deliver/entity"
"spark-server/internal/transport/tcp/protocol"
"spark-server/internal/transport/tcp/session"
)
// Handler fans a job out. Return error to leave the job in processing for RequeueStale.
type Handler func(ctx context.Context, job *entity.Job) error
type Worker struct {
log *zap.Logger
queue Queue
done Deduper
handle Handler
n int
timeout time.Duration
stop chan struct{}
wg sync.WaitGroup
}
type WorkerOptions struct {
Workers int
TakeTimeout time.Duration
Handler Handler
}
func NewWorker(log *zap.Logger, q Queue, done Deduper, o WorkerOptions) *Worker {
if log == nil {
log = zap.NewNop()
}
if o.Workers < 1 {
o.Workers = 1
}
if o.TakeTimeout <= 0 {
o.TakeTimeout = 5 * time.Second
}
if done == nil {
done = NopDeduper{}
}
if o.Handler == nil {
o.Handler = NopHandler
}
return &Worker{
log: log, queue: q, done: done, handle: o.Handler,
n: o.Workers, timeout: o.TakeTimeout, stop: make(chan struct{}),
}
}
func NopHandler(context.Context, *entity.Job) error { return nil }
type NopDeduper struct{}
func (NopDeduper) Mark(context.Context, int64) (bool, error) { return true, nil }
func SessionFanout(sessions session.Manager) Handler {
return func(ctx context.Context, job *entity.Job) error {
if sessions == nil || job == nil || len(job.ToUserIDs) == 0 {
return nil
}
cmd := job.Cmd
if cmd == 0 {
cmd = protocol.CmdMessage
}
pkt := &session.Packet{Command: cmd, Body: job.Payload}
for _, uid := range job.ToUserIDs {
_ = sessions.SendToUserExceptDevice(ctx, uid, job.ExcludeDevice, pkt)
}
return nil
}
}
func (w *Worker) Start(ctx context.Context) {
if w == nil || w.queue == nil {
return
}
if err := w.queue.RequeueStale(ctx); err != nil {
w.log.Error("deliver requeue", zap.Error(err))
}
for i := 0; i < w.n; i++ {
w.wg.Add(1)
go w.loop()
}
}
func (w *Worker) Stop() {
if w == nil {
return
}
select {
case <-w.stop:
default:
close(w.stop)
}
w.wg.Wait()
}
func (w *Worker) loop() {
defer w.wg.Done()
for {
select {
case <-w.stop:
return
default:
}
ctx, cancel := context.WithTimeout(context.Background(), w.timeout+time.Second)
job, err := w.queue.Take(ctx, w.timeout)
cancel()
if err != nil {
select {
case <-w.stop:
return
case <-time.After(200 * time.Millisecond):
}
continue
}
if job == nil {
select {
case <-w.stop:
return
default:
continue
}
}
w.process(job)
}
}
func (w *Worker) process(job *entity.Job) {
defer func() {
if rec := recover(); rec != nil {
w.log.Error("deliver panic", zap.Any("panic", rec), zap.Int64("msg_id", job.MsgID))
}
}()
ctx := logger.Attach(context.Background(), w.log)
first, err := w.done.Mark(ctx, job.MsgID)
if err != nil {
w.log.Error("deliver done", zap.Error(err), zap.Int64("msg_id", job.MsgID))
return
}
if !first {
_ = w.queue.Ack(ctx, job)
return
}
if err := w.handle(ctx, job); err != nil {
w.log.Error("deliver handle", zap.Error(err), zap.Int64("msg_id", job.MsgID))
return
}
if err := w.queue.Ack(ctx, job); err != nil {
w.log.Error("deliver ack", zap.Error(err), zap.Int64("msg_id", job.MsgID))
}
}

View File

@ -0,0 +1,110 @@
package logic
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
"spark-server/internal/module/deliver/entity"
"spark-server/internal/module/deliver/model"
)
func TestMemoryQueueAckUsesOriginalBytes(t *testing.T) {
q := model.NewMemoryQueue()
ctx := t.Context()
job := &entity.Job{MsgID: 9, FromUserID: 1, ExcludeDevice: 7, ToUserIDs: []int64{2}}
if err := q.Push(ctx, job); err != nil {
t.Fatal(err)
}
got, err := q.Take(ctx, time.Second)
if err != nil || got == nil || got.MsgID != 9 {
t.Fatalf("take %+v %v", got, err)
}
got.FromUserID = 99
if err := q.Ack(ctx, got); err != nil {
t.Fatal(err)
}
if err := q.RequeueStale(ctx); err != nil {
t.Fatal(err)
}
again, err := q.Take(ctx, 10*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if again != nil {
t.Fatalf("acked job still in processing %+v", again)
}
}
func TestDeduperSkipsSecond(t *testing.T) {
d := model.NewMemoryDone()
first, err := d.Mark(t.Context(), 11)
if err != nil || !first {
t.Fatalf("first %v %v", first, err)
}
second, err := d.Mark(t.Context(), 11)
if err != nil || second {
t.Fatalf("second %v %v", second, err)
}
}
func TestWorkerIdempotentAck(t *testing.T) {
q := model.NewMemoryQueue()
d := model.NewMemoryDone()
var n atomic.Int32
w := NewWorker(nil, q, d, WorkerOptions{
Workers: 1, TakeTimeout: 50 * time.Millisecond,
Handler: func(context.Context, *entity.Job) error { n.Add(1); return nil },
})
ctx := t.Context()
raw, _ := json.Marshal(&entity.Job{MsgID: 3, Cmd: 6})
if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil {
t.Fatal(err)
}
_ = raw
w.Start(ctx)
defer w.Stop()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && n.Load() == 0 {
time.Sleep(10 * time.Millisecond)
}
if n.Load() != 1 {
t.Fatalf("handled %d", n.Load())
}
if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil {
t.Fatal(err)
}
time.Sleep(150 * time.Millisecond)
if n.Load() != 1 {
t.Fatalf("idempotent %d", n.Load())
}
}
func TestWorkerRecoversPanic(t *testing.T) {
q := model.NewMemoryQueue()
var n atomic.Int32
w := NewWorker(nil, q, model.NewMemoryDone(), WorkerOptions{
Workers: 1, TakeTimeout: 50 * time.Millisecond,
Handler: func(_ context.Context, job *entity.Job) error {
n.Add(1)
if job.MsgID == 1 {
panic("boom")
}
return nil
},
})
ctx := t.Context()
_ = q.Push(ctx, &entity.Job{MsgID: 1})
_ = q.Push(ctx, &entity.Job{MsgID: 2})
w.Start(ctx)
defer w.Stop()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && n.Load() < 2 {
time.Sleep(10 * time.Millisecond)
}
if n.Load() < 2 {
t.Fatalf("worker died after panic, handled %d", n.Load())
}
}

View File

@ -0,0 +1,117 @@
package model
import (
"bytes"
"context"
"encoding/json"
"sync"
"time"
"spark-server/internal/module/deliver/entity"
)
type MemoryQueue struct {
mu sync.Mutex
queue [][]byte
processing [][]byte
wait chan struct{}
}
func NewMemoryQueue() *MemoryQueue {
return &MemoryQueue{wait: make(chan struct{}, 1)}
}
func (q *MemoryQueue) Push(_ context.Context, job *entity.Job) error {
raw, err := json.Marshal(job)
if err != nil {
return err
}
q.mu.Lock()
q.queue = append(q.queue, raw)
q.mu.Unlock()
select {
case q.wait <- struct{}{}:
default:
}
return nil
}
func (q *MemoryQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) {
deadline := time.Now().Add(timeout)
for {
q.mu.Lock()
if len(q.queue) > 0 {
raw := q.queue[0]
q.queue = q.queue[1:]
q.processing = append(q.processing, raw)
q.mu.Unlock()
job := &entity.Job{}
if err := json.Unmarshal(raw, job); err != nil {
return nil, err
}
job.SetRaw(raw)
return job, nil
}
q.mu.Unlock()
remain := time.Until(deadline)
if remain <= 0 {
return nil, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(remain):
return nil, nil
case <-q.wait:
}
}
}
func (q *MemoryQueue) Ack(_ context.Context, job *entity.Job) error {
if job == nil {
return nil
}
raw := job.Raw()
q.mu.Lock()
defer q.mu.Unlock()
for i, p := range q.processing {
if bytes.Equal(p, raw) {
q.processing = append(q.processing[:i], q.processing[i+1:]...)
return nil
}
}
return nil
}
func (q *MemoryQueue) RequeueStale(context.Context) error {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.processing) == 0 {
return nil
}
q.queue = append(q.processing, q.queue...)
q.processing = nil
return nil
}
type MemoryDone struct {
mu sync.Mutex
seen map[int64]struct{}
}
func NewMemoryDone() *MemoryDone {
return &MemoryDone{seen: make(map[int64]struct{})}
}
func (d *MemoryDone) Mark(_ context.Context, msgID int64) (bool, error) {
if msgID <= 0 {
return true, nil
}
d.mu.Lock()
defer d.mu.Unlock()
if _, ok := d.seen[msgID]; ok {
return false, nil
}
d.seen[msgID] = struct{}{}
return true, nil
}

View File

@ -0,0 +1,122 @@
// Package model is deliver queue persistence (Redis lists and SETNX done keys).
package model
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"spark-server/internal/module/deliver/entity"
"spark-server/internal/pkg/errs"
)
type RedisQueue struct {
rdb *redis.Client
queueKey string
processKey string
}
func NewRedisQueue(rdb *redis.Client, queueKey, processKey string) *RedisQueue {
if queueKey == "" {
queueKey = "deliver:queue"
}
if processKey == "" {
processKey = "deliver:processing"
}
return &RedisQueue{rdb: rdb, queueKey: queueKey, processKey: processKey}
}
func (q *RedisQueue) Push(ctx context.Context, job *entity.Job) error {
if q.rdb == nil {
return errs.ErrInternal
}
raw, err := json.Marshal(job)
if err != nil {
return err
}
return q.rdb.RPush(ctx, q.queueKey, raw).Err()
}
func (q *RedisQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) {
if q.rdb == nil {
return nil, errs.ErrInternal
}
if timeout <= 0 {
timeout = 5 * time.Second
}
res, err := q.rdb.BRPopLPush(ctx, q.queueKey, q.processKey, timeout).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
raw := []byte(res)
job := &entity.Job{}
if err := json.Unmarshal(raw, job); err != nil {
_ = q.rdb.LRem(ctx, q.processKey, 1, res).Err()
return nil, fmt.Errorf("deliver job: %w", err)
}
job.SetRaw(raw)
return job, nil
}
func (q *RedisQueue) Ack(ctx context.Context, job *entity.Job) error {
if q.rdb == nil || job == nil {
return nil
}
raw := job.Raw()
if len(raw) == 0 {
var err error
raw, err = json.Marshal(job)
if err != nil {
return err
}
}
return q.rdb.LRem(ctx, q.processKey, 1, raw).Err()
}
func (q *RedisQueue) RequeueStale(ctx context.Context) error {
if q.rdb == nil {
return nil
}
for {
_, err := q.rdb.RPopLPush(ctx, q.processKey, q.queueKey).Result()
if err == redis.Nil {
return nil
}
if err != nil {
return err
}
}
}
type RedisDone struct {
rdb *redis.Client
prefix string
ttl time.Duration
}
func NewRedisDone(rdb *redis.Client, prefix string, ttl time.Duration) *RedisDone {
if prefix == "" {
prefix = "deliver:done:"
}
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &RedisDone{rdb: rdb, prefix: prefix, ttl: ttl}
}
func (d *RedisDone) Mark(ctx context.Context, msgID int64) (bool, error) {
if d == nil || d.rdb == nil || msgID <= 0 {
return true, nil
}
ok, err := d.rdb.SetNX(ctx, fmt.Sprintf("%s%d", d.prefix, msgID), "1", d.ttl).Result()
if err != nil {
return false, err
}
return ok, nil
}

View File

@ -1,26 +0,0 @@
package deliver
import (
"context"
"time"
)
// Job is a fanout unit. ExcludeDevice is the sender device. NotMailbox matches BFF NotSaveIch.
type Job struct {
MsgID int64
Cmd uint16
ConversationID int64
FromUserID int64
FromDeviceID uint64
ExcludeDevice uint64
NotPush bool
NotMailbox bool
Payload []byte
}
type Queue interface {
Push(ctx context.Context, job *Job) error
Take(ctx context.Context, timeout time.Duration) (*Job, error)
Ack(ctx context.Context, job *Job) error
RequeueStale(ctx context.Context) error
}

View File

@ -1,78 +0,0 @@
package deliver
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"spark-server/internal/pkg/errs"
)
type redisQueue struct {
rdb *redis.Client
queueKey string
processKey string
}
func NewRedisQueue(rdb *redis.Client, queueKey, processKey string) Queue {
return &redisQueue{rdb: rdb, queueKey: queueKey, processKey: processKey}
}
func (q *redisQueue) Push(ctx context.Context, job *Job) error {
if q.rdb == nil {
return errs.ErrInternal
}
raw, err := json.Marshal(job)
if err != nil {
return err
}
return q.rdb.RPush(ctx, q.queueKey, raw).Err()
}
func (q *redisQueue) Take(ctx context.Context, timeout time.Duration) (*Job, error) {
if q.rdb == nil {
return nil, errs.ErrInternal
}
res, err := q.rdb.BRPopLPush(ctx, q.queueKey, q.processKey, timeout).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
job := &Job{}
if err := json.Unmarshal([]byte(res), job); err != nil {
return nil, fmt.Errorf("deliver job: %w", err)
}
return job, nil
}
func (q *redisQueue) Ack(ctx context.Context, job *Job) error {
if q.rdb == nil || job == nil {
return nil
}
raw, err := json.Marshal(job)
if err != nil {
return err
}
return q.rdb.LRem(ctx, q.processKey, 1, raw).Err()
}
func (q *redisQueue) RequeueStale(ctx context.Context) error {
if q.rdb == nil {
return nil
}
for {
res, err := q.rdb.RPopLPush(ctx, q.processKey, q.queueKey).Result()
if err == redis.Nil {
return nil
}
if err != nil {
return err
}
_ = res
}
}

View File

@ -0,0 +1,28 @@
// Package entity holds favorite domain types. Media keys in Content are perm/fav copies.
package entity
const (
ListDefault = 20
ListMax = 100
)
// Favorite is a user-owned snapshot of a message. Object keys are copied off chat/.
type Favorite struct {
ID int64
UserID int64
MsgID int64
ConversationID int64
MessageType int16
Content []byte
CreatedAtMS int64
}
func ClampLimit(limit int) int {
if limit <= 0 {
return ListDefault
}
if limit > ListMax {
return ListMax
}
return limit
}

View File

@ -0,0 +1,51 @@
package logic
import (
"context"
"time"
msgentity "spark-server/internal/module/message/entity"
uploadentity "spark-server/internal/module/upload/entity"
"spark-server/internal/pkg/errs"
)
func (s *Service) copyMedia(ctx context.Context, userID int64, payload *msgentity.Content) ([]string, error) {
keys := payload.ObjectKeys()
if len(keys) == 0 {
return nil, nil
}
if s.objects == nil {
return nil, errs.ErrAvailable
}
day := time.Now().UTC().Format("20060102")
mapping := make(map[string]string, len(keys))
copied := make([]string, 0, len(keys))
for _, src := range keys {
if _, ok := mapping[src]; ok {
continue
}
objectID, err := s.ids.NextID()
if err != nil {
s.deleteKeys(ctx, copied)
return nil, err
}
dst := uploadentity.BuildFavKey(userID, objectID, day, uploadentity.ExtOfKey(src))
if err := s.objects.Copy(ctx, src, dst); err != nil {
s.deleteKeys(ctx, copied)
return nil, err
}
mapping[src] = dst
copied = append(copied, dst)
}
payload.ReplaceObjectKeys(mapping)
return copied, nil
}
func (s *Service) deleteKeys(ctx context.Context, keys []string) {
if s.objects == nil {
return
}
for _, key := range keys {
_ = s.objects.Delete(ctx, key)
}
}

View File

@ -0,0 +1,117 @@
package logic
import (
"context"
"encoding/json"
"errors"
"time"
"spark-server/internal/module/favorite/entity"
msgentity "spark-server/internal/module/message/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
// CreateInput favorites an existing message. Expired chat objects fail; no client re-upload.
type CreateInput struct {
MsgID int64 `json:"msg_id" validate:"required,gt=0" label:"field.msg_id" errmsg:"required:validate.favorite.msg_required;gt:validate.favorite.msg_invalid"`
}
// Item is the HTTP/TCP favorite view. Content is the snapshot JSON (perm/fav keys).
type Item struct {
ID int64 `json:"id"`
MsgID int64 `json:"msg_id"`
ConversationID int64 `json:"conversation_id"`
MessageType int16 `json:"message_type"`
Content json.RawMessage `json:"content"`
CreatedAt int64 `json:"created_at"`
}
func (s *Service) Create(ctx context.Context, userID int64, in *CreateInput) (*Item, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if existing, err := s.repo.GetByUserMsg(ctx, userID, in.MsgID); err == nil {
return itemOf(existing), nil
} else if !errors.Is(err, errs.ErrNotFound) {
return nil, err
}
msg, payload, err := s.loadFavoriteSource(ctx, userID, in.MsgID)
if err != nil {
return nil, err
}
copied, err := s.copyMedia(ctx, userID, payload)
if err != nil {
return nil, err
}
fav, err := s.persistFavorite(ctx, userID, msg, payload)
if err != nil {
s.deleteKeys(ctx, copied)
if errors.Is(err, errs.ErrDuplicate) {
existing, getErr := s.repo.GetByUserMsg(ctx, userID, in.MsgID)
if getErr == nil {
return itemOf(existing), nil
}
}
return nil, err
}
return itemOf(fav), nil
}
func (s *Service) loadFavoriteSource(ctx context.Context, userID, msgID int64) (*msgentity.Message, *msgentity.Content, error) {
msg, err := s.msgs.GetByID(ctx, msgID)
if err != nil {
return nil, nil, err
}
if msg.Status == msgentity.StatusHidden {
return nil, nil, errs.ErrNotFound
}
if _, err := s.members.GetMember(ctx, msg.ConversationID, userID); err != nil {
if errors.Is(err, errs.ErrNotFound) {
return nil, nil, errs.ErrForbidden
}
return nil, nil, err
}
payload, err := msg.Payload()
if err != nil || payload == nil {
return nil, nil, errs.ErrInvalidArgument
}
return msg, payload, nil
}
func (s *Service) persistFavorite(ctx context.Context, userID int64, msg *msgentity.Message, payload *msgentity.Content) (*entity.Favorite, error) {
raw, err := payload.Marshal()
if err != nil {
return nil, errs.ErrInvalidArgument
}
id, err := s.ids.NextID()
if err != nil {
return nil, err
}
now := time.Now()
fav := &entity.Favorite{
ID: id, UserID: userID, MsgID: msg.ID, ConversationID: msg.ConversationID,
MessageType: int16(msg.Type), Content: raw, CreatedAtMS: now.UnixMilli(),
}
if err := s.repo.Create(ctx, fav); err != nil {
return nil, err
}
return fav, nil
}
func itemOf(fav *entity.Favorite) *Item {
if fav == nil {
return nil
}
return &Item{
ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID,
MessageType: fav.MessageType, Content: fav.Content, CreatedAt: fav.CreatedAtMS,
}
}

View File

@ -0,0 +1,136 @@
package logic
import (
"context"
"errors"
"strings"
"testing"
"time"
"spark-server/internal/infrastructure/id"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
favmodel "spark-server/internal/module/favorite/model"
msgentity "spark-server/internal/module/message/entity"
msgmodel "spark-server/internal/module/message/model"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
)
type memObjects struct {
keys map[string]bool
}
func (m *memObjects) Copy(_ context.Context, src, dst string) error {
if m.keys == nil || !m.keys[src] {
return errs.ErrMediaExpired
}
m.keys[dst] = true
return nil
}
func (m *memObjects) Delete(_ context.Context, key string) error {
delete(m.keys, key)
return nil
}
func testFav(t *testing.T) (*Service, *msgmodel.MemoryRepo, *convmodel.MemoryP2P, *memObjects, *id.Generator) {
t.Helper()
ids, err := id.New(1)
if err != nil {
t.Fatal(err)
}
msgs := msgmodel.NewMemoryRepo()
convs := convmodel.NewMemoryP2P()
objs := &memObjects{keys: map[string]bool{}}
svc := NewService(favmodel.NewMemoryRepo(), msgs, convs, objs, ids)
return svc, msgs, convs, objs, ids
}
func TestFavoriteCopiesMediaAndExpires(t *testing.T) {
svc, msgs, convs, objs, ids := testFav(t)
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"})
p2p := convlogic.NewP2PService(convs, ids)
convID, err := p2p.EnsureP2P(ctx, 1, 2)
if err != nil {
t.Fatal(err)
}
src := "chat/20260917/1/99.jpg"
objs.keys[src] = true
raw, err := msgentity.NewImage(&msgentity.Image{Key: src, Mime: "image/jpeg"}).Marshal()
if err != nil {
t.Fatal(err)
}
msgID, err := ids.NextID()
if err != nil {
t.Fatal(err)
}
if err := msgs.Create(ctx, &msgentity.Message{
ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "c1",
Type: msgentity.TypeImage, Content: raw, Status: msgentity.StatusNormal,
CreatedAtMS: time.Now().UnixMilli(),
}); err != nil {
t.Fatal(err)
}
item, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID})
if err != nil {
t.Fatal(err)
}
if item.MsgID != msgID || !strings.Contains(string(item.Content), "perm/fav/1/") {
t.Fatalf("%+v", item)
}
if strings.Contains(string(item.Content), src) {
t.Fatal("still points at chat key")
}
again, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID})
if err != nil || again.ID != item.ID {
t.Fatalf("idempotent %+v %v", again, err)
}
expiredID, err := ids.NextID()
if err != nil {
t.Fatal(err)
}
gone := "chat/20260917/1/gone.jpg"
raw2, err := msgentity.NewImage(&msgentity.Image{Key: gone, Mime: "image/jpeg"}).Marshal()
if err != nil {
t.Fatal(err)
}
if err := msgs.Create(ctx, &msgentity.Message{
ID: expiredID, ConversationID: convID, SenderID: 1, ClientMsgID: "c2",
Type: msgentity.TypeImage, Content: raw2, Status: msgentity.StatusNormal,
}); err != nil {
t.Fatal(err)
}
_, err = svc.Create(ctx, 1, &CreateInput{MsgID: expiredID})
if !errors.Is(err, errs.ErrMediaExpired) {
t.Fatalf("expired %v", err)
}
}
func TestFavoriteForbidsNonMember(t *testing.T) {
svc, msgs, convs, _, ids := testFav(t)
ctx := t.Context()
p2p := convlogic.NewP2PService(convs, ids)
convID, err := p2p.EnsureP2P(ctx, 1, 2)
if err != nil {
t.Fatal(err)
}
raw, err := msgentity.NewText("hi").Marshal()
if err != nil {
t.Fatal(err)
}
msgID, err := ids.NextID()
if err != nil {
t.Fatal(err)
}
if err := msgs.Create(ctx, &msgentity.Message{
ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "t1",
Type: msgentity.TypeText, Content: raw, Status: msgentity.StatusNormal,
}); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, 9, &CreateInput{MsgID: msgID}); !errors.Is(err, errs.ErrForbidden) {
t.Fatalf("member %v", err)
}
}

View File

@ -0,0 +1,57 @@
package logic
import (
"context"
"spark-server/internal/module/favorite/entity"
msgentity "spark-server/internal/module/message/entity"
"spark-server/internal/pkg/errs"
)
type ListResult struct {
Items []*Item `json:"items"`
}
func (s *Service) List(ctx context.Context, userID int64, limit int) (*ListResult, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
rows, err := s.repo.ListByUser(ctx, userID, entity.ClampLimit(limit))
if err != nil {
return nil, err
}
out := &ListResult{Items: make([]*Item, 0, len(rows))}
for _, row := range rows {
out.Items = append(out.Items, itemOf(row))
}
return out, nil
}
func (s *Service) Delete(ctx context.Context, userID, id int64) error {
if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized
}
if id <= 0 {
return errs.ErrInvalidArgument
}
fav, err := s.repo.Get(ctx, id)
if err != nil {
return err
}
if fav.UserID != userID {
return errs.ErrForbidden
}
if err := s.repo.Delete(ctx, userID, id); err != nil {
return err
}
s.deleteFavoriteMedia(ctx, fav.Content)
return nil
}
func (s *Service) deleteFavoriteMedia(ctx context.Context, raw []byte) {
payload, err := msgentity.UnmarshalContent(raw)
if err != nil {
return
}
s.deleteKeys(ctx, payload.ObjectKeys())
}

View File

@ -0,0 +1,52 @@
package logic
import (
"context"
conventity "spark-server/internal/module/conversation/entity"
"spark-server/internal/module/favorite/entity"
msgentity "spark-server/internal/module/message/entity"
)
// Repository persists favorites. Model implements it.
type Repository interface {
Get(ctx context.Context, id int64) (*entity.Favorite, error)
GetByUserMsg(ctx context.Context, userID, msgID int64) (*entity.Favorite, error)
Create(ctx context.Context, fav *entity.Favorite) error
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error)
Delete(ctx context.Context, userID, id int64) error
}
type Messages interface {
GetByID(ctx context.Context, id int64) (*msgentity.Message, error)
}
type Members interface {
GetMember(ctx context.Context, conversationID, userID int64) (*conventity.Member, error)
}
type Objects interface {
Copy(ctx context.Context, src, dst string) error
Delete(ctx context.Context, key string) error
}
type IDGen interface {
NextID() (int64, error)
}
// Service is the favorite use-case layer (HTTP and TCP share it).
type Service struct {
repo Repository
msgs Messages
members Members
objects Objects
ids IDGen
}
func NewService(repo Repository, msgs Messages, members Members, objects Objects, ids IDGen) *Service {
return &Service{repo: repo, msgs: msgs, members: members, objects: objects, ids: ids}
}
func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.msgs != nil && s.members != nil && s.ids != nil
}

View File

@ -0,0 +1,44 @@
package model
import (
"time"
"spark-server/internal/module/favorite/entity"
)
// Favorite maps table favorites.
type Favorite struct {
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 收藏 id"`
UserID int64 `gorm:"column:user_id;comment:收藏者 user_id"`
MsgID int64 `gorm:"column:msg_id;comment:原消息 Snowflake id,仅作来源;撤回不影响收藏"`
ConversationID int64 `gorm:"column:conversation_id;comment:原会话 id,仅作来源"`
MessageType int16 `gorm:"column:message_type;comment:消息类型,与 messages.message_type 一致"`
Content []byte `gorm:"column:content;type:blob;comment:收藏快照(JSON 字节);媒体 key 已复制到 perm/fav/"`
CreatedAt time.Time `gorm:"column:created_at;comment:收藏时间"`
}
func (Favorite) TableName() string { return "favorites" }
func (r *Favorite) Entity() *entity.Favorite {
if r == nil {
return nil
}
return &entity.Favorite{
ID: r.ID, UserID: r.UserID, MsgID: r.MsgID, ConversationID: r.ConversationID,
MessageType: r.MessageType, Content: r.Content, CreatedAtMS: r.CreatedAt.UnixMilli(),
}
}
func fromEntity(f *entity.Favorite) *Favorite {
if f == nil {
return nil
}
row := &Favorite{
ID: f.ID, UserID: f.UserID, MsgID: f.MsgID, ConversationID: f.ConversationID,
MessageType: f.MessageType, Content: f.Content,
}
if f.CreatedAtMS > 0 {
row.CreatedAt = time.UnixMilli(f.CreatedAtMS)
}
return row
}

View File

@ -0,0 +1,104 @@
package model
import (
"context"
"sort"
"sync"
"spark-server/internal/module/favorite/entity"
"spark-server/internal/pkg/errs"
)
type userMsgKey struct {
UserID int64
MsgID int64
}
// MemoryRepo is an in-process favorite store for tests and empty-DSN bring-up.
type MemoryRepo struct {
mu sync.Mutex
byID map[int64]*entity.Favorite
byPair map[userMsgKey]int64
}
func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{byID: make(map[int64]*entity.Favorite), byPair: make(map[userMsgKey]int64)}
}
func (m *MemoryRepo) Get(_ context.Context, id int64) (*entity.Favorite, error) {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.byID[id]
if !ok {
return nil, errs.ErrNotFound
}
return cloneFav(row), nil
}
func (m *MemoryRepo) GetByUserMsg(_ context.Context, userID, msgID int64) (*entity.Favorite, error) {
m.mu.Lock()
defer m.mu.Unlock()
id, ok := m.byPair[userMsgKey{UserID: userID, MsgID: msgID}]
if !ok {
return nil, errs.ErrNotFound
}
return cloneFav(m.byID[id]), nil
}
func (m *MemoryRepo) Create(_ context.Context, fav *entity.Favorite) error {
if fav == nil || fav.ID <= 0 || fav.UserID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
key := userMsgKey{UserID: fav.UserID, MsgID: fav.MsgID}
if _, ok := m.byPair[key]; ok {
return errs.ErrDuplicate
}
if _, ok := m.byID[fav.ID]; ok {
return errs.ErrDuplicate
}
m.byID[fav.ID] = cloneFav(fav)
m.byPair[key] = fav.ID
return nil
}
func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Favorite, error) {
m.mu.Lock()
defer m.mu.Unlock()
limit = entity.ClampLimit(limit)
out := make([]*entity.Favorite, 0)
for _, row := range m.byID {
if row.UserID == userID {
out = append(out, cloneFav(row))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error {
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.byID[id]
if !ok || row.UserID != userID {
return errs.ErrNotFound
}
delete(m.byID, id)
delete(m.byPair, userMsgKey{UserID: row.UserID, MsgID: row.MsgID})
return nil
}
func cloneFav(f *entity.Favorite) *entity.Favorite {
if f == nil {
return nil
}
cp := *f
if f.Content != nil {
cp.Content = append([]byte(nil), f.Content...)
}
return &cp
}

Some files were not shown because too many files have changed in this diff Show More